From 0e17ffa3d738b42b8f54f7da18b9c84e051eadf3 Mon Sep 17 00:00:00 2001 From: erickson Date: Wed, 26 Nov 2008 03:43:40 +0000 Subject: [PATCH 1/1] Adding constrictor (per IRC conversation) source to contrib repo. needs a lot of tuning to fit the latest evergreen/opensrf python bits. git-svn-id: svn://svn.open-ils.org/ILS-Contrib/constrictor/trunk@51 6d9bc8c9-1ec2-4278-b937-99fde70a366f --- COPYING | 12 + LICENSE | 674 +++++++++++++++++++++ README | 25 + constrictor.properties | 70 +++ constrictor.py | 146 +++++ constrictor/__init__.py | 17 + constrictor/controller.py | 359 +++++++++++ constrictor/db.py | 188 ++++++ constrictor/log.py | 38 ++ constrictor/properties.py | 332 ++++++++++ constrictor/script.py | 172 ++++++ constrictor/task.py | 88 +++ constrictor/utils.py | 88 +++ constrictor_gui/__init__.py | 0 constrictor_gui/control/__init__.py | 0 constrictor_gui/control/models.py | 52 ++ .../control/templates/admin/base_site.html | 15 + .../control/templates/constrictor/.xinitrc | 1 + .../control/templates/constrictor/actions.html | 102 ++++ .../control/templates/constrictor/config.html | 30 + .../control/templates/constrictor/docs.html | 117 ++++ .../control/templates/constrictor/drones.html | 108 ++++ .../control/templates/constrictor/index.html | 66 ++ .../control/templates/constrictor/props.html | 79 +++ constrictor_gui/control/views.py | 213 +++++++ constrictor_gui/manage.py | 11 + constrictor_gui/settings.py | 95 +++ constrictor_gui/urls.py | 17 + contrib/evergreen/config.xml | 113 ++++ contrib/evergreen/eg_checkin.py | 22 + contrib/evergreen/eg_checkout.py | 32 + contrib/evergreen/eg_checkout_roundtrip.py | 35 ++ contrib/evergreen/eg_data.py | 88 +++ contrib/evergreen/eg_fetch_user_groups.py | 34 ++ contrib/evergreen/eg_renew.py | 29 + contrib/evergreen/eg_tasks.py | 169 ++++++ contrib/evergreen/eg_title_hold.py | 47 ++ contrib/evergreen/eg_utils.py | 169 ++++++ contrib/evergreen/eg_workflow.py | 142 +++++ deploy.py | 166 +++++ runcontroller.py | 29 + samples/config.xml | 15 + samples/sleep.py | 50 ++ 43 files changed, 4255 insertions(+) create mode 100644 COPYING create mode 100644 LICENSE create mode 100644 README create mode 100644 constrictor.properties create mode 100755 constrictor.py create mode 100644 constrictor/__init__.py create mode 100644 constrictor/controller.py create mode 100644 constrictor/db.py create mode 100644 constrictor/log.py create mode 100644 constrictor/properties.py create mode 100644 constrictor/script.py create mode 100644 constrictor/task.py create mode 100755 constrictor/utils.py create mode 100644 constrictor_gui/__init__.py create mode 100644 constrictor_gui/control/__init__.py create mode 100644 constrictor_gui/control/models.py create mode 100644 constrictor_gui/control/templates/admin/base_site.html create mode 100644 constrictor_gui/control/templates/constrictor/.xinitrc create mode 100644 constrictor_gui/control/templates/constrictor/actions.html create mode 100644 constrictor_gui/control/templates/constrictor/config.html create mode 100644 constrictor_gui/control/templates/constrictor/docs.html create mode 100644 constrictor_gui/control/templates/constrictor/drones.html create mode 100644 constrictor_gui/control/templates/constrictor/index.html create mode 100644 constrictor_gui/control/templates/constrictor/props.html create mode 100644 constrictor_gui/control/views.py create mode 100644 constrictor_gui/manage.py create mode 100644 constrictor_gui/settings.py create mode 100644 constrictor_gui/urls.py create mode 100644 contrib/evergreen/config.xml create mode 100644 contrib/evergreen/eg_checkin.py create mode 100644 contrib/evergreen/eg_checkout.py create mode 100644 contrib/evergreen/eg_checkout_roundtrip.py create mode 100644 contrib/evergreen/eg_data.py create mode 100644 contrib/evergreen/eg_fetch_user_groups.py create mode 100644 contrib/evergreen/eg_renew.py create mode 100644 contrib/evergreen/eg_tasks.py create mode 100644 contrib/evergreen/eg_title_hold.py create mode 100644 contrib/evergreen/eg_utils.py create mode 100644 contrib/evergreen/eg_workflow.py create mode 100755 deploy.py create mode 100755 runcontroller.py create mode 100644 samples/config.xml create mode 100755 samples/sleep.py diff --git a/COPYING b/COPYING new file mode 100644 index 000000000..2991b96ac --- /dev/null +++ b/COPYING @@ -0,0 +1,12 @@ +Copyright (C) 2007-2008 King County Library System + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 3 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..94a9ed024 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README b/README new file mode 100644 index 000000000..f7868c6da --- /dev/null +++ b/README @@ -0,0 +1,25 @@ + + Constrictor + + What is it? + ---------- + + Constrictor is a general purpose, distributed, multi-threaded load testing framework. + + Project Site and Documentation + ------------------------------ + + http://svn.open-ils.org/trac/ILS-Contrib/wiki/Constrictor + + Requirements + ----------- + + Python 2.4 or higher + pysqlite2 (for sqlite3 : http://www.initd.org/tracker/pysqlite/wiki/pysqlite) + + For the Evergreen contrib module: + Python simplejson + OpenSRF Python libs + Evergreen Pythong libs + + diff --git a/constrictor.properties b/constrictor.properties new file mode 100644 index 000000000..cde875068 --- /dev/null +++ b/constrictor.properties @@ -0,0 +1,70 @@ +# ---- Constrictor Properties ------------------------------------- + +# script to run by default +constrictor.script=sleep.py +#constrictor.script=eg_fetch_user_groups.py +#constrictor.script=eg_checkout_roundtrip.py +#constrictor.script=eg_title_hold.py + +# comma separated list of directories where test scripts are found +constrictor.scriptDirs=samples,contrib/evergreen + +# local directory to cache test-specific files +constrictor.cacheDir=cache + +# default number of threads +constrictor.numThreads=3 + +# default number of iterations-per-thread +constrictor.numIterations=1 + +# sqlite database filename +constrictor.dbFile=constrictor.db + +# IP address to listen on when waiting on controller commands +constrictor.listenAddress='' + +# port to listen on when waiting on controller commands +constrictor.port=21800 + +# if true, constrictor will wait for commands from the controller (dashboard) +#constrictor.listen=true + +#logs to stdout and stderr. options are 0=none,1=error,2=info,3=debug +constrictor.loglevel=2 + + + + +# ---- Properties for the Evergreen contrib module -------------- + +# Where on the server can we find the latest IDL file +evergreen.IDLPath=/reports/fm_IDL.xml + +# the server to test +evergreen.server=dev.gapines.org + +# defines the HTTP gateway path where queries are sent +evergreen.gatewayPath=osrf-gateway-v1 + +# defines the HTTP gateway data format +#evergreen.netProtocol=XML +evergreen.netProtocol=JSON + +# if true, we will automatically login at script load time. Set to tru +# if any actions in the script require authentication +evergreen.autologin=true + +# username, password, and workstation to login with. +# this is the user whose permissions will be checked +# when performing any action that requires authentication +evergreen.username=demo +evergreen.password=demo123 +evergreen.workstation=demo +#evergreen.titleIDs= +#evergreen.patronIDs= +#evergreen.orgIDs= +#evergreen.copyBarcodes= +#evergreen.patronBarcodes= +#evergreen.osrfConfig= +#evergreen.osrfConfigContext= diff --git a/constrictor.py b/constrictor.py new file mode 100755 index 000000000..d3f3fc0d4 --- /dev/null +++ b/constrictor.py @@ -0,0 +1,146 @@ +#!/usr/bin/python +# ----------------------------------------------------------------------- +# Copyright (C) 2007-2008 King County Library System +# Bill Erickson +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# ----------------------------------------------------------------------- + +import sys, getopt, os, errno +from constrictor.properties import Properties +from constrictor.db import DBConnection +from constrictor.controller import DroneController +from constrictor.script import ScriptThread, ScriptManager +from constrictor.log import * +from constrictor.utils import loadProps, saveProps, initDirs, initDB, openScript, PROPS_FILENAME + +props = None +droneController = None + +def usage(): + print ''' +python %s [options] + + By default, all options are read from the properties file constrictor.properties. + Arguments passed via the command line will override any properties + loaded from the properties file + + Options: + -h show this help message + -s test script to run (property constrictor.script) + -t number of threads to launch (property constrictor.numThreads) + -i number of test iterations per thread (property constrictor.numIterations) + -d database file (constrictor.property dbFile) + -p port to listen for controller connections on + -l listen address for incoming controller connections +''' % sys.argv[0] + sys.exit(0) + + + +def onThreadsComplete(scriptManager): + global droneController + summary = ScriptThread.currentScriptThread().dbConnection.createTaskSummary() + droneController.sendResult(type='task_summary', **summary) + + +def readArgv(): + # see if we have any command-line args that override the properties file + ops, args = getopt.getopt(sys.argv[1:], 's:t:i:d:p:l:h') + options = dict( (k,v) for k,v in ops ) + + if options.has_key('-h'): + usage() + if options.has_key('-s'): + props.setProperty('constrictor.script', options['-s']) + if options.has_key('-t'): + props.setProperty('constrictor.numThreads', options['-t']) + if options.has_key('-i'): + props.setProperty('constrictor.numIterations', options['-i']) + if options.has_key('-d'): + props.setProperty('constrictor.dbFile', options['-d']) + if options.has_key('-p'): + props.setProperty('constrictor.port', options['-p']) + if options.has_key('-l'): + props.setProperty('constrictor.listenAddress', options['-l']) + + + +def onThreadsComplete(scriptManager): + global droneController + summary = ScriptThread.currentScriptThread().dbConnection.createTaskSummary() + droneController.sendResult(type='task_summary', **summary) + +loadProps(PROPS_FILENAME) +props = Properties.getProperties() +readArgv() +initDirs() +initLog() +scriptDirs = props.getProperty('constrictor.scriptDirs').split(',') + + + +if props.getProperty('constrictor.listen') == 'true': + + ''' This is the main controller listen loop. Here, we + accept commands from the controller module, perform + the action, then go back to listening ''' + + droneController = DroneController( + props.getProperty('constrictor.address'), + int(props.getProperty('constrictor.port'))) + + ScriptManager.setOnThreadsComplete(onThreadsComplete) + + while True: + try: + command = droneController.recv()['command'] + + if command['action'] == 'setprop': + prop = str(command['prop']) + val = str(command['val']) + logInfo('setting property %s %s' % (prop, val)) + props.setProperty(prop, val) + continue + + if command['action'] == 'saveprops': + logInfo("saving properties back to file") + saveProps() + continue + + if command['action'] == 'run': + ScriptThread.resetThreadSeed() + script = props.getProperty('constrictor.script') + logInfo('running ' + script) + f = openScript(scriptDirs, script) + if f: + initDB() + try: + exec(f) + except Exception, e: + logError("script execution failed: %s" % str(e)) + droneController.sendError(text=str(e)) + f.close() + continue + + except KeyboardInterrupt: + droneController.shutdown() + +else: + initDB() + script = props.getProperty('constrictor.script') # execute the requested script + ScriptThread.resetThreadSeed() + f = openScript(scriptDirs, script) + if f: + exec(f) + f.close() + + diff --git a/constrictor/__init__.py b/constrictor/__init__.py new file mode 100644 index 000000000..1d60a33fd --- /dev/null +++ b/constrictor/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/python +# ----------------------------------------------------------------------- +# Copyright (C) 2007-2008 King County Library System +# Bill Erickson +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# ----------------------------------------------------------------------- + + diff --git a/constrictor/controller.py b/constrictor/controller.py new file mode 100644 index 000000000..2263d850e --- /dev/null +++ b/constrictor/controller.py @@ -0,0 +1,359 @@ +#!/usr/bin/python +# ----------------------------------------------------------------------- +# Copyright (C) 2007-2008 King County Library System +# Bill Erickson +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# ----------------------------------------------------------------------- + + +import sys, socket, select, errno, re +from xml.sax import handler, make_parser, saxutils +from properties import Properties +from log import * +from threading import Thread + +SOCKET_TIMEOUT = 4 +READSIZE = 256 +START_STREAM = "" +END_STREAM = "" + +def connected(sock): + try: + p = sock.getpeername() + return True + except: + return False + +class Controller(object): + ''' Generic class for sending and receiving controller communications ''' + + def __init__(self): + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.queue = [] + self.remote = None + self.parser = None + self.connected = False + self.streamSent = False + self.reset() + + def disconnect(self): + ''' Shuts down the socket ''' + try: + self.socket.shutdown(socket.SHUT_RDWR) + self.socket.close() + except: + pass + + def shutdown(self): + ''' Shuts down the socket and ends the process ''' + self.disconnect() + sys.exit(0) + + def startStream(self): + ''' Sends the openening XML stream tag to initialize the converstaion. ''' + if not self.streamSent: + self.remote.send(START_STREAM) + self.streamSent = True + + def endStream(self): + ''' Closes the stream document by sending the final closing tag ''' + self.remote.send(END_STREAM) + + def reset(self): + ''' Creates a new stream parser and flags this object as not being + connected to the remote node ''' + logInfo("Resetting controller connection") + self.parser = make_parser() + self.parser.setContentHandler(CommandParser(self)) + self.connected = False + self.streamSent = False + + def _send(self, tag, **kwargs): + ''' Sends an XML tag to the remote node. kwargs are encoded as XML attributes ''' + s = '<%s' % tag + for k,v in kwargs.items(): + v = re.compile("'").sub('"', str(v)) # change ' to " to prevent xml attr breaking.. + s += " %s='%s'" % (saxutils.escape(k), saxutils.escape(v)) + s += '/>' + logDebug('Sending ' + s) + self.remote.send(s) + + + def _recv(self): + ''' Reads data from the socket and passes it to the SAX parser. + This method will continue to read data until at least + one event has occrred in the parser (i.e. we have something + sitting in the receive queue) ''' + + while len(self.queue) == 0: + + if self.remote is None: + # we're acting as a server process, waiting for a connection + self.remote = self.socket.accept() + logInfo('Controller connection from %s' % str(self.remote.remoteAddress)) + + try: + data = self.remote.recv() + + if data is None: + # client severed the connection + self.reset() + continue + + self.parser.feed(data) + + except KeyboardInterrupt: + self.shutdown() + + except socket.error, e: + # controller disconnected while we were waiting for a command + # go back to the top and wait for a new controller connection + if e.args[0] == errno.ECONNRESET: + self.reset() + continue + + return self.queue.pop(0) + + + def recv(self): + ''' Reads data from the socket and passes it to the SAX parser. + This method will continue to read data until at least + one event has occrred in the parser (i.e. we have something + sitting in the receive queue) ''' + + while len(self.queue) == 0: + + if self.remote is None: + # we're acting as a server process, waiting for a connection + self.remote, address = self.socket.accept() + logInfo('Controller connection from %s' % str(address)) + + try: + data = self.remote.recv(READSIZE) + + # client severed the connection + if data is None or data == '': + self.reset() + continue + + logDebug("Read %s" % data) + self.parser.feed(data) + self.remote.setblocking(0) + + except KeyboardInterrupt: + self.shutdown() + + except socket.error, e: + + # controller disconnected while we were waiting for a command + # go back to the top and wait for a new controller connection + if e.args[0] == errno.ECONNRESET: + self.reset() + continue + + # read 0 bytes on a non-blocking socket. set it back + # to blocking and see if we're done + if e.args[0] == errno.EAGAIN: + self.remote.setblocking(1) + continue + + return self.queue.pop(0) + + +class DroneController(Controller): + ''' Used by a worker drone to receive commands from the console/controller process ''' + + def __init__(self, listenAddr='', port=21800): + Controller.__init__(self) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.socket.bind((listenAddr, port)) + self.socket.listen(1) + + def sendResult(self, **kwargs): + ''' Sends a element ''' + self._send('result', **kwargs) + + def sendError(self, **kwargs): + ''' Sends an element ''' + self._send('error', **kwargs) + + def reset(self): + ''' Resets this drone's connection ''' + Controller.reset(self) + self.remote = None + +class GUIController(Controller): + ''' Models a single connection from the console GUI to a single drone machine ''' + + def __init__(self, remoteAddr, port, id, controlSet): + Controller.__init__(self) + self.address = remoteAddr + self.port = port + self.remote = self.socket + self.socket.settimeout(SOCKET_TIMEOUT) + self.id = id + self.name = '%s:%s' % (remoteAddr, port) + self.failed = None + self.connect() + + def connect(self): + try: + self.socket.connect((self.address, self.port)) + self.reset() + self.startStream() + while not self.connected: + self.parser.feed(self.remote.recv(READSIZE)) + + except socket.error, e: + err = _("Error connecting to %s:%s %s" % (self.address, self.port, str(e))) + self.failed = err + logError(err) + return False + + except socket.timeout: + err = _("Connection to %s:%s timed out" % (self.address, self.port)) + self.failed = err + logError(err) + return False + + def __str__(self): + return self.name + + def sendCommand(self, **kwargs): + self._send('command', **kwargs) + + +class GUIControllerSet(object): + def __init__(self): + self.controllers = [] # our list of connected controllers + self.testBatches = [] + self.connectedHandler = None + self.failedControllers = [] + + def createController(self, remoteAddr, port, id): + c = GUIController(remoteAddr, port, id, self) + if c.connected: + self.controllers.append(c) + else: + self.failedControllers.append(c) + + def isEmpty(self): + if len(self.controllers) == 0: + return True + return False + + def checkReady(self, timeout): + ''' Returns a list of controller objects + that have data available for recv()'ing ''' + rlist = [ c.socket for c in self.controllers if connected(c.socket) ] + if len(rlist) > 0: + try: + rlist = select.select(rlist, [], [], timeout) + except Exception, e: + logError("select() failed with " + str(e)) + return [] + return [ c for c in self.controllers if c.socket in rlist[0] ] + return [] + + def pushReady(self, controllers): + self.responded += controllers + + def broadcastCommand(self, **kwargs): + for c in self.controllers: + try: + c.sendCommand(**kwargs) + except socket.error, e: + self.controllers.remove(c) + except socket.timeout, t: + self.controllers.remove(c) + + def sendCommand(self, controllerID, **kwargs): + c = [ co for co in self.controllers if int(co.id) == int(controllerID) ] + if len(c) == 0: return + c[0].sendCommand(**kwargs); + + def getSet(self): + return self.controllers + + def getFailed(self): + return self.failedControllers + + def reset(self): + while len(self.controllers) > 0: + self.controllers.pop().disconnect() + + def runBatch(self): + b = GUIControllerSet.TestBatch(self) + self.broadcastCommand(action='run') + self.testBatches.append(b) + return b + + def getLastBatch(self): + return self.testBatches[len(self.testBatches)] + + class TestBatch(object): + def __init__(self, controlSet): + self.responded = {} + self.controlSet = controlSet + self.complete = False + + def recv(self, timeout): + ready = self.controlSet.checkReady(timeout) + for c in ready: + self.responded[c.name] = c.recv()['result'] + if len(self.responded) == len(self.controlSet.getSet()): + self.complete = True + return self.responded + + def getResponded(self): + ''' Returns a list of controllers that have responded from this batch ''' + return self.responded + + def clearResponded(self): + self.responded = {} + + + + +class CommandParser(handler.ContentHandler): + + def __init__(self, controller): + self.controller = controller + + def getAttr(self, attrs, name): + for (k, v) in attrs.items(): + if k == name: + return v + + def __xmlNode2Python(self, name, attrs): + p = {} + p[name] = dict([(k,v) for k,v in attrs.items()]) + return p + + def startElement(self, name, attrs): + logDebug("Received %s %s" % (name, str([(k,v) for k,v in attrs.items()]))) + + if name == 'controlset': + if not self.controller.connected: + self.controller.connected = True + self.controller.startStream() + return + + p = self.__xmlNode2Python(name, attrs) + + try: + if p['command']['action'] == 'shutdown': + self.controller.shutdown() + except KeyError: pass + + self.controller.queue.append(p) + diff --git a/constrictor/db.py b/constrictor/db.py new file mode 100644 index 000000000..dc1296981 --- /dev/null +++ b/constrictor/db.py @@ -0,0 +1,188 @@ +# ----------------------------------------------------------------------- +# Copyright (C) 2007-2008 King County Library System +# Bill Erickson +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# ----------------------------------------------------------------------- + +from pysqlite2 import dbapi2 as sqlite +import sys, threading, thread, time +from log import * +from properties import Properties + +# wrap DB access in a semaphore +dbSema = threading.BoundedSemaphore(value=1) + + +class DBConnection(object): + + # the ID of the currently executing task set + taskSetID = 0 + + def __init__(self, dbFile): + self.conn = sqlite.connect(dbFile) + + def createTaskSet(self): + SQL = ''' + insert into task_set (start_time) values (%f) + ''' % time.time() + cur = self.execute(SQL, True) + DBConnection.taskSetID = cur.lastrowid + + def finishTaskSet(self): + SQL = ''' + update task_set set end_time = %f where id = %d + ''' % (time.time(), DBConnection.taskSetID) + self.execute(SQL, True) + + + def createTables(self): + taskSQL = ''' + CREATE TABLE IF NOT EXISTS task ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_name TEXT, + task_set INTEGER, + runtime REAL, + runner INTEGER, + thread_id INTEGER, + duration INTEGER, + success INTEGER + ); + ''' + + taskSetSQL = ''' + CREATE TABLE IF NOT EXISTS task_set ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_time REAL, + end_time REAL + ); + ''' + + self.execute(taskSQL) + self.execute(taskSetSQL, True) + + + def createTaskSummary(self): + summary = {} + + cur = self.execute( + 'select end_time - start_time from task_set where id = %d' % DBConnection.taskSetID) + row = cur.fetchone() + summary['task_set_duration'] = float(row[0]) or '' + + cur = self.execute( + 'select avg(duration) from task where success = 1 and task_set = %d' % DBConnection.taskSetID) + row = cur.fetchone() + summary['avg_task_duration'] = row[0] or '' + + cur = self.execute( + 'select count(*) from task where success = 1 and task_set = %d' % DBConnection.taskSetID) + row = cur.fetchone() + summary['num_task_success'] = row[0] + + cur = self.execute( + 'select count(*) from task where success = 0 and task_set = %d' % DBConnection.taskSetID) + row = cur.fetchone() + summary['num_task_failed'] = row[0] + + summary['amortized_task_duration'] = '' + if summary['num_task_success'] > 0: + summary['amortized_task_duration'] = \ + float(summary['task_set_duration']) / float(summary['num_task_success']) + + summary['amortized_tasks_per_second'] = '' + if summary['num_task_success'] > 0: + summary['amortized_tasks_per_second'] = 1.0 / float(summary['amortized_task_duration']) + + props = Properties.getProperties() + summary['thread_count'] = props.getProperty('constrictor.numThreads') + summary['iteration_count'] = props.getProperty('constrictor.numIterations') + + self.makeTaskTypeSummary(summary) + + logInfo('created summary %s' % summary) + return summary + + def makeTaskTypeSummary(self, summary): + ''' build a summary string for each task type of ::; ''' + + summary['task_type_summary'] = '' + tasks = self.execute( + 'select distinct(task_name) from task where task_set = %d' % DBConnection.taskSetID) + + for t in tasks: + t = t[0] + + # grab the average duration for this type of task + cur = self.execute( + 'select avg(duration) from task where task_set = %d and task_name = "%s"' % ( + DBConnection.taskSetID, t)) + avg = cur.fetchone()[0] + + # grab the number of times this task was run + cur = self.execute( + 'select count(*) from task where task_set = %d and task_name = "%s"' % ( + DBConnection.taskSetID, t)) + row = cur.fetchone() + count = row[0] + + summary['task_type_summary'] += '%s:%d:%f;' % (t, count, float(avg)) + + + + + def dropTables(self): + SQL = 'drop table task;' + self.execute(SQL, True) + + + def disconnect(self): + if self.conn is not None: + self.conn.close() + + + def execute(self, sql, commit=False): + logDebug('SQL ' + sql) + from script import ScriptThread + cur = None + try: + dbSema.acquire() + cur = self.conn.cursor() + cur.execute(sql) + if commit: + self.conn.commit() + except Exception, e: + sys.stderr.write('DB error: thread = %d : %s\n' % (ScriptThread.getThreadID(), str(e))) + sys.stderr.flush() + dbSema.release() + sys.exit(1) + + dbSema.release() + return cur + + def insertTask(self, taskRunner): + from script import ScriptThread + + SQL = """ + insert into task (task_name, runtime, runner, thread_id, task_set, duration, success) + values ('%s', %f, %d, %d, %d, %f, %d) """ % ( + taskRunner.task.name, + time.time(), + 1, # XXX get me from the task runner ? + ScriptThread.getThreadID(), + DBConnection.taskSetID, + taskRunner.duration, + taskRunner.success + ) + self.execute(SQL, True) + + + diff --git a/constrictor/log.py b/constrictor/log.py new file mode 100644 index 000000000..edf8c62e0 --- /dev/null +++ b/constrictor/log.py @@ -0,0 +1,38 @@ +#!/usr/bin/python +# ----------------------------------------------------------------------- +# Copyright (C) 2007-2008 King County Library System +# Bill Erickson +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# ----------------------------------------------------------------------- + + +from properties import Properties +import sys + +loglevel = 1 +def initLog(): + global loglevel + props = Properties.getProperties() + loglevel = int(props.getProperty('constrictor.loglevel') or 0) + +def logError(msg=''): + if loglevel < 1: return + sys.stderr.write('Error*: %s\n' % msg) + sys.stderr.flush() + +def logInfo(msg=''): + if loglevel < 2: return + print 'Info: %s' % msg + +def logDebug(msg=''): + if loglevel < 3: return + print 'Debug: %s' % msg diff --git a/constrictor/properties.py b/constrictor/properties.py new file mode 100644 index 000000000..eb7e6a501 --- /dev/null +++ b/constrictor/properties.py @@ -0,0 +1,332 @@ +""" +A Python replacement for java.util.Properties class +This is modelled as closely as possible to the Java original. + +Created - Anand B Pillai + +Edited by Bill Erickson + - added getProperties to return a global properties object + - added property name sorting to the store() method +""" + +import sys,os +import re +import time + +class IllegalArgumentException(Exception): + + def __init__(self, lineno, msg): + self.lineno = lineno + self.msg = msg + + def __str__(self): + s='Exception at line number %d => %s' % (self.lineno, self.msg) + return s + +globalProps = None +class Properties(object): + """ A Python replacement for java.util.Properties """ + + def __init__(self, props=None): + # Note: We don't take a default properties object + # as argument yet + + # Dictionary of properties. + self._props = {} + # Dictionary of properties with 'pristine' keys + # This is used for dumping the properties to a file + # using the 'store' method + self._origprops = {} + + # Dictionary mapping keys from property + # dictionary to pristine dictionary + self._keymap = {} + + self.othercharre = re.compile(r'(?',line + # Means we need to split by space. + first, last = m2.span() + sepidx = first + elif m: + # print 'Other match=>',line + # No matching wspace char found, need + # to split by either '=' or ':' + first, last = m.span() + sepidx = last - 1 + # print line[sepidx] + + + # If the last character is a backslash + # it has to be preceded by a space in which + # case the next line is read as part of the + # same property + while line[-1] == '\\': + # Read next line + nextline = i.next() + nextline = nextline.strip() + lineno += 1 + # This line will become part of the value + line = line[:-1] + nextline + + # Now split to key,value according to separation char + if sepidx != -1: + key, value = line[:sepidx], line[sepidx+1:] + else: + key,value = line,'' + + self.processPair(key, value) + + def processPair(self, key, value): + """ Process a (key, value) pair """ + + oldkey = key + oldvalue = value + + # Create key intelligently + keyparts = self.bspacere.split(key) + # print keyparts + + strippable = False + lastpart = keyparts[-1] + + if lastpart.find('\\ ') != -1: + keyparts[-1] = lastpart.replace('\\','') + + # If no backspace is found at the end, but empty + # space is found, strip it + elif lastpart and lastpart[-1] == ' ': + strippable = True + + key = ''.join(keyparts) + if strippable: + key = key.strip() + oldkey = oldkey.strip() + + oldvalue = self.unescape(oldvalue) + value = self.unescape(value) + + self._props[key] = value.strip() + + # Check if an entry exists in pristine keys + if self._keymap.has_key(key): + oldkey = self._keymap.get(key) + self._origprops[oldkey] = oldvalue.strip() + else: + self._origprops[oldkey] = oldvalue.strip() + # Store entry in keymap + self._keymap[key] = oldkey + + def escape(self, value): + + # Java escapes the '=' and ':' in the value + # string with backslashes in the store method. + # So let us do the same. + newvalue = value.replace(':','\:') + newvalue = newvalue.replace('=','\=') + + return newvalue + + def unescape(self, value): + + # Reverse of escape + newvalue = value.replace('\:',':') + newvalue = newvalue.replace('\=','=') + + return newvalue + + def load(self, stream): + """ Load properties from an open file stream """ + + # For the time being only accept file input streams + if type(stream) is not file: + raise TypeError,'Argument should be a file object!' + # Check for the opened mode + if stream.mode != 'r': + raise ValueError,'Stream should be opened in read-only mode!' + + try: + lines = stream.readlines() + self.__parse(lines) + except IOError, e: + raise + + def getProperty(self, key): + """ Return a property for the given key """ + + return self._props.get(key,'') + + def setProperty(self, key, value): + """ Set the property for the given key """ + + if type(key) is str and type(value) is str: + self.processPair(key, value) + else: + raise TypeError,'both key and value should be strings!' + + def propertyNames(self): + """ Return an iterator over all the keys of the property + dictionary, i.e the names of the properties """ + + return self._props.keys() + + def list(self, out=sys.stdout): + """ Prints a listing of the properties to the + stream 'out' which defaults to the standard output """ + + out.write('-- listing properties --\n') + for key,value in self._props.items(): + out.write(''.join((key,'=',value,'\n'))) + + def store(self, out, header=""): + """ Write the properties list to the stream 'out' along + with the optional 'header' """ + + if out.mode[0] != 'w': + raise ValueError,'Steam should be opened in write mode!' + + try: + out.write(''.join(('#',header,'\n'))) + # Write timestamp + tstamp = time.strftime('%a %b %d %H:%M:%S %Z %Y', time.localtime()) + out.write(''.join(('#',tstamp,'\n'))) + # Write properties from the pristine dictionary + props = self._origprops + keys = props.keys() + keys.sort() + for k in keys: + out.write(''.join((k,'=',self.escape(props[k]),'\n'))) + + out.close() + except IOError, e: + raise + + def getPropertyDict(self): + return self._props + + def __getitem__(self, name): + """ To support direct dictionary like access """ + + return self.getProperty(name) + + def __setitem__(self, name, value): + """ To support direct dictionary like access """ + + self.setProperty(name, value) + + def __getattr__(self, name): + """ For attributes not found in self, redirect + to the properties dictionary """ + + try: + return self.__dict__[name] + except KeyError: + if hasattr(self._props,name): + return getattr(self._props, name) + +if __name__=="__main__": + p = Properties() + p.load(open('test2.properties')) + p.list() + print p + print p.items() + print p['name3'] + p['name3'] = 'changed = value' + print p['name3'] + p['new key'] = 'new value' + p.store(open('test2.properties','w')) diff --git a/constrictor/script.py b/constrictor/script.py new file mode 100644 index 000000000..662c70c8b --- /dev/null +++ b/constrictor/script.py @@ -0,0 +1,172 @@ +#!/usr/bin/python +# ----------------------------------------------------------------------- +# Copyright (C) 2007-2008 King County Library System +# Bill Erickson +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# ----------------------------------------------------------------------- + +import time, sys, threading +from threading import Thread, local +from properties import Properties +from db import DBConnection +import threading +from log import * + + +class Script(object): + def __init__(self, name=''): + self.name = name + def run(self): + logError('Override Script.run() to run a script!') + sys.exit(1) + def onThreadInit(self, scriptThread): + pass + def onThreadComplete(self, scriptThread): + pass + + +class ScriptThread(Thread): + """ Models a single thread of execution for a script. + This class will run the script object up to numItr iterations. + """ + # this is used to determine the thread ID of newly created threads + threadSeed = 0 + + def __init__(self, script, numItr, onComplete=None ): + Thread.__init__(self) + logDebug("Creating thread with ID %d" % ScriptThread.threadSeed) + self.script = script # our script object + self.numItr = numItr # number of times to run our script + self.setName(ScriptThread.threadSeed) # use the Thread name as our threadID + self.dbConnection = None # create this after the thread has launched + self.onComplete = onComplete + ScriptThread.threadSeed += 1 + self.userData = None + + def resetThreadSeed(): + ScriptThread.threadSeed = 0 + resetThreadSeed = staticmethod(resetThreadSeed) + + def run(self): + """ Run our script object up to numItr times.""" + self.initThread() + tid = ScriptThread.getThreadID() + self.script.onThreadInit(self) + for i in range(0, self.numItr): + logInfo('running thread %d, iteration %d' % (tid, i)) + try: + self.script.run() + except Exception, e: + logError("Script exception: %s" % str(e)) + break + + self.script.onThreadComplete(self) + + if self.onComplete: + self.onComplete(self) + + + def initThread(self): + """ Perform any thread-specific house keeping.""" + data = ScriptThread.__threadData() + data.scriptThread = self + + props = Properties.getProperties() + self.dbConnection = DBConnection(props.getProperty('constrictor.dbFile')) + + + def getThreadID(): + """ Returns the ID of the current thread. + Returns -1 if this is the main thread + """ + i = -1 + try: + i = int(threading.currentThread().getName()) + except: + pass + return i + getThreadID = staticmethod(getThreadID) + + + def currentScriptThread(): + data = ScriptThread.__threadData() + return data.scriptThread + currentScriptThread = staticmethod(currentScriptThread) + + + threadDataStore = None + def __threadData(): + if ScriptThread.threadDataStore is None: + ScriptThread.threadDataStore = threading.local() + return ScriptThread.threadDataStore + __threadData = staticmethod(__threadData) + + +class ScriptManager(object): + """ Manages the script threads. """ + + # called when all script threads have completed + onThreadsComplete = None + + def __init__(self, script, numThreads, numItr): + """ numThreads - number of simultaneous script threads to run + numItr - number of times to repeat a script within a given thread + """ + self.script = script + self.numThreads = numThreads + self.numItr = numItr + self.numComplete = 0 + + if self.script is None: + logError('Please register a script to run') + sys.exit(1) + + + def setOnThreadsComplete(func): + ScriptManager.onThreadsComplete = staticmethod(func) + setOnThreadsComplete = staticmethod(setOnThreadsComplete) + + def runScriptThreads(self): + + def onComplete(scriptThread): + self.numComplete += 1 + if self.numComplete == self.numThreads: + # all threads have completed + scriptThread.dbConnection.finishTaskSet() + logInfo('all threads done.. finishing task set') + if ScriptManager.onThreadsComplete: + ScriptManager.onThreadsComplete(ScriptManager) + + """ Launches the script threads. """ + for i in range(0, self.numThreads): + tt = ScriptThread(self.script, self.numItr, onComplete) + tt.start() + + def go(script): + """ The main script passes control to the ScriptManager via this function. + Here, we parse the config and launch the script threads + """ + props = Properties.getProperties() + + threads = int(props.getProperty('constrictor.numThreads')) + itrs = int(props.getProperty('constrictor.numIterations')) + + logDebug('launching %d threads with %d iterations' % (threads, itrs)) + + manager = ScriptManager(script, threads, itrs) + manager.runScriptThreads() + + go = staticmethod(go) + + + + diff --git a/constrictor/task.py b/constrictor/task.py new file mode 100644 index 000000000..5fbfa3413 --- /dev/null +++ b/constrictor/task.py @@ -0,0 +1,88 @@ +#!/usr/bin/python +# ----------------------------------------------------------------------- +# Copyright (C) 2007-2008 King County Library System +# Bill Erickson +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# ----------------------------------------------------------------------- + + +import time, sys +from script import ScriptThread +from threading import local +import threading +from log import * + + +class Task(object): + """ A Task represents a single unit of work. They are created + before thread allocation, so no state information is stored + on the actual Task object. + """ + + def __init__(self, name=''): + self.name = name + def run(self, **kwargs): + """Override this method with the work to perform""" + logError('Override me!') + def wrap(self): + """ Static method for wrapping and registering a task. + Statistics will only be collected for registered tasks. + """ + return TaskWrapper(self) + + +class TaskWrapper(object): + """ Provides a new run() method for the client to call directly. + The main purpose of the TaskWrapper is to allow the creation of + a TaskRunner after thread creation. + """ + + def __init__(self, task): + self.task = task + def run(self, **kwargs): + return TaskRunner(self.task).go(**kwargs) + + + +class TaskRunner(object): + """ Runs a single task and collects timing information on the task for a given thread. """ + + def __init__(self, task): + self.task = task + self.duration = 0 + self.success = False + self.complete = False + + def go(self, **kwargs): + start = time.time() + ret = None # capture the return value from the task + + try: + ret = self.task.run(**kwargs) + self.duration = time.time() - start + self.complete = True + self.success = True + except Exception, E: + sys.stderr.write(str(E)) + # store the error info somewhere? + + logDebug('%s: thread = %d : duration = %f' % ( + self.task.name, ScriptThread.getThreadID(), self.duration)) + + sys.stdout.flush() + dbConn = ScriptThread.currentScriptThread().dbConnection + dbConn.insertTask(self) + return ret + + + + diff --git a/constrictor/utils.py b/constrictor/utils.py new file mode 100755 index 000000000..0aaaa85f9 --- /dev/null +++ b/constrictor/utils.py @@ -0,0 +1,88 @@ +#!/usr/bin/python +# ----------------------------------------------------------------------- +# Copyright (C) 2007-2008 King County Library System +# Bill Erickson +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# ----------------------------------------------------------------------- + +import sys, getopt, os, errno +from constrictor.properties import Properties +from constrictor.db import DBConnection +from constrictor.log import * + +DEFAULT_PORT = 21800 +CONFIG_FILENAME = 'config.xml' +PROPS_FILENAME = 'constrictor.properties' + +props = Properties() + +def loadProps(file): + + try: + # parse the properties file + p = open(PROPS_FILENAME) + Properties.setGlobalProperties(props) + props.load(p) + p.close() + except IOError: + print ''' + WARNING: No properties file (constrictor.properties) found. + Using command line options only. + ''' + +def saveProps(): + try: + p = open(PROPS_FILENAME, 'w') + props.store(p) + p.close() + except Exception, e: + print "WARNING: Unable to store properties to file\n%s" % str(e) + + +def initDB(): + ''' connect to the db and make sure the tables exist ''' + dbConnection = DBConnection(props.getProperty('constrictor.dbFile')) + dbConnection.createTables() + dbConnection.createTaskSet() + dbConnection.disconnect() + + +def initDirs(): + cachedir = props.getProperty('constrictor.cacheDir') + if not os.path.exists(cachedir): + os.mkdir(props.getProperty('constrictor.cacheDir')) + +pathsAdded = [] +def openScript(dirs, script): + ''' Finds the script file in the set of script diretories, + opens the file and returns the file object ''' + f = None + for d in dirs: + try: + # try to open the script file + f = open(os.path.join(d, script)) + + # add the script directory to the python path + if d not in pathsAdded: + sys.path.append(os.path.join(os.path.abspath(''), d)) + pathsAdded.append(d) + + return f + except IOError: + pass + logError("Unable to find script %s in path %s" % (script, str(dirs))) + return None + + + + + diff --git a/constrictor_gui/__init__.py b/constrictor_gui/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/constrictor_gui/control/__init__.py b/constrictor_gui/control/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/constrictor_gui/control/models.py b/constrictor_gui/control/models.py new file mode 100644 index 000000000..927d5ed25 --- /dev/null +++ b/constrictor_gui/control/models.py @@ -0,0 +1,52 @@ +# ----------------------------------------------------------------------- +# Copyright (C) 2007-2008 King County Library System +# Bill Erickson +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# ----------------------------------------------------------------------- + +from django.db import models + + +class Drone(models.Model): + address = models.CharField(maxlength=200) + port = models.IntegerField() + enabled = models.BooleanField(blank=True) + class Admin: + list_display = ('address', 'port', 'enabled') + def __str__(self): + return "%s:%d" % (self.address, self.port) + +class Plugin(models.Model): + name = models.CharField(maxlength=100) + description = models.TextField(maxlength=500) + class Admin: + pass + def __str__(self): + return self.name + +class Script(models.Model): + plugin = models.ForeignKey(Plugin) + name = models.CharField(maxlength=200) + description = models.TextField(maxlength=500) + class Admin: + list_display = ('plugin', 'name', 'description') + def __str__(self): + return self.name + +class Property(models.Model): + plugin = models.ForeignKey(Plugin) + name = models.CharField(maxlength=100) + description = models.TextField(maxlength=500) + class Admin: + list_display = ('plugin', 'name', 'description') + def __str__(self): + return self.name diff --git a/constrictor_gui/control/templates/admin/base_site.html b/constrictor_gui/control/templates/admin/base_site.html new file mode 100644 index 000000000..a739fcbd8 --- /dev/null +++ b/constrictor_gui/control/templates/admin/base_site.html @@ -0,0 +1,15 @@ +{% extends "admin/base.html" %} +{% load i18n %} + +{% block title %}{{ title|escape }}{% endblock %} + +{% block branding %} +

