diff --git a/MANIFEST.in b/MANIFEST.in index bfff8cadd0..24c24f078b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,2 @@ -recursive-include locust/static * -recursive-include locust/templates * +recursive-include locust/static * +recursive-include locust/templates * diff --git a/docs/api.rst b/docs/api.rst index fec4c6f859..b7d0397eea 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,78 +1,78 @@ -### -API -### - - -Locust class -============ - -.. autoclass:: locust.core.Locust - :members: min_wait, max_wait, task_set - -HttpLocust class -================ - -.. autoclass:: locust.core.HttpLocust - :members: min_wait, max_wait, task_set, client - - -TaskSet class -============= - -.. autoclass:: locust.core.TaskSet - :members: locust, parent, min_wait, max_wait, client, tasks, interrupt, schedule_task - -task decorator -============== - -.. autofunction:: locust.core.task - - -HttpSession class -================= - -.. autoclass:: locust.clients.HttpSession - :members: __init__, request, get, post, delete, put, head, options, patch - -Response class -============== - -This class actually resides in the `python-requests `_ library, -since that's what Locust is using to make HTTP requests, but it's included in the API docs -for locust since it's so central when writing locust load tests. You can also look at the -:py:class:`Response ` class at the -`requests documentation `_. - -.. autoclass:: requests.Response - :inherited-members: - :noindex: - -ResponseContextManager class -============================ - -.. autoclass:: locust.clients.ResponseContextManager - :members: success, failure - - -InterruptTaskSet Exception -========================== -.. autoexception:: locust.exception.InterruptTaskSet - - -.. _events: - -Event hooks -=========== - -The event hooks are instances of the **locust.events.EventHook** class: - -.. autoclass:: locust.events.EventHook - -Available hooks ---------------- - -The following event hooks are available under the **locust.events** module: - -.. automodule:: locust.events - :members: request_success, request_failure, locust_error, report_to_master, slave_report, hatch_complete, quitting - +### +API +### + + +Locust class +============ + +.. autoclass:: locust.core.Locust + :members: min_wait, max_wait, task_set + +HttpLocust class +================ + +.. autoclass:: locust.core.HttpLocust + :members: min_wait, max_wait, task_set, client + + +TaskSet class +============= + +.. autoclass:: locust.core.TaskSet + :members: locust, parent, min_wait, max_wait, client, tasks, interrupt, schedule_task + +task decorator +============== + +.. autofunction:: locust.core.task + + +HttpSession class +================= + +.. autoclass:: locust.clients.HttpSession + :members: __init__, request, get, post, delete, put, head, options, patch + +Response class +============== + +This class actually resides in the `python-requests `_ library, +since that's what Locust is using to make HTTP requests, but it's included in the API docs +for locust since it's so central when writing locust load tests. You can also look at the +:py:class:`Response ` class at the +`requests documentation `_. + +.. autoclass:: requests.Response + :inherited-members: + :noindex: + +ResponseContextManager class +============================ + +.. autoclass:: locust.clients.ResponseContextManager + :members: success, failure + + +InterruptTaskSet Exception +========================== +.. autoexception:: locust.exception.InterruptTaskSet + + +.. _events: + +Event hooks +=========== + +The event hooks are instances of the **locust.events.EventHook** class: + +.. autoclass:: locust.events.EventHook + +Available hooks +--------------- + +The following event hooks are available under the **locust.events** module: + +.. automodule:: locust.events + :members: request_success, request_failure, locust_error, report_to_master, slave_report, hatch_complete, quitting + diff --git a/docs/index.rst b/docs/index.rst index 47abff6c69..45ef78fc72 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,26 +1,26 @@ -===================== -Locust Documentation -===================== - -.. rubric:: Everything you need to know about Locust - -.. sidebar:: About locust - - Locust is a scalable load testing framework written in Python - - * **Website**: `http://locust.io `_ - * **Source code**: `http://github.com/locustio/locust `_ - * **Twitter**: `@locustio `_ - - -.. toctree :: - :maxdepth: 2 - - what-is-locust - installation - quickstart - writing-a-locustfile - api - extending-locust - changelog - +===================== +Locust Documentation +===================== + +.. rubric:: Everything you need to know about Locust + +.. sidebar:: About locust + + Locust is a scalable load testing framework written in Python + + * **Website**: `http://locust.io `_ + * **Source code**: `http://github.com/locustio/locust `_ + * **Twitter**: `@locustio `_ + + +.. toctree :: + :maxdepth: 2 + + what-is-locust + installation + quickstart + writing-a-locustfile + api + extending-locust + changelog + diff --git a/docs/installation.rst b/docs/installation.rst index ab2ca97fc1..ce82e97d5c 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -1,53 +1,53 @@ -Installation -============ - -Locust is available on PyPI and can be installed through pip or easy_install - -:: - - pip install locustio - -or:: - - easy_install locustio - -When Locust is installed, a **locust** command should be available in your shell (if you're not using -virtualenv - which you should - make sure your python script directory is on your path). - -To see available options, run:: - - locust --help - - -Installing ZeroMQ ------------------ - -If you intend to run Locust distributed across multiple processes/machines, we recommend you to also -install **pyzmq**:: - - pip install pyzmq - -or:: - - easy_install pyzmq - -Installing Locust on Windows ----------------------------- - -The easiest way to get Locust running on Windows is to first install pre built binary packages for -gevent (0.13) and greenlet and then follow the above instructions. - -You can find an unofficial collection of pre built python packages for windows here: -`http://www.lfd.uci.edu/~gohlke/pythonlibs/ `_ - -Installing Locust on OS X ----------------------------- - -The following is currently the shortest path to installing gevent on OS X using Homebrew. - -#. Install [Homebrew](http://mxcl.github.com/homebrew/). -#. Install libevent (dependency for gevent):: - - brew install libevent - -#. Then follow the above instructions. +Installation +============ + +Locust is available on PyPI and can be installed through pip or easy_install + +:: + + pip install locustio + +or:: + + easy_install locustio + +When Locust is installed, a **locust** command should be available in your shell (if you're not using +virtualenv - which you should - make sure your python script directory is on your path). + +To see available options, run:: + + locust --help + + +Installing ZeroMQ +----------------- + +If you intend to run Locust distributed across multiple processes/machines, we recommend you to also +install **pyzmq**:: + + pip install pyzmq + +or:: + + easy_install pyzmq + +Installing Locust on Windows +---------------------------- + +The easiest way to get Locust running on Windows is to first install pre built binary packages for +gevent (0.13) and greenlet and then follow the above instructions. + +You can find an unofficial collection of pre built python packages for windows here: +`http://www.lfd.uci.edu/~gohlke/pythonlibs/ `_ + +Installing Locust on OS X +---------------------------- + +The following is currently the shortest path to installing gevent on OS X using Homebrew. + +#. Install [Homebrew](http://mxcl.github.com/homebrew/). +#. Install libevent (dependency for gevent):: + + brew install libevent + +#. Then follow the above instructions. diff --git a/docs/quickstart.rst b/docs/quickstart.rst index b2192add99..77fe61c044 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -1,98 +1,98 @@ -============= -Quick start -============= - -Below is a quick little example of a simple **locustfile.py**:: - - from locust import HttpLocust, TaskSet - - def login(l): - l.client.post("/login", {"username":"ellen_key", "password":"education"}) - - def index(l): - l.client.get("/") - - def profile(l): - l.client.get("/profile") - - class UserBehavior(TaskSet): - tasks = {index:2, profile:1} - - def on_start(self): - login(self) - - class WebsiteUser(HttpLocust): - task_set = UserBehavior - min_wait=5000 - max_wait=9000 - - -Here we define a number of locust tasks, which are normal Python callables that take one argument -(a Locust class instance). These tasks are gathered under a :py:class:`TaskSet ` -class in the *task* attribute. Then we have a :py:class:`HttpLocust ` class which -represents a User, where we define how long a simulated user should wait between executing tasks, as -well as what TaskSet class should define the user's "behaviour". TaskSets can be nested. - -The :py:class:`HttpLocust ` class inherits from the -:py:class:`Locust ` class, and it adds a client attribute which is an instance of -:py:class:`HttpSession , that can be used to make HTTP requests. - -Another way we could declare tasks, which is usually more convenient, is to use the -@task decorator. The following code is equivalent to the above:: - - from locust import HttpLocust, TaskSet, task - - class UserBehavior(TaskSet): - def on_start(self): - """ on_start is called when a Locust start before any task is scheduled """ - self.login() - - def login(self): - self.client.post("/login", {"username":"ellen_key", "password":"education"}) - - @task(2) - def index(self): - self.client.get("/") - - @task(1) - def profile(self): - self.client.get("/profile") - - class WebsiteUser(HttpLocust): - task_set = UserBehavior - min_wait=5000 - max_wait=9000 - -The Locust class (as well as HttpLocust, since it's a subclass) also allows one to specify minimum -and maximum wait time - per simulated user - between the execution of tasks (*min_wait* and *max_wait*) -as well as other user behaviours. - -To run Locust with the above locust file, if it was named *locustfile.py*, we could run -(in the same directory as locustfile.py):: - - locust - -or if the locust file is located elsewhere we could run:: - - locust -f ../locust_files/my_locust_file.py - -To run Locust distributed across multiple processes we would start a master process by specifying --master:: - - locust -f ../locust_files/my_locust_file.py --master - -and then we would start an arbitrary number of slave processes:: - - locust -f ../locust_files/my_locust_file.py --slave - -If we want to run locust distributed on multiple machines we would also have to specify the master host when -starting the slaves (this is not needed when running locust distributed on a single machine, since the master -host defaults to 127.0.0.1):: - - locust -f ../locust_files/my_locust_file.py --slave --master-host=192.168.0.100 - -.. note:: - - To see all available options type - - locust --help - +============= +Quick start +============= + +Below is a quick little example of a simple **locustfile.py**:: + + from locust import HttpLocust, TaskSet + + def login(l): + l.client.post("/login", {"username":"ellen_key", "password":"education"}) + + def index(l): + l.client.get("/") + + def profile(l): + l.client.get("/profile") + + class UserBehavior(TaskSet): + tasks = {index:2, profile:1} + + def on_start(self): + login(self) + + class WebsiteUser(HttpLocust): + task_set = UserBehavior + min_wait=5000 + max_wait=9000 + + +Here we define a number of locust tasks, which are normal Python callables that take one argument +(a Locust class instance). These tasks are gathered under a :py:class:`TaskSet ` +class in the *task* attribute. Then we have a :py:class:`HttpLocust ` class which +represents a User, where we define how long a simulated user should wait between executing tasks, as +well as what TaskSet class should define the user's "behaviour". TaskSets can be nested. + +The :py:class:`HttpLocust ` class inherits from the +:py:class:`Locust ` class, and it adds a client attribute which is an instance of +:py:class:`HttpSession , that can be used to make HTTP requests. + +Another way we could declare tasks, which is usually more convenient, is to use the +@task decorator. The following code is equivalent to the above:: + + from locust import HttpLocust, TaskSet, task + + class UserBehavior(TaskSet): + def on_start(self): + """ on_start is called when a Locust start before any task is scheduled """ + self.login() + + def login(self): + self.client.post("/login", {"username":"ellen_key", "password":"education"}) + + @task(2) + def index(self): + self.client.get("/") + + @task(1) + def profile(self): + self.client.get("/profile") + + class WebsiteUser(HttpLocust): + task_set = UserBehavior + min_wait=5000 + max_wait=9000 + +The Locust class (as well as HttpLocust, since it's a subclass) also allows one to specify minimum +and maximum wait time - per simulated user - between the execution of tasks (*min_wait* and *max_wait*) +as well as other user behaviours. + +To run Locust with the above locust file, if it was named *locustfile.py*, we could run +(in the same directory as locustfile.py):: + + locust + +or if the locust file is located elsewhere we could run:: + + locust -f ../locust_files/my_locust_file.py + +To run Locust distributed across multiple processes we would start a master process by specifying --master:: + + locust -f ../locust_files/my_locust_file.py --master + +and then we would start an arbitrary number of slave processes:: + + locust -f ../locust_files/my_locust_file.py --slave + +If we want to run locust distributed on multiple machines we would also have to specify the master host when +starting the slaves (this is not needed when running locust distributed on a single machine, since the master +host defaults to 127.0.0.1):: + + locust -f ../locust_files/my_locust_file.py --slave --master-host=192.168.0.100 + +.. note:: + + To see all available options type + + locust --help + diff --git a/docs/what-is-locust.rst b/docs/what-is-locust.rst index 0baac06348..f9302a373a 100644 --- a/docs/what-is-locust.rst +++ b/docs/what-is-locust.rst @@ -1,89 +1,89 @@ -=============================== -What is Locust? -=============================== - -Locust is an easy-to-use, distributed, user load testing tool. Intended for load testing web sites -(or other systems) and figuring out how many concurrent users a system can handle. - -The idea is that during a test, a swarm of `locusts `_ -will attack your website. The behavior of each -locust (or test user if you will) is defined by you and the swarming process is monitored from a -web UI in real-time. This will help you battle test and identify bottlenecks in your code before -letting real users in. - -Locust is completely event based, and therefore it's possible to support thousands of concurrent -users on a single machine. In contrast to many other event-based apps it doesn't use callbacks. -Instead it uses light-weight processes, through `gevent `_. Each locust -swarming your site is actually running inside it's own process (or greenlet, to be correct). This -allows you to write very expressive scenarios in Python without complicating your code with callbacks. - - -Features -======== - -* **Write user test scenarios in plain-old Python** - - No need for clunky UIs or bloated XML, just code as you normally would. Based on coroutines instead - of callbacks (aka boomerang code) allows code to look and behave like normal, blocking Python code. - -* **Distributed & Scalable - supports hundreds of thousands of users** - - Locust supports running load tests distributed over multiple machines. - Being event based, even one Locust node can handle thousands of users in a single process. - Part of the reason behind this is that even if you simulate that many users, not all are actively - hitting your system. Often, users are idle figuring out what to do next. - Request per second != number of users online. - -* **Web-based UI** - - Locust has a neat HTML+JS user interface that shows relevent test details in real-time. And since - the UI is web-based, it's cross-platform and easily extendable. - -* **Can test any system** - - Even though Locust is web-oriented, it can be used to test almost any system. Just write a client - for what ever you wish to test and swarm it with locusts! It's super easy! - -* **Hackable** - - Locust is small and very hackable and we intend to keep it that way. All heavy-lifting of evented - I/O and coroutines are delegated to gevent. The brittleness of alternative testing tools was the - reason we created Locust. - -Background -========== - -Locust was created because we were fed up with existing solutions. None of them are solving the -right problem and to me, they are missing the point. We've tried both Apache JMeter and Tsung. -Both tools are quite ok to use, we've used the former many times benchmarking stuff at work. -JMeter comes with UI which you might think for second is a good thing. But you soon realize it's -a PITA to "code" your testing scenarios through some point-and-click interface. Secondly, JMeter -is thread-bound. This means for every user you want to simulate, you need a separate thread. -Needless to say, benchmarking thousands of users on a single machine just isn't feasible. - -Tsung, on the other hand, does not have these thread issues as it's written in Erlang. It can make -use of the light-weight processes offered by BEAM itself and happily scale up. But when it comes to -defining the test scenarios, Tsung is as limited as JMeter. It offers an XML-based DSL to define how -a user should behave when testing. I guess you can imagine the horror of "coding" this. Displaying -any sorts of graphs or reports when completed requires you post-process the log files generated from -the test. Only then can you get an understanding of how the test went. - -Anyway, we've tried to address these issues when creating Locust. Hopefully none of the above -painpoints should exist. - -I guess you could say we're really just trying to scratch our own itch here. We hope others will -find it as useful as we do. - -Authors -======= - -- `Jonatan Heyman `_ (`@jonatanheyman `_ on Twitter) -- Carl Byström (`@cgbystrom `_ on Twitter) -- Joakim Hamrén (`@Jahaaja `_ on Twitter) -- Hugo Heyman (`@hugoheyman `_ on Twitter) - -License -======= - -Open source licensed under the MIT license (see LICENSE file for details). - +=============================== +What is Locust? +=============================== + +Locust is an easy-to-use, distributed, user load testing tool. Intended for load testing web sites +(or other systems) and figuring out how many concurrent users a system can handle. + +The idea is that during a test, a swarm of `locusts `_ +will attack your website. The behavior of each +locust (or test user if you will) is defined by you and the swarming process is monitored from a +web UI in real-time. This will help you battle test and identify bottlenecks in your code before +letting real users in. + +Locust is completely event based, and therefore it's possible to support thousands of concurrent +users on a single machine. In contrast to many other event-based apps it doesn't use callbacks. +Instead it uses light-weight processes, through `gevent `_. Each locust +swarming your site is actually running inside it's own process (or greenlet, to be correct). This +allows you to write very expressive scenarios in Python without complicating your code with callbacks. + + +Features +======== + +* **Write user test scenarios in plain-old Python** + + No need for clunky UIs or bloated XML, just code as you normally would. Based on coroutines instead + of callbacks (aka boomerang code) allows code to look and behave like normal, blocking Python code. + +* **Distributed & Scalable - supports hundreds of thousands of users** + + Locust supports running load tests distributed over multiple machines. + Being event based, even one Locust node can handle thousands of users in a single process. + Part of the reason behind this is that even if you simulate that many users, not all are actively + hitting your system. Often, users are idle figuring out what to do next. + Request per second != number of users online. + +* **Web-based UI** + + Locust has a neat HTML+JS user interface that shows relevent test details in real-time. And since + the UI is web-based, it's cross-platform and easily extendable. + +* **Can test any system** + + Even though Locust is web-oriented, it can be used to test almost any system. Just write a client + for what ever you wish to test and swarm it with locusts! It's super easy! + +* **Hackable** + + Locust is small and very hackable and we intend to keep it that way. All heavy-lifting of evented + I/O and coroutines are delegated to gevent. The brittleness of alternative testing tools was the + reason we created Locust. + +Background +========== + +Locust was created because we were fed up with existing solutions. None of them are solving the +right problem and to me, they are missing the point. We've tried both Apache JMeter and Tsung. +Both tools are quite ok to use, we've used the former many times benchmarking stuff at work. +JMeter comes with UI which you might think for second is a good thing. But you soon realize it's +a PITA to "code" your testing scenarios through some point-and-click interface. Secondly, JMeter +is thread-bound. This means for every user you want to simulate, you need a separate thread. +Needless to say, benchmarking thousands of users on a single machine just isn't feasible. + +Tsung, on the other hand, does not have these thread issues as it's written in Erlang. It can make +use of the light-weight processes offered by BEAM itself and happily scale up. But when it comes to +defining the test scenarios, Tsung is as limited as JMeter. It offers an XML-based DSL to define how +a user should behave when testing. I guess you can imagine the horror of "coding" this. Displaying +any sorts of graphs or reports when completed requires you post-process the log files generated from +the test. Only then can you get an understanding of how the test went. + +Anyway, we've tried to address these issues when creating Locust. Hopefully none of the above +painpoints should exist. + +I guess you could say we're really just trying to scratch our own itch here. We hope others will +find it as useful as we do. + +Authors +======= + +- `Jonatan Heyman `_ (`@jonatanheyman `_ on Twitter) +- Carl Byström (`@cgbystrom `_ on Twitter) +- Joakim Hamrén (`@Jahaaja `_ on Twitter) +- Hugo Heyman (`@hugoheyman `_ on Twitter) + +License +======= + +Open source licensed under the MIT license (see LICENSE file for details). + diff --git a/docs/writing-a-locustfile.rst b/docs/writing-a-locustfile.rst index 81198bc3f9..c7277fe797 100644 --- a/docs/writing-a-locustfile.rst +++ b/docs/writing-a-locustfile.rst @@ -1,332 +1,332 @@ -====================== -Writing a locustfile -====================== - -A locustfile is a normal python file. The only requirement is that it declares at least one class - -let's call it the locust class - that inherits from the class Locust. - -The Locust class -================ - -A locust class represents one user (or a swarming locust if you will). Locust will spawn (hatch) one -instance of the locust class for each user that is being simulated. There are a few attributes that -a locust class should typically define. - -The :py:attr:`task_set ` attribute ---------------------------------------------------------------- - -The :py:attr:`task_set ` attribute should point to a -:py:class:`TaskSet ` class which defines the behaviour of the user and -is described in more details below. - -The *min_wait* and *max_wait* attributes ----------------------------------------- - -Additionally to the task_set attribute, one usually want to declare the *min_wait* and *max_wait* -attributes. These are the minimum and maximum time, in milliseconds, that a simulated user will wait -between executing each task. *min_wait* and *max_wait* defaults to 1000, and therefore a locust will -always wait 1 second between each task if *min_wait* and *max_wait* is not declared. - -With the following locustfile, each user would wait between 5 and 15 seconds between tasks:: - - from locust import Locust, TaskSet, task - - class MyTaskSet(TaskSet): - @task - def my_task(l): - print "executing my_task" - - class MyLocust(Locust): - task_set = MyTaskSet - min_wait = 5000 - max_wait = 15000 - -The *min_wait* and *max_wait* attributes can also be overrided in a TaskSet class. - -The *host* attribute --------------------- - -The host attribute is a URL prefix (i.e. "http://google.com") to the host that is to be loaded. -Usually, this is specified on the command line, using the --host option, when locust is started. -If one declares a host attribute in the locust class, it will be used in the case when no --host -is specified on the command line. - - -TaskSet class -============= - -If the Locust class represents a swarming locust, you could say that the TaskSet class represents -the brain of the locust. Each Locust class must have a *task_set* attribute set, that points to -a TaskSet. - -A TaskSet is, like it's name suggests, a collection of tasks. These tasks are normal python callables -and - if we were loadtesting an auction website - could do stuff like "loading the start page", -"searching for some product" and "making a bid". - -When a load test is started, each instance of the spawned Locust classes will start executing their -TaskSet. What happens then is that each TaskSet will pick one of it's tasks and call it. It will then -wait a number of milliseconds, choosed at random between the Locust class' *min_wait* and *max_wait* attributes -(unless min_wait/max_wait has been defined directly under the TaskSet, in which case it will use -it's own values instead). Then it will again pick a new task which will be called, then wait again, -and so on. - -Declaring tasks ---------------- - -The typical way of declaring tasks for a TaskSet it to use the :py:meth:`task ` decorator. - -Here is an example:: - - from locust import Locust, TaskSet, task - - class MyTaskSet(TaskSet): - @task - def my_task(self): - print "Locust instance (%r) executing my_task" % (self.locust) - - class MyLocust(Locust): - task_set = MyTaskSet - -**@task** takes an optional weight argument that can be used to specify the tasks' execution ratio. In -the following example *task2* will be executed twice as much as *task1*:: - - from locust import Locust, TaskSet, task - - class MyTaskSet(TaskSet): - min_wait = 5000 - max_wait = 15000 - - @task(3) - def task1(self): - pass - - @task(6) - def task2(self): - pass - - class MyLocust(Locust): - task_set = MyTaskSet - - -task attribute --------------- - -Using the @task decorator to declare tasks is a convenience, and usually that's the best way to do -it. However, it's also possible to define the tasks of a TaskSet by setting the -:py:attr:`tasks ` attribute (actually using the @task decorator will actually -just populate the *tasks* attribute). - -The *tasks* attribute which is either a list of python callables, or a ** dict. -The tasks are python callables, that recieves one argument - the TaskSet class instance that is executing -the task. Here is an extremely simple example of a locustfile (this locsutfile won't actually load test anything):: - - from locust import Locust, TaskSet - - def my_task(l): - pass - - class MyTaskSet(TaskSet): - tasks = [my_task] - - class MyLocust(Locust): - task_set = MyTaskSet - - -If the -tasks attribute is specified as a list, each time a task is to be performed, it will be randomly -chosen from the *tasks* attribute. If however, *tasks* is a dict - with callables as keys and ints -as values - the task that is to be executed will be chosen at random but with the int as ratio. So -with a tasks that looks like this:: - - {my_task: 3, another_task:1} - -*my_task* would be 3 times more likely to be executed than *another_task*. - -TaskSets can be nested ----------------------- - -A very important property of TaskSets are that they can be nested, because real websites are usually -built up in an hierarchical way, with multiple sub sections. Nesting TaskSets will therefore allow -us to define a behaviour that simulates users in a more realistic way. For example -we could define TaskSets with the following structure: - -* Main user behaviour - - * Index page - * Forum page - - * Read thread - - * Reply - - * New thread - * View next page - - * Browse categories - - * Watch movie - * Filter movies - - * About page - -The way you nest TaskSets is just like when you specify a task using the **tasks** attribute, but -instead of refering to a python function, you point it to another TaskSet:: - - class ForumPage(TaskSet): - @task(20) - def read_thread(self): - pass - - @task(1) - def new_thread(self): - pass - - @task(5) - def stop(self): - self.interrupt() - - class UserBehaviour(TaskSet): - tasks = {ForumPage:10} - - @task - def index(self): - pass - -So in above example, if the ForumPage would get selected for execution when the UserBehaviour -TaskSet is executing, is. that the ForumPage TaskSet would start executing. The ForumPage TaskSet -would then pick one of it's own task, execute it, then wait, and so on. - -There is one important thing to note about the above example, and that is the call to -self.interrupt() in the ForumPage's stop method. What this does is essentially that it will -stop executing the ForumPage task set and the execution will continue in the UserBehaviour instance. -If we wouldn't have had a call to the :py:meth:`interrupt() ` method -somewhere in ForumPage, the Locust would never stop running the ForumPage task once it has started. -But by having the interrupt function, we can - together with task weighting - define how likely it -is that a simulated user leaves the forum. - -It's also possible to declare a nested TaskSet, inline in a class, using the -:py:meth:`@task ` decorator, just like when declaring normal tasks:: - - class MyTaskSet(TaskSet): - @task - class SubTaskSet(TaskSet): - @task - def my_task(self): - pass - - -The on_start function ---------------------- - -A TaskSet class can optionally declare an :py:meth:`on_start ` function. -If so, that function is called when a simulated user starts executing that TaskSet class. - - -Referencing the Locust instance, or the parent TaskSet instance ---------------------------------------------------------------- - -A TaskSet instance will have the attribute :py:attr:`locust ` point to -it's Locust instance, and the attribute :py:attr:`parent ` point to it's -parent TaskSet (it will point to the Locust instance, in the base TaskSet). - - -Making HTTP requests -===================== - -So far, we've only covered the task scheduling part of a Locust user. In order to actually load test -a system we need to make HTTP requests. To help us do this, the :py:class:`HttpLocust ` -class exists. When using this class, each instance gets a -:py:attr:`client ` attribute which will be an instance of -:py:attr:`HttpSession ` which can be used to make HTTP requests. - -.. autoclass:: locust.core.HttpLocust - :members: client - :noindex: - -When inheriting from the HttpLocust class, we can use it's client attribute to make HTTP requests -against the server. Here is an example of a locust file that can be used to load test a site -with two urls; **/** and **/about/**:: - - from locust import HttpLocust, TaskSet, task - - class MyTaskSet(TaskSet): - @task(2) - def index(self): - self.client.get("/") - - @task(1) - def about(self): - self.client.get("/about/") - - class MyLocust(HttpLocust): - task_set = MyTaskSet - min_wait = 5000 - max_wait = 15000 - -Using the above Locust class, each simulated user will wait between 5 and 15 seconds -between the requests, and **/** will be requested twice as much as **/about/**. - -The attentive reader will find it odd that we can reference the HttpSession instance -using *self.client* inside the TaskSet, and not *self.locust.client*. We can do this -because the :py:class:`TaskSet ` class has a convenience property -called client that simply returns self.locust.client. - - -Using the HTTP client -====================== - -Each instance of HttpLocust has an instance of :py:class:`HttpSession ` -in the *client* attribute. The HttpSession class is actually a subclass of -:py:class:`requests.Session` and can be used to make HTTP requests, that will be reported to Locust's -statistics, using the :py:meth:`get `, -:py:meth:`post `, :py:meth:`put `, -:py:meth:`delete `, :py:meth:`head `, -:py:meth:`patch ` and :py:meth:`options ` -methods. The HttpSession instance will preserve cookies between requests so that it can be used to log in -to websites and keep a session between requests. The client attribute can also be reference from the Locust -instance's TaskSet instances so that it's easy to retrieve the client and make HTTP requests from within your -tasks. - -Here's a simple example that makes a GET request to the */about* path (in this case we assume *self* -is an instance of a :py:class:`TaskSet ` or :py:class:`HttpLocust ` -class:: - - response = self.client.get("/about") - print "Response status code:", response.status_code - print "Response content:", response.content - -And here's an example making a POST request:: - - response = self.client.post("/login", {"username":"testuser", "password":"secret"}) - -Safe mode ---------- -The HTTP client is configured to run in safe_mode. What this does is that any request that fails due to -a connection error, timeout, or similar will not raise an exception, but rather return an empty dummy -Response object. The request will be reported as a failure in Locust's statistics. The returned dummy -Response's *content* attribute will be set to None, and it's *status_code* will be 0. - - -Manually controlling if a request should be considered successful or a failure ------------------------------------------------------------------------------- - -By default, requests are marked as failed requests unless the HTTP response code is ok (2xx). -Most of the time, this default is what you want. Sometimes however - for example when testing -a URL endpoint that you expect to return 404, or testing a badly designed system that might -return *200 OK* even though an error occurred - there's a need for manually controlling if -locust should consider a request as a success or a failure. - -One can mark requests as failed, even when the response code is okay, by using the -*catch_response* argument and a with statement:: - - with client.get("/", catch_response=True) as response: - if response.content != "Success": - response.failure("Got wrong response") - -Just as one can mark requests with OK response codes as failures, one can also use **catch_response** -argument together with a *with* statement to make requests that resulted in an HTTP error code still -be reported as a success in the statistics:: - - with client.get("/does_not_exist/", catch_response=True) as response: - if response.status_code == 404: - response.success() +====================== +Writing a locustfile +====================== + +A locustfile is a normal python file. The only requirement is that it declares at least one class - +let's call it the locust class - that inherits from the class Locust. + +The Locust class +================ + +A locust class represents one user (or a swarming locust if you will). Locust will spawn (hatch) one +instance of the locust class for each user that is being simulated. There are a few attributes that +a locust class should typically define. + +The :py:attr:`task_set ` attribute +--------------------------------------------------------------- + +The :py:attr:`task_set ` attribute should point to a +:py:class:`TaskSet ` class which defines the behaviour of the user and +is described in more details below. + +The *min_wait* and *max_wait* attributes +---------------------------------------- + +Additionally to the task_set attribute, one usually want to declare the *min_wait* and *max_wait* +attributes. These are the minimum and maximum time, in milliseconds, that a simulated user will wait +between executing each task. *min_wait* and *max_wait* defaults to 1000, and therefore a locust will +always wait 1 second between each task if *min_wait* and *max_wait* is not declared. + +With the following locustfile, each user would wait between 5 and 15 seconds between tasks:: + + from locust import Locust, TaskSet, task + + class MyTaskSet(TaskSet): + @task + def my_task(l): + print "executing my_task" + + class MyLocust(Locust): + task_set = MyTaskSet + min_wait = 5000 + max_wait = 15000 + +The *min_wait* and *max_wait* attributes can also be overrided in a TaskSet class. + +The *host* attribute +-------------------- + +The host attribute is a URL prefix (i.e. "http://google.com") to the host that is to be loaded. +Usually, this is specified on the command line, using the --host option, when locust is started. +If one declares a host attribute in the locust class, it will be used in the case when no --host +is specified on the command line. + + +TaskSet class +============= + +If the Locust class represents a swarming locust, you could say that the TaskSet class represents +the brain of the locust. Each Locust class must have a *task_set* attribute set, that points to +a TaskSet. + +A TaskSet is, like it's name suggests, a collection of tasks. These tasks are normal python callables +and - if we were loadtesting an auction website - could do stuff like "loading the start page", +"searching for some product" and "making a bid". + +When a load test is started, each instance of the spawned Locust classes will start executing their +TaskSet. What happens then is that each TaskSet will pick one of it's tasks and call it. It will then +wait a number of milliseconds, choosed at random between the Locust class' *min_wait* and *max_wait* attributes +(unless min_wait/max_wait has been defined directly under the TaskSet, in which case it will use +it's own values instead). Then it will again pick a new task which will be called, then wait again, +and so on. + +Declaring tasks +--------------- + +The typical way of declaring tasks for a TaskSet it to use the :py:meth:`task ` decorator. + +Here is an example:: + + from locust import Locust, TaskSet, task + + class MyTaskSet(TaskSet): + @task + def my_task(self): + print "Locust instance (%r) executing my_task" % (self.locust) + + class MyLocust(Locust): + task_set = MyTaskSet + +**@task** takes an optional weight argument that can be used to specify the tasks' execution ratio. In +the following example *task2* will be executed twice as much as *task1*:: + + from locust import Locust, TaskSet, task + + class MyTaskSet(TaskSet): + min_wait = 5000 + max_wait = 15000 + + @task(3) + def task1(self): + pass + + @task(6) + def task2(self): + pass + + class MyLocust(Locust): + task_set = MyTaskSet + + +task attribute +-------------- + +Using the @task decorator to declare tasks is a convenience, and usually that's the best way to do +it. However, it's also possible to define the tasks of a TaskSet by setting the +:py:attr:`tasks ` attribute (actually using the @task decorator will actually +just populate the *tasks* attribute). + +The *tasks* attribute which is either a list of python callables, or a ** dict. +The tasks are python callables, that recieves one argument - the TaskSet class instance that is executing +the task. Here is an extremely simple example of a locustfile (this locsutfile won't actually load test anything):: + + from locust import Locust, TaskSet + + def my_task(l): + pass + + class MyTaskSet(TaskSet): + tasks = [my_task] + + class MyLocust(Locust): + task_set = MyTaskSet + + +If the +tasks attribute is specified as a list, each time a task is to be performed, it will be randomly +chosen from the *tasks* attribute. If however, *tasks* is a dict - with callables as keys and ints +as values - the task that is to be executed will be chosen at random but with the int as ratio. So +with a tasks that looks like this:: + + {my_task: 3, another_task:1} + +*my_task* would be 3 times more likely to be executed than *another_task*. + +TaskSets can be nested +---------------------- + +A very important property of TaskSets are that they can be nested, because real websites are usually +built up in an hierarchical way, with multiple sub sections. Nesting TaskSets will therefore allow +us to define a behaviour that simulates users in a more realistic way. For example +we could define TaskSets with the following structure: + +* Main user behaviour + + * Index page + * Forum page + + * Read thread + + * Reply + + * New thread + * View next page + + * Browse categories + + * Watch movie + * Filter movies + + * About page + +The way you nest TaskSets is just like when you specify a task using the **tasks** attribute, but +instead of refering to a python function, you point it to another TaskSet:: + + class ForumPage(TaskSet): + @task(20) + def read_thread(self): + pass + + @task(1) + def new_thread(self): + pass + + @task(5) + def stop(self): + self.interrupt() + + class UserBehaviour(TaskSet): + tasks = {ForumPage:10} + + @task + def index(self): + pass + +So in above example, if the ForumPage would get selected for execution when the UserBehaviour +TaskSet is executing, is. that the ForumPage TaskSet would start executing. The ForumPage TaskSet +would then pick one of it's own task, execute it, then wait, and so on. + +There is one important thing to note about the above example, and that is the call to +self.interrupt() in the ForumPage's stop method. What this does is essentially that it will +stop executing the ForumPage task set and the execution will continue in the UserBehaviour instance. +If we wouldn't have had a call to the :py:meth:`interrupt() ` method +somewhere in ForumPage, the Locust would never stop running the ForumPage task once it has started. +But by having the interrupt function, we can - together with task weighting - define how likely it +is that a simulated user leaves the forum. + +It's also possible to declare a nested TaskSet, inline in a class, using the +:py:meth:`@task ` decorator, just like when declaring normal tasks:: + + class MyTaskSet(TaskSet): + @task + class SubTaskSet(TaskSet): + @task + def my_task(self): + pass + + +The on_start function +--------------------- + +A TaskSet class can optionally declare an :py:meth:`on_start ` function. +If so, that function is called when a simulated user starts executing that TaskSet class. + + +Referencing the Locust instance, or the parent TaskSet instance +--------------------------------------------------------------- + +A TaskSet instance will have the attribute :py:attr:`locust ` point to +it's Locust instance, and the attribute :py:attr:`parent ` point to it's +parent TaskSet (it will point to the Locust instance, in the base TaskSet). + + +Making HTTP requests +===================== + +So far, we've only covered the task scheduling part of a Locust user. In order to actually load test +a system we need to make HTTP requests. To help us do this, the :py:class:`HttpLocust ` +class exists. When using this class, each instance gets a +:py:attr:`client ` attribute which will be an instance of +:py:attr:`HttpSession ` which can be used to make HTTP requests. + +.. autoclass:: locust.core.HttpLocust + :members: client + :noindex: + +When inheriting from the HttpLocust class, we can use it's client attribute to make HTTP requests +against the server. Here is an example of a locust file that can be used to load test a site +with two urls; **/** and **/about/**:: + + from locust import HttpLocust, TaskSet, task + + class MyTaskSet(TaskSet): + @task(2) + def index(self): + self.client.get("/") + + @task(1) + def about(self): + self.client.get("/about/") + + class MyLocust(HttpLocust): + task_set = MyTaskSet + min_wait = 5000 + max_wait = 15000 + +Using the above Locust class, each simulated user will wait between 5 and 15 seconds +between the requests, and **/** will be requested twice as much as **/about/**. + +The attentive reader will find it odd that we can reference the HttpSession instance +using *self.client* inside the TaskSet, and not *self.locust.client*. We can do this +because the :py:class:`TaskSet ` class has a convenience property +called client that simply returns self.locust.client. + + +Using the HTTP client +====================== + +Each instance of HttpLocust has an instance of :py:class:`HttpSession ` +in the *client* attribute. The HttpSession class is actually a subclass of +:py:class:`requests.Session` and can be used to make HTTP requests, that will be reported to Locust's +statistics, using the :py:meth:`get `, +:py:meth:`post `, :py:meth:`put `, +:py:meth:`delete `, :py:meth:`head `, +:py:meth:`patch ` and :py:meth:`options ` +methods. The HttpSession instance will preserve cookies between requests so that it can be used to log in +to websites and keep a session between requests. The client attribute can also be reference from the Locust +instance's TaskSet instances so that it's easy to retrieve the client and make HTTP requests from within your +tasks. + +Here's a simple example that makes a GET request to the */about* path (in this case we assume *self* +is an instance of a :py:class:`TaskSet ` or :py:class:`HttpLocust ` +class:: + + response = self.client.get("/about") + print "Response status code:", response.status_code + print "Response content:", response.content + +And here's an example making a POST request:: + + response = self.client.post("/login", {"username":"testuser", "password":"secret"}) + +Safe mode +--------- +The HTTP client is configured to run in safe_mode. What this does is that any request that fails due to +a connection error, timeout, or similar will not raise an exception, but rather return an empty dummy +Response object. The request will be reported as a failure in Locust's statistics. The returned dummy +Response's *content* attribute will be set to None, and it's *status_code* will be 0. + + +Manually controlling if a request should be considered successful or a failure +------------------------------------------------------------------------------ + +By default, requests are marked as failed requests unless the HTTP response code is ok (2xx). +Most of the time, this default is what you want. Sometimes however - for example when testing +a URL endpoint that you expect to return 404, or testing a badly designed system that might +return *200 OK* even though an error occurred - there's a need for manually controlling if +locust should consider a request as a success or a failure. + +One can mark requests as failed, even when the response code is okay, by using the +*catch_response* argument and a with statement:: + + with client.get("/", catch_response=True) as response: + if response.content != "Success": + response.failure("Got wrong response") + +Just as one can mark requests with OK response codes as failures, one can also use **catch_response** +argument together with a *with* statement to make requests that resulted in an HTTP error code still +be reported as a success in the statistics:: + + with client.get("/does_not_exist/", catch_response=True) as response: + if response.status_code == 404: + response.success() diff --git a/locust/__init__.py b/locust/__init__.py index 99584322ce..661cc2a6e1 100644 --- a/locust/__init__.py +++ b/locust/__init__.py @@ -1,4 +1,4 @@ -from core import HttpLocust, Locust, TaskSet, task -from exception import InterruptTaskSet, ResponseError, RescheduleTaskImmediately - -version = "0.7.0" +from core import HttpLocust, Locust, TaskSet, task +from exception import InterruptTaskSet, ResponseError, RescheduleTaskImmediately + +version = "0.7.0" diff --git a/locust/clients.py b/locust/clients.py index 69cfc2ddb4..ca85b01690 100644 --- a/locust/clients.py +++ b/locust/clients.py @@ -1,253 +1,253 @@ -import re -import time -from urlparse import urlparse, urlunparse - -import requests -from requests import Response, Request -from requests.packages.urllib3.response import HTTPResponse -from requests.auth import HTTPBasicAuth -from requests.exceptions import (RequestException, MissingSchema, - InvalidSchema, InvalidURL) - -import events -from exception import CatchResponseError, ResponseError - -absolute_http_url_regexp = re.compile(r"^https?://", re.I) - - -def timedelta_to_ms(td): - "python 2.7 has a total_seconds method for timedelta objects. This is here for py<2.7 compat." - return int((td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) / 10**3) - - -class LocustResponse(Response): - - def raise_for_status(self): - if hasattr(self, 'error') and self.error: - raise self.error - Response.raise_for_status(self) - - -class HttpSession(requests.Session): - """ - Class for performing web requests and holding (session-) cookies between requests (in order - to be able to log in and out of websites). Each request is logged so that locust can display - statistics. - - This is a slightly extended version of `python-request `_'s - :py:class:`requests.Session` class and mostly this class works exactly the same. However - the methods for making requests (get, post, delete, put, head, options, patch, request) - can now take a *url* argument that's only the path part of the URL, in which case the host - part of the URL will be prepended with the HttpSession.base_url which is normally inherited - from a Locust class' host property. - - Each of the methods for making requests also takes two additional optional arguments which - are Locust specific and doesn't exist in python-requests. These are: - - :param name: (optional) An argument that can be specified to use as label in Locust's statistics instead of the URL path. - This can be used to group different URL's that are requested into a single entry in Locust's statistics. - :param catch_response: (optional) Boolean argument that, if set, can be used to make a request return a context manager - to work as argument to a with statement. This will allow the request to be marked as a fail based on the content of the - response, even if the response code is ok (2xx). The opposite also works, one can use catch_response to catch a request - and then mark it as successful even if the response code was not (i.e 500 or 404). - """ - def __init__(self, base_url, *args, **kwargs): - requests.Session.__init__(self, *args, **kwargs) - - self.base_url = base_url - - # Check for basic authentication - parsed_url = urlparse(self.base_url) - if parsed_url.username and parsed_url.password: - netloc = parsed_url.hostname - if parsed_url.port: - netloc += ":%d" % parsed_url.port - - # remove username and password from the base_url - self.base_url = urlunparse((parsed_url.scheme, netloc, parsed_url.path, parsed_url.params, parsed_url.query, parsed_url.fragment)) - # configure requests to use basic auth - self.auth = HTTPBasicAuth(parsed_url.username, parsed_url.password) - - def _build_url(self, path): - """ prepend url with hostname unless it's already an absolute URL """ - if absolute_http_url_regexp.match(path): - return path - else: - return "%s%s" % (self.base_url, path) - - def request(self, method, url, name=None, catch_response=False, **kwargs): - """ - Constructs and sends a :py:class:`requests.Request`. - Returns :py:class:`requests.Response` object. - - :param method: method for the new :class:`Request` object. - :param url: URL for the new :class:`Request` object. - :param name: (optional) An argument that can be specified to use as label in Locust's statistics instead of the URL path. - This can be used to group different URL's that are requested into a single entry in Locust's statistics. - :param catch_response: (optional) Boolean argument that, if set, can be used to make a request return a context manager - to work as argument to a with statement. This will allow the request to be marked as a fail based on the content of the - response, even if the response code is ok (2xx). The opposite also works, one can use catch_response to catch a request - and then mark it as successful even if the response code was not (i.e 500 or 404). - :param params: (optional) Dictionary or bytes to be sent in the query string for the :class:`Request`. - :param data: (optional) Dictionary or bytes to send in the body of the :class:`Request`. - :param headers: (optional) Dictionary of HTTP Headers to send with the :class:`Request`. - :param cookies: (optional) Dict or CookieJar object to send with the :class:`Request`. - :param files: (optional) Dictionary of 'filename': file-like-objects for multipart encoding upload. - :param auth: (optional) Auth tuple or callable to enable Basic/Digest/Custom HTTP Auth. - :param timeout: (optional) Float describing the timeout of the request. - :param allow_redirects: (optional) Boolean. Set to True by default. - :param proxies: (optional) Dictionary mapping protocol to the URL of the proxy. - :param return_response: (optional) If False, an un-sent Request object will returned. - :param config: (optional) A configuration dictionary. See ``request.defaults`` for allowed keys and their default values. - :param stream: (optional) whether to immediately download the response content. Defaults to ``False``. - :param verify: (optional) if ``True``, the SSL cert will be verified. A CA_BUNDLE path can also be provided. - :param cert: (optional) if String, path to ssl client cert file (.pem). If Tuple, ('cert', 'key') pair. - """ - - # prepend url with hostname unless it's already an absolute URL - url = self._build_url(url) - - # store meta data that is used when reporting the request to locust's statistics - request_meta = {} - - # set up pre_request hook for attaching meta data to the request object - request_meta["start_time"] = time.time() - - response = self._send_request_safe_mode(method, url, **kwargs) - - request_meta["method"] = response.request.method - request_meta["name"] = name or response.request.path_url - - # record the consumed time - request_meta["response_time"] = timedelta_to_ms(response.elapsed) - - # get the length of the content, but if the argument stream is set to True, we take - # the size from the content-length header, in order to not trigger fetching of the body - if kwargs.get("stream", False): - request_meta["content_size"] = int(response.headers.get("content-length") or 0) - else: - request_meta["content_size"] = len(response.content or "") - - if catch_response: - response.locust_request_meta = request_meta - return ResponseContextManager(response) - else: - try: - response.raise_for_status() - except RequestException as e: - events.request_failure.fire( - request_type=request_meta["method"], - name=request_meta["name"], - response_time=request_meta["response_time"], - exception=e, - response=None, - ) - else: - events.request_success.fire( - request_type=request_meta["method"], - name=request_meta["name"], - response_time=request_meta["response_time"], - response_length=request_meta["content_size"], - ) - return response - - def _send_request_safe_mode(self, method, url, **kwargs): - """ - Send an HTTP request, and catch any exception that might occur due to connection problems. - - Safe mode has been removed from requests 1.x. - """ - try: - return requests.Session.request(self, method, url, **kwargs) - except (MissingSchema, InvalidSchema, InvalidURL): - raise - except RequestException as e: - r = LocustResponse() - r.error = e - r.raw = HTTPResponse() # otherwise, tests fail - r.status_code = 0 # with this status_code, content returns None - r.request = Request(method, url).prepare() - return r - - -class ResponseContextManager(LocustResponse): - """ - A Response class that also acts as a context manager that provides the ability to manually - control if an HTTP request should be marked as successful or a failure in Locust's statistics - - This class is a subclass of :py:class:`Response ` with two additional - methods: :py:meth:`success ` and - :py:meth:`failure `. - """ - - _is_reported = False - - def __init__(self, response): - # copy data from response to this object - self.__dict__ = response.__dict__ - - def __enter__(self): - return self - - def __exit__(self, exc, value, traceback): - if self._is_reported: - # if the user has already manually marked this response as failure or success - # we can ignore the default haviour of letting the response code determine the outcome - return exc is None - - if exc: - if isinstance(value, ResponseError): - self.failure(value) - else: - return False - else: - try: - self.raise_for_status() - except requests.exceptions.RequestException as e: - self.failure(e) - else: - self.success() - return True - - def success(self): - """ - Report the response as successful - - Example:: - - with self.client.get("/does/not/exist", catch_response=True) as response: - if response.status_code == 404: - response.success() - """ - events.request_success.fire( - request_type=self.locust_request_meta["method"], - name=self.locust_request_meta["name"], - response_time=self.locust_request_meta["response_time"], - response_length=self.locust_request_meta["content_size"], - ) - self._is_reported = True - - def failure(self, exc): - """ - Report the response as a failure. - - exc can be either a python exception, or a string in which case it will - be wrapped inside a CatchResponseError. - - Example:: - - with self.client.get("/", catch_response=True) as response: - if response.content == "": - response.failure("No data") - """ - if isinstance(exc, basestring): - exc = CatchResponseError(exc) - - events.request_failure.fire( - request_type=self.locust_request_meta["method"], - name=self.locust_request_meta["name"], - response_time=self.locust_request_meta["response_time"], - exception=exc, - response=self, - ) - self._is_reported = True +import re +import time +from urlparse import urlparse, urlunparse + +import requests +from requests import Response, Request +from requests.packages.urllib3.response import HTTPResponse +from requests.auth import HTTPBasicAuth +from requests.exceptions import (RequestException, MissingSchema, + InvalidSchema, InvalidURL) + +import events +from exception import CatchResponseError, ResponseError + +absolute_http_url_regexp = re.compile(r"^https?://", re.I) + + +def timedelta_to_ms(td): + "python 2.7 has a total_seconds method for timedelta objects. This is here for py<2.7 compat." + return int((td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) / 10**3) + + +class LocustResponse(Response): + + def raise_for_status(self): + if hasattr(self, 'error') and self.error: + raise self.error + Response.raise_for_status(self) + + +class HttpSession(requests.Session): + """ + Class for performing web requests and holding (session-) cookies between requests (in order + to be able to log in and out of websites). Each request is logged so that locust can display + statistics. + + This is a slightly extended version of `python-request `_'s + :py:class:`requests.Session` class and mostly this class works exactly the same. However + the methods for making requests (get, post, delete, put, head, options, patch, request) + can now take a *url* argument that's only the path part of the URL, in which case the host + part of the URL will be prepended with the HttpSession.base_url which is normally inherited + from a Locust class' host property. + + Each of the methods for making requests also takes two additional optional arguments which + are Locust specific and doesn't exist in python-requests. These are: + + :param name: (optional) An argument that can be specified to use as label in Locust's statistics instead of the URL path. + This can be used to group different URL's that are requested into a single entry in Locust's statistics. + :param catch_response: (optional) Boolean argument that, if set, can be used to make a request return a context manager + to work as argument to a with statement. This will allow the request to be marked as a fail based on the content of the + response, even if the response code is ok (2xx). The opposite also works, one can use catch_response to catch a request + and then mark it as successful even if the response code was not (i.e 500 or 404). + """ + def __init__(self, base_url, *args, **kwargs): + requests.Session.__init__(self, *args, **kwargs) + + self.base_url = base_url + + # Check for basic authentication + parsed_url = urlparse(self.base_url) + if parsed_url.username and parsed_url.password: + netloc = parsed_url.hostname + if parsed_url.port: + netloc += ":%d" % parsed_url.port + + # remove username and password from the base_url + self.base_url = urlunparse((parsed_url.scheme, netloc, parsed_url.path, parsed_url.params, parsed_url.query, parsed_url.fragment)) + # configure requests to use basic auth + self.auth = HTTPBasicAuth(parsed_url.username, parsed_url.password) + + def _build_url(self, path): + """ prepend url with hostname unless it's already an absolute URL """ + if absolute_http_url_regexp.match(path): + return path + else: + return "%s%s" % (self.base_url, path) + + def request(self, method, url, name=None, catch_response=False, **kwargs): + """ + Constructs and sends a :py:class:`requests.Request`. + Returns :py:class:`requests.Response` object. + + :param method: method for the new :class:`Request` object. + :param url: URL for the new :class:`Request` object. + :param name: (optional) An argument that can be specified to use as label in Locust's statistics instead of the URL path. + This can be used to group different URL's that are requested into a single entry in Locust's statistics. + :param catch_response: (optional) Boolean argument that, if set, can be used to make a request return a context manager + to work as argument to a with statement. This will allow the request to be marked as a fail based on the content of the + response, even if the response code is ok (2xx). The opposite also works, one can use catch_response to catch a request + and then mark it as successful even if the response code was not (i.e 500 or 404). + :param params: (optional) Dictionary or bytes to be sent in the query string for the :class:`Request`. + :param data: (optional) Dictionary or bytes to send in the body of the :class:`Request`. + :param headers: (optional) Dictionary of HTTP Headers to send with the :class:`Request`. + :param cookies: (optional) Dict or CookieJar object to send with the :class:`Request`. + :param files: (optional) Dictionary of 'filename': file-like-objects for multipart encoding upload. + :param auth: (optional) Auth tuple or callable to enable Basic/Digest/Custom HTTP Auth. + :param timeout: (optional) Float describing the timeout of the request. + :param allow_redirects: (optional) Boolean. Set to True by default. + :param proxies: (optional) Dictionary mapping protocol to the URL of the proxy. + :param return_response: (optional) If False, an un-sent Request object will returned. + :param config: (optional) A configuration dictionary. See ``request.defaults`` for allowed keys and their default values. + :param stream: (optional) whether to immediately download the response content. Defaults to ``False``. + :param verify: (optional) if ``True``, the SSL cert will be verified. A CA_BUNDLE path can also be provided. + :param cert: (optional) if String, path to ssl client cert file (.pem). If Tuple, ('cert', 'key') pair. + """ + + # prepend url with hostname unless it's already an absolute URL + url = self._build_url(url) + + # store meta data that is used when reporting the request to locust's statistics + request_meta = {} + + # set up pre_request hook for attaching meta data to the request object + request_meta["start_time"] = time.time() + + response = self._send_request_safe_mode(method, url, **kwargs) + + request_meta["method"] = response.request.method + request_meta["name"] = name or response.request.path_url + + # record the consumed time + request_meta["response_time"] = timedelta_to_ms(response.elapsed) + + # get the length of the content, but if the argument stream is set to True, we take + # the size from the content-length header, in order to not trigger fetching of the body + if kwargs.get("stream", False): + request_meta["content_size"] = int(response.headers.get("content-length") or 0) + else: + request_meta["content_size"] = len(response.content or "") + + if catch_response: + response.locust_request_meta = request_meta + return ResponseContextManager(response) + else: + try: + response.raise_for_status() + except RequestException as e: + events.request_failure.fire( + request_type=request_meta["method"], + name=request_meta["name"], + response_time=request_meta["response_time"], + exception=e, + response=None, + ) + else: + events.request_success.fire( + request_type=request_meta["method"], + name=request_meta["name"], + response_time=request_meta["response_time"], + response_length=request_meta["content_size"], + ) + return response + + def _send_request_safe_mode(self, method, url, **kwargs): + """ + Send an HTTP request, and catch any exception that might occur due to connection problems. + + Safe mode has been removed from requests 1.x. + """ + try: + return requests.Session.request(self, method, url, **kwargs) + except (MissingSchema, InvalidSchema, InvalidURL): + raise + except RequestException as e: + r = LocustResponse() + r.error = e + r.raw = HTTPResponse() # otherwise, tests fail + r.status_code = 0 # with this status_code, content returns None + r.request = Request(method, url).prepare() + return r + + +class ResponseContextManager(LocustResponse): + """ + A Response class that also acts as a context manager that provides the ability to manually + control if an HTTP request should be marked as successful or a failure in Locust's statistics + + This class is a subclass of :py:class:`Response ` with two additional + methods: :py:meth:`success ` and + :py:meth:`failure `. + """ + + _is_reported = False + + def __init__(self, response): + # copy data from response to this object + self.__dict__ = response.__dict__ + + def __enter__(self): + return self + + def __exit__(self, exc, value, traceback): + if self._is_reported: + # if the user has already manually marked this response as failure or success + # we can ignore the default haviour of letting the response code determine the outcome + return exc is None + + if exc: + if isinstance(value, ResponseError): + self.failure(value) + else: + return False + else: + try: + self.raise_for_status() + except requests.exceptions.RequestException as e: + self.failure(e) + else: + self.success() + return True + + def success(self): + """ + Report the response as successful + + Example:: + + with self.client.get("/does/not/exist", catch_response=True) as response: + if response.status_code == 404: + response.success() + """ + events.request_success.fire( + request_type=self.locust_request_meta["method"], + name=self.locust_request_meta["name"], + response_time=self.locust_request_meta["response_time"], + response_length=self.locust_request_meta["content_size"], + ) + self._is_reported = True + + def failure(self, exc): + """ + Report the response as a failure. + + exc can be either a python exception, or a string in which case it will + be wrapped inside a CatchResponseError. + + Example:: + + with self.client.get("/", catch_response=True) as response: + if response.content == "": + response.failure("No data") + """ + if isinstance(exc, basestring): + exc = CatchResponseError(exc) + + events.request_failure.fire( + request_type=self.locust_request_meta["method"], + name=self.locust_request_meta["name"], + response_time=self.locust_request_meta["response_time"], + exception=exc, + response=self, + ) + self._is_reported = True diff --git a/locust/events.py b/locust/events.py index 82cc10be0c..8850857713 100644 --- a/locust/events.py +++ b/locust/events.py @@ -1,105 +1,105 @@ -class EventHook(object): - """ - Simple event class used to provide hooks for different types of events in Locust. - - Here's how to use the EventHook class:: - - my_event = EventHook() - def on_my_event(a, b, **kw): - print "Event was fired with arguments: %s, %s" % (a, b) - my_event += on_my_event - my_event.fire(a="foo", b="bar") - """ - - def __init__(self): - self._handlers = [] - - def __iadd__(self, handler): - self._handlers.append(handler) - return self - - def __isub__(self, handler): - self._handlers.remove(handler) - return self - - def fire(self, **kwargs): - for handler in self._handlers: - handler(**kwargs) - -request_success = EventHook() -""" -*request_success* is fired when an HTTP request is completed successfully. - -Listeners should take the following arguments: - -* *request_type*: Request type method used -* *name*: Path to the URL that was called (or override name if it was used in the call to the client) -* *response_time*: Response time in milliseconds -* *response_length*: Content-length of the response -""" - -request_failure = EventHook() -""" -*request_failure* is fired when an HTTP request fails - -Event is fired with the following arguments: - -* *request_type*: Request type method used -* *name*: Path to the URL that was called (or override name if it was used in the call to the client) -* *response_time*: Time in milliseconds until exception was thrown -* *exception*: Exception instance that was thrown -* *response*: If the failure was due to an HTTP error code (exception is an instance of urllib2.HTTPError), - then response will be an instance of locus.clients.HttpResponse. Otherwise response will be None. -""" - -locust_error = EventHook() -""" -*locust_error* is fired when an exception occurs inside the execution of a Locust class. - -Event is fired with the following arguments: - -* *locust_instance*: Locust class instance where the exception occurred -* *exception*: Exception that was thrown -* *traceback*: Traceback object (from sys.exc_info()[2]) -""" - -report_to_master = EventHook() -""" -*report_to_master* is used when Locust is running in --slave mode. It can be used to attach -data to the dicts that are regularly sent to the master. It's fired regularly when a report -is to be sent to the master server. - -Note that the keys "stats" and "errors" are used by Locust and shouldn't be overridden. - -Event is fired with the following arguments: - -* *client_id*: The client id of the running locust process. -* *data*: Data dict that can be modified in order to attach data that should be sent to the master. -""" - -slave_report = EventHook() -""" -*slave_report* is used when Locust is running in --master mode and is fired when the master -server receives a report from a Locust slave server. - -This event can be used to aggregate data from the locust slave servers. - -Event is fired with following arguments: - -* *client_id*: Client id of the reporting locust slave -* *data*: Data dict with the data from the slave node -""" - -hatch_complete = EventHook() -""" -*hatch_complete* is fired when all locust users has been spawned. - -Event is fire with the following arguments: - -* *user_count*: Number of users that was hatched -""" - -quitting = EventHook() -""" -*quitting* is fired when the locust process in exiting -""" +class EventHook(object): + """ + Simple event class used to provide hooks for different types of events in Locust. + + Here's how to use the EventHook class:: + + my_event = EventHook() + def on_my_event(a, b, **kw): + print "Event was fired with arguments: %s, %s" % (a, b) + my_event += on_my_event + my_event.fire(a="foo", b="bar") + """ + + def __init__(self): + self._handlers = [] + + def __iadd__(self, handler): + self._handlers.append(handler) + return self + + def __isub__(self, handler): + self._handlers.remove(handler) + return self + + def fire(self, **kwargs): + for handler in self._handlers: + handler(**kwargs) + +request_success = EventHook() +""" +*request_success* is fired when an HTTP request is completed successfully. + +Listeners should take the following arguments: + +* *request_type*: Request type method used +* *name*: Path to the URL that was called (or override name if it was used in the call to the client) +* *response_time*: Response time in milliseconds +* *response_length*: Content-length of the response +""" + +request_failure = EventHook() +""" +*request_failure* is fired when an HTTP request fails + +Event is fired with the following arguments: + +* *request_type*: Request type method used +* *name*: Path to the URL that was called (or override name if it was used in the call to the client) +* *response_time*: Time in milliseconds until exception was thrown +* *exception*: Exception instance that was thrown +* *response*: If the failure was due to an HTTP error code (exception is an instance of urllib2.HTTPError), + then response will be an instance of locus.clients.HttpResponse. Otherwise response will be None. +""" + +locust_error = EventHook() +""" +*locust_error* is fired when an exception occurs inside the execution of a Locust class. + +Event is fired with the following arguments: + +* *locust_instance*: Locust class instance where the exception occurred +* *exception*: Exception that was thrown +* *traceback*: Traceback object (from sys.exc_info()[2]) +""" + +report_to_master = EventHook() +""" +*report_to_master* is used when Locust is running in --slave mode. It can be used to attach +data to the dicts that are regularly sent to the master. It's fired regularly when a report +is to be sent to the master server. + +Note that the keys "stats" and "errors" are used by Locust and shouldn't be overridden. + +Event is fired with the following arguments: + +* *client_id*: The client id of the running locust process. +* *data*: Data dict that can be modified in order to attach data that should be sent to the master. +""" + +slave_report = EventHook() +""" +*slave_report* is used when Locust is running in --master mode and is fired when the master +server receives a report from a Locust slave server. + +This event can be used to aggregate data from the locust slave servers. + +Event is fired with following arguments: + +* *client_id*: Client id of the reporting locust slave +* *data*: Data dict with the data from the slave node +""" + +hatch_complete = EventHook() +""" +*hatch_complete* is fired when all locust users has been spawned. + +Event is fire with the following arguments: + +* *user_count*: Number of users that was hatched +""" + +quitting = EventHook() +""" +*quitting* is fired when the locust process in exiting +""" diff --git a/locust/exception.py b/locust/exception.py index 137045407e..fd997ab9a4 100644 --- a/locust/exception.py +++ b/locust/exception.py @@ -1,36 +1,36 @@ -class LocustError(Exception): - pass - -class ResponseError(Exception): - pass - -class CatchResponseError(Exception): - pass - -class InterruptTaskSet(Exception): - """ - Exception that will interrupt a Locust when thrown inside a task - """ - - def __init__(self, reschedule=True): - """ - If *reschedule* is True and the InterruptTaskSet is raised inside a nested TaskSet, - the parent TaskSet whould immediately reschedule another task. - """ - self.reschedule = reschedule - -class StopLocust(Exception): - pass - -class RescheduleTask(Exception): - """ - When raised in a task it's equivalent of a return statement. - - Used internally by TaskSet. When raised within the task control flow of a TaskSet, - but not inside a task, the execution should be handed over to the parent TaskSet. - """ - -class RescheduleTaskImmediately(Exception): - """ - When raised in a Locust task, another locust task will be rescheduled immediately - """ +class LocustError(Exception): + pass + +class ResponseError(Exception): + pass + +class CatchResponseError(Exception): + pass + +class InterruptTaskSet(Exception): + """ + Exception that will interrupt a Locust when thrown inside a task + """ + + def __init__(self, reschedule=True): + """ + If *reschedule* is True and the InterruptTaskSet is raised inside a nested TaskSet, + the parent TaskSet whould immediately reschedule another task. + """ + self.reschedule = reschedule + +class StopLocust(Exception): + pass + +class RescheduleTask(Exception): + """ + When raised in a task it's equivalent of a return statement. + + Used internally by TaskSet. When raised within the task control flow of a TaskSet, + but not inside a task, the execution should be handed over to the parent TaskSet. + """ + +class RescheduleTaskImmediately(Exception): + """ + When raised in a Locust task, another locust task will be rescheduled immediately + """ diff --git a/locust/main.py b/locust/main.py index b38111575b..7c87240703 100644 --- a/locust/main.py +++ b/locust/main.py @@ -1,446 +1,446 @@ -import locust -import runners - -import gevent -import sys -import os -import signal -import inspect -import logging -import socket -from optparse import OptionParser - -import web -from log import setup_logging, console_logger -from stats import stats_printer, print_percentile_stats, print_error_report, print_stats -from inspectlocust import print_task_ratio, get_task_ratio_dict -from core import Locust, HttpLocust -from runners import MasterLocustRunner, SlaveLocustRunner, LocalLocustRunner -import events - -_internals = [Locust, HttpLocust] -version = locust.version - -def parse_options(): - """ - Handle command-line options with optparse.OptionParser. - - Return list of arguments, largely for use in `parse_arguments`. - """ - - # Initialize - parser = OptionParser(usage="locust [options] [LocustClass [LocustClass2 ... ]]") - - parser.add_option( - '-H', '--host', - dest="host", - default=None, - help="Host to load test in the following format: http://10.21.32.33" - ) - - parser.add_option( - '--web-host', - dest="web_host", - default="", - help="Host to bind the web interface to. Defaults to '' (all interfaces)" - ) - - parser.add_option( - '-P', '--port', '--web-port', - type="int", - dest="port", - default=8089, - help="Port on which to run web host" - ) - - parser.add_option( - '-f', '--locustfile', - dest='locustfile', - default='locustfile', - help="Python module file to import, e.g. '../other.py'. Default: locustfile" - ) - - # if locust should be run in distributed mode as master - parser.add_option( - '--master', - action='store_true', - dest='master', - default=False, - help="Set locust to run in distributed mode with this process as master" - ) - - # if locust should be run in distributed mode as slave - parser.add_option( - '--slave', - action='store_true', - dest='slave', - default=False, - help="Set locust to run in distributed mode with this process as slave" - ) - - # master host options - parser.add_option( - '--master-host', - action='store', - type='str', - dest='master_host', - default="127.0.0.1", - help="Host or IP address of locust master for distributed load testing. Only used when running with --slave. Defaults to 127.0.0.1." - ) - - parser.add_option( - '--master-port', - action='store', - type='int', - dest='master_port', - default=5557, - help="The port to connect to that is used by the locust master for distributed load testing. Only used when running with --slave. Defaults to 5557. Note that slaves will also connect to the master node on this port + 1." - ) - - parser.add_option( - '--master-bind-host', - action='store', - type='str', - dest='master_bind_host', - default="*", - help="Interfaces (hostname, ip) that locust master should bind to. Only used when running with --master. Defaults to * (all available interfaces)." - ) - - parser.add_option( - '--master-bind-port', - action='store', - type='int', - dest='master_bind_port', - default=5557, - help="Port that locust master should bind to. Only used when running with --master. Defaults to 5557. Note that Locust will also use this port + 1, so by default the master node will bind to 5557 and 5558." - ) - - # if we should print stats in the console - parser.add_option( - '--no-web', - action='store_true', - dest='no_web', - default=False, - help="Disable the web interface, and instead start running the test immediately. Requires -c and -r to be specified." - ) - - # Number of clients - parser.add_option( - '-c', '--clients', - action='store', - type='int', - dest='num_clients', - default=1, - help="Number of concurrent clients. Only used together with --no-web" - ) - - # Client hatch rate - parser.add_option( - '-r', '--hatch-rate', - action='store', - type='float', - dest='hatch_rate', - default=1, - help="The rate per second in which clients are spawned. Only used together with --no-web" - ) - - # Number of requests - parser.add_option( - '-n', '--num-request', - action='store', - type='int', - dest='num_requests', - default=None, - help="Number of requests to perform. Only used together with --no-web" - ) - - # log level - parser.add_option( - '--loglevel', '-L', - action='store', - type='str', - dest='loglevel', - default='INFO', - help="Choose between DEBUG/INFO/WARNING/ERROR/CRITICAL. Default is INFO.", - ) - - # log file - parser.add_option( - '--logfile', - action='store', - type='str', - dest='logfile', - default=None, - help="Path to log file. If not set, log will go to stdout/stderr", - ) - - # if we should print stats in the console - parser.add_option( - '--print-stats', - action='store_true', - dest='print_stats', - default=False, - help="Print stats in the console" - ) - - # only print summary stats - parser.add_option( - '--only-summary', - action='store_true', - dest='only_summary', - default=False, - help='Only print the summary stats' - ) - - # List locust commands found in loaded locust files/source files - parser.add_option( - '-l', '--list', - action='store_true', - dest='list_commands', - default=False, - help="Show list of possible locust classes and exit" - ) - - # Display ratio table of all tasks - parser.add_option( - '--show-task-ratio', - action='store_true', - dest='show_task_ratio', - default=False, - help="print table of the locust classes' task execution ratio" - ) - # Display ratio table of all tasks in JSON format - parser.add_option( - '--show-task-ratio-json', - action='store_true', - dest='show_task_ratio_json', - default=False, - help="print json data of the locust classes' task execution ratio" - ) - - # Version number (optparse gives you --version but we have to do it - # ourselves to get -V too. sigh) - parser.add_option( - '-V', '--version', - action='store_true', - dest='show_version', - default=False, - help="show program's version number and exit" - ) - - # Finalize - # Return three-tuple of parser + the output from parse_args (opt obj, args) - opts, args = parser.parse_args() - return parser, opts, args - - -def _is_package(path): - """ - Is the given path a Python package? - """ - return ( - os.path.isdir(path) - and os.path.exists(os.path.join(path, '__init__.py')) - ) - - -def find_locustfile(locustfile): - """ - Attempt to locate a locustfile, either explicitly or by searching parent dirs. - """ - # Obtain env value - names = [locustfile] - # Create .py version if necessary - if not names[0].endswith('.py'): - names += [names[0] + '.py'] - # Does the name contain path elements? - if os.path.dirname(names[0]): - # If so, expand home-directory markers and test for existence - for name in names: - expanded = os.path.expanduser(name) - if os.path.exists(expanded): - if name.endswith('.py') or _is_package(expanded): - return os.path.abspath(expanded) - else: - # Otherwise, start in cwd and work downwards towards filesystem root - path = '.' - # Stop before falling off root of filesystem (should be platform - # agnostic) - while os.path.split(os.path.abspath(path))[1]: - for name in names: - joined = os.path.join(path, name) - if os.path.exists(joined): - if name.endswith('.py') or _is_package(joined): - return os.path.abspath(joined) - path = os.path.join('..', path) - # Implicit 'return None' if nothing was found - - -def is_locust(tup): - """ - Takes (name, object) tuple, returns True if it's a public Locust subclass. - """ - name, item = tup - return ( - inspect.isclass(item) - and issubclass(item, Locust) - and (item not in _internals) - and not name.startswith('_') - ) - - -def load_locustfile(path): - """ - Import given locustfile path and return (docstring, callables). - - Specifically, the locustfile's ``__doc__`` attribute (a string) and a - dictionary of ``{'name': callable}`` containing all callables which pass - the "is a Locust" test. - """ - # Get directory and locustfile name - directory, locustfile = os.path.split(path) - # If the directory isn't in the PYTHONPATH, add it so our import will work - added_to_path = False - index = None - if directory not in sys.path: - sys.path.insert(0, directory) - added_to_path = True - # If the directory IS in the PYTHONPATH, move it to the front temporarily, - # otherwise other locustfiles -- like Locusts's own -- may scoop the intended - # one. - else: - i = sys.path.index(directory) - if i != 0: - # Store index for later restoration - index = i - # Add to front, then remove from original position - sys.path.insert(0, directory) - del sys.path[i + 1] - # Perform the import (trimming off the .py) - imported = __import__(os.path.splitext(locustfile)[0]) - # Remove directory from path if we added it ourselves (just to be neat) - if added_to_path: - del sys.path[0] - # Put back in original index if we moved it - if index is not None: - sys.path.insert(index + 1, directory) - del sys.path[0] - # Return our two-tuple - locusts = dict(filter(is_locust, vars(imported).items())) - return imported.__doc__, locusts - -def main(): - parser, options, arguments = parse_options() - - # setup logging - setup_logging(options.loglevel, options.logfile) - logger = logging.getLogger(__name__) - - if options.show_version: - print "Locust %s" % (version,) - sys.exit(0) - - locustfile = find_locustfile(options.locustfile) - if not locustfile: - logger.error("Could not find any locustfile! See --help for available options.") - sys.exit(1) - - docstring, locusts = load_locustfile(locustfile) - - if options.list_commands: - print "Available Locusts:" - for name in locusts: - print " " + name - sys.exit(0) - - if not locusts: - logger.error("No Locust class found!") - sys.exit(1) - - # make sure specified Locust exists - if arguments: - missing = set(arguments) - set(locusts.keys()) - if missing: - logger.error("Unknown Locust(s): %s\n" % (", ".join(missing))) - sys.exit(1) - else: - names = set(arguments) & set(locusts.keys()) - locust_classes = [locusts[n] for n in names] - else: - locust_classes = locusts.values() - - if options.show_task_ratio: - console_logger.info("\n Task ratio per locust class") - console_logger.info( "-" * 80) - print_task_ratio(locust_classes) - console_logger.info("\n Total task ratio") - console_logger.info("-" * 80) - print_task_ratio(locust_classes, total=True) - sys.exit(0) - if options.show_task_ratio_json: - from json import dumps - task_data = { - "per_class": get_task_ratio_dict(locust_classes), - "total": get_task_ratio_dict(locust_classes, total=True) - } - console_logger.info(dumps(task_data)) - sys.exit(0) - - # if --master is set, make sure --no-web isn't set - if options.master and options.no_web: - logger.error("Locust can not run distributed with the web interface disabled (do not use --no-web and --master together)") - sys.exit(0) - - if not options.no_web and not options.slave: - # spawn web greenlet - logger.info("Starting web monitor at %s:%s" % (options.web_host or "*", options.port)) - main_greenlet = gevent.spawn(web.start, locust_classes, options) - - if not options.master and not options.slave: - runners.locust_runner = LocalLocustRunner(locust_classes, options) - # spawn client spawning/hatching greenlet - if options.no_web: - runners.locust_runner.start_hatching(wait=True) - main_greenlet = runners.locust_runner.greenlet - elif options.master: - runners.locust_runner = MasterLocustRunner(locust_classes, options) - elif options.slave: - try: - runners.locust_runner = SlaveLocustRunner(locust_classes, options) - main_greenlet = runners.locust_runner.greenlet - except socket.error, e: - logger.error("Failed to connect to the Locust master: %s", e) - sys.exit(-1) - - if not options.only_summary and (options.print_stats or (options.no_web and not options.slave)): - # spawn stats printing greenlet - gevent.spawn(stats_printer) - - def shutdown(code=0): - """ - Shut down locust by firing quitting event, printing stats and exiting - """ - logger.info("Shutting down, bye..") - - events.quitting.fire() - print_stats(runners.locust_runner.request_stats) - print_percentile_stats(runners.locust_runner.request_stats) - - print_error_report() - sys.exit(code) - - # install SIGTERM handler - def sig_term_handler(): - logger.info("Got SIGTERM signal") - shutdown(0) - gevent.signal(signal.SIGTERM, sig_term_handler) - - try: - logger.info("Starting Locust %s" % version) - main_greenlet.join() - shutdown(0) - except KeyboardInterrupt as e: - shutdown(0) - -if __name__ == '__main__': - main() +import locust +import runners + +import gevent +import sys +import os +import signal +import inspect +import logging +import socket +from optparse import OptionParser + +import web +from log import setup_logging, console_logger +from stats import stats_printer, print_percentile_stats, print_error_report, print_stats +from inspectlocust import print_task_ratio, get_task_ratio_dict +from core import Locust, HttpLocust +from runners import MasterLocustRunner, SlaveLocustRunner, LocalLocustRunner +import events + +_internals = [Locust, HttpLocust] +version = locust.version + +def parse_options(): + """ + Handle command-line options with optparse.OptionParser. + + Return list of arguments, largely for use in `parse_arguments`. + """ + + # Initialize + parser = OptionParser(usage="locust [options] [LocustClass [LocustClass2 ... ]]") + + parser.add_option( + '-H', '--host', + dest="host", + default=None, + help="Host to load test in the following format: http://10.21.32.33" + ) + + parser.add_option( + '--web-host', + dest="web_host", + default="", + help="Host to bind the web interface to. Defaults to '' (all interfaces)" + ) + + parser.add_option( + '-P', '--port', '--web-port', + type="int", + dest="port", + default=8089, + help="Port on which to run web host" + ) + + parser.add_option( + '-f', '--locustfile', + dest='locustfile', + default='locustfile', + help="Python module file to import, e.g. '../other.py'. Default: locustfile" + ) + + # if locust should be run in distributed mode as master + parser.add_option( + '--master', + action='store_true', + dest='master', + default=False, + help="Set locust to run in distributed mode with this process as master" + ) + + # if locust should be run in distributed mode as slave + parser.add_option( + '--slave', + action='store_true', + dest='slave', + default=False, + help="Set locust to run in distributed mode with this process as slave" + ) + + # master host options + parser.add_option( + '--master-host', + action='store', + type='str', + dest='master_host', + default="127.0.0.1", + help="Host or IP address of locust master for distributed load testing. Only used when running with --slave. Defaults to 127.0.0.1." + ) + + parser.add_option( + '--master-port', + action='store', + type='int', + dest='master_port', + default=5557, + help="The port to connect to that is used by the locust master for distributed load testing. Only used when running with --slave. Defaults to 5557. Note that slaves will also connect to the master node on this port + 1." + ) + + parser.add_option( + '--master-bind-host', + action='store', + type='str', + dest='master_bind_host', + default="*", + help="Interfaces (hostname, ip) that locust master should bind to. Only used when running with --master. Defaults to * (all available interfaces)." + ) + + parser.add_option( + '--master-bind-port', + action='store', + type='int', + dest='master_bind_port', + default=5557, + help="Port that locust master should bind to. Only used when running with --master. Defaults to 5557. Note that Locust will also use this port + 1, so by default the master node will bind to 5557 and 5558." + ) + + # if we should print stats in the console + parser.add_option( + '--no-web', + action='store_true', + dest='no_web', + default=False, + help="Disable the web interface, and instead start running the test immediately. Requires -c and -r to be specified." + ) + + # Number of clients + parser.add_option( + '-c', '--clients', + action='store', + type='int', + dest='num_clients', + default=1, + help="Number of concurrent clients. Only used together with --no-web" + ) + + # Client hatch rate + parser.add_option( + '-r', '--hatch-rate', + action='store', + type='float', + dest='hatch_rate', + default=1, + help="The rate per second in which clients are spawned. Only used together with --no-web" + ) + + # Number of requests + parser.add_option( + '-n', '--num-request', + action='store', + type='int', + dest='num_requests', + default=None, + help="Number of requests to perform. Only used together with --no-web" + ) + + # log level + parser.add_option( + '--loglevel', '-L', + action='store', + type='str', + dest='loglevel', + default='INFO', + help="Choose between DEBUG/INFO/WARNING/ERROR/CRITICAL. Default is INFO.", + ) + + # log file + parser.add_option( + '--logfile', + action='store', + type='str', + dest='logfile', + default=None, + help="Path to log file. If not set, log will go to stdout/stderr", + ) + + # if we should print stats in the console + parser.add_option( + '--print-stats', + action='store_true', + dest='print_stats', + default=False, + help="Print stats in the console" + ) + + # only print summary stats + parser.add_option( + '--only-summary', + action='store_true', + dest='only_summary', + default=False, + help='Only print the summary stats' + ) + + # List locust commands found in loaded locust files/source files + parser.add_option( + '-l', '--list', + action='store_true', + dest='list_commands', + default=False, + help="Show list of possible locust classes and exit" + ) + + # Display ratio table of all tasks + parser.add_option( + '--show-task-ratio', + action='store_true', + dest='show_task_ratio', + default=False, + help="print table of the locust classes' task execution ratio" + ) + # Display ratio table of all tasks in JSON format + parser.add_option( + '--show-task-ratio-json', + action='store_true', + dest='show_task_ratio_json', + default=False, + help="print json data of the locust classes' task execution ratio" + ) + + # Version number (optparse gives you --version but we have to do it + # ourselves to get -V too. sigh) + parser.add_option( + '-V', '--version', + action='store_true', + dest='show_version', + default=False, + help="show program's version number and exit" + ) + + # Finalize + # Return three-tuple of parser + the output from parse_args (opt obj, args) + opts, args = parser.parse_args() + return parser, opts, args + + +def _is_package(path): + """ + Is the given path a Python package? + """ + return ( + os.path.isdir(path) + and os.path.exists(os.path.join(path, '__init__.py')) + ) + + +def find_locustfile(locustfile): + """ + Attempt to locate a locustfile, either explicitly or by searching parent dirs. + """ + # Obtain env value + names = [locustfile] + # Create .py version if necessary + if not names[0].endswith('.py'): + names += [names[0] + '.py'] + # Does the name contain path elements? + if os.path.dirname(names[0]): + # If so, expand home-directory markers and test for existence + for name in names: + expanded = os.path.expanduser(name) + if os.path.exists(expanded): + if name.endswith('.py') or _is_package(expanded): + return os.path.abspath(expanded) + else: + # Otherwise, start in cwd and work downwards towards filesystem root + path = '.' + # Stop before falling off root of filesystem (should be platform + # agnostic) + while os.path.split(os.path.abspath(path))[1]: + for name in names: + joined = os.path.join(path, name) + if os.path.exists(joined): + if name.endswith('.py') or _is_package(joined): + return os.path.abspath(joined) + path = os.path.join('..', path) + # Implicit 'return None' if nothing was found + + +def is_locust(tup): + """ + Takes (name, object) tuple, returns True if it's a public Locust subclass. + """ + name, item = tup + return ( + inspect.isclass(item) + and issubclass(item, Locust) + and (item not in _internals) + and not name.startswith('_') + ) + + +def load_locustfile(path): + """ + Import given locustfile path and return (docstring, callables). + + Specifically, the locustfile's ``__doc__`` attribute (a string) and a + dictionary of ``{'name': callable}`` containing all callables which pass + the "is a Locust" test. + """ + # Get directory and locustfile name + directory, locustfile = os.path.split(path) + # If the directory isn't in the PYTHONPATH, add it so our import will work + added_to_path = False + index = None + if directory not in sys.path: + sys.path.insert(0, directory) + added_to_path = True + # If the directory IS in the PYTHONPATH, move it to the front temporarily, + # otherwise other locustfiles -- like Locusts's own -- may scoop the intended + # one. + else: + i = sys.path.index(directory) + if i != 0: + # Store index for later restoration + index = i + # Add to front, then remove from original position + sys.path.insert(0, directory) + del sys.path[i + 1] + # Perform the import (trimming off the .py) + imported = __import__(os.path.splitext(locustfile)[0]) + # Remove directory from path if we added it ourselves (just to be neat) + if added_to_path: + del sys.path[0] + # Put back in original index if we moved it + if index is not None: + sys.path.insert(index + 1, directory) + del sys.path[0] + # Return our two-tuple + locusts = dict(filter(is_locust, vars(imported).items())) + return imported.__doc__, locusts + +def main(): + parser, options, arguments = parse_options() + + # setup logging + setup_logging(options.loglevel, options.logfile) + logger = logging.getLogger(__name__) + + if options.show_version: + print "Locust %s" % (version,) + sys.exit(0) + + locustfile = find_locustfile(options.locustfile) + if not locustfile: + logger.error("Could not find any locustfile! See --help for available options.") + sys.exit(1) + + docstring, locusts = load_locustfile(locustfile) + + if options.list_commands: + print "Available Locusts:" + for name in locusts: + print " " + name + sys.exit(0) + + if not locusts: + logger.error("No Locust class found!") + sys.exit(1) + + # make sure specified Locust exists + if arguments: + missing = set(arguments) - set(locusts.keys()) + if missing: + logger.error("Unknown Locust(s): %s\n" % (", ".join(missing))) + sys.exit(1) + else: + names = set(arguments) & set(locusts.keys()) + locust_classes = [locusts[n] for n in names] + else: + locust_classes = locusts.values() + + if options.show_task_ratio: + console_logger.info("\n Task ratio per locust class") + console_logger.info( "-" * 80) + print_task_ratio(locust_classes) + console_logger.info("\n Total task ratio") + console_logger.info("-" * 80) + print_task_ratio(locust_classes, total=True) + sys.exit(0) + if options.show_task_ratio_json: + from json import dumps + task_data = { + "per_class": get_task_ratio_dict(locust_classes), + "total": get_task_ratio_dict(locust_classes, total=True) + } + console_logger.info(dumps(task_data)) + sys.exit(0) + + # if --master is set, make sure --no-web isn't set + if options.master and options.no_web: + logger.error("Locust can not run distributed with the web interface disabled (do not use --no-web and --master together)") + sys.exit(0) + + if not options.no_web and not options.slave: + # spawn web greenlet + logger.info("Starting web monitor at %s:%s" % (options.web_host or "*", options.port)) + main_greenlet = gevent.spawn(web.start, locust_classes, options) + + if not options.master and not options.slave: + runners.locust_runner = LocalLocustRunner(locust_classes, options) + # spawn client spawning/hatching greenlet + if options.no_web: + runners.locust_runner.start_hatching(wait=True) + main_greenlet = runners.locust_runner.greenlet + elif options.master: + runners.locust_runner = MasterLocustRunner(locust_classes, options) + elif options.slave: + try: + runners.locust_runner = SlaveLocustRunner(locust_classes, options) + main_greenlet = runners.locust_runner.greenlet + except socket.error, e: + logger.error("Failed to connect to the Locust master: %s", e) + sys.exit(-1) + + if not options.only_summary and (options.print_stats or (options.no_web and not options.slave)): + # spawn stats printing greenlet + gevent.spawn(stats_printer) + + def shutdown(code=0): + """ + Shut down locust by firing quitting event, printing stats and exiting + """ + logger.info("Shutting down, bye..") + + events.quitting.fire() + print_stats(runners.locust_runner.request_stats) + print_percentile_stats(runners.locust_runner.request_stats) + + print_error_report() + sys.exit(code) + + # install SIGTERM handler + def sig_term_handler(): + logger.info("Got SIGTERM signal") + shutdown(0) + gevent.signal(signal.SIGTERM, sig_term_handler) + + try: + logger.info("Starting Locust %s" % version) + main_greenlet.join() + shutdown(0) + except KeyboardInterrupt as e: + shutdown(0) + +if __name__ == '__main__': + main() diff --git a/locust/rpc/socketrpc.py b/locust/rpc/socketrpc.py index 0b76e90169..d083c80942 100644 --- a/locust/rpc/socketrpc.py +++ b/locust/rpc/socketrpc.py @@ -1,121 +1,121 @@ -import struct -import logging - -import gevent -from gevent import socket -from gevent import queue - -from locust.exception import LocustError -from .protocol import Message - -logger = logging.getLogger(__name__) - -def _recv_bytes(sock, bytes): - data = "" - while bytes: - temp = sock.recv(bytes) - if not temp: - raise Exception("Connection reset by peer? Received so far: %r" % (data, )) - bytes -= len(temp) - data += temp - return data - -def _send_obj(sock, msg): - data = msg.serialize() - packed = struct.pack('!i', len(data)) + data - try: - sock.sendall(packed) - except Exception as e: - try: - sock.close() - except: - pass - finally: - raise LocustError("Slave has disconnected") - -def _recv_obj(sock): - d = _recv_bytes(sock, 4) - bytes, = struct.unpack('!i', d) - data = _recv_bytes(sock, bytes) - return Message.unserialize(data) - -class Client(object): - def __init__(self, host, port): - self.host = host - self.port = port - self.command_queue = gevent.queue.Queue() - self.socket = self._connect() - - def _connect(self): - sock = socket.create_connection((self.host, self.port)) - def handle(): - try: - while True: - self.command_queue.put_nowait(_recv_obj(sock)) - except Exception as e: - try: - sock.close() - except: - pass - - gevent.spawn(handle) - return sock - - def send(self, event): - _send_obj(self.socket, event) - - def recv(self): - return self.command_queue.get() - -class Server(object): - def __init__(self, host, port): - self.host = "0.0.0.0" if host == "*" else host - self.port = port - self.event_queue = gevent.queue.Queue() - self.command_dispatcher = self._listen() - - def send(self, msg): - self.command_dispatcher(msg) - - def recv(self): - return self.event_queue.get() - - def _listen(self): - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind((self.host, self.port)) - sock.listen(256) - self.slave_index = 0 - slaves = [] - - def dispatch_command(cmd): - - _send_obj(slaves[self.slave_index], cmd) - self.slave_index += 1 - if self.slave_index == len(slaves): - self.slave_index = 0 - - def handle_slave(sock): - try: - while True: - self.event_queue.put_nowait(_recv_obj(sock)) - except Exception as e: - logger.info("Slave disconnected") - slaves.remove(sock) - if self.slave_index == len(slaves) and len(slaves) > 0: - self.slave_index -= 1 - - try: - sock.close() - except: - pass - - def listener(): - while True: - _socket, _addr = sock.accept() - logger.info("Slave connected") - slaves.append(_socket) - gevent.spawn(lambda: handle_slave(_socket)) - - gevent.spawn(listener) - return dispatch_command +import struct +import logging + +import gevent +from gevent import socket +from gevent import queue + +from locust.exception import LocustError +from .protocol import Message + +logger = logging.getLogger(__name__) + +def _recv_bytes(sock, bytes): + data = "" + while bytes: + temp = sock.recv(bytes) + if not temp: + raise Exception("Connection reset by peer? Received so far: %r" % (data, )) + bytes -= len(temp) + data += temp + return data + +def _send_obj(sock, msg): + data = msg.serialize() + packed = struct.pack('!i', len(data)) + data + try: + sock.sendall(packed) + except Exception as e: + try: + sock.close() + except: + pass + finally: + raise LocustError("Slave has disconnected") + +def _recv_obj(sock): + d = _recv_bytes(sock, 4) + bytes, = struct.unpack('!i', d) + data = _recv_bytes(sock, bytes) + return Message.unserialize(data) + +class Client(object): + def __init__(self, host, port): + self.host = host + self.port = port + self.command_queue = gevent.queue.Queue() + self.socket = self._connect() + + def _connect(self): + sock = socket.create_connection((self.host, self.port)) + def handle(): + try: + while True: + self.command_queue.put_nowait(_recv_obj(sock)) + except Exception as e: + try: + sock.close() + except: + pass + + gevent.spawn(handle) + return sock + + def send(self, event): + _send_obj(self.socket, event) + + def recv(self): + return self.command_queue.get() + +class Server(object): + def __init__(self, host, port): + self.host = "0.0.0.0" if host == "*" else host + self.port = port + self.event_queue = gevent.queue.Queue() + self.command_dispatcher = self._listen() + + def send(self, msg): + self.command_dispatcher(msg) + + def recv(self): + return self.event_queue.get() + + def _listen(self): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((self.host, self.port)) + sock.listen(256) + self.slave_index = 0 + slaves = [] + + def dispatch_command(cmd): + + _send_obj(slaves[self.slave_index], cmd) + self.slave_index += 1 + if self.slave_index == len(slaves): + self.slave_index = 0 + + def handle_slave(sock): + try: + while True: + self.event_queue.put_nowait(_recv_obj(sock)) + except Exception as e: + logger.info("Slave disconnected") + slaves.remove(sock) + if self.slave_index == len(slaves) and len(slaves) > 0: + self.slave_index -= 1 + + try: + sock.close() + except: + pass + + def listener(): + while True: + _socket, _addr = sock.accept() + logger.info("Slave connected") + slaves.append(_socket) + gevent.spawn(lambda: handle_slave(_socket)) + + gevent.spawn(listener) + return dispatch_command diff --git a/locust/rpc/zmqrpc.py b/locust/rpc/zmqrpc.py index af9551a501..44a3bff13b 100644 --- a/locust/rpc/zmqrpc.py +++ b/locust/rpc/zmqrpc.py @@ -1,31 +1,31 @@ -import zmq.green as zmq -from .protocol import Message - -class BaseSocket(object): - - def send(self, msg): - self.sender.send(msg.serialize()) - - def recv(self): - data = self.receiver.recv() - return Message.unserialize(data) - - -class Server(BaseSocket): - def __init__(self, host, port): - context = zmq.Context() - self.receiver = context.socket(zmq.PULL) - self.receiver.bind("tcp://%s:%i" % (host, port)) - - self.sender = context.socket(zmq.PUSH) - self.sender.bind("tcp://%s:%i" % (host, port+1)) - - -class Client(BaseSocket): - def __init__(self, host, port): - context = zmq.Context() - self.receiver = context.socket(zmq.PULL) - self.receiver.connect("tcp://%s:%i" % (host, port+1)) - - self.sender = context.socket(zmq.PUSH) - self.sender.connect("tcp://%s:%i" % (host, port)) +import zmq.green as zmq +from .protocol import Message + +class BaseSocket(object): + + def send(self, msg): + self.sender.send(msg.serialize()) + + def recv(self): + data = self.receiver.recv() + return Message.unserialize(data) + + +class Server(BaseSocket): + def __init__(self, host, port): + context = zmq.Context() + self.receiver = context.socket(zmq.PULL) + self.receiver.bind("tcp://%s:%i" % (host, port)) + + self.sender = context.socket(zmq.PUSH) + self.sender.bind("tcp://%s:%i" % (host, port+1)) + + +class Client(BaseSocket): + def __init__(self, host, port): + context = zmq.Context() + self.receiver = context.socket(zmq.PULL) + self.receiver.connect("tcp://%s:%i" % (host, port+1)) + + self.sender = context.socket(zmq.PUSH) + self.sender.connect("tcp://%s:%i" % (host, port)) diff --git a/locust/static/locust.js b/locust/static/locust.js index 921000c866..a93f9e258a 100644 --- a/locust/static/locust.js +++ b/locust/static/locust.js @@ -1,146 +1,146 @@ -$(window).ready(function() { - if($("#locust_count").length > 0) { - $("#locust_count").focus().select(); - } -}); - -$("#box_stop a").click(function(event) { - event.preventDefault(); - $.get($(this).attr("href")); - $("body").attr("class", "stopped"); - $(".box_stop").hide(); - $("a.new_test").show(); - $("a.edit_test").hide(); - $(".user_count").hide(); -}); - -$("#box_reset a").click(function(event) { - event.preventDefault(); - $.get($(this).attr("href")); -}); - -$("#new_test").click(function(event) { - event.preventDefault(); - $("#start").show(); - $("#locust_count").focus().select(); -}); - -$(".edit_test").click(function(event) { - event.preventDefault(); - $("#edit").show(); - $("#new_locust_count").focus().select(); -}); - -$(".close_link").click(function(event) { - event.preventDefault(); - $(this).parent().parent().hide(); -}); - -var alternate = false; - -$("ul.tabs").tabs("div.panes > div"); - -var stats_tpl = $('#stats-template'); -var errors_tpl = $('#errors-template'); -var exceptions_tpl = $('#exceptions-template'); - -$('#swarm_form').submit(function(event) { - event.preventDefault(); - $.post($(this).attr("action"), $(this).serialize(), - function(response) { - if (response.success) { - $("body").attr("class", "hatching"); - $("#start").fadeOut(); - $("#status").fadeIn(); - $(".box_running").fadeIn(); - $("a.new_test").fadeOut(); - $("a.edit_test").fadeIn(); - $(".user_count").fadeIn(); - } - } - ); -}); - -$('#edit_form').submit(function(event) { - event.preventDefault(); - $.post($(this).attr("action"), $(this).serialize(), - function(response) { - if (response.success) { - $("body").attr("class", "hatching"); - $("#edit").fadeOut(); - } - } - ); -}); - -var sortBy = function(field, reverse, primer){ - reverse = (reverse) ? -1 : 1; - return function(a,b){ - a = a[field]; - b = b[field]; - if (typeof(primer) != 'undefined'){ - a = primer(a); - b = primer(b); - } - if (ab) return reverse * 1; - return 0; - } -} - -// Sorting by column -var sortAttribute = "name"; -var desc = false; -var report; -$(".stats_label").click(function(event) { - event.preventDefault(); - sortAttribute = $(this).attr("data-sortkey"); - desc = !desc; - - $('#stats tbody').empty(); - $('#errors tbody').empty(); - alternate = false; - totalRow = report.stats.pop() - sortedStats = (report.stats).sort(sortBy(sortAttribute, desc)) - sortedStats.push(totalRow) - $('#stats tbody').jqoteapp(stats_tpl, sortedStats); - alternate = false; - $('#errors tbody').jqoteapp(errors_tpl, (report.errors).sort(sortBy(sortAttribute, desc))); -}); - -function updateStats() { - $.get('/stats/requests', function (data) { - report = JSON.parse(data); - $("#total_rps").html(Math.round(report.total_rps*100)/100); - //$("#fail_ratio").html(Math.round(report.fail_ratio*10000)/100); - $("#fail_ratio").html(Math.round(report.fail_ratio*100)); - $("#status_text").html(report.state); - $("#userCount").html(report.user_count); - - if (report.slave_count) - $("#slaveCount").html(report.slave_count) - - $('#stats tbody').empty(); - $('#errors tbody').empty(); - - alternate = false; - - totalRow = report.stats.pop() - sortedStats = (report.stats).sort(sortBy(sortAttribute, desc)) - sortedStats.push(totalRow) - $('#stats tbody').jqoteapp(stats_tpl, sortedStats); - alternate = false; - $('#errors tbody').jqoteapp(errors_tpl, (report.errors).sort(sortBy(sortAttribute, desc))); - setTimeout(updateStats, 2000); - }); -} -updateStats(); - -function updateExceptions() { - $.get('/exceptions', function (data) { - $('#exceptions tbody').empty(); - $('#exceptions tbody').jqoteapp(exceptions_tpl, data.exceptions); - setTimeout(updateExceptions, 5000); - }); -} +$(window).ready(function() { + if($("#locust_count").length > 0) { + $("#locust_count").focus().select(); + } +}); + +$("#box_stop a").click(function(event) { + event.preventDefault(); + $.get($(this).attr("href")); + $("body").attr("class", "stopped"); + $(".box_stop").hide(); + $("a.new_test").show(); + $("a.edit_test").hide(); + $(".user_count").hide(); +}); + +$("#box_reset a").click(function(event) { + event.preventDefault(); + $.get($(this).attr("href")); +}); + +$("#new_test").click(function(event) { + event.preventDefault(); + $("#start").show(); + $("#locust_count").focus().select(); +}); + +$(".edit_test").click(function(event) { + event.preventDefault(); + $("#edit").show(); + $("#new_locust_count").focus().select(); +}); + +$(".close_link").click(function(event) { + event.preventDefault(); + $(this).parent().parent().hide(); +}); + +var alternate = false; + +$("ul.tabs").tabs("div.panes > div"); + +var stats_tpl = $('#stats-template'); +var errors_tpl = $('#errors-template'); +var exceptions_tpl = $('#exceptions-template'); + +$('#swarm_form').submit(function(event) { + event.preventDefault(); + $.post($(this).attr("action"), $(this).serialize(), + function(response) { + if (response.success) { + $("body").attr("class", "hatching"); + $("#start").fadeOut(); + $("#status").fadeIn(); + $(".box_running").fadeIn(); + $("a.new_test").fadeOut(); + $("a.edit_test").fadeIn(); + $(".user_count").fadeIn(); + } + } + ); +}); + +$('#edit_form').submit(function(event) { + event.preventDefault(); + $.post($(this).attr("action"), $(this).serialize(), + function(response) { + if (response.success) { + $("body").attr("class", "hatching"); + $("#edit").fadeOut(); + } + } + ); +}); + +var sortBy = function(field, reverse, primer){ + reverse = (reverse) ? -1 : 1; + return function(a,b){ + a = a[field]; + b = b[field]; + if (typeof(primer) != 'undefined'){ + a = primer(a); + b = primer(b); + } + if (ab) return reverse * 1; + return 0; + } +} + +// Sorting by column +var sortAttribute = "name"; +var desc = false; +var report; +$(".stats_label").click(function(event) { + event.preventDefault(); + sortAttribute = $(this).attr("data-sortkey"); + desc = !desc; + + $('#stats tbody').empty(); + $('#errors tbody').empty(); + alternate = false; + totalRow = report.stats.pop() + sortedStats = (report.stats).sort(sortBy(sortAttribute, desc)) + sortedStats.push(totalRow) + $('#stats tbody').jqoteapp(stats_tpl, sortedStats); + alternate = false; + $('#errors tbody').jqoteapp(errors_tpl, (report.errors).sort(sortBy(sortAttribute, desc))); +}); + +function updateStats() { + $.get('/stats/requests', function (data) { + report = JSON.parse(data); + $("#total_rps").html(Math.round(report.total_rps*100)/100); + //$("#fail_ratio").html(Math.round(report.fail_ratio*10000)/100); + $("#fail_ratio").html(Math.round(report.fail_ratio*100)); + $("#status_text").html(report.state); + $("#userCount").html(report.user_count); + + if (report.slave_count) + $("#slaveCount").html(report.slave_count) + + $('#stats tbody').empty(); + $('#errors tbody').empty(); + + alternate = false; + + totalRow = report.stats.pop() + sortedStats = (report.stats).sort(sortBy(sortAttribute, desc)) + sortedStats.push(totalRow) + $('#stats tbody').jqoteapp(stats_tpl, sortedStats); + alternate = false; + $('#errors tbody').jqoteapp(errors_tpl, (report.errors).sort(sortBy(sortAttribute, desc))); + setTimeout(updateStats, 2000); + }); +} +updateStats(); + +function updateExceptions() { + $.get('/exceptions', function (data) { + $('#exceptions tbody').empty(); + $('#exceptions tbody').jqoteapp(exceptions_tpl, data.exceptions); + setTimeout(updateExceptions, 5000); + }); +} updateExceptions(); \ No newline at end of file diff --git a/locust/test/test_stats.py b/locust/test/test_stats.py index a8915d03fb..fdfe23f209 100644 --- a/locust/test/test_stats.py +++ b/locust/test/test_stats.py @@ -1,227 +1,227 @@ -import unittest -import time - -from requests.exceptions import RequestException - -from testcases import WebserverTestCase -from locust.stats import RequestStats, StatsEntry, global_stats -from locust.core import HttpLocust, Locust, TaskSet, task -from locust.inspectlocust import get_task_ratio_dict -from locust.rpc.protocol import Message - -class TestRequestStats(unittest.TestCase): - def setUp(self): - self.stats = RequestStats() - self.stats.start_time = time.time() - self.s = StatsEntry(self.stats, "test_entry", "GET") - self.s.log(45, 0) - self.s.log(135, 0) - self.s.log(44, 0) - self.s.log_error(Exception("dummy fail")) - self.s.log_error(Exception("dummy fail")) - self.s.log(375, 0) - self.s.log(601, 0) - self.s.log(35, 0) - self.s.log(79, 0) - self.s.log_error(Exception("dummy fail")) - - def test_percentile(self): - s = StatsEntry(self.stats, "percentile_test", "GET") - for x in xrange(100): - s.log(x, 0) - - self.assertEqual(s.get_response_time_percentile(0.5), 50) - self.assertEqual(s.get_response_time_percentile(0.6), 60) - self.assertEqual(s.get_response_time_percentile(0.95), 95) - - def test_median(self): - self.assertEqual(self.s.median_response_time, 79) - - def test_total_rps(self): - self.assertEqual(self.s.total_rps, 7) - - def test_current_rps(self): - self.stats.last_request_timestamp = int(time.time()) + 4 - self.assertEqual(self.s.current_rps, 3.5) - - self.stats.last_request_timestamp = int(time.time()) + 25 - self.assertEqual(self.s.current_rps, 0) - - def test_num_reqs_fails(self): - self.assertEqual(self.s.num_requests, 7) - self.assertEqual(self.s.num_failures, 3) - - def test_avg(self): - self.assertEqual(self.s.avg_response_time, 187.71428571428571428571428571429) - - def test_reset(self): - self.s.reset() - self.s.log(756, 0) - self.s.log_error(Exception("dummy fail after reset")) - self.s.log(85, 0) - - self.assertEqual(self.s.total_rps, 2) - self.assertEqual(self.s.num_requests, 2) - self.assertEqual(self.s.num_failures, 1) - self.assertEqual(self.s.avg_response_time, 420.5) - self.assertEqual(self.s.median_response_time, 85) - - def test_aggregation(self): - s1 = StatsEntry(self.stats, "aggregate me!", "GET") - s1.log(12, 0) - s1.log(12, 0) - s1.log(38, 0) - s1.log_error("Dummy exzeption") - - s2 = StatsEntry(self.stats, "aggregate me!", "GET") - s2.log_error("Dummy exzeption") - s2.log_error("Dummy exzeption") - s2.log(12, 0) - s2.log(99, 0) - s2.log(14, 0) - s2.log(55, 0) - s2.log(38, 0) - s2.log(55, 0) - s2.log(97, 0) - - s = StatsEntry(self.stats, "GET", "") - s.extend(s1, full_request_history=True) - s.extend(s2, full_request_history=True) - - self.assertEqual(s.num_requests, 10) - self.assertEqual(s.num_failures, 3) - self.assertEqual(s.median_response_time, 38) - self.assertEqual(s.avg_response_time, 43.2) - - def test_serialize_through_message(self): - """ - Serialize a RequestStats instance, then serialize it through a Message, - and unserialize the whole thing again. This is done "IRL" when stats are sent - from slaves to master. - """ - s1 = StatsEntry(self.stats, "test", "GET") - s1.log(10, 0) - s1.log(20, 0) - s1.log(40, 0) - u1 = StatsEntry.unserialize(s1.serialize()) - - data = Message.unserialize(Message("dummy", s1.serialize(), "none").serialize()).data - u1 = StatsEntry.unserialize(data) - - self.assertEqual(20, u1.median_response_time) - - -class TestRequestStatsWithWebserver(WebserverTestCase): - def test_request_stats_content_length(self): - class MyLocust(HttpLocust): - host = "http://127.0.0.1:%i" % self.port - - locust = MyLocust() - locust.client.get("/ultra_fast") - self.assertEqual(global_stats.get("/ultra_fast", "GET").avg_content_length, len("This is an ultra fast response")) - locust.client.get("/ultra_fast") - self.assertEqual(global_stats.get("/ultra_fast", "GET").avg_content_length, len("This is an ultra fast response")) - - def test_request_stats_no_content_length(self): - class MyLocust(HttpLocust): - host = "http://127.0.0.1:%i" % self.port - l = MyLocust() - path = "/no_content_length" - r = l.client.get(path) - self.assertEqual(global_stats.get(path, "GET").avg_content_length, len("This response does not have content-length in the header")) - +import unittest +import time + +from requests.exceptions import RequestException + +from testcases import WebserverTestCase +from locust.stats import RequestStats, StatsEntry, global_stats +from locust.core import HttpLocust, Locust, TaskSet, task +from locust.inspectlocust import get_task_ratio_dict +from locust.rpc.protocol import Message + +class TestRequestStats(unittest.TestCase): + def setUp(self): + self.stats = RequestStats() + self.stats.start_time = time.time() + self.s = StatsEntry(self.stats, "test_entry", "GET") + self.s.log(45, 0) + self.s.log(135, 0) + self.s.log(44, 0) + self.s.log_error(Exception("dummy fail")) + self.s.log_error(Exception("dummy fail")) + self.s.log(375, 0) + self.s.log(601, 0) + self.s.log(35, 0) + self.s.log(79, 0) + self.s.log_error(Exception("dummy fail")) + + def test_percentile(self): + s = StatsEntry(self.stats, "percentile_test", "GET") + for x in xrange(100): + s.log(x, 0) + + self.assertEqual(s.get_response_time_percentile(0.5), 50) + self.assertEqual(s.get_response_time_percentile(0.6), 60) + self.assertEqual(s.get_response_time_percentile(0.95), 95) + + def test_median(self): + self.assertEqual(self.s.median_response_time, 79) + + def test_total_rps(self): + self.assertEqual(self.s.total_rps, 7) + + def test_current_rps(self): + self.stats.last_request_timestamp = int(time.time()) + 4 + self.assertEqual(self.s.current_rps, 3.5) + + self.stats.last_request_timestamp = int(time.time()) + 25 + self.assertEqual(self.s.current_rps, 0) + + def test_num_reqs_fails(self): + self.assertEqual(self.s.num_requests, 7) + self.assertEqual(self.s.num_failures, 3) + + def test_avg(self): + self.assertEqual(self.s.avg_response_time, 187.71428571428571428571428571429) + + def test_reset(self): + self.s.reset() + self.s.log(756, 0) + self.s.log_error(Exception("dummy fail after reset")) + self.s.log(85, 0) + + self.assertEqual(self.s.total_rps, 2) + self.assertEqual(self.s.num_requests, 2) + self.assertEqual(self.s.num_failures, 1) + self.assertEqual(self.s.avg_response_time, 420.5) + self.assertEqual(self.s.median_response_time, 85) + + def test_aggregation(self): + s1 = StatsEntry(self.stats, "aggregate me!", "GET") + s1.log(12, 0) + s1.log(12, 0) + s1.log(38, 0) + s1.log_error("Dummy exzeption") + + s2 = StatsEntry(self.stats, "aggregate me!", "GET") + s2.log_error("Dummy exzeption") + s2.log_error("Dummy exzeption") + s2.log(12, 0) + s2.log(99, 0) + s2.log(14, 0) + s2.log(55, 0) + s2.log(38, 0) + s2.log(55, 0) + s2.log(97, 0) + + s = StatsEntry(self.stats, "GET", "") + s.extend(s1, full_request_history=True) + s.extend(s2, full_request_history=True) + + self.assertEqual(s.num_requests, 10) + self.assertEqual(s.num_failures, 3) + self.assertEqual(s.median_response_time, 38) + self.assertEqual(s.avg_response_time, 43.2) + + def test_serialize_through_message(self): + """ + Serialize a RequestStats instance, then serialize it through a Message, + and unserialize the whole thing again. This is done "IRL" when stats are sent + from slaves to master. + """ + s1 = StatsEntry(self.stats, "test", "GET") + s1.log(10, 0) + s1.log(20, 0) + s1.log(40, 0) + u1 = StatsEntry.unserialize(s1.serialize()) + + data = Message.unserialize(Message("dummy", s1.serialize(), "none").serialize()).data + u1 = StatsEntry.unserialize(data) + + self.assertEqual(20, u1.median_response_time) + + +class TestRequestStatsWithWebserver(WebserverTestCase): + def test_request_stats_content_length(self): + class MyLocust(HttpLocust): + host = "http://127.0.0.1:%i" % self.port + + locust = MyLocust() + locust.client.get("/ultra_fast") + self.assertEqual(global_stats.get("/ultra_fast", "GET").avg_content_length, len("This is an ultra fast response")) + locust.client.get("/ultra_fast") + self.assertEqual(global_stats.get("/ultra_fast", "GET").avg_content_length, len("This is an ultra fast response")) + + def test_request_stats_no_content_length(self): + class MyLocust(HttpLocust): + host = "http://127.0.0.1:%i" % self.port + l = MyLocust() + path = "/no_content_length" + r = l.client.get(path) + self.assertEqual(global_stats.get(path, "GET").avg_content_length, len("This response does not have content-length in the header")) + def test_request_stats_no_content_length_streaming(self): - class MyLocust(HttpLocust): - host = "http://127.0.0.1:%i" % self.port - l = MyLocust() - path = "/no_content_length" + class MyLocust(HttpLocust): + host = "http://127.0.0.1:%i" % self.port + l = MyLocust() + path = "/no_content_length" r = l.client.get(path, stream=True) - self.assertEqual(0, global_stats.get(path, "GET").avg_content_length) - - def test_request_stats_named_endpoint(self): - class MyLocust(HttpLocust): - host = "http://127.0.0.1:%i" % self.port - - locust = MyLocust() - locust.client.get("/ultra_fast", name="my_custom_name") - self.assertEqual(1, global_stats.get("my_custom_name", "GET").num_requests) - - def test_request_stats_query_variables(self): - class MyLocust(HttpLocust): - host = "http://127.0.0.1:%i" % self.port - - locust = MyLocust() - locust.client.get("/ultra_fast?query=1") - self.assertEqual(1, global_stats.get("/ultra_fast?query=1", "GET").num_requests) - - def test_request_connection_error(self): - class MyLocust(HttpLocust): - host = "http://localhost:1" - - locust = MyLocust() - response = locust.client.get("/", timeout=0.1) + self.assertEqual(0, global_stats.get(path, "GET").avg_content_length) + + def test_request_stats_named_endpoint(self): + class MyLocust(HttpLocust): + host = "http://127.0.0.1:%i" % self.port + + locust = MyLocust() + locust.client.get("/ultra_fast", name="my_custom_name") + self.assertEqual(1, global_stats.get("my_custom_name", "GET").num_requests) + + def test_request_stats_query_variables(self): + class MyLocust(HttpLocust): + host = "http://127.0.0.1:%i" % self.port + + locust = MyLocust() + locust.client.get("/ultra_fast?query=1") + self.assertEqual(1, global_stats.get("/ultra_fast?query=1", "GET").num_requests) + + def test_request_connection_error(self): + class MyLocust(HttpLocust): + host = "http://localhost:1" + + locust = MyLocust() + response = locust.client.get("/", timeout=0.1) self.assertEqual(response.status_code, 0) - self.assertEqual(1, global_stats.get("/", "GET").num_failures) - self.assertEqual(0, global_stats.get("/", "GET").num_requests) - - def test_max_requests(self): - class MyTaskSet(TaskSet): - @task - def my_task(self): - self.client.get("/ultra_fast") - class MyLocust(HttpLocust): - host = "http://127.0.0.1:%i" % self.port - task_set = MyTaskSet - min_wait = 1 - max_wait = 1 - - try: - from locust.exception import StopLocust - global_stats.clear_all() - global_stats.max_requests = 2 - - l = MyLocust() - self.assertRaises(StopLocust, lambda: l.task_set(l).run()) - self.assertEqual(2, global_stats.num_requests) - - global_stats.clear_all() - global_stats.max_requests = 2 - self.assertEqual(0, global_stats.num_requests) - - l.run() - self.assertEqual(2, global_stats.num_requests) - finally: - global_stats.clear_all() - global_stats.max_requests = None - - -class MyTaskSet(TaskSet): - @task(75) - def root_task(self): - pass - - @task(25) - class MySubTaskSet(TaskSet): - @task - def task1(self): - pass - @task - def task2(self): - pass - -class TestInspectLocust(unittest.TestCase): - def test_get_task_ratio_dict_relative(self): - ratio = get_task_ratio_dict([MyTaskSet]) - self.assertEqual(1.0, ratio["MyTaskSet"]["ratio"]) - self.assertEqual(0.75, ratio["MyTaskSet"]["tasks"]["root_task"]["ratio"]) - self.assertEqual(0.25, ratio["MyTaskSet"]["tasks"]["MySubTaskSet"]["ratio"]) - self.assertEqual(0.5, ratio["MyTaskSet"]["tasks"]["MySubTaskSet"]["tasks"]["task1"]["ratio"]) - self.assertEqual(0.5, ratio["MyTaskSet"]["tasks"]["MySubTaskSet"]["tasks"]["task2"]["ratio"]) - - def test_get_task_ratio_dict_total(self): - ratio = get_task_ratio_dict([MyTaskSet], total=True) - self.assertEqual(1.0, ratio["MyTaskSet"]["ratio"]) - self.assertEqual(0.75, ratio["MyTaskSet"]["tasks"]["root_task"]["ratio"]) - self.assertEqual(0.25, ratio["MyTaskSet"]["tasks"]["MySubTaskSet"]["ratio"]) - self.assertEqual(0.125, ratio["MyTaskSet"]["tasks"]["MySubTaskSet"]["tasks"]["task1"]["ratio"]) - self.assertEqual(0.125, ratio["MyTaskSet"]["tasks"]["MySubTaskSet"]["tasks"]["task2"]["ratio"]) + self.assertEqual(1, global_stats.get("/", "GET").num_failures) + self.assertEqual(0, global_stats.get("/", "GET").num_requests) + + def test_max_requests(self): + class MyTaskSet(TaskSet): + @task + def my_task(self): + self.client.get("/ultra_fast") + class MyLocust(HttpLocust): + host = "http://127.0.0.1:%i" % self.port + task_set = MyTaskSet + min_wait = 1 + max_wait = 1 + + try: + from locust.exception import StopLocust + global_stats.clear_all() + global_stats.max_requests = 2 + + l = MyLocust() + self.assertRaises(StopLocust, lambda: l.task_set(l).run()) + self.assertEqual(2, global_stats.num_requests) + + global_stats.clear_all() + global_stats.max_requests = 2 + self.assertEqual(0, global_stats.num_requests) + + l.run() + self.assertEqual(2, global_stats.num_requests) + finally: + global_stats.clear_all() + global_stats.max_requests = None + + +class MyTaskSet(TaskSet): + @task(75) + def root_task(self): + pass + + @task(25) + class MySubTaskSet(TaskSet): + @task + def task1(self): + pass + @task + def task2(self): + pass + +class TestInspectLocust(unittest.TestCase): + def test_get_task_ratio_dict_relative(self): + ratio = get_task_ratio_dict([MyTaskSet]) + self.assertEqual(1.0, ratio["MyTaskSet"]["ratio"]) + self.assertEqual(0.75, ratio["MyTaskSet"]["tasks"]["root_task"]["ratio"]) + self.assertEqual(0.25, ratio["MyTaskSet"]["tasks"]["MySubTaskSet"]["ratio"]) + self.assertEqual(0.5, ratio["MyTaskSet"]["tasks"]["MySubTaskSet"]["tasks"]["task1"]["ratio"]) + self.assertEqual(0.5, ratio["MyTaskSet"]["tasks"]["MySubTaskSet"]["tasks"]["task2"]["ratio"]) + + def test_get_task_ratio_dict_total(self): + ratio = get_task_ratio_dict([MyTaskSet], total=True) + self.assertEqual(1.0, ratio["MyTaskSet"]["ratio"]) + self.assertEqual(0.75, ratio["MyTaskSet"]["tasks"]["root_task"]["ratio"]) + self.assertEqual(0.25, ratio["MyTaskSet"]["tasks"]["MySubTaskSet"]["ratio"]) + self.assertEqual(0.125, ratio["MyTaskSet"]["tasks"]["MySubTaskSet"]["tasks"]["task1"]["ratio"]) + self.assertEqual(0.125, ratio["MyTaskSet"]["tasks"]["MySubTaskSet"]["tasks"]["task2"]["ratio"]) diff --git a/locust/web.py b/locust/web.py index 6391869068..2689718502 100644 --- a/locust/web.py +++ b/locust/web.py @@ -1,197 +1,197 @@ -# encoding: utf-8 - -import json -import os.path -from time import time -from itertools import chain -from collections import defaultdict - -from gevent import wsgi -from flask import Flask, make_response, request, render_template - -import runners -from runners import MasterLocustRunner -from locust.stats import median_from_dict -from locust import version -import gevent - -import logging -logger = logging.getLogger(__name__) - -DEFAULT_CACHE_TIME = 2.0 - -app = Flask(__name__) -app.debug = True -app.root_path = os.path.dirname(os.path.abspath(__file__)) - -_request_stats_context_cache = {} - -@app.route('/') -def index(): - is_distributed = isinstance(runners.locust_runner, MasterLocustRunner) - if is_distributed: - slave_count = runners.locust_runner.slave_count - else: - slave_count = 0 - - return render_template("index.html", - state=runners.locust_runner.state, - is_distributed=is_distributed, - slave_count=slave_count, - user_count=runners.locust_runner.user_count, - version=version - ) - -@app.route('/swarm', methods=["POST"]) -def swarm(): - assert request.method == "POST" - - locust_count = int(request.form["locust_count"]) - hatch_rate = float(request.form["hatch_rate"]) - runners.locust_runner.start_hatching(locust_count, hatch_rate) - response = make_response(json.dumps({'success':True, 'message': 'Swarming started'})) - response.headers["Content-type"] = "application/json" - return response - -@app.route('/stop') -def stop(): - runners.locust_runner.stop() - response = make_response(json.dumps({'success':True, 'message': 'Test stopped'})) - response.headers["Content-type"] = "application/json" - return response - -@app.route("/stats/reset") -def reset_stats(): - runners.locust_runner.stats.reset_all() - return "ok" - -@app.route("/stats/requests/csv") -def request_stats_csv(): - rows = [ - ",".join([ - '"Method"', - '"Name"', - '"# requests"', - '"# failures"', - '"Median response time"', - '"Average response time"', - '"Min response time"', - '"Max response time"', - '"Average Content Size"', - '"Requests/s"', - ]) - ] - - for s in chain(_sort_stats(runners.locust_runner.request_stats), [runners.locust_runner.stats.aggregated_stats("Total", full_request_history=True)]): - rows.append('"%s","%s",%i,%i,%i,%i,%i,%i,%i,%.2f' % ( - s.method, - s.name, - s.num_requests, - s.num_failures, - s.median_response_time, - s.avg_response_time, - s.min_response_time or 0, - s.max_response_time, - s.avg_content_length, - s.total_rps, - )) - - response = make_response("\n".join(rows)) - file_name = "requests_{0}.csv".format(time()) - disposition = "attachment;filename={0}".format(file_name) - response.headers["Content-type"] = "text/csv" - response.headers["Content-disposition"] = disposition - return response - -@app.route("/stats/distribution/csv") -def distribution_stats_csv(): - rows = [",".join(( - '"Name"', - '"# requests"', - '"50%"', - '"66%"', - '"75%"', - '"80%"', - '"90%"', - '"95%"', - '"98%"', - '"99%"', - '"100%"', - ))] - for s in chain(_sort_stats(runners.locust_runner.request_stats), [runners.locust_runner.stats.aggregated_stats("Total", full_request_history=True)]): - if s.num_requests: - rows.append(s.percentile(tpl='"%s",%i,%i,%i,%i,%i,%i,%i,%i,%i,%i')) - else: - rows.append('"%s",0,"N/A","N/A","N/A","N/A","N/A","N/A","N/A","N/A","N/A"' % s.name) - - response = make_response("\n".join(rows)) - file_name = "distribution_{0}.csv".format(time()) - disposition = "attachment;filename={0}".format(file_name) - response.headers["Content-type"] = "text/csv" - response.headers["Content-disposition"] = disposition - return response - -@app.route('/stats/requests') -def request_stats(): - global _request_stats_context_cache - - if not _request_stats_context_cache or _request_stats_context_cache["last_time"] < time() - _request_stats_context_cache.get("cache_time", DEFAULT_CACHE_TIME): - cache_time = _request_stats_context_cache.get("cache_time", DEFAULT_CACHE_TIME) - now = time() - - stats = [] - for s in chain(_sort_stats(runners.locust_runner.request_stats), [runners.locust_runner.stats.aggregated_stats("Total")]): - stats.append({ - "method": s.method, - "name": s.name, - "num_requests": s.num_requests, - "num_failures": s.num_failures, - "avg_response_time": s.avg_response_time, - "min_response_time": s.min_response_time, - "max_response_time": s.max_response_time, - "current_rps": s.current_rps, - "median_response_time": s.median_response_time, - "avg_content_length": s.avg_content_length, - }) - - report = {"stats":stats, "errors":[e.to_dict() for e in runners.locust_runner.errors.itervalues()]} - if stats: - report["total_rps"] = stats[len(stats)-1]["current_rps"] - report["fail_ratio"] = runners.locust_runner.stats.aggregated_stats("Total").fail_ratio - - # since generating a total response times dict with all response times from all - # urls is slow, we make a new total response time dict which will consist of one - # entry per url with the median response time as key and the number of requests as - # value - response_times = defaultdict(int) # used for calculating total median - for i in xrange(len(stats)-1): - response_times[stats[i]["median_response_time"]] += stats[i]["num_requests"] - - # calculate total median - stats[len(stats)-1]["median_response_time"] = median_from_dict(stats[len(stats)-1]["num_requests"], response_times) - - is_distributed = isinstance(runners.locust_runner, MasterLocustRunner) - if is_distributed: - report["slave_count"] = runners.locust_runner.slave_count - - report["state"] = runners.locust_runner.state - report["user_count"] = runners.locust_runner.user_count - - elapsed = time() - now - cache_time = max(cache_time, elapsed * 2.0) # Increase cache_time when report generating starts to take longer time - _request_stats_context_cache = {"last_time": elapsed - now, "report": report, "cache_time": cache_time} - else: - report = _request_stats_context_cache["report"] - return json.dumps(report) - -@app.route("/exceptions") -def exceptions(): - response = make_response(json.dumps({'exceptions': [{"count": row["count"], "msg": row["msg"], "traceback": row["traceback"], "nodes" : ", ".join(row["nodes"])} for row in runners.locust_runner.exceptions.itervalues()]})) - response.headers["Content-type"] = "application/json" - return response - -def start(locust, options): - wsgi.WSGIServer((options.web_host, options.port), app, log=None).serve_forever() - -def _sort_stats(stats): - return [stats[key] for key in sorted(stats.iterkeys())] +# encoding: utf-8 + +import json +import os.path +from time import time +from itertools import chain +from collections import defaultdict + +from gevent import wsgi +from flask import Flask, make_response, request, render_template + +import runners +from runners import MasterLocustRunner +from locust.stats import median_from_dict +from locust import version +import gevent + +import logging +logger = logging.getLogger(__name__) + +DEFAULT_CACHE_TIME = 2.0 + +app = Flask(__name__) +app.debug = True +app.root_path = os.path.dirname(os.path.abspath(__file__)) + +_request_stats_context_cache = {} + +@app.route('/') +def index(): + is_distributed = isinstance(runners.locust_runner, MasterLocustRunner) + if is_distributed: + slave_count = runners.locust_runner.slave_count + else: + slave_count = 0 + + return render_template("index.html", + state=runners.locust_runner.state, + is_distributed=is_distributed, + slave_count=slave_count, + user_count=runners.locust_runner.user_count, + version=version + ) + +@app.route('/swarm', methods=["POST"]) +def swarm(): + assert request.method == "POST" + + locust_count = int(request.form["locust_count"]) + hatch_rate = float(request.form["hatch_rate"]) + runners.locust_runner.start_hatching(locust_count, hatch_rate) + response = make_response(json.dumps({'success':True, 'message': 'Swarming started'})) + response.headers["Content-type"] = "application/json" + return response + +@app.route('/stop') +def stop(): + runners.locust_runner.stop() + response = make_response(json.dumps({'success':True, 'message': 'Test stopped'})) + response.headers["Content-type"] = "application/json" + return response + +@app.route("/stats/reset") +def reset_stats(): + runners.locust_runner.stats.reset_all() + return "ok" + +@app.route("/stats/requests/csv") +def request_stats_csv(): + rows = [ + ",".join([ + '"Method"', + '"Name"', + '"# requests"', + '"# failures"', + '"Median response time"', + '"Average response time"', + '"Min response time"', + '"Max response time"', + '"Average Content Size"', + '"Requests/s"', + ]) + ] + + for s in chain(_sort_stats(runners.locust_runner.request_stats), [runners.locust_runner.stats.aggregated_stats("Total", full_request_history=True)]): + rows.append('"%s","%s",%i,%i,%i,%i,%i,%i,%i,%.2f' % ( + s.method, + s.name, + s.num_requests, + s.num_failures, + s.median_response_time, + s.avg_response_time, + s.min_response_time or 0, + s.max_response_time, + s.avg_content_length, + s.total_rps, + )) + + response = make_response("\n".join(rows)) + file_name = "requests_{0}.csv".format(time()) + disposition = "attachment;filename={0}".format(file_name) + response.headers["Content-type"] = "text/csv" + response.headers["Content-disposition"] = disposition + return response + +@app.route("/stats/distribution/csv") +def distribution_stats_csv(): + rows = [",".join(( + '"Name"', + '"# requests"', + '"50%"', + '"66%"', + '"75%"', + '"80%"', + '"90%"', + '"95%"', + '"98%"', + '"99%"', + '"100%"', + ))] + for s in chain(_sort_stats(runners.locust_runner.request_stats), [runners.locust_runner.stats.aggregated_stats("Total", full_request_history=True)]): + if s.num_requests: + rows.append(s.percentile(tpl='"%s",%i,%i,%i,%i,%i,%i,%i,%i,%i,%i')) + else: + rows.append('"%s",0,"N/A","N/A","N/A","N/A","N/A","N/A","N/A","N/A","N/A"' % s.name) + + response = make_response("\n".join(rows)) + file_name = "distribution_{0}.csv".format(time()) + disposition = "attachment;filename={0}".format(file_name) + response.headers["Content-type"] = "text/csv" + response.headers["Content-disposition"] = disposition + return response + +@app.route('/stats/requests') +def request_stats(): + global _request_stats_context_cache + + if not _request_stats_context_cache or _request_stats_context_cache["last_time"] < time() - _request_stats_context_cache.get("cache_time", DEFAULT_CACHE_TIME): + cache_time = _request_stats_context_cache.get("cache_time", DEFAULT_CACHE_TIME) + now = time() + + stats = [] + for s in chain(_sort_stats(runners.locust_runner.request_stats), [runners.locust_runner.stats.aggregated_stats("Total")]): + stats.append({ + "method": s.method, + "name": s.name, + "num_requests": s.num_requests, + "num_failures": s.num_failures, + "avg_response_time": s.avg_response_time, + "min_response_time": s.min_response_time, + "max_response_time": s.max_response_time, + "current_rps": s.current_rps, + "median_response_time": s.median_response_time, + "avg_content_length": s.avg_content_length, + }) + + report = {"stats":stats, "errors":[e.to_dict() for e in runners.locust_runner.errors.itervalues()]} + if stats: + report["total_rps"] = stats[len(stats)-1]["current_rps"] + report["fail_ratio"] = runners.locust_runner.stats.aggregated_stats("Total").fail_ratio + + # since generating a total response times dict with all response times from all + # urls is slow, we make a new total response time dict which will consist of one + # entry per url with the median response time as key and the number of requests as + # value + response_times = defaultdict(int) # used for calculating total median + for i in xrange(len(stats)-1): + response_times[stats[i]["median_response_time"]] += stats[i]["num_requests"] + + # calculate total median + stats[len(stats)-1]["median_response_time"] = median_from_dict(stats[len(stats)-1]["num_requests"], response_times) + + is_distributed = isinstance(runners.locust_runner, MasterLocustRunner) + if is_distributed: + report["slave_count"] = runners.locust_runner.slave_count + + report["state"] = runners.locust_runner.state + report["user_count"] = runners.locust_runner.user_count + + elapsed = time() - now + cache_time = max(cache_time, elapsed * 2.0) # Increase cache_time when report generating starts to take longer time + _request_stats_context_cache = {"last_time": elapsed - now, "report": report, "cache_time": cache_time} + else: + report = _request_stats_context_cache["report"] + return json.dumps(report) + +@app.route("/exceptions") +def exceptions(): + response = make_response(json.dumps({'exceptions': [{"count": row["count"], "msg": row["msg"], "traceback": row["traceback"], "nodes" : ", ".join(row["nodes"])} for row in runners.locust_runner.exceptions.itervalues()]})) + response.headers["Content-type"] = "application/json" + return response + +def start(locust, options): + wsgi.WSGIServer((options.web_host, options.port), app, log=None).serve_forever() + +def _sort_stats(stats): + return [stats[key] for key in sorted(stats.iterkeys())] diff --git a/setup.py b/setup.py index 2333211a58..c38e9d3862 100644 --- a/setup.py +++ b/setup.py @@ -1,55 +1,55 @@ -# encoding: utf-8 - -from setuptools import setup, find_packages, Command -import sys, os - -version = '0.7.0' - - -class Unit2Discover(Command): - user_options = [] - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - import sys, subprocess - basecmd = ['unit2', 'discover'] - errno = subprocess.call(basecmd) - raise SystemExit(errno) - - -setup( - name='locustio', - version=version, - description="Website load testing framework", - long_description="""Locust is a python utility for doing easy, distributed load testing of a web site""", - classifiers=[ - "Topic :: Software Development :: Testing :: Traffic Generation", - "Development Status :: 4 - Beta", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Intended Audience :: Developers", - "Intended Audience :: System Administrators", - ], - keywords='', - author='Jonatan Heyman, Carl Bystrom, Joakim Hamrén, Hugo Heyman', - author_email='', - url='http://locust.io', - license='MIT', - packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), - include_package_data=True, - zip_safe=False, - install_requires=["gevent==1.0", "flask>=0.8", "requests>=1.2", "msgpack-python==0.3.0"], - tests_require=['unittest2', 'mock', 'pyzmq'], - entry_points={ - 'console_scripts': [ - 'locust = locust.main:main', - ] - }, - test_suite='unittest2.collector', -) +# encoding: utf-8 + +from setuptools import setup, find_packages, Command +import sys, os + +version = '0.7.0' + + +class Unit2Discover(Command): + user_options = [] + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def run(self): + import sys, subprocess + basecmd = ['unit2', 'discover'] + errno = subprocess.call(basecmd) + raise SystemExit(errno) + + +setup( + name='locustio', + version=version, + description="Website load testing framework", + long_description="""Locust is a python utility for doing easy, distributed load testing of a web site""", + classifiers=[ + "Topic :: Software Development :: Testing :: Traffic Generation", + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + ], + keywords='', + author='Jonatan Heyman, Carl Bystrom, Joakim Hamrén, Hugo Heyman', + author_email='', + url='http://locust.io', + license='MIT', + packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), + include_package_data=True, + zip_safe=False, + install_requires=["gevent==1.0", "flask>=0.8", "requests>=1.2", "msgpack-python==0.3.0"], + tests_require=['unittest2', 'mock', 'pyzmq'], + entry_points={ + 'console_scripts': [ + 'locust = locust.main:main', + ] + }, + test_suite='unittest2.collector', +)