{% trans 'Constrictor Dashboard' %}

+{% endblock %} + +{% block nav-global %} + + +{% trans 'Constrictor Home' %} +{% trans 'Logout' %} +{% endblock %} diff --git a/constrictor_gui/control/templates/constrictor/.xinitrc b/constrictor_gui/control/templates/constrictor/.xinitrc new file mode 100644 index 000000000..61b465234 --- /dev/null +++ b/constrictor_gui/control/templates/constrictor/.xinitrc @@ -0,0 +1 @@ +exec bery diff --git a/constrictor_gui/control/templates/constrictor/actions.html b/constrictor_gui/control/templates/constrictor/actions.html new file mode 100644 index 000000000..5fd020536 --- /dev/null +++ b/constrictor_gui/control/templates/constrictor/actions.html @@ -0,0 +1,102 @@ + + + +
+ + +

Configure and Run Tests

+ + + + + + + + + + + + + +
+
+ + + + + + +
Thread Count + Iteration Count + Test Script + +
+
+
+ Reset Connections +   ·   + Run Tests +
+
+ diff --git a/constrictor_gui/control/templates/constrictor/config.html b/constrictor_gui/control/templates/constrictor/config.html new file mode 100644 index 000000000..b9ddc8f3f --- /dev/null +++ b/constrictor_gui/control/templates/constrictor/config.html @@ -0,0 +1,30 @@ + +
+

Configure Scripts and Drones

+ + + + + + + + + + + + +
Edit Connected Drones
Edit Test Scripts
Set Test Properties
+
+ diff --git a/constrictor_gui/control/templates/constrictor/docs.html b/constrictor_gui/control/templates/constrictor/docs.html new file mode 100644 index 000000000..d7ed4da64 --- /dev/null +++ b/constrictor_gui/control/templates/constrictor/docs.html @@ -0,0 +1,117 @@ + + +{% extends 'admin/base_site.html' %} +{% load i18n %} +{% block coltype %}colMS{% endblock %} +{% block bodyclass %}dashboard{% endblock %} +{% block breadcrumbs %}{% endblock %} + +{% block content %} + Home +

+
+

Glossary

+ + + + + + + + + +
Task + A single, timed unit of work. A task usually corresponds to an atomic action. +
Script + A collection of one or more tasks performed in a certain sequence. +
+
+
+
+

Configure and Run Tests

+ + + + + + + + + + + + + + + + + + + + + + + +
Thread Count + The number of simultaneous threads of execution on each of the + connected drones. +
Iterations-per-Thread + The number of times a test will be run within each thread of + execution on each drone. The total number of tests run on a given drone + will be the number of threads times the number of iterations per thread. +
Test Script + The test script to run the next time tests are run. +
Reset Connections + disconnects and attempts to reconnect to all configured drones. + Use this after adding or removing drones from the configuration + or after any of the services on the connected drones has been restarted. +
Run TestsTells all connected drones to run the currently configured test
+
+
+
+

Configure Scripts and Drones

+ + + + + + + + + + + + + + + + +
Edit Connected Drones + This option allows you to add, edit, and remove connected test drones. +
Edit Test Scripts + This option allows you to see and edit information about configured test scripts. +
Set Test Properties + This option allows you to set test-specific properties
+
+
+
+

Drone Status

+
+ Displays the list of configured drones and the current status of the + connections to those drones. +
+
+{% endblock %} + diff --git a/constrictor_gui/control/templates/constrictor/drones.html b/constrictor_gui/control/templates/constrictor/drones.html new file mode 100644 index 000000000..d2e9e89cf --- /dev/null +++ b/constrictor_gui/control/templates/constrictor/drones.html @@ -0,0 +1,108 @@ + + +{% load i18n %} +
+

Drone Status

+ + + {% for data in droneData %} + + + + + {% endfor %} + +
+ {{ data.drone.address }}:{{ data.drone.port }} + + {% if data.running %} + {% trans 'Running...' %} + {% else %} + {% if data.drone.failed %} + {{ data.drone.failed }} + {% else %} + {% if data.summary %} + + + + + + + + + + + + + + + + + + + + {% ifequal data.summary.thread_count '1' %} + + {% else %} + + {% endifequal %} + + + + {% ifequal data.summary.thread_count '1' %} + + {% else %} + + {% endifequal %} + + + + + +
Number of Successful Tasks{{ data.summary.num_task_success }}
Number of Failed Tasks + {% ifequal data.summary.num_task_failed '0' %} + 0 + {% else %} + {{ data.summary.num_task_failed }} + {% endifequal %} +
Total Duration{{ data.summary.task_set_duration|floatformat:8}}
Average Task Duration{{ data.summary.avg_task_duration|floatformat:8}}
Total Duration / # Successful Tasks{% trans 'N/A' %}{{ data.summary.amortized_task_duration|floatformat:8 }}
Avg. Tasks per Second{% trans 'N/A' %}{{ data.summary.amortized_tasks_per_second|floatformat:8 }}
+
+

Task Details

+ + + + + + + {%for info in data.summary.task_type_summary%} + + + + + + {%endfor%} + +
Name# RunsAvg. Duration
{{info.task_name}}{{info.task_count}}{{info.task_avg_duration}}
+
+
+ {% else %} + {% trans 'Connected' %} + {% endif %} + {% endif %} + {% endif %} +
+
+ diff --git a/constrictor_gui/control/templates/constrictor/index.html b/constrictor_gui/control/templates/constrictor/index.html new file mode 100644 index 000000000..5f2d269b8 --- /dev/null +++ b/constrictor_gui/control/templates/constrictor/index.html @@ -0,0 +1,66 @@ + + +{% extends 'admin/index.html' %} +{% load i18n %} +{% block coltype %}colMS{% endblock %} +{% block bodyclass %}dashboard{% endblock %} +{% block breadcrumbs %}{% endblock %} + +{% block content %} +
+
+ {% if isRunning %}{% endif %} +
+ {% include 'constrictor/actions.html' %} +
+ {% include 'constrictor/config.html' %} +
+
+ {% include 'constrictor/drones.html' %} +
+{% endblock %} + +{% block sidebar %} + + +{% endblock %} + diff --git a/constrictor_gui/control/templates/constrictor/props.html b/constrictor_gui/control/templates/constrictor/props.html new file mode 100644 index 000000000..56bd3cd46 --- /dev/null +++ b/constrictor_gui/control/templates/constrictor/props.html @@ -0,0 +1,79 @@ + + +{% extends 'admin/index.html' %} +{% load i18n %} + +{% block content %} + +
+

Set Test Properties

+ +
+ + + + + + +
+ + + + +
+
+ +
+

Test Description

+
+
+ +
+ +

+ + * Note: Properties that accept multiple values should be formatted as comma-separated lists +
+{% endblock %} + + + diff --git a/constrictor_gui/control/views.py b/constrictor_gui/control/views.py new file mode 100644 index 000000000..8ce8a8c54 --- /dev/null +++ b/constrictor_gui/control/views.py @@ -0,0 +1,213 @@ +# ----------------------------------------------------------------------- +# Copyright (C) 2007-2008 King County Library System +# Bill Erickson +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# ----------------------------------------------------------------------- +from django.http import HttpResponse, HttpResponseRedirect +from django.shortcuts import get_object_or_404, render_to_response +from constrictor.controller import GUIControllerSet +from constrictor.properties import Properties +from constrictor_gui.control.models import Drone, Script, Property, Plugin +from constrictor.log import * + +from django.contrib.admin.views.decorators import staff_member_required +from django.views.decorators.cache import never_cache + + + +controllerSet = None +MAIN_TEMPLATE = 'constrictor/index.html' +SET_PROP_TEMPLATE = 'constrictor/props.html' +TITLE = 'Constrictor' +globalThreadCount = 2 +globalIterationCount = 5 +globalSelectedScript = 'sleep' +isRunning = False +runningBatch = None +actionList = [] +initPropsSet = False + + +def __init(): + global controllerSet + if not controllerSet or controllerSet.isEmpty(): + controllerSet = GUIControllerSet() + for drone in Drone.objects.all(): # filter option for enabled? + if drone.enabled: + controllerSet.createController(drone.address, drone.port, drone.id) + + +def collectDroneData(): + global controllerSet, runningBatch, isRunning + + responded = {} + if runningBatch: + responded = runningBatch.recv(0) + if runningBatch.complete: + isRunning = False + else: + isRunning = False + + droneData = [] + + for d in controllerSet.getSet(): + info = {'drone': d, 'summary': responded.get(d.name)} + + if info['summary']: + info['running'] = False + tsummary = info['summary'].get('task_type_summary') + + if tsummary: + if not isinstance(tsummary, list): # we've already parsed this summary + taskInfo = tsummary.split(';') + info['summary']['task_type_summary'] = [] + for t in taskInfo: + if not t: continue + tn, tc, ts = t.split(':') + info['summary']['task_type_summary'].append({ + 'task_name' : tn, + 'task_count' : tc, + 'task_avg_duration' : ts + }) + + else: + if isRunning: + info['running'] = True + droneData.append(info) + + for d in controllerSet.getFailed(): + droneData.append({'drone':d}) + + return droneData + + +def makeContext(action=None): + global controllerSet, runningBatch, isRunning + + if action: + actionList.append(_(action)) + + args = { + 'globalIterationCount': globalIterationCount, + 'globalThreadCount': globalThreadCount, + 'droneData': collectDroneData(), + 'isRunning' : isRunning, + 'scripts' : Script.objects.all(), + 'selectedScript' : globalSelectedScript, + 'properties' : Property.objects.all(), + 'plugins' : Plugin.objects.all() + } + + if actionList: + a = list(actionList)[-10:] + args['actionList'] = a + + if runningBatch and not runningBatch.complete: + args['display'] = _('Running Tests...') + + return args + + +def index(request): + global initPropsSet + __init() + if not initPropsSet: + _updateBasicProps() + initPropsSet = True + return render_to_response(MAIN_TEMPLATE, makeContext()) +index = staff_member_required(never_cache(index)) + + +def redirect(request): + return HttpResponseRedirect('/control/') + +def reset(request): + global runningBatch + __init() + controllerSet.reset() + __init() + if runningBatch: + runningBatch.clearResponded() + return render_to_response(MAIN_TEMPLATE, makeContext('Reset')) +reset = staff_member_required(never_cache(reset)) + + +def run(request): + __init() + global controllerSet, isRunning, runningBatch + + isRunning = True + runningBatch = controllerSet.runBatch() + return render_to_response(MAIN_TEMPLATE, makeContext('Run Tests')) +run = staff_member_required(never_cache(run)) + + +def editDrones(request): + __init() + return render_to_response(MAIN_TEMPLATE, makeContext('Edit Drones')) +editDrones = staff_member_required(never_cache(editDrones)) + +def doc(request): + return render_to_response('constrictor/docs.html', {}) + +def setProps(request): + __init() + + if request.GET.get('propval'): + + prop = request.GET['prop'] + propval = request.GET['propval'] + drone = request.GET['drone'] + + + if drone == 'all': + controllerSet.broadcastCommand(action='setprop', prop=prop, val=propval) + else: + controllerSet.sendCommand(drone, action='setprop', prop=prop, val=propval) + + return render_to_response(SET_PROP_TEMPLATE, makeContext('Set Properties')) + + else: + return render_to_response(SET_PROP_TEMPLATE, makeContext('Set Properties')) +setProps = staff_member_required(never_cache(setProps)) + +def saveProps(request): + __init() + controllerSet.broadcastCommand(action='saveprops') + return render_to_response(SET_PROP_TEMPLATE, makeContext('Set Properties')) + +saveProps = staff_member_required(never_cache(saveProps)) + + + +def _updateBasicProps(): + global globalThreadCount, globalIterationCount, globalSelectedScript + controllerSet.broadcastCommand( + action='setprop', prop='constrictor.numThreads', val=globalThreadCount) + controllerSet.broadcastCommand( + action='setprop', prop='constrictor.numIterations', val=globalIterationCount) + controllerSet.broadcastCommand( + action='setprop', prop='constrictor.script', val=globalSelectedScript+'.py') + +def updateBasicProps(request): + global globalThreadCount, globalIterationCount, globalSelectedScript + __init() + + globalThreadCount = request.GET['threadCount'] + globalIterationCount = request.GET['iterationCount'] + globalSelectedScript = request.GET['selectedScript'] + _updateBasicProps() + + return render_to_response(MAIN_TEMPLATE, makeContext('Update Basic Props')) + +updateBasicProps = staff_member_required(updateBasicProps) + diff --git a/constrictor_gui/manage.py b/constrictor_gui/manage.py new file mode 100644 index 000000000..5e78ea979 --- /dev/null +++ b/constrictor_gui/manage.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python +from django.core.management import execute_manager +try: + import settings # Assumed to be in the same directory. +except ImportError: + import sys + sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) + sys.exit(1) + +if __name__ == "__main__": + execute_manager(settings) diff --git a/constrictor_gui/settings.py b/constrictor_gui/settings.py new file mode 100644 index 000000000..465d699be --- /dev/null +++ b/constrictor_gui/settings.py @@ -0,0 +1,95 @@ +# Django settings for constrictor_gui project. + +DEBUG = True +TEMPLATE_DEBUG = DEBUG + +ADMINS = ( + # ('Your Name', 'your_email@domain.com'), +) + +MANAGERS = ADMINS + +DATABASE_ENGINE = 'sqlite3' # 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'ado_mssql'. +DATABASE_NAME = 'django.db' # Or path to database file if using sqlite3. +DATABASE_USER = '' # Not used with sqlite3. +DATABASE_PASSWORD = '' # Not used with sqlite3. +DATABASE_HOST = '' # Set to empty string for localhost. Not used with sqlite3. +DATABASE_PORT = '' # Set to empty string for default. Not used with sqlite3. + +# Local time zone for this installation. Choices can be found here: +# http://www.postgresql.org/docs/8.1/static/datetime-keywords.html#DATETIME-TIMEZONE-SET-TABLE +# although not all variations may be possible on all operating systems. +# If running in a Windows environment this must be set to the same as your +# system time zone. +TIME_ZONE = 'America/New_York' + +# Language code for this installation. All choices can be found here: +# http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes +# http://blogs.law.harvard.edu/tech/stories/storyReader$15 +LANGUAGE_CODE = 'en-us' + +SITE_ID = 1 + +# If you set this to False, Django will make some optimizations so as not +# to load the internationalization machinery. +USE_I18N = True + +# Absolute path to the directory that holds media. +# Example: "/home/media/media.lawrence.com/" +MEDIA_ROOT = '' + +# URL that handles the media served from MEDIA_ROOT. Make sure to use a +# trailing slash if there is a path component (optional in other cases). +# Examples: "http://media.lawrence.com", "http://example.com/media/" +MEDIA_URL = '' + +# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a +# trailing slash. +# Examples: "http://foo.com/media/", "/media/". +ADMIN_MEDIA_PREFIX = '/media/' + +# Make this unique, and don't share it with anybody. +SECRET_KEY = '8q9a02!=p-5esy_o2_**v+xmbgen5bi%jkc1v=0##py6s=q^0d' + +# List of callables that know how to import templates from various sources. +TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.load_template_source', + 'django.template.loaders.app_directories.load_template_source', +# 'django.template.loaders.eggs.load_template_source', +) + +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.middleware.doc.XViewMiddleware', +) + +ROOT_URLCONF = 'constrictor_gui.urls' + +import os.path +TEMPLATE_DIRS = ( + 'control/templates', + #os.path.join(os.path.basename(__file__), 'templates') + # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". + # Always use forward slashes, even on Windows. + # Don't forget to use absolute paths, not relative paths. +) + +INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.admin', + 'constrictor_gui.control', +) + + +import sys, os +#print os.path.join(os.path.basename(__file__), '/../') +#sys.path.append(os.path.join(os.path.basename(__file__), '/../')) + +# append the parent directory to the python path +sys.path.append(os.path.join(os.path.abspath(''), '..')) + diff --git a/constrictor_gui/urls.py b/constrictor_gui/urls.py new file mode 100644 index 000000000..6f1db140b --- /dev/null +++ b/constrictor_gui/urls.py @@ -0,0 +1,17 @@ +from django.conf.urls.defaults import * + +urlpatterns = patterns('', + (r'^$', 'constrictor_gui.control.views.redirect'), + (r'^control/$', 'constrictor_gui.control.views.index'), + (r'^control/reset/$', 'constrictor_gui.control.views.reset'), + (r'^control/run/$', 'constrictor_gui.control.views.run'), +# (r'^control/updateThreads/$', 'constrictor_gui.control.views.updateThreads'), +# (r'^control/updateIterations/$', 'constrictor_gui.control.views.updateIterations'), + (r'^control/editDrones/$', 'constrictor_gui.control.views.editDrones'), + (r'^control/doc/$', 'constrictor_gui.control.views.doc'), +# (r'^control/setScript/$', 'constrictor_gui.control.views.setScript'), + (r'^control/updateBasicProps/$', 'constrictor_gui.control.views.updateBasicProps'), + (r'^control/setProps/$', 'constrictor_gui.control.views.setProps'), + (r'^control/saveProps/$', 'constrictor_gui.control.views.saveProps'), + (r'^admin/', include('django.contrib.admin.urls')), +) diff --git a/contrib/evergreen/config.xml b/contrib/evergreen/config.xml new file mode 100644 index 000000000..a1534ca95 --- /dev/null +++ b/contrib/evergreen/config.xml @@ -0,0 +1,113 @@ + + + + + + + + + Test bundle for the Evergreen Open Source Integrated Library System (OpenILS). + + + + + + + The hostname or IP address of the Evergreen server + + + The URL base path the Evergreen gateway + + + The communication protocol used with the Evergreen gateway. Choices are XML and JSON. + + + The login name of the Evergreen user running the tests + + + The password of the Evergreen user running the tests + + + + The name of the workstation where tests should be run from. + The workstation is used to define "where" a test is occuring. + + + + Comma-separated list of asset.copy (copy) barcodes used in tests + + + Comma-separated list of biblio.record_entry (title) IDs for tests + + + + Comma-separated list of actor.usr (user) IDs for tests + + + Comma-separated list of actor.org_unit (Org Unit) IDs for tests + + + Path to the opensrf config file. Only necessary if evergreen.netProtocol is set to jabber + + + The opensrf config file context path. Only necessary if evergreen.netProtocol is set to jabber + + + + + + + + + + + + + + diff --git a/contrib/evergreen/eg_checkin.py b/contrib/evergreen/eg_checkin.py new file mode 100644 index 000000000..95ae5dec3 --- /dev/null +++ b/contrib/evergreen/eg_checkin.py @@ -0,0 +1,22 @@ +from constrictor.script import Script +import eg_utils +from eg_data import * +from eg_workflow import * + +eg_utils.init() + +class CheckinScript(Script): + + def onThreadInit(self, scriptThread): + eg_utils.initThread() + + def run(self): + + dm = DataManager() + copyBarcode = dm.getThreadData(PROP_COPY_BARCODES) + + evt = doCheckin(copyBarcode) + if not evt: return False + +ScriptManager.go(CheckinScript()) + diff --git a/contrib/evergreen/eg_checkout.py b/contrib/evergreen/eg_checkout.py new file mode 100644 index 000000000..fa28553e9 --- /dev/null +++ b/contrib/evergreen/eg_checkout.py @@ -0,0 +1,32 @@ +from constrictor.script import Script +from constrictor.log import * + +import eg_utils +from eg_data import * +from eg_workflow import * + +eg_utils.init() + + +class CheckoutScript(Script): + + def onThreadInit(self, scriptThread): + eg_utils.initThread() + + def run(self): + + dm = DataManager() + patronID = dm.getThreadData(PROP_PATRON_IDS) + copyBarcode = dm.getThreadData(PROP_COPY_BARCODES, True) + + evt = doCheckoutPermit(copyBarcode, patronID) + if not evt: return False + + evt = doCheckout(copyBarcode, patronID, evt['payload']) + if not evt: return False + + return True + +ScriptManager.go(CheckoutScript()) + + diff --git a/contrib/evergreen/eg_checkout_roundtrip.py b/contrib/evergreen/eg_checkout_roundtrip.py new file mode 100644 index 000000000..026bf2fbe --- /dev/null +++ b/contrib/evergreen/eg_checkout_roundtrip.py @@ -0,0 +1,35 @@ +from constrictor.script import Script +from constrictor.log import * + +import eg_utils +from eg_data import * +from eg_workflow import * + +eg_utils.init() + + +class CheckoutRoundtripScript(Script): + + def onThreadInit(self, scriptThread): + eg_utils.initThread() + + def run(self): + + dm = DataManager() + patronID = dm.getThreadData(PROP_PATRON_IDS) + copyBarcode = dm.getThreadData(PROP_COPY_BARCODES, True) + + evt = doCheckoutPermit(copyBarcode, patronID) + if not evt: return False + + evt = doCheckout(copyBarcode, patronID, evt['payload']) + if not evt: return False + + evt = doCheckin(copyBarcode) + if not evt: return False + + return True + +ScriptManager.go(CheckoutRoundtripScript()) + + diff --git a/contrib/evergreen/eg_data.py b/contrib/evergreen/eg_data.py new file mode 100644 index 000000000..d1d0de9ad --- /dev/null +++ b/contrib/evergreen/eg_data.py @@ -0,0 +1,88 @@ +from constrictor.properties import Properties +from constrictor.script import ScriptThread +from constrictor.log import * +from oils.utils.utils import unique + +PROP_USERNAME = 'evergreen.username' +PROP_PASSWORD = 'evergreen.password' +PROP_WORKSTATION = 'evergreen.workstation' +PROP_COPY_BARCODES = 'evergreen.copyBarcodes' +PROP_TITLE_IDS = 'evergreen.titleIDs' +PROP_PATRON_BARCODES = 'evergreen.patronBarcodes' +PROP_ORG_IDS = 'evergreen.orgIDs' +PROP_PATRON_IDS = 'evergreen.patronIDs' + +PROP_CONSTRICTOR_THREADS = 'constrictor.numThreads' + +class DataManagerException(Exception): + pass + +class DataManager(object): + ''' This module manages a global cache of test data ''' + + def __init__(self): + self.data = {} + self.props = Properties.getProperties() + self.readProps() + logDebug(self) + + def __str__(self): + s = 'DataManager() read properties:\n' + for p in [ + PROP_PASSWORD, + PROP_WORKSTATION, + PROP_COPY_BARCODES, + PROP_TITLE_IDS, + PROP_PATRON_BARCODES, + PROP_PATRON_IDS, + PROP_ORG_IDS ]: + + s += "\t%s=%s\n" % (p, str(self.data[p])) + return s + + + def readProps(self): + self.readProp(PROP_USERNAME) + self.readProp(PROP_PASSWORD) + self.readProp(PROP_WORKSTATION) + self.readProp(PROP_COPY_BARCODES, True) + self.readProp(PROP_TITLE_IDS, True) + self.readProp(PROP_PATRON_BARCODES, True) + self.readProp(PROP_PATRON_IDS, True) + self.readProp(PROP_ORG_IDS, True) + + def readProp(self, prop, split=False): + v = self.props.getProperty(prop) + if split and v: + v = unique(v.split(',')) + self.data[prop] = v + logDebug("DataManager set property %s => %s" % (prop, str(v))) + + def getThreadData(self, prop, noSharing=False): + ''' If the caller is requesting array-based data, we want to de-multiplex(?) + the requested data based on the currently running thread. In other + words, each thread should have its own view into the array of data + so that different threads are not sharing data. If there are more + running threads than unique points of data, then sharing is inevitable, + and it's assumed that's OK unless noSharing is set to true, in which + case an exception is raised. For atomic data, the value is returned + and no thread data is checked ''' + + data = self.data[prop] + if not isinstance(data, list): + return data + + currentThread = ScriptThread.getThreadID() + totalThreads = self.props.getProperty(PROP_CONSTRICTOR_THREADS) + + if len(data) > currentThread: + return data[currentThread] + + if noSharing: + raise DataManagerException( + "Too many threads for unique data. Thread index is %d, size of dataset is %d" % ( + currentThread, len(data))) + + # data sharing is OK + return data[currentThread % len(data)] + diff --git a/contrib/evergreen/eg_fetch_user_groups.py b/contrib/evergreen/eg_fetch_user_groups.py new file mode 100644 index 000000000..ad31efa99 --- /dev/null +++ b/contrib/evergreen/eg_fetch_user_groups.py @@ -0,0 +1,34 @@ +from constrictor.task import Task +from constrictor.script import Script, ScriptManager +from constrictor.log import * +from osrf.gateway import XMLGatewayRequest +import eg_utils + +SERVICE = 'open-ils.actor' +METHOD = 'open-ils.actor.groups.tree.retrieve' + + +class FetchUserGroupsTask(Task): + + def __init__(self): + Task.__init__(self, self.__class__.__name__) + + def run(self, **kwargs): + request = XMLGatewayRequest(SERVICE, METHOD) + return request.send() + + +eg_utils.init() +fetchGroupsTask = FetchUserGroupsTask().wrap() + + +class FetchUserGroupsScript(Script): + def run(self): + res = fetchGroupsTask.run() + logInfo('Fetched group tree with root "%s"' % res.name()) + return True + +ScriptManager.go(FetchUserGroupsScript()) + + + diff --git a/contrib/evergreen/eg_renew.py b/contrib/evergreen/eg_renew.py new file mode 100644 index 000000000..ce3c3d929 --- /dev/null +++ b/contrib/evergreen/eg_renew.py @@ -0,0 +1,29 @@ +from constrictor.script import Script +from constrictor.log import * + +import eg_utils +from eg_data import * +from eg_workflow import * + +eg_utils.init() + + +class RenewScript(Script): + + def onThreadInit(self, scriptThread): + eg_utils.initThread() + + def run(self): + + dm = DataManager() + copyBarcode = dm.getThreadData(PROP_COPY_BARCODES, True) + + evt = doRenew(copyBarcode) + if not evt: return False + + return True + + +ScriptManager.go(RenewScript()) + + diff --git a/contrib/evergreen/eg_tasks.py b/contrib/evergreen/eg_tasks.py new file mode 100644 index 000000000..7a7991016 --- /dev/null +++ b/contrib/evergreen/eg_tasks.py @@ -0,0 +1,169 @@ +from constrictor.task import Task +from constrictor.log import * +from osrf.gateway import XMLGatewayRequest +import eg_utils +from oils.const import * +from osrf.net_obj import * + +TASKS = {} + +def registerTask(task): + TASKS[task.name] = task.wrap() + +class AbstractMethodTask(Task): + ''' Generic superclass for tasks that perform a single + server-side operation ''' + + def __init__(self, name=None): + if not name: + name = self.__class__.__name__ + Task.__init__(self, name) + self.service = None + self.method = None + + def runMethod(self, *args): + return eg_utils.request(self.service, self.method, *args).send() + + def register(self): + TASKS[self.name] = self.wrap() + +class CheckoutPermitTask(AbstractMethodTask): + def __init__(self): + AbstractMethodTask.__init__(self) + self.service = OILS_APP_CIRC + self.method = 'open-ils.circ.checkout.permit' + + def run(self, **kw): + ''' kw[copy_barcode] The item barcode + kw[patron_id] The user's id ''' + + return self.runMethod(eg_utils.authtoken(), dict(kw)) + +registerTask(CheckoutPermitTask()) + + +class CheckoutTask(AbstractMethodTask): + + def __init__(self): + AbstractMethodTask.__init__(self) + self.service = OILS_APP_CIRC + self.method = 'open-ils.circ.checkout' + + def run(self, **kw): + ''' kw[copy_barcode] The item barcode + kw[patron_id] The user's id + kw[permit_key] The key returned by checkout permit ''' + + return self.runMethod(eg_utils.authtoken(), dict(kw)) + +registerTask(CheckoutTask()) + + +class RenewTask(AbstractMethodTask): + def __init__(self): + AbstractMethodTask.__init__(self) + self.service = OILS_APP_CIRC + self.method = 'open-ils.circ.renew' + + def run(self, **kw): + ''' kw[copy_barcode] The item barcode ''' + + return self.runMethod(eg_utils.authtoken(), dict(kw)) + +registerTask(RenewTask()) + + +class CheckinTask(AbstractMethodTask): + + def __init__(self): + AbstractMethodTask.__init__(self) + self.service = OILS_APP_CIRC + self.method = 'open-ils.circ.checkin' + + def run(self, **kw): + ''' kw[copy_barcode] ''' + return self.runMethod(eg_utils.authtoken(), dict(kw)) + +registerTask(CheckinTask()) + + +class AbortTransitTask(AbstractMethodTask): + def __init__(self): + AbstractMethodTask.__init__(self) + self.service = OILS_APP_CIRC + self.method = 'open-ils.circ.transit.abort' + + def run(self, **kw): + ''' kw[copy_barcode] ''' + return self.runMethod(eg_utils.authtoken(), {"barcode":kw['copy_barcode']}) + +registerTask(AbortTransitTask()) + +class TitleHoldPermitTask(AbstractMethodTask): + def __init__(self): + AbstractMethodTask.__init__(self) + self.service = OILS_APP_CIRC + self.method = 'open-ils.circ.title_hold.is_possible' + + def run(self, **kw): + ''' kw[title_id] + kw[patron_id] + kw[pickup_lib] + ''' + return self.runMethod(eg_utils.authtoken(), { + "patronid" : kw['patron_id'], + "titleid" : kw['title_id'], + "pickup_lib" : kw['pickup_lib']}) + +registerTask(TitleHoldPermitTask()) + + + +class TitleHoldTask(AbstractMethodTask): + def __init__(self): + AbstractMethodTask.__init__(self) + self.service = OILS_APP_CIRC + self.method = 'open-ils.circ.holds.create' + + def run(self, **kw): + ''' kw[title_id] + kw[patron_id] + kw[pickup_lib] + ''' + # construct the hold object + hold = osrfNetworkObject.ahr() + hold.pickup_lib(kw['pickup_lib']) + hold.usr(kw['patron_id']) + hold.target(kw['title_id']) + hold.hold_type('T') + + return self.runMethod(eg_utils.authtoken(), hold) + +registerTask(TitleHoldTask()) + + +class TitleHoldCancelTask(AbstractMethodTask): + def __init__(self): + AbstractMethodTask.__init__(self) + self.service = OILS_APP_CIRC + self.method = 'open-ils.circ.hold.cancel' + + def run(self, **kw): + ''' kw['hold_id'] ''' + return self.runMethod(eg_utils.authtoken(), kw['hold_id']) + +registerTask(TitleHoldCancelTask()) + + +class TitleHoldFetchAllTask(AbstractMethodTask): + + def __init__(self): + AbstractMethodTask.__init__(self) + self.service = OILS_APP_CIRC + self.method = 'open-ils.circ.holds.id_list.retrieve' + + def run(self, **kw): + ''' kw['patron_id'] ''' + return self.runMethod(eg_utils.authtoken(), kw['patron_id']) + +registerTask(TitleHoldFetchAllTask()) diff --git a/contrib/evergreen/eg_title_hold.py b/contrib/evergreen/eg_title_hold.py new file mode 100644 index 000000000..a5a3d04a7 --- /dev/null +++ b/contrib/evergreen/eg_title_hold.py @@ -0,0 +1,47 @@ +from constrictor.script import Script, ScriptThread +import eg_utils +from eg_data import * +from eg_workflow import * + +eg_utils.init() + + +class CreateTitleHoldScript(Script): + + def onThreadInit(self, scriptThread): + # collect all of the holds the current thread user already has + # so any new holds can be cancelled after the thread is complete + eg_utils.initThread() + dm = DataManager() + patronID = dm.getThreadData(PROP_PATRON_IDS) + scriptThread.userData = doTitleHoldFetchAll(patronID) + logDebug("init: grabbed existing holds %s" % str([ int(i) for i in scriptThread.userData])) + + + def run(self): + + dm = DataManager() + titleID = dm.getThreadData(PROP_TITLE_IDS) + pickupLib = dm.getThreadData(PROP_ORG_IDS) + patronID = dm.getThreadData(PROP_PATRON_IDS) + + doTitleHold(titleID, patronID, pickupLib) + + # XXX TODO update EG to return hold IDs for new holds so + # that this script does not have to manually sort through + # the holds to find what to clean up + + # go ahead and cancel any new holds + allHolds = doTitleHoldFetchAll(patronID) + logDebug("grabbed all holds %s" % str([ int(i) for i in allHolds])) + + scriptThread = ScriptThread.currentScriptThread() + cancelHolds = [ h for h in allHolds if h not in scriptThread.userData ] + + for hold in cancelHolds: + doTitleHoldCancel(hold) + + + +ScriptManager.go(CreateTitleHoldScript()) + diff --git a/contrib/evergreen/eg_utils.py b/contrib/evergreen/eg_utils.py new file mode 100644 index 000000000..7995a1eb0 --- /dev/null +++ b/contrib/evergreen/eg_utils.py @@ -0,0 +1,169 @@ +from constrictor.properties import Properties +from constrictor.log import * +from osrf.net_obj import NetworkObject +import osrf.json +from osrf.gateway import GatewayRequest, XMLGatewayRequest, JSONGatewayRequest +from oils.utils.idl import IDLParser +from oils.utils.utils import eventText, eventCode, md5sum +from oils.const import * +import os, errno + +props = Properties.getProperties() + +def init(): + + loadIDL() + initOsrf() + + if props.getProperty('evergreen.autologin') == 'true': + login( + props.getProperty('evergreen.username'), + props.getProperty('evergreen.password'), + props.getProperty('evergreen.workstation')) + + user = None + if not props.getProperty('evergreen.patronIDs'): + # if there are not configured patron IDs, go ahead + # and use the ID of the logged in user + user = fetchSessionUser() + logInfo("Setting evergreen.patronIDs to logged in user %s" % str(user.id())) + props.setProperty('evergreen.patronIDs', str(user.id())) + + if not props.getProperty('evergreen.orgIDs'): + # simlilarly, if no org is provided, use the home org of the logged in user + if not user: + user = fetchSessionUser() + logInfo("Setting evergreen.orgIDs to logged in user's home_ou %s" % str(user.home_ou())) + props.setProperty('evergreen.orgIDs', str(user.home_ou())) + + + + +def initOsrf(): + # if necessary, create a connection to the opensrf network for this thread + if str(props.getProperty('evergreen.netProtocol')).lower() == 'jabber': + if props.getProperty('evergreen.osrfConfig'): + logInfo("Connecting to the opensrf network") + from osrf.system import osrfConnect + osrfConnect( + props.getProperty('evergreen.osrfConfig'), + props.getProperty('evergreen.osrfConfigContext')) + + +def initThread(): + ''' Performs thread-specific initialization ''' + initOsrf() + +def fetchSessionUser(): + user = request('open-ils.auth', 'open-ils.auth.session.retrieve', authtoken()).send() + if eventCode(user): + raise ILSEventException(osrf.json.to_json(user)) + logInfo("fetched user %s" % user.usrname()) + return user + +def loadIDL(): + + # XX add logic to allow IDL fetching via jabber + + server = props.getProperty('evergreen.server') + cacheDir = props.getProperty('constrictor.cacheDir') + GatewayRequest.setDefaultHost(server) + + import urllib2 + parser = IDLParser() + file = None + filePath = '%s/evergreen/fm_IDL.xml' % cacheDir + + try: + # see if we have a local copy of the IDL already in the cache + file = open(filePath, 'r') + + except IOError: + logInfo('fetching: http://%s/%s' % (server, props.getProperty('evergreen.IDLPath'))) + f = urllib2.urlopen('http://%s/%s' % (server, props.getProperty('evergreen.IDLPath'))) + + if not os.path.exists('%s/evergreen' % cacheDir): + os.mkdir('%s/evergreen' % cacheDir) + + file = open(filePath, 'w') + file.write(f.read()) + file.close() + + logInfo("parsing Evergreen IDL file...") + parser.setIDL(filePath) + parser.parseIDL() + + +class AtomicReqWrapper(object): + ''' This wraps the built-in osrfAtomicRequest in a + gateway request-style class interface ''' + + def __init__(self, service, method, *args): + self.service = service + self.method = method + self.args = list(args) + + def send(self): + from osrf.ses import osrfAtomicRequest + return osrfAtomicRequest(self.service, self.method, *(self.args)) + + + +def request(service, method, *args): + global props + proto = props.getProperty('evergreen.netProtocol') + if str(proto).lower() == 'jabber': + req = AtomicReqWrapper(service, method, *args) + else: + if str(proto).lower() == 'json': + req = JSONGatewayRequest(service, method, *args) + else: + req = XMLGatewayRequest(service, method, *args) + req.setPath(props.getProperty('evergreen.gatewayPath')) + return req + + +__authtoken = None +def authtoken(): + if not __authtoken: + raise AuthException() + return __authtoken + +def login(username, password, workstation=None): + ''' Login to the server and get back an authtoken''' + global __authtoken + + logInfo("attempting login with user " + username) + + seed = request( + 'open-ils.auth', + 'open-ils.auth.authenticate.init', username).send() + + # generate the hashed password + password = md5sum(seed + md5sum(password)) + + result = request( + 'open-ils.auth', + 'open-ils.auth.authenticate.complete', + { 'workstation' : workstation, + 'username' : username, + 'password' : password, + 'type' : 'staff' + }).send() + + if eventText(result) != OILS_EVENT_SUCCESS: + raise AuthException(eventText(result)) + + __authtoken = result['payload']['authtoken'] + return __authtoken + + +class AuthException(Exception): + def __init__(self, msg=''): + self.msg = msg + def __str__(self): + return 'AuthException: %s' % self.msg + +class ILSEventException(Exception): + pass + diff --git a/contrib/evergreen/eg_workflow.py b/contrib/evergreen/eg_workflow.py new file mode 100644 index 000000000..ba51766c7 --- /dev/null +++ b/contrib/evergreen/eg_workflow.py @@ -0,0 +1,142 @@ +from eg_tasks import TASKS +from eg_data import DataManager +from eg_utils import ILSEventException +from constrictor.log import * +from oils.utils.utils import eventText, eventCode +from oils.const import * +from osrf.json import osrfObjectToJSON + +SUCCESS = {'textcode':'SUCCESS', 'ilsevent':0} + +def doCheckoutPermit(copyBarcode, patronID, recurse=False): + + evt = TASKS['CheckoutPermitTask'].run(copy_barcode=copyBarcode, patron_id=patronID) + logInfo("CheckoutPermit(%s, %s) -> %s" % (copyBarcode, patronID, eventText(evt))) + + if eventText(evt) == OILS_EVENT_SUCCESS: + return evt + + if recurse: return None + + if eventText(evt) == 'OPEN_CIRCULATION_EXISTS': + evt = doCheckin(copyBarcode) + if eventText(evt) == OILS_EVENT_SUCCESS: + return doCheckoutPermit(copyBarcode, patronID, True) + logInfo("* Unable to checkin open circ: %s" % copyBarcode) + + return None + + +def doCheckout(copyBarcode, patronID, permitKey): + + evt = TASKS['CheckoutTask'].run( + copy_barcode=copyBarcode, patron_id=patronID, permit_key=permitKey) + logInfo("Checkout(%s,%s) -> %s" % (copyBarcode, patronID, eventText(evt))) + + if eventText(evt) == OILS_EVENT_SUCCESS: + return evt + + return None + + +def doRenew(copyBarcode): + + evt = TASKS['RenewTask'].run(copy_barcode=copyBarcode) + logInfo("Checkout(%s) -> %s" % (copyBarcode, eventText(evt))) + + if eventText(evt) == OILS_EVENT_SUCCESS: + return evt + + return None + + + +def doCheckin(copyBarcode): + + evt = TASKS['CheckinTask'].run(copy_barcode=copyBarcode) + logInfo("Checkin(%s) -> %s" % (copyBarcode, eventText(evt))) + + if eventText(evt) == OILS_EVENT_SUCCESS or eventText(evt) == 'NO_CHANGE': + return SUCCESS + + if eventText(evt) == 'ROUTE_ITEM': + + logInfo("Cancelling post-checkin transit...") + evt = TASKS['AbortTransitTask'].run(copy_barcode=copyBarcode) + + if eventCode(evt): # transit returns "1" on success + logError("Unable to abort transit for %s : %s" % (copyBarcode, osrfObjectToJSON(evt))) + return None + + return SUCCESS + + return None + + + +def doTitleHoldPermit(titleID, patronID, pickupLib): + + evt = TASKS['TitleHoldPermitTask'].run( + title_id=titleID, patron_id=patronID, pickup_lib=pickupLib) + + if eventCode(evt): + raise ILSEventException( + "TitleHoldPermit(%s, %s, %s) failed -> %s" % ( + titleID, patronID, pickupLib, osrfObjectToJSON(evt))) + + if str(evt) != '1': + logInfo("TitleHoldPermit(%s, %s, %s) not allowed -> %s" % ( + titleID, patronID, pickupLib, evt)) + return None + + logInfo('TitleHoldPermit(%s, %s, %s) -> SUCCESS' % (titleID, patronID, pickupLib)) + return True + + +def doTitleHold(titleID, patronID, pickupLib): + + if not doTitleHoldPermit(titleID, patronID, pickupLib): + return + + evt = TASKS['TitleHoldTask'].run(title_id=titleID, patron_id=patronID, pickup_lib=pickupLib) + + if isinstance(evt, list): + evts = [] + for e in evt: + evts.append(e['textcode']) + logInfo("TitleHold(%s, %s, %s) -> %s" % (titleID, patronID, pickupLib, str(evts))) + return None + + if eventCode(evt): + logInfo("TitleHold(%s, %s, %s) -> %s" % (titleID, patronID, pickupLib, eventCode(evt))) + return None + + if str(evt) != '1': + logInfo("TitleHold(%s, %s, %s) placement failed: %s" % ( + titleID, patronID, pickupLib, str(evt))) + return None + + logInfo('TitleHold(%s, %s, %s) -> SUCCESS' % (titleID, patronID, pickupLib)) + return True + +def doTitleHoldCancel(holdID): + + evt = TASKS['TitleHoldCancelTask'].run(hold_id=holdID) + + if eventCode(evt): + if eventText(evt) == 'ACTION_HOLD_REQUEST_NOT_FOUND': + return True + raise ILSEventException( + "TitleHoldCancel(%s) failed -> %s" % (holdID, str(evt))) + + logInfo('TitleHoldCancel(%s) -> SUCCESS' % holdID) + return True + +def doTitleHoldFetchAll(patronID): + evt = TASKS['TitleHoldFetchAllTask'].run(patron_id=patronID) + if eventCode(evt): + raise ILSEventException( + "TitleHoldFetchAll(%s) failed -> %s" % (patronID, evt)) + return evt + + diff --git a/deploy.py b/deploy.py new file mode 100755 index 000000000..d638bfca3 --- /dev/null +++ b/deploy.py @@ -0,0 +1,166 @@ +#!/usr/bin/python +# ----------------------------------------------------------------------- +# Copyright (C) 2007-2008 King County Library System +# Bill Erickson +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# ----------------------------------------------------------------------- + +import os, sys, getopt, re, constrictor.properties + +# tell django where to find the settings file +os.environ['DJANGO_SETTINGS_MODULE'] = 'constrictor_gui.settings' + +from django.contrib.auth.models import User +from constrictor_gui.control.models import Drone, Script, Plugin, Property +from constrictor.utils import loadProps +import constrictor_gui.settings as settings +from constrictor.log import * +import xml.dom.minidom + + +ops, args = getopt.getopt(sys.argv[1:], 'fu') +options = dict( (k,v) for k,v in ops ) + + +def buildDjangoDB(): + + ''' Re-synchronizes the Django db from the django models. + re-creates the admin user. + If there are no drones configured, we create a default drone at 127.0.0.1 ''' + + global options + if not options.has_key('-f'): + r = raw_input('This will destroy the existing django database. continue? [yes/no] ') + if r.lower() != 'yes': + sys.exit(0) + + if os.path.exists(settings.DATABASE_NAME): + os.remove(settings.DATABASE_NAME) + + if os.system('python manage.py --noinput syncdb') != 0: + # tell django to build the base tables + sys.stderr.write('Error syncing database...') + sys.exit(1) + + # create the admin user + admin = User.objects.create_user('admin', 'admin@example.org', 'constrictor') + admin.is_staff = True + admin.is_superuser = True + admin.save() + + # create a default drone if there are none + if len(Drone.objects.all()) == 0: + Drone(address='127.0.0.1', port=constrictor.utils.DEFAULT_PORT, enabled=True).save() + + +def getXMLAttr(node, name, ns=None): + for (k, v) in node.attributes.items(): + if k == name: + return v + return None + + + +def loadModuleConfigs(): + ''' Returns the DOM nodes for the XML configs if a config is found ''' + + props = constrictor.properties.Properties.getProperties() + scriptDirs = props.getProperty('constrictor.scriptDirs').split(',') + configs = [] + for d in scriptDirs: + conf = None + try: + confFile = "%s/%s" % (d, constrictor.utils.CONFIG_FILENAME) + print "Parsing config file: " + confFile + conf = xml.dom.minidom.parse(confFile) + configs.append(conf) + except: + continue + return configs + +def updateDjangoPlugins(): + configs = loadModuleConfigs() + + for conf in configs: + name = getXMLAttr(conf.documentElement, 'plugin') + desc = conf.documentElement.getElementsByTagName('desc')[0] + desc = desc.childNodes[0].nodeValue + print "Registering plugin %s" % name + plugin = Plugin(name=name, description=desc) + plugin.save() + + updateDjangoScripts(plugin, conf) + updateDjangoProperties(plugin, conf) + +def updateDjangoScripts(plugin, conf): + + for scriptNode in conf.getElementsByTagName('script'): + # read the scripts from the config and insert them into + # the django db if publish is true + + if getXMLAttr(scriptNode, 'publish')+''.lower() != 'true': + continue + name = getXMLAttr(scriptNode, 'name') + + # see if this script already exists in the database + existing = Script.objects.filter(name=name) + if len(existing) > 0: continue + + print 'Registering script "%s" for module "%s"' % (name, plugin.name) + + #if it does not exist in the db, create it + desc = scriptNode.getElementsByTagName('desc')[0] + if desc and len(desc.childNodes) > 0: + desc = desc.childNodes[0].nodeValue + + script = Script(plugin_id=plugin.id, name=name, description=desc or '') + script.save() + +def updateDjangoProperties(plugin, conf): + + for propNode in conf.getElementsByTagName('property'): + + if getXMLAttr(propNode, 'publish')+''.lower() != 'true': + continue + + name = getXMLAttr(propNode, 'name') + existing = Property.objects.filter(name=name) + if len(existing) > 0: continue + + print 'Registering property "%s" for module "%s"' % (name, plugin.name) + + #if it does not exist in the db, create it + desc = propNode.getElementsByTagName('desc')[0] + if desc and len(desc.childNodes) > 0: + desc = desc.childNodes[0].nodeValue + + property = Property(plugin_id=plugin.id, name=name, description=desc or '') + property.save() + + + + +loadProps('consctrictor.properties') +basedir = os.getcwd() +os.chdir('constrictor_gui') +os.environ['PYTHONPATH'] = os.path.join(os.path.abspath(''), '..') +if not options.has_key('-u'): + buildDjangoDB() +else: + # force a db connection so changing dirs won't affect the db + print "We already have the following scripts: %s" \ + % str(tuple([s.name for s in Script.objects.all()])) + +os.chdir(basedir) +updateDjangoPlugins(); + + diff --git a/runcontroller.py b/runcontroller.py new file mode 100755 index 000000000..fe5f85e2c --- /dev/null +++ b/runcontroller.py @@ -0,0 +1,29 @@ +#!/usr/bin/python +# ----------------------------------------------------------------------- +# Copyright (C) 2007-2008 King County Library System +# Bill Erickson +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# ----------------------------------------------------------------------- + + +''' +This launches the Django server. This script is especially useful for +running Django from a local (non-system-wide) install of Django +''' + +import os, sys + +os.chdir('constrictor_gui') +os.environ['PYTHONPATH'] = os.path.join(os.path.abspath(''), '..') + +if os.system('python manage.py runserver') != 0: + sys.stderr.write("Error starting Django admin") diff --git a/samples/config.xml b/samples/config.xml new file mode 100644 index 000000000..4d242f626 --- /dev/null +++ b/samples/config.xml @@ -0,0 +1,15 @@ + + + + + Sample plugin + + + + + diff --git a/samples/sleep.py b/samples/sleep.py new file mode 100755 index 000000000..5c7382c32 --- /dev/null +++ b/samples/sleep.py @@ -0,0 +1,50 @@ +#!/usr/bin/python +# -------------------------------------------------------------- +# Simple script sample. Eacch task just sleeps for some portion +# of a second +# -------------------------------------------------------------- + + +from constrictor.task import Task +from constrictor.script import Script, ScriptManager +from constrictor.properties import Properties +import random, time + + +class MyTask(Task): + ''' Subclass the Task class, call the superclass constructor, + and define the run() method ''' + + def __init__(self, name=''): + Task.__init__(self, name) + + def run(self): + # perform a random sleep + s = float(random.randint(0,3)) / random.randint(1,6) + if self.name == 'task1': s /= 2 + time.sleep(s) + return self.name + + +# wrap the tasks so they can be analyzed +task = MyTask('task1').wrap() +task2 = MyTask('task2').wrap() +task3 = MyTask('task3').wrap() +task4 = MyTask('task4').wrap() + + +class MyScript(Script): + ''' Subclass the Script class, define the run() method ''' + def run(self): + ret = task.run() + ret = task2.run() + ret = task3.run() + ret = task4.run() + ret = task.run() + ret = task2.run() + return True + +# Launch the script +ScriptManager.go(MyScript()) + + -- 2.11.0