pax_global_header00006660000000000000000000000064122254602500014510gustar00rootroot0000000000000052 comment=20e778788f70637ce54e3ba3112c1607553c31c6 buildbot-0.8.8/000077500000000000000000000000001222546025000133315ustar00rootroot00000000000000buildbot-0.8.8/COPYING000066400000000000000000000354221222546025000143720ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) 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 this service 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 make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. 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. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), 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 distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the 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 a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE 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. END OF TERMS AND CONDITIONS buildbot-0.8.8/CREDITS000066400000000000000000000074401222546025000143560ustar00rootroot00000000000000This is a list of everybody who has contributed to Buildbot in some way, in no particular order. Thanks everybody! A. T. Hofkamp Aaron Hsieh Abdelrahman Hussein Adam Collard Adam MacBeth Adam Sjøgren Adam Slater Adam Vandenberg Alexander Lorenz Alexander Staubo Aloisio Almeida Jr Amar Takhar Amber Yust Andi Albrecht Andreas Lawitzky Andrew Bennetts Andrew Bortz Andrew Melo Andrew Straw Andriy Senkovych Andy Howell Anthony Baxter Arkadiusz Miskiewicz Augie Fackler Aurélien Bompard Aviv Ben-Yosef Axel Hecht Baptiste Lepilleur Ben Bangert Ben Hearsum Benjamin Smedberg Benoit Sigoure Benoît Allard Bobby Impollonia Brad Hards Brandon Ehle Brandon Philips Brandon R. Stoner Brett Neely Brian Campbell Brian Warner Chad S Metcalf Charles Davis Charles Hardin Charles Lepple Chase Phillips Chris AtLee Chris Peyer Chris Rivera Chris Soyars Chris Templin Christian Lins Christian Unger Claude Vittoria Clement Stenac Dan Kegel Dan Locks Dan Savilonis Dan Scott Daniel Dunbar Daniel Svensson Darragh Bailey Dave Abrahams Dave Liebreich Dave Peticolas David Adam (zanchey) Derek Hurley Dmitry Gladkov Dmitry Nezhevenko Dobes Vandermeer Doug Goldstein Doug Latornell Douglas Hubler Douglas Leeder Duncan Ferguson Dustin J. Mitchell Dustin Sallings Elliot Murphy Fabrice Crestois Federico G. Schwindt Filip Hautekeete François Poirotte Gabriele Giacone Gareth Armstrong Gary Granger Gary Poster Gavin McDonald Georges Racinet Georgi Valkov Gerald Combs Gerard Escalante Geraud Boyer Greg McNew Greg Ward Grig Gheorghiu Haavard Skinnemoen Harry Borkhuis Ian Zimmerman Igor Slepchin Iustin Pop Jakub Gustak James Knight James Porter James Tomson Jared Grubb Jared Morrow Jason Hoos Jay Soffian Jean-Paul Calderone Jeff Bailey Jeff Olson Jeremy Gill Jerome Davann Jochen Eisinger Johan Bergström John Backstrand John Carr John F Leach John Ford John O'Duinn John Pye John Saxton Johnnie Pittman Jon Olsson Jonathan Romero Jonathan S. Romero Jorge Gonzalez Jose Dapena Paz Joshua Kugler Joshua Olson Joshua Root Julien Boeuf Justin Wood KATO Kazuyoshi Karl Norby Kevin Turner Kirill Lapshin Kovarththanan Rajaratnam Kristian Nielsen Lital Natan Louis Opter Love Hörnquist Åstrand Loïc Minier Lukas Blakk Łukasz Jernaś Marc Abramowitz Marc Mengel Marc-Antoine Ruel Marcus Lindblom Marius Gedminas Mark A. Grondona Mark Dillavou Mark Hammond Mark Lakewood Mark Pauley Mark Rowe Mark Wielaard Martin Nordholts Mateusz Loskot Matisse Enzer Matt Heitzenroder Matt Whiteley Matthew Scott Matthew Jacobi Mattias Brändström Michael Haggerty Michael Lyle Michael MacDonald Michael Stapelberg Michał Šrajer Mihai Parparita Mikael Lind Mike "Bear" Taylor Mikhail Gusarov Mirko Boehm Monty Taylor Nathaniel Smith Nate Bragg Neal Norwitz Neil Hemingway Nick Mathewson Nick Mills Nick Trout Nicolas Sylvain Nicolás Alvarez Niklaus Giger Olivier Bonnet Olly Betts P. Christeas Pam Selle Patrick Gansterer Paul Warren Paul Winkler Phil Thompson Philipp Frauenfelder Philippe McLean Pierre Tardy Piotr Sikora Pradeepkumar Gayam Quentin Raynaud Rafaël Carré Randall Bosetti Renato Alves Rene Müller Rene Rivera Riccardo Magliocchetti Richard Holden Richard Levitte Rob Helmer Robert Collins Robert Iannucci Robin Eckert Saurabh Kumar Satya Graha Scott Garman Scott Lamb Scott Lawrence Seo Sanghyeon Sergey Lipnevich Shawn Chin Shimizukawa Sidnei da Silva Simon Kennedy Stanislav Kupryakhin Stefan Marr Stefan Seefeld Stefan Zager Stephen Davis Steve "Ashcrow" Milner Steven Walter Stuart Auchterlonie Ted Mielczarek Terence Haddock Thijs Triemstra Thomas Moschny Thomas Vander Stichele Tim Hatch Timothy Fitz Tobi Vollebregt Tobias Oberstein Tom Fogal Tom Prince Tom Wardill Tomaz Muraus Umesh Patel Unknown tagger Wade Brainerd William Deegan William Siegrist Yoz Grahame Zandr Milewski Zellyn Hunter Zooko Wilcox-O'Hearn Name Unknown: adam chops code gollum gv lurker99 strank buildbot-0.8.8/MANIFEST.in000066400000000000000000000021271222546025000150710ustar00rootroot00000000000000include MANIFEST.in README CREDITS COPYING UPGRADING include docs/examples/*.cfg include docs/conf.py include docs/Makefile include docs/buildbot.1 include docs/*.rst include docs/_images/* include docs/_static/* include docs/_templates/* include docs/tutorial/*.rst include docs/tutorial/_images/*.png include docs/manual/*.rst include docs/manual/_images/*.svg include docs/manual/_images/*.txt include docs/manual/_images/*.ai include docs/manual/_images/icon.blend include docs/manual/_images/Makefile include docs/bbdocs/*.py include docs/developer/* include docs/relnotes/* include buildbot/scripts/sample.cfg include buildbot/scripts/buildbot_tac.tmpl include buildbot/status/web/files/* include buildbot/status/web/templates/*.html buildbot/status/web/templates/*.xml include buildbot/clients/debug.glade include buildbot/buildbot.png include buildbot/db/migrate/README buildbot/db/migrate/migrate.cfg include contrib/* contrib/windows/* contrib/os-x/* contrib/css/* contrib/libvirt/* include contrib/trac/* contrib/trac/bbwatcher/* contrib/trac/bbwatcher/templates/* include contrib/init-scripts/* buildbot-0.8.8/NEWS000066400000000000000000000154271222546025000140410ustar00rootroot00000000000000Release Notes for Buildbot v0.8.8 ================================= .. Any change that adds a feature or fixes a bug should have an entry here. Most simply need an additional bulleted list item, but more significant changes can be given a subsection of their own. The following are the release notes for Buildbot v0.8.8 Buildbot v0.8.8 was released on August 22, 2013 Master ------ Features ~~~~~~~~ * The ``MasterShellCommand`` step now correctly handles environment variables passed as list. * The master now poll the database for pending tasks when running buildbot in multi-master mode. * The algorithm to match build requests to slaves has been rewritten in :bb:pull:`615`. The new algorithm automatically takes locks into account, and will not schedule a build only to have it wait on a lock. The algorithm also introduces a ``canStartBuild`` builder configuration option which can be used to prevent a build request being assigned to a slave. * ``buildbot stop`` and ``buildbot restart`` now accept ``--clean`` to stop or restart the master cleanly (allowing all running builds to complete first). * The :bb:status:`IRC` bot now supports clean shutdown and immediate shutdown by using the command 'shutdown'. To allow the command to function, you must provide `allowShutdown=True`. * :bb:step:`CopyDirectory` has been added. * :bb:sched:`BuildslaveChoiceParameter` has been added to provide a way to explicitly choose a buildslave for a given build. * default.css now wraps preformatted text by default. * Slaves can now be paused through the web status. * The latent buildslave support is less buggy, thanks to :bb:pull:`646`. * The ``treeStableTimer`` for ``AnyBranchScheduler`` now maintains separate timers for separate branches, codebases, projects, and repositories. * :bb:step:`SVN` has a new option `preferLastChangedRev=True` to use the last changed revision for ``got_revision`` * The build request DB connector method :py:meth:`~buildbot.db.buildrequests.BuildRequestsConnectorComponent.getBuildRequests` can now filter by branch and repository. * A new :bb:step:`SetProperty` step has been added in ``buildbot.steps.master`` which can set a property directly without accessing the slave. * The new :bb:step:`LogRenderable` step logs Python objects, which can contain renderables, to the logfile. This is helpful for debugging property values during a build. * 'buildbot try' now has an additional :option:`--property` option to set properties. Unlike the existing :option:`--properties` option, this new option supports setting only a single property and therefore allows commas to be included in the property name and value. * The ``Git`` step has a new ``config`` option, which accepts a dict of git configuration options to pass to the low-level git commands. See :bb:step:`Git` for details. * In :bb:step:`ShellCommand` ShellCommand now validates its arguments during config and will identify any invalid arguments before a build is started. * The list of force schedulers in the web UI is now sorted by name. * OpenStack-based Latent Buildslave support was added. See :bb:pull:`666`. * Master-side support for P4 is available, and provides a great deal more flexibility than the old slave-side step. See :bb:pull:`596`. * Master-side support for Repo is available. The step parameters changed to camelCase. ``repo_downloads``, and ``manifest_override_url`` properties are no longer hardcoded, but instead consult as default values via renderables. Renderable are used in favor of callables for ``syncAllBranches`` and ``updateTarball``. * Builder configurations can now include a ``description``, which will appear in the web UI to help humans figure out what the builder does. * GNUAutoconf and other pre-defined factories now work correctly (:bb:bug:`2402`) * The pubDate in RSS feeds is now rendered correctly (:bb:bug:`2530`) Deprecations, Removals, and Non-Compatible Changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * The ``split_file`` function for :bb:chsrc:`SVNPoller` may now return a dictionary instead of a tuple. This allows it to add extra information about a change (such as ``project`` or ``repository``). * The ``workdir`` build property has been renamed to ``builddir``. This change accurately reflects its content; the term "workdir" means something different. ``workdir`` is currently still supported for backwards compatability, but will be removed eventually. * The ``Blocker`` step has been removed. * Several polling ChangeSources are now documented to take a ``pollInterval`` argument, instead of ``pollinterval``. The old name is still supported. * StatusReceivers' checkConfig method should no longer take an `errors` parameter. It should indicate errors by calling :py:func:`~buildbot.config.error`. * Build steps now require that their name be a string. Previously, they would accept anything, but not behave appropriately. * The web status no longer displays a potentially misleading message, indicating whether the build can be rebuilt exactly. * The ``SetProperty`` step in ``buildbot.steps.shell`` has been renamed to :bb:step:`SetPropertyFromCommand`. * The EC2 and libvirt latent slaves have been moved to ``buildbot.buildslave.ec2`` and ``buildbot.buildslave.libirt`` respectively. * Pre v0.8.7 versions of buildbot supported passing keyword arguments to ``buildbot.process.BuildFactory.addStep``, but this was dropped. Support was added again, while still being deprecated, to ease transition. Changes for Developers ~~~~~~~~~~~~~~~~~~~~~~ * Added an optional build start callback to ``buildbot.status.status_gerrit.GerritStatusPush`` This release includes the fix for :bb:bug:`2536`. * An optional ``startCB`` callback to :bb:status:`GerritStatusPush` can be used to send a message back to the committer. See the linked documentation for details. * bb:sched:`ChoiceStringParameter` has a new method ``getChoices`` that can be used to generate content dynamically for Force scheduler forms. Slave ----- Features ~~~~~~~~ * The fix for Twisted bug #5079 is now applied on the slave side, too. This fixes a perspective broker memory leak in older versions of Twisted. This fix was added on the master in Buildbot-0.8.4 (see :bb:bug:`1958`). * The ``--nodaemon`` option to ``buildslave start`` now correctly prevents the slave from forking before running. Deprecations, Removals, and Non-Compatible Changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Details ------- For a more detailed description of the changes made in this version, see the git log itself:: git log v0.8.7..v0.8.8 Older Versions -------------- Release notes for older versions of Buildbot are available in the :bb:src:`master/docs/relnotes/` directory of the source tree. Newer versions are also available here: .. toctree:: :maxdepth: 1 0.8.7 0.8.6 buildbot-0.8.8/PKG-INFO000066400000000000000000000027101222546025000144260ustar00rootroot00000000000000Metadata-Version: 1.1 Name: buildbot Version: 0.8.8 Summary: BuildBot build automation system Home-page: http://buildbot.net/ Author: Dustin J. Mitchell Author-email: dustin@v.igoro.us License: GNU GPL Description: The BuildBot is a system to automate the compile/test cycle required by most software projects to validate code changes. By automatically rebuilding and testing the tree each time something has changed, build problems are pinpointed quickly, before other developers are inconvenienced by the failure. The guilty developer can be identified and harassed without human intervention. By running the builds on a variety of platforms, developers who do not have the facilities to test their changes everywhere before checkin will at least know shortly afterwards whether they have broken the build or not. Warning counts, lint checks, image size, compile time, and other build parameters can be tracked over time, are more visible, and are therefore easier to improve. Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: No Input/Output (Daemon) Classifier: Environment :: Web Environment Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: GNU General Public License (GPL) Classifier: Topic :: Software Development :: Build Tools Classifier: Topic :: Software Development :: Testing buildbot-0.8.8/README000066400000000000000000000045211222546025000142130ustar00rootroot00000000000000 BuildBot: build/test automation http://buildbot.net Brian Warner Dustin J. Mitchell Buildbot is a continuous integration system designed to automate the build/test cycle. By automatically rebuilding and testing the tree each time something has changed, build problems are pinpointed quickly, before other developers are inconvenienced by the failure. Features * Buildbot is easy to set up, but very extensible and customizable. It supports arbitrary build processes, and is not limited to common build processes for particular languages (e.g., autotools or ant) * Buildbot supports building and testing on a variety of platforms. Developers, who do not have the facilities to test their changes everywhere before committing, will know shortly afterwards whether they have broken the build or not. * Buildbot has minimal requirements for slaves: using virtualenv, only a Python installation is required. * Slaves can be run behind a NAT firewall and communicate with the master * Buildbot has a variety of status-reporting tools to get information about builds in front of developers in a timely manner. DOCUMENTATION: See http://buildbot.net/buildbot/docs/current for documentation of the current version of Buildbot. REQUIREMENTS: See http://buildbot.net/buildbot/docs/latest/manual/installation.html Briefly: python, Twisted, Jinja, simplejson, and SQLite. Simplejson and SQLite are included with recent versions of Python. SUPPORT: Please send questions, bugs, patches, etc, to the buildbot-devel mailing list reachable through http://buildbot.net/, so that everyone can see them. COPYING: Buildbot 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, version 2. 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. For full details, please see the file named COPYING in the top directory of the source tree. You should have received a copy of the GNU General Public License along with this program. If not, see . buildbot-0.8.8/UPGRADING000066400000000000000000000002631222546025000145750ustar00rootroot00000000000000For information on ugprading Buildbot, see the section "Upgrading an Existing Buildmaster" in the buildbot documentation. This may be found locally in docs/installation.texinfo. buildbot-0.8.8/bin/000077500000000000000000000000001222546025000141015ustar00rootroot00000000000000buildbot-0.8.8/bin/buildbot000077500000000000000000000001101222546025000156230ustar00rootroot00000000000000#!/usr/bin/env python from buildbot.scripts import runner runner.run() buildbot-0.8.8/buildbot/000077500000000000000000000000001222546025000151355ustar00rootroot00000000000000buildbot-0.8.8/buildbot/VERSION000066400000000000000000000000051222546025000162000ustar00rootroot000000000000000.8.8buildbot-0.8.8/buildbot/__init__.py000066400000000000000000000030221222546025000172430ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement # strategy: # # if there is a VERSION file, use its contents. otherwise, call git to # get a version string. if that also fails, use 'latest'. # import os version = "latest" try: fn = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'VERSION') with open(fn) as f: version = f.read().strip() except IOError: from subprocess import Popen, PIPE import re VERSION_MATCH = re.compile(r'\d+\.\d+\.\d+(\w|-)*') try: dir = os.path.dirname(os.path.abspath(__file__)) p = Popen(['git', 'describe', '--tags', '--always'], cwd=dir, stdout=PIPE, stderr=PIPE) out = p.communicate()[0] if (not p.returncode) and out: v = VERSION_MATCH.search(out) if v: version = v.group() except OSError: pass buildbot-0.8.8/buildbot/buildbot.png000066400000000000000000000014171222546025000174520ustar00rootroot00000000000000PNG  IHDRa pHYs  tIME bެIDAT8˥kU?~ $ԅ)J-tckэSM.*?M7t#B@ jŅ1b"5-$MZi:$4}]Tw=p9ΕC@yHs1 ޠa fW]+W8\sb_لՒɌfb89s]|F1G Rel8#Cx /"D25gWtEsJ{#EՆsNI! {v1hPόw^Tݨs;'ǧ|eu:+W/3ͧ\kRL, ܻc>1,+ks~򵣯{rJVcօ{_rs!wf1UQ:3[1Vš#(g_#HJ T!DR$3@)+҉K}0T<+PuYAf" % (self.__class__.__name__, self.slavename) def updateLocks(self): """Convert the L{LockAccess} objects in C{self.locks} into real lock objects, while also maintaining the subscriptions to lock releases.""" # unsubscribe from any old locks for s in self.lock_subscriptions: s.unsubscribe() # convert locks into their real form locks = [ (self.botmaster.getLockFromLockAccess(a), a) for a in self.access ] self.locks = [(l.getLock(self), la) for l, la in locks] self.lock_subscriptions = [ l.subscribeToReleases(self._lockReleased) for l, la in self.locks ] def locksAvailable(self): """ I am called to see if all the locks I depend on are available, in which I return True, otherwise I return False """ if not self.locks: return True for lock, access in self.locks: if not lock.isAvailable(self, access): return False return True def acquireLocks(self): """ I am called when a build is preparing to run. I try to claim all the locks that are needed for a build to happen. If I can't, then my caller should give up the build and try to get another slave to look at it. """ log.msg("acquireLocks(slave %s, locks %s)" % (self, self.locks)) if not self.locksAvailable(): log.msg("slave %s can't lock, giving up" % (self, )) return False # all locks are available, claim them all for lock, access in self.locks: lock.claim(self, access) return True def releaseLocks(self): """ I am called to release any locks after a build has finished """ log.msg("releaseLocks(%s): %s" % (self, self.locks)) for lock, access in self.locks: lock.release(self, access) def _lockReleased(self): """One of the locks for this slave was released; try scheduling builds.""" if not self.botmaster: return # oh well.. self.botmaster.maybeStartBuildsForSlave(self.slavename) def setServiceParent(self, parent): # botmaster needs to set before setServiceParent which calls startService self.botmaster = parent self.master = parent.master service.MultiService.setServiceParent(self, parent) def startService(self): self.updateLocks() self.startMissingTimer() return service.MultiService.startService(self) @defer.inlineCallbacks def reconfigService(self, new_config): # Given a new BuildSlave, configure this one identically. Because # BuildSlave objects are remotely referenced, we can't replace them # without disconnecting the slave, yet there's no reason to do that. new = self.findNewSlaveInstance(new_config) assert self.slavename == new.slavename # do we need to re-register? if (not self.registration or self.password != new.password or new_config.slavePortnum != self.registered_port): if self.registration: yield self.registration.unregister() self.registration = None self.password = new.password self.registered_port = new_config.slavePortnum self.registration = self.master.pbmanager.register( self.registered_port, self.slavename, self.password, self.getPerspective) # adopt new instance's configuration parameters self.max_builds = new.max_builds self.access = new.access self.notify_on_missing = new.notify_on_missing self.keepalive_interval = new.keepalive_interval if self.missing_timeout != new.missing_timeout: running_missing_timer = self.missing_timer self.stopMissingTimer() self.missing_timeout = new.missing_timeout if running_missing_timer: self.startMissingTimer() properties = Properties() properties.updateFromProperties(new.properties) self.properties = properties self.updateLocks() # update the attached slave's notion of which builders are attached. # This assumes that the relevant builders have already been configured, # which is why the reconfig_priority is set low in this class. yield self.updateSlave() yield config.ReconfigurableServiceMixin.reconfigService(self, new_config) def stopService(self): if self.registration: self.registration.unregister() self.registration = None self.stopMissingTimer() return service.MultiService.stopService(self) def findNewSlaveInstance(self, new_config): # TODO: called multiple times per reconfig; use 1-element cache? for sl in new_config.slaves: if sl.slavename == self.slavename: return sl assert 0, "no new slave named '%s'" % self.slavename def startMissingTimer(self): if self.notify_on_missing and self.missing_timeout and self.parent: self.stopMissingTimer() # in case it's already running self.missing_timer = reactor.callLater(self.missing_timeout, self._missing_timer_fired) def stopMissingTimer(self): if self.missing_timer: self.missing_timer.cancel() self.missing_timer = None def getPerspective(self, mind, slavename): assert slavename == self.slavename metrics.MetricCountEvent.log("attached_slaves", 1) # record when this connection attempt occurred if self.slave_status: self.slave_status.recordConnectTime() # try to use TCP keepalives try: mind.broker.transport.setTcpKeepAlive(1) except: pass if self.isConnected(): # duplicate slave - send it to arbitration arb = botmaster.DuplicateSlaveArbitrator(self) return arb.getPerspective(mind, slavename) else: log.msg("slave '%s' attaching from %s" % (slavename, mind.broker.transport.getPeer())) return self def doKeepalive(self): self.keepalive_timer = reactor.callLater(self.keepalive_interval, self.doKeepalive) if not self.slave: return d = self.slave.callRemote("print", "Received keepalive from master") d.addErrback(log.msg, "Keepalive failed for '%s'" % (self.slavename, )) def stopKeepaliveTimer(self): if self.keepalive_timer: self.keepalive_timer.cancel() def startKeepaliveTimer(self): assert self.keepalive_interval log.msg("Starting buildslave keepalive timer for '%s'" % \ (self.slavename, )) self.doKeepalive() def isConnected(self): return self.slave def _missing_timer_fired(self): self.missing_timer = None # notify people, but only if we're still in the config if not self.parent: return buildmaster = self.botmaster.master status = buildmaster.getStatus() text = "The Buildbot working for '%s'\n" % status.getTitle() text += ("has noticed that the buildslave named %s went away\n" % self.slavename) text += "\n" text += ("It last disconnected at %s (buildmaster-local time)\n" % time.ctime(time.time() - self.missing_timeout)) # approx text += "\n" text += "The admin on record (as reported by BUILDSLAVE:info/admin)\n" text += "was '%s'.\n" % self.slave_status.getAdmin() text += "\n" text += "Sincerely,\n" text += " The Buildbot\n" text += " %s\n" % status.getTitleURL() text += "\n" text += "%s\n" % status.getURLForThing(self.slave_status) subject = "Buildbot: buildslave %s was lost" % self.slavename return self._mail_missing_message(subject, text) def updateSlave(self): """Called to add or remove builders after the slave has connected. @return: a Deferred that indicates when an attached slave has accepted the new builders and/or released the old ones.""" if self.slave: return self.sendBuilderList() else: return defer.succeed(None) def updateSlaveStatus(self, buildStarted=None, buildFinished=None): if buildStarted: self.slave_status.buildStarted(buildStarted) if buildFinished: self.slave_status.buildFinished(buildFinished) @metrics.countMethod('AbstractBuildSlave.attached()') def attached(self, bot): """This is called when the slave connects. @return: a Deferred that fires when the attachment is complete """ # the botmaster should ensure this. assert not self.isConnected() metrics.MetricCountEvent.log("AbstractBuildSlave.attached_slaves", 1) # set up the subscription point for eventual detachment self.detached_subs = subscription.SubscriptionPoint("detached") # now we go through a sequence of calls, gathering information, then # tell the Botmaster that it can finally give this slave to all the # Builders that care about it. # we accumulate slave information in this 'state' dictionary, then # set it atomically if we make it far enough through the process state = {} # Reset graceful shutdown status self.slave_status.setGraceful(False) # We want to know when the graceful shutdown flag changes self.slave_status.addGracefulWatcher(self._gracefulChanged) d = defer.succeed(None) def _log_attachment_on_slave(res): d1 = bot.callRemote("print", "attached") d1.addErrback(lambda why: None) return d1 d.addCallback(_log_attachment_on_slave) def _get_info(res): d1 = bot.callRemote("getSlaveInfo") def _got_info(info): log.msg("Got slaveinfo from '%s'" % self.slavename) # TODO: info{} might have other keys state["admin"] = info.get("admin") state["host"] = info.get("host") state["access_uri"] = info.get("access_uri", None) state["slave_environ"] = info.get("environ", {}) state["slave_basedir"] = info.get("basedir", None) state["slave_system"] = info.get("system", None) def _info_unavailable(why): why.trap(pb.NoSuchMethod) # maybe an old slave, doesn't implement remote_getSlaveInfo log.msg("BuildSlave.info_unavailable") log.err(why) d1.addCallbacks(_got_info, _info_unavailable) return d1 d.addCallback(_get_info) self.startKeepaliveTimer() def _get_version(res): d = bot.callRemote("getVersion") def _got_version(version): state["version"] = version def _version_unavailable(why): why.trap(pb.NoSuchMethod) # probably an old slave state["version"] = '(unknown)' d.addCallbacks(_got_version, _version_unavailable) return d d.addCallback(_get_version) def _get_commands(res): d1 = bot.callRemote("getCommands") def _got_commands(commands): state["slave_commands"] = commands def _commands_unavailable(why): # probably an old slave if why.check(AttributeError): return log.msg("BuildSlave.getCommands is unavailable - ignoring") log.err(why) d1.addCallbacks(_got_commands, _commands_unavailable) return d1 d.addCallback(_get_commands) def _accept_slave(res): self.slave_status.setAdmin(state.get("admin")) self.slave_status.setHost(state.get("host")) self.slave_status.setAccessURI(state.get("access_uri")) self.slave_status.setVersion(state.get("version")) self.slave_status.setConnected(True) self.slave_commands = state.get("slave_commands") self.slave_environ = state.get("slave_environ") self.slave_basedir = state.get("slave_basedir") self.slave_system = state.get("slave_system") self.slave = bot if self.slave_system == "nt": self.path_module = namedModule("ntpath") else: # most eveything accepts / as separator, so posix should be a # reasonable fallback self.path_module = namedModule("posixpath") log.msg("bot attached") self.messageReceivedFromSlave() self.stopMissingTimer() self.botmaster.master.status.slaveConnected(self.slavename) return self.updateSlave() d.addCallback(_accept_slave) d.addCallback(lambda _: self.botmaster.maybeStartBuildsForSlave(self.slavename)) # Finally, the slave gets a reference to this BuildSlave. They # receive this later, after we've started using them. d.addCallback(lambda _: self) return d def messageReceivedFromSlave(self): now = time.time() self.lastMessageReceived = now self.slave_status.setLastMessageReceived(now) def detached(self, mind): metrics.MetricCountEvent.log("AbstractBuildSlave.attached_slaves", -1) self.slave = None self._old_builder_list = [] self.slave_status.removeGracefulWatcher(self._gracefulChanged) self.slave_status.setConnected(False) log.msg("BuildSlave.detached(%s)" % self.slavename) self.botmaster.master.status.slaveDisconnected(self.slavename) self.stopKeepaliveTimer() self.releaseLocks() # notify watchers, but do so in the next reactor iteration so that # any further detached() action by subclasses happens first def notif(): subs = self.detached_subs self.detached_subs = None subs.deliver() eventually(notif) def subscribeToDetach(self, callback): """ Request that C{callable} be invoked with no arguments when the L{detached} method is invoked. @returns: L{Subscription} """ assert self.detached_subs, "detached_subs is only set if attached" return self.detached_subs.subscribe(callback) def disconnect(self): """Forcibly disconnect the slave. This severs the TCP connection and returns a Deferred that will fire (with None) when the connection is probably gone. If the slave is still alive, they will probably try to reconnect again in a moment. This is called in two circumstances. The first is when a slave is removed from the config file. In this case, when they try to reconnect, they will be rejected as an unknown slave. The second is when we wind up with two connections for the same slave, in which case we disconnect the older connection. """ if not self.slave: return defer.succeed(None) log.msg("disconnecting old slave %s now" % self.slavename) # When this Deferred fires, we'll be ready to accept the new slave return self._disconnect(self.slave) def _disconnect(self, slave): # all kinds of teardown will happen as a result of # loseConnection(), but it happens after a reactor iteration or # two. Hook the actual disconnect so we can know when it is safe # to connect the new slave. We have to wait one additional # iteration (with callLater(0)) to make sure the *other* # notifyOnDisconnect handlers have had a chance to run. d = defer.Deferred() # notifyOnDisconnect runs the callback with one argument, the # RemoteReference being disconnected. def _disconnected(rref): eventually(d.callback, None) slave.notifyOnDisconnect(_disconnected) tport = slave.broker.transport # this is the polite way to request that a socket be closed tport.loseConnection() try: # but really we don't want to wait for the transmit queue to # drain. The remote end is unlikely to ACK the data, so we'd # probably have to wait for a (20-minute) TCP timeout. #tport._closeSocket() # however, doing _closeSocket (whether before or after # loseConnection) somehow prevents the notifyOnDisconnect # handlers from being run. Bummer. tport.offset = 0 tport.dataBuffer = "" except: # however, these hacks are pretty internal, so don't blow up if # they fail or are unavailable log.msg("failed to accelerate the shutdown process") log.msg("waiting for slave to finish disconnecting") return d def sendBuilderList(self): our_builders = self.botmaster.getBuildersForSlave(self.slavename) blist = [(b.name, b.config.slavebuilddir) for b in our_builders] if blist == self._old_builder_list: return defer.succeed(None) d = self.slave.callRemote("setBuilderList", blist) def sentBuilderList(ign): self._old_builder_list = blist return ign d.addCallback(sentBuilderList) return d def perspective_keepalive(self): self.messageReceivedFromSlave() def perspective_shutdown(self): log.msg("slave %s wants to shut down" % self.slavename) self.slave_status.setGraceful(True) def addSlaveBuilder(self, sb): self.slavebuilders[sb.builder_name] = sb def removeSlaveBuilder(self, sb): try: del self.slavebuilders[sb.builder_name] except KeyError: pass def buildFinished(self, sb): """This is called when a build on this slave is finished.""" self.botmaster.maybeStartBuildsForSlave(self.slavename) def canStartBuild(self): """ I am called when a build is requested to see if this buildslave can start a build. This function can be used to limit overall concurrency on the buildslave. Note for subclassers: if a slave can become willing to start a build without any action on that slave (for example, by a resource in use on another slave becoming available), then you must arrange for L{maybeStartBuildsForSlave} to be called at that time, or builds on this slave will not start. """ if self.slave_status.isPaused(): return False # If we're waiting to shutdown gracefully, then we shouldn't # accept any new jobs. if self.slave_status.getGraceful(): return False if self.max_builds: active_builders = [sb for sb in self.slavebuilders.values() if sb.isBusy()] if len(active_builders) >= self.max_builds: return False if not self.locksAvailable(): return False return True def _mail_missing_message(self, subject, text): # first, see if we have a MailNotifier we can use. This gives us a # fromaddr and a relayhost. buildmaster = self.botmaster.master for st in buildmaster.status: if isinstance(st, MailNotifier): break else: # if not, they get a default MailNotifier, which always uses SMTP # to localhost and uses a dummy fromaddr of "buildbot". log.msg("buildslave-missing msg using default MailNotifier") st = MailNotifier("buildbot") # now construct the mail m = Message() m.set_payload(text) m['Date'] = formatdate(localtime=True) m['Subject'] = subject m['From'] = st.fromaddr recipients = self.notify_on_missing m['To'] = ", ".join(recipients) d = st.sendMessage(m, recipients) # return the Deferred for testing purposes return d def _gracefulChanged(self, graceful): """This is called when our graceful shutdown setting changes""" self.maybeShutdown() @defer.inlineCallbacks def shutdown(self): """Shutdown the slave""" if not self.slave: log.msg("no remote; slave is already shut down") return # First, try the "new" way - calling our own remote's shutdown # method. The method was only added in 0.8.3, so ignore NoSuchMethod # failures. def new_way(): d = self.slave.callRemote('shutdown') d.addCallback(lambda _ : True) # successful shutdown request def check_nsm(f): f.trap(pb.NoSuchMethod) return False # fall through to the old way d.addErrback(check_nsm) def check_connlost(f): f.trap(pb.PBConnectionLost) return True # the slave is gone, so call it finished d.addErrback(check_connlost) return d if (yield new_way()): return # done! # Now, the old way. Look for a builder with a remote reference to the # client side slave. If we can find one, then call "shutdown" on the # remote builder, which will cause the slave buildbot process to exit. def old_way(): d = None for b in self.slavebuilders.values(): if b.remote: d = b.remote.callRemote("shutdown") break if d: log.msg("Shutting down (old) slave: %s" % self.slavename) # The remote shutdown call will not complete successfully since the # buildbot process exits almost immediately after getting the # shutdown request. # Here we look at the reason why the remote call failed, and if # it's because the connection was lost, that means the slave # shutdown as expected. def _errback(why): if why.check(pb.PBConnectionLost): log.msg("Lost connection to %s" % self.slavename) else: log.err("Unexpected error when trying to shutdown %s" % self.slavename) d.addErrback(_errback) return d log.err("Couldn't find remote builder to shut down slave") return defer.succeed(None) yield old_way() def maybeShutdown(self): """Shut down this slave if it has been asked to shut down gracefully, and has no active builders.""" if not self.slave_status.getGraceful(): return active_builders = [sb for sb in self.slavebuilders.values() if sb.isBusy()] if active_builders: return d = self.shutdown() d.addErrback(log.err, 'error while shutting down slave') def pause(self): """Stop running new builds on the slave.""" self.slave_status.setPaused(True) def unpause(self): """Restart running new builds on the slave.""" self.slave_status.setPaused(False) self.botmaster.maybeStartBuildsForSlave(self.slavename) def isPaused(self): return self.paused class BuildSlave(AbstractBuildSlave): def sendBuilderList(self): d = AbstractBuildSlave.sendBuilderList(self) def _sent(slist): # Nothing has changed, so don't need to re-attach to everything if not slist: return dl = [] for name, remote in slist.items(): # use get() since we might have changed our mind since then b = self.botmaster.builders.get(name) if b: d1 = b.attached(self, remote, self.slave_commands) dl.append(d1) return defer.DeferredList(dl) def _set_failed(why): log.msg("BuildSlave.sendBuilderList (%s) failed" % self) log.err(why) # TODO: hang up on them?, without setBuilderList we can't use # them d.addCallbacks(_sent, _set_failed) return d def detached(self, mind): AbstractBuildSlave.detached(self, mind) self.botmaster.slaveLost(self) self.startMissingTimer() def buildFinished(self, sb): """This is called when a build on this slave is finished.""" AbstractBuildSlave.buildFinished(self, sb) # If we're gracefully shutting down, and we have no more active # builders, then it's safe to disconnect self.maybeShutdown() class AbstractLatentBuildSlave(AbstractBuildSlave): """A build slave that will start up a slave instance when needed. To use, subclass and implement start_instance and stop_instance. See ec2buildslave.py for a concrete example. Also see the stub example in test/test_slaves.py. """ implements(ILatentBuildSlave) substantiated = False substantiation_deferred = None substantiation_build = None insubstantiating = False build_wait_timer = None _shutdown_callback_handle = None def __init__(self, name, password, max_builds=None, notify_on_missing=[], missing_timeout=60*20, build_wait_timeout=60*10, properties={}, locks=None): AbstractBuildSlave.__init__( self, name, password, max_builds, notify_on_missing, missing_timeout, properties, locks) self.building = set() self.build_wait_timeout = build_wait_timeout def start_instance(self, build): # responsible for starting instance that will try to connect with this # master. Should return deferred with either True (instance started) # or False (instance not started, so don't run a build here). Problems # should use an errback. raise NotImplementedError def stop_instance(self, fast=False): # responsible for shutting down instance. raise NotImplementedError def substantiate(self, sb, build): if self.substantiated: self._clearBuildWaitTimer() self._setBuildWaitTimer() return defer.succeed(True) if self.substantiation_deferred is None: if self.parent and not self.missing_timer: # start timer. if timer times out, fail deferred self.missing_timer = reactor.callLater( self.missing_timeout, self._substantiation_failed, defer.TimeoutError()) self.substantiation_deferred = defer.Deferred() self.substantiation_build = build if self.slave is None: d = self._substantiate(build) # start up instance d.addErrback(log.err, "while substantiating") # else: we're waiting for an old one to detach. the _substantiate # will be done in ``detached`` below. return self.substantiation_deferred def _substantiate(self, build): # register event trigger d = self.start_instance(build) self._shutdown_callback_handle = reactor.addSystemEventTrigger( 'before', 'shutdown', self._soft_disconnect, fast=True) def start_instance_result(result): # If we don't report success, then preparation failed. if not result: log.msg("Slave '%s' doesn not want to substantiate at this time" % (self.slavename,)) d = self.substantiation_deferred self.substantiation_deferred = None d.callback(False) return result def clean_up(failure): if self.missing_timer is not None: self.missing_timer.cancel() self._substantiation_failed(failure) if self._shutdown_callback_handle is not None: handle = self._shutdown_callback_handle del self._shutdown_callback_handle reactor.removeSystemEventTrigger(handle) return failure d.addCallbacks(start_instance_result, clean_up) return d def attached(self, bot): if self.substantiation_deferred is None and self.build_wait_timeout >= 0: msg = 'Slave %s received connection while not trying to ' \ 'substantiate. Disconnecting.' % (self.slavename,) log.msg(msg) self._disconnect(bot) return defer.fail(RuntimeError(msg)) return AbstractBuildSlave.attached(self, bot) def detached(self, mind): AbstractBuildSlave.detached(self, mind) if self.substantiation_deferred is not None: d = self._substantiate(self.substantiation_build) d.addErrback(log.err, 'while re-substantiating') def _substantiation_failed(self, failure): self.missing_timer = None if self.substantiation_deferred: d = self.substantiation_deferred self.substantiation_deferred = None self.substantiation_build = None d.errback(failure) self.insubstantiate() # notify people, but only if we're still in the config if not self.parent or not self.notify_on_missing: return buildmaster = self.botmaster.master status = buildmaster.getStatus() text = "The Buildbot working for '%s'\n" % status.getTitle() text += ("has noticed that the latent buildslave named %s \n" % self.slavename) text += "never substantiated after a request\n" text += "\n" text += ("The request was made at %s (buildmaster-local time)\n" % time.ctime(time.time() - self.missing_timeout)) # approx text += "\n" text += "Sincerely,\n" text += " The Buildbot\n" text += " %s\n" % status.getTitleURL() subject = "Buildbot: buildslave %s never substantiated" % self.slavename return self._mail_missing_message(subject, text) def canStartBuild(self): if self.insubstantiating: return False return AbstractBuildSlave.canStartBuild(self) def buildStarted(self, sb): assert self.substantiated self._clearBuildWaitTimer() self.building.add(sb.builder_name) def buildFinished(self, sb): AbstractBuildSlave.buildFinished(self, sb) self.building.remove(sb.builder_name) if not self.building: if self.build_wait_timeout == 0: self.insubstantiate() else: self._setBuildWaitTimer() def _clearBuildWaitTimer(self): if self.build_wait_timer is not None: if self.build_wait_timer.active(): self.build_wait_timer.cancel() self.build_wait_timer = None def _setBuildWaitTimer(self): self._clearBuildWaitTimer() if self.build_wait_timeout <= 0: return self.build_wait_timer = reactor.callLater( self.build_wait_timeout, self._soft_disconnect) @defer.inlineCallbacks def insubstantiate(self, fast=False): self.insubstantiating = True self._clearBuildWaitTimer() d = self.stop_instance(fast) if self._shutdown_callback_handle is not None: handle = self._shutdown_callback_handle del self._shutdown_callback_handle reactor.removeSystemEventTrigger(handle) self.substantiated = False self.building.clear() # just to be sure yield d self.insubstantiating = False @defer.inlineCallbacks def _soft_disconnect(self, fast=False): # a negative build_wait_timeout means the slave should never be shut # down, so just disconnect. if self.build_wait_timeout < 0: yield AbstractBuildSlave.disconnect(self) return if self.missing_timer: self.missing_timer.cancel() self.missing_timer = None if self.substantiation_deferred is not None: log.msg("Weird: Got request to stop before started. Allowing " "slave to start cleanly to avoid inconsistent state") yield self.substantiation_deferred self.substantiation_deferred = None self.substantiation_build = None log.msg("Substantiation complete, immediately terminating.") if self.slave is not None: # this could be called when the slave needs to shut down, such as # in BotMaster.removeSlave, *or* when a new slave requests a # connection when we already have a slave. It's not clear what to # do in the second case: this shouldn't happen, and if it # does...if it's a latent slave, shutting down will probably kill # something we want...but we can't know what the status is. So, # here, we just do what should be appropriate for the first case, # and put our heads in the sand for the second, at least for now. # The best solution to the odd situation is removing it as a # possibility: make the master in charge of connecting to the # slave, rather than vice versa. TODO. yield defer.DeferredList([ AbstractBuildSlave.disconnect(self), self.insubstantiate(fast) ], consumeErrors=True, fireOnOneErrback=True) else: yield AbstractBuildSlave.disconnect(self) yield self.stop_instance(fast) def disconnect(self): # This returns a Deferred but we don't use it self._soft_disconnect() # this removes the slave from all builders. It won't come back # without a restart (or maybe a sighup) self.botmaster.slaveLost(self) def stopService(self): res = defer.maybeDeferred(AbstractBuildSlave.stopService, self) if self.slave is not None: d = self._soft_disconnect() res = defer.DeferredList([res, d]) return res def updateSlave(self): """Called to add or remove builders after the slave has connected. Also called after botmaster's builders are initially set. @return: a Deferred that indicates when an attached slave has accepted the new builders and/or released the old ones.""" for b in self.botmaster.getBuildersForSlave(self.slavename): if b.name not in self.slavebuilders: b.addLatentSlave(self) return AbstractBuildSlave.updateSlave(self) def sendBuilderList(self): d = AbstractBuildSlave.sendBuilderList(self) def _sent(slist): if not slist: return dl = [] for name, remote in slist.items(): # use get() since we might have changed our mind since then. # we're checking on the builder in addition to the # slavebuilders out of a bit of paranoia. b = self.botmaster.builders.get(name) sb = self.slavebuilders.get(name) if b and sb: d1 = sb.attached(self, remote, self.slave_commands) dl.append(d1) return defer.DeferredList(dl) def _set_failed(why): log.msg("BuildSlave.sendBuilderList (%s) failed" % self) log.err(why) # TODO: hang up on them?, without setBuilderList we can't use # them if self.substantiation_deferred: d = self.substantiation_deferred self.substantiation_deferred = None self.substantiation_build = None d.errback(why) if self.missing_timer: self.missing_timer.cancel() self.missing_timer = None # TODO: maybe log? send an email? return why d.addCallbacks(_sent, _set_failed) def _substantiated(res): log.msg("Slave %s substantiated \o/" % self.slavename) self.substantiated = True if not self.substantiation_deferred: log.msg("No substantiation deferred for %s" % self.slavename) if self.substantiation_deferred: log.msg("Firing %s substantiation deferred with success" % self.slavename) d = self.substantiation_deferred self.substantiation_deferred = None self.substantiation_build = None d.callback(True) # note that the missing_timer is already handled within # ``attached`` if not self.building: self._setBuildWaitTimer() d.addCallback(_substantiated) return d buildbot-0.8.8/buildbot/buildslave/ec2.py000066400000000000000000000327541222546025000203250ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members from __future__ import with_statement # Portions Copyright Canonical Ltd. 2009 """A LatentSlave that uses EC2 to instantiate the slaves on demand. Tested with Python boto 1.5c """ import os import re import time import boto import boto.ec2 import boto.exception from twisted.internet import defer, threads from twisted.python import log from buildbot.buildslave.base import AbstractLatentBuildSlave from buildbot import interfaces PENDING = 'pending' RUNNING = 'running' SHUTTINGDOWN = 'shutting-down' TERMINATED = 'terminated' class EC2LatentBuildSlave(AbstractLatentBuildSlave): instance = image = None _poll_resolution = 5 # hook point for tests def __init__(self, name, password, instance_type, ami=None, valid_ami_owners=None, valid_ami_location_regex=None, elastic_ip=None, identifier=None, secret_identifier=None, aws_id_file_path=None, user_data=None, region=None, keypair_name='latent_buildbot_slave', security_name='latent_buildbot_slave', max_builds=None, notify_on_missing=[], missing_timeout=60*20, build_wait_timeout=60*10, properties={}, locks=None): AbstractLatentBuildSlave.__init__( self, name, password, max_builds, notify_on_missing, missing_timeout, build_wait_timeout, properties, locks) if not ((ami is not None) ^ (valid_ami_owners is not None or valid_ami_location_regex is not None)): raise ValueError( 'You must provide either a specific ami, or one or both of ' 'valid_ami_location_regex and valid_ami_owners') self.ami = ami if valid_ami_owners is not None: if isinstance(valid_ami_owners, (int, long)): valid_ami_owners = (valid_ami_owners,) else: for element in valid_ami_owners: if not isinstance(element, (int, long)): raise ValueError( 'valid_ami_owners should be int or iterable ' 'of ints', element) if valid_ami_location_regex is not None: if not isinstance(valid_ami_location_regex, basestring): raise ValueError( 'valid_ami_location_regex should be a string') else: # verify that regex will compile re.compile(valid_ami_location_regex) self.valid_ami_owners = valid_ami_owners self.valid_ami_location_regex = valid_ami_location_regex self.instance_type = instance_type self.keypair_name = keypair_name self.security_name = security_name self.user_data = user_data if identifier is None: assert secret_identifier is None, ( 'supply both or neither of identifier, secret_identifier') if aws_id_file_path is None: home = os.environ['HOME'] aws_id_file_path = os.path.join(home, '.ec2', 'aws_id') if not os.path.exists(aws_id_file_path): raise ValueError( "Please supply your AWS access key identifier and secret " "access key identifier either when instantiating this %s " "or in the %s file (on two lines).\n" % (self.__class__.__name__, aws_id_file_path)) with open(aws_id_file_path, 'r') as aws_file: identifier = aws_file.readline().strip() secret_identifier = aws_file.readline().strip() else: assert aws_id_file_path is None, \ 'if you supply the identifier and secret_identifier, ' \ 'do not specify the aws_id_file_path' assert secret_identifier is not None, \ 'supply both or neither of identifier, secret_identifier' region_found = None # Make the EC2 connection. if region is not None: for r in boto.ec2.regions(aws_access_key_id=identifier, aws_secret_access_key=secret_identifier): if r.name == region: region_found = r if region_found is not None: self.conn = boto.ec2.connect_to_region(region, aws_access_key_id=identifier, aws_secret_access_key=secret_identifier) else: raise ValueError('The specified region does not exist: {0}'.format(region)) else: self.conn = boto.connect_ec2(identifier, secret_identifier) # Make a keypair # # We currently discard the keypair data because we don't need it. # If we do need it in the future, we will always recreate the keypairs # because there is no way to # programmatically retrieve the private key component, unless we # generate it and store it on the filesystem, which is an unnecessary # usage requirement. try: key_pair = self.conn.get_all_key_pairs(keypair_name)[0] assert key_pair # key_pair.delete() # would be used to recreate except boto.exception.EC2ResponseError, e: if 'InvalidKeyPair.NotFound' not in e.body: if 'AuthFailure' in e.body: print ('POSSIBLE CAUSES OF ERROR:\n' ' Did you sign up for EC2?\n' ' Did you put a credit card number in your AWS ' 'account?\n' 'Please doublecheck before reporting a problem.\n') raise # make one; we would always do this, and stash the result, if we # needed the key (for instance, to SSH to the box). We'd then # use paramiko to use the key to connect. self.conn.create_key_pair(keypair_name) # create security group try: group = self.conn.get_all_security_groups(security_name)[0] assert group except boto.exception.EC2ResponseError, e: if 'InvalidGroup.NotFound' in e.body: self.security_group = self.conn.create_security_group( security_name, 'Authorization to access the buildbot instance.') # Authorize the master as necessary # TODO this is where we'd open the hole to do the reverse pb # connect to the buildbot # ip = urllib.urlopen( # 'http://checkip.amazonaws.com').read().strip() # self.security_group.authorize('tcp', 22, 22, '%s/32' % ip) # self.security_group.authorize('tcp', 80, 80, '%s/32' % ip) else: raise # get the image if self.ami is not None: self.image = self.conn.get_image(self.ami) else: # verify we have access to at least one acceptable image discard = self.get_image() assert discard # get the specified elastic IP, if any if elastic_ip is not None: elastic_ip = self.conn.get_all_addresses([elastic_ip])[0] self.elastic_ip = elastic_ip def get_image(self): if self.image is not None: return self.image if self.valid_ami_location_regex: level = 0 options = [] get_match = re.compile(self.valid_ami_location_regex).match for image in self.conn.get_all_images( owners=self.valid_ami_owners): # gather sorting data match = get_match(image.location) if match: alpha_sort = int_sort = None if level < 2: try: alpha_sort = match.group(1) except IndexError: level = 2 else: if level == 0: try: int_sort = int(alpha_sort) except ValueError: level = 1 options.append([int_sort, alpha_sort, image.location, image.id, image]) if level: log.msg('sorting images at level %d' % level) options = [candidate[level:] for candidate in options] else: options = [(image.location, image.id, image) for image in self.conn.get_all_images( owners=self.valid_ami_owners)] options.sort() log.msg('sorted images (last is chosen): %s' % (', '.join( ['%s (%s)' % (candidate[-1].id, candidate[-1].location) for candidate in options]))) if not options: raise ValueError('no available images match constraints') return options[-1][-1] def dns(self): if self.instance is None: return None return self.instance.public_dns_name dns = property(dns) def start_instance(self, build): if self.instance is not None: raise ValueError('instance active') return threads.deferToThread(self._start_instance) def _start_instance(self): image = self.get_image() reservation = image.run( key_name=self.keypair_name, security_groups=[self.security_name], instance_type=self.instance_type, user_data=self.user_data) self.instance = reservation.instances[0] log.msg('%s %s starting instance %s' % (self.__class__.__name__, self.slavename, self.instance.id)) duration = 0 interval = self._poll_resolution while self.instance.state == PENDING: time.sleep(interval) duration += interval if duration % 60 == 0: log.msg('%s %s has waited %d minutes for instance %s' % (self.__class__.__name__, self.slavename, duration//60, self.instance.id)) self.instance.update() if self.instance.state == RUNNING: self.output = self.instance.get_console_output() minutes = duration//60 seconds = duration%60 log.msg('%s %s instance %s started on %s ' 'in about %d minutes %d seconds (%s)' % (self.__class__.__name__, self.slavename, self.instance.id, self.dns, minutes, seconds, self.output.output)) if self.elastic_ip is not None: self.instance.use_ip(self.elastic_ip) return [self.instance.id, image.id, '%02d:%02d:%02d' % (minutes//60, minutes%60, seconds)] else: log.msg('%s %s failed to start instance %s (%s)' % (self.__class__.__name__, self.slavename, self.instance.id, self.instance.state)) raise interfaces.LatentBuildSlaveFailedToSubstantiate( self.instance.id, self.instance.state) def stop_instance(self, fast=False): if self.instance is None: # be gentle. Something may just be trying to alert us that an # instance never attached, and it's because, somehow, we never # started. return defer.succeed(None) instance = self.instance self.output = self.instance = None return threads.deferToThread( self._stop_instance, instance, fast) def _stop_instance(self, instance, fast): if self.elastic_ip is not None: self.conn.disassociate_address(self.elastic_ip.public_ip) instance.update() if instance.state not in (SHUTTINGDOWN, TERMINATED): instance.terminate() log.msg('%s %s terminating instance %s' % (self.__class__.__name__, self.slavename, instance.id)) duration = 0 interval = self._poll_resolution if fast: goal = (SHUTTINGDOWN, TERMINATED) instance.update() else: goal = (TERMINATED,) while instance.state not in goal: time.sleep(interval) duration += interval if duration % 60 == 0: log.msg( '%s %s has waited %d minutes for instance %s to end' % (self.__class__.__name__, self.slavename, duration//60, instance.id)) instance.update() log.msg('%s %s instance %s %s ' 'after about %d minutes %d seconds' % (self.__class__.__name__, self.slavename, instance.id, goal, duration//60, duration%60)) buildbot-0.8.8/buildbot/buildslave/libvirt.py000066400000000000000000000230061222546025000213150ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members # Portions Copyright 2010 Isotoma Limited from __future__ import absolute_import import os from twisted.internet import defer, utils, threads from twisted.python import log, failure from buildbot.buildslave.base import AbstractBuildSlave, AbstractLatentBuildSlave from buildbot.util.eventual import eventually from buildbot import config try: import libvirt libvirt = libvirt except ImportError: libvirt = None class WorkQueue(object): """ I am a class that turns parallel access into serial access. I exist because we want to run libvirt access in threads as we don't trust calls not to block, but under load libvirt doesn't seem to like this kind of threaded use. """ def __init__(self): self.queue = [] def _process(self): log.msg("Looking to start a piece of work now...") # Is there anything to do? if not self.queue: log.msg("_process called when there is no work") return # Peek at the top of the stack - get a function to call and # a deferred to fire when its all over d, next_operation, args, kwargs = self.queue[0] # Start doing some work - expects a deferred try: d2 = next_operation(*args, **kwargs) except: d2 = defer.fail() # Whenever a piece of work is done, whether it worked or not # call this to schedule the next piece of work def _work_done(res): log.msg("Completed a piece of work") self.queue.pop(0) if self.queue: log.msg("Preparing next piece of work") eventually(self._process) return res d2.addBoth(_work_done) # When the work is done, trigger d d2.chainDeferred(d) def execute(self, cb, *args, **kwargs): kickstart_processing = not self.queue d = defer.Deferred() self.queue.append((d, cb, args, kwargs)) if kickstart_processing: self._process() return d def executeInThread(self, cb, *args, **kwargs): return self.execute(threads.deferToThread, cb, *args, **kwargs) # A module is effectively a singleton class, so this is OK queue = WorkQueue() class Domain(object): """ I am a wrapper around a libvirt Domain object """ def __init__(self, connection, domain): self.connection = connection self.domain = domain def name(self): return queue.executeInThread(self.domain.name) def create(self): return queue.executeInThread(self.domain.create) def shutdown(self): return queue.executeInThread(self.domain.shutdown) def destroy(self): return queue.executeInThread(self.domain.destroy) class Connection(object): """ I am a wrapper around a libvirt Connection object. """ DomainClass = Domain def __init__(self, uri): self.uri = uri self.connection = libvirt.open(uri) @defer.inlineCallbacks def lookupByName(self, name): """ I lookup an existing predefined domain """ res = yield queue.executeInThread(self.connection.lookupByName, name) defer.returnValue(self.DomainClass(self, res)) @defer.inlineCallbacks def create(self, xml): """ I take libvirt XML and start a new VM """ res = yield queue.executeInThread(self.connection.createXML, xml, 0) defer.returnValue(self.DomainClass(self, res)) @defer.inlineCallbacks def all(self): domains = [] domain_ids = yield queue.executeInThread(self.connection.listDomainsID) for did in domain_ids: domain = yield queue.executeInThread(self.connection.lookupByID, did) domains.append(self.DomainClass(self, domain)) defer.returnValue(domains) class LibVirtSlave(AbstractLatentBuildSlave): def __init__(self, name, password, connection, hd_image, base_image = None, xml=None, max_builds=None, notify_on_missing=[], missing_timeout=60*20, build_wait_timeout=60*10, properties={}, locks=None): AbstractLatentBuildSlave.__init__(self, name, password, max_builds, notify_on_missing, missing_timeout, build_wait_timeout, properties, locks) if not libvirt: config.error("The python module 'libvirt' is needed to use a LibVirtSlave") self.name = name self.connection = connection self.image = hd_image self.base_image = base_image self.xml = xml self.cheap_copy = True self.graceful_shutdown = False self.domain = None self.ready = False self._find_existing_deferred = self._find_existing_instance() @defer.inlineCallbacks def _find_existing_instance(self): """ I find existing VMs that are already running that might be orphaned instances of this slave. """ if not self.connection: defer.returnValue(None) domains = yield self.connection.all() for d in domains: name = yield d.name() if name.startswith(self.name): self.domain = d self.substantiated = True break self.ready = True def canStartBuild(self): if not self.ready: log.msg("Not accepting builds as existing domains not iterated") return False if self.domain and not self.isConnected(): log.msg("Not accepting builds as existing domain but slave not connected") return False return AbstractLatentBuildSlave.canStartBuild(self) def _prepare_base_image(self): """ I am a private method for creating (possibly cheap) copies of a base_image for start_instance to boot. """ if not self.base_image: return defer.succeed(True) if self.cheap_copy: clone_cmd = "qemu-img" clone_args = "create -b %(base)s -f qcow2 %(image)s" else: clone_cmd = "cp" clone_args = "%(base)s %(image)s" clone_args = clone_args % { "base": self.base_image, "image": self.image, } log.msg("Cloning base image: %s %s'" % (clone_cmd, clone_args)) def _log_result(res): log.msg("Cloning exit code was: %d" % res) return res d = utils.getProcessValue(clone_cmd, clone_args.split()) d.addBoth(_log_result) return d @defer.inlineCallbacks def start_instance(self, build): """ I start a new instance of a VM. If a base_image is specified, I will make a clone of that otherwise i will use image directly. If i'm not given libvirt domain definition XML, I will look for my name in the list of defined virtual machines and start that. """ if self.domain is not None: log.msg("Cannot start_instance '%s' as already active" % self.name) defer.returnValue(False) yield self._prepare_base_image() try: if self.xml: self.domain = yield self.connection.create(self.xml) else: self.domain = yield self.connection.lookupByName(self.name) yield self.domain.create() except: log.err(failure.Failure(), "Cannot start a VM (%s), failing gracefully and triggering" "a new build check" % self.name) self.domain = None defer.returnValue(False) defer.returnValue(True) def stop_instance(self, fast=False): """ I attempt to stop a running VM. I make sure any connection to the slave is removed. If the VM was using a cloned image, I remove the clone When everything is tidied up, I ask that bbot looks for work to do """ log.msg("Attempting to stop '%s'" % self.name) if self.domain is None: log.msg("I don't think that domain is even running, aborting") return defer.succeed(None) domain = self.domain self.domain = None if self.graceful_shutdown and not fast: log.msg("Graceful shutdown chosen for %s" % self.name) d = domain.shutdown() else: d = domain.destroy() def _disconnect(res): log.msg("VM destroyed (%s): Forcing its connection closed." % self.name) return AbstractBuildSlave.disconnect(self) d.addCallback(_disconnect) def _disconnected(res): log.msg("We forced disconnection (%s), cleaning up and triggering new build" % self.name) if self.base_image: os.remove(self.image) self.botmaster.maybeStartBuildsForSlave(self.name) return res d.addBoth(_disconnected) return d buildbot-0.8.8/buildbot/buildslave/openstack.py000066400000000000000000000165101222546025000216330ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members # Portions Copyright 2013 Cray Inc. import time from twisted.internet import defer, threads from twisted.python import log from buildbot.buildslave.base import AbstractLatentBuildSlave from buildbot import config, interfaces try: import novaclient.exceptions as nce from novaclient.v1_1 import client _hush_pyflakes = [nce, client] except ImportError: nce = None client = None ACTIVE = 'ACTIVE' BUILD = 'BUILD' DELETED = 'DELETED' UNKNOWN = 'UNKNOWN' class OpenStackLatentBuildSlave(AbstractLatentBuildSlave): instance = None _poll_resolution = 5 # hook point for tests def __init__(self, name, password, flavor, image, os_username, os_password, os_tenant_name, os_auth_url, meta=None, max_builds=None, notify_on_missing=[], missing_timeout=60*20, build_wait_timeout=60*10, properties={}, locks=None): if not client or not nce: config.error("The python module 'novaclient' is needed " "to use a OpenStackLatentBuildSlave") AbstractLatentBuildSlave.__init__( self, name, password, max_builds, notify_on_missing, missing_timeout, build_wait_timeout, properties, locks) self.flavor = flavor self.image = image self.os_username = os_username self.os_password = os_password self.os_tenant_name = os_tenant_name self.os_auth_url = os_auth_url self.meta = meta def _getImage(self, os_client): # If self.image is a callable, then pass it the list of images. The # function should return the image's UUID to use. if callable(self.image): image_uuid = self.image(os_client.images.list()) else: image_uuid = self.image return image_uuid def start_instance(self, build): if self.instance is not None: raise ValueError('instance active') return threads.deferToThread(self._start_instance) def _start_instance(self): # Authenticate to OpenStack. os_client = client.Client(self.os_username, self.os_password, self.os_tenant_name, self.os_auth_url) image_uuid = self._getImage(os_client) flavor_id = self.flavor boot_args = [self.slavename, image_uuid, flavor_id] boot_kwargs = {} if self.meta is not None: boot_kwargs['meta'] = self.meta self.instance = os_client.servers.create(*boot_args, **boot_kwargs) log.msg('%s %s starting instance %s (image %s)' % (self.__class__.__name__, self.slavename, self.instance.id, image_uuid)) duration = 0 interval = self._poll_resolution inst = self.instance while inst.status == BUILD: time.sleep(interval) duration += interval if duration % 60 == 0: log.msg('%s %s has waited %d minutes for instance %s' % (self.__class__.__name__, self.slavename, duration//60, self.instance.id)) try: inst = os_client.servers.get(self.instance.id) except nce.NotFound: log.msg('%s %s instance %s (%s) went missing' % (self.__class__.__name__, self.slavename, self.instance.id, self.instance.name)) raise interfaces.LatentBuildSlaveFailedToSubstantiate( self.instance.id, self.instance.status) if inst.status == ACTIVE: minutes = duration//60 seconds = duration%60 log.msg('%s %s instance %s (%s) started ' 'in about %d minutes %d seconds' % (self.__class__.__name__, self.slavename, self.instance.id, self.instance.name, minutes, seconds)) return [self.instance.id, image_uuid, '%02d:%02d:%02d' % (minutes//60, minutes%60, seconds)] else: log.msg('%s %s failed to start instance %s (%s)' % (self.__class__.__name__, self.slavename, self.instance.id, inst.status)) raise interfaces.LatentBuildSlaveFailedToSubstantiate( self.instance.id, self.instance.status) def stop_instance(self, fast=False): if self.instance is None: # be gentle. Something may just be trying to alert us that an # instance never attached, and it's because, somehow, we never # started. return defer.succeed(None) instance = self.instance self.instance = None return threads.deferToThread(self._stop_instance, instance, fast) def _stop_instance(self, instance, fast): # Authenticate to OpenStack. This is needed since it seems the update # method doesn't do a whole lot of updating right now. os_client = client.Client(self.os_username, self.os_password, self.os_tenant_name, self.os_auth_url) # When the update method does work, replace the lines like below with # instance.update(). try: inst = os_client.servers.get(instance.id) except nce.NotFound: # If can't find the instance, then it's already gone. log.msg('%s %s instance %s (%s) already terminated' % (self.__class__.__name__, self.slavename, instance.id, instance.name)) return if inst.status not in (DELETED, UNKNOWN): inst.delete() log.msg('%s %s terminating instance %s (%s)' % (self.__class__.__name__, self.slavename, instance.id, instance.name)) duration = 0 interval = self._poll_resolution if fast: goal = (DELETED, UNKNOWN) else: goal = (DELETED,) while inst.status not in goal: time.sleep(interval) duration += interval if duration % 60 == 0: log.msg( '%s %s has waited %d minutes for instance %s to end' % (self.__class__.__name__, self.slavename, duration//60, instance.id)) try: inst = os_client.servers.get(instance.id) except nce.NotFound: break log.msg('%s %s instance %s %s ' 'after about %d minutes %d seconds' % (self.__class__.__name__, self.slavename, instance.id, goal, duration//60, duration%60)) buildbot-0.8.8/buildbot/changes/000077500000000000000000000000001222546025000165455ustar00rootroot00000000000000buildbot-0.8.8/buildbot/changes/__init__.py000066400000000000000000000000001222546025000206440ustar00rootroot00000000000000buildbot-0.8.8/buildbot/changes/base.py000066400000000000000000000062361222546025000200400ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from twisted.application import service from twisted.internet import defer, task, reactor from twisted.python import log from buildbot.interfaces import IChangeSource from buildbot import util class ChangeSource(service.Service, util.ComparableMixin): implements(IChangeSource) master = None "if C{self.running} is true, then C{cs.master} points to the buildmaster." def describe(self): pass class PollingChangeSource(ChangeSource): """ Utility subclass for ChangeSources that use some kind of periodic polling operation. Subclasses should define C{poll} and set C{self.pollInterval}. The rest is taken care of. Any subclass will be available via the "poller" webhook. """ pollInterval = 60 "time (in seconds) between calls to C{poll}" _loop = None def __init__(self, name=None, pollInterval=60*10): if name: self.setName(name) self.pollInterval = pollInterval self.doPoll = util.misc.SerializedInvocation(self.doPoll) def doPoll(self): """ This is the method that is called by LoopingCall to actually poll. It may also be called by change hooks to request a poll. It is serialiazed - if you call it while a poll is in progress then the 2nd invocation won't start until the 1st has finished. """ d = defer.maybeDeferred(self.poll) d.addErrback(log.err, 'while polling for changes') return d def poll(self): """ Perform the polling operation, and return a deferred that will fire when the operation is complete. Failures will be logged, but the method will be called again after C{pollInterval} seconds. """ def startLoop(self): self._loop = task.LoopingCall(self.doPoll) self._loop.start(self.pollInterval, now=False) def stopLoop(self): if self._loop and self._loop.running: self._loop.stop() self._loop = None def startService(self): ChangeSource.startService(self) # delay starting doing anything until the reactor is running - if # services are still starting up, they may miss an initial flood of # changes if self.pollInterval: reactor.callWhenRunning(self.startLoop) else: reactor.callWhenRunning(self.doPoll) def stopService(self): self.stopLoop() return ChangeSource.stopService(self) buildbot-0.8.8/buildbot/changes/bonsaipoller.py000066400000000000000000000224241222546025000216140ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import time from xml.dom import minidom from twisted.python import log from twisted.internet import defer from twisted.web import client from buildbot.changes import base from buildbot.util import epoch2datetime class InvalidResultError(Exception): def __init__(self, value="InvalidResultError"): self.value = value def __str__(self): return repr(self.value) class EmptyResult(Exception): pass class NoMoreCiNodes(Exception): pass class NoMoreFileNodes(Exception): pass class BonsaiResult: """I hold a list of CiNodes""" def __init__(self, nodes=[]): self.nodes = nodes def __cmp__(self, other): if len(self.nodes) != len(other.nodes): return False for i in range(len(self.nodes)): if self.nodes[i].log != other.nodes[i].log \ or self.nodes[i].who != other.nodes[i].who \ or self.nodes[i].date != other.nodes[i].date \ or len(self.nodes[i].files) != len(other.nodes[i].files): return -1 for j in range(len(self.nodes[i].files)): if self.nodes[i].files[j].revision \ != other.nodes[i].files[j].revision \ or self.nodes[i].files[j].filename \ != other.nodes[i].files[j].filename: return -1 return 0 class CiNode: """I hold information baout one node, including a list of files""" def __init__(self, log="", who="", date=0, files=[]): self.log = log self.who = who self.date = date self.files = files class FileNode: """I hold information about one node""" def __init__(self, revision="", filename=""): self.revision = revision self.filename = filename class BonsaiParser: """I parse the XML result from a bonsai cvsquery.""" def __init__(self, data): try: # this is a fix for non-ascii characters # because bonsai does not give us an encoding to work with # it impossible to be 100% sure what to decode it as but latin1 covers # the broadest base data = data.decode("latin1") data = data.encode("ascii", "replace") self.dom = minidom.parseString(data) log.msg(data) except: raise InvalidResultError("Malformed XML in result") self.ciNodes = self.dom.getElementsByTagName("ci") self.currentCiNode = None # filled in by _nextCiNode() self.fileNodes = None # filled in by _nextCiNode() self.currentFileNode = None # filled in by _nextFileNode() self.bonsaiResult = self._parseData() def getData(self): return self.bonsaiResult def _parseData(self): """Returns data from a Bonsai cvsquery in a BonsaiResult object""" nodes = [] try: while self._nextCiNode(): files = [] try: while self._nextFileNode(): files.append(FileNode(self._getRevision(), self._getFilename())) except NoMoreFileNodes: pass except InvalidResultError: raise cinode = CiNode(self._getLog(), self._getWho(), self._getDate(), files) # hack around bonsai xml output bug for empty check-in comments if not cinode.log and nodes and \ not nodes[-1].log and \ cinode.who == nodes[-1].who and \ cinode.date == nodes[-1].date: nodes[-1].files += cinode.files else: nodes.append(cinode) except NoMoreCiNodes: pass except (InvalidResultError, EmptyResult): raise return BonsaiResult(nodes) def _nextCiNode(self): """Iterates to the next node and fills self.fileNodes with child nodes""" try: self.currentCiNode = self.ciNodes.pop(0) if len(self.currentCiNode.getElementsByTagName("files")) > 1: raise InvalidResultError("Multiple for one ") self.fileNodes = self.currentCiNode.getElementsByTagName("f") except IndexError: # if there was zero nodes in the result if not self.currentCiNode: raise EmptyResult else: raise NoMoreCiNodes return True def _nextFileNode(self): """Iterates to the next node""" try: self.currentFileNode = self.fileNodes.pop(0) except IndexError: raise NoMoreFileNodes return True def _getLog(self): """Returns the log of the current node""" logs = self.currentCiNode.getElementsByTagName("log") if len(logs) < 1: raise InvalidResultError("No log present") elif len(logs) > 1: raise InvalidResultError("Multiple logs present") # catch empty check-in comments if logs[0].firstChild: return logs[0].firstChild.data return '' def _getWho(self): """Returns the e-mail address of the commiter""" # convert unicode string to regular string return str(self.currentCiNode.getAttribute("who")) def _getDate(self): """Returns the date (unix time) of the commit""" # convert unicode number to regular one try: commitDate = int(self.currentCiNode.getAttribute("date")) except ValueError: raise InvalidResultError return commitDate def _getFilename(self): """Returns the filename of the current node""" try: filename = self.currentFileNode.firstChild.data except AttributeError: raise InvalidResultError("Missing filename") return filename def _getRevision(self): return self.currentFileNode.getAttribute("rev") class BonsaiPoller(base.PollingChangeSource): compare_attrs = ["bonsaiURL", "pollInterval", "tree", "module", "branch", "cvsroot"] def __init__(self, bonsaiURL, module, branch, tree="default", cvsroot="/cvsroot", pollInterval=30, project=''): base.PollingChangeSource.__init__(self, name=bonsaiURL, pollInterval=pollInterval) self.bonsaiURL = bonsaiURL self.module = module self.branch = branch self.tree = tree self.cvsroot = cvsroot self.repository = module != 'all' and module or '' self.lastChange = time.time() self.lastPoll = time.time() def describe(self): str = "" str += "Getting changes from the Bonsai service running at %s " \ % self.bonsaiURL str += "
Using tree: %s, branch: %s, and module: %s" % (self.tree, \ self.branch, self.module) return str def poll(self): d = self._get_changes() d.addCallback(self._process_changes) return d def _make_url(self): args = ["treeid=%s" % self.tree, "module=%s" % self.module, "branch=%s" % self.branch, "branchtype=match", "sortby=Date", "date=explicit", "mindate=%d" % self.lastChange, "maxdate=%d" % int(time.time()), "cvsroot=%s" % self.cvsroot, "xml=1"] # build the bonsai URL url = self.bonsaiURL url += "/cvsquery.cgi?" url += "&".join(args) return url def _get_changes(self): url = self._make_url() log.msg("Polling Bonsai tree at %s" % url) self.lastPoll = time.time() # get the page, in XML format return client.getPage(url, timeout=self.pollInterval) @defer.inlineCallbacks def _process_changes(self, query): try: bp = BonsaiParser(query) result = bp.getData() except InvalidResultError, e: log.msg("Could not process Bonsai query: " + e.value) return except EmptyResult: return for cinode in result.nodes: files = [file.filename + ' (revision '+file.revision+')' for file in cinode.files] self.lastChange = self.lastPoll yield self.master.addChange(author = cinode.who, files = files, comments = cinode.log, when_timestamp = epoch2datetime(cinode.date), branch = self.branch) buildbot-0.8.8/buildbot/changes/changes.py000066400000000000000000000247001222546025000205320ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os, time from cPickle import dump from zope.interface import implements from twisted.python import log, runtime from twisted.internet import defer from twisted.web import html from buildbot.util import datetime2epoch from buildbot import interfaces, util from buildbot.process.properties import Properties class Change: """I represent a single change to the source tree. This may involve several files, but they are all changed by the same person, and there is a change comment for the group as a whole.""" implements(interfaces.IStatusEvent) number = None branch = None category = None revision = None # used to create a source-stamp links = [] # links are gone, but upgrade code expects this attribute @classmethod def fromChdict(cls, master, chdict): """ Class method to create a L{Change} from a dictionary as returned by L{ChangesConnectorComponent.getChange}. @param master: build master instance @param ssdict: change dictionary @returns: L{Change} via Deferred """ cache = master.caches.get_cache("Changes", cls._make_ch) return cache.get(chdict['changeid'], chdict=chdict, master=master) @classmethod def _make_ch(cls, changeid, master, chdict): change = cls(None, None, None, _fromChdict=True) change.who = chdict['author'] change.comments = chdict['comments'] change.isdir = chdict['is_dir'] change.revision = chdict['revision'] change.branch = chdict['branch'] change.category = chdict['category'] change.revlink = chdict['revlink'] change.repository = chdict['repository'] change.codebase = chdict['codebase'] change.project = chdict['project'] change.number = chdict['changeid'] when = chdict['when_timestamp'] if when: when = datetime2epoch(when) change.when = when change.files = chdict['files'][:] change.files.sort() change.properties = Properties() for n, (v,s) in chdict['properties'].iteritems(): change.properties.setProperty(n, v, s) return defer.succeed(change) def __init__(self, who, files, comments, isdir=0, revision=None, when=None, branch=None, category=None, revlink='', properties={}, repository='', codebase='', project='', _fromChdict=False): # skip all this madness if we're being built from the database if _fromChdict: return self.who = who self.comments = comments self.isdir = isdir def none_or_unicode(x): if x is None: return x return unicode(x) self.revision = none_or_unicode(revision) now = util.now() if when is None: self.when = now elif when > now: # this happens when the committing system has an incorrect clock, for example. # handle it gracefully log.msg("received a Change with when > now; assuming the change happened now") self.when = now else: self.when = when self.branch = none_or_unicode(branch) self.category = none_or_unicode(category) self.revlink = revlink self.properties = Properties() self.properties.update(properties, "Change") self.repository = repository self.codebase = codebase self.project = project # keep a sorted list of the files, for easier display self.files = (files or [])[:] self.files.sort() def __setstate__(self, dict): self.__dict__ = dict # Older Changes won't have a 'properties' attribute in them if not hasattr(self, 'properties'): self.properties = Properties() if not hasattr(self, 'revlink'): self.revlink = "" def __str__(self): return (u"Change(revision=%r, who=%r, branch=%r, comments=%r, " + u"when=%r, category=%r, project=%r, repository=%r, " + u"codebase=%r)") % ( self.revision, self.who, self.branch, self.comments, self.when, self.category, self.project, self.repository, self.codebase) def __cmp__(self, other): return self.number - other.number def asText(self): data = "" data += self.getFileContents() if self.repository: data += "On: %s\n" % self.repository if self.project: data += "For: %s\n" % self.project data += "At: %s\n" % self.getTime() data += "Changed By: %s\n" % self.who data += "Comments: %s" % self.comments data += "Properties: \n%s\n\n" % self.getProperties() return data def asDict(self): '''returns a dictonary with suitable info for html/mail rendering''' result = {} files = [ dict(name=f) for f in self.files ] files.sort(cmp=lambda a, b: a['name'] < b['name']) # Constant result['number'] = self.number result['branch'] = self.branch result['category'] = self.category result['who'] = self.getShortAuthor() result['comments'] = self.comments result['revision'] = self.revision result['rev'] = self.revision result['when'] = self.when result['at'] = self.getTime() result['files'] = files result['revlink'] = getattr(self, 'revlink', None) result['properties'] = self.properties.asList() result['repository'] = getattr(self, 'repository', None) result['codebase'] = getattr(self, 'codebase', '') result['project'] = getattr(self, 'project', None) return result def getShortAuthor(self): return self.who def getTime(self): if not self.when: return "?" return time.strftime("%a %d %b %Y %H:%M:%S", time.localtime(self.when)) def getTimes(self): return (self.when, None) def getText(self): return [html.escape(self.who)] def getLogs(self): return {} def getFileContents(self): data = "" if len(self.files) == 1: if self.isdir: data += "Directory: %s\n" % self.files[0] else: data += "File: %s\n" % self.files[0] else: data += "Files:\n" for f in self.files: data += " %s\n" % f return data def getProperties(self): data = "" for prop in self.properties.asList(): data += " %s: %s" % (prop[0], prop[1]) return data class ChangeMaster: # pragma: no cover # this is a stub, retained to allow the "buildbot upgrade-master" tool to # read old changes.pck pickle files and convert their contents into the # new database format. This is only instantiated by that tool, or by # test_db.py which tests that tool. The functionality that previously # lived here has been moved into buildbot.changes.manager.ChangeManager def __init__(self): self.changes = [] # self.basedir must be filled in by the parent self.nextNumber = 1 def saveYourself(self): filename = os.path.join(self.basedir, "changes.pck") tmpfilename = filename + ".tmp" try: with open(tmpfilename, "wb") as f: dump(self, f) if runtime.platformType == 'win32': # windows cannot rename a file on top of an existing one if os.path.exists(filename): os.unlink(filename) os.rename(tmpfilename, filename) except Exception: log.msg("unable to save changes") log.err() # This method is used by contrib/fix_changes_pickle_encoding.py to recode all # bytestrings in an old changes.pck into unicode strings def recode_changes(self, old_encoding, quiet=False): """Processes the list of changes, with the change attributes re-encoded unicode objects""" nconvert = 0 for c in self.changes: # give revision special handling, in case it is an integer if isinstance(c.revision, int): c.revision = unicode(c.revision) for attr in ("who", "comments", "revlink", "category", "branch", "revision"): a = getattr(c, attr) if isinstance(a, str): try: setattr(c, attr, a.decode(old_encoding)) nconvert += 1 except UnicodeDecodeError: raise UnicodeError("Error decoding %s of change #%s as %s:\n%r" % (attr, c.number, old_encoding, a)) # filenames are a special case, but in general they'll have the same encoding # as everything else on a system. If not, well, hack this script to do your # import! newfiles = [] for filename in util.flatten(c.files): if isinstance(filename, str): try: filename = filename.decode(old_encoding) nconvert += 1 except UnicodeDecodeError: raise UnicodeError("Error decoding filename '%s' of change #%s as %s:\n%r" % (filename.decode('ascii', 'replace'), c.number, old_encoding, a)) newfiles.append(filename) c.files = newfiles if not quiet: print "converted %d strings" % nconvert class OldChangeMaster(ChangeMaster): # pragma: no cover # this is a reminder that the ChangeMaster class is old pass # vim: set ts=4 sts=4 sw=4 et: buildbot-0.8.8/buildbot/changes/filter.py000066400000000000000000000123351222546025000204100ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import re, types from buildbot.util import ComparableMixin, NotABranch class ChangeFilter(ComparableMixin): # NOTE: If users use a filter_fn, we have no way to determine whether it has # changed at reconfig, so the scheduler will always be restarted. That's as # good as Python can do. compare_attrs = ('filter_fn', 'checks') def __init__(self, # gets a Change object, returns boolean filter_fn=None, # change attribute comparisons: exact match to PROJECT, member of # list PROJECTS, regular expression match to PROJECT_RE, or # PROJECT_FN returns True when called with the project; repository, # branch, and so on are similar. Note that the regular expressions # are anchored to the first character of the string. For convenience, # a list can also be specified to the singular option (e.g,. PROJETS project=None, project_re=None, project_fn=None, repository=None, repository_re=None, repository_fn=None, branch=NotABranch, branch_re=None, branch_fn=None, category=None, category_re=None, category_fn=None, codebase=None, codebase_re=None, codebase_fn=None): def mklist(x): if x is not None and type(x) is not types.ListType: return [ x ] return x def mklist_br(x): # branch needs to be handled specially if x is NotABranch: return None if type(x) is not types.ListType: return [ x ] return x def mkre(r): if r is not None and not hasattr(r, 'match'): r = re.compile(r) return r self.filter_fn = filter_fn self.checks = [ (mklist(project), mkre(project_re), project_fn, "project"), (mklist(repository), mkre(repository_re), repository_fn, "repository"), (mklist_br(branch), mkre(branch_re), branch_fn, "branch"), (mklist(category), mkre(category_re), category_fn, "category"), (mklist(codebase), mkre(codebase_re), codebase_fn, "codebase"), ] def filter_change(self, change): if self.filter_fn is not None and not self.filter_fn(change): return False for (filt_list, filt_re, filt_fn, chg_attr) in self.checks: chg_val = getattr(change, chg_attr, '') if filt_list is not None and chg_val not in filt_list: return False if filt_re is not None and (chg_val is None or not filt_re.match(chg_val)): return False if filt_fn is not None and not filt_fn(chg_val): return False return True def __repr__(self): checks = [] for (filt_list, filt_re, filt_fn, chg_attr) in self.checks: if filt_list is not None and len(filt_list) == 1: checks.append('%s == %s' % (chg_attr, filt_list[0])) elif filt_list is not None: checks.append('%s in %r' % (chg_attr, filt_list)) if filt_re is not None : checks.append('%s ~/%s/' % (chg_attr, filt_re)) if filt_fn is not None : checks.append('%s(%s)' % (filt_fn.__name__, chg_attr)) return "<%s on %s>" % (self.__class__.__name__, ' and '.join(checks)) @staticmethod def fromSchedulerConstructorArgs(change_filter=None, branch=NotABranch, categories=None): """ Static method to create a filter based on constructor args change_filter, branch, and categories; use default values @code{None}, @code{NotABranch}, and @code{None}, respectively. These arguments are interpreted as documented for the L{buildbot.schedulers.basic.Scheduler} class. @returns: L{ChangeFilter} instance or None for not filtering """ # use a change_filter, if given one if change_filter: if (branch is not NotABranch or categories is not None): raise RuntimeError("cannot specify both change_filter and " "branch or categories") return change_filter elif branch is not NotABranch or categories: # build a change filter from the deprecated category and branch args cfargs = {} if branch is not NotABranch: cfargs['branch'] = branch if categories: cfargs['category'] = categories return ChangeFilter(**cfargs) else: return None buildbot-0.8.8/buildbot/changes/gerritchangesource.py000066400000000000000000000200431222546025000230010ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.internet import reactor from buildbot.changes import base from buildbot.util import json from buildbot import util from twisted.python import log from twisted.internet import defer from twisted.internet.protocol import ProcessProtocol class GerritChangeSource(base.ChangeSource): """This source will maintain a connection to gerrit ssh server that will provide us gerrit events in json format.""" compare_attrs = ["gerritserver", "gerritport"] STREAM_GOOD_CONNECTION_TIME = 120 "(seconds) connections longer than this are considered good, and reset the backoff timer" STREAM_BACKOFF_MIN = 0.5 "(seconds) minimum, but nonzero, time to wait before retrying a failed connection" STREAM_BACKOFF_EXPONENT = 1.5 "multiplier used to increase the backoff from MIN to MAX on repeated failures" STREAM_BACKOFF_MAX = 60 "(seconds) maximum time to wait before retrying a failed connection" def __init__(self, gerritserver, username, gerritport=29418, identity_file=None): """ @type gerritserver: string @param gerritserver: the dns or ip that host the gerrit ssh server, @type gerritport: int @param gerritport: the port of the gerrit ssh server, @type username: string @param username: the username to use to connect to gerrit, @type identity_file: string @param identity_file: identity file to for authentication (optional). """ # TODO: delete API comment when documented self.gerritserver = gerritserver self.gerritport = gerritport self.username = username self.identity_file = identity_file self.process = None self.wantProcess = False self.streamProcessTimeout = self.STREAM_BACKOFF_MIN class LocalPP(ProcessProtocol): def __init__(self, change_source): self.change_source = change_source self.data = "" @defer.inlineCallbacks def outReceived(self, data): """Do line buffering.""" self.data += data lines = self.data.split("\n") self.data = lines.pop(-1) # last line is either empty or incomplete for line in lines: log.msg("gerrit: %s" % (line,)) yield self.change_source.lineReceived(line) def errReceived(self, data): log.msg("gerrit stderr: %s" % (data,)) def processEnded(self, status_object): self.change_source.streamProcessStopped() def lineReceived(self, line): try: event = json.loads(line.decode('utf-8')) except ValueError: log.msg("bad json line: %s" % (line,)) return defer.succeed(None) if not(type(event) == type({}) and "type" in event): log.msg("no type in event %s" % (line,)) return defer.succeed(None) func = getattr(self, "eventReceived_"+event["type"].replace("-","_"), None) if func == None: log.msg("unsupported event %s" % (event["type"],)) return defer.succeed(None) # flatten the event dictionary, for easy access with WithProperties def flatten(properties, base, event): for k, v in event.items(): if type(v) == dict: flatten(properties, base + "." + k, v) else: # already there properties[base + "." + k] = v properties = {} flatten(properties, "event", event) return func(properties,event) def addChange(self, chdict): d = self.master.addChange(**chdict) # eat failures.. d.addErrback(log.err, 'error adding change from GerritChangeSource') return d def eventReceived_patchset_created(self, properties, event): change = event["change"] return self.addChange(dict( author="%s <%s>" % (change["owner"]["name"], change["owner"]["email"]), project=change["project"], repository="ssh://%s@%s:%s/%s" % ( self.username, self.gerritserver, self.gerritport, change["project"]), branch=change["branch"]+"/"+change["number"], revision=event["patchSet"]["revision"], revlink=change["url"], comments=change["subject"], files=["unknown"], category=event["type"], properties=properties)) def eventReceived_ref_updated(self, properties, event): ref = event["refUpdate"] author = "gerrit" if "submitter" in event: author="%s <%s>" % (event["submitter"]["name"], event["submitter"]["email"]) return self.addChange(dict( author=author, project=ref["project"], repository="ssh://%s@%s:%s/%s" % ( self.username, self.gerritserver, self.gerritport, ref["project"]), branch=ref["refName"], revision=ref["newRev"], comments="Gerrit: patchset(s) merged.", files=["unknown"], category=event["type"], properties=properties)) def streamProcessStopped(self): self.process = None # if the service is stopped, don't try to restart the process if not self.wantProcess: log.msg("service is not running; not reconnecting") return now = util.now() if now - self.lastStreamProcessStart < self.STREAM_GOOD_CONNECTION_TIME: # bad startup; start the stream process again after a timeout, and then # increase the timeout log.msg("'gerrit stream-events' failed; restarting after %ds" % round(self.streamProcessTimeout)) reactor.callLater(self.streamProcessTimeout, self.startStreamProcess) self.streamProcessTimeout *= self.STREAM_BACKOFF_EXPONENT if self.streamProcessTimeout > self.STREAM_BACKOFF_MAX: self.streamProcessTimeout = self.STREAM_BACKOFF_MAX else: # good startup, but lost connection; restart immediately, and set the timeout # to its minimum self.startStreamProcess() self.streamProcessTimeout = self.STREAM_BACKOFF_MIN def startStreamProcess(self): log.msg("starting 'gerrit stream-events'") self.lastStreamProcessStart = util.now() args = [ self.username+"@"+self.gerritserver,"-p", str(self.gerritport)] if self.identity_file is not None: args = args + [ '-i', self.identity_file ] self.process = reactor.spawnProcess(self.LocalPP(self), "ssh", [ "ssh" ] + args + [ "gerrit", "stream-events" ]) def startService(self): self.wantProcess = True self.startStreamProcess() def stopService(self): self.wantProcess = False if self.process: self.process.signalProcess("KILL") # TODO: if this occurs while the process is restarting, some exceptions may # be logged, although things will settle down normally return base.ChangeSource.stopService(self) def describe(self): status = "" if not self.process: status = "[NOT CONNECTED - check log]" str = ('GerritChangeSource watching the remote Gerrit repository %s@%s %s' % (self.username, self.gerritserver, status)) return str buildbot-0.8.8/buildbot/changes/gitpoller.py000066400000000000000000000213371222546025000211260ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os import urllib from twisted.python import log from twisted.internet import defer, utils from buildbot.changes import base from buildbot.util import epoch2datetime from buildbot.util.state import StateMixin from buildbot import config class GitPoller(base.PollingChangeSource, StateMixin): """This source will poll a remote git repo for changes and submit them to the change master.""" compare_attrs = ["repourl", "branches", "workdir", "pollInterval", "gitbin", "usetimestamps", "category", "project"] def __init__(self, repourl, branches=None, branch=None, workdir=None, pollInterval=10*60, gitbin='git', usetimestamps=True, category=None, project=None, pollinterval=-2, fetch_refspec=None, encoding='utf-8'): # for backward compatibility; the parameter used to be spelled with 'i' if pollinterval != -2: pollInterval = pollinterval base.PollingChangeSource.__init__(self, name=repourl, pollInterval=pollInterval) if project is None: project = '' if branch and branches: config.error("GitPoller: can't specify both branch and branches") elif branch: branches = [branch] elif not branches: branches = ['master'] self.repourl = repourl self.branches = branches self.encoding = encoding self.gitbin = gitbin self.workdir = workdir self.usetimestamps = usetimestamps self.category = category self.project = project self.changeCount = 0 self.lastRev = {} if fetch_refspec is not None: config.error("GitPoller: fetch_refspec is no longer supported. " "Instead, only the given branches are downloaded.") if self.workdir == None: self.workdir = 'gitpoller-work' def startService(self): # make our workdir absolute, relative to the master's basedir if not os.path.isabs(self.workdir): self.workdir = os.path.join(self.master.basedir, self.workdir) log.msg("gitpoller: using workdir '%s'" % self.workdir) d = self.getState('lastRev', {}) def setLastRev(lastRev): self.lastRev = lastRev d.addCallback(setLastRev) d.addCallback(lambda _: base.PollingChangeSource.startService(self)) d.addErrback(log.err, 'while initializing GitPoller repository') return d def describe(self): status = "" if not self.master: status = "[STOPPED - check log]" str = ('GitPoller watching the remote git repository %s, branches: %s %s' % (self.repourl, ', '.join(self.branches), status)) return str @defer.inlineCallbacks def poll(self): yield self._dovccmd('init', ['--bare', self.workdir]) refspecs = [ '+%s:%s'% (branch, self._localBranch(branch)) for branch in self.branches ] yield self._dovccmd('fetch', [self.repourl] + refspecs, path=self.workdir) revs = {} for branch in self.branches: try: revs[branch] = rev = yield self._dovccmd('rev-parse', [self._localBranch(branch)], path=self.workdir) yield self._process_changes(rev, branch) except: log.err(_why="trying to poll branch %s of %s" % (branch, self.repourl)) self.lastRev.update(revs) yield self.setState('lastRev', self.lastRev) def _get_commit_comments(self, rev): args = ['--no-walk', r'--format=%s%n%b', rev, '--'] d = self._dovccmd('log', args, path=self.workdir) def process(git_output): git_output = git_output.decode(self.encoding) if len(git_output) == 0: raise EnvironmentError('could not get commit comment for rev') return git_output d.addCallback(process) return d def _get_commit_timestamp(self, rev): # unix timestamp args = ['--no-walk', r'--format=%ct', rev, '--'] d = self._dovccmd('log', args, path=self.workdir) def process(git_output): if self.usetimestamps: try: stamp = float(git_output) except Exception, e: log.msg('gitpoller: caught exception converting output \'%s\' to timestamp' % git_output) raise e return stamp else: return None d.addCallback(process) return d def _get_commit_files(self, rev): args = ['--name-only', '--no-walk', r'--format=%n', rev, '--'] d = self._dovccmd('log', args, path=self.workdir) def process(git_output): fileList = git_output.split() return fileList d.addCallback(process) return d def _get_commit_author(self, rev): args = ['--no-walk', r'--format=%aN <%aE>', rev, '--'] d = self._dovccmd('log', args, path=self.workdir) def process(git_output): git_output = git_output.decode(self.encoding) if len(git_output) == 0: raise EnvironmentError('could not get commit author for rev') return git_output d.addCallback(process) return d @defer.inlineCallbacks def _process_changes(self, newRev, branch): """ Read changes since last change. - Read list of commit hashes. - Extract details from each commit. - Add changes to database. """ lastRev = self.lastRev.get(branch) self.lastRev[branch] = newRev if not lastRev: return # get the change list revListArgs = [r'--format=%H', '%s..%s' % (lastRev, newRev), '--'] self.changeCount = 0 results = yield self._dovccmd('log', revListArgs, path=self.workdir) # process oldest change first revList = results.split() revList.reverse() self.changeCount = len(revList) log.msg('gitpoller: processing %d changes: %s from "%s"' % (self.changeCount, revList, self.repourl) ) for rev in revList: dl = defer.DeferredList([ self._get_commit_timestamp(rev), self._get_commit_author(rev), self._get_commit_files(rev), self._get_commit_comments(rev), ], consumeErrors=True) results = yield dl # check for failures failures = [ r[1] for r in results if not r[0] ] if failures: # just fail on the first error; they're probably all related! raise failures[0] timestamp, author, files, comments = [ r[1] for r in results ] yield self.master.addChange( author=author, revision=rev, files=files, comments=comments, when_timestamp=epoch2datetime(timestamp), branch=branch, category=self.category, project=self.project, repository=self.repourl, src='git') def _dovccmd(self, command, args, path=None): d = utils.getProcessOutputAndValue(self.gitbin, [command] + args, path=path, env=os.environ) def _convert_nonzero_to_failure(res): "utility to handle the result of getProcessOutputAndValue" (stdout, stderr, code) = res if code != 0: raise EnvironmentError('command failed with exit code %d: %s' % (code, stderr)) return stdout.strip() d.addCallback(_convert_nonzero_to_failure) return d def _localBranch(self, branch): return "refs/buildbot/%s/%s" % (urllib.quote(self.repourl, ''), branch) buildbot-0.8.8/buildbot/changes/hgbuildbot.py000066400000000000000000000131661222546025000212510ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members # Portions Copyright 2007 Frederic Leroy # hook extension to send change notifications to buildbot when a changeset is # brought into the repository from elsewhere. # # See the Buildbot manual for configuration instructions. import os from mercurial.node import bin, hex, nullid #@UnresolvedImport # mercurial's on-demand-importing hacks interfere with the: #from zope.interface import Interface # that Twisted needs to do, so disable it. try: from mercurial import demandimport demandimport.disable() except ImportError: pass # In Mercurial post-1.7, some strings might be stored as a # encoding.localstr class. encoding.fromlocal will translate # those back to UTF-8 strings. try: from mercurial.encoding import fromlocal _hush_pyflakes = [fromlocal] del _hush_pyflakes except ImportError: def fromlocal(s): return s def hook(ui, repo, hooktype, node=None, source=None, **kwargs): # read config parameters baseurl = ui.config('hgbuildbot', 'baseurl', ui.config('web', 'baseurl', '')) masters = ui.configlist('hgbuildbot', 'master') if masters: branchtype = ui.config('hgbuildbot', 'branchtype', 'inrepo') branch = ui.config('hgbuildbot', 'branch') fork = ui.configbool('hgbuildbot', 'fork', False) # notify also has this setting stripcount = int(ui.config('notify','strip') or ui.config('hgbuildbot','strip',3)) category = ui.config('hgbuildbot', 'category', None) project = ui.config('hgbuildbot', 'project', '') auth = ui.config('hgbuildbot', 'auth', None) else: ui.write("* You must add a [hgbuildbot] section to .hg/hgrc in " "order to use buildbot hook\n") return if hooktype != "changegroup": ui.status("hgbuildbot: hooktype %s not supported.\n" % hooktype) return if fork: child_pid = os.fork() if child_pid == 0: #child pass else: #parent ui.status("Notifying buildbot...\n") return # only import inside the fork if forked from buildbot.clients import sendchange from twisted.internet import defer, reactor if branch is None: if branchtype == 'dirname': branch = os.path.basename(repo.root) if not auth: auth = 'change:changepw' auth = auth.split(':', 1) # process changesets def _send(res, s, c): if not fork: ui.status("rev %s sent\n" % c['revision']) return s.send(c['branch'], c['revision'], c['comments'], c['files'], c['username'], category=category, repository=repository, project=project, vc='hg', properties=c['properties']) try: # first try Mercurial 1.1+ api start = repo[node].rev() end = len(repo) except TypeError: # else fall back to old api start = repo.changelog.rev(bin(node)) end = repo.changelog.count() repository = strip(repo.root, stripcount) repository = baseurl + repository for master in masters: s = sendchange.Sender(master, auth=auth) d = defer.Deferred() reactor.callLater(0, d.callback, None) for rev in xrange(start, end): # send changeset node = repo.changelog.node(rev) manifest, user, (time, timezone), files, desc, extra = repo.changelog.read(node) parents = filter(lambda p: not p == nullid, repo.changelog.parents(node)) if branchtype == 'inrepo': branch = extra['branch'] is_merge = len(parents) > 1 # merges don't always contain files, but at least one file is required by buildbot if is_merge and not files: files = ["merge"] properties = {'is_merge': is_merge} if branch: branch = fromlocal(branch) change = { 'master': master, 'username': fromlocal(user), 'revision': hex(node), 'comments': fromlocal(desc), 'files': files, 'branch': branch, 'properties':properties } d.addCallback(_send, s, change) def _printSuccess(res): ui.status(s.getSuccessString(res) + '\n') def _printFailure(why): ui.warn(s.getFailureString(why) + '\n') d.addCallbacks(_printSuccess, _printFailure) d.addBoth(lambda _ : reactor.stop()) reactor.run() if fork: os._exit(os.EX_OK) else: return # taken from the mercurial notify extension def strip(path, count): '''Strip the count first slash of the path''' # First normalize it path = '/'.join(path.split(os.sep)) # and strip it part after part while count > 0: c = path.find('/') if c == -1: break path = path[c + 1:] count -= 1 return path buildbot-0.8.8/buildbot/changes/hgpoller.py000066400000000000000000000272701222546025000207430ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import time import os from twisted.python import log from twisted.internet import defer, utils from buildbot import config from buildbot.util import deferredLocked from buildbot.changes import base from buildbot.util import epoch2datetime class HgPoller(base.PollingChangeSource): """This source will poll a remote hg repo for changes and submit them to the change master.""" compare_attrs = ["repourl", "branch", "workdir", "pollInterval", "hgpoller", "usetimestamps", "category", "project"] db_class_name = 'HgPoller' def __init__(self, repourl, branch='default', workdir=None, pollInterval=10*60, hgbin='hg', usetimestamps=True, category=None, project='', pollinterval=-2, encoding='utf-8'): # for backward compatibility; the parameter used to be spelled with 'i' if pollinterval != -2: pollInterval = pollinterval self.repourl = repourl self.branch = branch base.PollingChangeSource.__init__( self, name=repourl, pollInterval=pollInterval) self.encoding = encoding self.lastChange = time.time() self.lastPoll = time.time() self.hgbin = hgbin self.workdir = workdir self.usetimestamps = usetimestamps self.category = category self.project = project self.commitInfo = {} self.initLock = defer.DeferredLock() if self.workdir == None: config.error("workdir is mandatory for now in HgPoller") def describe(self): status = "" if not self.master: status = "[STOPPED - check log]" return ("HgPoller watching the remote Mercurial repository %r, " "branch: %r, in workdir %r %s") % (self.repourl, self.branch, self.workdir, status) @deferredLocked('initLock') def poll(self): d = self._getChanges() d.addCallback(self._processChanges) d.addErrback(self._processChangesFailure) return d def _absWorkdir(self): workdir = self.workdir if os.path.isabs(workdir): return workdir return os.path.join(self.master.basedir, workdir) def _getRevDetails(self, rev): """Return a deferred for (date, author, files, comments) of given rev. Deferred will be in error if rev is unknown. """ args = ['log', '-r', rev, os.linesep.join(( '--template={date|hgdate}', '{author}', '{files}', '{desc|strip}'))] # Mercurial fails with status 255 if rev is unknown d = utils.getProcessOutput(self.hgbin, args, path=self._absWorkdir(), env=os.environ, errortoo=False ) def process(output): # fortunately, Mercurial issues all filenames one one line date, author, files, comments = output.decode(self.encoding, "replace").split( os.linesep, 3) if not self.usetimestamps: stamp = None else: try: stamp = float(date.split()[0]) except: log.msg('hgpoller: caught exception converting output %r ' 'to timestamp' % date) raise return stamp, author.strip(), files.split(), comments.strip() d.addCallback(process) return d def _isRepositoryReady(self): """Easy to patch in tests.""" return os.path.exists(os.path.join(self._absWorkdir(), '.hg')) def _initRepository(self): """Have mercurial init the workdir as a repository (hg init) if needed. hg init will also create all needed intermediate directories. """ if self._isRepositoryReady(): return defer.succeed(None) log.msg('hgpoller: initializing working dir from %s' % self.repourl) d = utils.getProcessOutputAndValue(self.hgbin, ['init', self._absWorkdir()], env=os.environ) d.addCallback(self._convertNonZeroToFailure) d.addErrback(self._stopOnFailure) d.addCallback(lambda _ : log.msg( "hgpoller: finished initializing working dir %r" % self.workdir)) return d def _getChanges(self): self.lastPoll = time.time() d = self._initRepository() d.addCallback(lambda _ : log.msg( "hgpoller: polling hg repo at %s" % self.repourl)) # get a deferred object that performs the fetch args = ['pull', '-b', self.branch, self.repourl] # This command always produces data on stderr, but we actually do not # care about the stderr or stdout from this command. # We set errortoo=True to avoid an errback from the deferred. # The callback which will be added to this # deferred will not use the response. d.addCallback(lambda _: utils.getProcessOutput( self.hgbin, args, path=self._absWorkdir(), env=os.environ, errortoo=True)) return d def _getStateObjectId(self): """Return a deferred for object id in state db. Being unique among pollers, workdir is used with branch as instance name for db. """ return self.master.db.state.getObjectId( '#'.join((self.workdir, self.branch)), self.db_class_name) def _getCurrentRev(self): """Return a deferred for object id in state db and current numeric rev. If never has been set, current rev is None. """ d = self._getStateObjectId() def oid_cb(oid): d = self.master.db.state.getState(oid, 'current_rev', None) def addOid(cur): if cur is not None: return oid, int(cur) return oid, cur d.addCallback(addOid) return d d.addCallback(oid_cb) return d def _setCurrentRev(self, rev, oid=None): """Return a deferred to set current revision in persistent state. oid is self's id for state db. It can be passed to avoid a db lookup.""" if oid is None: d = self._getStateObjectId() else: d = defer.succeed(oid) def set_in_state(obj_id): return self.master.db.state.setState(obj_id, 'current_rev', rev) d.addCallback(set_in_state) return d def _getHead(self): """Return a deferred for branch head revision or None. We'll get an error if there is no head for this branch, which is proabably a good thing, since it's probably a mispelling (if really buildbotting a branch that does not have any changeset yet, one shouldn't be surprised to get errors) """ d = utils.getProcessOutput(self.hgbin, ['heads', self.branch, '--template={rev}' + os.linesep], path=self._absWorkdir(), env=os.environ, errortoo=False) def no_head_err(exc): log.err("hgpoller: could not find branch %r in repository %r" % ( self.branch, self.repourl)) d.addErrback(no_head_err) def results(heads): if not heads: return if len(heads.split()) > 1: log.err(("hgpoller: caught several heads in branch %r " "from repository %r. Staying at previous revision" "You should wait until the situation is normal again " "due to a merge or directly strip if remote repo " "gets stripped later.") % (self.branch, self.repourl)) return # in case of whole reconstruction, are we sure that we'll get the # same node -> rev assignations ? return int(heads.strip()) d.addCallback(results) return d @defer.inlineCallbacks def _processChanges(self, unused_output): """Send info about pulled changes to the master and record current. GitPoller does the recording by moving the working dir to the head of the branch. We don't update the tree (unnecessary treatment and waste of space) instead, we simply store the current rev number in a file. Recall that hg rev numbers are local and incremental. """ oid, current = yield self._getCurrentRev() # hg log on a range of revisions is never empty # also, if a numeric revision does not exist, a node may match. # Therefore, we have to check explicitely that branch head > current. head = yield self._getHead() if head <= current: return if current is None: # we could have used current = -1 convention as well (as hg does) revrange = '%d:%d' % (head, head) else: revrange = '%d:%s' % (current + 1, head) # two passes for hg log makes parsing simpler (comments is multi-lines) revListArgs = ['log', '-b', self.branch, '-r', revrange, r'--template={rev}:{node}\n'] results = yield utils.getProcessOutput(self.hgbin, revListArgs, path=self._absWorkdir(), env=os.environ, errortoo=False ) revNodeList = [rn.split(':', 1) for rn in results.strip().split()] log.msg('hgpoller: processing %d changes: %r in %r' % (len(revNodeList), revNodeList, self._absWorkdir())) for rev, node in revNodeList: timestamp, author, files, comments = yield self._getRevDetails( node) yield self.master.addChange( author=author, revision=node, files=files, comments=comments, when_timestamp=epoch2datetime(timestamp), branch=self.branch, category=self.category, project=self.project, repository=self.repourl, src='hg') # writing after addChange so that a rev is never missed, # but at once to avoid impact from later errors yield self._setCurrentRev(rev, oid=oid) def _processChangesFailure(self, f): log.msg('hgpoller: repo poll failed') log.err(f) # eat the failure to continue along the defered chain - we still want to catch up return None def _convertNonZeroToFailure(self, res): "utility method to handle the result of getProcessOutputAndValue" (stdout, stderr, code) = res if code != 0: raise EnvironmentError('command failed with exit code %d: %s' % (code, stderr)) return (stdout, stderr, code) def _stopOnFailure(self, f): "utility method to stop the service when a failure occurs" if self.running: d = defer.maybeDeferred(lambda : self.stopService()) d.addErrback(log.err, 'while stopping broken HgPoller service') return f buildbot-0.8.8/buildbot/changes/mail.py000066400000000000000000000452071222546025000200510ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members """ Parse various kinds of 'CVS notify' email. """ import re import time, calendar import datetime from email import message_from_file from email.Utils import parseaddr, parsedate_tz, mktime_tz from email.Iterators import body_line_iterator from zope.interface import implements from twisted.python import log from twisted.internet import defer from buildbot import util from buildbot.interfaces import IChangeSource from buildbot.util.maildir import MaildirService class MaildirSource(MaildirService, util.ComparableMixin): """Generic base class for Maildir-based change sources""" implements(IChangeSource) compare_attrs = ["basedir", "pollinterval", "prefix"] def __init__(self, maildir, prefix=None, category='', repository=''): MaildirService.__init__(self, maildir) self.prefix = prefix self.category = category self.repository = repository if prefix and not prefix.endswith("/"): log.msg("%s: you probably want your prefix=('%s') to end with " "a slash") def describe(self): return "%s watching maildir '%s'" % (self.__class__.__name__, self.basedir) def messageReceived(self, filename): d = defer.succeed(None) def parse_file(_): f = self.moveToCurDir(filename) return self.parse_file(f, self.prefix) d.addCallback(parse_file) def add_change(chtuple): src, chdict = None, None if chtuple: src, chdict = chtuple if chdict: return self.master.addChange(src=src, **chdict) else: log.msg("no change found in maildir file '%s'" % filename) d.addCallback(add_change) return d def parse_file(self, fd, prefix=None): m = message_from_file(fd) return self.parse(m, prefix) class CVSMaildirSource(MaildirSource): name = "CVSMaildirSource" def __init__(self, maildir, prefix=None, category='', repository='', properties={}): MaildirSource.__init__(self, maildir, prefix, category, repository) self.properties = properties def parse(self, m, prefix=None): """Parse messages sent by the 'buildbot-cvs-mail' program. """ # The mail is sent from the person doing the checkin. Assume that the # local username is enough to identify them (this assumes a one-server # cvs-over-rsh environment rather than the server-dirs-shared-over-NFS # model) name, addr = parseaddr(m["from"]) if not addr: return None # no From means this message isn't from buildbot-cvs-mail at = addr.find("@") if at == -1: author = addr # might still be useful else: author = addr[:at] # CVS accecpts RFC822 dates. buildbot-cvs-mail adds the date as # part of the mail header, so use that. # This assumes cvs is being access via ssh or pserver, so the time # will be the CVS server's time. # calculate a "revision" based on that timestamp, or the current time # if we're unable to parse the date. log.msg('Processing CVS mail') dateTuple = parsedate_tz(m["date"]) if dateTuple == None: when = util.now() else: when = mktime_tz(dateTuple) theTime = datetime.datetime.utcfromtimestamp(float(when)) rev = theTime.strftime('%Y-%m-%d %H:%M:%S') catRE = re.compile( '^Category:\s*(\S.*)') cvsRE = re.compile( '^CVSROOT:\s*(\S.*)') cvsmodeRE = re.compile( '^Cvsmode:\s*(\S.*)') filesRE = re.compile( '^Files:\s*(\S.*)') modRE = re.compile( '^Module:\s*(\S.*)') pathRE = re.compile( '^Path:\s*(\S.*)') projRE = re.compile( '^Project:\s*(\S.*)') singleFileRE = re.compile( '(.*) (NONE|\d(\.|\d)+) (NONE|\d(\.|\d)+)') tagRE = re.compile( '^\s+Tag:\s*(\S.*)') updateRE = re.compile( '^Update of:\s*(\S.*)') comments = "" branch = None cvsroot = None fileList = None files = [] isdir = 0 path = None project = None lines = list(body_line_iterator(m)) while lines: line = lines.pop(0) m = catRE.match(line) if m: category = m.group(1) continue m = cvsRE.match(line) if m: cvsroot = m.group(1) continue m = cvsmodeRE.match(line) if m: cvsmode = m.group(1) continue m = filesRE.match(line) if m: fileList = m.group(1) continue m = modRE.match(line) if m: # We don't actually use this #module = m.group(1) continue m = pathRE.match(line) if m: path = m.group(1) continue m = projRE.match(line) if m: project = m.group(1) continue m = tagRE.match(line) if m: branch = m.group(1) continue m = updateRE.match(line) if m: # We don't actually use this #updateof = m.group(1) continue if line == "Log Message:\n": break # CVS 1.11 lists files as: # repo/path file,old-version,new-version file2,old-version,new-version # Version 1.12 lists files as: # file1 old-version new-version file2 old-version new-version # # files consists of tuples of 'file-name old-version new-version' # The versions are either dotted-decimal version numbers, ie 1.1 # or NONE. New files are of the form 'NONE NUMBER', while removed # files are 'NUMBER NONE'. 'NONE' is a literal string # Parsing this instead of files list in 'Added File:' etc # makes it possible to handle files with embedded spaces, though # it could fail if the filename was 'bad 1.1 1.2' # For cvs version 1.11, we expect # my_module new_file.c,NONE,1.1 # my_module removed.txt,1.2,NONE # my_module modified_file.c,1.1,1.2 # While cvs version 1.12 gives us # new_file.c NONE 1.1 # removed.txt 1.2 NONE # modified_file.c 1.1,1.2 if fileList is None: log.msg('CVSMaildirSource Mail with no files. Ignoring') return None # We don't have any files. Email not from CVS if cvsmode == '1.11': # Please, no repo paths with spaces! m = re.search('([^ ]*) ', fileList) if m: path = m.group(1) else: log.msg('CVSMaildirSource can\'t get path from file list. Ignoring mail') return fileList = fileList[len(path):].strip() singleFileRE = re.compile( '(.+?),(NONE|(?:\d+\.(?:\d+\.\d+\.)*\d+)),(NONE|(?:\d+\.(?:\d+\.\d+\.)*\d+))(?: |$)') elif cvsmode == '1.12': singleFileRE = re.compile( '(.+?) (NONE|(?:\d+\.(?:\d+\.\d+\.)*\d+)) (NONE|(?:\d+\.(?:\d+\.\d+\.)*\d+))(?: |$)') if path is None: raise ValueError('CVSMaildirSource cvs 1.12 require path. Check cvs loginfo config') else: raise ValueError('Expected cvsmode 1.11 or 1.12. got: %s' % cvsmode) log.msg("CVSMaildirSource processing filelist: %s" % fileList) while(fileList): m = singleFileRE.match(fileList) if m: curFile = path + '/' + m.group(1) files.append( curFile ) fileList = fileList[m.end():] else: log.msg('CVSMaildirSource no files matched regex. Ignoring') return None # bail - we couldn't parse the files that changed # Now get comments while lines: line = lines.pop(0) comments += line comments = comments.rstrip() + "\n" if comments == '\n': comments = None return ('cvs', dict(author=author, files=files, comments=comments, isdir=isdir, when=when, branch=branch, revision=rev, category=category, repository=cvsroot, project=project, properties=self.properties)) # svn "commit-email.pl" handler. The format is very similar to freshcvs mail; # here's a sample: # From: username [at] apache.org [slightly obfuscated to avoid spam here] # To: commits [at] spamassassin.apache.org # Subject: svn commit: r105955 - in spamassassin/trunk: . lib/Mail # ... # # Author: username # Date: Sat Nov 20 00:17:49 2004 [note: TZ = local tz on server!] # New Revision: 105955 # # Modified: [also Removed: and Added:] # [filename] # ... # Log: # [log message] # ... # # # Modified: spamassassin/trunk/lib/Mail/SpamAssassin.pm # [unified diff] # # [end of mail] class SVNCommitEmailMaildirSource(MaildirSource): name = "SVN commit-email.pl" def parse(self, m, prefix=None): """Parse messages sent by the svn 'commit-email.pl' trigger. """ # The mail is sent from the person doing the checkin. Assume that the # local username is enough to identify them (this assumes a one-server # cvs-over-rsh environment rather than the server-dirs-shared-over-NFS # model) name, addr = parseaddr(m["from"]) if not addr: return None # no From means this message isn't from svn at = addr.find("@") if at == -1: author = addr # might still be useful else: author = addr[:at] # we take the time of receipt as the time of checkin. Not correct (it # depends upon the email latency), but it avoids the # out-of-order-changes issue. Also syncmail doesn't give us anything # better to work with, unless you count pulling the v1-vs-v2 # timestamp out of the diffs, which would be ugly. TODO: Pulling the # 'Date:' header from the mail is a possibility, and # email.Utils.parsedate_tz may be useful. It should be configurable, # however, because there are a lot of broken clocks out there. when = util.now() files = [] comments = "" lines = list(body_line_iterator(m)) rev = None while lines: line = lines.pop(0) # "Author: jmason" match = re.search(r"^Author: (\S+)", line) if match: author = match.group(1) # "New Revision: 105955" match = re.search(r"^New Revision: (\d+)", line) if match: rev = match.group(1) # possible TODO: use "Date: ..." data here instead of time of # commit message receipt, above. however, this timestamp is # specified *without* a timezone, in the server's local TZ, so to # be accurate buildbot would need a config setting to specify the # source server's expected TZ setting! messy. # this stanza ends with the "Log:" if (line == "Log:\n"): break # commit message is terminated by the file-listing section while lines: line = lines.pop(0) if (line == "Modified:\n" or line == "Added:\n" or line == "Removed:\n"): break comments += line comments = comments.rstrip() + "\n" while lines: line = lines.pop(0) if line == "\n": break if line.find("Modified:\n") == 0: continue # ignore this line if line.find("Added:\n") == 0: continue # ignore this line if line.find("Removed:\n") == 0: continue # ignore this line line = line.strip() thesefiles = line.split(" ") for f in thesefiles: if prefix: # insist that the file start with the prefix: we may get # changes we don't care about too if f.startswith(prefix): f = f[len(prefix):] else: log.msg("ignored file from svn commit: prefix '%s' " "does not match filename '%s'" % (prefix, f)) continue # TODO: figure out how new directories are described, set # .isdir files.append(f) if not files: log.msg("no matching files found, ignoring commit") return None return ('svn', dict(author=author, files=files, comments=comments, when=when, revision=rev)) # bzr Launchpad branch subscription mails. Sample mail: # # From: noreply@launchpad.net # Subject: [Branch ~knielsen/maria/tmp-buildbot-test] Rev 2701: test add file # To: Joe # ... # # ------------------------------------------------------------ # revno: 2701 # committer: Joe # branch nick: tmpbb # timestamp: Fri 2009-05-15 10:35:43 +0200 # message: # test add file # added: # test-add-file # # # -- # # https://code.launchpad.net/~knielsen/maria/tmp-buildbot-test # # You are subscribed to branch lp:~knielsen/maria/tmp-buildbot-test. # To unsubscribe from this branch go to https://code.launchpad.net/~knielsen/maria/tmp-buildbot-test/+edit-subscription. # # [end of mail] class BzrLaunchpadEmailMaildirSource(MaildirSource): name = "Launchpad" compare_attrs = MaildirSource.compare_attrs + ["branchMap", "defaultBranch"] def __init__(self, maildir, prefix=None, branchMap=None, defaultBranch=None, **kwargs): self.branchMap = branchMap self.defaultBranch = defaultBranch MaildirSource.__init__(self, maildir, prefix, **kwargs) def parse(self, m, prefix=None): """Parse branch notification messages sent by Launchpad. """ subject = m["subject"] match = re.search(r"^\s*\[Branch\s+([^]]+)\]", subject) if match: repository = match.group(1) else: repository = None # Put these into a dictionary, otherwise we cannot assign them # from nested function definitions. d = { 'files': [], 'comments': u"" } gobbler = None rev = None author = None when = util.now() def gobble_comment(s): d['comments'] += s + "\n" def gobble_removed(s): d['files'].append('%s REMOVED' % s) def gobble_added(s): d['files'].append('%s ADDED' % s) def gobble_modified(s): d['files'].append('%s MODIFIED' % s) def gobble_renamed(s): match = re.search(r"^(.+) => (.+)$", s) if match: d['files'].append('%s RENAMED %s' % (match.group(1), match.group(2))) else: d['files'].append('%s RENAMED' % s) lines = list(body_line_iterator(m, True)) rev = None while lines: line = unicode(lines.pop(0), "utf-8", errors="ignore") # revno: 101 match = re.search(r"^revno: ([0-9.]+)", line) if match: rev = match.group(1) # committer: Joe match = re.search(r"^committer: (.*)$", line) if match: author = match.group(1) # timestamp: Fri 2009-05-15 10:35:43 +0200 # datetime.strptime() is supposed to support %z for time zone, but # it does not seem to work. So handle the time zone manually. match = re.search(r"^timestamp: [a-zA-Z]{3} (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) ([-+])(\d{2})(\d{2})$", line) if match: datestr = match.group(1) tz_sign = match.group(2) tz_hours = match.group(3) tz_minutes = match.group(4) when = parseLaunchpadDate(datestr, tz_sign, tz_hours, tz_minutes) if re.search(r"^message:\s*$", line): gobbler = gobble_comment elif re.search(r"^removed:\s*$", line): gobbler = gobble_removed elif re.search(r"^added:\s*$", line): gobbler = gobble_added elif re.search(r"^renamed:\s*$", line): gobbler = gobble_renamed elif re.search(r"^modified:\s*$", line): gobbler = gobble_modified elif re.search(r"^ ", line) and gobbler: gobbler(line[2:-1]) # Use :-1 to gobble trailing newline # Determine the name of the branch. branch = None if self.branchMap and repository: if self.branchMap.has_key(repository): branch = self.branchMap[repository] elif self.branchMap.has_key('lp:' + repository): branch = self.branchMap['lp:' + repository] if not branch: if self.defaultBranch: branch = self.defaultBranch else: if repository: branch = 'lp:' + repository else: branch = None if rev and author: return ('bzr', dict(author=author, files=d['files'], comments=d['comments'], when=when, revision=rev, branch=branch, repository=repository or '')) else: return None def parseLaunchpadDate(datestr, tz_sign, tz_hours, tz_minutes): time_no_tz = calendar.timegm(time.strptime(datestr, "%Y-%m-%d %H:%M:%S")) tz_delta = 60 * 60 * int(tz_sign + tz_hours) + 60 * int(tz_minutes) return time_no_tz - tz_delta buildbot-0.8.8/buildbot/changes/manager.py000066400000000000000000000050671222546025000205410ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from twisted.python import log from twisted.internet import defer from twisted.application import service from buildbot import interfaces, config, util from buildbot.process import metrics class ChangeManager(config.ReconfigurableServiceMixin, service.MultiService): """ This is the master-side service which receives file change notifications from version-control systems. It is a Twisted service, which has instances of L{buildbot.interfaces.IChangeSource} as child services. These are added by the master with C{addSource}. """ implements(interfaces.IEventSource) name = "changemanager" def __init__(self, master): service.MultiService.__init__(self) self.setName('change_manager') self.master = master @defer.inlineCallbacks def reconfigService(self, new_config): timer = metrics.Timer("ChangeManager.reconfigService") timer.start() removed, added = util.diffSets( set(self), new_config.change_sources) if removed or added: log.msg("adding %d new changesources, removing %d" % (len(added), len(removed))) for src in removed: yield defer.maybeDeferred( src.disownServiceParent) src.master = None for src in added: src.master = self.master src.setServiceParent(self) num_sources = len(list(self)) assert num_sources == len(new_config.change_sources) metrics.MetricCountEvent.log("num_sources", num_sources, absolute=True) # reconfig any newly-added change sources, as well as existing yield config.ReconfigurableServiceMixin.reconfigService(self, new_config) timer.stop() buildbot-0.8.8/buildbot/changes/p4poller.py000066400000000000000000000160561222546025000206700ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members # Portions Copyright 2011 National Instruments # Many thanks to Dave Peticolas for contributing this module import re import time import os from twisted.python import log from twisted.internet import defer, utils from buildbot import util from buildbot.changes import base class P4PollerError(Exception): """Something went wrong with the poll. This is used as a distinctive exception type so that unit tests can detect and ignore it.""" def get_simple_split(branchfile): """Splits the branchfile argument and assuming branch is the first path component in branchfile, will return branch and file else None.""" index = branchfile.find('/') if index == -1: return None, None branch, file = branchfile.split('/', 1) return branch, file class P4Source(base.PollingChangeSource, util.ComparableMixin): """This source will poll a perforce repository for changes and submit them to the change master.""" compare_attrs = ["p4port", "p4user", "p4passwd", "p4base", "p4bin", "pollInterval"] env_vars = ["P4CLIENT", "P4PORT", "P4PASSWD", "P4USER", "P4CHARSET" , "PATH"] changes_line_re = re.compile( r"Change (?P\d+) on \S+ by \S+@\S+ '.*'$") describe_header_re = re.compile( r"Change \d+ by (?P\S+)@\S+ on (?P.+)$") file_re = re.compile(r"^\.\.\. (?P[^#]+)#\d+ [/\w]+$") datefmt = '%Y/%m/%d %H:%M:%S' parent = None # filled in when we're added last_change = None loop = None def __init__(self, p4port=None, p4user=None, p4passwd=None, p4base='//', p4bin='p4', split_file=lambda branchfile: (None, branchfile), pollInterval=60 * 10, histmax=None, pollinterval=-2, encoding='utf8', project=None, name=None): # for backward compatibility; the parameter used to be spelled with 'i' if pollinterval != -2: pollInterval = pollinterval base.PollingChangeSource.__init__(self, name=name, pollInterval=pollInterval) if project is None: project = '' self.p4port = p4port self.p4user = p4user self.p4passwd = p4passwd self.p4base = p4base self.p4bin = p4bin self.split_file = split_file self.encoding = encoding self.project = project def describe(self): return "p4source %s %s" % (self.p4port, self.p4base) def poll(self): d = self._poll() d.addErrback(log.err, 'P4 poll failed') return d def _get_process_output(self, args): env = dict([(e, os.environ.get(e)) for e in self.env_vars if os.environ.get(e)]) d = utils.getProcessOutput(self.p4bin, args, env) return d @defer.inlineCallbacks def _poll(self): args = [] if self.p4port: args.extend(['-p', self.p4port]) if self.p4user: args.extend(['-u', self.p4user]) if self.p4passwd: args.extend(['-P', self.p4passwd]) args.extend(['changes']) if self.last_change is not None: args.extend(['%s...@%d,now' % (self.p4base, self.last_change+1)]) else: args.extend(['-m', '1', '%s...' % (self.p4base,)]) result = yield self._get_process_output(args) last_change = self.last_change changelists = [] for line in result.split('\n'): line = line.strip() if not line: continue m = self.changes_line_re.match(line) if not m: raise P4PollerError("Unexpected 'p4 changes' output: %r" % result) num = int(m.group('num')) if last_change is None: # first time through, the poller just gets a "baseline" for where to # start on the next poll log.msg('P4Poller: starting at change %d' % num) self.last_change = num return changelists.append(num) changelists.reverse() # oldest first # Retrieve each sequentially. for num in changelists: args = [] if self.p4port: args.extend(['-p', self.p4port]) if self.p4user: args.extend(['-u', self.p4user]) if self.p4passwd: args.extend(['-P', self.p4passwd]) args.extend(['describe', '-s', str(num)]) result = yield self._get_process_output(args) # decode the result from its designated encoding result = result.decode(self.encoding) lines = result.split('\n') # SF#1555985: Wade Brainerd reports a stray ^M at the end of the date # field. The rstrip() is intended to remove that. lines[0] = lines[0].rstrip() m = self.describe_header_re.match(lines[0]) if not m: raise P4PollerError("Unexpected 'p4 describe -s' result: %r" % result) who = m.group('who') when = time.mktime(time.strptime(m.group('when'), self.datefmt)) comments = '' while not lines[0].startswith('Affected files'): comments += lines.pop(0) + '\n' lines.pop(0) # affected files branch_files = {} # dict for branch mapped to file(s) while lines: line = lines.pop(0).strip() if not line: continue m = self.file_re.match(line) if not m: raise P4PollerError("Invalid file line: %r" % line) path = m.group('path') if path.startswith(self.p4base): branch, file = self.split_file(path[len(self.p4base):]) if (branch == None and file == None): continue if branch_files.has_key(branch): branch_files[branch].append(file) else: branch_files[branch] = [file] for branch in branch_files: yield self.master.addChange( author=who, files=branch_files[branch], comments=comments, revision=str(num), when_timestamp=util.epoch2datetime(when), branch=branch, project=self.project) self.last_change = num buildbot-0.8.8/buildbot/changes/pb.py000066400000000000000000000135151222546025000175250ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.python import log from twisted.internet import defer from buildbot.pbutil import NewCredPerspective from buildbot.changes import base from buildbot.util import epoch2datetime from buildbot import config class ChangePerspective(NewCredPerspective): def __init__(self, master, prefix): self.master = master self.prefix = prefix def attached(self, mind): return self def detached(self, mind): pass def perspective_addChange(self, changedict): log.msg("perspective_addChange called") if 'revlink' in changedict and not changedict['revlink']: changedict['revlink'] = '' if 'repository' in changedict and not changedict['repository']: changedict['repository'] = '' if 'project' in changedict and not changedict['project']: changedict['project'] = '' if 'files' not in changedict or not changedict['files']: changedict['files'] = [] # rename arguments to new names. Note that the client still uses the # "old" names (who, when, and isdir), as they are not deprecated yet, # although the master will accept the new names (author, # when_timestamp, and is_dir). After a few revisions have passed, we # can switch the client to use the new names. if 'isdir' in changedict: changedict['is_dir'] = changedict['isdir'] del changedict['isdir'] if 'who' in changedict: changedict['author'] = changedict['who'] del changedict['who'] if 'when' in changedict: when = None if changedict['when'] is not None: when = epoch2datetime(changedict['when']) changedict['when_timestamp'] = when del changedict['when'] # turn any bytestring keys into unicode, assuming utf8 but just # replacing unknown characters. Ideally client would send us unicode # in the first place, but older clients do not, so this fallback is # useful. for key in changedict: if type(changedict[key]) == str: changedict[key] = changedict[key].decode('utf8', 'replace') changedict['files'] = list(changedict['files']) for i, file in enumerate(changedict.get('files', [])): if type(file) == str: changedict['files'][i] = file.decode('utf8', 'replace') files = [] for path in changedict['files']: if self.prefix: if not path.startswith(self.prefix): # this file does not start with the prefix, so ignore it continue path = path[len(self.prefix):] files.append(path) changedict['files'] = files if not files: log.msg("No files listed in change... bit strange, but not fatal.") if changedict.has_key('links'): log.msg("Found links: "+repr(changedict['links'])) del changedict['links'] d = self.master.addChange(**changedict) # since this is a remote method, we can't return a Change instance, so # this just sets the return value to None: d.addCallback(lambda _ : None) return d class PBChangeSource(config.ReconfigurableServiceMixin, base.ChangeSource): compare_attrs = ["user", "passwd", "port", "prefix", "port"] def __init__(self, user="change", passwd="changepw", port=None, prefix=None): self.user = user self.passwd = passwd self.port = port self.prefix = prefix self.registration = None self.registered_port = None def describe(self): portname = self.registered_port d = "PBChangeSource listener on " + str(portname) if self.prefix is not None: d += " (prefix '%s')" % self.prefix return d @defer.inlineCallbacks def reconfigService(self, new_config): # calculate the new port port = self.port if port is None: port = new_config.slavePortnum # and, if it's changed, re-register if port != self.registered_port: yield self._unregister() self._register(port) yield config.ReconfigurableServiceMixin.reconfigService( self, new_config) def stopService(self): d = defer.maybeDeferred(base.ChangeSource.stopService, self) d.addCallback(lambda _ : self._unregister()) return d def _register(self, port): if not port: log.msg("PBChangeSource has no port to listen on") return self.registered_port = port self.registration = self.master.pbmanager.register( port, self.user, self.passwd, self.getPerspective) def _unregister(self): self.registered_port = None if self.registration: reg = self.registration self.registration = None return reg.unregister() else: return defer.succeed(None) def getPerspective(self, mind, username): assert username == self.user return ChangePerspective(self.master, self.prefix) buildbot-0.8.8/buildbot/changes/svnpoller.py000066400000000000000000000413451222546025000211520ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement # Based on the work of Dave Peticolas for the P4poll # Changed to svn (using xml.dom.minidom) by Niklaus Giger # Hacked beyond recognition by Brian Warner from twisted.python import log from twisted.internet import defer, utils from buildbot import util from buildbot.changes import base import xml.dom.minidom import os, urllib # these split_file_* functions are available for use as values to the # split_file= argument. def split_file_alwaystrunk(path): return dict(path=path) def split_file_branches(path): # turn "trunk/subdir/file.c" into (None, "subdir/file.c") # and "trunk/subdir/" into (None, "subdir/") # and "trunk/" into (None, "") # and "branches/1.5.x/subdir/file.c" into ("branches/1.5.x", "subdir/file.c") # and "branches/1.5.x/subdir/" into ("branches/1.5.x", "subdir/") # and "branches/1.5.x/" into ("branches/1.5.x", "") pieces = path.split('/') if len(pieces) > 1 and pieces[0] == 'trunk': return (None, '/'.join(pieces[1:])) elif len(pieces) > 2 and pieces[0] == 'branches': return ('/'.join(pieces[0:2]), '/'.join(pieces[2:])) else: return None def split_file_projects_branches(path): # turn projectname/trunk/subdir/file.c into dict(project=projectname, branch=trunk, path=subdir/file.c) if not "/" in path: return None project, path = path.split("/", 1) f = split_file_branches(path) if f: info = dict(project=project, path=f[1]) if f[0]: info['branch'] = f[0] return info return f class SVNPoller(base.PollingChangeSource, util.ComparableMixin): """ Poll a Subversion repository for changes and submit them to the change master. """ compare_attrs = ["svnurl", "split_file", "svnuser", "svnpasswd", "project", "pollInterval", "histmax", "svnbin", "category", "cachepath"] parent = None # filled in when we're added last_change = None loop = None def __init__(self, svnurl, split_file=None, svnuser=None, svnpasswd=None, pollInterval=10*60, histmax=100, svnbin='svn', revlinktmpl='', category=None, project='', cachepath=None, pollinterval=-2, extra_args=None): # for backward compatibility; the parameter used to be spelled with 'i' if pollinterval != -2: pollInterval = pollinterval base.PollingChangeSource.__init__(self, name=svnurl, pollInterval=pollInterval) if svnurl.endswith("/"): svnurl = svnurl[:-1] # strip the trailing slash self.svnurl = svnurl self.extra_args = extra_args self.split_file = split_file or split_file_alwaystrunk self.svnuser = svnuser self.svnpasswd = svnpasswd self.revlinktmpl = revlinktmpl self.environ = os.environ.copy() # include environment variables # required for ssh-agent auth self.svnbin = svnbin self.histmax = histmax self._prefix = None self.category = category self.project = project self.cachepath = cachepath if self.cachepath and os.path.exists(self.cachepath): try: with open(self.cachepath, "r") as f: self.last_change = int(f.read().strip()) log.msg("SVNPoller: SVNPoller(%s) setting last_change to %s" % (self.svnurl, self.last_change)) # try writing it, too with open(self.cachepath, "w") as f: f.write(str(self.last_change)) except: self.cachepath = None log.msg(("SVNPoller: SVNPoller(%s) cache file corrupt or unwriteable; " + "skipping and not using") % self.svnurl) log.err() def describe(self): return "SVNPoller: watching %s" % self.svnurl def poll(self): # Our return value is only used for unit testing. # we need to figure out the repository root, so we can figure out # repository-relative pathnames later. Each SVNURL is in the form # (ROOT)/(PROJECT)/(BRANCH)/(FILEPATH), where (ROOT) is something # like svn://svn.twistedmatrix.com/svn/Twisted (i.e. there is a # physical repository at /svn/Twisted on that host), (PROJECT) is # something like Projects/Twisted (i.e. within the repository's # internal namespace, everything under Projects/Twisted/ has # something to do with Twisted, but these directory names do not # actually appear on the repository host), (BRANCH) is something like # "trunk" or "branches/2.0.x", and (FILEPATH) is a tree-relative # filename like "twisted/internet/defer.py". # our self.svnurl attribute contains (ROOT)/(PROJECT) combined # together in a way that we can't separate without svn's help. If the # user is not using the split_file= argument, then self.svnurl might # be (ROOT)/(PROJECT)/(BRANCH) . In any case, the filenames we will # get back from 'svn log' will be of the form # (PROJECT)/(BRANCH)/(FILEPATH), but we want to be able to remove # that (PROJECT) prefix from them. To do this without requiring the # user to tell us how svnurl is split into ROOT and PROJECT, we do an # 'svn info --xml' command at startup. This command will include a # element that tells us ROOT. We then strip this prefix from # self.svnurl to determine PROJECT, and then later we strip the # PROJECT prefix from the filenames reported by 'svn log --xml' to # get a (BRANCH)/(FILEPATH) that can be passed to split_file() to # turn into separate BRANCH and FILEPATH values. # whew. if self.project: log.msg("SVNPoller: polling " + self.project) else: log.msg("SVNPoller: polling") d = defer.succeed(None) if not self._prefix: d.addCallback(lambda _ : self.get_prefix()) def set_prefix(prefix): self._prefix = prefix d.addCallback(set_prefix) d.addCallback(self.get_logs) d.addCallback(self.parse_logs) d.addCallback(self.get_new_logentries) d.addCallback(self.create_changes) d.addCallback(self.submit_changes) d.addCallback(self.finished_ok) d.addErrback(log.err, 'SVNPoller: Error in while polling') # eat errors return d def getProcessOutput(self, args): # this exists so we can override it during the unit tests d = utils.getProcessOutput(self.svnbin, args, self.environ) return d def get_prefix(self): args = ["info", "--xml", "--non-interactive", self.svnurl] if self.svnuser: args.extend(["--username=%s" % self.svnuser]) if self.svnpasswd: args.extend(["--password=%s" % self.svnpasswd]) if self.extra_args: args.extend(self.extra_args) d = self.getProcessOutput(args) def determine_prefix(output): try: doc = xml.dom.minidom.parseString(output) except xml.parsers.expat.ExpatError: log.msg("SVNPoller: SVNPoller._determine_prefix_2: ExpatError in '%s'" % output) raise rootnodes = doc.getElementsByTagName("root") if not rootnodes: # this happens if the URL we gave was already the root. In this # case, our prefix is empty. self._prefix = "" return self._prefix rootnode = rootnodes[0] root = "".join([c.data for c in rootnode.childNodes]) # root will be a unicode string if not self.svnurl.startswith(root): log.msg(format="svnurl='%(svnurl)s' doesn't start with ='%(root)s'", svnurl=self.svnurl, root=root) raise RuntimeError("Can't handle redirected svn connections!? " "This shouldn't happen.") prefix = self.svnurl[len(root):] if prefix.startswith("/"): prefix = prefix[1:] log.msg("SVNPoller: svnurl=%s, root=%s, so prefix=%s" % (self.svnurl, root, prefix)) return prefix d.addCallback(determine_prefix) return d def get_logs(self, _): args = [] args.extend(["log", "--xml", "--verbose", "--non-interactive"]) if self.svnuser: args.extend(["--username=%s" % self.svnuser]) if self.svnpasswd: args.extend(["--password=%s" % self.svnpasswd]) if self.extra_args: args.extend(self.extra_args) args.extend(["--limit=%d" % (self.histmax), self.svnurl]) d = self.getProcessOutput(args) return d def parse_logs(self, output): # parse the XML output, return a list of nodes try: doc = xml.dom.minidom.parseString(output) except xml.parsers.expat.ExpatError: log.msg("SVNPoller: SVNPoller.parse_logs: ExpatError in '%s'" % output) raise logentries = doc.getElementsByTagName("logentry") return logentries def get_new_logentries(self, logentries): last_change = old_last_change = self.last_change # given a list of logentries, calculate new_last_change, and # new_logentries, where new_logentries contains only the ones after # last_change new_last_change = None new_logentries = [] if logentries: new_last_change = int(logentries[0].getAttribute("revision")) if last_change is None: # if this is the first time we've been run, ignore any changes # that occurred before now. This prevents a build at every # startup. log.msg('SVNPoller: starting at change %s' % new_last_change) elif last_change == new_last_change: # an unmodified repository will hit this case log.msg('SVNPoller: no changes') else: for el in logentries: if last_change == int(el.getAttribute("revision")): break new_logentries.append(el) new_logentries.reverse() # return oldest first self.last_change = new_last_change log.msg('SVNPoller: _process_changes %s .. %s' % (old_last_change, new_last_change)) return new_logentries def _get_text(self, element, tag_name): try: child_nodes = element.getElementsByTagName(tag_name)[0].childNodes text = "".join([t.data for t in child_nodes]) except: text = "" return text def _transform_path(self, path): if not path.startswith(self._prefix): log.msg(format="SVNPoller: ignoring path '%(path)s' which doesn't" "start with prefix '%(prefix)s'", path=path, prefix=self._prefix) return relative_path = path[len(self._prefix):] if relative_path.startswith("/"): relative_path = relative_path[1:] where = self.split_file(relative_path) # 'where' is either None, (branch, final_path) or a dict if not where: return if isinstance(where, tuple): where = dict(branch=where[0], path=where[1]) return where def create_changes(self, new_logentries): changes = [] for el in new_logentries: revision = str(el.getAttribute("revision")) revlink='' if self.revlinktmpl: if revision: revlink = self.revlinktmpl % urllib.quote_plus(revision) log.msg("Adding change revision %s" % (revision,)) author = self._get_text(el, "author") comments = self._get_text(el, "msg") # there is a "date" field, but it provides localtime in the # repository's timezone, whereas we care about buildmaster's # localtime (since this will get used to position the boxes on # the Waterfall display, etc). So ignore the date field, and # addChange will fill in with the current time branches = {} try: pathlist = el.getElementsByTagName("paths")[0] except IndexError: # weird, we got an empty revision log.msg("ignoring commit with no paths") continue for p in pathlist.getElementsByTagName("path"): kind = p.getAttribute("kind") action = p.getAttribute("action") path = "".join([t.data for t in p.childNodes]) # the rest of buildbot is certaily not yet ready to handle # unicode filenames, because they get put in RemoteCommands # which get sent via PB to the buildslave, and PB doesn't # handle unicode. path = path.encode("ascii") if path.startswith("/"): path = path[1:] if kind == "dir" and not path.endswith("/"): path += "/" where = self._transform_path(path) # if 'where' is None, the file was outside any project that # we care about and we should ignore it if where: branch = where.get("branch", None) filename = where["path"] if not branch in branches: branches[branch] = { 'files': [], 'number_of_directories': 0} if filename == "": # root directory of branch branches[branch]['files'].append(filename) branches[branch]['number_of_directories'] += 1 elif filename.endswith("/"): # subdirectory of branch branches[branch]['files'].append(filename[:-1]) branches[branch]['number_of_directories'] += 1 else: branches[branch]['files'].append(filename) if not branches[branch].has_key('action'): branches[branch]['action'] = action for key in ("repository", "project", "codebase"): if key in where: branches[branch][key] = where[key] for branch in branches.keys(): action = branches[branch]['action'] files = branches[branch]['files'] number_of_directories_changed = branches[branch]['number_of_directories'] number_of_files_changed = len(files) if action == u'D' and number_of_directories_changed == 1 and number_of_files_changed == 1 and files[0] == '': log.msg("Ignoring deletion of branch '%s'" % branch) else: chdict = dict( author=author, files=files, comments=comments, revision=revision, branch=branch, revlink=revlink, category=self.category, repository=branches[branch].get('repository', self.svnurl), project=branches[branch].get('project', self.project), codebase=branches[branch].get('codebase', None)) changes.append(chdict) return changes @defer.inlineCallbacks def submit_changes(self, changes): for chdict in changes: yield self.master.addChange(src='svn', **chdict) def finished_ok(self, res): if self.cachepath: with open(self.cachepath, "w") as f: f.write(str(self.last_change)) log.msg("SVNPoller: finished polling %s" % res) return res buildbot-0.8.8/buildbot/clients/000077500000000000000000000000001222546025000165765ustar00rootroot00000000000000buildbot-0.8.8/buildbot/clients/__init__.py000066400000000000000000000000001222546025000206750ustar00rootroot00000000000000buildbot-0.8.8/buildbot/clients/base.py000066400000000000000000000053261222546025000200700ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.spread import pb class StatusClient(pb.Referenceable): """To use this, call my .connected method with a RemoteReference to the buildmaster's StatusClientPerspective object. """ def __init__(self, events): self.builders = {} self.events = events def connected(self, remote): print "connected" self.remote = remote remote.callRemote("subscribe", self.events, 5, self) def remote_builderAdded(self, buildername, builder): print "builderAdded", buildername def remote_builderRemoved(self, buildername): print "builderRemoved", buildername def remote_builderChangedState(self, buildername, state, eta): print "builderChangedState", buildername, state, eta def remote_buildStarted(self, buildername, build): print "buildStarted", buildername def remote_buildFinished(self, buildername, build, results): print "buildFinished", results def remote_buildETAUpdate(self, buildername, build, eta): print "ETA", buildername, eta def remote_stepStarted(self, buildername, build, stepname, step): print "stepStarted", buildername, stepname def remote_stepFinished(self, buildername, build, stepname, step, results): print "stepFinished", buildername, stepname, results def remote_stepETAUpdate(self, buildername, build, stepname, step, eta, expectations): print "stepETA", buildername, stepname, eta def remote_logStarted(self, buildername, build, stepname, step, logname, log): print "logStarted", buildername, stepname def remote_logFinished(self, buildername, build, stepname, step, logname, log): print "logFinished", buildername, stepname def remote_logChunk(self, buildername, build, stepname, step, logname, log, channel, text): ChunkTypes = ["STDOUT", "STDERR", "HEADER"] print "logChunk[%s]: %s" % (ChunkTypes[channel], text) buildbot-0.8.8/buildbot/clients/debug.glade000066400000000000000000000614571222546025000206770ustar00rootroot00000000000000 True Buildbot Debug Tool GTK_WINDOW_TOPLEVEL GTK_WIN_POS_NONE False True False True False False GDK_WINDOW_TYPE_HINT_NORMAL GDK_GRAVITY_NORTH_WEST True False True False 0 True False 0 True True Connect True GTK_RELIEF_NORMAL True 0 False False True Disconnected False False GTK_JUSTIFY_CENTER False False 0.5 0.5 0 0 PANGO_ELLIPSIZE_NONE -1 False 0 0 True True 0 False False True False 0 True True Reload .cfg True GTK_RELIEF_NORMAL True 0 False False True False True Rebuild .py True GTK_RELIEF_NORMAL True 0 False False True True poke IRC True GTK_RELIEF_NORMAL True 0 False False 0 True True True False 0 True True Branch: True GTK_RELIEF_NORMAL True False False True 0 False False True True True True 0 True * False 0 True True 0 True True True False 0 True True Revision: True GTK_RELIEF_NORMAL True False False True 0 False False True True True True 0 True * False 0 True True 0 True True 4 True 0 0.5 GTK_SHADOW_ETCHED_IN True 0.5 0.5 1 1 0 0 0 0 True False 0 True False 0 True True commit True GTK_RELIEF_NORMAL True 0 False False True True True True 0 twisted/internet/app.py True * False 0 True True 0 True True True False 0 True Who: False False GTK_JUSTIFY_LEFT False False 0.5 0.5 0 0 PANGO_ELLIPSIZE_NONE -1 False 0 0 False False True True True True 0 bob True * False 0 True True 0 True True True Commit False False GTK_JUSTIFY_LEFT False False 0.5 0.5 2 0 PANGO_ELLIPSIZE_NONE -1 False 0 label_item 0 True True 4 True 0 0.5 GTK_SHADOW_ETCHED_IN True False 0 True False 3 True Builder: False False GTK_JUSTIFY_CENTER False False 0.5 0.5 0 0 PANGO_ELLIPSIZE_NONE -1 False 0 0 False False True True True True 0 one True * False 0 True True 0 True True True False 0 True True Request Build True GTK_RELIEF_NORMAL True 0 False False True True Ping Builder True GTK_RELIEF_NORMAL True 0 False False 0 True True True False 0 True Currently: False False GTK_JUSTIFY_CENTER False False 0.5 0.5 7 0 PANGO_ELLIPSIZE_NONE -1 False 0 0 False False True True offline True GTK_RELIEF_NORMAL True 0 False False True True idle True GTK_RELIEF_NORMAL True 0 False False True True waiting True GTK_RELIEF_NORMAL True 0 False False True True building True GTK_RELIEF_NORMAL True 0 False False 0 True True True Builder False False GTK_JUSTIFY_LEFT False False 0.5 0.5 2 0 PANGO_ELLIPSIZE_NONE -1 False 0 label_item 0 True True buildbot-0.8.8/buildbot/clients/debug.py000066400000000000000000000152761222546025000202510ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.internet import gtk2reactor gtk2reactor.install() from twisted.internet import reactor from twisted.python import util from twisted.spread import pb from twisted.cred import credentials import gtk.glade #@UnresolvedImport import re class DebugWidget: def __init__(self, master="localhost:8007", passwd="debugpw"): self.connected = 0 try: host, port = re.search(r'(.+):(\d+)', master).groups() except: print "unparseable master location '%s'" % master print " expecting something more like localhost:8007" raise self.host = host self.port = int(port) self.passwd = passwd self.remote = None xml = self.xml = gtk.glade.XML(util.sibpath(__file__, "debug.glade")) g = xml.get_widget self.buildname = g('buildname') self.filename = g('filename') self.connectbutton = g('connectbutton') self.connectlabel = g('connectlabel') g('window1').connect('destroy', lambda win: gtk.main_quit()) # put the master info in the window's titlebar g('window1').set_title("Buildbot Debug Tool: %s" % master) c = xml.signal_connect c('do_connect', self.do_connect) c('do_reload', self.do_reload) c('do_rebuild', self.do_rebuild) c('do_poke_irc', self.do_poke_irc) c('do_build', self.do_build) c('do_ping', self.do_ping) c('do_commit', self.do_commit) c('on_usebranch_toggled', self.usebranch_toggled) self.usebranch_toggled(g('usebranch')) c('on_userevision_toggled', self.userevision_toggled) self.userevision_toggled(g('userevision')) c('do_current_offline', self.do_current, "offline") c('do_current_idle', self.do_current, "idle") c('do_current_waiting', self.do_current, "waiting") c('do_current_building', self.do_current, "building") def do_connect(self, widget): if self.connected: self.connectlabel.set_text("Disconnecting...") if self.remote: self.remote.broker.transport.loseConnection() else: self.connectlabel.set_text("Connecting...") f = pb.PBClientFactory() creds = credentials.UsernamePassword("debug", self.passwd) d = f.login(creds) reactor.connectTCP(self.host, int(self.port), f) d.addCallbacks(self.connect_complete, self.connect_failed) def connect_complete(self, ref): self.connectbutton.set_label("Disconnect") self.connectlabel.set_text("Connected") self.connected = 1 self.remote = ref self.remote.callRemote("print", "hello cleveland") self.remote.notifyOnDisconnect(self.disconnected) def connect_failed(self, why): self.connectlabel.set_text("Failed") print why def disconnected(self, ref): self.connectbutton.set_label("Connect") self.connectlabel.set_text("Disconnected") self.connected = 0 self.remote = None def do_reload(self, widget): if not self.remote: return d = self.remote.callRemote("reload") d.addErrback(self.err) def do_rebuild(self, widget): print "Not yet implemented" return def do_poke_irc(self, widget): if not self.remote: return d = self.remote.callRemote("pokeIRC") d.addErrback(self.err) def do_build(self, widget): if not self.remote: return name = self.buildname.get_text() branch = None if self.xml.get_widget("usebranch").get_active(): branch = self.xml.get_widget('branch').get_text() if branch == '': branch = None revision = None if self.xml.get_widget("userevision").get_active(): revision = self.xml.get_widget('revision').get_text() if revision == '': revision = None reason = "debugclient 'Request Build' button pushed" properties = {} d = self.remote.callRemote("requestBuild", name, reason, branch, revision, properties) d.addErrback(self.err) def do_ping(self, widget): if not self.remote: return name = self.buildname.get_text() d = self.remote.callRemote("pingBuilder", name) d.addErrback(self.err) def usebranch_toggled(self, widget): rev = self.xml.get_widget('branch') if widget.get_active(): rev.set_sensitive(True) else: rev.set_sensitive(False) def userevision_toggled(self, widget): rev = self.xml.get_widget('revision') if widget.get_active(): rev.set_sensitive(True) else: rev.set_sensitive(False) def do_commit(self, widget): if not self.remote: return filename = self.filename.get_text() who = self.xml.get_widget("who").get_text() branch = None if self.xml.get_widget("usebranch").get_active(): branch = self.xml.get_widget('branch').get_text() if branch == '': branch = None revision = None if self.xml.get_widget("userevision").get_active(): revision = self.xml.get_widget('revision').get_text() try: revision = int(revision) except ValueError: pass if revision == '': revision = None kwargs = { 'revision': revision, 'who': who } if branch: kwargs['branch'] = branch d = self.remote.callRemote("fakeChange", filename, **kwargs) d.addErrback(self.err) def do_current(self, widget, state): if not self.remote: return name = self.buildname.get_text() d = self.remote.callRemote("setCurrentState", name, state) d.addErrback(self.err) def err(self, failure): print "received error:", failure def run(self): reactor.run() buildbot-0.8.8/buildbot/clients/gtkPanes.py000066400000000000000000000433671222546025000207410ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.internet import gtk2reactor gtk2reactor.install() #@UndefinedVariable import sys, time import pygtk #@UnresolvedImport pygtk.require("2.0") import gobject, gtk #@UnresolvedImport assert(gtk.Window) # in gtk1 it's gtk.GtkWindow from twisted.spread import pb #from buildbot.clients.base import Builder, Client from buildbot.clients.base import StatusClient from buildbot.clients.text import TextClient from buildbot.util import now from buildbot.status.results import SUCCESS, WARNINGS, FAILURE, EXCEPTION ''' class Pane: def __init__(self): pass class OneRow(Pane): """This is a one-row status bar. It has one square per Builder, and that square is either red, yellow, or green. """ def __init__(self): Pane.__init__(self) self.widget = gtk.VBox(gtk.FALSE, 2) self.nameBox = gtk.HBox(gtk.TRUE) self.statusBox = gtk.HBox(gtk.TRUE) self.widget.add(self.nameBox) self.widget.add(self.statusBox) self.widget.show_all() self.builders = [] def getWidget(self): return self.widget def addBuilder(self, builder): print "OneRow.addBuilder" # todo: ordering. Should follow the order in which they were added # to the original BotMaster self.builders.append(builder) # add the name to the left column, and a label (with background) to # the right name = gtk.Label(builder.name) status = gtk.Label('??') status.set_size_request(64,64) box = gtk.EventBox() box.add(status) name.show() box.show_all() self.nameBox.add(name) self.statusBox.add(box) builder.haveSomeWidgets([name, status, box]) class R2Builder(Builder): def start(self): self.nameSquare.set_text(self.name) self.statusSquare.set_text("???") self.subscribe() def haveSomeWidgets(self, widgets): self.nameSquare, self.statusSquare, self.statusBox = widgets def remote_newLastBuildStatus(self, event): color = None if event: text = "\n".join(event.text) color = event.color else: text = "none" self.statusSquare.set_text(text) if color: print "color", color self.statusBox.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse(color)) def remote_currentlyOffline(self): self.statusSquare.set_text("offline") def remote_currentlyIdle(self): self.statusSquare.set_text("idle") def remote_currentlyWaiting(self, seconds): self.statusSquare.set_text("waiting") def remote_currentlyInterlocked(self): self.statusSquare.set_text("interlocked") def remote_currentlyBuilding(self, eta): self.statusSquare.set_text("building") class CompactRow(Pane): def __init__(self): Pane.__init__(self) self.widget = gtk.VBox(gtk.FALSE, 3) self.nameBox = gtk.HBox(gtk.TRUE, 2) self.lastBuildBox = gtk.HBox(gtk.TRUE, 2) self.statusBox = gtk.HBox(gtk.TRUE, 2) self.widget.add(self.nameBox) self.widget.add(self.lastBuildBox) self.widget.add(self.statusBox) self.widget.show_all() self.builders = [] def getWidget(self): return self.widget def addBuilder(self, builder): self.builders.append(builder) name = gtk.Label(builder.name) name.show() self.nameBox.add(name) last = gtk.Label('??') last.set_size_request(64,64) lastbox = gtk.EventBox() lastbox.add(last) lastbox.show_all() self.lastBuildBox.add(lastbox) status = gtk.Label('??') status.set_size_request(64,64) statusbox = gtk.EventBox() statusbox.add(status) statusbox.show_all() self.statusBox.add(statusbox) builder.haveSomeWidgets([name, last, lastbox, status, statusbox]) def removeBuilder(self, name, builder): self.nameBox.remove(builder.nameSquare) self.lastBuildBox.remove(builder.lastBuildBox) self.statusBox.remove(builder.statusBox) self.builders.remove(builder) class CompactBuilder(Builder): def setup(self): self.timer = None self.text = [] self.eta = None def start(self): self.nameSquare.set_text(self.name) self.statusSquare.set_text("???") self.subscribe() def haveSomeWidgets(self, widgets): (self.nameSquare, self.lastBuildSquare, self.lastBuildBox, self.statusSquare, self.statusBox) = widgets def remote_currentlyOffline(self): self.eta = None self.stopTimer() self.statusSquare.set_text("offline") self.statusBox.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse("red")) def remote_currentlyIdle(self): self.eta = None self.stopTimer() self.statusSquare.set_text("idle") def remote_currentlyWaiting(self, seconds): self.nextBuild = now() + seconds self.startTimer(self.updateWaiting) def remote_currentlyInterlocked(self): self.stopTimer() self.statusSquare.set_text("interlocked") def startTimer(self, func): # the func must clear self.timer and return gtk.FALSE when the event # has arrived self.stopTimer() self.timer = gtk.timeout_add(1000, func) func() def stopTimer(self): if self.timer: gtk.timeout_remove(self.timer) self.timer = None def updateWaiting(self): when = self.nextBuild if now() < when: next = time.strftime("%H:%M:%S", time.localtime(when)) secs = "[%d seconds]" % (when - now()) self.statusSquare.set_text("waiting\n%s\n%s" % (next, secs)) return gtk.TRUE # restart timer else: # done self.statusSquare.set_text("waiting\n[RSN]") self.timer = None return gtk.FALSE def remote_currentlyBuilding(self, eta): self.stopTimer() self.statusSquare.set_text("building") if eta: d = eta.callRemote("subscribe", self, 5) def remote_newLastBuildStatus(self, event): color = None if event: text = "\n".join(event.text) color = event.color else: text = "none" if not color: color = "gray" self.lastBuildSquare.set_text(text) self.lastBuildBox.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse(color)) def remote_newEvent(self, event): assert(event.__class__ == GtkUpdatingEvent) self.current = event event.builder = self self.text = event.text if not self.text: self.text = ["idle"] self.eta = None self.stopTimer() self.updateText() color = event.color if not color: color = "gray" self.statusBox.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse(color)) def updateCurrent(self): text = self.current.text if text: self.text = text self.updateText() color = self.current.color if color: self.statusBox.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse(color)) def updateText(self): etatext = [] if self.eta: etatext = [time.strftime("%H:%M:%S", time.localtime(self.eta))] if now() > self.eta: etatext += ["RSN"] else: seconds = self.eta - now() etatext += ["[%d secs]" % seconds] text = "\n".join(self.text + etatext) self.statusSquare.set_text(text) def updateTextTimer(self): self.updateText() return gtk.TRUE # restart timer def remote_progress(self, seconds): if seconds == None: self.eta = None else: self.eta = now() + seconds self.startTimer(self.updateTextTimer) self.updateText() def remote_finished(self, eta): self.eta = None self.stopTimer() self.updateText() eta.callRemote("unsubscribe", self) ''' class Box: def __init__(self, text="?"): self.text = text self.box = gtk.EventBox() self.label = gtk.Label(text) self.box.add(self.label) self.box.set_size_request(64,64) self.timer = None def getBox(self): return self.box def setText(self, text): self.text = text self.label.set_text(text) def setColor(self, color): if not color: return self.box.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse(color)) def setETA(self, eta): if eta: self.when = now() + eta self.startTimer() else: self.stopTimer() def startTimer(self): self.stopTimer() self.timer = gobject.timeout_add(1000, self.update) self.update() def stopTimer(self): if self.timer: gobject.source_remove(self.timer) self.timer = None self.label.set_text(self.text) def update(self): if now() < self.when: next = time.strftime("%H:%M:%S", time.localtime(self.when)) secs = "[%d secs]" % (self.when - now()) self.label.set_text("%s\n%s\n%s" % (self.text, next, secs)) return True # restart timer else: # done self.label.set_text("%s\n[soon]\n[overdue]" % (self.text,)) self.timer = None return False class ThreeRowBuilder: def __init__(self, name, ref): self.name = name self.last = Box() self.current = Box() self.step = Box("idle") self.step.setColor("white") self.ref = ref def getBoxes(self): return self.last.getBox(), self.current.getBox(), self.step.getBox() def getLastBuild(self): d = self.ref.callRemote("getLastFinishedBuild") d.addCallback(self.gotLastBuild) def gotLastBuild(self, build): if build: build.callRemote("getText").addCallback(self.gotLastText) build.callRemote("getResults").addCallback(self.gotLastResult) def gotLastText(self, text): print "Got text", text self.last.setText("\n".join(text)) def gotLastResult(self, result): colormap = {SUCCESS: 'green', FAILURE: 'red', WARNINGS: 'orange', EXCEPTION: 'purple', } self.last.setColor(colormap[result]) def getState(self): self.ref.callRemote("getState").addCallback(self.gotState) def gotState(self, res): state, ETA, builds = res # state is one of: offline, idle, waiting, interlocked, building # TODO: ETA is going away, you have to look inside the builds to get # that value currentmap = {"offline": "red", "idle": "white", "waiting": "yellow", "interlocked": "yellow", "building": "yellow",} text = state self.current.setColor(currentmap[state]) if ETA is not None: text += "\nETA=%s secs" % ETA self.current.setText(state) def buildStarted(self, build): print "[%s] buildStarted" % (self.name,) self.current.setColor("yellow") def buildFinished(self, build, results): print "[%s] buildFinished: %s" % (self.name, results) self.gotLastBuild(build) self.current.setColor("white") self.current.stopTimer() def buildETAUpdate(self, eta): print "[%s] buildETAUpdate: %s" % (self.name, eta) self.current.setETA(eta) def stepStarted(self, stepname, step): print "[%s] stepStarted: %s" % (self.name, stepname) self.step.setText(stepname) self.step.setColor("yellow") def stepFinished(self, stepname, step, results): print "[%s] stepFinished: %s %s" % (self.name, stepname, results) self.step.setText("idle") self.step.setColor("white") self.step.stopTimer() def stepETAUpdate(self, stepname, eta): print "[%s] stepETAUpdate: %s %s" % (self.name, stepname, eta) self.step.setETA(eta) class ThreeRowClient(pb.Referenceable): def __init__(self, window): self.window = window self.buildernames = [] self.builders = {} def connected(self, ref): print "connected" self.ref = ref self.pane = gtk.VBox(False, 2) self.table = gtk.Table(1+3, 1) self.pane.add(self.table) self.window.vb.add(self.pane) self.pane.show_all() ref.callRemote("subscribe", "logs", 5, self) def removeTable(self): for child in self.table.get_children(): self.table.remove(child) self.pane.remove(self.table) def makeTable(self): columns = len(self.builders) self.table = gtk.Table(2, columns) self.pane.add(self.table) for i in range(len(self.buildernames)): name = self.buildernames[i] b = self.builders[name] last,current,step = b.getBoxes() self.table.attach(gtk.Label(name), i, i+1, 0, 1) self.table.attach(last, i, i+1, 1, 2, xpadding=1, ypadding=1) self.table.attach(current, i, i+1, 2, 3, xpadding=1, ypadding=1) self.table.attach(step, i, i+1, 3, 4, xpadding=1, ypadding=1) self.table.show_all() def rebuildTable(self): self.removeTable() self.makeTable() def remote_builderAdded(self, buildername, builder): print "builderAdded", buildername assert buildername not in self.buildernames self.buildernames.append(buildername) b = ThreeRowBuilder(buildername, builder) self.builders[buildername] = b self.rebuildTable() b.getLastBuild() b.getState() def remote_builderRemoved(self, buildername): del self.builders[buildername] self.buildernames.remove(buildername) self.rebuildTable() def remote_builderChangedState(self, name, state, eta): self.builders[name].gotState((state, eta, None)) def remote_buildStarted(self, name, build): self.builders[name].buildStarted(build) def remote_buildFinished(self, name, build, results): self.builders[name].buildFinished(build, results) def remote_buildETAUpdate(self, name, build, eta): self.builders[name].buildETAUpdate(eta) def remote_stepStarted(self, name, build, stepname, step): self.builders[name].stepStarted(stepname, step) def remote_stepFinished(self, name, build, stepname, step, results): self.builders[name].stepFinished(stepname, step, results) def remote_stepETAUpdate(self, name, build, stepname, step, eta, expectations): # expectations is a list of (metricname, current_value, # expected_value) tuples, so that we could show individual progress # meters for each metric self.builders[name].stepETAUpdate(stepname, eta) def remote_logStarted(self, buildername, build, stepname, step, logname, log): pass def remote_logFinished(self, buildername, build, stepname, step, logname, log): pass class GtkClient(TextClient): ClientClass = ThreeRowClient def __init__(self, master, events="steps", username="statusClient", passwd="clientpw"): self.master = master self.username = username self.passwd = passwd self.listener = StatusClient(events) w = gtk.Window() self.w = w #w.set_size_request(64,64) w.connect('destroy', lambda win: gtk.main_quit()) self.vb = gtk.VBox(False, 2) self.status = gtk.Label("unconnected") self.vb.add(self.status) self.listener = self.ClientClass(self) w.add(self.vb) w.show_all() def connected(self, ref): self.status.set_text("connected") TextClient.connected(self, ref) """ def addBuilder(self, name, builder): Client.addBuilder(self, name, builder) self.pane.addBuilder(builder) def removeBuilder(self, name): self.pane.removeBuilder(name, self.builders[name]) Client.removeBuilder(self, name) def startConnecting(self, master): self.master = master Client.startConnecting(self, master) self.status.set_text("connecting to %s.." % master) def connected(self, remote): Client.connected(self, remote) self.status.set_text(self.master) remote.notifyOnDisconnect(self.disconnected) def disconnected(self, remote): self.status.set_text("disconnected, will retry") """ def main(): master = "localhost:8007" if len(sys.argv) > 1: master = sys.argv[1] c = GtkClient(master) c.run() if __name__ == '__main__': main() buildbot-0.8.8/buildbot/clients/sendchange.py000066400000000000000000000047001222546025000212500ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.spread import pb from twisted.cred import credentials from twisted.internet import reactor class Sender: def __init__(self, master, auth=('change','changepw'), encoding='utf8'): self.username, self.password = auth self.host, self.port = master.split(":") self.port = int(self.port) self.encoding = encoding def send(self, branch, revision, comments, files, who=None, category=None, when=None, properties={}, repository='', vc=None, project='', revlink='', codebase=None): change = {'project': project, 'repository': repository, 'who': who, 'files': files, 'comments': comments, 'branch': branch, 'revision': revision, 'category': category, 'when': when, 'properties': properties, 'revlink': revlink, 'src': vc} # codebase is only sent if set; this won't work with masters older than # 0.8.7 if codebase: change['codebase'] = codebase for key in change: if type(change[key]) == str: change[key] = change[key].decode(self.encoding, 'replace') change['files'] = list(change['files']) for i, file in enumerate(change.get('files', [])): if type(file) == str: change['files'][i] = file.decode(self.encoding, 'replace') f = pb.PBClientFactory() d = f.login(credentials.UsernamePassword(self.username, self.password)) reactor.connectTCP(self.host, self.port, f) def call_addChange(remote): d = remote.callRemote('addChange', change) d.addCallback(lambda res: remote.broker.transport.loseConnection()) return d d.addCallback(call_addChange) return d buildbot-0.8.8/buildbot/clients/text.py000066400000000000000000000065511222546025000201430ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import re from twisted.spread import pb from twisted.cred import credentials, error from twisted.internet import reactor from buildbot.clients import base class TextClient: def __init__(self, master, events="steps", username="statusClient", passwd="clientpw"): """ @type master: string @param master: a host:port string to masters L{buildbot.status.client.PBListener} @type username: string @param username: @type passwd: string @param passwd: @type events: string, one of builders, builds, steps, logs, full @param events: specify what level of detail should be reported. - 'builders': only announce new/removed Builders - 'builds': also announce builderChangedState, buildStarted, and buildFinished - 'steps': also announce buildETAUpdate, stepStarted, stepFinished - 'logs': also announce stepETAUpdate, logStarted, logFinished - 'full': also announce log contents """ self.master = master self.username = username self.passwd = passwd self.listener = base.StatusClient(events) def run(self): """Start the TextClient.""" self.startConnecting() reactor.run() def startConnecting(self): try: host, port = re.search(r'(.+):(\d+)', self.master).groups() port = int(port) except: print "unparseable master location '%s'" % self.master print " expecting something more like localhost:8007" raise cf = pb.PBClientFactory() creds = credentials.UsernamePassword(self.username, self.passwd) d = cf.login(creds) reactor.connectTCP(host, port, cf) d.addCallbacks(self.connected, self.not_connected) return d def connected(self, ref): ref.notifyOnDisconnect(self.disconnected) self.listener.connected(ref) def not_connected(self, why): if why.check(error.UnauthorizedLogin): print """ Unable to login.. are you sure we are connecting to a buildbot.status.client.PBListener port and not to the slaveport? """ reactor.stop() return why def disconnected(self, ref): print "lost connection" # we can get here in one of two ways: the buildmaster has # disconnected us (probably because it shut itself down), or because # we've been SIGINT'ed. In the latter case, our reactor is already # shut down, but we have no easy way of detecting that. So protect # our attempt to shut down the reactor. try: reactor.stop() except RuntimeError: pass buildbot-0.8.8/buildbot/clients/tryclient.py000066400000000000000000000740401222546025000211720ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import random import re import sys import time from twisted.cred import credentials from twisted.internet import defer from twisted.internet import protocol from twisted.internet import reactor from twisted.internet import task from twisted.internet import utils from twisted.python import log from twisted.python.procutils import which from twisted.spread import pb from buildbot.sourcestamp import SourceStamp from buildbot.status import builder from buildbot.util import json from buildbot.util import now from buildbot.util.eventual import fireEventually class SourceStampExtractor: def __init__(self, treetop, branch, repository): self.treetop = treetop self.repository = repository self.branch = branch exes = which(self.vcexe) if not exes: print "Could not find executable '%s'." % self.vcexe sys.exit(1) self.exe = exes[0] def dovc(self, cmd): """This accepts the arguments of a command, without the actual command itself.""" env = os.environ.copy() env['LC_ALL'] = "C" d = utils.getProcessOutputAndValue(self.exe, cmd, env=env, path=self.treetop) d.addCallback(self._didvc, cmd) return d def _didvc(self, res, cmd): (stdout, stderr, code) = res # 'bzr diff' sets rc=1 if there were any differences. # cvs does something similar, so don't bother requring rc=0. return stdout def get(self): """Return a Deferred that fires with a SourceStamp instance.""" d = self.getBaseRevision() d.addCallback(self.getPatch) d.addCallback(self.done) return d def readPatch(self, diff, patchlevel): if not diff: diff = None self.patch = (patchlevel, diff) def done(self, res): if not self.repository: self.repository = self.treetop # TODO: figure out the branch and project too ss = SourceStamp(self.branch, self.baserev, self.patch, repository=self.repository) return ss class CVSExtractor(SourceStampExtractor): patchlevel = 0 vcexe = "cvs" def getBaseRevision(self): # this depends upon our local clock and the repository's clock being # reasonably synchronized with each other. We express everything in # UTC because the '%z' format specifier for strftime doesn't always # work. self.baserev = time.strftime("%Y-%m-%d %H:%M:%S +0000", time.gmtime(now())) return defer.succeed(None) def getPatch(self, res): # the -q tells CVS to not announce each directory as it works if self.branch is not None: # 'cvs diff' won't take both -r and -D at the same time (it # ignores the -r). As best I can tell, there is no way to make # cvs give you a diff relative to a timestamp on the non-trunk # branch. A bare 'cvs diff' will tell you about the changes # relative to your checked-out versions, but I know of no way to # find out what those checked-out versions are. print "Sorry, CVS 'try' builds don't work with branches" sys.exit(1) args = ['-q', 'diff', '-u', '-D', self.baserev] d = self.dovc(args) d.addCallback(self.readPatch, self.patchlevel) return d class SVNExtractor(SourceStampExtractor): patchlevel = 0 vcexe = "svn" def getBaseRevision(self): d = self.dovc(["status", "-u"]) d.addCallback(self.parseStatus) return d def parseStatus(self, res): # svn shows the base revision for each file that has been modified or # which needs an update. You can update each file to a different # version, so each file is displayed with its individual base # revision. It also shows the repository-wide latest revision number # on the last line ("Status against revision: \d+"). # for our purposes, we use the latest revision number as the "base" # revision, and get a diff against that. This means we will get # reverse-diffs for local files that need updating, but the resulting # tree will still be correct. The only weirdness is that the baserev # that we emit may be different than the version of the tree that we # first checked out. # to do this differently would probably involve scanning the revision # numbers to find the max (or perhaps the min) revision, and then # using that as a base. for line in res.split("\n"): m = re.search(r'^Status against revision:\s+(\d+)', line) if m: self.baserev = int(m.group(1)) return print "Could not find 'Status against revision' in SVN output: %s" % res sys.exit(1) def getPatch(self, res): d = self.dovc(["diff", "-r%d" % self.baserev]) d.addCallback(self.readPatch, self.patchlevel) return d class BzrExtractor(SourceStampExtractor): patchlevel = 0 vcexe = "bzr" def getBaseRevision(self): d = self.dovc(["revision-info", "-rsubmit:"]) d.addCallback(self.get_revision_number) return d def get_revision_number(self, out): revno, revid = out.split() self.baserev = 'revid:' + revid return def getPatch(self, res): d = self.dovc(["diff", "-r%s.." % self.baserev]) d.addCallback(self.readPatch, self.patchlevel) return d class MercurialExtractor(SourceStampExtractor): patchlevel = 1 vcexe = "hg" def getBaseRevision(self): upstream = "" if self.repository: upstream = "r'%s'" % self.repository d = self.dovc(["log", "--template", "{node}\\n", "-r", "limit(parents(outgoing(%s) and branch(parents())) or parents(), 1)" % upstream]) d.addCallback(self.parseStatus) return d def parseStatus(self, output): m = re.search(r'^(\w+)', output) self.baserev = m.group(0) def getPatch(self, res): d = self.dovc(["diff", "-r", self.baserev]) d.addCallback(self.readPatch, self.patchlevel) return d class PerforceExtractor(SourceStampExtractor): patchlevel = 0 vcexe = "p4" def getBaseRevision(self): d = self.dovc(["changes", "-m1", "..."]) d.addCallback(self.parseStatus) return d def parseStatus(self, res): # # extract the base change number # m = re.search(r'Change (\d+)', res) if m: self.baserev = m.group(1) return print "Could not find change number in output: %s" % res sys.exit(1) def readPatch(self, res, patchlevel): # # extract the actual patch from "res" # if not self.branch: print "you must specify a branch" sys.exit(1) mpatch = "" found = False for line in res.split("\n"): m = re.search('==== //depot/' + self.branch + r'/([\w\/\.\d\-\_]+)#(\d+) -', line) if m: mpatch += "--- %s#%s\n" % (m.group(1), m.group(2)) mpatch += "+++ %s\n" % (m.group(1)) found = True else: mpatch += line mpatch += "\n" if not found: print "could not parse patch file" sys.exit(1) self.patch = (patchlevel, mpatch) def getPatch(self, res): d = self.dovc(["diff"]) d.addCallback(self.readPatch, self.patchlevel) return d class DarcsExtractor(SourceStampExtractor): patchlevel = 1 vcexe = "darcs" def getBaseRevision(self): d = self.dovc(["changes", "--context"]) d.addCallback(self.parseStatus) return d def parseStatus(self, res): self.baserev = res # the whole context file def getPatch(self, res): d = self.dovc(["diff", "-u"]) d.addCallback(self.readPatch, self.patchlevel) return d class GitExtractor(SourceStampExtractor): patchlevel = 1 vcexe = "git" config = None def getBaseRevision(self): # If a branch is specified, parse out the rev it points to # and extract the local name (assuming it has a slash). # This may break if someone specifies the name of a local # branch that has a slash in it and has no corresponding # remote branch (or something similarly contrived). if self.branch: d = self.dovc(["rev-parse", self.branch]) if '/' in self.branch: self.branch = self.branch.split('/', 1)[1] d.addCallback(self.override_baserev) return d d = self.dovc(["branch", "--no-color", "-v", "--no-abbrev"]) d.addCallback(self.parseStatus) return d def readConfig(self): if self.config: return defer.succeed(self.config) d = self.dovc(["config", "-l"]) d.addCallback(self.parseConfig) return d def parseConfig(self, res): self.config = {} for l in res.split("\n"): if l.strip(): parts = l.strip().split("=", 2) self.config[parts[0]] = parts[1] return self.config def parseTrackingBranch(self, res): # If we're tracking a remote, consider that the base. remote = self.config.get("branch." + self.branch + ".remote") ref = self.config.get("branch." + self.branch + ".merge") if remote and ref: remote_branch = ref.split("/", 3)[-1] d = self.dovc(["rev-parse", remote + "/" + remote_branch]) d.addCallback(self.override_baserev) return d def override_baserev(self, res): self.baserev = res.strip() def parseStatus(self, res): # The current branch is marked by '*' at the start of the # line, followed by the branch name and the SHA1. # # Branch names may contain pretty much anything but whitespace. m = re.search(r'^\* (\S+)\s+([0-9a-f]{40})', res, re.MULTILINE) if m: self.baserev = m.group(2) self.branch = m.group(1) d = self.readConfig() d.addCallback(self.parseTrackingBranch) return d print "Could not find current GIT branch: %s" % res sys.exit(1) def getPatch(self, res): d = self.dovc(["diff", self.baserev]) d.addCallback(self.readPatch, self.patchlevel) return d class MonotoneExtractor(SourceStampExtractor): patchlevel = 0 vcexe = "mtn" def getBaseRevision(self): d = self.dovc(["automate", "get_base_revision_id"]) d.addCallback(self.parseStatus) return d def parseStatus(self, output): hash = output.strip() if len(hash) != 40: self.baserev = None self.baserev = hash def getPatch(self, res): d = self.dovc(["diff"]) d.addCallback(self.readPatch, self.patchlevel) return d def getSourceStamp(vctype, treetop, branch=None, repository=None): if vctype == "cvs": cls = CVSExtractor elif vctype == "svn": cls = SVNExtractor elif vctype == "bzr": cls = BzrExtractor elif vctype == "hg": cls = MercurialExtractor elif vctype == "p4": cls = PerforceExtractor elif vctype == "darcs": cls = DarcsExtractor elif vctype == "git": cls = GitExtractor elif vctype == "mtn": cls = MonotoneExtractor else: print "unknown vctype '%s'" % vctype sys.exit(1) return cls(treetop, branch, repository).get() def ns(s): return "%d:%s," % (len(s), s) def createJobfile(jobid, branch, baserev, patch_level, patch_body, repository, project, who, comment, builderNames, properties): #Determine job file version from provided arguments if properties: version = 5 elif comment: version = 4 elif who: version = 3 else: version = 2 job = "" job += ns(str(version)) if version < 5: job += ns(jobid) job += ns(branch) job += ns(str(baserev)) job += ns("%d" % patch_level) job += ns(patch_body) job += ns(repository) job += ns(project) if (version >= 3): job += ns(who) if (version >= 4): job += ns(comment) for bn in builderNames: job += ns(bn) else: job += ns( json.dumps({ 'jobid': jobid, 'branch': branch, 'baserev': str(baserev), 'patch_level': patch_level, 'patch_body': patch_body, 'repository': repository, 'project': project, 'who': who, 'comment': comment, 'builderNames': builderNames, 'properties': properties, })) return job def getTopdir(topfile, start=None): """walk upwards from the current directory until we find this topfile""" if not start: start = os.getcwd() here = start toomany = 20 while toomany > 0: if os.path.exists(os.path.join(here, topfile)): return here next = os.path.dirname(here) if next == here: break # we've hit the root here = next toomany -= 1 print ("Unable to find topfile '%s' anywhere from %s upwards" % (topfile, start)) sys.exit(1) class RemoteTryPP(protocol.ProcessProtocol): def __init__(self, job): self.job = job self.d = defer.Deferred() def connectionMade(self): self.transport.write(self.job) self.transport.closeStdin() def outReceived(self, data): sys.stdout.write(data) def errReceived(self, data): sys.stderr.write(data) def processEnded(self, status_object): sig = status_object.value.signal rc = status_object.value.exitCode if sig != None or rc != 0: self.d.errback(RuntimeError("remote 'buildbot tryserver' failed" ": sig=%s, rc=%s" % (sig, rc))) return self.d.callback((sig, rc)) class BuildSetStatusGrabber: retryCount = 5 # how many times to we try to grab the BuildSetStatus? retryDelay = 3 # seconds to wait between attempts def __init__(self, status, bsid): self.status = status self.bsid = bsid def grab(self): # return a Deferred that either fires with the BuildSetStatus # reference or errbacks because we were unable to grab it self.d = defer.Deferred() # wait a second before querying to give the master's maildir watcher # a chance to see the job reactor.callLater(1, self.go) return self.d def go(self, dummy=None): if self.retryCount == 0: print "couldn't find matching buildset" sys.exit(1) self.retryCount -= 1 d = self.status.callRemote("getBuildSets") d.addCallback(self._gotSets) def _gotSets(self, buildsets): for bs, bsid in buildsets: if bsid == self.bsid: # got it self.d.callback(bs) return d = defer.Deferred() d.addCallback(self.go) reactor.callLater(self.retryDelay, d.callback, None) class Try(pb.Referenceable): buildsetStatus = None quiet = False printloop = False def __init__(self, config): self.config = config self.connect = self.getopt('connect') if self.connect not in ['ssh', 'pb']: print "you must specify a connect style: ssh or pb" sys.exit(1) self.builderNames = self.getopt('builders') self.project = self.getopt('project', '') self.who = self.getopt('who') self.comment = self.getopt('comment') def getopt(self, config_name, default=None): value = self.config.get(config_name) if value is None or value == []: value = default return value def createJob(self): # returns a Deferred which fires when the job parameters have been # created # generate a random (unique) string. It would make sense to add a # hostname and process ID here, but a) I suspect that would cause # windows portability problems, and b) really this is good enough self.bsid = "%d-%s" % (time.time(), random.randint(0, 1000000)) # common options branch = self.getopt("branch") difffile = self.config.get("diff") if difffile: baserev = self.config.get("baserev") if difffile == "-": diff = sys.stdin.read() else: with open(difffile, "r") as f: diff = f.read() if not diff: diff = None patch = (self.config['patchlevel'], diff) ss = SourceStamp( branch, baserev, patch, repository=self.getopt("repository")) d = defer.succeed(ss) else: vc = self.getopt("vc") if vc in ("cvs", "svn"): # we need to find the tree-top topdir = self.getopt("topdir") if topdir: treedir = os.path.expanduser(topdir) else: topfile = self.getopt("topfile") if topfile: treedir = getTopdir(topfile) else: print "Must specify topdir or topfile." sys.exit(1) else: treedir = os.getcwd() d = getSourceStamp(vc, treedir, branch, self.getopt("repository")) d.addCallback(self._createJob_1) return d def _createJob_1(self, ss): self.sourcestamp = ss if self.connect == "ssh": patchlevel, diff = ss.patch revspec = ss.revision if revspec is None: revspec = "" self.jobfile = createJobfile( self.bsid, ss.branch or "", revspec, patchlevel, diff, ss.repository, self.project, self.who, self.comment, self.builderNames, self.config.get('properties', {})) def fakeDeliverJob(self): # Display the job to be delivered, but don't perform delivery. ss = self.sourcestamp print ("Job:\n\tRepository: %s\n\tProject: %s\n\tBranch: %s\n\t" "Revision: %s\n\tBuilders: %s\n%s" % (ss.repository, self.project, ss.branch, ss.revision, self.builderNames, ss.patch[1])) d = defer.Deferred() d.callback(True) return d def deliverJob(self): # returns a Deferred that fires when the job has been delivered if self.connect == "ssh": tryhost = self.getopt("host") tryuser = self.getopt("username") trydir = self.getopt("jobdir") buildbotbin = self.getopt("buildbotbin") argv = ["ssh", "-l", tryuser, tryhost, buildbotbin, "tryserver", "--jobdir", trydir] pp = RemoteTryPP(self.jobfile) reactor.spawnProcess(pp, argv[0], argv, os.environ) d = pp.d return d if self.connect == "pb": user = self.getopt("username") passwd = self.getopt("passwd") master = self.getopt("master") tryhost, tryport = master.split(":") tryport = int(tryport) f = pb.PBClientFactory() d = f.login(credentials.UsernamePassword(user, passwd)) reactor.connectTCP(tryhost, tryport, f) d.addCallback(self._deliverJob_pb) return d raise RuntimeError("unknown connecttype '%s', should be 'ssh' or 'pb'" % self.connect) def _deliverJob_pb(self, remote): ss = self.sourcestamp print "Delivering job; comment=", self.comment d = remote.callRemote("try", ss.branch, ss.revision, ss.patch, ss.repository, self.project, self.builderNames, self.who, self.comment, self.config.get('properties', {})) d.addCallback(self._deliverJob_pb2) return d def _deliverJob_pb2(self, status): self.buildsetStatus = status return status def getStatus(self): # returns a Deferred that fires when the builds have finished, and # may emit status messages while we wait wait = bool(self.getopt("wait")) if not wait: # TODO: emit the URL where they can follow the builds. This # requires contacting the Status server over PB and doing # getURLForThing() on the BuildSetStatus. To get URLs for # individual builds would require we wait for the builds to # start. print "not waiting for builds to finish" return d = self.running = defer.Deferred() if self.buildsetStatus: self._getStatus_1() return self.running # contact the status port # we're probably using the ssh style master = self.getopt("master") host, port = master.split(":") port = int(port) self.announce("contacting the status port at %s:%d" % (host, port)) f = pb.PBClientFactory() creds = credentials.UsernamePassword("statusClient", "clientpw") d = f.login(creds) reactor.connectTCP(host, port, f) d.addCallback(self._getStatus_ssh_1) return self.running def _getStatus_ssh_1(self, remote): # find a remotereference to the corresponding BuildSetStatus object self.announce("waiting for job to be accepted") g = BuildSetStatusGrabber(remote, self.bsid) d = g.grab() d.addCallback(self._getStatus_1) return d def _getStatus_1(self, res=None): if res: self.buildsetStatus = res # gather the set of BuildRequests d = self.buildsetStatus.callRemote("getBuildRequests") d.addCallback(self._getStatus_2) def _getStatus_2(self, brs): self.builderNames = [] self.buildRequests = {} # self.builds holds the current BuildStatus object for each one self.builds = {} # self.outstanding holds the list of builderNames which haven't # finished yet self.outstanding = [] # self.results holds the list of build results. It holds a tuple of # (result, text) self.results = {} # self.currentStep holds the name of the Step that each build is # currently running self.currentStep = {} # self.ETA holds the expected finishing time (absolute time since # epoch) self.ETA = {} for n, br in brs: self.builderNames.append(n) self.buildRequests[n] = br self.builds[n] = None self.outstanding.append(n) self.results[n] = [None, None] self.currentStep[n] = None self.ETA[n] = None # get new Builds for this buildrequest. We follow each one until # it finishes or is interrupted. br.callRemote("subscribe", self) # now that those queries are in transit, we can start the # display-status-every-30-seconds loop if not self.getopt("quiet"): self.printloop = task.LoopingCall(self.printStatus) self.printloop.start(3, now=False) # these methods are invoked by the status objects we've subscribed to def remote_newbuild(self, bs, builderName): if self.builds[builderName]: self.builds[builderName].callRemote("unsubscribe", self) self.builds[builderName] = bs bs.callRemote("subscribe", self, 20) d = bs.callRemote("waitUntilFinished") d.addCallback(self._build_finished, builderName) def remote_stepStarted(self, buildername, build, stepname, step): self.currentStep[buildername] = stepname def remote_stepFinished(self, buildername, build, stepname, step, results): pass def remote_buildETAUpdate(self, buildername, build, eta): self.ETA[buildername] = now() + eta def _build_finished(self, bs, builderName): # we need to collect status from the newly-finished build. We don't # remove the build from self.outstanding until we've collected # everything we want. self.builds[builderName] = None self.ETA[builderName] = None self.currentStep[builderName] = "finished" d = bs.callRemote("getResults") d.addCallback(self._build_finished_2, bs, builderName) return d def _build_finished_2(self, results, bs, builderName): self.results[builderName][0] = results d = bs.callRemote("getText") d.addCallback(self._build_finished_3, builderName) return d def _build_finished_3(self, text, builderName): self.results[builderName][1] = text self.outstanding.remove(builderName) if not self.outstanding: # all done return self.statusDone() def printStatus(self): try: names = self.buildRequests.keys() names.sort() for n in names: if n not in self.outstanding: # the build is finished, and we have results code, text = self.results[n] t = builder.Results[code] if text: t += " (%s)" % " ".join(text) elif self.builds[n]: t = self.currentStep[n] or "building" if self.ETA[n]: t += " [ETA %ds]" % (self.ETA[n] - now()) else: t = "no build" self.announce("%s: %s" % (n, t)) self.announce("") except Exception: log.err(None, "printing status") def statusDone(self): if self.printloop: self.printloop.stop() print "All Builds Complete" # TODO: include a URL for all failing builds names = self.buildRequests.keys() names.sort() happy = True for n in names: code, text = self.results[n] t = "%s: %s" % (n, builder.Results[code]) if text: t += " (%s)" % " ".join(text) print t if code != builder.SUCCESS: happy = False if happy: self.exitcode = 0 else: self.exitcode = 1 self.running.callback(self.exitcode) def getAvailableBuilderNames(self): # This logs into the master using the PB protocol to # get the names of the configured builders that can # be used for the --builder argument if self.connect == "pb": user = self.getopt("username") passwd = self.getopt("passwd") master = self.getopt("master") tryhost, tryport = master.split(":") tryport = int(tryport) f = pb.PBClientFactory() d = f.login(credentials.UsernamePassword(user, passwd)) reactor.connectTCP(tryhost, tryport, f) d.addCallback(self._getBuilderNames, self._getBuilderNames2) return d if self.connect == "ssh": print "Cannot get availble builders over ssh." sys.exit(1) raise RuntimeError( "unknown connecttype '%s', should be 'pb'" % self.connect) def _getBuilderNames(self, remote, output): d = remote.callRemote("getAvailableBuilderNames") d.addCallback(self._getBuilderNames2) return d def _getBuilderNames2(self, buildernames): print "The following builders are available for the try scheduler: " for buildername in buildernames: print buildername def announce(self, message): if not self.quiet: print message def run(self): # we can't do spawnProcess until we're inside reactor.run(), so get # funky print "using '%s' connect method" % self.connect self.exitcode = 0 d = fireEventually(None) if bool(self.config.get("get-builder-names")): d.addCallback(lambda res: self.getAvailableBuilderNames()) else: d.addCallback(lambda res: self.createJob()) d.addCallback(lambda res: self.announce("job created")) deliver = self.deliverJob if bool(self.config.get("dryrun")): deliver = self.fakeDeliverJob d.addCallback(lambda res: deliver()) d.addCallback(lambda res: self.announce("job has been delivered")) d.addCallback(lambda res: self.getStatus()) d.addErrback(self.trapSystemExit) d.addErrback(log.err) d.addCallback(self.cleanup) d.addCallback(lambda res: reactor.stop()) reactor.run() sys.exit(self.exitcode) def trapSystemExit(self, why): why.trap(SystemExit) self.exitcode = why.value.code def cleanup(self, res=None): if self.buildsetStatus: self.buildsetStatus.broker.transport.loseConnection() buildbot-0.8.8/buildbot/clients/usersclient.py000066400000000000000000000036721222546025000215200ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # this class is known to contain cruft and will be looked at later, so # no current implementation utilizes it aside from scripts.runner. from twisted.spread import pb from twisted.cred import credentials from twisted.internet import reactor class UsersClient(object): """ Client set up in buildbot.scripts.runner to send `buildbot user` args over a PB connection to perspective_commandline that will execute the args on the database. """ def __init__(self, master, username, password, port): self.host = master self.username = username self.password = password self.port = int(port) def send(self, op, bb_username, bb_password, ids, info): f = pb.PBClientFactory() d = f.login(credentials.UsernamePassword(self.username, self.password)) reactor.connectTCP(self.host, self.port, f) def call_commandline(remote): d = remote.callRemote("commandline", op, bb_username, bb_password, ids, info) def returnAndLose(res): remote.broker.transport.loseConnection() return res d.addCallback(returnAndLose) return d d.addCallback(call_commandline) return d buildbot-0.8.8/buildbot/config.py000066400000000000000000000622521222546025000167630ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import re import sys import warnings from buildbot.util import safeTranslate from buildbot import interfaces from buildbot import locks from buildbot.revlinks import default_revlink_matcher from twisted.python import log, failure from twisted.internet import defer from twisted.application import service class ConfigErrors(Exception): def __init__(self, errors=[]): self.errors = errors[:] def __str__(self): return "\n".join(self.errors) def addError(self, msg): self.errors.append(msg) def __nonzero__(self): return len(self.errors) _errors = None def error(error): if _errors is not None: _errors.addError(error) else: raise ConfigErrors([error]) class MasterConfig(object): def __init__(self): # local import to avoid circular imports from buildbot.process import properties # default values for all attributes # global self.title = 'Buildbot' self.titleURL = 'http://buildbot.net' self.buildbotURL = 'http://localhost:8080/' self.changeHorizon = None self.eventHorizon = 50 self.logHorizon = None self.buildHorizon = None self.logCompressionLimit = 4*1024 self.logCompressionMethod = 'bz2' self.logMaxTailSize = None self.logMaxSize = None self.properties = properties.Properties() self.mergeRequests = None self.codebaseGenerator = None self.prioritizeBuilders = None self.slavePortnum = None self.multiMaster = False self.debugPassword = None self.manhole = None self.validation = dict( branch=re.compile(r'^[\w.+/~-]*$'), revision=re.compile(r'^[ \w\.\-\/]*$'), property_name=re.compile(r'^[\w\.\-\/\~:]*$'), property_value=re.compile(r'^[\w\.\-\/\~:]*$'), ) self.db = dict( db_url='sqlite:///state.sqlite', db_poll_interval=None, ) self.metrics = None self.caches = dict( Builds=15, Changes=10, ) self.schedulers = {} self.builders = [] self.slaves = [] self.change_sources = [] self.status = [] self.user_managers = [] self.revlink = default_revlink_matcher _known_config_keys = set([ "buildbotURL", "buildCacheSize", "builders", "buildHorizon", "caches", "change_source", "codebaseGenerator", "changeCacheSize", "changeHorizon", 'db', "db_poll_interval", "db_url", "debugPassword", "eventHorizon", "logCompressionLimit", "logCompressionMethod", "logHorizon", "logMaxSize", "logMaxTailSize", "manhole", "mergeRequests", "metrics", "multiMaster", "prioritizeBuilders", "projectName", "projectURL", "properties", "revlink", "schedulers", "slavePortnum", "slaves", "status", "title", "titleURL", "user_managers", "validation" ]) @classmethod def loadConfig(cls, basedir, filename): if not os.path.isdir(basedir): raise ConfigErrors([ "basedir '%s' does not exist" % (basedir,), ]) filename = os.path.join(basedir, filename) if not os.path.exists(filename): raise ConfigErrors([ "configuration file '%s' does not exist" % (filename,), ]) try: f = open(filename, "r") except IOError, e: raise ConfigErrors([ "unable to open configuration file %r: %s" % (filename, e), ]) log.msg("Loading configuration from %r" % (filename,)) # execute the config file localDict = { 'basedir': os.path.expanduser(basedir), '__file__': os.path.abspath(filename), } # from here on out we can batch errors together for the user's # convenience global _errors _errors = errors = ConfigErrors() old_sys_path = sys.path[:] sys.path.append(basedir) try: try: exec f in localDict except ConfigErrors, e: for err in e.errors: error(err) raise errors except: log.err(failure.Failure(), 'error while parsing config file:') error("error while parsing config file: %s (traceback in logfile)" % (sys.exc_info()[1],), ) raise errors finally: f.close() sys.path[:] = old_sys_path _errors = None if 'BuildmasterConfig' not in localDict: error("Configuration file %r does not define 'BuildmasterConfig'" % (filename,), ) config_dict = localDict['BuildmasterConfig'] # check for unknown keys unknown_keys = set(config_dict.keys()) - cls._known_config_keys if unknown_keys: if len(unknown_keys) == 1: error('Unknown BuildmasterConfig key %s' % (unknown_keys.pop())) else: error('Unknown BuildmasterConfig keys %s' % (', '.join(sorted(unknown_keys)))) # instantiate a new config object, which will apply defaults # automatically config = cls() _errors = errors # and defer the rest to sub-functions, for code clarity try: config.load_global(filename, config_dict) config.load_validation(filename, config_dict) config.load_db(filename, config_dict) config.load_metrics(filename, config_dict) config.load_caches(filename, config_dict) config.load_schedulers(filename, config_dict) config.load_builders(filename, config_dict) config.load_slaves(filename, config_dict) config.load_change_sources(filename, config_dict) config.load_status(filename, config_dict) config.load_user_managers(filename, config_dict) # run some sanity checks config.check_single_master() config.check_schedulers() config.check_locks() config.check_builders() config.check_status() config.check_horizons() config.check_slavePortnum() finally: _errors = None if errors: raise errors return config def load_global(self, filename, config_dict): def copy_param(name, alt_key=None, check_type=None, check_type_name=None): if name in config_dict: v = config_dict[name] elif alt_key and alt_key in config_dict: v = config_dict[alt_key] else: return if v is not None and check_type and not isinstance(v, check_type): error("c['%s'] must be %s" % (name, check_type_name)) else: setattr(self, name, v) def copy_int_param(name, alt_key=None): copy_param(name, alt_key=alt_key, check_type=int, check_type_name='an int') def copy_str_param(name, alt_key=None): copy_param(name, alt_key=alt_key, check_type=basestring, check_type_name='a string') copy_str_param('title', alt_key='projectName') copy_str_param('titleURL', alt_key='projectURL') copy_str_param('buildbotURL') copy_int_param('changeHorizon') copy_int_param('eventHorizon') copy_int_param('logHorizon') copy_int_param('buildHorizon') copy_int_param('logCompressionLimit') if 'logCompressionMethod' in config_dict: logCompressionMethod = config_dict.get('logCompressionMethod') if logCompressionMethod not in ('bz2', 'gz'): error("c['logCompressionMethod'] must be 'bz2' or 'gz'") self.logCompressionMethod = logCompressionMethod copy_int_param('logMaxSize') copy_int_param('logMaxTailSize') properties = config_dict.get('properties', {}) if not isinstance(properties, dict): error("c['properties'] must be a dictionary") else: self.properties.update(properties, filename) mergeRequests = config_dict.get('mergeRequests') if (mergeRequests not in (None, True, False) and not callable(mergeRequests)): error("mergeRequests must be a callable, True, or False") else: self.mergeRequests = mergeRequests codebaseGenerator = config_dict.get('codebaseGenerator') if (codebaseGenerator is not None and not callable(codebaseGenerator)): error("codebaseGenerator must be a callable accepting a dict and returning a str") else: self.codebaseGenerator = codebaseGenerator prioritizeBuilders = config_dict.get('prioritizeBuilders') if prioritizeBuilders is not None and not callable(prioritizeBuilders): error("prioritizeBuilders must be a callable") else: self.prioritizeBuilders = prioritizeBuilders if 'slavePortnum' in config_dict: slavePortnum = config_dict.get('slavePortnum') if isinstance(slavePortnum, int): slavePortnum = "tcp:%d" % slavePortnum self.slavePortnum = slavePortnum if 'multiMaster' in config_dict: self.multiMaster = config_dict["multiMaster"] copy_str_param('debugPassword') if 'manhole' in config_dict: # we don't check that this is a manhole instance, since that # requires importing buildbot.manhole for every user, and currently # that will fail if pycrypto isn't installed self.manhole = config_dict['manhole'] if 'revlink' in config_dict: revlink = config_dict['revlink'] if not callable(revlink): error("revlink must be a callable") else: self.revlink = revlink def load_validation(self, filename, config_dict): validation = config_dict.get("validation", {}) if not isinstance(validation, dict): error("c['validation'] must be a dictionary") else: unknown_keys = ( set(validation.keys()) - set(self.validation.keys())) if unknown_keys: error("unrecognized validation key(s): %s" % (", ".join(unknown_keys))) else: self.validation.update(validation) def load_db(self, filename, config_dict): if 'db' in config_dict: db = config_dict['db'] if set(db.keys()) > set(['db_url', 'db_poll_interval']): error("unrecognized keys in c['db']") self.db.update(db) if 'db_url' in config_dict: self.db['db_url'] = config_dict['db_url'] if 'db_poll_interval' in config_dict: self.db['db_poll_interval'] = config_dict["db_poll_interval"] # we don't attempt to parse db URLs here - the engine strategy will do so # check the db_poll_interval db_poll_interval = self.db['db_poll_interval'] if db_poll_interval is not None and \ not isinstance(db_poll_interval, int): error("c['db_poll_interval'] must be an int") else: self.db['db_poll_interval'] = db_poll_interval def load_metrics(self, filename, config_dict): # we don't try to validate metrics keys if 'metrics' in config_dict: metrics = config_dict["metrics"] if not isinstance(metrics, dict): error("c['metrics'] must be a dictionary") else: self.metrics = metrics def load_caches(self, filename, config_dict): explicit = False if 'caches' in config_dict: explicit = True caches = config_dict['caches'] if not isinstance(caches, dict): error("c['caches'] must be a dictionary") else: valPairs = caches.items() for (x, y) in valPairs: if not isinstance(y, int): error("value for cache size '%s' must be an integer" % x) self.caches.update(caches) if 'buildCacheSize' in config_dict: if explicit: msg = "cannot specify c['caches'] and c['buildCacheSize']" error(msg) self.caches['Builds'] = config_dict['buildCacheSize'] if 'changeCacheSize' in config_dict: if explicit: msg = "cannot specify c['caches'] and c['changeCacheSize']" error(msg) self.caches['Changes'] = config_dict['changeCacheSize'] def load_schedulers(self, filename, config_dict): if 'schedulers' not in config_dict: return schedulers = config_dict['schedulers'] ok = True if not isinstance(schedulers, (list, tuple)): ok = False else: for s in schedulers: if not interfaces.IScheduler.providedBy(s): ok = False if not ok: msg="c['schedulers'] must be a list of Scheduler instances" error(msg) # convert from list to dict, first looking for duplicates seen_names = set() for s in schedulers: if s.name in seen_names: error("scheduler name '%s' used multiple times" % s.name) seen_names.add(s.name) self.schedulers = dict((s.name, s) for s in schedulers) def load_builders(self, filename, config_dict): if 'builders' not in config_dict: return builders = config_dict['builders'] if not isinstance(builders, (list, tuple)): error("c['builders'] must be a list") return # convert all builder configs to BuilderConfig instances def mapper(b): if isinstance(b, BuilderConfig): return b elif isinstance(b, dict): return BuilderConfig(**b) else: error("%r is not a builder config (in c['builders']" % (b,)) builders = [ mapper(b) for b in builders ] for builder in builders: if builder and os.path.isabs(builder.builddir): warnings.warn("Absolute path '%s' for builder may cause " "mayhem. Perhaps you meant to specify slavebuilddir " "instead.") self.builders = builders def load_slaves(self, filename, config_dict): if 'slaves' not in config_dict: return slaves = config_dict['slaves'] if not isinstance(slaves, (list, tuple)): error("c['slaves'] must be a list") return for sl in slaves: if not interfaces.IBuildSlave.providedBy(sl): msg = "c['slaves'] must be a list of BuildSlave instances" error(msg) return if sl.slavename in ("debug", "change", "status"): msg = "slave name '%s' is reserved" % sl.slavename error(msg) self.slaves = config_dict['slaves'] def load_change_sources(self, filename, config_dict): change_source = config_dict.get('change_source', []) if isinstance(change_source, (list, tuple)): change_sources = change_source else: change_sources = [change_source] for s in change_sources: if not interfaces.IChangeSource.providedBy(s): msg = "c['change_source'] must be a list of change sources" error(msg) return self.change_sources = change_sources def load_status(self, filename, config_dict): if 'status' not in config_dict: return status = config_dict.get('status', []) msg = "c['status'] must be a list of status receivers" if not isinstance(status, (list, tuple)): error(msg) return for s in status: if not interfaces.IStatusReceiver.providedBy(s): error(msg) return self.status = status def load_user_managers(self, filename, config_dict): if 'user_managers' not in config_dict: return user_managers = config_dict['user_managers'] msg = "c['user_managers'] must be a list of user managers" if not isinstance(user_managers, (list, tuple)): error(msg) return self.user_managers = user_managers def check_single_master(self): # check additional problems that are only valid in a single-master # installation if self.multiMaster: return if not self.slaves: error("no slaves are configured") if not self.builders: error("no builders are configured") # check that all builders are implemented on this master unscheduled_buildernames = set([ b.name for b in self.builders ]) for s in self.schedulers.itervalues(): for n in s.listBuilderNames(): if n in unscheduled_buildernames: unscheduled_buildernames.remove(n) if unscheduled_buildernames: error("builder(s) %s have no schedulers to drive them" % (', '.join(unscheduled_buildernames),)) def check_schedulers(self): # don't perform this check in multiMaster mode if self.multiMaster: return all_buildernames = set([ b.name for b in self.builders ]) for s in self.schedulers.itervalues(): for n in s.listBuilderNames(): if n not in all_buildernames: error("Unknown builder '%s' in scheduler '%s'" % (n, s.name)) def check_locks(self): # assert that all locks used by the Builds and their Steps are # uniquely named. lock_dict = {} def check_lock(l): if isinstance(l, locks.LockAccess): l = l.lockid if lock_dict.has_key(l.name): if lock_dict[l.name] is not l: msg = "Two locks share the same name, '%s'" % l.name error(msg) else: lock_dict[l.name] = l for b in self.builders: if b.locks: for l in b.locks: check_lock(l) def check_builders(self): # look both for duplicate builder names, and for builders pointing # to unknown slaves slavenames = set([ s.slavename for s in self.slaves ]) seen_names = set() seen_builddirs = set() for b in self.builders: unknowns = set(b.slavenames) - slavenames if unknowns: error("builder '%s' uses unknown slaves %s" % (b.name, ", ".join(`u` for u in unknowns))) if b.name in seen_names: error("duplicate builder name '%s'" % b.name) seen_names.add(b.name) if b.builddir in seen_builddirs: error("duplicate builder builddir '%s'" % b.builddir) seen_builddirs.add(b.builddir) def check_status(self): # allow status receivers to check themselves against the rest of the # receivers for s in self.status: s.checkConfig(self.status) def check_horizons(self): if self.logHorizon is not None and self.buildHorizon is not None: if self.logHorizon > self.buildHorizon: error("logHorizon must be less than or equal to buildHorizon") def check_slavePortnum(self): if self.slavePortnum: return if self.slaves: error("slaves are configured, but no slavePortnum is set") if self.debugPassword: error("debug client is configured, but no slavePortnum is set") class BuilderConfig: def __init__(self, name=None, slavename=None, slavenames=None, builddir=None, slavebuilddir=None, factory=None, category=None, nextSlave=None, nextBuild=None, locks=None, env=None, properties=None, mergeRequests=None, description=None, canStartBuild=None): # name is required, and can't start with '_' if not name or type(name) not in (str, unicode): error("builder's name is required") name = '' elif name[0] == '_': error("builder names must not start with an underscore: '%s'" % name) self.name = name # factory is required if factory is None: error("builder '%s' has no factory" % name) from buildbot.process.factory import BuildFactory if factory is not None and not isinstance(factory, BuildFactory): error("builder '%s's factory is not a BuildFactory instance" % name) self.factory = factory # slavenames can be a single slave name or a list, and should also # include slavename, if given if type(slavenames) is str: slavenames = [ slavenames ] if slavenames: if not isinstance(slavenames, list): error("builder '%s': slavenames must be a list or a string" % (name,)) else: slavenames = [] if slavename: if type(slavename) != str: error("builder '%s': slavename must be a string" % (name,)) slavenames = slavenames + [ slavename ] if not slavenames: error("builder '%s': at least one slavename is required" % (name,)) self.slavenames = slavenames # builddir defaults to name if builddir is None: builddir = safeTranslate(name) self.builddir = builddir # slavebuilddir defaults to builddir if slavebuilddir is None: slavebuilddir = builddir self.slavebuilddir = slavebuilddir # remainder are optional if category is not None and not isinstance(category, str): error("builder '%s': category must be a string" % (name,)) self.category = category or '' self.nextSlave = nextSlave if nextSlave and not callable(nextSlave): error('nextSlave must be a callable') self.nextBuild = nextBuild if nextBuild and not callable(nextBuild): error('nextBuild must be a callable') self.canStartBuild = canStartBuild if canStartBuild and not callable(canStartBuild): error('canStartBuild must be a callable') self.locks = locks or [] self.env = env or {} if not isinstance(self.env, dict): error("builder's env must be a dictionary") self.properties = properties or {} self.mergeRequests = mergeRequests self.description = description def getConfigDict(self): # note: this method will disappear eventually - put your smarts in the # constructor! rv = { 'name': self.name, 'slavenames': self.slavenames, 'factory': self.factory, 'builddir': self.builddir, 'slavebuilddir': self.slavebuilddir, } if self.category: rv['category'] = self.category if self.nextSlave: rv['nextSlave'] = self.nextSlave if self.nextBuild: rv['nextBuild'] = self.nextBuild if self.locks: rv['locks'] = self.locks if self.env: rv['env'] = self.env if self.properties: rv['properties'] = self.properties if self.mergeRequests: rv['mergeRequests'] = self.mergeRequests if self.description: rv['description'] = self.description return rv class ReconfigurableServiceMixin: reconfig_priority = 128 @defer.inlineCallbacks def reconfigService(self, new_config): if not service.IServiceCollection.providedBy(self): return # get a list of child services to reconfigure reconfigurable_services = [ svc for svc in self if isinstance(svc, ReconfigurableServiceMixin) ] # sort by priority reconfigurable_services.sort(key=lambda svc : -svc.reconfig_priority) for svc in reconfigurable_services: yield svc.reconfigService(new_config) buildbot-0.8.8/buildbot/db/000077500000000000000000000000001222546025000155225ustar00rootroot00000000000000buildbot-0.8.8/buildbot/db/__init__.py000066400000000000000000000013011222546025000176260ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members buildbot-0.8.8/buildbot/db/base.py000066400000000000000000000061451222546025000170140ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members class DBConnectorComponent(object): # A fixed component of the DBConnector, handling one particular aspect of # the database. Instances of subclasses are assigned to attributes of the # DBConnector object, so that they are available at e.g., # C{master.db.model} or C{master.db.changes}. This parent class takes care # of the necessary backlinks and other housekeeping. connector = None def __init__(self, connector): self.db = connector # set up caches for method in dir(self.__class__): o = getattr(self, method) if isinstance(o, CachedMethod): setattr(self, method, o.get_cached_method(self)) _is_check_length_necessary = None def check_length(self, col, value): # for use by subclasses to check that 'value' will fit in 'col', where # 'col' is a table column from the model. # ignore this check for database engines that either provide this error # themselves (postgres) or that do not enforce maximum-length # restrictions (sqlite) if not self._is_check_length_necessary: if self.db.pool.engine.dialect.name == 'mysql': self._is_check_length_necessary = True else: # not necessary, so just stub out the method self.check_length = lambda col, value : None return assert col.type.length, "column %s does not have a length" % (col,) if value and len(value) > col.type.length: raise RuntimeError( "value for column %s is greater than max of %d characters: %s" % (col, col.type.length, value)) class CachedMethod(object): def __init__(self, cache_name, method): self.cache_name = cache_name self.method = method def get_cached_method(self, component): meth = self.method meth_name = meth.__name__ cache = component.db.master.caches.get_cache(self.cache_name, lambda key : meth(component, key)) def wrap(key, no_cache=0): if no_cache: return meth(component, key) return cache.get(key) wrap.__name__ = meth_name + " (wrapped)" wrap.__module__ = meth.__module__ wrap.__doc__ = meth.__doc__ wrap.cache = cache return wrap def cached(cache_name): return lambda method : CachedMethod(cache_name, method) buildbot-0.8.8/buildbot/db/buildrequests.py000066400000000000000000000254441222546025000210000ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import itertools import sqlalchemy as sa from twisted.internet import reactor from twisted.python import log from buildbot.db import base from buildbot.util import epoch2datetime, datetime2epoch class AlreadyClaimedError(Exception): pass class NotClaimedError(Exception): pass class BrDict(dict): pass # private decorator to add a _master_objectid keyword argument, querying from # the master def with_master_objectid(fn): def wrap(self, *args, **kwargs): d = self.db.master.getObjectId() d.addCallback(lambda master_objectid : fn(self, _master_objectid=master_objectid, *args, **kwargs)) return d wrap.__name__ = fn.__name__ wrap.__doc__ = fn.__doc__ return wrap class BuildRequestsConnectorComponent(base.DBConnectorComponent): # Documentation is in developer/database.rst @with_master_objectid def getBuildRequest(self, brid, _master_objectid=None): def thd(conn): reqs_tbl = self.db.model.buildrequests claims_tbl = self.db.model.buildrequest_claims res = conn.execute(sa.select([ reqs_tbl.outerjoin(claims_tbl, (reqs_tbl.c.id == claims_tbl.c.brid)) ], whereclause=(reqs_tbl.c.id == brid)), use_labels=True) row = res.fetchone() rv = None if row: rv = self._brdictFromRow(row, _master_objectid) res.close() return rv return self.db.pool.do(thd) @with_master_objectid def getBuildRequests(self, buildername=None, complete=None, claimed=None, bsid=None, _master_objectid=None, branch=None, repository=None): def thd(conn): reqs_tbl = self.db.model.buildrequests claims_tbl = self.db.model.buildrequest_claims bsets_tbl = self.db.model.buildsets sstamps_tbls = self.db.model.sourcestamps from_clause = reqs_tbl.outerjoin(claims_tbl, reqs_tbl.c.id == claims_tbl.c.brid) if branch or repository: from_clause = from_clause.join(bsets_tbl, reqs_tbl.c.buildsetid == bsets_tbl.c.id) from_clause = from_clause.join(sstamps_tbls, bsets_tbl.c.sourcestampsetid == sstamps_tbls.c.sourcestampsetid) q = sa.select([ reqs_tbl, claims_tbl ]).select_from(from_clause) if claimed is not None: if not claimed: q = q.where( (claims_tbl.c.claimed_at == None) & (reqs_tbl.c.complete == 0)) elif claimed == "mine": q = q.where( (claims_tbl.c.objectid == _master_objectid)) else: q = q.where( (claims_tbl.c.claimed_at != None)) if buildername is not None: q = q.where(reqs_tbl.c.buildername == buildername) if complete is not None: if complete: q = q.where(reqs_tbl.c.complete != 0) else: q = q.where(reqs_tbl.c.complete == 0) if bsid is not None: q = q.where(reqs_tbl.c.buildsetid == bsid) if branch is not None: q = q.where(sstamps_tbls.c.branch == branch) if repository is not None: q = q.where(sstamps_tbls.c.repository == repository) res = conn.execute(q) return [ self._brdictFromRow(row, _master_objectid) for row in res.fetchall() ] return self.db.pool.do(thd) @with_master_objectid def claimBuildRequests(self, brids, claimed_at=None, _reactor=reactor, _master_objectid=None): if claimed_at is not None: claimed_at = datetime2epoch(claimed_at) else: claimed_at = _reactor.seconds() def thd(conn): transaction = conn.begin() tbl = self.db.model.buildrequest_claims try: q = tbl.insert() conn.execute(q, [ dict(brid=id, objectid=_master_objectid, claimed_at=claimed_at) for id in brids ]) except (sa.exc.IntegrityError, sa.exc.ProgrammingError): transaction.rollback() raise AlreadyClaimedError transaction.commit() return self.db.pool.do(thd) @with_master_objectid def reclaimBuildRequests(self, brids, _reactor=reactor, _master_objectid=None): def thd(conn): transaction = conn.begin() tbl = self.db.model.buildrequest_claims claimed_at = _reactor.seconds() # we'll need to batch the brids into groups of 100, so that the # parameter lists supported by the DBAPI aren't exhausted iterator = iter(brids) while 1: batch = list(itertools.islice(iterator, 100)) if not batch: break # success! q = tbl.update(tbl.c.brid.in_(batch) & (tbl.c.objectid==_master_objectid)) res = conn.execute(q, claimed_at=claimed_at) # if fewer rows were updated than expected, then something # went wrong if res.rowcount != len(batch): transaction.rollback() raise AlreadyClaimedError transaction.commit() return self.db.pool.do(thd) @with_master_objectid def unclaimBuildRequests(self, brids, _master_objectid=None): def thd(conn): transaction = conn.begin() claims_tbl = self.db.model.buildrequest_claims # we'll need to batch the brids into groups of 100, so that the # parameter lists supported by the DBAPI aren't exhausted iterator = iter(brids) while 1: batch = list(itertools.islice(iterator, 100)) if not batch: break # success! try: q = claims_tbl.delete( (claims_tbl.c.brid.in_(batch)) & (claims_tbl.c.objectid == _master_objectid)) conn.execute(q) except: transaction.rollback() raise transaction.commit() return self.db.pool.do(thd) @with_master_objectid def completeBuildRequests(self, brids, results, complete_at=None, _reactor=reactor, _master_objectid=None): if complete_at is not None: complete_at = datetime2epoch(complete_at) else: complete_at = _reactor.seconds() def thd(conn): transaction = conn.begin() # the update here is simple, but a number of conditions are # attached to ensure that we do not update a row inappropriately, # Note that checking that the request is mine would require a # subquery, so for efficiency that is not checed. reqs_tbl = self.db.model.buildrequests # we'll need to batch the brids into groups of 100, so that the # parameter lists supported by the DBAPI aren't exhausted iterator = iter(brids) while 1: batch = list(itertools.islice(iterator, 100)) if not batch: break # success! q = reqs_tbl.update() q = q.where(reqs_tbl.c.id.in_(batch)) q = q.where(reqs_tbl.c.complete != 1) res = conn.execute(q, complete=1, results=results, complete_at=complete_at) # if an incorrect number of rows were updated, then we failed. if res.rowcount != len(batch): log.msg("tried to complete %d buildreqests, " "but only completed %d" % (len(batch), res.rowcount)) transaction.rollback() raise NotClaimedError transaction.commit() return self.db.pool.do(thd) def unclaimExpiredRequests(self, old, _reactor=reactor): def thd(conn): reqs_tbl = self.db.model.buildrequests claims_tbl = self.db.model.buildrequest_claims old_epoch = _reactor.seconds() - old # select any expired requests, and delete each one individually expired_brids = sa.select([ reqs_tbl.c.id ], whereclause=(reqs_tbl.c.complete != 1)) res = conn.execute(claims_tbl.delete( (claims_tbl.c.claimed_at < old_epoch) & claims_tbl.c.brid.in_(expired_brids))) return res.rowcount d = self.db.pool.do(thd) def log_nonzero_count(count): if count != 0: log.msg("unclaimed %d expired buildrequests (over %d seconds " "old)" % (count, old)) d.addCallback(log_nonzero_count) return d def _brdictFromRow(self, row, master_objectid): claimed = mine = False claimed_at = None if row.claimed_at is not None: claimed_at = row.claimed_at claimed = True mine = row.objectid == master_objectid def mkdt(epoch): if epoch: return epoch2datetime(epoch) submitted_at = mkdt(row.submitted_at) complete_at = mkdt(row.complete_at) claimed_at = mkdt(row.claimed_at) return BrDict(brid=row.id, buildsetid=row.buildsetid, buildername=row.buildername, priority=row.priority, claimed=claimed, claimed_at=claimed_at, mine=mine, complete=bool(row.complete), results=row.results, submitted_at=submitted_at, complete_at=complete_at) buildbot-0.8.8/buildbot/db/builds.py000066400000000000000000000055641222546025000173700ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.internet import reactor from buildbot.db import base from buildbot.util import epoch2datetime class BuildsConnectorComponent(base.DBConnectorComponent): # Documentation is in developer/database.rst def getBuild(self, bid): def thd(conn): tbl = self.db.model.builds res = conn.execute(tbl.select(whereclause=(tbl.c.id == bid))) row = res.fetchone() rv = None if row: rv = self._bdictFromRow(row) res.close() return rv return self.db.pool.do(thd) def getBuildsForRequest(self, brid): def thd(conn): tbl = self.db.model.builds q = tbl.select(whereclause=(tbl.c.brid == brid)) res = conn.execute(q) return [ self._bdictFromRow(row) for row in res.fetchall() ] return self.db.pool.do(thd) def addBuild(self, brid, number, _reactor=reactor): def thd(conn): start_time = _reactor.seconds() r = conn.execute(self.db.model.builds.insert(), dict(number=number, brid=brid, start_time=start_time, finish_time=None)) return r.inserted_primary_key[0] return self.db.pool.do(thd) def finishBuilds(self, bids, _reactor=reactor): def thd(conn): transaction = conn.begin() tbl = self.db.model.builds now = _reactor.seconds() # split the bids into batches, so as not to overflow the parameter # lists of the database interface remaining = bids while remaining: batch, remaining = remaining[:100], remaining[100:] q = tbl.update(whereclause=(tbl.c.id.in_(batch))) conn.execute(q, finish_time=now) transaction.commit() return self.db.pool.do(thd) def _bdictFromRow(self, row): def mkdt(epoch): if epoch: return epoch2datetime(epoch) return dict( bid=row.id, brid=row.brid, number=row.number, start_time=mkdt(row.start_time), finish_time=mkdt(row.finish_time)) buildbot-0.8.8/buildbot/db/buildsets.py000066400000000000000000000173531222546025000201030ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members """ Support for buildsets in the database """ import sqlalchemy as sa from twisted.internet import reactor from buildbot.util import json from buildbot.db import base from buildbot.util import epoch2datetime, datetime2epoch class BsDict(dict): pass class BuildsetsConnectorComponent(base.DBConnectorComponent): # Documentation is in developer/database.rst def addBuildset(self, sourcestampsetid, reason, properties, builderNames, external_idstring=None, _reactor=reactor): def thd(conn): buildsets_tbl = self.db.model.buildsets submitted_at = _reactor.seconds() self.check_length(buildsets_tbl.c.reason, reason) self.check_length(buildsets_tbl.c.external_idstring, external_idstring) transaction = conn.begin() # insert the buildset itself r = conn.execute(buildsets_tbl.insert(), dict( sourcestampsetid=sourcestampsetid, submitted_at=submitted_at, reason=reason, complete=0, complete_at=None, results=-1, external_idstring=external_idstring)) bsid = r.inserted_primary_key[0] # add any properties if properties: bs_props_tbl = self.db.model.buildset_properties inserts = [ dict(buildsetid=bsid, property_name=k, property_value=json.dumps([v,s])) for k,(v,s) in properties.iteritems() ] for i in inserts: self.check_length(bs_props_tbl.c.property_name, i['property_name']) self.check_length(bs_props_tbl.c.property_value, i['property_value']) conn.execute(bs_props_tbl.insert(), inserts) # and finish with a build request for each builder. Note that # sqlalchemy and the Python DBAPI do not provide a way to recover # inserted IDs from a multi-row insert, so this is done one row at # a time. brids = {} br_tbl = self.db.model.buildrequests ins = br_tbl.insert() for buildername in builderNames: self.check_length(br_tbl.c.buildername, buildername) r = conn.execute(ins, dict(buildsetid=bsid, buildername=buildername, priority=0, claimed_at=0, claimed_by_name=None, claimed_by_incarnation=None, complete=0, results=-1, submitted_at=submitted_at, complete_at=None)) brids[buildername] = r.inserted_primary_key[0] transaction.commit() return (bsid, brids) return self.db.pool.do(thd) def completeBuildset(self, bsid, results, complete_at=None, _reactor=reactor): if complete_at is not None: complete_at = datetime2epoch(complete_at) else: complete_at = _reactor.seconds() def thd(conn): tbl = self.db.model.buildsets q = tbl.update(whereclause=( (tbl.c.id == bsid) & ((tbl.c.complete == None) | (tbl.c.complete != 1)))) res = conn.execute(q, complete=1, results=results, complete_at=complete_at) if res.rowcount != 1: raise KeyError return self.db.pool.do(thd) def getBuildset(self, bsid): def thd(conn): bs_tbl = self.db.model.buildsets q = bs_tbl.select(whereclause=(bs_tbl.c.id == bsid)) res = conn.execute(q) row = res.fetchone() if not row: return None return self._row2dict(row) return self.db.pool.do(thd) def getBuildsets(self, complete=None): def thd(conn): bs_tbl = self.db.model.buildsets q = bs_tbl.select() if complete is not None: if complete: q = q.where(bs_tbl.c.complete != 0) else: q = q.where((bs_tbl.c.complete == 0) | (bs_tbl.c.complete == None)) res = conn.execute(q) return [ self._row2dict(row) for row in res.fetchall() ] return self.db.pool.do(thd) def getRecentBuildsets(self, count, branch=None, repository=None, complete=None): def thd(conn): bs_tbl = self.db.model.buildsets ss_tbl = self.db.model.sourcestamps j = sa.join(self.db.model.buildsets, self.db.model.sourcestampsets) j = j.join(self.db.model.sourcestamps) q = sa.select(columns=[bs_tbl], from_obj=[j], distinct=True) q = q.order_by(sa.desc(bs_tbl.c.submitted_at)) q = q.limit(count) if complete is not None: if complete: q = q.where(bs_tbl.c.complete != 0) else: q = q.where((bs_tbl.c.complete == 0) | (bs_tbl.c.complete == None)) if branch: q = q.where(ss_tbl.c.branch == branch) if repository: q = q.where(ss_tbl.c.repository == repository) res = conn.execute(q) return list(reversed([ self._row2dict(row) for row in res.fetchall() ])) return self.db.pool.do(thd) def getBuildsetProperties(self, buildsetid): """ Return the properties for a buildset, in the same format they were given to L{addBuildset}. Note that this method does not distinguish a nonexistent buildset from a buildset with no properties, and returns C{{}} in either case. @param buildsetid: buildset ID @returns: dictionary mapping property name to (value, source), via Deferred """ def thd(conn): bsp_tbl = self.db.model.buildset_properties q = sa.select( [ bsp_tbl.c.property_name, bsp_tbl.c.property_value ], whereclause=(bsp_tbl.c.buildsetid == buildsetid)) l = [] for row in conn.execute(q): try: properties = json.loads(row.property_value) l.append((row.property_name, tuple(properties))) except ValueError: pass return dict(l) return self.db.pool.do(thd) def _row2dict(self, row): def mkdt(epoch): if epoch: return epoch2datetime(epoch) return BsDict(external_idstring=row.external_idstring, reason=row.reason, sourcestampsetid=row.sourcestampsetid, submitted_at=mkdt(row.submitted_at), complete=bool(row.complete), complete_at=mkdt(row.complete_at), results=row.results, bsid=row.id) buildbot-0.8.8/buildbot/db/changes.py000066400000000000000000000236221222546025000175110ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members """ Support for changes in the database """ from buildbot.util import json import sqlalchemy as sa from twisted.internet import defer, reactor from buildbot.db import base from buildbot.util import epoch2datetime, datetime2epoch class ChDict(dict): pass class ChangesConnectorComponent(base.DBConnectorComponent): # Documentation is in developer/database.rst def addChange(self, author=None, files=None, comments=None, is_dir=0, revision=None, when_timestamp=None, branch=None, category=None, revlink='', properties={}, repository='', codebase='', project='', uid=None, _reactor=reactor): assert project is not None, "project must be a string, not None" assert repository is not None, "repository must be a string, not None" if when_timestamp is None: when_timestamp = epoch2datetime(_reactor.seconds()) # verify that source is 'Change' for each property for pv in properties.values(): assert pv[1] == 'Change', ("properties must be qualified with" "source 'Change'") def thd(conn): # note that in a read-uncommitted database like SQLite this # transaction does not buy atomicitiy - other database users may # still come across a change without its files, properties, # etc. That's OK, since we don't announce the change until it's # all in the database, but beware. transaction = conn.begin() ch_tbl = self.db.model.changes self.check_length(ch_tbl.c.author, author) self.check_length(ch_tbl.c.comments, comments) self.check_length(ch_tbl.c.branch, branch) self.check_length(ch_tbl.c.revision, revision) self.check_length(ch_tbl.c.revlink, revlink) self.check_length(ch_tbl.c.category, category) self.check_length(ch_tbl.c.repository, repository) self.check_length(ch_tbl.c.project, project) r = conn.execute(ch_tbl.insert(), dict( author=author, comments=comments, is_dir=is_dir, branch=branch, revision=revision, revlink=revlink, when_timestamp=datetime2epoch(when_timestamp), category=category, repository=repository, codebase=codebase, project=project)) changeid = r.inserted_primary_key[0] if files: tbl = self.db.model.change_files for f in files: self.check_length(tbl.c.filename, f) conn.execute(tbl.insert(), [ dict(changeid=changeid, filename=f) for f in files ]) if properties: tbl = self.db.model.change_properties inserts = [ dict(changeid=changeid, property_name=k, property_value=json.dumps(v)) for k,v in properties.iteritems() ] for i in inserts: self.check_length(tbl.c.property_name, i['property_name']) self.check_length(tbl.c.property_value, i['property_value']) conn.execute(tbl.insert(), inserts) if uid: ins = self.db.model.change_users.insert() conn.execute(ins, dict(changeid=changeid, uid=uid)) transaction.commit() return changeid d = self.db.pool.do(thd) return d @base.cached("chdicts") def getChange(self, changeid): assert changeid >= 0 def thd(conn): # get the row from the 'changes' table changes_tbl = self.db.model.changes q = changes_tbl.select(whereclause=(changes_tbl.c.changeid == changeid)) rp = conn.execute(q) row = rp.fetchone() if not row: return None # and fetch the ancillary data (files, properties) return self._chdict_from_change_row_thd(conn, row) d = self.db.pool.do(thd) return d def getChangeUids(self, changeid): assert changeid >= 0 def thd(conn): cu_tbl = self.db.model.change_users q = cu_tbl.select(whereclause=(cu_tbl.c.changeid == changeid)) res = conn.execute(q) rows = res.fetchall() row_uids = [ row.uid for row in rows ] return row_uids d = self.db.pool.do(thd) return d def getRecentChanges(self, count): def thd(conn): # get the changeids from the 'changes' table changes_tbl = self.db.model.changes q = sa.select([changes_tbl.c.changeid], order_by=[sa.desc(changes_tbl.c.changeid)], limit=count) rp = conn.execute(q) changeids = [ row.changeid for row in rp ] rp.close() return list(reversed(changeids)) d = self.db.pool.do(thd) # then turn those into changes, using the cache def get_changes(changeids): return defer.gatherResults([ self.getChange(changeid) for changeid in changeids ]) d.addCallback(get_changes) return d def getLatestChangeid(self): def thd(conn): changes_tbl = self.db.model.changes q = sa.select([ changes_tbl.c.changeid ], order_by=sa.desc(changes_tbl.c.changeid), limit=1) return conn.scalar(q) d = self.db.pool.do(thd) return d # utility methods def pruneChanges(self, changeHorizon): """ Called periodically by DBConnector, this method deletes changes older than C{changeHorizon}. """ if not changeHorizon: return defer.succeed(None) def thd(conn): changes_tbl = self.db.model.changes # First, get the list of changes to delete. This could be written # as a subquery but then that subquery would be run for every # table, which is very inefficient; also, MySQL's subquery support # leaves much to be desired, and doesn't support this particular # form. q = sa.select([changes_tbl.c.changeid], order_by=[sa.desc(changes_tbl.c.changeid)], offset=changeHorizon) res = conn.execute(q) ids_to_delete = [ r.changeid for r in res ] # and delete from all relevant tables, in dependency order for table_name in ('scheduler_changes', 'sourcestamp_changes', 'change_files', 'change_properties', 'changes', 'change_users'): remaining = ids_to_delete[:] while remaining: batch, remaining = remaining[:100], remaining[100:] table = self.db.model.metadata.tables[table_name] conn.execute( table.delete(table.c.changeid.in_(batch))) return self.db.pool.do(thd) def _chdict_from_change_row_thd(self, conn, ch_row): # This method must be run in a db.pool thread, and returns a chdict # given a row from the 'changes' table change_files_tbl = self.db.model.change_files change_properties_tbl = self.db.model.change_properties chdict = ChDict( changeid=ch_row.changeid, author=ch_row.author, files=[], # see below comments=ch_row.comments, is_dir=ch_row.is_dir, revision=ch_row.revision, when_timestamp=epoch2datetime(ch_row.when_timestamp), branch=ch_row.branch, category=ch_row.category, revlink=ch_row.revlink, properties={}, # see below repository=ch_row.repository, codebase=ch_row.codebase, project=ch_row.project) query = change_files_tbl.select( whereclause=(change_files_tbl.c.changeid == ch_row.changeid)) rows = conn.execute(query) for r in rows: chdict['files'].append(r.filename) # and properties must be given without a source, so strip that, but # be flexible in case users have used a development version where the # change properties were recorded incorrectly def split_vs(vs): try: v,s = vs if s != "Change": v,s = vs, "Change" except: v,s = vs, "Change" return v, s query = change_properties_tbl.select( whereclause=(change_properties_tbl.c.changeid == ch_row.changeid)) rows = conn.execute(query) for r in rows: try: v, s = split_vs(json.loads(r.property_value)) chdict['properties'][r.property_name] = (v,s) except ValueError: pass return chdict buildbot-0.8.8/buildbot/db/connector.py000066400000000000000000000114171222546025000200720ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import textwrap from twisted.internet import defer from twisted.python import log from twisted.application import internet, service from buildbot import config from buildbot.db import enginestrategy from buildbot.db import pool, model, changes, schedulers, sourcestamps, sourcestampsets from buildbot.db import state, buildsets, buildrequests, builds, users class DatabaseNotReadyError(Exception): pass upgrade_message = textwrap.dedent("""\ The Buildmaster database needs to be upgraded before this version of buildbot can run. Use the following command-line buildbot upgrade-master path/to/master to upgrade the database, and try starting the buildmaster again. You may want to make a backup of your buildmaster before doing so. """).strip() class DBConnector(config.ReconfigurableServiceMixin, service.MultiService): # The connection between Buildbot and its backend database. This is # generally accessible as master.db, but is also used during upgrades. # # Most of the interesting operations available via the connector are # implemented in connector components, available as attributes of this # object, and listed below. # Period, in seconds, of the cleanup task. This master will perform # periodic cleanup actions on this schedule. CLEANUP_PERIOD = 3600 def __init__(self, master, basedir): service.MultiService.__init__(self) self.setName('db') self.master = master self.basedir = basedir # not configured yet - we don't build an engine until the first # reconfig self.configured_url = None # set up components self._engine = None # set up in reconfigService self.pool = None # set up in reconfigService self.model = model.Model(self) self.changes = changes.ChangesConnectorComponent(self) self.schedulers = schedulers.SchedulersConnectorComponent(self) self.sourcestamps = sourcestamps.SourceStampsConnectorComponent(self) self.sourcestampsets = sourcestampsets.SourceStampSetsConnectorComponent(self) self.buildsets = buildsets.BuildsetsConnectorComponent(self) self.buildrequests = buildrequests.BuildRequestsConnectorComponent(self) self.state = state.StateConnectorComponent(self) self.builds = builds.BuildsConnectorComponent(self) self.users = users.UsersConnectorComponent(self) self.cleanup_timer = internet.TimerService(self.CLEANUP_PERIOD, self._doCleanup) self.cleanup_timer.setServiceParent(self) def setup(self, check_version=True, verbose=True): db_url = self.configured_url = self.master.config.db['db_url'] log.msg("Setting up database with URL %r" % (db_url,)) # set up the engine and pool self._engine = enginestrategy.create_engine(db_url, basedir=self.basedir) self.pool = pool.DBThreadPool(self._engine, verbose=verbose) # make sure the db is up to date, unless specifically asked not to if check_version: d = self.model.is_current() def check_current(res): if not res: for l in upgrade_message.split('\n'): log.msg(l) raise DatabaseNotReadyError() d.addCallback(check_current) else: d = defer.succeed(None) return d def reconfigService(self, new_config): # double-check -- the master ensures this in config checks assert self.configured_url == new_config.db['db_url'] return config.ReconfigurableServiceMixin.reconfigService(self, new_config) def _doCleanup(self): """ Perform any periodic database cleanup tasks. @returns: Deferred """ # pass on this if we're not configured yet if not self.configured_url: return d = self.changes.pruneChanges(self.master.config.changeHorizon) d.addErrback(log.err, 'while pruning changes') return d buildbot-0.8.8/buildbot/db/enginestrategy.py000066400000000000000000000175561222546025000211420ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members """ A wrapper around `sqlalchemy.create_engine` that handles all of the special cases that Buildbot needs. Those include: - pool_recycle for MySQL - %(basedir) substitution - optimal thread pool size calculation """ import os import sqlalchemy as sa from twisted.python import log from sqlalchemy.engine import strategies, url from sqlalchemy.pool import NullPool from buildbot.util import sautils # from http://www.mail-archive.com/sqlalchemy@googlegroups.com/msg15079.html class ReconnectingListener(object): def __init__(self): self.retried = False class BuildbotEngineStrategy(strategies.ThreadLocalEngineStrategy): # A subclass of the ThreadLocalEngineStrategy that can effectively interact # with Buildbot. # # This adjusts the passed-in parameters to ensure that we get the behaviors # Buildbot wants from particular drivers, and wraps the outgoing Engine # object so that its methods run in threads and return deferreds. name = 'buildbot' def special_case_sqlite(self, u, kwargs): """For sqlite, percent-substitute %(basedir)s and use a full path to the basedir. If using a memory database, force the pool size to be 1.""" max_conns = None # when given a database path, stick the basedir in there if u.database: # Use NullPool instead of the sqlalchemy-0.6.8-default # SingletonThreadpool for sqlite to suppress the error in # http://groups.google.com/group/sqlalchemy/msg/f8482e4721a89589, # which also explains that NullPool is the new default in # sqlalchemy 0.7 for non-memory SQLite databases. kwargs.setdefault('poolclass', NullPool) u.database = u.database % dict(basedir = kwargs['basedir']) if not os.path.isabs(u.database[0]): u.database = os.path.join(kwargs['basedir'], u.database) # in-memory databases need exactly one connection if not u.database: kwargs['pool_size'] = 1 max_conns = 1 # allow serializing access to the db if 'serialize_access' in u.query: u.query.pop('serialize_access') max_conns = 1 return u, kwargs, max_conns def set_up_sqlite_engine(self, u, engine): """Special setup for sqlite engines""" # try to enable WAL logging if u.database: def connect_listener(connection, record): connection.execute("pragma checkpoint_fullfsync = off") if sautils.sa_version() < (0,7,0): class CheckpointFullfsyncDisabler(object): pass disabler = CheckpointFullfsyncDisabler() disabler.connect = connect_listener engine.pool.add_listener(disabler) else: sa.event.listen(engine.pool, 'connect', connect_listener) log.msg("setting database journal mode to 'wal'") try: engine.execute("pragma journal_mode = wal") except: log.msg("failed to set journal mode - database may fail") def special_case_mysql(self, u, kwargs): """For mysql, take max_idle out of the query arguments, and use its value for pool_recycle. Also, force use_unicode and charset to be True and 'utf8', failing if they were set to anything else.""" kwargs['pool_recycle'] = int(u.query.pop('max_idle', 3600)) # default to the InnoDB storage engine storage_engine = u.query.pop('storage_engine', 'MyISAM') kwargs['connect_args'] = { 'init_command' : 'SET storage_engine=%s' % storage_engine, } if 'use_unicode' in u.query: if u.query['use_unicode'] != "True": raise TypeError("Buildbot requires use_unicode=True " + "(and adds it automatically)") else: u.query['use_unicode'] = True if 'charset' in u.query: if u.query['charset'] != "utf8": raise TypeError("Buildbot requires charset=utf8 " + "(and adds it automatically)") else: u.query['charset'] = 'utf8' return u, kwargs, None def set_up_mysql_engine(self, u, engine): """Special setup for mysql engines""" # add the reconnecting PoolListener that will detect a # disconnected connection and automatically start a new # one. This provides a measure of additional safety over # the pool_recycle parameter, and is useful when e.g., the # mysql server goes away def checkout_listener(dbapi_con, con_record, con_proxy): try: cursor = dbapi_con.cursor() cursor.execute("SELECT 1") except dbapi_con.OperationalError, ex: if ex.args[0] in (2006, 2013, 2014, 2045, 2055): # sqlalchemy will re-create the connection raise sa.exc.DisconnectionError() raise # older versions of sqlalchemy require the listener to be specified # in the kwargs, in a class instance if sautils.sa_version() < (0,7,0): class ReconnectingListener(object): pass rcl = ReconnectingListener() rcl.checkout = checkout_listener engine.pool.add_listener(rcl) else: sa.event.listen(engine.pool, 'checkout', checkout_listener) def create(self, name_or_url, **kwargs): if 'basedir' not in kwargs: raise TypeError('no basedir supplied to create_engine') max_conns = None # apply special cases u = url.make_url(name_or_url) if u.drivername.startswith('sqlite'): u, kwargs, max_conns = self.special_case_sqlite(u, kwargs) elif u.drivername.startswith('mysql'): u, kwargs, max_conns = self.special_case_mysql(u, kwargs) # remove the basedir as it may confuse sqlalchemy basedir = kwargs.pop('basedir') # calculate the maximum number of connections from the pool parameters, # if it hasn't already been specified if max_conns is None: max_conns = kwargs.get('pool_size', 5) + kwargs.get('max_overflow', 10) engine = strategies.ThreadLocalEngineStrategy.create(self, u, **kwargs) # annotate the engine with the optimal thread pool size; this is used # by DBConnector to configure the surrounding thread pool engine.optimal_thread_pool_size = max_conns # keep the basedir engine.buildbot_basedir = basedir if u.drivername.startswith('sqlite'): self.set_up_sqlite_engine(u, engine) elif u.drivername.startswith('mysql'): self.set_up_mysql_engine(u, engine) return engine BuildbotEngineStrategy() # this module is really imported for the side-effects, but pyflakes will like # us to use something from the module -- so offer a copy of create_engine, # which explicitly adds the strategy argument def create_engine(*args, **kwargs): kwargs['strategy'] = 'buildbot' return sa.create_engine(*args, **kwargs) buildbot-0.8.8/buildbot/db/exceptions.py000066400000000000000000000013631222546025000202600ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members class DatabaseNotReadyError(Exception): pass buildbot-0.8.8/buildbot/db/migrate/000077500000000000000000000000001222546025000171525ustar00rootroot00000000000000buildbot-0.8.8/buildbot/db/migrate/README000066400000000000000000000001531222546025000200310ustar00rootroot00000000000000This is a database migration repository. More information at http://code.google.com/p/sqlalchemy-migrate/ buildbot-0.8.8/buildbot/db/migrate/migrate.cfg000066400000000000000000000017301222546025000212640ustar00rootroot00000000000000[db_settings] # Used to identify which repository this database is versioned under. # You can use the name of your project. repository_id=Buildbot # The name of the database table used to track the schema version. # This name shouldn't already be used by your project. # If this is changed once a database is under version control, you'll need to # change the table name in each database too. version_table=migrate_version # When committing a change script, Migrate will attempt to generate the # sql for all supported databases; normally, if one of them fails - probably # because you don't have that database installed - it is ignored and the # commit continues, perhaps ending successfully. # Databases in this list MUST compile successfully during a commit, or the # entire commit will fail. List the databases your application will actually # be using to ensure your updates to that database work properly. # This must be a list; example: ['postgres','sqlite'] required_dbs=[] buildbot-0.8.8/buildbot/db/migrate/versions/000077500000000000000000000000001222546025000210225ustar00rootroot00000000000000buildbot-0.8.8/buildbot/db/migrate/versions/001_initial.py000066400000000000000000000255011222546025000234100ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import cPickle from twisted.persisted import styles from buildbot.util import json import sqlalchemy as sa metadata = sa.MetaData() last_access = sa.Table('last_access', metadata, sa.Column('who', sa.String(256), nullable=False), sa.Column('writing', sa.Integer, nullable=False), sa.Column('last_access', sa.Integer, nullable=False), ) changes_nextid = sa.Table('changes_nextid', metadata, sa.Column('next_changeid', sa.Integer), ) changes = sa.Table('changes', metadata, sa.Column('changeid', sa.Integer, autoincrement=False, primary_key=True), sa.Column('author', sa.String(256), nullable=False), sa.Column('comments', sa.String(1024), nullable=False), sa.Column('is_dir', sa.SmallInteger, nullable=False), sa.Column('branch', sa.String(256)), sa.Column('revision', sa.String(256)), sa.Column('revlink', sa.String(256)), sa.Column('when_timestamp', sa.Integer, nullable=False), sa.Column('category', sa.String(256)), ) change_links = sa.Table('change_links', metadata, sa.Column('changeid', sa.Integer, sa.ForeignKey('changes.changeid'), nullable=False), sa.Column('link', sa.String(1024), nullable=False), ) change_files = sa.Table('change_files', metadata, sa.Column('changeid', sa.Integer, sa.ForeignKey('changes.changeid'), nullable=False), sa.Column('filename', sa.String(1024), nullable=False), ) change_properties = sa.Table('change_properties', metadata, sa.Column('changeid', sa.Integer, sa.ForeignKey('changes.changeid'), nullable=False), sa.Column('property_name', sa.String(256), nullable=False), sa.Column('property_value', sa.String(1024), nullable=False), ) schedulers = sa.Table("schedulers", metadata, sa.Column('schedulerid', sa.Integer, autoincrement=False, primary_key=True), sa.Column('name', sa.String(128), nullable=False), sa.Column('state', sa.String(1024), nullable=False), ) scheduler_changes = sa.Table('scheduler_changes', metadata, sa.Column('schedulerid', sa.Integer, sa.ForeignKey('schedulers.schedulerid')), sa.Column('changeid', sa.Integer, sa.ForeignKey('changes.changeid')), sa.Column('important', sa.SmallInteger), ) scheduler_upstream_buildsets = sa.Table('scheduler_upstream_buildsets', metadata, sa.Column('buildsetid', sa.Integer, sa.ForeignKey('buildsets.id')), sa.Column('schedulerid', sa.Integer, sa.ForeignKey('schedulers.schedulerid')), sa.Column('active', sa.SmallInteger), ) sourcestamps = sa.Table('sourcestamps', metadata, sa.Column('id', sa.Integer, autoincrement=False, primary_key=True), sa.Column('branch', sa.String(256)), sa.Column('revision', sa.String(256)), sa.Column('patchid', sa.Integer, sa.ForeignKey('patches.id')), ) patches = sa.Table('patches', metadata, sa.Column('id', sa.Integer, autoincrement=False, primary_key=True), sa.Column('patchlevel', sa.Integer, nullable=False), sa.Column('patch_base64', sa.Text, nullable=False), sa.Column('subdir', sa.Text), ) sourcestamp_changes = sa.Table('sourcestamp_changes', metadata, sa.Column('sourcestampid', sa.Integer, sa.ForeignKey('sourcestamps.id'), nullable=False), sa.Column('changeid', sa.Integer, sa.ForeignKey('changes.changeid'), nullable=False), ) buildsets = sa.Table('buildsets', metadata, sa.Column('id', sa.Integer, autoincrement=False, primary_key=True), sa.Column('external_idstring', sa.String(256)), sa.Column('reason', sa.String(256)), sa.Column('sourcestampid', sa.Integer, sa.ForeignKey('sourcestamps.id'), nullable=False), sa.Column('submitted_at', sa.Integer, nullable=False), sa.Column('complete', sa.SmallInteger, nullable=False, server_default=sa.DefaultClause("0")), sa.Column('complete_at', sa.Integer), sa.Column('results', sa.SmallInteger), ) buildset_properties = sa.Table('buildset_properties', metadata, sa.Column('buildsetid', sa.Integer, sa.ForeignKey('buildsets.id'), nullable=False), sa.Column('property_name', sa.String(256), nullable=False), sa.Column('property_value', sa.String(1024), nullable=False), ) buildrequests = sa.Table('buildrequests', metadata, sa.Column('id', sa.Integer, autoincrement=False, primary_key=True), sa.Column('buildsetid', sa.Integer, sa.ForeignKey("buildsets.id"), nullable=False), sa.Column('buildername', sa.String(length=256), nullable=False), sa.Column('priority', sa.Integer, nullable=False, server_default=sa.DefaultClause("0")), sa.Column('claimed_at', sa.Integer, server_default=sa.DefaultClause("0")), sa.Column('claimed_by_name', sa.String(length=256)), sa.Column('claimed_by_incarnation', sa.String(length=256)), sa.Column('complete', sa.Integer, server_default=sa.DefaultClause("0")), sa.Column('results', sa.SmallInteger), sa.Column('submitted_at', sa.Integer, nullable=False), sa.Column('complete_at', sa.Integer), ) builds = sa.Table('builds', metadata, sa.Column('id', sa.Integer, autoincrement=False, primary_key=True), sa.Column('number', sa.Integer, nullable=False), sa.Column('brid', sa.Integer, sa.ForeignKey('buildrequests.id'), nullable=False), sa.Column('start_time', sa.Integer, nullable=False), sa.Column('finish_time', sa.Integer), ) def test_unicode(migrate_engine): """Test that the database can handle inserting and selecting Unicode""" # set up a subsidiary MetaData object to hold this temporary table submeta = sa.MetaData() submeta.bind = migrate_engine test_unicode = sa.Table('test_unicode', submeta, sa.Column('u', sa.Unicode(length=100)), sa.Column('b', sa.LargeBinary), ) test_unicode.create() # insert a unicode value in there u = u"Frosty the \N{SNOWMAN}" b='\xff\xff\x00' ins = test_unicode.insert().values(u=u, b=b) migrate_engine.execute(ins) # see if the data is intact row = migrate_engine.execute(sa.select([test_unicode])).fetchall()[0] assert type(row['u']) is unicode assert row['u'] == u assert type(row['b']) is str assert row['b'] == b # drop the test table test_unicode.drop() def import_changes(migrate_engine): # get the basedir from the engine - see model.py if you're wondering # how it got there basedir = migrate_engine.buildbot_basedir # strip None from any of these values, just in case def remove_none(x): if x is None: return u"" elif isinstance(x, str): return x.decode("utf8") else: return x # if we still have a changes.pck, then we need to migrate it changes_pickle = os.path.join(basedir, "changes.pck") if not os.path.exists(changes_pickle): migrate_engine.execute(changes_nextid.insert(), next_changeid=1) return #if not quiet: print "migrating changes.pck to database" # 'source' will be an old b.c.changes.ChangeMaster instance, with a # .changes attribute. Note that we use 'r', and not 'rb', because these # pickles were written using the old text pickle format, which requires # newline translation with open(changes_pickle,"r") as f: source = cPickle.load(f) styles.doUpgrade() #if not quiet: print " (%d Change objects)" % len(source.changes) # first, scan for changes without a number. If we find any, then we'll # renumber the changes sequentially have_unnumbered = False for c in source.changes: if c.revision and c.number is None: have_unnumbered = True break if have_unnumbered: n = 1 for c in source.changes: if c.revision: c.number = n n = n + 1 # insert the changes for c in source.changes: if not c.revision: continue try: values = dict( changeid=c.number, author=c.who, comments=c.comments, is_dir=c.isdir, branch=c.branch, revision=c.revision, revlink=c.revlink, when_timestamp=c.when, category=c.category) values = dict([ (k, remove_none(v)) for k, v in values.iteritems() ]) except UnicodeDecodeError, e: raise UnicodeError("Trying to import change data as UTF-8 failed. Please look at contrib/fix_changes_pickle_encoding.py: %s" % str(e)) migrate_engine.execute(changes.insert(), **values) # NOTE: change_links is not populated, since it is deleted in db # version 20. The table is still created, though. # sometimes c.files contains nested lists -- why, I do not know! But we deal with # it all the same - see bug #915. We'll assume for now that c.files contains *either* # lists of filenames or plain filenames, not both. def flatten(l): if l and type(l[0]) == list: rv = [] for e in l: if type(e) == list: rv.extend(e) else: rv.append(e) return rv else: return l for filename in flatten(c.files): migrate_engine.execute(change_files.insert(), changeid=c.number, filename=filename) for propname,propvalue in c.properties.properties.items(): encoded_value = json.dumps(propvalue) migrate_engine.execute(change_properties.insert(), changeid=c.number, property_name=propname, property_value=encoded_value) # update next_changeid max_changeid = max([ c.number for c in source.changes if c.revision ] + [ 0 ]) migrate_engine.execute(changes_nextid.insert(), next_changeid=max_changeid+1) #if not quiet: # print "moving changes.pck to changes.pck.old; delete it or keep it as a backup" os.rename(changes_pickle, changes_pickle+".old") def upgrade(migrate_engine): metadata.bind = migrate_engine # do some tests before getting started test_unicode(migrate_engine) # create the initial schema metadata.create_all() # and import some changes import_changes(migrate_engine) buildbot-0.8.8/buildbot/db/migrate/versions/002_add_proj_repo.py000066400000000000000000000025411222546025000245660ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine # add project and repository columns to 'changes' an 'sourcestamps' def add_cols(table): repository = sa.Column('repository', sa.String(512), nullable=False, server_default=sa.DefaultClause('')) repository.create(table, populate_default=True) project = sa.Column('project', sa.String(512), nullable=False, server_default=sa.DefaultClause('')) project.create(table, populate_default=True) add_cols(sa.Table('changes', metadata, autoload=True)) add_cols(sa.Table('sourcestamps', metadata, autoload=True)) buildbot-0.8.8/buildbot/db/migrate/versions/003_scheduler_class_name.py000066400000000000000000000024231222546025000261220ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine # add an empty class_name to the schedulers table schedulers = sa.Table('schedulers', metadata, autoload=True) class_name = sa.Column('class_name', sa.String(length=128), nullable=False, server_default=sa.DefaultClause('')) class_name.create(schedulers, populate_default=True) # and an index since we'll be selecting with (name= AND class=) idx = sa.Index('name_and_class', schedulers.c.name, schedulers.c.class_name) idx.create(migrate_engine) buildbot-0.8.8/buildbot/db/migrate/versions/004_add_autoincrement.py000066400000000000000000000137751222546025000254610ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine # re-include some of the relevant tables, as they were in version 3, since # sqlalchemy's reflection doesn't work very well for defaults. These must # be complete table specifications as for some dialects sqlalchemy will # create a brand new, temporary table, and copy data over sa.Table("schedulers", metadata, sa.Column('schedulerid', sa.Integer, autoincrement=False, primary_key=True), sa.Column('name', sa.String(128), nullable=False), sa.Column('state', sa.String(1024), nullable=False), sa.Column('class_name', sa.String(128), nullable=False), ) sa.Table('changes', metadata, sa.Column('changeid', sa.Integer, autoincrement=False, primary_key=True), sa.Column('author', sa.String(256), nullable=False), sa.Column('comments', sa.String(1024), nullable=False), sa.Column('is_dir', sa.SmallInteger, nullable=False), sa.Column('branch', sa.String(256)), sa.Column('revision', sa.String(256)), sa.Column('revlink', sa.String(256)), sa.Column('when_timestamp', sa.Integer, nullable=False), sa.Column('category', sa.String(256)), sa.Column('repository', sa.Text, nullable=False, server_default=''), sa.Column('project', sa.Text, nullable=False, server_default=''), ) sa.Table('patches', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('patchlevel', sa.Integer, nullable=False), sa.Column('patch_base64', sa.Text, nullable=False), sa.Column('subdir', sa.Text), ) sa.Table('sourcestamps', metadata, sa.Column('id', sa.Integer, autoincrement=True, primary_key=True), sa.Column('branch', sa.String(256)), sa.Column('revision', sa.String(256)), sa.Column('patchid', sa.Integer, sa.ForeignKey('patches.id')), sa.Column('repository', sa.Text(length=None), nullable=False, server_default=''), sa.Column('project', sa.Text(length=None), nullable=False, server_default=''), ) sa.Table('buildsets', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('external_idstring', sa.String(256)), sa.Column('reason', sa.String(256)), sa.Column('sourcestampid', sa.Integer, sa.ForeignKey('sourcestamps.id'), nullable=False), sa.Column('submitted_at', sa.Integer, nullable=False), sa.Column('complete', sa.SmallInteger, nullable=False, server_default=sa.DefaultClause("0")), sa.Column('complete_at', sa.Integer), sa.Column('results', sa.SmallInteger), ) sa.Table('buildrequests', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('buildsetid', sa.Integer, sa.ForeignKey("buildsets.id"), nullable=False), sa.Column('buildername', sa.String(length=None), nullable=False), sa.Column('priority', sa.Integer, nullable=False, server_default=sa.DefaultClause("0")), sa.Column('claimed_at', sa.Integer, server_default=sa.DefaultClause("0")), sa.Column('claimed_by_name', sa.String(length=None)), sa.Column('claimed_by_incarnation', sa.String(length=None)), sa.Column('complete', sa.Integer, server_default=sa.DefaultClause("0")), sa.Column('results', sa.SmallInteger), sa.Column('submitted_at', sa.Integer, nullable=False), sa.Column('complete_at', sa.Integer), ) sa.Table('builds', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('number', sa.Integer, nullable=False), sa.Column('brid', sa.Integer, sa.ForeignKey('buildrequests.id'), nullable=False), sa.Column('start_time', sa.Integer, nullable=False), sa.Column('finish_time', sa.Integer), ) to_autoinc = [ s.split(".") for s in "schedulers.schedulerid", "builds.id", "changes.changeid", "buildrequests.id", "buildsets.id", "patches.id", "sourcestamps.id", ] # It seems that SQLAlchemy's ALTER TABLE doesn't work when migrating from # INTEGER to PostgreSQL's SERIAL data type (which is just pseudo data type # for INTEGER with SEQUENCE), so we have to work-around this with raw SQL. if migrate_engine.dialect.name in ('postgres', 'postgresql'): for table_name, col_name in to_autoinc: migrate_engine.execute("CREATE SEQUENCE %s_%s_seq" % (table_name, col_name)) migrate_engine.execute("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT nextval('%s_%s_seq'::regclass)" % (table_name, col_name, table_name, col_name)) migrate_engine.execute("ALTER SEQUENCE %s_%s_seq OWNED BY %s.%s" % (table_name, col_name, table_name, col_name)) else: for table_name, col_name in to_autoinc: table = metadata.tables[table_name] col = table.c[col_name] col.alter(autoincrement=True) # also drop the changes_nextid table here (which really should have been a # sequence..) table = sa.Table('changes_nextid', metadata, autoload=True) table.drop() buildbot-0.8.8/buildbot/db/migrate/versions/005_add_indexes.py000066400000000000000000000140331222546025000242300ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine # note that all of the tables defined here omit the ForeignKey constraints; # this just lets this code specify the tables in any order; the tables are # not re-created here, so this omission causes no problems - the key # constraints are still defined in the table def add_index(table_name, col_name): idx_name = "%s_%s" % (table_name, col_name) idx = sa.Index(idx_name, metadata.tables[table_name].c[col_name]) idx.create(migrate_engine) sa.Table('buildrequests', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('buildsetid', sa.Integer, nullable=False), sa.Column('buildername', sa.String(length=None), nullable=False), sa.Column('priority', sa.Integer, nullable=False), sa.Column('claimed_at', sa.Integer, server_default=sa.DefaultClause("0")), sa.Column('claimed_by_name', sa.String(length=None)), sa.Column('claimed_by_incarnation', sa.String(length=None)), sa.Column('complete', sa.Integer, server_default=sa.DefaultClause("0")), sa.Column('results', sa.SmallInteger), sa.Column('submitted_at', sa.Integer, nullable=False), sa.Column('complete_at', sa.Integer), ) add_index("buildrequests", "buildsetid") add_index("buildrequests", "buildername") add_index("buildrequests", "complete") add_index("buildrequests", "claimed_at") add_index("buildrequests", "claimed_by_name") sa.Table('builds', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('number', sa.Integer, nullable=False), sa.Column('brid', sa.Integer, nullable=False), sa.Column('start_time', sa.Integer, nullable=False), sa.Column('finish_time', sa.Integer), ) add_index("builds", "number") add_index("builds", "brid") sa.Table('buildsets', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('external_idstring', sa.String(256)), sa.Column('reason', sa.String(256)), sa.Column('sourcestampid', sa.Integer, nullable=False), sa.Column('submitted_at', sa.Integer, nullable=False), sa.Column('complete', sa.SmallInteger, nullable=False, server_default=sa.DefaultClause("0")), sa.Column('complete_at', sa.Integer), sa.Column('results', sa.SmallInteger), ) add_index("buildsets", "complete") add_index("buildsets", "submitted_at") sa.Table('buildset_properties', metadata, sa.Column('buildsetid', sa.Integer, nullable=False), sa.Column('property_name', sa.String(256), nullable=False), sa.Column('property_value', sa.String(1024), nullable=False), ) add_index("buildset_properties", "buildsetid") sa.Table('changes', metadata, sa.Column('changeid', sa.Integer, primary_key=True), sa.Column('author', sa.String(256), nullable=False), sa.Column('comments', sa.String(1024), nullable=False), sa.Column('is_dir', sa.SmallInteger, nullable=False), sa.Column('branch', sa.String(256)), sa.Column('revision', sa.String(256)), sa.Column('revlink', sa.String(256)), sa.Column('when_timestamp', sa.Integer, nullable=False), sa.Column('category', sa.String(256)), sa.Column('repository', sa.Text, nullable=False, server_default=''), sa.Column('project', sa.Text, nullable=False, server_default=''), ) add_index("changes", "branch") add_index("changes", "revision") add_index("changes", "author") add_index("changes", "category") add_index("changes", "when_timestamp") sa.Table('change_files', metadata, sa.Column('changeid', sa.Integer, nullable=False), sa.Column('filename', sa.String(1024), nullable=False), ) add_index("change_files", "changeid") sa.Table('change_links', metadata, sa.Column('changeid', sa.Integer, nullable=False), sa.Column('link', sa.String(1024), nullable=False), ) add_index("change_links", "changeid") sa.Table('change_properties', metadata, sa.Column('changeid', sa.Integer, nullable=False), sa.Column('property_name', sa.String(256), nullable=False), sa.Column('property_value', sa.String(1024), nullable=False), ) add_index("change_properties", "changeid") # schedulers already has an index sa.Table('scheduler_changes', metadata, sa.Column('schedulerid', sa.Integer), sa.Column('changeid', sa.Integer), sa.Column('important', sa.SmallInteger), ) add_index("scheduler_changes", "schedulerid") add_index("scheduler_changes", "changeid") sa.Table('scheduler_upstream_buildsets', metadata, sa.Column('buildsetid', sa.Integer), sa.Column('schedulerid', sa.Integer), sa.Column('active', sa.SmallInteger), ) add_index("scheduler_upstream_buildsets", "buildsetid") add_index("scheduler_upstream_buildsets", "schedulerid") add_index("scheduler_upstream_buildsets", "active") # sourcestamps are only queried by id, no need for additional indexes sa.Table('sourcestamp_changes', metadata, sa.Column('sourcestampid', sa.Integer, nullable=False), sa.Column('changeid', sa.Integer, nullable=False), ) add_index("sourcestamp_changes", "sourcestampid") buildbot-0.8.8/buildbot/db/migrate/versions/006_drop_last_access.py000066400000000000000000000016071222546025000252750ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine table = sa.Table('last_access', metadata, autoload=True) table.drop() buildbot-0.8.8/buildbot/db/migrate/versions/007_add_object_tables.py000066400000000000000000000030201222546025000253650ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine objects = sa.Table("objects", metadata, sa.Column("id", sa.Integer, primary_key=True), sa.Column('name', sa.String(128), nullable=False), sa.Column('class_name', sa.String(128), nullable=False), sa.UniqueConstraint('name', 'class_name', name='object_identity'), ) objects.create() object_state = sa.Table("object_state", metadata, sa.Column("objectid", sa.Integer, sa.ForeignKey('objects.id'), nullable=False), sa.Column("name", sa.String(length=256), nullable=False), sa.Column("value_json", sa.Text, nullable=False), sa.UniqueConstraint('objectid', 'name', name='name_per_object'), ) object_state.create() buildbot-0.8.8/buildbot/db/migrate/versions/008_add_scheduler_changes_index.py000066400000000000000000000023121222546025000274260ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine scheduler_changes = sa.Table('scheduler_changes', metadata, sa.Column('schedulerid', sa.Integer), sa.Column('changeid', sa.Integer), sa.Column('important', sa.SmallInteger), ) idx = sa.Index('scheduler_changes_unique', scheduler_changes.c.schedulerid, scheduler_changes.c.changeid, unique=True) idx.create(migrate_engine) buildbot-0.8.8/buildbot/db/migrate/versions/009_add_patch_author.py000066400000000000000000000026701222546025000252620ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine # add patch_author and patch_comment to the patches table # mysql doesn't like default values on these columns defaults = {} if migrate_engine.dialect.name != "mysql": defaults['server_default'] = sa.DefaultClause('') patches = sa.Table('patches', metadata, autoload=True) patch_author= sa.Column('patch_author', sa.Text, nullable=False, **defaults) patch_author.create(patches, populate_default=True) patch_author= sa.Column('patch_comment', sa.Text, nullable=False, **defaults) patch_author.create(patches, populate_default=True) buildbot-0.8.8/buildbot/db/migrate/versions/010_fix_column_lengths.py000066400000000000000000000050211222546025000256410ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa from migrate import changeset def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine # the old (non-sqlalchemy-migrate) migration scripts messed up the # lengths of these columns, so fix them here. changeset.alter_column( sa.Column('class_name', sa.String(128), nullable=False), table="schedulers", metadata=metadata, engine=migrate_engine) changeset.alter_column( sa.Column('name', sa.String(128), nullable=False), table="schedulers", metadata=metadata, engine=migrate_engine) # sqlalchemy's reflection gets the server_defaults wrong, so this # table has to be included here. changes = sa.Table('changes', metadata, sa.Column('changeid', sa.Integer, primary_key=True), sa.Column('author', sa.String(256), nullable=False), sa.Column('comments', sa.String(1024), nullable=False), sa.Column('is_dir', sa.SmallInteger, nullable=False), sa.Column('branch', sa.String(256)), sa.Column('revision', sa.String(256)), sa.Column('revlink', sa.String(256)), sa.Column('when_timestamp', sa.Integer, nullable=False), sa.Column('category', sa.String(256)), sa.Column('repository', sa.String(length=512), nullable=False, server_default=''), sa.Column('project', sa.String(length=512), nullable=False, server_default=''), ) changeset.alter_column( sa.Column('author', sa.String(256), nullable=False), table=changes, metadata=metadata, engine=migrate_engine) changeset.alter_column( sa.Column('branch', sa.String(256)), table=changes, metadata=metadata, engine=migrate_engine) buildbot-0.8.8/buildbot/db/migrate/versions/011_add_buildrequest_claims.py000066400000000000000000000120021222546025000266200ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa import migrate from buildbot.util import sautils def migrate_claims(migrate_engine, metadata, buildrequests, objects, buildrequest_claims): # First, ensure there is an object row for each master null_id = sa.null().label('id') if migrate_engine.dialect.name == 'postgresql': # postgres needs NULL cast to an integer: null_id = sa.cast(null_id, sa.INTEGER) new_objects = sa.select([ null_id, buildrequests.c.claimed_by_name.label("name"), sa.literal_column("'BuildMaster'").label("class_name"), ], whereclause=buildrequests.c.claimed_by_name != None, distinct=True) # this doesn't seem to work without str() -- verified in sqla 0.6.0 - 0.7.1 migrate_engine.execute( str(sautils.InsertFromSelect(objects, new_objects))) # now make a buildrequest_claims row for each claimed build request join = buildrequests.join(objects, (buildrequests.c.claimed_by_name == objects.c.name) # (have to use sa.text because str, below, doesn't work # with placeholders) & (objects.c.class_name == sa.text("'BuildMaster'"))) claims = sa.select([ buildrequests.c.id.label('brid'), objects.c.id.label('objectid'), buildrequests.c.claimed_at, ], from_obj=[ join ], whereclause=buildrequests.c.claimed_by_name != None) migrate_engine.execute( str(sautils.InsertFromSelect(buildrequest_claims, claims))) def drop_columns(metadata, buildrequests): # sqlalchemy-migrate <0.7.0 has a bug with sqlalchemy >=0.7.0, where # it tries to change an immutable column; this is the workaround, from # http://code.google.com/p/sqlalchemy-migrate/issues/detail?id=112 if not sa.__version__.startswith('0.6.'): if not hasattr(migrate, '__version__'): # that is, older than 0.7 buildrequests.columns = buildrequests._columns buildrequests.c.claimed_at.drop() buildrequests.c.claimed_by_name.drop() buildrequests.c.claimed_by_incarnation.drop() def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine # a copy of the buildrequests table, but with the foreign keys stripped buildrequests = sa.Table('buildrequests', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('buildsetid', sa.Integer, nullable=False), sa.Column('buildername', sa.String(length=256), nullable=False), sa.Column('priority', sa.Integer, nullable=False, server_default=sa.DefaultClause("0")), sa.Column('claimed_at', sa.Integer, server_default=sa.DefaultClause("0")), sa.Column('claimed_by_name', sa.String(length=256)), sa.Column('claimed_by_incarnation', sa.String(length=256)), sa.Column('complete', sa.Integer, server_default=sa.DefaultClause("0")), sa.Column('results', sa.SmallInteger), sa.Column('submitted_at', sa.Integer, nullable=False), sa.Column('complete_at', sa.Integer), ) # existing objects table, used as a foreign key objects = sa.Table("objects", metadata, # unique ID for this object sa.Column("id", sa.Integer, primary_key=True), # object's user-given name sa.Column('name', sa.String(128), nullable=False), # object's class name, basically representing a "type" for the state sa.Column('class_name', sa.String(128), nullable=False), # prohibit multiple id's for the same object sa.UniqueConstraint('name', 'class_name', name='object_identity'), ) # and a new buildrequest_claims table buildrequest_claims = sa.Table('buildrequest_claims', metadata, sa.Column('brid', sa.Integer, sa.ForeignKey('buildrequests.id'), index=True, unique=True), sa.Column('objectid', sa.Integer, sa.ForeignKey('objects.id'), index=True, nullable=True), sa.Column('claimed_at', sa.Integer, nullable=False), ) # create the new table buildrequest_claims.create() # migrate the claims into that table migrate_claims(migrate_engine, metadata, buildrequests, objects, buildrequest_claims) # and drop the claim-related columns in buildrequests drop_columns(metadata, buildrequests) buildbot-0.8.8/buildbot/db/migrate/versions/012_add_users_table.py000066400000000000000000000046161222546025000251050ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine # what defines a user users = sa.Table("users", metadata, sa.Column("uid", sa.Integer, primary_key=True), sa.Column("identifier", sa.String(256), nullable=False), ) users.create() idx = sa.Index('users_identifier', users.c.identifier) idx.create() # ways buildbot knows about users users_info = sa.Table("users_info", metadata, sa.Column("uid", sa.Integer, sa.ForeignKey('users.uid'), nullable=False), sa.Column("attr_type", sa.String(128), nullable=False), sa.Column("attr_data", sa.String(128), nullable=False) ) users_info.create() idx = sa.Index('users_info_uid', users_info.c.uid) idx.create() idx = sa.Index('users_info_uid_attr_type', users_info.c.uid, users_info.c.attr_type, unique=True) idx.create() idx = sa.Index('users_info_attrs', users_info.c.attr_type, users_info.c.attr_data, unique=True) idx.create() # correlates change authors and user uids sa.Table('changes', metadata, autoload=True) change_users = sa.Table("change_users", metadata, sa.Column("changeid", sa.Integer, sa.ForeignKey('changes.changeid'), nullable=False), sa.Column("uid", sa.Integer, sa.ForeignKey('users.uid'), nullable=False) ) change_users.create() idx = sa.Index('change_users_changeid', change_users.c.changeid) idx.create() # note that existing changes are not added to the users table; this would # be *very* time-consuming and would not be helpful to the vast majority of # users. buildbot-0.8.8/buildbot/db/migrate/versions/013_remove_schedulers_state_column.py000066400000000000000000000030011222546025000302440ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa from migrate import changeset def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine # Specify what the new table should look like schedulers = sa.Table("schedulers", metadata, # unique ID for scheduler sa.Column('schedulerid', sa.Integer, primary_key=True), # TODO: rename to id # scheduler's name in master.cfg sa.Column('name', sa.String(128), nullable=False), # scheduler's class name, basically representing a "type" for the state sa.Column('class_name', sa.String(128), nullable=False), ) # Now drop column changeset.drop_column( sa.Column('state', sa.String(128), nullable=False), table=schedulers, metadata=metadata, engine=migrate_engine) buildbot-0.8.8/buildbot/db/migrate/versions/014_add_users_userpass_columns.py000066400000000000000000000022101222546025000274110ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine users_table = sa.Table('users', metadata, autoload=True) username = sa.Column('bb_username', sa.String(128)) username.create(users_table) password = sa.Column('bb_password', sa.String(128)) password.create(users_table) idx = sa.Index('users_bb_user', users_table.c.bb_username, unique=True) idx.create() buildbot-0.8.8/buildbot/db/migrate/versions/015_remove_bad_master_objectid.py000066400000000000000000000055071222546025000273110ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine # older build masters stored some of their state with an object named # 'master' with class 'buildbot.master.BuildMaster', while other state was # stored with objects named after each master itself, with class # BuildMaster. objects_table = sa.Table('objects', metadata, autoload=True) object_state_table = sa.Table('object_state', metadata, autoload=True) # get the old, unwanted ID q = sa.select([ objects_table.c.id ], whereclause=(objects_table.c.name=='master') & (objects_table.c.class_name == 'buildbot.master.BuildMaster')) res = q.execute() old_id = res.scalar() # if there's no such ID, there's nothing to change if old_id is not None: # get the new ID q = sa.select([ objects_table.c.id ], whereclause=objects_table.c.class_name == 'BuildMaster') res = q.execute() ids = res.fetchall() # if there is exactly one ID, update the existing object_states. If # there are zero or multiple object_states, then we do not know which # master to assign last_processed_change to, so we just delete it. # This indicates to the master that it has processed all changes, which # is probably accurate. if len(ids) == 1: new_id = ids[0][0] # update rows with the old id to use the new id q = object_state_table.update( whereclause=(object_state_table.c.objectid == old_id)) q.execute(objectid=new_id) else: q = object_state_table.delete( whereclause=(object_state_table.c.objectid == old_id)) q.execute() # in either case, delete the old object row q = objects_table.delete( whereclause=(objects_table.c.id == old_id)) q.execute() # and update the class name for the new rows q = objects_table.update( whereclause=(objects_table.c.class_name == 'BuildMaster')) q.execute(class_name='buildbot.master.BuildMaster') buildbot-0.8.8/buildbot/db/migrate/versions/016_restore_buildrequest_indices.py000066400000000000000000000030421222546025000277320ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine # the column drops in 011_add_buildrequest_claims.py unfortunately # also drop a great deal of other stuff on sqlite. In particular, all # indexes and foreign keys. # # The foreign keys do not matter anyway - SQLite tracks them but ignores # them. The indices, however, are important, so they are re-added here, # but only for the sqlite dialect. if migrate_engine.dialect.name != 'sqlite': return buildrequests = sa.Table('buildrequests', metadata, autoload=True) sa.Index('buildrequests_buildsetid', buildrequests.c.buildsetid).create() sa.Index('buildrequests_buildername', buildrequests.c.buildername).create() sa.Index('buildrequests_complete', buildrequests.c.complete).create() buildbot-0.8.8/buildbot/db/migrate/versions/017_restore_other_indices.py000066400000000000000000000056541222546025000263570ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine # the column changes in 010_fix_column_lengths.py unfortunately also drop a # great deal of other stuff on sqlite. In particular, all indexes and # foreign keys on the 'changes' and 'schedulers' tables. # # The foreign keys do not matter anyway - SQLite tracks them but ignores # them. The indices, however, are important, so they are re-added here, # but only for the sqlite dialect. if migrate_engine.dialect.name == 'sqlite': schedulers = sa.Table('schedulers', metadata, autoload=True) sa.Index('name_and_class', schedulers.c.name, schedulers.c.class_name).create() changes = sa.Table('changes', metadata, autoload=True) sa.Index('changes_branch', changes.c.branch).create() sa.Index('changes_revision', changes.c.revision).create() sa.Index('changes_author', changes.c.author).create() sa.Index('changes_category', changes.c.category).create() sa.Index('changes_when_timestamp', changes.c.when_timestamp).create() # These were implemented as UniqueConstraint objects, which are # recognized as indexes on non-sqlite DB's. So add them as explicit # indexes on sqlite. objects = sa.Table('objects', metadata, autoload=True) sa.Index('object_identity', objects.c.name, objects.c.class_name, unique=True).create() object_state = sa.Table('object_state', metadata, autoload=True) sa.Index('name_per_object', object_state.c.objectid, object_state.c.name, unique=True).create() # Due to a coding bug in version 012, the users_identifier index is not # unique (on any DB). SQLAlchemy-migrate does not provide an interface to # drop columns, so we fake it here. users = sa.Table('users', metadata, autoload=True) dialect = migrate_engine.dialect.name if dialect in ('sqlite', 'postgresql'): migrate_engine.execute("DROP INDEX users_identifier") elif dialect == 'mysql': migrate_engine.execute("DROP INDEX users_identifier ON users") sa.Index('users_identifier', users.c.identifier, unique=True).create() buildbot-0.8.8/buildbot/db/migrate/versions/018_add_sourcestampset.py000066400000000000000000000053471222546025000256660ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa from migrate.changeset import constraint from buildbot.util import sautils def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine sourcestamps_table = sa.Table('sourcestamps', metadata, autoload=True) buildsets_table = sa.Table('buildsets', metadata, autoload=True) # Create the sourcestampset table # that defines a sourcestampset sourcestampsets_table = sa.Table("sourcestampsets", metadata, sa.Column("id", sa.Integer, primary_key=True), ) sourcestampsets_table.create() # All current sourcestampid's are migrated to sourcestampsetid # Insert all sourcestampid's as setid's into sourcestampsets table sourcestampsetids = sa.select([sourcestamps_table.c.id]) # this doesn't seem to work without str() -- verified in sqla 0.6.0 - 0.7.1 migrate_engine.execute(str(sautils.InsertFromSelect(sourcestampsets_table, sourcestampsetids))) # rename the buildsets table column buildsets_table.c.sourcestampid.alter(name='sourcestampsetid') metadata.remove(buildsets_table) buildsets_table = sa.Table('buildsets', metadata, autoload=True) cons = constraint.ForeignKeyConstraint([buildsets_table.c.sourcestampsetid], [sourcestampsets_table.c.id]) cons.create() # Add sourcestampsetid including index to sourcestamps table ss_sourcestampsetid = sa.Column('sourcestampsetid', sa.Integer) ss_sourcestampsetid.create(sourcestamps_table) # Update the setid to the same value as sourcestampid migrate_engine.execute(str(sourcestamps_table.update().values(sourcestampsetid=sourcestamps_table.c.id))) ss_sourcestampsetid.alter(nullable=False) # Data is up to date, now force integrity cons = constraint.ForeignKeyConstraint([sourcestamps_table.c.sourcestampsetid], [sourcestampsets_table.c.id]) cons.create() # Add index for performance reasons to find all sourcestamps in a set quickly idx = sa.Index('sourcestamps_sourcestampsetid', sourcestamps_table.c.sourcestampsetid, unique=False) idx.create() buildbot-0.8.8/buildbot/db/migrate/versions/019_merge_schedulers_to_objects.py000066400000000000000000000051171222546025000275240ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine # autoload the tables that are only referenced here sa.Table('changes', metadata, autoload=True) sa.Table('buildsets', metadata, autoload=True) sa.Table("objects", metadata, autoload=True) # drop all tables. Schedulers will re-populate on startup scheduler_changes_tbl = sa.Table('scheduler_changes', metadata, sa.Column('schedulerid', sa.Integer), # ... ) scheduler_changes_tbl.drop() metadata.remove(scheduler_changes_tbl) scheduler_upstream_buildsets_tbl = sa.Table('scheduler_upstream_buildsets', metadata, sa.Column('buildsetid', sa.Integer), # ... ) scheduler_upstream_buildsets_tbl.drop() metadata.remove(scheduler_upstream_buildsets_tbl) schedulers_tbl = sa.Table("schedulers", metadata, sa.Column('schedulerid', sa.Integer), # ... ) schedulers_tbl.drop() metadata.remove(schedulers_tbl) # schedulers and scheduler_upstream_buildsets aren't coming back, but # scheduler_changes is -- along with its indexes scheduler_changes_tbl = sa.Table('scheduler_changes', metadata, sa.Column('objectid', sa.Integer, sa.ForeignKey('objects.id')), sa.Column('changeid', sa.Integer, sa.ForeignKey('changes.changeid')), sa.Column('important', sa.Integer), ) scheduler_changes_tbl.create() idx = sa.Index('scheduler_changes_objectid', scheduler_changes_tbl.c.objectid) idx.create() idx = sa.Index('scheduler_changes_changeid', scheduler_changes_tbl.c.changeid) idx.create() idx = sa.Index('scheduler_changes_unique', scheduler_changes_tbl.c.objectid, scheduler_changes_tbl.c.changeid, unique=True) idx.create() buildbot-0.8.8/buildbot/db/migrate/versions/020_remove_change_links.py000066400000000000000000000016041222546025000257600ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine tbl = sa.Table('change_links', metadata, autoload=True) tbl.drop() buildbot-0.8.8/buildbot/db/migrate/versions/021_fix_postgres_sequences.py000066400000000000000000000032011222546025000265410ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): # see bug #2119 # this only applies to postgres if migrate_engine.dialect.name != 'postgresql': return metadata = sa.MetaData() metadata.bind = migrate_engine to_fix = [ 'buildrequests.id', 'builds.id', 'buildsets.id', 'changes.changeid', 'patches.id', 'sourcestampsets.id', 'sourcestamps.id', 'objects.id', 'users.uid', ] for col in to_fix: tbl_name, col_name = col.split('.') tbl = sa.Table(tbl_name, metadata, autoload=True) col = tbl.c[col_name] res = migrate_engine.execute(sa.select([ sa.func.max(col) ])) max = res.fetchall()[0][0] if max: seq_name = "%s_%s_seq" % (tbl_name, col_name) r = migrate_engine.execute("SELECT setval('%s', %d)" % (seq_name, max)) r.close() buildbot-0.8.8/buildbot/db/migrate/versions/022_add_codebase.py000066400000000000000000000024621222546025000243400ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa def upgrade(migrate_engine): metadata = sa.MetaData() metadata.bind = migrate_engine sourcestamps_table = sa.Table('sourcestamps', metadata, autoload=True) changes_table = sa.Table('changes', metadata, autoload=True) # Add codebase to tables ss_codebase = sa.Column('codebase', sa.String(length=256), nullable=False, server_default=sa.DefaultClause("")) ss_codebase.create(sourcestamps_table) c_codebase = sa.Column('codebase', sa.String(length=256), nullable=False, server_default=sa.DefaultClause("")) c_codebase.create(changes_table) buildbot-0.8.8/buildbot/db/migrate/versions/__init__.py000066400000000000000000000000001222546025000231210ustar00rootroot00000000000000buildbot-0.8.8/buildbot/db/model.py000066400000000000000000000537311222546025000172050ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa import migrate import migrate.versioning.schema import migrate.versioning.repository from twisted.python import util, log from buildbot.db import base try: from migrate.versioning import exceptions _hush_pyflakes = exceptions except ImportError: from migrate import exceptions class Model(base.DBConnectorComponent): # # schema # metadata = sa.MetaData() # NOTES # * server_defaults here are included to match those added by the migration # scripts, but they should not be depended on - all code accessing these # tables should supply default values as necessary. The defaults are # required during migration when adding non-nullable columns to existing # tables. # # * dates are stored as unix timestamps (UTC-ish epoch time) # # * sqlalchemy does not handle sa.Boolean very well on MySQL or Postgres; # use sa.Integer instead # build requests # A BuildRequest is a request for a particular build to be performed. Each # BuildRequest is a part of a Buildset. BuildRequests are claimed by # masters, to avoid multiple masters running the same build. buildrequests = sa.Table('buildrequests', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('buildsetid', sa.Integer, sa.ForeignKey("buildsets.id"), nullable=False), sa.Column('buildername', sa.String(length=256), nullable=False), sa.Column('priority', sa.Integer, nullable=False, server_default=sa.DefaultClause("0")), # if this is zero, then the build is still pending sa.Column('complete', sa.Integer, server_default=sa.DefaultClause("0")), # results is only valid when complete == 1; 0 = SUCCESS, 1 = WARNINGS, # etc - see master/buildbot/status/builder.py sa.Column('results', sa.SmallInteger), # time the buildrequest was created sa.Column('submitted_at', sa.Integer, nullable=False), # time the buildrequest was completed, or NULL sa.Column('complete_at', sa.Integer), ) # Each row in this table represents a claimed build request, where the # claim is made by the object referenced by objectid. buildrequest_claims = sa.Table('buildrequest_claims', metadata, sa.Column('brid', sa.Integer, sa.ForeignKey('buildrequests.id'), index=True, unique=True), sa.Column('objectid', sa.Integer, sa.ForeignKey('objects.id'), index=True, nullable=True), sa.Column('claimed_at', sa.Integer, nullable=False), ) # builds # This table contains basic information about each build. Note that most # data about a build is still stored in on-disk pickles. builds = sa.Table('builds', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('number', sa.Integer, nullable=False), sa.Column('brid', sa.Integer, sa.ForeignKey('buildrequests.id'), nullable=False), sa.Column('start_time', sa.Integer, nullable=False), sa.Column('finish_time', sa.Integer), ) # buildsets # This table contains input properties for buildsets buildset_properties = sa.Table('buildset_properties', metadata, sa.Column('buildsetid', sa.Integer, sa.ForeignKey('buildsets.id'), nullable=False), sa.Column('property_name', sa.String(256), nullable=False), # JSON-encoded tuple of (value, source) sa.Column('property_value', sa.String(1024), nullable=False), ) # This table represents Buildsets - sets of BuildRequests that share the # same original cause and source information. buildsets = sa.Table('buildsets', metadata, sa.Column('id', sa.Integer, primary_key=True), # a simple external identifier to track down this buildset later, e.g., # for try requests sa.Column('external_idstring', sa.String(256)), # a short string giving the reason the buildset was created sa.Column('reason', sa.String(256)), sa.Column('submitted_at', sa.Integer, nullable=False), # if this is zero, then the build set is still pending sa.Column('complete', sa.SmallInteger, nullable=False, server_default=sa.DefaultClause("0")), sa.Column('complete_at', sa.Integer), # results is only valid when complete == 1; 0 = SUCCESS, 1 = WARNINGS, # etc - see master/buildbot/status/builder.py sa.Column('results', sa.SmallInteger), # buildset belongs to all sourcestamps with setid sa.Column('sourcestampsetid', sa.Integer, sa.ForeignKey('sourcestampsets.id')), ) # changes # Files touched in changes change_files = sa.Table('change_files', metadata, sa.Column('changeid', sa.Integer, sa.ForeignKey('changes.changeid'), nullable=False), sa.Column('filename', sa.String(1024), nullable=False), ) # Properties for changes change_properties = sa.Table('change_properties', metadata, sa.Column('changeid', sa.Integer, sa.ForeignKey('changes.changeid'), nullable=False), sa.Column('property_name', sa.String(256), nullable=False), # JSON-encoded tuple of (value, source) sa.Column('property_value', sa.String(1024), nullable=False), ) # users associated with this change; this allows multiple users for # situations where a version-control system can represent both an author # and committer, for example. change_users = sa.Table("change_users", metadata, sa.Column("changeid", sa.Integer, sa.ForeignKey('changes.changeid'), nullable=False), # uid for the author of the change with the given changeid sa.Column("uid", sa.Integer, sa.ForeignKey('users.uid'), nullable=False) ) # Changes to the source code, produced by ChangeSources changes = sa.Table('changes', metadata, # changeid also serves as 'change number' sa.Column('changeid', sa.Integer, primary_key=True), # author's name (usually an email address) sa.Column('author', sa.String(256), nullable=False), # commit comment sa.Column('comments', sa.String(1024), nullable=False), # old, CVS-related boolean sa.Column('is_dir', sa.SmallInteger, nullable=False), # old, for CVS # The branch where this change occurred. When branch is NULL, that # means the main branch (trunk, master, etc.) sa.Column('branch', sa.String(256)), # revision identifier for this change sa.Column('revision', sa.String(256)), # CVS uses NULL sa.Column('revlink', sa.String(256)), # this is the timestamp of the change - it is usually copied from the # version-control system, and may be long in the past or even in the # future! sa.Column('when_timestamp', sa.Integer, nullable=False), # an arbitrary string used for filtering changes sa.Column('category', sa.String(256)), # repository specifies, along with revision and branch, the # source tree in which this change was detected. sa.Column('repository', sa.String(length=512), nullable=False, server_default=''), # codebase is a logical name to specify what is in the repository sa.Column('codebase', sa.String(256), nullable=False, server_default=sa.DefaultClause("")), # project names the project this source code represents. It is used # later to filter changes sa.Column('project', sa.String(length=512), nullable=False, server_default=''), ) # sourcestamps # Patches for SourceStamps that were generated through the try mechanism patches = sa.Table('patches', metadata, sa.Column('id', sa.Integer, primary_key=True), # number of directory levels to strip off (patch -pN) sa.Column('patchlevel', sa.Integer, nullable=False), # base64-encoded version of the patch file sa.Column('patch_base64', sa.Text, nullable=False), # patch author, if known sa.Column('patch_author', sa.Text, nullable=False), # patch comment sa.Column('patch_comment', sa.Text, nullable=False), # subdirectory in which the patch should be applied; NULL for top-level sa.Column('subdir', sa.Text), ) # The changes that led up to a particular source stamp. sourcestamp_changes = sa.Table('sourcestamp_changes', metadata, sa.Column('sourcestampid', sa.Integer, sa.ForeignKey('sourcestamps.id'), nullable=False), sa.Column('changeid', sa.Integer, sa.ForeignKey('changes.changeid'), nullable=False), ) # A sourcestampset identifies a set of sourcestamps. A sourcestamp belongs # to a particular set if the sourcestamp has the same setid sourcestampsets = sa.Table('sourcestampsets', metadata, sa.Column('id', sa.Integer, primary_key=True), ) # A sourcestamp identifies a particular instance of the source code. # Ideally, this would always be absolute, but in practice source stamps can # also mean "latest" (when revision is NULL), which is of course a # time-dependent definition. sourcestamps = sa.Table('sourcestamps', metadata, sa.Column('id', sa.Integer, primary_key=True), # the branch to check out. When branch is NULL, that means # the main branch (trunk, master, etc.) sa.Column('branch', sa.String(256)), # the revision to check out, or the latest if NULL sa.Column('revision', sa.String(256)), # the patch to apply to generate this source code sa.Column('patchid', sa.Integer, sa.ForeignKey('patches.id')), # the repository from which this source should be checked out sa.Column('repository', sa.String(length=512), nullable=False, server_default=''), # codebase is a logical name to specify what is in the repository sa.Column('codebase', sa.String(256), nullable=False, server_default=sa.DefaultClause("")), # the project this source code represents sa.Column('project', sa.String(length=512), nullable=False, server_default=''), # each sourcestamp belongs to a set of sourcestamps sa.Column('sourcestampsetid', sa.Integer, sa.ForeignKey('sourcestampsets.id')), ) # schedulers # This table references "classified" changes that have not yet been # "processed". That is, the scheduler has looked at these changes and # determined that something should be done, but that hasn't happened yet. # Rows are deleted from this table as soon as the scheduler is done with # the change. scheduler_changes = sa.Table('scheduler_changes', metadata, sa.Column('objectid', sa.Integer, sa.ForeignKey('objects.id')), sa.Column('changeid', sa.Integer, sa.ForeignKey('changes.changeid')), # true (nonzero) if this change is important to this scheduler sa.Column('important', sa.Integer), ) # objects # This table uniquely identifies objects that need to maintain state across # invocations. objects = sa.Table("objects", metadata, # unique ID for this object sa.Column("id", sa.Integer, primary_key=True), # object's user-given name sa.Column('name', sa.String(128), nullable=False), # object's class name, basically representing a "type" for the state sa.Column('class_name', sa.String(128), nullable=False), ) # This table stores key/value pairs for objects, where the key is a string # and the value is a JSON string. object_state = sa.Table("object_state", metadata, # object for which this value is set sa.Column("objectid", sa.Integer, sa.ForeignKey('objects.id'), nullable=False), # name for this value (local to the object) sa.Column("name", sa.String(length=256), nullable=False), # value, as a JSON string sa.Column("value_json", sa.Text, nullable=False), ) #users # This table identifies individual users, and contains buildbot-specific # information about those users. users = sa.Table("users", metadata, # unique user id number sa.Column("uid", sa.Integer, primary_key=True), # identifier (nickname) for this user; used for display sa.Column("identifier", sa.String(256), nullable=False), # username portion of user credentials for authentication sa.Column("bb_username", sa.String(128)), # password portion of user credentials for authentication sa.Column("bb_password", sa.String(128)), ) # This table stores information identifying a user that's related to a # particular interface - a version-control system, status plugin, etc. users_info = sa.Table("users_info", metadata, # unique user id number sa.Column("uid", sa.Integer, sa.ForeignKey('users.uid'), nullable=False), # type of user attribute, such as 'git' sa.Column("attr_type", sa.String(128), nullable=False), # data for given user attribute, such as a commit string or password sa.Column("attr_data", sa.String(128), nullable=False), ) # indexes sa.Index('buildrequests_buildsetid', buildrequests.c.buildsetid) sa.Index('buildrequests_buildername', buildrequests.c.buildername) sa.Index('buildrequests_complete', buildrequests.c.complete) sa.Index('builds_number', builds.c.number) sa.Index('builds_brid', builds.c.brid) sa.Index('buildsets_complete', buildsets.c.complete) sa.Index('buildsets_submitted_at', buildsets.c.submitted_at) sa.Index('buildset_properties_buildsetid', buildset_properties.c.buildsetid) sa.Index('changes_branch', changes.c.branch) sa.Index('changes_revision', changes.c.revision) sa.Index('changes_author', changes.c.author) sa.Index('changes_category', changes.c.category) sa.Index('changes_when_timestamp', changes.c.when_timestamp) sa.Index('change_files_changeid', change_files.c.changeid) sa.Index('change_properties_changeid', change_properties.c.changeid) sa.Index('scheduler_changes_objectid', scheduler_changes.c.objectid) sa.Index('scheduler_changes_changeid', scheduler_changes.c.changeid) sa.Index('scheduler_changes_unique', scheduler_changes.c.objectid, scheduler_changes.c.changeid, unique=True) sa.Index('sourcestamp_changes_sourcestampid', sourcestamp_changes.c.sourcestampid) sa.Index('sourcestamps_sourcestampsetid', sourcestamps.c.sourcestampsetid, unique=False) sa.Index('users_identifier', users.c.identifier, unique=True) sa.Index('users_info_uid', users_info.c.uid) sa.Index('users_info_uid_attr_type', users_info.c.uid, users_info.c.attr_type, unique=True) sa.Index('users_info_attrs', users_info.c.attr_type, users_info.c.attr_data, unique=True) sa.Index('change_users_changeid', change_users.c.changeid) sa.Index('users_bb_user', users.c.bb_username, unique=True) sa.Index('object_identity', objects.c.name, objects.c.class_name, unique=True) sa.Index('name_per_object', object_state.c.objectid, object_state.c.name, unique=True) # MySQl creates indexes for foreign keys, and these appear in the # reflection. This is a list of (table, index) names that should be # expected on this platform implied_indexes = [ ('change_users', dict(unique=False, column_names=['uid'], name='uid')), ('sourcestamps', dict(unique=False, column_names=['patchid'], name='patchid')), ('sourcestamp_changes', dict(unique=False, column_names=['changeid'], name='changeid')), ('buildsets', dict(unique=False, column_names=['sourcestampsetid'], name='buildsets_sourcestampsetid_fkey')), ] # # migration support # # this is a bit more complicated than might be expected because the first # seven database versions were once implemented using a homespun migration # system, and we need to support upgrading masters from that system. The # old system used a 'version' table, where SQLAlchemy-Migrate uses # 'migrate_version' repo_path = util.sibpath(__file__, "migrate") def is_current(self): def thd(engine): # we don't even have to look at the old version table - if there's # no migrate_version, then we're not up to date. repo = migrate.versioning.repository.Repository(self.repo_path) repo_version = repo.latest try: # migrate.api doesn't let us hand in an engine schema = migrate.versioning.schema.ControlledSchema(engine, self.repo_path) db_version = schema.version except exceptions.DatabaseNotControlledError: return False return db_version == repo_version return self.db.pool.do_with_engine(thd) def upgrade(self): # here, things are a little tricky. If we have a 'version' table, then # we need to version_control the database with the proper version # number, drop 'version', and then upgrade. If we have no 'version' # table and no 'migrate_version' table, then we need to version_control # the database. Otherwise, we just need to upgrade it. def table_exists(engine, tbl): try: r = engine.execute("select * from %s limit 1" % tbl) r.close() return True except: return False # http://code.google.com/p/sqlalchemy-migrate/issues/detail?id=100 # means we cannot use the migrate.versioning.api module. So these # methods perform similar wrapping functions to what is done by the API # functions, but without disposing of the engine. def upgrade(engine): schema = migrate.versioning.schema.ControlledSchema(engine, self.repo_path) changeset = schema.changeset(None) for version, change in changeset: log.msg('migrating schema version %s -> %d' % (version, version + 1)) schema.runchange(version, change, 1) def check_sqlalchemy_migrate_version(): # sqlalchemy-migrate started including a version number in 0.7; we # support back to 0.6.1, but not 0.6. We'll use some discovered # differences between 0.6.1 and 0.6 to get that resolution. version = getattr(migrate, '__version__', 'old') if version == 'old': try: from migrate.versioning import schemadiff if hasattr(schemadiff, 'ColDiff'): version = "0.6.1" else: version = "0.6" except: version = "0.0" version_tup = tuple(map(int, version.split('.'))) log.msg("using SQLAlchemy-Migrate version %s" % (version,)) if version_tup < (0,6,1): raise RuntimeError("You are using SQLAlchemy-Migrate %s. " "The minimum version is 0.6.1." % (version,)) def version_control(engine, version=None): migrate.versioning.schema.ControlledSchema.create(engine, self.repo_path, version) # the upgrade process must run in a db thread def thd(engine): # if the migrate_version table exists, we can just let migrate # take care of this process. if table_exists(engine, 'migrate_version'): upgrade(engine) # if the version table exists, then we can version_control things # at that version, drop the version table, and let migrate take # care of the rest. elif table_exists(engine, 'version'): # get the existing version r = engine.execute("select version from version limit 1") old_version = r.scalar() # set up migrate at the same version version_control(engine, old_version) # drop the no-longer-required version table, using a dummy # metadata entry table = sa.Table('version', self.metadata, sa.Column('x', sa.Integer)) table.drop(bind=engine) # clear the dummy metadata entry self.metadata.remove(table) # and, finally, upgrade using migrate upgrade(engine) # otherwise, this db is uncontrolled, so we just version control it # and update it. else: version_control(engine) upgrade(engine) check_sqlalchemy_migrate_version() return self.db.pool.do_with_engine(thd) # migrate has a bug in one of its warnings; this is fixed in version control # (3ba66abc4d), but not yet released. It can't hurt to fix it here, too, so we # get realistic tracebacks try: import migrate.versioning.exceptions as ex1 import migrate.changeset.exceptions as ex2 ex1.MigrateDeprecationWarning = ex2.MigrateDeprecationWarning except (ImportError,AttributeError): pass buildbot-0.8.8/buildbot/db/pool.py000066400000000000000000000247041222546025000170540ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import time import traceback import inspect import shutil import os import sqlalchemy as sa import tempfile from buildbot.process import metrics from twisted.internet import reactor, threads from twisted.python import threadpool, log # set this to True for *very* verbose query debugging output; this can # be monkey-patched from master.cfg, too: # from buildbot.db import pool # pool.debug = True debug = False _debug_id = 1 def timed_do_fn(f): """Decorate a do function to log before, after, and elapsed time, with the name of the calling function. This is not speedy!""" def wrap(callable, *args, **kwargs): global _debug_id # get a description of the function that called us st = traceback.extract_stack(limit=2) file, line, name, _ = st[0] # and its locals frame = inspect.currentframe(1) locals = frame.f_locals # invent a unique ID for the description id, _debug_id = _debug_id, _debug_id+1 descr = "%s-%08x" % (name, id) start_time = time.time() log.msg("%s - before ('%s' line %d)" % (descr, file, line)) for name in locals: if name in ('self', 'thd'): continue log.msg("%s - %s = %r" % (descr, name, locals[name])) # wrap the callable to log the begin and end of the actual thread # function def callable_wrap(*args, **kargs): log.msg("%s - thd start" % (descr,)) try: return callable(*args, **kwargs) finally: log.msg("%s - thd end" % (descr,)) d = f(callable_wrap, *args, **kwargs) def after(x): end_time = time.time() elapsed = (end_time - start_time) * 1000 log.msg("%s - after (%0.2f ms elapsed)" % (descr, elapsed)) return x d.addBoth(after) return d wrap.__name__ = f.__name__ wrap.__doc__ = f.__doc__ return wrap class DBThreadPool(threadpool.ThreadPool): running = False # Some versions of SQLite incorrectly cache metadata about which tables are # and are not present on a per-connection basis. This cache can be flushed # by querying the sqlite_master table. We currently assume all versions of # SQLite have this bug, although it has only been observed in 3.4.2. A # dynamic check for this bug would be more appropriate. This is documented # in bug #1810. __broken_sqlite = None def __init__(self, engine, verbose=False): # verbose is used by upgrade scripts, and if it is set we should print # messages about versions and other warnings log_msg = log.msg if verbose: def log_msg(m): print m pool_size = 5 # If the engine has an C{optimal_thread_pool_size} attribute, then the # maxthreads of the thread pool will be set to that value. This is # most useful for SQLite in-memory connections, where exactly one # connection (and thus thread) should be used. if hasattr(engine, 'optimal_thread_pool_size'): pool_size = engine.optimal_thread_pool_size threadpool.ThreadPool.__init__(self, minthreads=1, maxthreads=pool_size, name='DBThreadPool') self.engine = engine if engine.dialect.name == 'sqlite': vers = self.get_sqlite_version() if vers < (3,7): log_msg("Using SQLite Version %s" % (vers,)) log_msg("NOTE: this old version of SQLite does not support " "WAL journal mode; a busy master may encounter " "'Database is locked' errors. Consider upgrading.") if vers < (3,4): log_msg("NOTE: this old version of SQLite is not " "supported.") raise RuntimeError("unsupported SQLite version") if self.__broken_sqlite is None: self.__class__.__broken_sqlite = self.detect_bug1810() brkn = self.__broken_sqlite if brkn: log_msg("Applying SQLite workaround from Buildbot bug #1810") self._start_evt = reactor.callWhenRunning(self._start) # patch the do methods to do verbose logging if necessary if debug: self.do = timed_do_fn(self.do) self.do_with_engine = timed_do_fn(self.do_with_engine) def _start(self): self._start_evt = None if not self.running: self.start() self._stop_evt = reactor.addSystemEventTrigger( 'during', 'shutdown', self._stop) self.running = True def _stop(self): self._stop_evt = None self.stop() self.engine.dispose() self.running = False def shutdown(self): """Manually stop the pool. This is only necessary from tests, as the pool will stop itself when the reactor stops under normal circumstances.""" if not self._stop_evt: return # pool is already stopped reactor.removeSystemEventTrigger(self._stop_evt) self._stop() # Try about 170 times over the space of a day, with the last few tries # being about an hour apart. This is designed to span a reasonable amount # of time for repairing a broken database server, while still failing # actual problematic queries eventually BACKOFF_START = 1.0 BACKOFF_MULT = 1.05 MAX_OPERATIONALERROR_TIME = 3600*24 # one day def __thd(self, with_engine, callable, args, kwargs): # try to call callable(arg, *args, **kwargs) repeatedly until no # OperationalErrors occur, where arg is either the engine (with_engine) # or a connection (not with_engine) backoff = self.BACKOFF_START start = time.time() while True: if with_engine: arg = self.engine else: arg = self.engine.contextual_connect() if self.__broken_sqlite: # see bug #1810 arg.execute("select * from sqlite_master") try: try: rv = callable(arg, *args, **kwargs) assert not isinstance(rv, sa.engine.ResultProxy), \ "do not return ResultProxy objects!" except sa.exc.OperationalError, e: text = e.orig.args[0] if not isinstance(text, basestring): raise if "Lost connection" in text \ or "database is locked" in text: # see if we've retried too much elapsed = time.time() - start if elapsed > self.MAX_OPERATIONALERROR_TIME: raise metrics.MetricCountEvent.log( "DBThreadPool.retry-on-OperationalError") log.msg("automatically retrying query after " "OperationalError (%ss sleep)" % backoff) # sleep (remember, we're in a thread..) time.sleep(backoff) backoff *= self.BACKOFF_MULT # and re-try continue else: raise finally: if not with_engine: arg.close() break return rv def do(self, callable, *args, **kwargs): return threads.deferToThreadPool(reactor, self, self.__thd, False, callable, args, kwargs) def do_with_engine(self, callable, *args, **kwargs): return threads.deferToThreadPool(reactor, self, self.__thd, True, callable, args, kwargs) def detect_bug1810(self): # detect buggy SQLite implementations; call only for a known-sqlite # dialect try: import pysqlite2.dbapi2 as sqlite sqlite = sqlite except ImportError: import sqlite3 as sqlite tmpdir = tempfile.mkdtemp() dbfile = os.path.join(tmpdir, "detect_bug1810.db") def test(select_from_sqlite_master=False): conn1 = None conn2 = None try: conn1 = sqlite.connect(dbfile) curs1 = conn1.cursor() curs1.execute("PRAGMA table_info('foo')") conn2 = sqlite.connect(dbfile) curs2 = conn2.cursor() curs2.execute("CREATE TABLE foo ( a integer )") if select_from_sqlite_master: curs1.execute("SELECT * from sqlite_master") curs1.execute("SELECT * from foo") finally: if conn1: conn1.close() if conn2: conn2.close() os.unlink(dbfile) try: test() except sqlite.OperationalError: # this is the expected error indicating it's broken shutil.rmtree(tmpdir) return True # but this version should not fail.. test(select_from_sqlite_master=True) shutil.rmtree(tmpdir) return False # not broken - no workaround required def get_sqlite_version(self): engine = sa.create_engine('sqlite://') conn = engine.contextual_connect() try: r = conn.execute("SELECT sqlite_version()") vers_row = r.fetchone() r.close() except: return (0,) if vers_row: try: return tuple(map(int, vers_row[0].split('.'))) except (TypeError, ValueError): return (0,) else: return (0,) buildbot-0.8.8/buildbot/db/schedulers.py000066400000000000000000000075621222546025000202470ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa import sqlalchemy.exc from buildbot.db import base class SchedulersConnectorComponent(base.DBConnectorComponent): # Documentation is in developer/database.rst def classifyChanges(self, objectid, classifications): def thd(conn): transaction = conn.begin() tbl = self.db.model.scheduler_changes ins_q = tbl.insert() upd_q = tbl.update( ((tbl.c.objectid == objectid) & (tbl.c.changeid == sa.bindparam('wc_changeid')))) for changeid, important in classifications.items(): # convert the 'important' value into an integer, since that # is the column type imp_int = important and 1 or 0 try: conn.execute(ins_q, objectid=objectid, changeid=changeid, important=imp_int) except (sqlalchemy.exc.ProgrammingError, sqlalchemy.exc.IntegrityError): # insert failed, so try an update conn.execute(upd_q, wc_changeid=changeid, important=imp_int) transaction.commit() return self.db.pool.do(thd) def flushChangeClassifications(self, objectid, less_than=None): def thd(conn): sch_ch_tbl = self.db.model.scheduler_changes wc = (sch_ch_tbl.c.objectid == objectid) if less_than is not None: wc = wc & (sch_ch_tbl.c.changeid < less_than) q = sch_ch_tbl.delete(whereclause=wc) conn.execute(q) return self.db.pool.do(thd) class Thunk: pass def getChangeClassifications(self, objectid, branch=Thunk, repository=Thunk, project=Thunk, codebase=Thunk): def thd(conn): sch_ch_tbl = self.db.model.scheduler_changes ch_tbl = self.db.model.changes wc = (sch_ch_tbl.c.objectid == objectid) # may need to filter further based on branch, etc extra_wheres = [] if branch is not self.Thunk: extra_wheres.append(ch_tbl.c.branch == branch) if repository is not self.Thunk: extra_wheres.append(ch_tbl.c.repository == repository) if project is not self.Thunk: extra_wheres.append(ch_tbl.c.project == project) if codebase is not self.Thunk: extra_wheres.append(ch_tbl.c.codebase == codebase) # if we need to filter further append those, as well as a join # on changeid (but just once for that one) if extra_wheres: wc &= (sch_ch_tbl.c.changeid == ch_tbl.c.changeid) for w in extra_wheres: wc &= w q = sa.select( [ sch_ch_tbl.c.changeid, sch_ch_tbl.c.important ], whereclause=wc) return dict([ (r.changeid, [False,True][r.important]) for r in conn.execute(q) ]) return self.db.pool.do(thd) buildbot-0.8.8/buildbot/db/sourcestamps.py000066400000000000000000000130421222546025000206240ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import base64 import sqlalchemy as sa from twisted.internet import defer from twisted.python import log from buildbot.db import base class SsDict(dict): pass class SsList(list): pass class SourceStampsConnectorComponent(base.DBConnectorComponent): # Documentation is in developer/database.rst def addSourceStamp(self, branch, revision, repository, project, sourcestampsetid, codebase='', patch_body=None, patch_level=0, patch_author="", patch_comment="", patch_subdir=None, changeids=[]): def thd(conn): transaction = conn.begin() # handle inserting a patch patchid = None if patch_body is not None: ins = self.db.model.patches.insert() r = conn.execute(ins, dict( patchlevel=patch_level, patch_base64=base64.b64encode(patch_body), patch_author=patch_author, patch_comment=patch_comment, subdir=patch_subdir)) patchid = r.inserted_primary_key[0] # insert the sourcestamp itself tbl = self.db.model.sourcestamps self.check_length(tbl.c.branch, branch) self.check_length(tbl.c.revision, revision) self.check_length(tbl.c.repository, repository) self.check_length(tbl.c.project, project) r = conn.execute(tbl.insert(), dict( branch=branch, revision=revision, patchid=patchid, repository=repository, codebase=codebase, project=project, sourcestampsetid=sourcestampsetid)) ssid = r.inserted_primary_key[0] # handle inserting change ids if changeids: ins = self.db.model.sourcestamp_changes.insert() conn.execute(ins, [ dict(sourcestampid=ssid, changeid=changeid) for changeid in changeids ]) transaction.commit() # and return the new ssid return ssid return self.db.pool.do(thd) @base.cached("sssetdicts") @defer.inlineCallbacks def getSourceStamps(self,sourcestampsetid): def getSourceStampIds(sourcestampsetid): def thd(conn): tbl = self.db.model.sourcestamps q = sa.select([tbl.c.id], whereclause=(tbl.c.sourcestampsetid == sourcestampsetid)) res = conn.execute(q) return [ row.id for row in res.fetchall() ] return self.db.pool.do(thd) ssids = yield getSourceStampIds(sourcestampsetid) sslist=SsList() for ssid in ssids: sourcestamp = yield self.getSourceStamp(ssid) sslist.append(sourcestamp) defer.returnValue(sslist) @base.cached("ssdicts") def getSourceStamp(self, ssid): def thd(conn): tbl = self.db.model.sourcestamps q = tbl.select(whereclause=(tbl.c.id == ssid)) res = conn.execute(q) row = res.fetchone() if not row: return None ssdict = SsDict(ssid=ssid, branch=row.branch, sourcestampsetid=row.sourcestampsetid, revision=row.revision, patch_body=None, patch_level=None, patch_author=None, patch_comment=None, patch_subdir=None, repository=row.repository, codebase=row.codebase, project=row.project, changeids=set([])) patchid = row.patchid res.close() # fetch the patch, if necessary if patchid is not None: tbl = self.db.model.patches q = tbl.select(whereclause=(tbl.c.id == patchid)) res = conn.execute(q) row = res.fetchone() if row: # note the subtle renaming here ssdict['patch_level'] = row.patchlevel ssdict['patch_subdir'] = row.subdir ssdict['patch_author'] = row.patch_author ssdict['patch_comment'] = row.patch_comment body = base64.b64decode(row.patch_base64) ssdict['patch_body'] = body else: log.msg('patchid %d, referenced from ssid %d, not found' % (patchid, ssid)) res.close() # fetch change ids tbl = self.db.model.sourcestamp_changes q = tbl.select(whereclause=(tbl.c.sourcestampid == ssid)) res = conn.execute(q) for row in res: ssdict['changeids'].add(row.changeid) res.close() return ssdict return self.db.pool.do(thd) buildbot-0.8.8/buildbot/db/sourcestampsets.py000066400000000000000000000023461222546025000213450ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.db import base class SourceStampSetsConnectorComponent(base.DBConnectorComponent): # Documentation is in developer/database.rst def addSourceStampSet(self): def thd(conn): # insert the sourcestampset. sourcestampset has no attributes, but # inserting a new row results in a new setid r = conn.execute(self.db.model.sourcestampsets.insert(), dict()) sourcestampsetid = r.inserted_primary_key[0] return sourcestampsetid return self.db.pool.do(thd) buildbot-0.8.8/buildbot/db/state.py000066400000000000000000000125131222546025000172160ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.util import json import sqlalchemy as sa import sqlalchemy.exc from buildbot.db import base class _IdNotFoundError(Exception): pass # used internally class ObjDict(dict): pass class StateConnectorComponent(base.DBConnectorComponent): # Documentation is in developer/database.rst def getObjectId(self, name, class_name): # defer to a cached method that only takes one parameter (a tuple) return self._getObjectId((name, class_name) ).addCallback(lambda objdict : objdict['id']) @base.cached('objectids') def _getObjectId(self, name_class_name_tuple): name, class_name = name_class_name_tuple def thd(conn): objects_tbl = self.db.model.objects self.check_length(objects_tbl.c.name, name) self.check_length(objects_tbl.c.class_name, class_name) def select(): q = sa.select([ objects_tbl.c.id ], whereclause=((objects_tbl.c.name == name) & (objects_tbl.c.class_name == class_name))) res = conn.execute(q) row = res.fetchone() res.close() if not row: raise _IdNotFoundError return row.id def insert(): res = conn.execute(objects_tbl.insert(), name=name, class_name=class_name) return res.inserted_primary_key[0] # we want to try selecting, then inserting, but if the insert fails # then try selecting again. We include an invocation of a hook # method to allow tests to exercise this particular behavior try: return ObjDict(id=select()) except _IdNotFoundError: pass self._test_timing_hook(conn) try: return ObjDict(id=insert()) except (sqlalchemy.exc.IntegrityError, sqlalchemy.exc.ProgrammingError): pass return ObjDict(id=select()) return self.db.pool.do(thd) class Thunk: pass def getState(self, objectid, name, default=Thunk): def thd(conn): object_state_tbl = self.db.model.object_state q = sa.select([ object_state_tbl.c.value_json ], whereclause=((object_state_tbl.c.objectid == objectid) & (object_state_tbl.c.name == name))) res = conn.execute(q) row = res.fetchone() res.close() if not row: if default is self.Thunk: raise KeyError("no such state value '%s' for object %d" % (name, objectid)) return default try: return json.loads(row.value_json) except: raise TypeError("JSON error loading state value '%s' for %d" % (name, objectid)) return self.db.pool.do(thd) def setState(self, objectid, name, value): def thd(conn): object_state_tbl = self.db.model.object_state try: value_json = json.dumps(value) except: raise TypeError("Error encoding JSON for %r" % (value,)) self.check_length(object_state_tbl.c.name, name) def update(): q = object_state_tbl.update( whereclause=((object_state_tbl.c.objectid == objectid) & (object_state_tbl.c.name == name))) res = conn.execute(q, value_json=value_json) # check whether that worked return res.rowcount > 0 def insert(): conn.execute(object_state_tbl.insert(), objectid=objectid, name=name, value_json=value_json) # try updating; if that fails, try inserting; if that fails, then # we raced with another instance to insert, so let that instance # win. if update(): return self._test_timing_hook(conn) try: insert() except (sqlalchemy.exc.IntegrityError, sqlalchemy.exc.ProgrammingError): pass # someone beat us to it - oh well return self.db.pool.do(thd) def _test_timing_hook(self, conn): # called so tests can simulate another process inserting a database row # at an inopportune moment pass buildbot-0.8.8/buildbot/db/users.py000066400000000000000000000207471222546025000172470ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa from sqlalchemy.sql.expression import and_ from buildbot.db import base class UsDict(dict): pass class UsersConnectorComponent(base.DBConnectorComponent): # Documentation is in developer/database.rst def findUserByAttr(self, identifier, attr_type, attr_data, _race_hook=None): def thd(conn, no_recurse=False): tbl = self.db.model.users tbl_info = self.db.model.users_info self.check_length(tbl.c.identifier, identifier) self.check_length(tbl_info.c.attr_type, attr_type) self.check_length(tbl_info.c.attr_data, attr_data) # try to find the user q = sa.select([ tbl_info.c.uid ], whereclause=and_(tbl_info.c.attr_type == attr_type, tbl_info.c.attr_data == attr_data)) rows = conn.execute(q).fetchall() if rows: return rows[0].uid _race_hook and _race_hook(conn) # try to do both of these inserts in a transaction, so that both # the new user and the corresponding attributes appear at the same # time from the perspective of other masters. transaction = conn.begin() try: r = conn.execute(tbl.insert(), dict(identifier=identifier)) uid = r.inserted_primary_key[0] conn.execute(tbl_info.insert(), dict(uid=uid, attr_type=attr_type, attr_data=attr_data)) transaction.commit() except (sa.exc.IntegrityError, sa.exc.ProgrammingError): transaction.rollback() # try it all over again, in case there was an overlapping, # identical call to findUserByAttr, but only retry once. if no_recurse: raise return thd(conn, no_recurse=True) return uid d = self.db.pool.do(thd) return d @base.cached("usdicts") def getUser(self, uid): def thd(conn): tbl = self.db.model.users tbl_info = self.db.model.users_info q = tbl.select(whereclause=(tbl.c.uid == uid)) users_row = conn.execute(q).fetchone() if not users_row: return None # make UsDict to return usdict = UsDict() # gather all attr_type and attr_data entries from users_info table q = tbl_info.select(whereclause=(tbl_info.c.uid == uid)) rows = conn.execute(q).fetchall() for row in rows: usdict[row.attr_type] = row.attr_data # add the users_row data *after* the attributes in case attr_type # matches one of these keys. usdict['uid'] = users_row.uid usdict['identifier'] = users_row.identifier usdict['bb_username'] = users_row.bb_username usdict['bb_password'] = users_row.bb_password return usdict d = self.db.pool.do(thd) return d def getUserByUsername(self, username): def thd(conn): tbl = self.db.model.users tbl_info = self.db.model.users_info q = tbl.select(whereclause=(tbl.c.bb_username == username)) users_row = conn.execute(q).fetchone() if not users_row: return None # make UsDict to return usdict = UsDict() # gather all attr_type and attr_data entries from users_info table q = tbl_info.select(whereclause=(tbl_info.c.uid == users_row.uid)) rows = conn.execute(q).fetchall() for row in rows: usdict[row.attr_type] = row.attr_data # add the users_row data *after* the attributes in case attr_type # matches one of these keys. usdict['uid'] = users_row.uid usdict['identifier'] = users_row.identifier usdict['bb_username'] = users_row.bb_username usdict['bb_password'] = users_row.bb_password return usdict d = self.db.pool.do(thd) return d def getUsers(self): def thd(conn): tbl = self.db.model.users rows = conn.execute(tbl.select()).fetchall() dicts = [] if rows: for row in rows: ud = dict(uid=row.uid, identifier=row.identifier) dicts.append(ud) return dicts d = self.db.pool.do(thd) return d def updateUser(self, uid=None, identifier=None, bb_username=None, bb_password=None, attr_type=None, attr_data=None, _race_hook=None): def thd(conn): transaction = conn.begin() tbl = self.db.model.users tbl_info = self.db.model.users_info update_dict = {} # first, add the identifier is it exists if identifier is not None: self.check_length(tbl.c.identifier, identifier) update_dict['identifier'] = identifier # then, add the creds if they exist if bb_username is not None: assert bb_password is not None self.check_length(tbl.c.bb_username, bb_username) self.check_length(tbl.c.bb_password, bb_password) update_dict['bb_username'] = bb_username update_dict['bb_password'] = bb_password # update the users table if it needs to be updated if update_dict: q = tbl.update(whereclause=(tbl.c.uid == uid)) res = conn.execute(q, update_dict) # then, update the attributes, carefully handling the potential # update-or-insert race condition. if attr_type is not None: assert attr_data is not None self.check_length(tbl_info.c.attr_type, attr_type) self.check_length(tbl_info.c.attr_data, attr_data) # first update, then insert q = tbl_info.update( whereclause=(tbl_info.c.uid == uid) & (tbl_info.c.attr_type == attr_type)) res = conn.execute(q, attr_data=attr_data) if res.rowcount == 0: _race_hook and _race_hook(conn) # the update hit 0 rows, so try inserting a new one try: q = tbl_info.insert() res = conn.execute(q, uid=uid, attr_type=attr_type, attr_data=attr_data) except (sa.exc.IntegrityError, sa.exc.ProgrammingError): # someone else beat us to the punch inserting this row; # let them win. transaction.rollback() return transaction.commit() d = self.db.pool.do(thd) return d def removeUser(self, uid): def thd(conn): # delete from dependent tables first, followed by 'users' for tbl in [ self.db.model.change_users, self.db.model.users_info, self.db.model.users, ]: conn.execute(tbl.delete(whereclause=(tbl.c.uid==uid))) d = self.db.pool.do(thd) return d def identifierToUid(self, identifier): def thd(conn): tbl = self.db.model.users q = tbl.select(whereclause=(tbl.c.identifier == identifier)) row = conn.execute(q).fetchone() if not row: return None return row.uid d = self.db.pool.do(thd) return d buildbot-0.8.8/buildbot/ec2buildslave.py000066400000000000000000000021141222546025000202310ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.python.deprecate import deprecatedModuleAttribute from twisted.python.versions import Version from buildbot.buildslave.libvirt import ( EC2LatentBuildSlave) deprecatedModuleAttribute(Version("Buildbot", 0, 8, 8), "It has been moved to buildbot.buildslave.ec2", "buildbot.libvirtbuildslave", "EC2LatentBuildSlave") _hush_pyflakes = [ EC2LatentBuildSlave] buildbot-0.8.8/buildbot/interfaces.py000066400000000000000000001422641222546025000176430ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members """Interface documentation. Define the interfaces that are implemented by various buildbot classes. """ # E0211: Method has no argument # E0213: Method should have "self" as first argument # pylint: disable-msg=E0211,E0213 from zope.interface import Interface, Attribute # exceptions that can be raised while trying to start a build class NoSlaveError(Exception): pass class BuilderInUseError(Exception): pass class BuildSlaveTooOldError(Exception): pass class LatentBuildSlaveFailedToSubstantiate(Exception): pass class IChangeSource(Interface): """ Service which feeds Change objects to the changemaster. When files or directories are changed in version control, this object should represent the changes as a change dictionary and call:: self.master.addChange(who=.., rev=.., ..) See 'Writing Change Sources' in the manual for more information. """ master = Attribute('master', 'Pointer to BuildMaster, automatically set when started.') def describe(): """Return a string which briefly describes this source.""" class ISourceStamp(Interface): """ @cvar branch: branch from which source was drawn @type branch: string or None @cvar revision: revision of the source, or None to use CHANGES @type revision: varies depending on VC @cvar patch: patch applied to the source, or None if no patch @type patch: None or tuple (level diff) @cvar changes: the source step should check out the latest revision in the given changes @type changes: tuple of L{buildbot.changes.changes.Change} instances, all of which are on the same branch @cvar project: project this source code represents @type project: string @cvar repository: repository from which source was drawn @type repository: string """ def canBeMergedWith(self, other): """ Can this SourceStamp be merged with OTHER? """ def mergeWith(self, others): """Generate a SourceStamp for the merger of me and all the other SourceStamps. This is called by a Build when it starts, to figure out what its sourceStamp should be.""" def getAbsoluteSourceStamp(self, got_revision): """Get a new SourceStamp object reflecting the actual revision found by a Source step.""" def getText(self): """Returns a list of strings to describe the stamp. These are intended to be displayed in a narrow column. If more space is available, the caller should join them together with spaces before presenting them to the user.""" class IEmailSender(Interface): """I know how to send email, and can be used by other parts of the Buildbot to contact developers.""" pass class IEmailLookup(Interface): def getAddress(user): """Turn a User-name string into a valid email address. Either return a string (with an @ in it), None (to indicate that the user cannot be reached by email), or a Deferred which will fire with the same.""" class IStatus(Interface): """I am an object, obtainable from the buildmaster, which can provide status information.""" def getTitle(): """Return the name of the project that this Buildbot is working for.""" def getTitleURL(): """Return the URL of this Buildbot's project.""" def getBuildbotURL(): """Return the URL of the top-most Buildbot status page, or None if this Buildbot does not provide a web status page.""" def getURLForThing(thing): """Return the URL of a page which provides information on 'thing', which should be an object that implements one of the status interfaces defined in L{buildbot.interfaces}. Returns None if no suitable page is available (or if no Waterfall is running).""" def getChangeSources(): """Return a list of IChangeSource objects.""" def getChange(number): """Return an IChange object.""" def getSchedulers(): """Return a list of ISchedulerStatus objects for all currently-registered Schedulers.""" def getBuilderNames(categories=None): """Return a list of the names of all current Builders.""" def getBuilder(name): """Return the IBuilderStatus object for a given named Builder. Raises KeyError if there is no Builder by that name.""" def getSlaveNames(): """Return a list of buildslave names, suitable for passing to getSlave().""" def getSlave(name): """Return the ISlaveStatus object for a given named buildslave.""" def getBuildSets(): """ Return a list of un-completed build sets. @returns: list of L{IBuildSetStatus} implementations, via Deferred. """ def generateFinishedBuilds(builders=[], branches=[], num_builds=None, finished_before=None, max_search=200): """Return a generator that will produce IBuildStatus objects each time you invoke its .next() method, starting with the most recent finished build and working backwards. @param builders: this is a list of Builder names, and the generator will only produce builds that ran on the given Builders. If the list is empty, produce builds from all Builders. @param branches: this is a list of branch names, and the generator will only produce builds that used the given branches. If the list is empty, produce builds from all branches. @param num_builds: the generator will stop after providing this many builds. The default of None means to produce as many builds as possible. @type finished_before: int: a timestamp, seconds since the epoch @param finished_before: if provided, do not produce any builds that finished after the given timestamp. @type max_search: int @param max_search: this method may have to examine a lot of builds to find some that match the search parameters, especially if there aren't any matching builds. This argument imposes a hard limit on the number of builds that will be examined within any given Builder. """ def subscribe(receiver): """Register an IStatusReceiver to receive new status events. The receiver will immediately be sent a set of 'builderAdded' messages for all current builders. It will receive further 'builderAdded' and 'builderRemoved' messages as the config file is reloaded and builders come and go. It will also receive 'buildsetSubmitted' messages for all outstanding BuildSets (and each new BuildSet that gets submitted). No additional messages will be sent unless the receiver asks for them by calling .subscribe on the IBuilderStatus objects which accompany the addedBuilder message.""" def unsubscribe(receiver): """Unregister an IStatusReceiver. No further status messgaes will be delivered.""" class IBuildSetStatus(Interface): """I represent a set of Builds, each run on a separate Builder but all using the same source tree.""" def getReason(): pass def getID(): """Return the BuildSet's ID string, if any. The 'try' feature uses a random string as a BuildSetID to relate submitted jobs with the resulting BuildSet.""" def getResponsibleUsers(): pass # not implemented def getInterestedUsers(): pass # not implemented def getBuilderNames(): """Return a list of the names of all Builders on which this set will do builds. @returns: list of names via Deferred""" def isFinished(): pass def waitUntilFinished(): """Return a Deferred that fires (with this IBuildSetStatus object) when all builds have finished.""" def getResults(): """Return SUCCESS/FAILURE, or None if the buildset is not finished yet""" class IBuildRequestStatus(Interface): """I represent a request to build a particular set of source code on a particular Builder. These requests may be merged by the time they are finally turned into a Build.""" def getSourceStamp(): """ Get a SourceStamp object which can be used to re-create the source tree that this build used. This method will return an absolute SourceStamp if possible, and its results may change as the build progresses. Specifically, a "HEAD" build may later be more accurately specified by an absolute SourceStamp with the specific revision information. This method will return None if the source information is no longer available. @returns: SourceStamp via Deferred """ def getBuilds(): """Return a list of IBuildStatus objects for each Build that has been started in an attempt to satify this BuildRequest.""" def subscribe(observer): """Register a callable that will be invoked (with a single IBuildStatus object) for each Build that is created to satisfy this request. There may be multiple Builds created in an attempt to handle the request: they may be interrupted by the user or abandoned due to a lost slave. The last Build (the one which actually gets to run to completion) is said to 'satisfy' the BuildRequest. The observer will be called once for each of these Builds, both old and new.""" def unsubscribe(observer): """Unregister the callable that was registered with subscribe().""" def getSubmitTime(): """Return the time when this request was submitted. Returns a Deferred.""" class ISlaveStatus(Interface): def getName(): """Return the name of the build slave.""" def getAdmin(): """Return a string with the slave admin's contact data.""" def getHost(): """Return a string with the slave host info.""" def isConnected(): """Return True if the slave is currently online, False if not.""" def lastMessageReceived(): """Return a timestamp (seconds since epoch) indicating when the most recent message was received from the buildslave.""" class ISchedulerStatus(Interface): def getName(): """Return the name of this Scheduler (a string).""" def getPendingBuildsets(): """Return an IBuildSet for all BuildSets that are pending. These BuildSets are waiting for their tree-stable-timers to expire.""" # TODO: this is not implemented anywhere class IBuilderStatus(Interface): def getName(): """Return the name of this Builder (a string).""" def getCategory(): """Return the category of this builder (a string).""" def getDescription(): """Return the description of this builder (a string).""" def getState(): # TODO: this isn't nearly as meaningful as it used to be """Return a tuple (state, builds) for this Builder. 'state' is the so-called 'big-status', indicating overall status (as opposed to which step is currently running). It is a string, one of 'offline', 'idle', or 'building'. 'builds' is a list of IBuildStatus objects (possibly empty) representing the currently active builds.""" def getSlaves(): """Return a list of ISlaveStatus objects for the buildslaves that are used by this builder.""" def getPendingBuildRequestStatuses(): """ Get a L{IBuildRequestStatus} implementations for all unclaimed build requests. @returns: list of objects via Deferred """ def getCurrentBuilds(): """Return a list containing an IBuildStatus object for each build currently in progress.""" # again, we could probably provide an object for 'waiting' and # 'interlocked' too, but things like the Change list might still be # subject to change def getLastFinishedBuild(): """Return the IBuildStatus object representing the last finished build, which may be None if the builder has not yet finished any builds.""" def getBuild(number): """Return an IBuildStatus object for a historical build. Each build is numbered (starting at 0 when the Builder is first added), getBuild(n) will retrieve the Nth such build. getBuild(-n) will retrieve a recent build, with -1 being the most recent build started. If the Builder is idle, this will be the same as getLastFinishedBuild(). If the Builder is active, it will be an unfinished build. This method will return None if the build is no longer available. Older builds are likely to have less information stored: Logs are the first to go, then Steps.""" def getEvent(number): """Return an IStatusEvent object for a recent Event. Builders connecting and disconnecting are events, as are ping attempts. getEvent(-1) will return the most recent event. Events are numbered, but it probably doesn't make sense to ever do getEvent(+n).""" def generateFinishedBuilds(branches=[], num_builds=None, max_buildnum=None, finished_before=None, max_search=200, ): """Return a generator that will produce IBuildStatus objects each time you invoke its .next() method, starting with the most recent finished build, then the previous build, and so on back to the oldest build available. @param branches: this is a list of branch names, and the generator will only produce builds that involve the given branches. If the list is empty, the generator will produce all builds regardless of what branch they used. @param num_builds: if provided, the generator will stop after providing this many builds. The default of None means to produce as many builds as possible. @param max_buildnum: if provided, the generator will start by providing the build with this number, or the highest-numbered preceding build (i.e. the generator will not produce any build numbered *higher* than max_buildnum). The default of None means to start with the most recent finished build. -1 means the same as None. -2 means to start with the next-most-recent completed build, etc. @type finished_before: int: a timestamp, seconds since the epoch @param finished_before: if provided, do not produce any builds that finished after the given timestamp. @type max_search: int @param max_search: this method may have to examine a lot of builds to find some that match the search parameters, especially if there aren't any matching builds. This argument imposes a hard limit on the number of builds that will be examined. """ def subscribe(receiver): """Register an IStatusReceiver to receive new status events. The receiver will be given builderChangedState, buildStarted, and buildFinished messages.""" def unsubscribe(receiver): """Unregister an IStatusReceiver. No further status messgaes will be delivered.""" class IEventSource(Interface): def eventGenerator(branches=[], categories=[], committers=[], minTime=0): """This function creates a generator which will yield all of this object's status events, starting with the most recent and progressing backwards in time. These events provide the IStatusEvent interface. At the moment they are all instances of buildbot.status.builder.Event or buildbot.status.builder.BuildStepStatus . @param branches: a list of branch names. The generator should only return events that are associated with these branches. If the list is empty, events for all branches should be returned (i.e. an empty list means 'accept all' rather than 'accept none'). @param categories: a list of category names. The generator should only return events that are categorized within the given category. If the list is empty, events for all categories should be returned. @param comitters: a list of committers. The generator should only return events caused by one of the listed committers. If the list is empty or None, events from every committers should be returned. @param minTime: a timestamp. Do not generate events occuring prior to this timestamp. """ class IBuildStatus(Interface): """I represent the status of a single Build/BuildRequest. It could be in-progress or finished.""" def getBuilder(): """ Return the BuilderStatus that owns this build. @rtype: implementor of L{IBuilderStatus} """ def isFinished(): """Return a boolean. True means the build has finished, False means it is still running.""" def waitUntilFinished(): """Return a Deferred that will fire when the build finishes. If the build has already finished, this deferred will fire right away. The callback is given this IBuildStatus instance as an argument.""" def getReason(): """Return a string that indicates why the build was run. 'changes', 'forced', and 'periodic' are the most likely values. 'try' will be added in the future.""" def getSourceStamps(): """Return a list of SourceStamp objects which can be used to re-create the source tree that this build used. This method will return None if the source information is no longer available.""" # TODO: it should be possible to expire the patch but still remember # that the build was r123+something. def getChanges(): """Return a list of Change objects which represent which source changes went into the build.""" def getRevisions(): """Returns a string representing the list of revisions that led to the build, rendered from each Change.revision""" def getResponsibleUsers(): """Return a list of Users who are to blame for the changes that went into this build. If anything breaks (at least anything that wasn't already broken), blame them. Specifically, this is the set of users who were responsible for the Changes that went into this build. Each User is a string, corresponding to their name as known by the VC repository.""" def getInterestedUsers(): """Return a list of Users who will want to know about the results of this build but who did not actually make the Changes that went into it (build sheriffs, code-domain owners).""" def getNumber(): """Within each builder, each Build has a number. Return it.""" def getPreviousBuild(): """Convenience method. Returns None if the previous build is unavailable.""" def getSteps(): """Return a list of IBuildStepStatus objects. For invariant builds (those which always use the same set of Steps), this should always return the complete list, however some of the steps may not have started yet (step.getTimes()[0] will be None). For variant builds, this may not be complete (asking again later may give you more of them).""" def getTimes(): """Returns a tuple of (start, end). 'start' and 'end' are the times (seconds since the epoch) when the Build started and finished. If the build is still running, 'end' will be None.""" # while the build is running, the following methods make sense. # Afterwards they return None def getETA(): """Returns the number of seconds from now in which the build is expected to finish, or None if we can't make a guess. This guess will be refined over time.""" def getCurrentStep(): """Return an IBuildStepStatus object representing the currently active step.""" # Once you know the build has finished, the following methods are legal. # Before ths build has finished, they all return None. def getSlavename(): """Return the name of the buildslave which handled this build.""" def getText(): """Returns a list of strings to describe the build. These are intended to be displayed in a narrow column. If more space is available, the caller should join them together with spaces before presenting them to the user.""" def getResults(): """Return a constant describing the results of the build: one of the constants in buildbot.status.builder: SUCCESS, WARNINGS, FAILURE, SKIPPED or EXCEPTION.""" def getLogs(): """Return a list of logs that describe the build as a whole. Some steps will contribute their logs, while others are are less important and will only be accessible through the IBuildStepStatus objects. Each log is an object which implements the IStatusLog interface.""" def getTestResults(): """Return a dictionary that maps test-name tuples to ITestResult objects. This may return an empty or partially-filled dictionary until the build has completed.""" # subscription interface def subscribe(receiver, updateInterval=None): """Register an IStatusReceiver to receive new status events. The receiver will be given stepStarted and stepFinished messages. If 'updateInterval' is non-None, buildETAUpdate messages will be sent every 'updateInterval' seconds.""" def unsubscribe(receiver): """Unregister an IStatusReceiver. No further status messgaes will be delivered.""" class ITestResult(Interface): """I describe the results of a single unit test.""" def getName(): """Returns a tuple of strings which make up the test name. Tests may be arranged in a hierarchy, so looking for common prefixes may be useful.""" def getResults(): """Returns a constant describing the results of the test: SUCCESS, WARNINGS, FAILURE.""" def getText(): """Returns a list of short strings which describe the results of the test in slightly more detail. Suggested components include 'failure', 'error', 'passed', 'timeout'.""" def getLogs(): # in flux, it may be possible to provide more structured information # like python Failure instances """Returns a dictionary of test logs. The keys are strings like 'stdout', 'log', 'exceptions'. The values are strings.""" class IBuildStepStatus(Interface): """I hold status for a single BuildStep.""" def getName(): """Returns a short string with the name of this step. This string may have spaces in it.""" def getBuild(): """Returns the IBuildStatus object which contains this step.""" def getTimes(): """Returns a tuple of (start, end). 'start' and 'end' are the times (seconds since the epoch) when the Step started and finished. If the step has not yet started, 'start' will be None. If the step is still running, 'end' will be None.""" def getExpectations(): """Returns a list of tuples (name, current, target). Each tuple describes a single axis along which the step's progress can be measured. 'name' is a string which describes the axis itself, like 'filesCompiled' or 'tests run' or 'bytes of output'. 'current' is a number with the progress made so far, while 'target' is the value that we expect (based upon past experience) to get to when the build is finished. 'current' will change over time until the step is finished. It is 'None' until the step starts. When the build is finished, 'current' may or may not equal 'target' (which is merely the expectation based upon previous builds).""" def getURLs(): """Returns a dictionary of URLs. Each key is a link name (a short string, like 'results' or 'coverage'), and each value is a URL. These links will be displayed along with the LogFiles. """ def getLogs(): """Returns a list of IStatusLog objects. If the step has not yet finished, this list may be incomplete (asking again later may give you more of them).""" def isFinished(): """Return a boolean. True means the step has finished, False means it is still running.""" def waitUntilFinished(): """Return a Deferred that will fire when the step finishes. If the step has already finished, this deferred will fire right away. The callback is given this IBuildStepStatus instance as an argument.""" # while the step is running, the following methods make sense. # Afterwards they return None def getETA(): """Returns the number of seconds from now in which the step is expected to finish, or None if we can't make a guess. This guess will be refined over time.""" # Once you know the step has finished, the following methods are legal. # Before ths step has finished, they all return None. def getText(): """Returns a list of strings which describe the step. These are intended to be displayed in a narrow column. If more space is available, the caller should join them together with spaces before presenting them to the user.""" def getResults(): """Return a tuple describing the results of the step: (result, strings). 'result' is one of the constants in buildbot.status.builder: SUCCESS, WARNINGS, FAILURE, or SKIPPED. 'strings' is an optional list of strings that the step wants to append to the overall build's results. These strings are usually more terse than the ones returned by getText(): in particular, successful Steps do not usually contribute any text to the overall build.""" # subscription interface def subscribe(receiver, updateInterval=10): """Register an IStatusReceiver to receive new status events. The receiver will be given logStarted and logFinished messages. It will also be given a ETAUpdate message every 'updateInterval' seconds.""" def unsubscribe(receiver): """Unregister an IStatusReceiver. No further status messgaes will be delivered.""" class IStatusEvent(Interface): """I represent a Builder Event, something non-Build related that can happen to a Builder.""" def getTimes(): """Returns a tuple of (start, end) like IBuildStepStatus, but end==0 indicates that this is a 'point event', which has no duration. SlaveConnect/Disconnect are point events. Ping is not: it starts when requested and ends when the response (positive or negative) is returned""" def getText(): """Returns a list of strings which describe the event. These are intended to be displayed in a narrow column. If more space is available, the caller should join them together with spaces before presenting them to the user.""" LOG_CHANNEL_STDOUT = 0 LOG_CHANNEL_STDERR = 1 LOG_CHANNEL_HEADER = 2 class IStatusLog(Interface): """I represent a single Log, which is a growing list of text items that contains some kind of output for a single BuildStep. I might be finished, in which case this list has stopped growing. Each Log has a name, usually something boring like 'log' or 'output'. These names are not guaranteed to be unique, however they are usually chosen to be useful within the scope of a single step (i.e. the Compile step might produce both 'log' and 'warnings'). The name may also have spaces. If you want something more globally meaningful, at least within a given Build, try:: '%s.%s' % (log.getStep.getName(), log.getName()) The Log can be presented as plain text, or it can be accessed as a list of items, each of which has a channel indicator (header, stdout, stderr) and a text chunk. An HTML display might represent the interleaved channels with different styles, while a straight download-the-text interface would just want to retrieve a big string. The 'header' channel is used by ShellCommands to prepend a note about which command is about to be run ('running command FOO in directory DIR'), and append another note giving the exit code of the process. Logs can be streaming: if the Log has not yet finished, you can subscribe to receive new chunks as they are added. A ShellCommand will have a Log associated with it that gathers stdout and stderr. Logs may also be created by parsing command output or through other synthetic means (grepping for all the warnings in a compile log, or listing all the test cases that are going to be run). Such synthetic Logs are usually finished as soon as they are created.""" def getName(): """Returns a short string with the name of this log, probably 'log'. """ def getStep(): """Returns the IBuildStepStatus which owns this log.""" # TODO: can there be non-Step logs? def isFinished(): """Return a boolean. True means the log has finished and is closed, False means it is still open and new chunks may be added to it.""" def waitUntilFinished(): """Return a Deferred that will fire when the log is closed. If the log has already finished, this deferred will fire right away. The callback is given this IStatusLog instance as an argument.""" def subscribe(receiver, catchup): """Register an IStatusReceiver to receive chunks (with logChunk) as data is added to the Log. If you use this, you will also want to use waitUntilFinished to find out when the listener can be retired. Subscribing to a closed Log is a no-op. If 'catchup' is True, the receiver will immediately be sent a series of logChunk messages to bring it up to date with the partially-filled log. This allows a status client to join a Log already in progress without missing any data. If the Log has already finished, it is too late to catch up: just do getText() instead. If the Log is very large, the receiver will be called many times with a lot of data. There is no way to throttle this data. If the receiver is planning on sending the data on to somewhere else, over a narrow connection, you can get a throttleable subscription by using C{subscribeConsumer} instead.""" def unsubscribe(receiver): """Remove a receiver previously registered with subscribe(). Attempts to remove a receiver which was not previously registered is a no-op. """ def subscribeConsumer(consumer): """Register an L{IStatusLogConsumer} to receive all chunks of the logfile, including all the old entries and any that will arrive in the future. The consumer will first have their C{registerProducer} method invoked with a reference to an object that can be told C{pauseProducing}, C{resumeProducing}, and C{stopProducing}. Then the consumer's C{writeChunk} method will be called repeatedly with each (channel, text) tuple in the log, starting with the very first. The consumer will be notified with C{finish} when the log has been exhausted (which can only happen when the log is finished). Note that a small amount of data could be written via C{writeChunk} even after C{pauseProducing} has been called. To unsubscribe the consumer, use C{producer.stopProducing}.""" # once the log has finished, the following methods make sense. They can # be called earlier, but they will only return the contents of the log up # to the point at which they were called. You will lose items that are # added later. Use C{subscribe} or C{subscribeConsumer} to avoid missing # anything. def hasContents(): """Returns True if the LogFile still has contents available. Returns False for logs that have been pruned. Clients should test this before offering to show the contents of any log.""" def getText(): """Return one big string with the contents of the Log. This merges all non-header chunks together.""" def readlines(channel=LOG_CHANNEL_STDOUT): """Read lines from one channel of the logfile. This returns an iterator that will provide single lines of text (including the trailing newline). """ def getTextWithHeaders(): """Return one big string with the contents of the Log. This merges all chunks (including headers) together.""" def getChunks(): """Generate a list of (channel, text) tuples. 'channel' is a number, 0 for stdout, 1 for stderr, 2 for header. (note that stderr is merged into stdout if PTYs are in use).""" class IStatusLogConsumer(Interface): """I am an object which can be passed to IStatusLog.subscribeConsumer(). I represent a target for writing the contents of an IStatusLog. This differs from a regular IStatusReceiver in that it can pause the producer. This makes it more suitable for use in streaming data over network sockets, such as an HTTP request. Note that the consumer can only pause the producer until it has caught up with all the old data. After that point, C{pauseProducing} is ignored and all new output from the log is sent directoy to the consumer.""" def registerProducer(producer, streaming): """A producer is being hooked up to this consumer. The consumer only has to handle a single producer. It should send .pauseProducing and .resumeProducing messages to the producer when it wants to stop or resume the flow of data. 'streaming' will be set to True because the producer is always a PushProducer. """ def unregisterProducer(): """The previously-registered producer has been removed. No further pauseProducing or resumeProducing calls should be made. The consumer should delete its reference to the Producer so it can be released.""" def writeChunk(chunk): """A chunk (i.e. a tuple of (channel, text)) is being written to the consumer.""" def finish(): """The log has finished sending chunks to the consumer.""" class IStatusReceiver(Interface): """I am an object which can receive build status updates. I may be subscribed to an IStatus, an IBuilderStatus, or an IBuildStatus.""" def buildsetSubmitted(buildset): """A new BuildSet has been submitted to the buildmaster. @type buildset: implementor of L{IBuildSetStatus} """ def requestSubmitted(request): """A new BuildRequest has been submitted to the buildmaster. @type request: implementor of L{IBuildRequestStatus} """ def requestCancelled(builder, request): """A BuildRequest has been cancelled on the given Builder. @type builder: L{buildbot.status.builder.BuilderStatus} @type request: implementor of L{IBuildRequestStatus} """ def builderAdded(builderName, builder): """ A new Builder has just been added. This method may return an IStatusReceiver (probably 'self') which will be subscribed to receive builderChangedState and buildStarted/Finished events. @type builderName: string @type builder: L{buildbot.status.builder.BuilderStatus} @rtype: implementor of L{IStatusReceiver} """ def builderChangedState(builderName, state): """Builder 'builderName' has changed state. The possible values for 'state' are 'offline', 'idle', and 'building'.""" def buildStarted(builderName, build): """Builder 'builderName' has just started a build. The build is an object which implements IBuildStatus, and can be queried for more information. This method may return an IStatusReceiver (it could even return 'self'). If it does so, stepStarted and stepFinished methods will be invoked on the object for the steps of this one build. This is a convenient way to subscribe to all build steps without missing any. This receiver will automatically be unsubscribed when the build finishes. It can also return a tuple of (IStatusReceiver, interval), in which case buildETAUpdate messages are sent ever 'interval' seconds, in addition to the stepStarted and stepFinished messages.""" def buildETAUpdate(build, ETA): """This is a periodic update on the progress this Build has made towards completion.""" def changeAdded(change): """A new Change was added to the ChangeMaster. By the time this event is received, all schedulers have already received the change.""" def stepStarted(build, step): """A step has just started. 'step' is the IBuildStepStatus which represents the step: it can be queried for more information. This method may return an IStatusReceiver (it could even return 'self'). If it does so, logStarted and logFinished methods will be invoked on the object for logs created by this one step. This receiver will be automatically unsubscribed when the step finishes. Alternatively, the method may return a tuple of an IStatusReceiver and an integer named 'updateInterval'. In addition to logStarted/logFinished messages, it will also receive stepETAUpdate messages about every updateInterval seconds.""" def stepTextChanged(build, step, text): """The text for a step has been updated. This is called when calling setText() on the step status, and hands in the text list.""" def stepText2Changed(build, step, text2): """The text2 for a step has been updated. This is called when calling setText2() on the step status, and hands in text2 list.""" def stepETAUpdate(build, step, ETA, expectations): """This is a periodic update on the progress this Step has made towards completion. It gets an ETA (in seconds from the present) of when the step ought to be complete, and a list of expectation tuples (as returned by IBuildStepStatus.getExpectations) with more detailed information.""" def logStarted(build, step, log): """A new Log has been started, probably because a step has just started running a shell command. 'log' is the IStatusLog object which can be queried for more information. This method may return an IStatusReceiver (such as 'self'), in which case the target's logChunk method will be invoked as text is added to the logfile. This receiver will automatically be unsubsribed when the log finishes.""" def logChunk(build, step, log, channel, text): """Some text has been added to this log. 'channel' is one of LOG_CHANNEL_STDOUT, LOG_CHANNEL_STDERR, or LOG_CHANNEL_HEADER, as defined in IStatusLog.getChunks.""" def logFinished(build, step, log): """A Log has been closed.""" def stepFinished(build, step, results): """A step has just finished. 'results' is the result tuple described in IBuildStepStatus.getResults.""" def buildFinished(builderName, build, results): """ A build has just finished. 'results' is the result tuple described in L{IBuildStatus.getResults}. @type builderName: string @type build: L{buildbot.status.build.BuildStatus} @type results: tuple """ def builderRemoved(builderName): """The Builder has been removed.""" def slaveConnected(slaveName): """The slave has connected.""" def slaveDisconnected(slaveName): """The slave has disconnected.""" def checkConfig(otherStatusReceivers): """Verify that there are no other status receivers which conflict with the current one. @type otherStatusReceivers: A list of L{IStatusReceiver} objects which will contain self. """ class IControl(Interface): def addChange(change): """Add a change to the change queue, for analysis by schedulers.""" def getBuilder(name): """Retrieve the IBuilderControl object for the given Builder.""" class IBuilderControl(Interface): def submitBuildRequest(ss, reason, props=None): """Create a BuildRequest, which will eventually cause a build of the given SourceStamp to be run on this builder. This returns a BuildRequestStatus object via a Deferred, which can be used to keep track of the builds that are performed.""" def rebuildBuild(buildStatus, reason=""): """Rebuild something we've already built before. This submits a BuildRequest to our Builder using the same SourceStamp as the earlier build. This has no effect (but may eventually raise an exception) if this Build has not yet finished.""" def getPendingBuildRequestControls(): """ Get a list of L{IBuildRequestControl} objects for this Builder. Each one corresponds to an unclaimed build request. @returns: list of objects via Deferred """ def getBuild(number): """Attempt to return an IBuildControl object for the given build. Returns None if no such object is available. This will only work for the build that is currently in progress: once the build finishes, there is nothing to control anymore.""" def ping(): """Attempt to contact the slave and see if it is still alive. This returns a Deferred which fires with either True (the slave is still alive) or False (the slave did not respond). As a side effect, adds an event to this builder's column in the waterfall display containing the results of the ping. Note that this may not fail for a long time, it is implemented in terms of the timeout on the underlying TCP connection.""" # TODO: this ought to live in ISlaveControl, maybe with disconnect() # or something. However the event that is emitted is most useful in # the Builder column, so it kinda fits here too. class IBuildRequestControl(Interface): def subscribe(observer): """Register a callable that will be invoked (with a single IBuildControl object) for each Build that is created to satisfy this request. There may be multiple Builds created in an attempt to handle the request: they may be interrupted by the user or abandoned due to a lost slave. The last Build (the one which actually gets to run to completion) is said to 'satisfy' the BuildRequest. The observer will be called once for each of these Builds, both old and new.""" def unsubscribe(observer): """Unregister the callable that was registered with subscribe().""" def cancel(): """Remove the build from the pending queue. Has no effect if the build has already been started.""" class IBuildControl(Interface): def getStatus(): """Return an IBuildStatus object for the Build that I control.""" def stopBuild(reason=""): """Halt the build. This has no effect if the build has already finished.""" class ILogFile(Interface): """This is the internal interface to a LogFile, used by the BuildStep to write data into the log. """ def addStdout(data): pass def addStderr(data): pass def addHeader(data): pass def finish(): """The process that is feeding the log file has finished, and no further data will be added. This closes the logfile.""" class ILogObserver(Interface): """Objects which provide this interface can be used in a BuildStep to watch the output of a LogFile and parse it incrementally. """ # internal methods def setStep(step): pass def setLog(log): pass # methods called by the LogFile def logChunk(build, step, log, channel, text): pass class IBuildSlave(Interface): # this is a marker interface for the BuildSlave class pass class ILatentBuildSlave(IBuildSlave): """A build slave that is not always running, but can run when requested. """ substantiated = Attribute('Substantiated', 'Whether the latent build slave is currently ' 'substantiated with a real instance.') def substantiate(): """Request that the slave substantiate with a real instance. Returns a deferred that will callback when a real instance has attached.""" # there is an insubstantiate too, but that is not used externally ATM. def buildStarted(sb): """Inform the latent build slave that a build has started. @param sb: a L{LatentSlaveBuilder}. The sb is the one for whom the build finished. """ def buildFinished(sb): """Inform the latent build slave that a build has finished. @param sb: a L{LatentSlaveBuilder}. The sb is the one for whom the build finished. """ class IRenderable(Interface): """An object that can be interpolated with properties from a build. """ def getRenderingFor(iprops): """Return a deferred that fires with interpolation with the given properties @param iprops: the L{IProperties} provider supplying the properties. """ class IProperties(Interface): """ An object providing access to build properties """ def getProperty(name, default=None): """Get the named property, returning the default if the property does not exist. @param name: property name @type name: string @param default: default value (default: @code{None}) @returns: property value """ def hasProperty(name): """Return true if the named property exists. @param name: property name @type name: string @returns: boolean """ def has_key(name): """Deprecated name for L{hasProperty}.""" def setProperty(name, value, source, runtime=False): """Set the given property, overwriting any existing value. The source describes the source of the value for human interpretation. @param name: property name @type name: string @param value: property value @type value: JSON-able value @param source: property source @type source: string @param runtime: (optional) whether this property was set during the build's runtime: usually left at its default value @type runtime: boolean """ def getProperties(): """Get the L{buildbot.process.properties.Properties} instance storing these properties. Note that the interface for this class is not stable, so where possible the other methods of this interface should be used. @returns: L{buildbot.process.properties.Properties} instance """ def getBuild(): """Get the L{buildbot.process.build.Build} instance for the current build. Note that this object is not available after the build is complete, at which point this method will return None. Try to avoid using this method, as the API of L{Build} instances is not well-defined. @returns L{buildbot.process.build.Build} instance """ def render(value): """Render @code{value} as an L{IRenderable}. This essentially coerces @code{value} to an L{IRenderable} and calls its @L{getRenderingFor} method. @name value: value to render @returns: rendered value """ class IScheduler(Interface): pass class ITriggerableScheduler(Interface): """ A scheduler that can be triggered by buildsteps. """ def trigger(sourcestamps, set_props=None): """Trigger a build with the given source stamp and properties. """ class IBuildStepFactory(Interface): def buildStep(): """ """ buildbot-0.8.8/buildbot/libvirtbuildslave.py000066400000000000000000000022361222546025000212400ustar00rootroot00000000000000 # This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.python.deprecate import deprecatedModuleAttribute from twisted.python.versions import Version from buildbot.buildslave.libvirt import ( LibVirtSlave, Domain, Connection) for _attr in ["LibVirtSlave", "Connection", "Domain"]: deprecatedModuleAttribute(Version("Buildbot", 0, 8, 8), "It has been moved to buildbot.buildslave.libvirt", "buildbot.libvirtbuildslave", _attr) _hush_pyflakes = [ LibVirtSlave, Domain, Connection] buildbot-0.8.8/buildbot/locks.py000066400000000000000000000301021222546025000166160ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.python import log from twisted.internet import defer from buildbot import util from buildbot.util import subscription from buildbot.util.eventual import eventually if False: # for debugging debuglog = log.msg else: debuglog = lambda m: None class BaseLock: """ Class handling claiming and releasing of L{self}, and keeping track of current and waiting owners. We maintain the wait queue in FIFO order, and ensure that counting waiters in the queue behind exclusive waiters cannot acquire the lock. This ensures that exclusive waiters are not starved. """ description = "" def __init__(self, name, maxCount=1): self.name = name # Name of the lock self.waiting = [] # Current queue, tuples (waiter, LockAccess, # deferred) self.owners = [] # Current owners, tuples (owner, LockAccess) self.maxCount = maxCount # maximal number of counting owners # subscriptions to this lock being released self.release_subs = subscription.SubscriptionPoint("%r releases" % (self,)) def __repr__(self): return self.description def _getOwnersCount(self): """ Return the number of current exclusive and counting owners. @return: Tuple (number exclusive owners, number counting owners) """ num_excl, num_counting = 0, 0 for owner in self.owners: if owner[1].mode == 'exclusive': num_excl = num_excl + 1 else: # mode == 'counting' num_counting = num_counting + 1 assert (num_excl == 1 and num_counting == 0) \ or (num_excl == 0 and num_counting <= self.maxCount) return num_excl, num_counting def isAvailable(self, requester, access): """ Return a boolean whether the lock is available for claiming """ debuglog("%s isAvailable(%s, %s): self.owners=%r" % (self, requester, access, self.owners)) num_excl, num_counting = self._getOwnersCount() # Find all waiters ahead of the requester in the wait queue for idx, waiter in enumerate(self.waiting): if waiter[0] == requester: w_index = idx break else: w_index = len(self.waiting) ahead = self.waiting[:w_index] if access.mode == 'counting': # Wants counting access return num_excl == 0 and num_counting + len(ahead) < self.maxCount \ and all([w[1].mode == 'counting' for w in ahead]) else: # Wants exclusive access return num_excl == 0 and num_counting == 0 and len(ahead) == 0 def claim(self, owner, access): """ Claim the lock (lock must be available) """ debuglog("%s claim(%s, %s)" % (self, owner, access.mode)) assert owner is not None assert self.isAvailable(owner, access), "ask for isAvailable() first" assert isinstance(access, LockAccess) assert access.mode in ['counting', 'exclusive'] self.waiting = [w for w in self.waiting if w[0] != owner] self.owners.append((owner, access)) debuglog(" %s is claimed '%s'" % (self, access.mode)) def subscribeToReleases(self, callback): """Schedule C{callback} to be invoked every time this lock is released. Returns a L{Subscription}.""" return self.release_subs.subscribe(callback) def release(self, owner, access): """ Release the lock """ assert isinstance(access, LockAccess) debuglog("%s release(%s, %s)" % (self, owner, access.mode)) entry = (owner, access) if not entry in self.owners: debuglog("%s already released" % self) return self.owners.remove(entry) # who can we wake up? # After an exclusive access, we may need to wake up several waiting. # Break out of the loop when the first waiting client should not be awakened. num_excl, num_counting = self._getOwnersCount() for i, (w_owner, w_access, d) in enumerate(self.waiting): if w_access.mode == 'counting': if num_excl > 0 or num_counting == self.maxCount: break else: num_counting = num_counting + 1 else: # w_access.mode == 'exclusive' if num_excl > 0 or num_counting > 0: break else: num_excl = num_excl + 1 # If the waiter has a deferred, wake it up and clear the deferred # from the wait queue entry to indicate that it has been woken. if d: self.waiting[i] = (w_owner, w_access, None) eventually(d.callback, self) # notify any listeners self.release_subs.deliver() def waitUntilMaybeAvailable(self, owner, access): """Fire when the lock *might* be available. The caller will need to check with isAvailable() when the deferred fires. This loose form is used to avoid deadlocks. If we were interested in a stronger form, this would be named 'waitUntilAvailable', and the deferred would fire after the lock had been claimed. """ debuglog("%s waitUntilAvailable(%s)" % (self, owner)) assert isinstance(access, LockAccess) if self.isAvailable(owner, access): return defer.succeed(self) d = defer.Deferred() # Are we already in the wait queue? w = [i for i, w in enumerate(self.waiting) if w[0] == owner] if w: self.waiting[w[0]] = (owner, access, d) else: self.waiting.append((owner, access, d)) return d def stopWaitingUntilAvailable(self, owner, access, d): debuglog("%s stopWaitingUntilAvailable(%s)" % (self, owner)) assert isinstance(access, LockAccess) assert (owner, access, d) in self.waiting self.waiting = [w for w in self.waiting if w[0] != owner] def isOwner(self, owner, access): return (owner, access) in self.owners class RealMasterLock(BaseLock): def __init__(self, lockid): BaseLock.__init__(self, lockid.name, lockid.maxCount) self.description = "" % (self.name, self.maxCount) def getLock(self, slave): return self class RealSlaveLock: def __init__(self, lockid): self.name = lockid.name self.maxCount = lockid.maxCount self.maxCountForSlave = lockid.maxCountForSlave self.description = "" % (self.name, self.maxCount, self.maxCountForSlave) self.locks = {} def __repr__(self): return self.description def getLock(self, slave): slavename = slave.slavename if not self.locks.has_key(slavename): maxCount = self.maxCountForSlave.get(slavename, self.maxCount) lock = self.locks[slavename] = BaseLock(self.name, maxCount) desc = "" % (self.name, maxCount, slavename, id(lock)) lock.description = desc self.locks[slavename] = lock return self.locks[slavename] class LockAccess(util.ComparableMixin): """ I am an object representing a way to access a lock. @param lockid: LockId instance that should be accessed. @type lockid: A MasterLock or SlaveLock instance. @param mode: Mode of accessing the lock. @type mode: A string, either 'counting' or 'exclusive'. """ compare_attrs = ['lockid', 'mode'] def __init__(self, lockid, mode, _skipChecks=False): self.lockid = lockid self.mode = mode if not _skipChecks: # these checks fail with mock < 0.8.0 when lockid is a Mock # TODO: remove this in Buildbot-0.9.0+ assert isinstance(lockid, (MasterLock, SlaveLock)) assert mode in ['counting', 'exclusive'] class BaseLockId(util.ComparableMixin): """ Abstract base class for LockId classes. Sets up the 'access()' function for the LockId's available to the user (MasterLock and SlaveLock classes). Derived classes should add - Comparison with the L{util.ComparableMixin} via the L{compare_attrs} class variable. - Link to the actual lock class should be added with the L{lockClass} class variable. """ def access(self, mode): """ Express how the lock should be accessed """ assert mode in ['counting', 'exclusive'] return LockAccess(self, mode) def defaultAccess(self): """ For buildbot 0.7.7 compability: When user doesn't specify an access mode, this one is chosen. """ return self.access('counting') # master.cfg should only reference the following MasterLock and SlaveLock # classes. They are identifiers that will be turned into real Locks later, # via the BotMaster.getLockByID method. class MasterLock(BaseLockId): """I am a semaphore that limits the number of simultaneous actions. Builds and BuildSteps can declare that they wish to claim me as they run. Only a limited number of such builds or steps will be able to run simultaneously. By default this number is one, but my maxCount parameter can be raised to allow two or three or more operations to happen at the same time. Use this to protect a resource that is shared among all builders and all slaves, for example to limit the load on a common SVN repository. """ compare_attrs = ['name', 'maxCount'] lockClass = RealMasterLock def __init__(self, name, maxCount=1): self.name = name self.maxCount = maxCount class SlaveLock(BaseLockId): """I am a semaphore that limits simultaneous actions on each buildslave. Builds and BuildSteps can declare that they wish to claim me as they run. Only a limited number of such builds or steps will be able to run simultaneously on any given buildslave. By default this number is one, but my maxCount parameter can be raised to allow two or three or more operations to happen on a single buildslave at the same time. Use this to protect a resource that is shared among all the builds taking place on each slave, for example to limit CPU or memory load on an underpowered machine. Each buildslave will get an independent copy of this semaphore. By default each copy will use the same owner count (set with maxCount), but you can provide maxCountForSlave with a dictionary that maps slavename to owner count, to allow some slaves more parallelism than others. """ compare_attrs = ['name', 'maxCount', '_maxCountForSlaveList'] lockClass = RealSlaveLock def __init__(self, name, maxCount=1, maxCountForSlave={}): self.name = name self.maxCount = maxCount self.maxCountForSlave = maxCountForSlave # for comparison purposes, turn this dictionary into a stably-sorted # list of tuples self._maxCountForSlaveList = self.maxCountForSlave.items() self._maxCountForSlaveList.sort() self._maxCountForSlaveList = tuple(self._maxCountForSlaveList) buildbot-0.8.8/buildbot/manhole.py000066400000000000000000000275071222546025000171450ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import types import binascii import base64 from twisted.python import log from twisted.application import service, strports from twisted.cred import checkers, portal from twisted.conch import manhole, telnet try: from twisted.conch import checkers as conchc, manhole_ssh _hush_pyflakes = [manhole_ssh, conchc] del _hush_pyflakes except ImportError: manhole_ssh = None conchc = None from twisted.conch.insults import insults from twisted.internet import protocol from buildbot import config from buildbot.util import ComparableMixin from zope.interface import implements # requires Twisted-2.0 or later # makeTelnetProtocol and _TelnetRealm are for the TelnetManhole class makeTelnetProtocol: # this curries the 'portal' argument into a later call to # TelnetTransport() def __init__(self, portal): self.portal = portal def __call__(self): auth = telnet.AuthenticatingTelnetProtocol return telnet.TelnetTransport(auth, self.portal) class _TelnetRealm: implements(portal.IRealm) def __init__(self, namespace_maker): self.namespace_maker = namespace_maker def requestAvatar(self, avatarId, *interfaces): if telnet.ITelnetProtocol in interfaces: namespace = self.namespace_maker() p = telnet.TelnetBootstrapProtocol(insults.ServerProtocol, manhole.ColoredManhole, namespace) return (telnet.ITelnetProtocol, p, lambda: None) raise NotImplementedError() class chainedProtocolFactory: # this curries the 'namespace' argument into a later call to # chainedProtocolFactory() def __init__(self, namespace): self.namespace = namespace def __call__(self): return insults.ServerProtocol(manhole.ColoredManhole, self.namespace) if conchc: class AuthorizedKeysChecker(conchc.SSHPublicKeyDatabase): """Accept connections using SSH keys from a given file. SSHPublicKeyDatabase takes the username that the prospective client has requested and attempts to get a ~/.ssh/authorized_keys file for that username. This requires root access, so it isn't as useful as you'd like. Instead, this subclass looks for keys in a single file, given as an argument. This file is typically kept in the buildmaster's basedir. The file should have 'ssh-dss ....' lines in it, just like authorized_keys. """ def __init__(self, authorized_keys_file): self.authorized_keys_file = os.path.expanduser(authorized_keys_file) def checkKey(self, credentials): with open(self.authorized_keys_file) as f: for l in f.readlines(): l2 = l.split() if len(l2) < 2: continue try: if base64.decodestring(l2[1]) == credentials.blob: return 1 except binascii.Error: continue return 0 class _BaseManhole(service.MultiService): """This provides remote access to a python interpreter (a read/exec/print loop) embedded in the buildmaster via an internal SSH server. This allows detailed inspection of the buildmaster state. It is of most use to buildbot developers. Connect to this by running an ssh client. """ def __init__(self, port, checker, using_ssh=True): """ @type port: string or int @param port: what port should the Manhole listen on? This is a strports specification string, like 'tcp:12345' or 'tcp:12345:interface=127.0.0.1'. Bare integers are treated as a simple tcp port. @type checker: an object providing the L{twisted.cred.checkers.ICredentialsChecker} interface @param checker: if provided, this checker is used to authenticate the client instead of using the username/password scheme. You must either provide a username/password or a Checker. Some useful values are:: import twisted.cred.checkers as credc import twisted.conch.checkers as conchc c = credc.AllowAnonymousAccess # completely open c = credc.FilePasswordDB(passwd_filename) # file of name:passwd c = conchc.UNIXPasswordDatabase # getpwnam() (probably /etc/passwd) @type using_ssh: bool @param using_ssh: If True, accept SSH connections. If False, accept regular unencrypted telnet connections. """ # unfortunately, these don't work unless we're running as root #c = credc.PluggableAuthenticationModulesChecker: PAM #c = conchc.SSHPublicKeyDatabase() # ~/.ssh/authorized_keys # and I can't get UNIXPasswordDatabase to work service.MultiService.__init__(self) if type(port) is int: port = "tcp:%d" % port self.port = port # for comparison later self.checker = checker # to maybe compare later def makeNamespace(): master = self.master namespace = { 'master': master, 'status': master.getStatus(), 'show': show, } return namespace def makeProtocol(): namespace = makeNamespace() p = insults.ServerProtocol(manhole.ColoredManhole, namespace) return p self.using_ssh = using_ssh if using_ssh: r = manhole_ssh.TerminalRealm() r.chainedProtocolFactory = makeProtocol p = portal.Portal(r, [self.checker]) f = manhole_ssh.ConchFactory(p) else: r = _TelnetRealm(makeNamespace) p = portal.Portal(r, [self.checker]) f = protocol.ServerFactory() f.protocol = makeTelnetProtocol(p) s = strports.service(self.port, f) s.setServiceParent(self) def startService(self): service.MultiService.startService(self) if self.using_ssh: via = "via SSH" else: via = "via telnet" log.msg("Manhole listening %s on port %s" % (via, self.port)) class TelnetManhole(_BaseManhole, ComparableMixin): """This Manhole accepts unencrypted (telnet) connections, and requires a username and password authorize access. You are encouraged to use the encrypted ssh-based manhole classes instead.""" compare_attrs = ["port", "username", "password"] def __init__(self, port, username, password): """ @type port: string or int @param port: what port should the Manhole listen on? This is a strports specification string, like 'tcp:12345' or 'tcp:12345:interface=127.0.0.1'. Bare integers are treated as a simple tcp port. @param username: @param password: username= and password= form a pair of strings to use when authenticating the remote user. """ self.username = username self.password = password c = checkers.InMemoryUsernamePasswordDatabaseDontUse() c.addUser(username, password) _BaseManhole.__init__(self, port, c, using_ssh=False) class PasswordManhole(_BaseManhole, ComparableMixin): """This Manhole accepts encrypted (ssh) connections, and requires a username and password to authorize access. """ compare_attrs = ["port", "username", "password"] def __init__(self, port, username, password): """ @type port: string or int @param port: what port should the Manhole listen on? This is a strports specification string, like 'tcp:12345' or 'tcp:12345:interface=127.0.0.1'. Bare integers are treated as a simple tcp port. @param username: @param password: username= and password= form a pair of strings to use when authenticating the remote user. """ if not manhole_ssh: config.error("pycrypto required for ssh mahole.") self.username = username self.password = password c = checkers.InMemoryUsernamePasswordDatabaseDontUse() c.addUser(username, password) _BaseManhole.__init__(self, port, c) class AuthorizedKeysManhole(_BaseManhole, ComparableMixin): """This Manhole accepts ssh connections, and requires that the prospective client have an ssh private key that matches one of the public keys in our authorized_keys file. It is created with the name of a file that contains the public keys that we will accept.""" compare_attrs = ["port", "keyfile"] def __init__(self, port, keyfile): """ @type port: string or int @param port: what port should the Manhole listen on? This is a strports specification string, like 'tcp:12345' or 'tcp:12345:interface=127.0.0.1'. Bare integers are treated as a simple tcp port. @param keyfile: the name of a file (relative to the buildmaster's basedir) that contains SSH public keys of authorized users, one per line. This is the exact same format as used by sshd in ~/.ssh/authorized_keys . """ if not manhole_ssh: config.error("pycrypto required for ssh mahole.") # TODO: expanduser this, and make it relative to the buildmaster's # basedir self.keyfile = keyfile c = AuthorizedKeysChecker(keyfile) _BaseManhole.__init__(self, port, c) class ArbitraryCheckerManhole(_BaseManhole, ComparableMixin): """This Manhole accepts ssh connections, but uses an arbitrary user-supplied 'checker' object to perform authentication.""" compare_attrs = ["port", "checker"] def __init__(self, port, checker): """ @type port: string or int @param port: what port should the Manhole listen on? This is a strports specification string, like 'tcp:12345' or 'tcp:12345:interface=127.0.0.1'. Bare integers are treated as a simple tcp port. @param checker: an instance of a twisted.cred 'checker' which will perform authentication """ if not manhole_ssh: config.error("pycrypto required for ssh mahole.") _BaseManhole.__init__(self, port, checker) ## utility functions for the manhole def show(x): """Display the data attributes of an object in a readable format""" print "data attributes of %r" % (x,) names = dir(x) maxlen = max([0] + [len(n) for n in names]) for k in names: v = getattr(x,k) t = type(v) if t == types.MethodType: continue if k[:2] == '__' and k[-2:] == '__': continue if t is types.StringType or t is types.UnicodeType: if len(v) > 80 - maxlen - 5: v = `v[:80 - maxlen - 5]` + "..." elif t in (types.IntType, types.NoneType): v = str(v) elif v in (types.ListType, types.TupleType, types.DictType): v = "%s (%d elements)" % (v, len(v)) else: v = str(t) print "%*s : %s" % (maxlen, k, v) return x buildbot-0.8.8/buildbot/master.py000066400000000000000000000702051222546025000170060ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os import signal import socket from zope.interface import implements from twisted.python import log, components, failure from twisted.internet import defer, reactor, task from twisted.application import service import buildbot import buildbot.pbmanager from buildbot.util import subscription, epoch2datetime from buildbot.status.master import Status from buildbot.changes import changes from buildbot.changes.manager import ChangeManager from buildbot import interfaces from buildbot.process.builder import BuilderControl from buildbot.db import connector from buildbot.schedulers.manager import SchedulerManager from buildbot.process.botmaster import BotMaster from buildbot.process import debug from buildbot.process import metrics from buildbot.process import cache from buildbot.process.users import users from buildbot.process.users.manager import UserManagerManager from buildbot.status.results import SUCCESS, WARNINGS, FAILURE from buildbot.util.eventual import eventually from buildbot import monkeypatches from buildbot import config ######################################## class LogRotation(object): def __init__(self): self.rotateLength = 1 * 1000 * 1000 self.maxRotatedFiles = 10 class BuildMaster(config.ReconfigurableServiceMixin, service.MultiService): # frequency with which to reclaim running builds; this should be set to # something fairly long, to avoid undue database load RECLAIM_BUILD_INTERVAL = 10*60 # multiplier on RECLAIM_BUILD_INTERVAL at which a build is considered # unclaimed; this should be at least 2 to avoid false positives UNCLAIMED_BUILD_FACTOR = 6 # if this quantity of unclaimed build requests are present in the table, # then something is probably wrong! The master will log a WARNING on every # database poll operation. WARNING_UNCLAIMED_COUNT = 10000 def __init__(self, basedir, configFileName="master.cfg", umask=None): service.MultiService.__init__(self) self.setName("buildmaster") self.umask = umask self.basedir = basedir assert os.path.isdir(self.basedir) self.configFileName = configFileName # set up child services self.create_child_services() # loop for polling the db self.db_loop = None # db configured values self.configured_db_url = None self.configured_poll_interval = None # configuration / reconfiguration handling self.config = config.MasterConfig() self.reconfig_active = False self.reconfig_requested = False self.reconfig_notifier = None # this stores parameters used in the tac file, and is accessed by the # WebStatus to duplicate those values. self.log_rotation = LogRotation() # subscription points self._change_subs = \ subscription.SubscriptionPoint("changes") self._new_buildrequest_subs = \ subscription.SubscriptionPoint("buildrequest_additions") self._new_buildset_subs = \ subscription.SubscriptionPoint("buildset_additions") self._complete_buildset_subs = \ subscription.SubscriptionPoint("buildset_completion") # local cache for this master's object ID self._object_id = None def create_child_services(self): # note that these are order-dependent. If you get the order wrong, # you'll know it, as the master will fail to start. self.metrics = metrics.MetricLogObserver() self.metrics.setServiceParent(self) self.caches = cache.CacheManager() self.caches.setServiceParent(self) self.pbmanager = buildbot.pbmanager.PBManager() self.pbmanager.setServiceParent(self) self.change_svc = ChangeManager(self) self.change_svc.setServiceParent(self) self.botmaster = BotMaster(self) self.botmaster.setServiceParent(self) self.scheduler_manager = SchedulerManager(self) self.scheduler_manager.setServiceParent(self) self.user_manager = UserManagerManager(self) self.user_manager.setServiceParent(self) self.db = connector.DBConnector(self, self.basedir) self.db.setServiceParent(self) self.debug = debug.DebugServices(self) self.debug.setServiceParent(self) self.status = Status(self) self.status.setServiceParent(self) # setup and reconfig handling _already_started = False @defer.inlineCallbacks def startService(self, _reactor=reactor): assert not self._already_started, "can only start the master once" self._already_started = True log.msg("Starting BuildMaster -- buildbot.version: %s" % buildbot.version) # Set umask if self.umask is not None: os.umask(self.umask) # first, apply all monkeypatches monkeypatches.patch_all() # we want to wait until the reactor is running, so we can call # reactor.stop() for fatal errors d = defer.Deferred() _reactor.callWhenRunning(d.callback, None) yield d try: # load the configuration file, treating errors as fatal try: self.config = config.MasterConfig.loadConfig(self.basedir, self.configFileName) except config.ConfigErrors, e: log.msg("Configuration Errors:") for msg in e.errors: log.msg(" " + msg) log.msg("Halting master.") _reactor.stop() return except: log.err(failure.Failure(), 'while starting BuildMaster') _reactor.stop() return # set up services that need access to the config before everything else # gets told to reconfig try: yield self.db.setup() except connector.DatabaseNotReadyError: # (message was already logged) _reactor.stop() return if hasattr(signal, "SIGHUP"): def sighup(*args): eventually(self.reconfig) signal.signal(signal.SIGHUP, sighup) if hasattr(signal, "SIGUSR1"): def sigusr1(*args): _reactor.callLater(0, self.botmaster.cleanShutdown) signal.signal(signal.SIGUSR1, sigusr1) # call the parent method yield defer.maybeDeferred(lambda : service.MultiService.startService(self)) # give all services a chance to load the new configuration, rather than # the base configuration yield self.reconfigService(self.config) except: f = failure.Failure() log.err(f, 'while starting BuildMaster') _reactor.stop() log.msg("BuildMaster is running") @defer.inlineCallbacks def stopService(self): if self.running: yield service.MultiService.stopService(self) if self.db_loop: self.db_loop.stop() self.db_loop = None def reconfig(self): # this method wraps doConfig, ensuring it is only ever called once at # a time, and alerting the user if the reconfig takes too long if self.reconfig_active: log.msg("reconfig already active; will reconfig again after") self.reconfig_requested = True return self.reconfig_active = reactor.seconds() metrics.MetricCountEvent.log("loaded_config", 1) # notify every 10 seconds that the reconfig is still going on, although # reconfigs should not take that long! self.reconfig_notifier = task.LoopingCall(lambda : log.msg("reconfig is ongoing for %d s" % (reactor.seconds() - self.reconfig_active))) self.reconfig_notifier.start(10, now=False) timer = metrics.Timer("BuildMaster.reconfig") timer.start() d = self.doReconfig() @d.addBoth def cleanup(res): timer.stop() self.reconfig_notifier.stop() self.reconfig_notifier = None self.reconfig_active = False if self.reconfig_requested: self.reconfig_requested = False self.reconfig() return res d.addErrback(log.err, 'while reconfiguring') return d # for tests @defer.inlineCallbacks def doReconfig(self): log.msg("beginning configuration update") changes_made = False failed = False try: new_config = config.MasterConfig.loadConfig(self.basedir, self.configFileName) changes_made = True self.config = new_config yield self.reconfigService(new_config) except config.ConfigErrors, e: for msg in e.errors: log.msg(msg) failed = True except: log.err(failure.Failure(), 'during reconfig:') failed = True if failed: if changes_made: log.msg("WARNING: reconfig partially applied; master " "may malfunction") else: log.msg("reconfig aborted without making any changes") else: log.msg("configuration update complete") def reconfigService(self, new_config): if self.configured_db_url is None: self.configured_db_url = new_config.db['db_url'] elif (self.configured_db_url != new_config.db['db_url']): config.error( "Cannot change c['db']['db_url'] after the master has started", ) # adjust the db poller if (self.configured_poll_interval != new_config.db['db_poll_interval']): if self.db_loop: self.db_loop.stop() self.db_loop = None self.configured_poll_interval = new_config.db['db_poll_interval'] if self.configured_poll_interval: self.db_loop = task.LoopingCall(self.pollDatabase) self.db_loop.start(self.configured_poll_interval, now=False) return config.ReconfigurableServiceMixin.reconfigService(self, new_config) ## informational methods def allSchedulers(self): return list(self.scheduler_manager) def getStatus(self): """ @rtype: L{buildbot.status.builder.Status} """ return self.status def getObjectId(self): """ Return the obejct id for this master, for associating state with the master. @returns: ID, via Deferred """ # try to get the cached value if self._object_id is not None: return defer.succeed(self._object_id) # failing that, get it from the DB; multiple calls to this function # at the same time will not hurt try: hostname = os.uname()[1] # only on unix except AttributeError: hostname = socket.getfqdn() master_name = "%s:%s" % (hostname, os.path.abspath(self.basedir)) d = self.db.state.getObjectId(master_name, "buildbot.master.BuildMaster") def keep(id): self._object_id = id return id d.addCallback(keep) return d ## triggering methods and subscriptions def addChange(self, who=None, files=None, comments=None, author=None, isdir=None, is_dir=None, revision=None, when=None, when_timestamp=None, branch=None, category=None, revlink='', properties={}, repository='', codebase=None, project='', src=None): """ Add a change to the buildmaster and act on it. This is a wrapper around L{ChangesConnectorComponent.addChange} which also acts on the resulting change and returns a L{Change} instance. Note that all parameters are keyword arguments, although C{who}, C{files}, and C{comments} can be specified positionally for backward-compatibility. @param author: the author of this change @type author: unicode string @param who: deprecated name for C{author} @param files: a list of filenames that were changed @type branch: list of unicode strings @param comments: user comments on the change @type branch: unicode string @param is_dir: deprecated @param isdir: deprecated name for C{is_dir} @param revision: the revision identifier for this change @type revision: unicode string @param when_timestamp: when this change occurred, or the current time if None @type when_timestamp: datetime instance or None @param when: deprecated name and type for C{when_timestamp} @type when: integer (UNIX epoch time) or None @param branch: the branch on which this change took place @type branch: unicode string @param category: category for this change (arbitrary use by Buildbot users) @type category: unicode string @param revlink: link to a web view of this revision @type revlink: unicode string @param properties: properties to set on this change @type properties: dictionary with string keys and simple values (JSON-able). Note that the property source is I{not} included in this dictionary. @param repository: the repository in which this change took place @type repository: unicode string @param project: the project this change is a part of @type project: unicode string @param src: source of the change (vcs or other) @type src: string @returns: L{Change} instance via Deferred """ metrics.MetricCountEvent.log("added_changes", 1) # handle translating deprecated names into new names for db.changes def handle_deprec(oldname, old, newname, new, default=None, converter = lambda x:x): if old is not None: if new is None: log.msg("WARNING: change source is using deprecated " "addChange parameter '%s'" % oldname) return converter(old) raise TypeError("Cannot provide '%s' and '%s' to addChange" % (oldname, newname)) if new is None: new = default return new author = handle_deprec("who", who, "author", author) is_dir = handle_deprec("isdir", isdir, "is_dir", is_dir, default=0) when_timestamp = handle_deprec("when", when, "when_timestamp", when_timestamp, converter=epoch2datetime) # add a source to each property for n in properties: properties[n] = (properties[n], 'Change') if codebase is None: if self.config.codebaseGenerator is not None: chdict = { 'changeid': None, 'author': author, 'files': files, 'comments': comments, 'is_dir': is_dir, 'revision': revision, 'when_timestamp': when_timestamp, 'branch': branch, 'category': category, 'revlink': revlink, 'properties': properties, 'repository': repository, 'project': project, } codebase = self.config.codebaseGenerator(chdict) else: codebase = '' d = defer.succeed(None) if src: # create user object, returning a corresponding uid d.addCallback(lambda _ : users.createUserObject(self, author, src)) # add the Change to the database d.addCallback(lambda uid : self.db.changes.addChange(author=author, files=files, comments=comments, is_dir=is_dir, revision=revision, when_timestamp=when_timestamp, branch=branch, category=category, revlink=revlink, properties=properties, repository=repository, codebase=codebase, project=project, uid=uid)) # convert the changeid to a Change instance d.addCallback(lambda changeid : self.db.changes.getChange(changeid)) d.addCallback(lambda chdict : changes.Change.fromChdict(self, chdict)) def notify(change): msg = u"added change %s to database" % change log.msg(msg.encode('utf-8', 'replace')) # only deliver messages immediately if we're not polling if not self.config.db['db_poll_interval']: self._change_subs.deliver(change) return change d.addCallback(notify) return d def subscribeToChanges(self, callback): """ Request that C{callback} be called with each Change object added to the cluster. Note: this method will go away in 0.9.x """ return self._change_subs.subscribe(callback) def addBuildset(self, **kwargs): """ Add a buildset to the buildmaster and act on it. Interface is identical to L{buildbot.db.buildsets.BuildsetConnectorComponent.addBuildset}, including returning a Deferred, but also potentially triggers the resulting builds. """ d = self.db.buildsets.addBuildset(**kwargs) def notify((bsid,brids)): log.msg("added buildset %d to database" % bsid) # note that buildset additions are only reported on this master self._new_buildset_subs.deliver(bsid=bsid, **kwargs) # only deliver messages immediately if we're not polling if not self.config.db['db_poll_interval']: for bn, brid in brids.iteritems(): self.buildRequestAdded(bsid=bsid, brid=brid, buildername=bn) return (bsid,brids) d.addCallback(notify) return d def subscribeToBuildsets(self, callback): """ Request that C{callback(bsid=bsid, ssid=ssid, reason=reason, properties=properties, builderNames=builderNames, external_idstring=external_idstring)} be called whenever a buildset is added. Properties is a dictionary as expected for L{BuildsetsConnectorComponent.addBuildset}. Note that this only works for buildsets added on this master. Note: this method will go away in 0.9.x """ return self._new_buildset_subs.subscribe(callback) @defer.inlineCallbacks def maybeBuildsetComplete(self, bsid): """ Instructs the master to check whether the buildset is complete, and notify appropriately if it is. Note that buildset completions are only reported on the master on which the last build request completes. """ brdicts = yield self.db.buildrequests.getBuildRequests( bsid=bsid, complete=False) # if there are incomplete buildrequests, bail out if brdicts: return brdicts = yield self.db.buildrequests.getBuildRequests(bsid=bsid) # figure out the overall results of the buildset cumulative_results = SUCCESS for brdict in brdicts: if brdict['results'] not in (SUCCESS, WARNINGS): cumulative_results = FAILURE # mark it as completed in the database yield self.db.buildsets.completeBuildset(bsid, cumulative_results) # and deliver to any listeners self._buildsetComplete(bsid, cumulative_results) def _buildsetComplete(self, bsid, results): self._complete_buildset_subs.deliver(bsid, results) def subscribeToBuildsetCompletions(self, callback): """ Request that C{callback(bsid, result)} be called whenever a buildset is complete. Note: this method will go away in 0.9.x """ return self._complete_buildset_subs.subscribe(callback) def buildRequestAdded(self, bsid, brid, buildername): """ Notifies the master that a build request is available to be claimed; this may be a brand new build request, or a build request that was previously claimed and unclaimed through a timeout or other calamity. @param bsid: containing buildset id @param brid: buildrequest ID @param buildername: builder named by the build request """ self._new_buildrequest_subs.deliver( dict(bsid=bsid, brid=brid, buildername=buildername)) def subscribeToBuildRequests(self, callback): """ Request that C{callback} be invoked with a dictionary with keys C{brid} (the build request id), C{bsid} (buildset id) and C{buildername} whenever a new build request is added to the database. Note that, due to the delayed nature of subscriptions, the build request may already be claimed by the time C{callback} is invoked. Note: this method will go away in 0.9.x """ return self._new_buildrequest_subs.subscribe(callback) ## database polling def pollDatabase(self): # poll each of the tables that can indicate new, actionable stuff for # this buildmaster to do. This is used in a TimerService, so returning # a Deferred means that we won't run two polling operations # simultaneously. Each particular poll method handles errors itself, # although catastrophic errors are handled here d = defer.gatherResults([ self.pollDatabaseChanges(), self.pollDatabaseBuildRequests(), # also unclaim ]) d.addErrback(log.err, 'while polling database') return d _last_processed_change = None @defer.inlineCallbacks def pollDatabaseChanges(self): # Older versions of Buildbot had each scheduler polling the database # independently, and storing a "last_processed" state indicating the # last change it had processed. This had the advantage of allowing # schedulers to pick up changes that arrived in the database while # the scheduler was not running, but was horribly inefficient. # This version polls the database on behalf of the schedulers, using a # similar state at the master level. timer = metrics.Timer("BuildMaster.pollDatabaseChanges()") timer.start() need_setState = False # get the last processed change id if self._last_processed_change is None: self._last_processed_change = \ yield self._getState('last_processed_change') # if it's still None, assume we've processed up to the latest changeid if self._last_processed_change is None: lpc = yield self.db.changes.getLatestChangeid() # if there *are* no changes, count the last as '0' so that we don't # skip the first change if lpc is None: lpc = 0 self._last_processed_change = lpc need_setState = True if self._last_processed_change is None: timer.stop() return while True: changeid = self._last_processed_change + 1 chdict = yield self.db.changes.getChange(changeid) # if there's no such change, we've reached the end and can # stop polling if not chdict: break change = yield changes.Change.fromChdict(self, chdict) self._change_subs.deliver(change) self._last_processed_change = changeid need_setState = True # write back the updated state, if it's changed if need_setState: yield self._setState('last_processed_change', self._last_processed_change) timer.stop() _last_unclaimed_brids_set = None _last_claim_cleanup = 0 @defer.inlineCallbacks def pollDatabaseBuildRequests(self): # deal with cleaning up unclaimed requests, and (if necessary) # requests from a previous instance of this master timer = metrics.Timer("BuildMaster.pollDatabaseBuildRequests()") timer.start() # cleanup unclaimed builds since_last_cleanup = reactor.seconds() - self._last_claim_cleanup if since_last_cleanup < self.RECLAIM_BUILD_INTERVAL: unclaimed_age = (self.RECLAIM_BUILD_INTERVAL * self.UNCLAIMED_BUILD_FACTOR) yield self.db.buildrequests.unclaimExpiredRequests(unclaimed_age) self._last_claim_cleanup = reactor.seconds() # _last_unclaimed_brids_set tracks the state of unclaimed build # requests; whenever it sees a build request which was not claimed on # the last poll, it notifies the subscribers. It only tracks that # state within the master instance, though; on startup, it notifies for # all unclaimed requests in the database. last_unclaimed = self._last_unclaimed_brids_set or set() if len(last_unclaimed) > self.WARNING_UNCLAIMED_COUNT: log.msg("WARNING: %d unclaimed buildrequests - is a scheduler " "producing builds for which no builder is running?" % len(last_unclaimed)) # get the current set of unclaimed buildrequests now_unclaimed_brdicts = \ yield self.db.buildrequests.getBuildRequests(claimed=False) now_unclaimed = set([ brd['brid'] for brd in now_unclaimed_brdicts ]) # and store that for next time self._last_unclaimed_brids_set = now_unclaimed # see what's new, and notify if anything is new_unclaimed = now_unclaimed - last_unclaimed if new_unclaimed: brdicts = dict((brd['brid'], brd) for brd in now_unclaimed_brdicts) for brid in new_unclaimed: brd = brdicts[brid] self.buildRequestAdded(brd['buildsetid'], brd['brid'], brd['buildername']) timer.stop() ## state maintenance (private) def _getState(self, name, default=None): "private wrapper around C{self.db.state.getState}" d = self.getObjectId() def get(objectid): return self.db.state.getState(objectid, name, default) d.addCallback(get) return d def _setState(self, name, value): "private wrapper around C{self.db.state.setState}" d = self.getObjectId() def set(objectid): return self.db.state.setState(objectid, name, value) d.addCallback(set) return d class Control: implements(interfaces.IControl) def __init__(self, master): self.master = master def addChange(self, change): self.master.addChange(change) def addBuildset(self, **kwargs): return self.master.addBuildset(**kwargs) def getBuilder(self, name): b = self.master.botmaster.builders[name] return BuilderControl(b, self) components.registerAdapter(Control, BuildMaster, interfaces.IControl) buildbot-0.8.8/buildbot/monkeypatches/000077500000000000000000000000001222546025000200075ustar00rootroot00000000000000buildbot-0.8.8/buildbot/monkeypatches/__init__.py000066400000000000000000000060241222546025000221220ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import twisted from twisted.python import versions from buildbot.util import sautils # NOTE: all of these patches test for applicability *before* importing the # patch module. This will help cut down on unnecessary imports where the # patches are not needed, and also avoid problems with patches importing # private things in external libraries that no longer exist. def patch_bug4881(): # this patch doesn't apply (or even import!) on Windows import sys if sys.platform == 'win32': return # this bug was only present in Twisted-10.2.0 if twisted.version == versions.Version('twisted', 10, 2, 0): from buildbot.monkeypatches import bug4881 bug4881.patch() def patch_bug4520(): # this bug was patched in twisted-11.1.0, and only affects py26 and up import sys py_26 = (sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 6)) if twisted.version < versions.Version('twisted', 11, 1, 0) and py_26: from buildbot.monkeypatches import bug4520 bug4520.patch() def patch_bug5079(): # this bug is patched in Twisted-12.0.0; it was probably # present in Twisted-8.x.0, but the patch doesn't work if (twisted.version < versions.Version('twisted', 12, 0, 0) and twisted.version >= versions.Version('twisted', 9, 0, 0)): from buildbot.monkeypatches import bug5079 bug5079.patch() def patch_sqlalchemy2364(): # fix for SQLAlchemy bug 2364 if sautils.sa_version() < (0,7,5): from buildbot.monkeypatches import sqlalchemy2364 sqlalchemy2364.patch() def patch_sqlalchemy2189(): # fix for SQLAlchemy bug 2189 if sautils.sa_version() <= (0,7,1): from buildbot.monkeypatches import sqlalchemy2189 sqlalchemy2189.patch() def patch_gatherResults(): if twisted.version < versions.Version('twisted', 11, 1, 0): from buildbot.monkeypatches import gatherResults gatherResults.patch() def patch_all(for_tests=False): patch_bug4881() patch_bug4520() patch_bug5079() patch_sqlalchemy2364() patch_sqlalchemy2189() patch_gatherResults() if for_tests: from buildbot.monkeypatches import servicechecks servicechecks.patch_servicechecks() from buildbot.monkeypatches import testcase_patch testcase_patch.patch_testcase_patch() buildbot-0.8.8/buildbot/monkeypatches/bug4520.py000066400000000000000000000041101222546025000214450ustar00rootroot00000000000000# coding=utf-8 # This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.spread import pb from twisted.python import log def patch(): log.msg("Applying patch for http://twistedmatrix.com/trac/ticket/4520") pb.RemoteError = RemoteError pb.CopiedFailure.throwExceptionIntoGenerator = \ CopiedFailure_throwExceptionIntoGenerator old_getStateToCopy = pb.CopyableFailure.getStateToCopy def getStateToCopy(self): state = old_getStateToCopy(self) state['value'] = str(self.value) # Exception instance return state ############################################################################# # Everything below this line was taken from Twisted, except as annotated. See # http://twistedmatrix.com/trac/changeset/32211 # # Merge copiedfailure-stringexc-4520 # # Author: sirgolan, Koblaid, glyph # Reviewer: exarkun, glyph # Fixes: #4520 # # Allow inlineCallbacks and exceptions raised from a twisted.spread remote # call to work together. A new RemoteError exception will be raised into # the generator when a yielded Deferred fails with a remote PB failure. class RemoteError(Exception): def __init__(self, remoteType, value, remoteTraceback): Exception.__init__(self, value) self.remoteType = remoteType self.remoteTraceback = remoteTraceback def CopiedFailure_throwExceptionIntoGenerator(self, g): return g.throw(RemoteError(self.type, self.value, self.traceback)) buildbot-0.8.8/buildbot/monkeypatches/bug4881.py000066400000000000000000000152331222546025000214670ustar00rootroot00000000000000# coding=utf-8 # This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os from twisted.internet import process from twisted.python import log def patch(): log.msg("Applying patch for http://twistedmatrix.com/trac/ticket/4881") process._listOpenFDs = _listOpenFDs ############################################################################# # Everything below this line was taken verbatim from Twisted, except as # annotated. ######## # r31474:trunk/LICENSE # Copyright (c) 2001-2010 # Allen Short # Andy Gayton # Andrew Bennetts # Antoine Pitrou # Apple Computer, Inc. # Benjamin Bruheim # Bob Ippolito # Canonical Limited # Christopher Armstrong # David Reid # Donovan Preston # Eric Mangold # Eyal Lotem # Itamar Shtull-Trauring # James Knight # Jason A. Mobarak # Jean-Paul Calderone # Jessica McKellar # Jonathan Jacobs # Jonathan Lange # Jonathan D. Simms # Jürgen Hermann # Kevin Horn # Kevin Turner # Mary Gardiner # Matthew Lefkowitz # Massachusetts Institute of Technology # Moshe Zadka # Paul Swartz # Pavel Pergamenshchik # Ralph Meijer # Sean Riley # Software Freedom Conservancy # Travis B. Hartwell # Thijs Triemstra # Thomas Herve # Timothy Allen # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ######## # r31474:trunk/twisted/internet/process.py # Copyright (c) Twisted Matrix Laboratories. # See LICENSE for details. class _FDDetector(object): """ This class contains the logic necessary to decide which of the available system techniques should be used to detect the open file descriptors for the current process. The chosen technique gets monkey-patched into the _listOpenFDs method of this class so that the detection only needs to occur once. @ivars listdir: The implementation of listdir to use. This gets overwritten by the test cases. @ivars getpid: The implementation of getpid to use, returns the PID of the running process. @ivars openfile: The implementation of open() to use, by default the Python builtin. """ # So that we can unit test this listdir = os.listdir getpid = os.getpid openfile = open def _listOpenFDs(self): """ Figure out which implementation to use, then run it. """ self._listOpenFDs = self._getImplementation() return self._listOpenFDs() def _getImplementation(self): """ Check if /dev/fd works, if so, use that. Otherwise, check if /proc/%d/fd exists, if so use that. Otherwise, ask resource.getrlimit, if that throws an exception, then fallback to _fallbackFDImplementation. """ try: self.listdir("/dev/fd") if self._checkDevFDSanity(): # FreeBSD support :-) return self._devFDImplementation else: return self._fallbackFDImplementation except: try: self.listdir("/proc/%d/fd" % (self.getpid(),)) return self._procFDImplementation except: try: self._resourceFDImplementation() # Imports resource return self._resourceFDImplementation except: return self._fallbackFDImplementation def _checkDevFDSanity(self): """ Returns true iff opening a file modifies the fds visible in /dev/fd, as it should on a sane platform. """ start = self.listdir("/dev/fd") self.openfile("/dev/null", "r") # changed in Buildbot to hush pyflakes end = self.listdir("/dev/fd") return start != end def _devFDImplementation(self): """ Simple implementation for systems where /dev/fd actually works. See: http://www.freebsd.org/cgi/man.cgi?fdescfs """ dname = "/dev/fd" result = [int(fd) for fd in os.listdir(dname)] return result def _procFDImplementation(self): """ Simple implementation for systems where /proc/pid/fd exists (we assume it works). """ dname = "/proc/%d/fd" % (os.getpid(),) return [int(fd) for fd in os.listdir(dname)] def _resourceFDImplementation(self): """ Fallback implementation where the resource module can inform us about how many FDs we can expect. Note that on OS-X we expect to be using the /dev/fd implementation. """ import resource maxfds = resource.getrlimit(resource.RLIMIT_NOFILE)[1] + 1 # OS-X reports 9223372036854775808. That's a lot of fds # to close if maxfds > 1024: maxfds = 1024 return xrange(maxfds) def _fallbackFDImplementation(self): """ Fallback-fallback implementation where we just assume that we need to close 256 FDs. """ maxfds = 256 return xrange(maxfds) detector = _FDDetector() def _listOpenFDs(): """ Use the global detector object to figure out which FD implementation to use. """ return detector._listOpenFDs() buildbot-0.8.8/buildbot/monkeypatches/bug5079.py000066400000000000000000000035361222546025000214720ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.spread import pb from twisted.python import log from twisted.spread.interfaces import IJellyable def patch(): log.msg("Applying patch for http://twistedmatrix.com/trac/ticket/5079") if not hasattr(pb, '_JellyableAvatarMixin'): log.msg("..patch not applicable; please file a bug at buildbot.net") else: pb._JellyableAvatarMixin._cbLogin = _fixed_cbLogin def _fixed_cbLogin(self, (interface, avatar, logout)): """ Ensure that the avatar to be returned to the client is jellyable and set up disconnection notification to call the realm's logout object. """ if not IJellyable.providedBy(avatar): avatar = pb.AsReferenceable(avatar, "perspective") puid = avatar.processUniqueID() # only call logout once, whether the connection is dropped (disconnect) # or a logout occurs (cleanup), and be careful to drop the reference to # it in either case logout = [ logout ] def maybeLogout(): if not logout: return fn = logout[0] del logout[0] fn() self.broker._localCleanup[puid] = maybeLogout self.broker.notifyOnDisconnect(maybeLogout) return avatar buildbot-0.8.8/buildbot/monkeypatches/gatherResults.py000066400000000000000000000054021222546025000232160ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.internet import defer def patch(): """ Patch gatherResults to support consumeErrors on old versions of twisted """ defer.gatherResults = gatherResults ############################################################################# # Everything below this line was taken from Twisted, except as annotated. See # https://twistedmatrix.com/trac/browser/trunk/twisted/internet/defer.py?rev=32405#L805 # # Merge gatherresults-consumeerrors-5159 # # Author: dustin # Reviewer: exarkun # Fixes: #5159 # # Add a `consumeErrors` parameter to `twisted.internet.defer.gatherResults` # with the same meaning as the parameter of the same name accepted by # `DeferredList`. def _parseDListResult(l, fireOnOneErrback=False): if __debug__: for success, value in l: assert success return [x[1] for x in l] def gatherResults(deferredList, consumeErrors=False): """ Returns, via a L{Deferred}, a list with the results of the given L{Deferred}s - in effect, a "join" of multiple deferred operations. The returned L{Deferred} will fire when I{all} of the provided L{Deferred}s have fired, or when any one of them has failed. This differs from L{DeferredList} in that you don't need to parse the result for success/failure. @type deferredList: C{list} of L{Deferred}s @param consumeErrors: (keyword param) a flag, defaulting to False, indicating that failures in any of the given L{Deferreds} should not be propagated to errbacks added to the individual L{Deferreds} after this L{gatherResults} invocation. Any such errors in the individual L{Deferred}s will be converted to a callback result of C{None}. This is useful to prevent spurious 'Unhandled error in Deferred' messages from being logged. This parameter is available since 11.1.0. @type consumeErrors: C{bool} """ d = defer.DeferredList(deferredList, fireOnOneErrback=True, consumeErrors=consumeErrors) d.addCallback(_parseDListResult) return d buildbot-0.8.8/buildbot/monkeypatches/servicechecks.py000066400000000000000000000023731222546025000232070ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members def patch_servicechecks(): """ Patch startService and stopService so that they check the previous state first. (used for debugging only) """ from twisted.application.service import Service old_startService = Service.startService old_stopService = Service.stopService def startService(self): assert not self.running return old_startService(self) def stopService(self): assert self.running return old_stopService(self) Service.startService = startService Service.stopService = stopService buildbot-0.8.8/buildbot/monkeypatches/sqlalchemy2189.py000066400000000000000000000105321222546025000230500ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import re from buildbot.util import sautils from sqlalchemy.engine import reflection from sqlalchemy.dialects.sqlite.base import SQLiteDialect, _pragma_cursor from sqlalchemy.dialects.sqlite.base import sqltypes, util @reflection.cache def get_columns_06x_fixed(self, connection, table_name, schema=None, **kw): quote = self.identifier_preparer.quote_identifier if schema is not None: pragma = "PRAGMA %s." % quote(schema) else: pragma = "PRAGMA " qtable = quote(table_name) c = _pragma_cursor(connection.execute("%stable_info(%s)" % (pragma, qtable))) #### found_table = False (pyflake) columns = [] while True: row = c.fetchone() if row is None: break (name, type_, nullable, default, has_default, primary_key) = (row[1], row[2].upper(), not row[3], row[4], row[4] is not None, row[5]) name = re.sub(r'^\"|\"$', '', name) #### if default: #### default = re.sub(r"^\'|\'$", '', default) match = re.match(r'(\w+)(\(.*?\))?', type_) if match: coltype = match.group(1) args = match.group(2) else: coltype = "VARCHAR" args = '' try: coltype = self.ischema_names[coltype] except KeyError: util.warn("Did not recognize type '%s' of column '%s'" % (coltype, name)) coltype = sqltypes.NullType if args is not None: args = re.findall(r'(\d+)', args) coltype = coltype(*[int(a) for a in args]) columns.append({ 'name' : name, 'type' : coltype, 'nullable' : nullable, 'default' : default, 'primary_key': primary_key }) return columns @reflection.cache def get_columns_07x_fixed(self, connection, table_name, schema=None, **kw): quote = self.identifier_preparer.quote_identifier if schema is not None: pragma = "PRAGMA %s." % quote(schema) else: pragma = "PRAGMA " qtable = quote(table_name) c = _pragma_cursor(connection.execute("%stable_info(%s)" % (pragma, qtable))) #### found_table = False (pyflake) columns = [] while True: row = c.fetchone() if row is None: break (name, type_, nullable, default, has_default, primary_key) = (row[1], row[2].upper(), not row[3], row[4], row[4] is not None, row[5]) name = re.sub(r'^\"|\"$', '', name) #### if default: #### default = re.sub(r"^\'|\'$", '', default) match = re.match(r'(\w+)(\(.*?\))?', type_) if match: coltype = match.group(1) args = match.group(2) else: coltype = "VARCHAR" args = '' try: coltype = self.ischema_names[coltype] if args is not None: args = re.findall(r'(\d+)', args) coltype = coltype(*[int(a) for a in args]) except KeyError: util.warn("Did not recognize type '%s' of column '%s'" % (coltype, name)) coltype = sqltypes.NullType() columns.append({ 'name' : name, 'type' : coltype, 'nullable' : nullable, 'default' : default, 'autoincrement':default is None, 'primary_key': primary_key }) return columns def patch(): # fix for http://www.sqlalchemy.org/trac/ticket/2189, backported to 0.6.0 if sautils.sa_version()[:2] == (0, 6): get_columns_fixed = get_columns_06x_fixed else: get_columns_fixed = get_columns_07x_fixed SQLiteDialect.get_columns = get_columns_fixed buildbot-0.8.8/buildbot/monkeypatches/sqlalchemy2364.py000066400000000000000000000022411222546025000230410ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members def patch(): # a fix for http://www.sqlalchemy.org/trac/ticket/2364 from sqlalchemy.dialects.sqlite.base import SQLiteDialect old_get_foreign_keys = SQLiteDialect.get_foreign_keys def get_foreign_keys_wrapper(*args, **kwargs): fkeys = old_get_foreign_keys(*args, **kwargs) # foreign keys don't have names for fkey in fkeys: fkey['name'] = None return fkeys SQLiteDialect.get_foreign_keys = get_foreign_keys_wrapper buildbot-0.8.8/buildbot/monkeypatches/testcase_patch.py000066400000000000000000000023541222546025000233570ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sys import twisted from twisted.trial import unittest def patch_testcase_patch(): """ Patch out TestCase.patch to skip the test on version combinations where it does not work. (used for debugging only) """ # Twisted-9.0.0 and earlier did not have a UnitTest.patch that worked on # Python-2.7 if twisted.version.major <= 9 and sys.version_info[:2] == (2,7): def nopatch(self, *args): raise unittest.SkipTest('unittest.TestCase.patch is not available') unittest.TestCase.patch = nopatch buildbot-0.8.8/buildbot/pbmanager.py000066400000000000000000000153331222546025000174500ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from twisted.spread import pb from twisted.python import failure, log from twisted.internet import defer from twisted.cred import portal, checkers, credentials, error from twisted.application import service, strports debug = False class PBManager(service.MultiService): """ A centralized manager for PB ports and authentication on them. Allows various pieces of code to request a (port, username) combo, along with a password and a perspective factory. """ def __init__(self): service.MultiService.__init__(self) self.setName('pbmanager') self.dispatchers = {} def register(self, portstr, username, password, pfactory): """ Register a perspective factory PFACTORY to be executed when a PB connection arrives on PORTSTR with USERNAME/PASSWORD. Returns a Registration object which can be used to unregister later. """ # do some basic normalization of portstrs if type(portstr) == type(0) or ':' not in portstr: portstr = "tcp:%s" % portstr reg = Registration(self, portstr, username) if portstr not in self.dispatchers: disp = self.dispatchers[portstr] = Dispatcher(portstr) disp.setServiceParent(self) else: disp = self.dispatchers[portstr] disp.register(username, password, pfactory) return reg def _unregister(self, registration): disp = self.dispatchers[registration.portstr] disp.unregister(registration.username) registration.username = None if not disp.users: disp = self.dispatchers[registration.portstr] del self.dispatchers[registration.portstr] return disp.disownServiceParent() return defer.succeed(None) class Registration(object): def __init__(self, pbmanager, portstr, username): self.portstr = portstr "portstr this registration is active on" self.username = username "username of this registration" self.pbmanager = pbmanager def __repr__(self): return "" % \ (self.username, self.portstr) def unregister(self): """ Unregister this registration, removing the username from the port, and closing the port if there are no more users left. Returns a Deferred. """ return self.pbmanager._unregister(self) def getPort(self): """ Helper method for testing; returns the TCP port used for this registration, even if it was specified as 0 and thus allocated by the OS. """ disp = self.pbmanager.dispatchers[self.portstr] return disp.port.getHost().port class Dispatcher(service.Service): implements(portal.IRealm, checkers.ICredentialsChecker) credentialInterfaces = [ credentials.IUsernamePassword, credentials.IUsernameHashedPassword ] def __init__(self, portstr): self.portstr = portstr self.users = {} # there's lots of stuff to set up for a PB connection! self.portal = portal.Portal(self) self.portal.registerChecker(self) self.serverFactory = pb.PBServerFactory(self.portal) self.serverFactory.unsafeTracebacks = True self.port = strports.listen(portstr, self.serverFactory) def __repr__(self): return "" % \ (", ".join(self.users.keys()), self.portstr) def stopService(self): # stop listening on the port when shut down d = defer.maybeDeferred(self.port.stopListening) d.addCallback(lambda _ : service.Service.stopService(self)) return d def register(self, username, password, pfactory): if debug: log.msg("registering username '%s' on pb port %s: %s" % (username, self.portstr, pfactory)) if username in self.users: raise KeyError, ("username '%s' is already registered on PB port %s" % (username, self.portstr)) self.users[username] = (password, pfactory) def unregister(self, username): if debug: log.msg("unregistering username '%s' on pb port %s" % (username, self.portstr)) del self.users[username] # IRealm def requestAvatar(self, username, mind, interface): assert interface == pb.IPerspective if username not in self.users: d = defer.succeed(None) # no perspective else: _, afactory = self.users.get(username) d = defer.maybeDeferred(afactory, mind, username) # check that we got a perspective def check(persp): if not persp: raise ValueError("no perspective for '%s'" % username) return persp d.addCallback(check) # call the perspective's attached(mind) def call_attached(persp): d = defer.maybeDeferred(persp.attached, mind) d.addCallback(lambda _ : persp) # keep returning the perspective return d d.addCallback(call_attached) # return the tuple requestAvatar is expected to return def done(persp): return (pb.IPerspective, persp, lambda: persp.detached(mind)) d.addCallback(done) return d # ICredentialsChecker def requestAvatarId(self, creds): if creds.username in self.users: password, _ = self.users[creds.username] d = defer.maybeDeferred(creds.checkPassword, password) def check(matched): if not matched: log.msg("invalid login from user '%s'" % creds.username) return failure.Failure(error.UnauthorizedLogin()) return creds.username d.addCallback(check) return d else: log.msg("invalid login from unknown user '%s'" % creds.username) return defer.fail(error.UnauthorizedLogin()) buildbot-0.8.8/buildbot/pbutil.py000066400000000000000000000135031222546025000170100ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members """Base classes handy for use with PB clients. """ from twisted.spread import pb from twisted.spread.pb import PBClientFactory from twisted.internet import protocol from twisted.python import log class NewCredPerspective(pb.Avatar): def attached(self, mind): return self def detached(self, mind): pass class ReconnectingPBClientFactory(PBClientFactory, protocol.ReconnectingClientFactory): """Reconnecting client factory for PB brokers. Like PBClientFactory, but if the connection fails or is lost, the factory will attempt to reconnect. Instead of using f.getRootObject (which gives a Deferred that can only be fired once), override the gotRootObject method. Instead of using the newcred f.login (which is also one-shot), call f.startLogin() with the credentials and client, and override the gotPerspective method. Instead of using the oldcred f.getPerspective (also one-shot), call f.startGettingPerspective() with the same arguments, and override gotPerspective. gotRootObject and gotPerspective will be called each time the object is received (once per successful connection attempt). You will probably want to use obj.notifyOnDisconnect to find out when the connection is lost. If an authorization error occurs, failedToGetPerspective() will be invoked. To use me, subclass, then hand an instance to a connector (like TCPClient). """ def __init__(self): PBClientFactory.__init__(self) self._doingLogin = False self._doingGetPerspective = False def clientConnectionFailed(self, connector, reason): PBClientFactory.clientConnectionFailed(self, connector, reason) # Twisted-1.3 erroneously abandons the connection on non-UserErrors. # To avoid this bug, don't upcall, and implement the correct version # of the method here. if self.continueTrying: self.connector = connector self.retry() def clientConnectionLost(self, connector, reason): PBClientFactory.clientConnectionLost(self, connector, reason, reconnecting=True) RCF = protocol.ReconnectingClientFactory RCF.clientConnectionLost(self, connector, reason) def clientConnectionMade(self, broker): self.resetDelay() PBClientFactory.clientConnectionMade(self, broker) if self._doingLogin: self.doLogin(self._root) if self._doingGetPerspective: self.doGetPerspective(self._root) self.gotRootObject(self._root) # oldcred methods def getPerspective(self, *args): raise RuntimeError, "getPerspective is one-shot: use startGettingPerspective instead" def startGettingPerspective(self, username, password, serviceName, perspectiveName=None, client=None): self._doingGetPerspective = True if perspectiveName == None: perspectiveName = username self._oldcredArgs = (username, password, serviceName, perspectiveName, client) def doGetPerspective(self, root): # oldcred getPerspective() (username, password, serviceName, perspectiveName, client) = self._oldcredArgs d = self._cbAuthIdentity(root, username, password) d.addCallback(self._cbGetPerspective, serviceName, perspectiveName, client) d.addCallbacks(self.gotPerspective, self.failedToGetPerspective) # newcred methods def login(self, *args): raise RuntimeError, "login is one-shot: use startLogin instead" def startLogin(self, credentials, client=None): self._credentials = credentials self._client = client self._doingLogin = True def doLogin(self, root): # newcred login() d = self._cbSendUsername(root, self._credentials.username, self._credentials.password, self._client) d.addCallbacks(self.gotPerspective, self.failedToGetPerspective) # methods to override def gotPerspective(self, perspective): """The remote avatar or perspective (obtained each time this factory connects) is now available.""" pass def gotRootObject(self, root): """The remote root object (obtained each time this factory connects) is now available. This method will be called each time the connection is established and the object reference is retrieved.""" pass def failedToGetPerspective(self, why): """The login process failed, most likely because of an authorization failure (bad password), but it is also possible that we lost the new connection before we managed to send our credentials. """ log.msg("ReconnectingPBClientFactory.failedToGetPerspective") if why.check(pb.PBConnectionLost): log.msg("we lost the brand-new connection") # retrying might help here, let clientConnectionLost decide return # probably authorization self.stopTrying() # logging in harder won't help log.err(why) buildbot-0.8.8/buildbot/process/000077500000000000000000000000001222546025000166135ustar00rootroot00000000000000buildbot-0.8.8/buildbot/process/__init__.py000066400000000000000000000000001222546025000207120ustar00rootroot00000000000000buildbot-0.8.8/buildbot/process/base.py000066400000000000000000000014061222546025000201000ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.process.build import Build _hush_pyflakes = [ Build ] buildbot-0.8.8/buildbot/process/botmaster.py000066400000000000000000000442641222546025000211770ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.python import log, reflect from twisted.python.failure import Failure from twisted.internet import defer, reactor from twisted.spread import pb from twisted.application import service from buildbot.process.builder import Builder from buildbot import interfaces, locks, config, util from buildbot.process import metrics from buildbot.process.buildrequestdistributor import BuildRequestDistributor class BotMaster(config.ReconfigurableServiceMixin, service.MultiService): """This is the master-side service which manages remote buildbot slaves. It provides them with BuildSlaves, and distributes build requests to them.""" debug = 0 def __init__(self, master): service.MultiService.__init__(self) self.setName("botmaster") self.master = master self.builders = {} self.builderNames = [] # builders maps Builder names to instances of bb.p.builder.Builder, # which is the master-side object that defines and controls a build. # self.slaves contains a ready BuildSlave instance for each # potential buildslave, i.e. all the ones listed in the config file. # If the slave is connected, self.slaves[slavename].slave will # contain a RemoteReference to their Bot instance. If it is not # connected, that attribute will hold None. self.slaves = {} # maps slavename to BuildSlave self.watchers = {} # self.locks holds the real Lock instances self.locks = {} # self.mergeRequests is the callable override for merging build # requests self.mergeRequests = None self.shuttingDown = False self.lastSlavePortnum = None # subscription to new build requests self.buildrequest_sub = None # a distributor for incoming build requests; see below self.brd = BuildRequestDistributor(self) self.brd.setServiceParent(self) def cleanShutdown(self, _reactor=reactor): """Shut down the entire process, once all currently-running builds are complete.""" if self.shuttingDown: return log.msg("Initiating clean shutdown") self.shuttingDown = True # first, stop the distributor; this will finish any ongoing scheduling # operations before firing d = self.brd.stopService() # then wait for all builds to finish def wait(_): l = [] for builder in self.builders.values(): for build in builder.builder_status.getCurrentBuilds(): l.append(build.waitUntilFinished()) if len(l) == 0: log.msg("No running jobs, starting shutdown immediately") else: log.msg("Waiting for %i build(s) to finish" % len(l)) return defer.DeferredList(l) d.addCallback(wait) # Finally, shut the whole process down def shutdown(ign): # Double check that we're still supposed to be shutting down # The shutdown may have been cancelled! if self.shuttingDown: # Check that there really aren't any running builds for builder in self.builders.values(): n = len(builder.builder_status.getCurrentBuilds()) if n > 0: log.msg("Not shutting down, builder %s has %i builds running" % (builder, n)) log.msg("Trying shutdown sequence again") self.shuttingDown = False self.cleanShutdown() return log.msg("Stopping reactor") _reactor.stop() else: self.brd.startService() d.addCallback(shutdown) d.addErrback(log.err, 'while processing cleanShutdown') def cancelCleanShutdown(self): """Cancel a clean shutdown that is already in progress, if any""" if not self.shuttingDown: return log.msg("Cancelling clean shutdown") self.shuttingDown = False @metrics.countMethod('BotMaster.slaveLost()') def slaveLost(self, bot): metrics.MetricCountEvent.log("BotMaster.attached_slaves", -1) for name, b in self.builders.items(): if bot.slavename in b.config.slavenames: b.detached(bot) @metrics.countMethod('BotMaster.getBuildersForSlave()') def getBuildersForSlave(self, slavename): return [ b for b in self.builders.values() if slavename in b.config.slavenames ] def getBuildernames(self): return self.builderNames def getBuilders(self): return self.builders.values() def startService(self): def buildRequestAdded(notif): self.maybeStartBuildsForBuilder(notif['buildername']) self.buildrequest_sub = \ self.master.subscribeToBuildRequests(buildRequestAdded) service.MultiService.startService(self) @defer.inlineCallbacks def reconfigService(self, new_config): timer = metrics.Timer("BotMaster.reconfigService") timer.start() # reconfigure slaves yield self.reconfigServiceSlaves(new_config) # reconfigure builders yield self.reconfigServiceBuilders(new_config) # call up yield config.ReconfigurableServiceMixin.reconfigService(self, new_config) # try to start a build for every builder; this is necessary at master # startup, and a good idea in any other case self.maybeStartBuildsForAllBuilders() timer.stop() @defer.inlineCallbacks def reconfigServiceSlaves(self, new_config): timer = metrics.Timer("BotMaster.reconfigServiceSlaves") timer.start() # arrange slaves by name old_by_name = dict([ (s.slavename, s) for s in list(self) if interfaces.IBuildSlave.providedBy(s) ]) old_set = set(old_by_name.iterkeys()) new_by_name = dict([ (s.slavename, s) for s in new_config.slaves ]) new_set = set(new_by_name.iterkeys()) # calculate new slaves, by name, and removed slaves removed_names, added_names = util.diffSets(old_set, new_set) # find any slaves for which the fully qualified class name has # changed, and treat those as an add and remove for n in old_set & new_set: old = old_by_name[n] new = new_by_name[n] # detect changed class name if reflect.qual(old.__class__) != reflect.qual(new.__class__): removed_names.add(n) added_names.add(n) if removed_names or added_names: log.msg("adding %d new slaves, removing %d" % (len(added_names), len(removed_names))) for n in removed_names: slave = old_by_name[n] del self.slaves[n] slave.master = None slave.botmaster = None yield defer.maybeDeferred(lambda : slave.disownServiceParent()) for n in added_names: slave = new_by_name[n] slave.setServiceParent(self) self.slaves[n] = slave metrics.MetricCountEvent.log("num_slaves", len(self.slaves), absolute=True) timer.stop() @defer.inlineCallbacks def reconfigServiceBuilders(self, new_config): timer = metrics.Timer("BotMaster.reconfigServiceBuilders") timer.start() # arrange builders by name old_by_name = dict([ (b.name, b) for b in list(self) if isinstance(b, Builder) ]) old_set = set(old_by_name.iterkeys()) new_by_name = dict([ (bc.name, bc) for bc in new_config.builders ]) new_set = set(new_by_name.iterkeys()) # calculate new builders, by name, and removed builders removed_names, added_names = util.diffSets(old_set, new_set) if removed_names or added_names: log.msg("adding %d new builders, removing %d" % (len(added_names), len(removed_names))) for n in removed_names: builder = old_by_name[n] del self.builders[n] builder.master = None builder.botmaster = None yield defer.maybeDeferred(lambda : builder.disownServiceParent()) for n in added_names: builder = Builder(n) self.builders[n] = builder builder.botmaster = self builder.master = self.master builder.setServiceParent(self) self.builderNames = self.builders.keys() metrics.MetricCountEvent.log("num_builders", len(self.builders), absolute=True) timer.stop() def stopService(self): if self.buildrequest_sub: self.buildrequest_sub.unsubscribe() self.buildrequest_sub = None for b in self.builders.values(): b.builder_status.addPointEvent(["master", "shutdown"]) b.builder_status.saveYourself() return service.MultiService.stopService(self) def getLockByID(self, lockid): """Convert a Lock identifier into an actual Lock instance. @param lockid: a locks.MasterLock or locks.SlaveLock instance @return: a locks.RealMasterLock or locks.RealSlaveLock instance """ assert isinstance(lockid, (locks.MasterLock, locks.SlaveLock)) if not lockid in self.locks: self.locks[lockid] = lockid.lockClass(lockid) # if the master.cfg file has changed maxCount= on the lock, the next # time a build is started, they'll get a new RealLock instance. Note # that this requires that MasterLock and SlaveLock (marker) instances # be hashable and that they should compare properly. return self.locks[lockid] def getLockFromLockAccess(self, access): # Convert a lock-access object into an actual Lock instance. if not isinstance(access, locks.LockAccess): # Buildbot 0.7.7 compability: user did not specify access access = access.defaultAccess() lock = self.getLockByID(access.lockid) return lock def maybeStartBuildsForBuilder(self, buildername): """ Call this when something suggests that a particular builder may now be available to start a build. @param buildername: the name of the builder """ self.brd.maybeStartBuildsOn([buildername]) def maybeStartBuildsForSlave(self, slave_name): """ Call this when something suggests that a particular slave may now be available to start a build. @param slave_name: the name of the slave """ builders = self.getBuildersForSlave(slave_name) self.brd.maybeStartBuildsOn([ b.name for b in builders ]) def maybeStartBuildsForAllBuilders(self): """ Call this when something suggests that this would be a good time to start some builds, but nothing more specific. """ self.brd.maybeStartBuildsOn(self.builderNames) class DuplicateSlaveArbitrator(object): """Utility class to arbitrate the situation when a new slave connects with the name of an existing, connected slave @ivar buildslave: L{buildbot.process.slavebuilder.AbstractBuildSlave} instance @ivar old_remote: L{RemoteReference} to the old slave @ivar new_remote: L{RemoteReference} to the new slave """ _reactor = reactor # for testing # There are several likely duplicate slave scenarios in practice: # # 1. two slaves are configured with the same username/password # # 2. the same slave process believes it is disconnected (due to a network # hiccup), and is trying to reconnect # # For the first case, we want to prevent the two slaves from repeatedly # superseding one another (which results in lots of failed builds), so we # will prefer the old slave. However, for the second case we need to # detect situations where the old slave is "gone". Sometimes "gone" means # that the TCP/IP connection to it is in a long timeout period (10-20m, # depending on the OS configuration), so this can take a while. PING_TIMEOUT = 10 """Timeout for pinging the old slave. Set this to something quite long, as a very busy slave (e.g., one sending a big log chunk) may take a while to return a ping. """ def __init__(self, buildslave): self.buildslave = buildslave self.old_remote = self.buildslave.slave def getPerspective(self, mind, slavename): self.new_remote = mind self.ping_old_slave_done = False self.old_slave_connected = True self.ping_new_slave_done = False old_tport = self.old_remote.broker.transport new_tport = self.new_remote.broker.transport log.msg("duplicate slave %s; delaying new slave (%s) and pinging old " "(%s)" % (self.buildslave.slavename, new_tport.getPeer(), old_tport.getPeer())) # delay the new slave until we decide what to do with it d = self.new_slave_d = defer.Deferred() # Ping the old slave. If this kills it, then we can allow the new # slave to connect. If this does not kill it, then we disconnect # the new slave. self.ping_old_slave(new_tport.getPeer()) # Print a message on the new slave, if possible. self.ping_new_slave() return d def ping_new_slave(self): d = defer.maybeDeferred(lambda : self.new_remote.callRemote("print", "master already has a " "connection named '%s' - checking its liveness" % self.buildslave.slavename)) def done(_): # failure or success, doesn't matter - the ping is done. self.ping_new_slave_done = True self.maybe_done() d.addBoth(done) def ping_old_slave(self, new_peer): # set a timer on this ping, in case the network is bad. TODO: a # timeout on the ping itself is not quite what we want. If there is # other data flowing over the PB connection, then we should keep # waiting. Bug #1703 def timeout(): self.ping_old_slave_timeout = None self.ping_old_slave_timed_out = True self.old_slave_connected = False self.ping_old_slave_done = True self.maybe_done() self.ping_old_slave_timeout = self._reactor.callLater( self.PING_TIMEOUT, timeout) self.ping_old_slave_timed_out = False # call this in maybeDeferred because callRemote tends to raise # exceptions instead of returning Failures d = defer.maybeDeferred(lambda : self.old_remote.callRemote("print", "master got a duplicate connection from %s; keeping this one" % new_peer)) def clear_timeout(r): if self.ping_old_slave_timeout: self.ping_old_slave_timeout.cancel() self.ping_old_slave_timeout = None return r d.addBoth(clear_timeout) def old_gone(f): if self.ping_old_slave_timed_out: return # ignore after timeout f.trap(pb.PBConnectionLost, pb.DeadReferenceError) log.msg(("connection lost while pinging old slave '%s' - " + "keeping new slave") % self.buildslave.slavename) self.old_slave_connected = False d.addErrback(old_gone) def other_err(f): log.err(f, "unexpected error pinging old slave; disconnecting it") self.old_slave_connected = False d.addErrback(other_err) def done(_): if self.ping_old_slave_timed_out: return # ignore after timeout self.ping_old_slave_done = True self.maybe_done() d.addCallback(done) def maybe_done(self): if not self.ping_new_slave_done or not self.ping_old_slave_done: return # both pings are done, so sort out the results if self.old_slave_connected: self.disconnect_new_slave() else: self.start_new_slave() def start_new_slave(self): # just in case if not self.new_slave_d: # pragma: ignore return d = self.new_slave_d self.new_slave_d = None if self.buildslave.isConnected(): # we need to wait until the old slave has fully detached, which can # take a little while as buffers drain, etc. def detached(): d.callback(self.buildslave) self.buildslave.subscribeToDetach(detached) self.old_remote.broker.transport.loseConnection() else: # pragma: ignore # by some unusual timing, it's quite possible that the old slave # has disconnected while the arbitration was going on. In that # case, we're already done! d.callback(self.buildslave) def disconnect_new_slave(self): # just in case if not self.new_slave_d: # pragma: ignore return d = self.new_slave_d self.new_slave_d = None log.msg("rejecting duplicate slave with exception") d.errback(Failure(RuntimeError("rejecting duplicate slave"))) buildbot-0.8.8/buildbot/process/build.py000066400000000000000000000517751222546025000203030ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import types from zope.interface import implements from twisted.python import log, components from twisted.python.failure import Failure from twisted.internet import defer, error from buildbot import interfaces from buildbot.status.results import SUCCESS, WARNINGS, FAILURE, EXCEPTION, \ RETRY, SKIPPED, worst_status from buildbot.status.builder import Results from buildbot.status.progress import BuildProgress from buildbot.process import metrics, properties from buildbot.util.eventual import eventually class Build(properties.PropertiesMixin): """I represent a single build by a single slave. Specialized Builders can use subclasses of Build to hold status information unique to those build processes. I control B{how} the build proceeds. The actual build is broken up into a series of steps, saved in the .buildSteps[] array as a list of L{buildbot.process.step.BuildStep} objects. Each step is a single remote command, possibly a shell command. During the build, I put status information into my C{BuildStatus} gatherer. After the build, I go away. I can be used by a factory by setting buildClass on L{buildbot.process.factory.BuildFactory} @ivar requests: the list of L{BuildRequest}s that triggered me @ivar build_status: the L{buildbot.status.build.BuildStatus} that collects our status """ implements(interfaces.IBuildControl) workdir = "build" build_status = None reason = "changes" finished = False results = None stopped = False set_runtime_properties = True def __init__(self, requests): self.requests = requests self.locks = [] # build a source stamp self.sources = requests[0].mergeSourceStampsWith(requests[1:]) self.reason = requests[0].mergeReasons(requests[1:]) self.progress = None self.currentStep = None self.slaveEnvironment = {} self.terminate = False self._acquiringLock = None def setBuilder(self, builder): """ Set the given builder as our builder. @type builder: L{buildbot.process.builder.Builder} """ self.builder = builder def setLocks(self, lockList): # convert all locks into their real forms self.locks = [(self.builder.botmaster.getLockFromLockAccess(access), access) for access in lockList ] def setSlaveEnvironment(self, env): self.slaveEnvironment = env def getSourceStamp(self, codebase=''): for source in self.sources: if source.codebase == codebase: return source return None def getAllSourceStamps(self): return list(self.sources) def allChanges(self): for s in self.sources: for c in s.changes: yield c def allFiles(self): # return a list of all source files that were changed files = [] for c in self.allChanges(): for f in c.files: files.append(f) return files def __repr__(self): return "" % (self.builder.name,) def blamelist(self): blamelist = [] for c in self.allChanges(): if c.who not in blamelist: blamelist.append(c.who) for source in self.sources: if source.patch_info: #Add patch author to blamelist blamelist.append(source.patch_info[0]) blamelist.sort() return blamelist def changesText(self): changetext = "" for c in self.allChanges(): changetext += "-" * 60 + "\n\n" + c.asText() + "\n" # consider sorting these by number return changetext def setStepFactories(self, step_factories): """Set a list of 'step factories', which are tuples of (class, kwargs), where 'class' is generally a subclass of step.BuildStep . These are used to create the Steps themselves when the Build starts (as opposed to when it is first created). By creating the steps later, their __init__ method will have access to things like build.allFiles() .""" self.stepFactories = list(step_factories) useProgress = True def getSlaveCommandVersion(self, command, oldversion=None): return self.slavebuilder.getSlaveCommandVersion(command, oldversion) def getSlaveName(self): return self.slavebuilder.slave.slavename def setupProperties(self): props = interfaces.IProperties(self) # give the properties a reference back to this build props.build = self # start with global properties from the configuration master = self.builder.botmaster.master props.updateFromProperties(master.config.properties) # from the SourceStamps, which have properties via Change for change in self.allChanges(): props.updateFromProperties(change.properties) # and finally, get any properties from requests (this is the path # through which schedulers will send us properties) for rq in self.requests: props.updateFromProperties(rq.properties) # now set some properties of our own, corresponding to the # build itself props.setProperty("buildnumber", self.build_status.number, "Build") if self.sources and len(self.sources) == 1: # old interface for backwards compatibility source = self.sources[0] props.setProperty("branch", source.branch, "Build") props.setProperty("revision", source.revision, "Build") props.setProperty("repository", source.repository, "Build") props.setProperty("codebase", source.codebase, "Build") props.setProperty("project", source.project, "Build") self.builder.setupProperties(props) def setupSlaveBuilder(self, slavebuilder): self.slavebuilder = slavebuilder self.path_module = slavebuilder.slave.path_module # navigate our way back to the L{buildbot.buildslave.BuildSlave} # object that came from the config, and get its properties buildslave_properties = slavebuilder.slave.properties self.getProperties().updateFromProperties(buildslave_properties) if slavebuilder.slave.slave_basedir: builddir = self.path_module.join( slavebuilder.slave.slave_basedir, self.builder.config.slavebuilddir) self.setProperty("builddir", builddir, "slave") self.setProperty("workdir", builddir, "slave (deprecated)") self.slavename = slavebuilder.slave.slavename self.build_status.setSlavename(self.slavename) def startBuild(self, build_status, expectations, slavebuilder): """This method sets up the build, then starts it by invoking the first Step. It returns a Deferred which will fire when the build finishes. This Deferred is guaranteed to never errback.""" # we are taking responsibility for watching the connection to the # remote. This responsibility was held by the Builder until our # startBuild was called, and will not return to them until we fire # the Deferred returned by this method. log.msg("%s.startBuild" % self) self.build_status = build_status # now that we have a build_status, we can set properties self.setupProperties() self.setupSlaveBuilder(slavebuilder) slavebuilder.slave.updateSlaveStatus(buildStarted=build_status) # then narrow SlaveLocks down to the right slave self.locks = [(l.getLock(self.slavebuilder.slave), a) for l, a in self.locks ] self.remote = slavebuilder.remote self.remote.notifyOnDisconnect(self.lostRemote) metrics.MetricCountEvent.log('active_builds', 1) d = self.deferred = defer.Deferred() def _uncount_build(res): metrics.MetricCountEvent.log('active_builds', -1) return res d.addBoth(_uncount_build) def _release_slave(res, slave, bs): self.slavebuilder.buildFinished() slave.updateSlaveStatus(buildFinished=bs) return res d.addCallback(_release_slave, self.slavebuilder.slave, build_status) try: self.setupBuild(expectations) # create .steps except: # the build hasn't started yet, so log the exception as a point # event instead of flunking the build. # TODO: associate this failure with the build instead. # this involves doing # self.build_status.buildStarted() from within the exception # handler log.msg("Build.setupBuild failed") log.err(Failure()) self.builder.builder_status.addPointEvent(["setupBuild", "exception"]) self.finished = True self.results = EXCEPTION self.deferred = None d.callback(self) return d self.build_status.buildStarted(self) self.acquireLocks().addCallback(self._startBuild_2) return d @staticmethod def canStartWithSlavebuilder(lockList, slavebuilder): for lock, access in lockList: slave_lock = lock.getLock(slavebuilder.slave) if not slave_lock.isAvailable(None, access): return False return True def acquireLocks(self, res=None): self._acquiringLock = None if not self.locks: return defer.succeed(None) if self.stopped: return defer.succeed(None) log.msg("acquireLocks(build %s, locks %s)" % (self, self.locks)) for lock, access in self.locks: if not lock.isAvailable(self, access): log.msg("Build %s waiting for lock %s" % (self, lock)) d = lock.waitUntilMaybeAvailable(self, access) d.addCallback(self.acquireLocks) self._acquiringLock = (lock, access, d) return d # all locks are available, claim them all for lock, access in self.locks: lock.claim(self, access) return defer.succeed(None) def _startBuild_2(self, res): self.startNextStep() def setupBuild(self, expectations): # create the actual BuildSteps. If there are any name collisions, we # add a count to the loser until it is unique. self.steps = [] self.stepStatuses = {} stepnames = {} sps = [] for factory in self.stepFactories: step = factory.buildStep() step.setBuild(self) step.setBuildSlave(self.slavebuilder.slave) if callable (self.workdir): step.setDefaultWorkdir (self.workdir (self.sources)) else: step.setDefaultWorkdir (self.workdir) name = step.name if stepnames.has_key(name): count = stepnames[name] count += 1 stepnames[name] = count name = step.name + "_%d" % count else: stepnames[name] = 0 step.name = name self.steps.append(step) # tell the BuildStatus about the step. This will create a # BuildStepStatus and bind it to the Step. step_status = self.build_status.addStepWithName(name) step.setStepStatus(step_status) sp = None if self.useProgress: # XXX: maybe bail if step.progressMetrics is empty? or skip # progress for that one step (i.e. "it is fast"), or have a # separate "variable" flag that makes us bail on progress # tracking sp = step.setupProgress() if sp: sps.append(sp) # Create a buildbot.status.progress.BuildProgress object. This is # called once at startup to figure out how to build the long-term # Expectations object, and again at the start of each build to get a # fresh BuildProgress object to track progress for that individual # build. TODO: revisit at-startup call if self.useProgress: self.progress = BuildProgress(sps) if self.progress and expectations: self.progress.setExpectationsFrom(expectations) # we are now ready to set up our BuildStatus. # pass all sourcestamps to the buildstatus self.build_status.setSourceStamps(self.sources) self.build_status.setReason(self.reason) self.build_status.setBlamelist(self.blamelist()) self.build_status.setProgress(self.progress) # gather owners from build requests owners = [r.properties['owner'] for r in self.requests if r.properties.has_key('owner')] if owners: self.setProperty('owners', owners, self.reason) self.results = [] # list of FAILURE, SUCCESS, WARNINGS, SKIPPED self.result = SUCCESS # overall result, may downgrade after each step self.text = [] # list of text string lists (text2) def getNextStep(self): """This method is called to obtain the next BuildStep for this build. When it returns None (or raises a StopIteration exception), the build is complete.""" if not self.steps: return None if not self.remote: return None if self.terminate or self.stopped: # Run any remaining alwaysRun steps, and skip over the others while True: s = self.steps.pop(0) if s.alwaysRun: return s if not self.steps: return None else: return self.steps.pop(0) def startNextStep(self): try: s = self.getNextStep() except StopIteration: s = None if not s: return self.allStepsDone() self.currentStep = s d = defer.maybeDeferred(s.startStep, self.remote) d.addCallback(self._stepDone, s) d.addErrback(self.buildException) def _stepDone(self, results, step): self.currentStep = None if self.finished: return # build was interrupted, don't keep building terminate = self.stepDone(results, step) # interpret/merge results if terminate: self.terminate = True return self.startNextStep() def stepDone(self, result, step): """This method is called when the BuildStep completes. It is passed a status object from the BuildStep and is responsible for merging the Step's results into those of the overall Build.""" terminate = False text = None if type(result) == types.TupleType: result, text = result assert type(result) == type(SUCCESS) log.msg(" step '%s' complete: %s" % (step.name, Results[result])) self.results.append(result) if text: self.text.extend(text) if not self.remote: terminate = True possible_overall_result = result if result == FAILURE: if not step.flunkOnFailure: possible_overall_result = SUCCESS if step.warnOnFailure: possible_overall_result = WARNINGS if step.flunkOnFailure: possible_overall_result = FAILURE if step.haltOnFailure: terminate = True elif result == WARNINGS: if not step.warnOnWarnings: possible_overall_result = SUCCESS else: possible_overall_result = WARNINGS if step.flunkOnWarnings: possible_overall_result = FAILURE elif result in (EXCEPTION, RETRY): terminate = True # if we skipped this step, then don't adjust the build status if result != SKIPPED: self.result = worst_status(self.result, possible_overall_result) return terminate def lostRemote(self, remote=None): # the slave went away. There are several possible reasons for this, # and they aren't necessarily fatal. For now, kill the build, but # TODO: see if we can resume the build when it reconnects. log.msg("%s.lostRemote" % self) self.remote = None if self.currentStep: # this should cause the step to finish. log.msg(" stopping currentStep", self.currentStep) self.currentStep.interrupt(Failure(error.ConnectionLost())) else: self.result = RETRY self.text = ["lost", "remote"] self.stopped = True if self._acquiringLock: lock, access, d = self._acquiringLock lock.stopWaitingUntilAvailable(self, access, d) d.callback(None) def stopBuild(self, reason=""): # the idea here is to let the user cancel a build because, e.g., # they realized they committed a bug and they don't want to waste # the time building something that they know will fail. Another # reason might be to abandon a stuck build. We want to mark the # build as failed quickly rather than waiting for the slave's # timeout to kill it on its own. log.msg(" %s: stopping build: %s" % (self, reason)) if self.finished: return # TODO: include 'reason' in this point event self.builder.builder_status.addPointEvent(['interrupt']) self.stopped = True if self.currentStep: self.currentStep.interrupt(reason) self.result = EXCEPTION if self._acquiringLock: lock, access, d = self._acquiringLock lock.stopWaitingUntilAvailable(self, access, d) d.callback(None) def allStepsDone(self): if self.result == FAILURE: text = ["failed"] elif self.result == WARNINGS: text = ["warnings"] elif self.result == EXCEPTION: text = ["exception"] elif self.result == RETRY: text = ["retry"] else: text = ["build", "successful"] text.extend(self.text) return self.buildFinished(text, self.result) def buildException(self, why): log.msg("%s.buildException" % self) log.err(why) # try to finish the build, but since we've already faced an exception, # this may not work well. try: self.buildFinished(["build", "exception"], EXCEPTION) except: log.err(Failure(), 'while finishing a build with an exception') def buildFinished(self, text, results): """This method must be called when the last Step has completed. It marks the Build as complete and returns the Builder to the 'idle' state. It takes two arguments which describe the overall build status: text, results. 'results' is one of SUCCESS, WARNINGS, or FAILURE. If 'results' is SUCCESS or WARNINGS, we will permit any dependant builds to start. If it is 'FAILURE', those builds will be abandoned.""" self.finished = True if self.remote: self.remote.dontNotifyOnDisconnect(self.lostRemote) self.remote = None self.results = results log.msg(" %s: build finished" % self) self.build_status.setText(text) self.build_status.setResults(results) self.build_status.buildFinished() if self.progress and results == SUCCESS: # XXX: also test a 'timing consistent' flag? log.msg(" setting expectations for next time") self.builder.setExpectations(self.progress) eventually(self.releaseLocks) self.deferred.callback(self) self.deferred = None def releaseLocks(self): if self.locks: log.msg("releaseLocks(%s): %s" % (self, self.locks)) for lock, access in self.locks: if lock.isOwner(self, access): lock.release(self, access) else: # This should only happen if we've been interrupted assert self.stopped # IBuildControl def getStatus(self): return self.build_status # stopBuild is defined earlier components.registerAdapter( lambda build : interfaces.IProperties(build.build_status), Build, interfaces.IProperties) buildbot-0.8.8/buildbot/process/builder.py000066400000000000000000000615621222546025000206250ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import weakref from zope.interface import implements from twisted.python import log, failure from twisted.spread import pb from twisted.application import service, internet from twisted.internet import defer from buildbot import interfaces, config from buildbot.status.progress import Expectations from buildbot.status.builder import RETRY from buildbot.status.buildrequest import BuildRequestStatus from buildbot.process.properties import Properties from buildbot.process import buildrequest, slavebuilder from buildbot.process.build import Build from buildbot.process.slavebuilder import BUILDING def enforceChosenSlave(bldr, slavebuilder, breq): if 'slavename' in breq.properties: slavename = breq.properties['slavename'] if isinstance(slavename, basestring): return slavename==slavebuilder.slave.slavename return True class Builder(config.ReconfigurableServiceMixin, pb.Referenceable, service.MultiService): # reconfigure builders before slaves reconfig_priority = 196 def __init__(self, name, _addServices=True): service.MultiService.__init__(self) self.name = name # this is created the first time we get a good build self.expectations = None # build/wannabuild slots: Build objects move along this sequence self.building = [] # old_building holds active builds that were stolen from a predecessor self.old_building = weakref.WeakKeyDictionary() # buildslaves which have connected but which are not yet available. # These are always in the ATTACHING state. self.attaching_slaves = [] # buildslaves at our disposal. Each SlaveBuilder instance has a # .state that is IDLE, PINGING, or BUILDING. "PINGING" is used when a # Build is about to start, to make sure that they're still alive. self.slaves = [] self.config = None self.builder_status = None if _addServices: self.reclaim_svc = internet.TimerService(10*60, self.reclaimAllBuilds) self.reclaim_svc.setServiceParent(self) # update big status every 30 minutes, working around #1980 self.updateStatusService = internet.TimerService(30*60, self.updateBigStatus) self.updateStatusService.setServiceParent(self) def reconfigService(self, new_config): # find this builder in the config for builder_config in new_config.builders: if builder_config.name == self.name: break else: assert 0, "no config found for builder '%s'" % self.name # set up a builder status object on the first reconfig if not self.builder_status: self.builder_status = self.master.status.builderAdded( builder_config.name, builder_config.builddir, builder_config.category, builder_config.description) self.config = builder_config self.builder_status.setDescription(builder_config.description) self.builder_status.setCategory(builder_config.category) self.builder_status.setSlavenames(self.config.slavenames) self.builder_status.setCacheSize(new_config.caches['Builds']) return defer.succeed(None) def stopService(self): d = defer.maybeDeferred(lambda : service.MultiService.stopService(self)) return d def __repr__(self): return "" % (self.name, id(self)) @defer.inlineCallbacks def getOldestRequestTime(self): """Returns the submitted_at of the oldest unclaimed build request for this builder, or None if there are no build requests. @returns: datetime instance or None, via Deferred """ unclaimed = yield self.master.db.buildrequests.getBuildRequests( buildername=self.name, claimed=False) if unclaimed: unclaimed = [ brd['submitted_at'] for brd in unclaimed ] unclaimed.sort() defer.returnValue(unclaimed[0]) else: defer.returnValue(None) def reclaimAllBuilds(self): brids = set() for b in self.building: brids.update([br.id for br in b.requests]) for b in self.old_building: brids.update([br.id for br in b.requests]) if not brids: return defer.succeed(None) d = self.master.db.buildrequests.reclaimBuildRequests(brids) d.addErrback(log.err, 'while re-claiming running BuildRequests') return d def getBuild(self, number): for b in self.building: if b.build_status and b.build_status.number == number: return b for b in self.old_building.keys(): if b.build_status and b.build_status.number == number: return b return None def addLatentSlave(self, slave): assert interfaces.ILatentBuildSlave.providedBy(slave) for s in self.slaves: if s == slave: break else: sb = slavebuilder.LatentSlaveBuilder(slave, self) self.builder_status.addPointEvent( ['added', 'latent', slave.slavename]) self.slaves.append(sb) self.botmaster.maybeStartBuildsForBuilder(self.name) def attached(self, slave, remote, commands): """This is invoked by the BuildSlave when the self.slavename bot registers their builder. @type slave: L{buildbot.buildslave.BuildSlave} @param slave: the BuildSlave that represents the buildslave as a whole @type remote: L{twisted.spread.pb.RemoteReference} @param remote: a reference to the L{buildbot.slave.bot.SlaveBuilder} @type commands: dict: string -> string, or None @param commands: provides the slave's version of each RemoteCommand @rtype: L{twisted.internet.defer.Deferred} @return: a Deferred that fires (with 'self') when the slave-side builder is fully attached and ready to accept commands. """ for s in self.attaching_slaves + self.slaves: if s.slave == slave: # already attached to them. This is fairly common, since # attached() gets called each time we receive the builder # list from the slave, and we ask for it each time we add or # remove a builder. So if the slave is hosting builders # A,B,C, and the config file changes A, we'll remove A and # re-add it, triggering two builder-list requests, getting # two redundant calls to attached() for B, and another two # for C. # # Therefore, when we see that we're already attached, we can # just ignore it. return defer.succeed(self) sb = slavebuilder.SlaveBuilder() sb.setBuilder(self) self.attaching_slaves.append(sb) d = sb.attached(slave, remote, commands) d.addCallback(self._attached) d.addErrback(self._not_attached, slave) return d def _attached(self, sb): self.builder_status.addPointEvent(['connect', sb.slave.slavename]) self.attaching_slaves.remove(sb) self.slaves.append(sb) self.updateBigStatus() return self def _not_attached(self, why, slave): # already log.err'ed by SlaveBuilder._attachFailure # TODO: remove from self.slaves (except that detached() should get # run first, right?) log.err(why, 'slave failed to attach') self.builder_status.addPointEvent(['failed', 'connect', slave.slavename]) # TODO: add an HTMLLogFile of the exception def detached(self, slave): """This is called when the connection to the bot is lost.""" for sb in self.attaching_slaves + self.slaves: if sb.slave == slave: break else: log.msg("WEIRD: Builder.detached(%s) (%s)" " not in attaching_slaves(%s)" " or slaves(%s)" % (slave, slave.slavename, self.attaching_slaves, self.slaves)) return if sb.state == BUILDING: # the Build's .lostRemote method (invoked by a notifyOnDisconnect # handler) will cause the Build to be stopped, probably right # after the notifyOnDisconnect that invoked us finishes running. pass if sb in self.attaching_slaves: self.attaching_slaves.remove(sb) if sb in self.slaves: self.slaves.remove(sb) self.builder_status.addPointEvent(['disconnect', slave.slavename]) sb.detached() # inform the SlaveBuilder that their slave went away self.updateBigStatus() def updateBigStatus(self): try: # Catch exceptions here, since this is called in a LoopingCall. if not self.builder_status: return if not self.slaves: self.builder_status.setBigState("offline") elif self.building or self.old_building: self.builder_status.setBigState("building") else: self.builder_status.setBigState("idle") except Exception: log.err(None, "while trying to update status of builder '%s'" % (self.name,)) def getAvailableSlaves(self): return [ sb for sb in self.slaves if sb.isAvailable() ] def canStartWithSlavebuilder(self, slavebuilder): locks = [(self.botmaster.getLockFromLockAccess(access), access) for access in self.config.locks ] return Build.canStartWithSlavebuilder(locks, slavebuilder) def canStartBuild(self, slavebuilder, breq): if callable(self.config.canStartBuild): return defer.maybeDeferred(self.config.canStartBuild, self, slavebuilder, breq) return defer.succeed(True) @defer.inlineCallbacks def _startBuildFor(self, slavebuilder, buildrequests): """Start a build on the given slave. @param build: the L{base.Build} to start @param sb: the L{SlaveBuilder} which will host this build @return: (via Deferred) boolean indicating that the build was succesfully started. """ # as of the Python versions supported now, try/finally can't be used # with a generator expression. So instead, we push cleanup functions # into a list so that, at any point, we can abort this operation. cleanups = [] def run_cleanups(): try: while cleanups: fn = cleanups.pop() fn() except: log.err(failure.Failure(), "while running %r" % (run_cleanups,)) # the last cleanup we want to perform is to update the big # status based on any other cleanup cleanups.append(lambda : self.updateBigStatus()) build = self.config.factory.newBuild(buildrequests) build.setBuilder(self) log.msg("starting build %s using slave %s" % (build, slavebuilder)) # set up locks build.setLocks(self.config.locks) cleanups.append(lambda : slavebuilder.slave.releaseLocks()) if len(self.config.env) > 0: build.setSlaveEnvironment(self.config.env) # append the build to self.building self.building.append(build) cleanups.append(lambda : self.building.remove(build)) # update the big status accordingly self.updateBigStatus() try: ready = yield slavebuilder.prepare(self.builder_status, build) except: log.err(failure.Failure(), 'while preparing slavebuilder:') ready = False # If prepare returns True then it is ready and we start a build # If it returns false then we don't start a new build. if not ready: log.msg("slave %s can't build %s after all; re-queueing the " "request" % (build, slavebuilder)) run_cleanups() defer.returnValue(False) return # ping the slave to make sure they're still there. If they've # fallen off the map (due to a NAT timeout or something), this # will fail in a couple of minutes, depending upon the TCP # timeout. # # TODO: This can unnecessarily suspend the starting of a build, in # situations where the slave is live but is pushing lots of data to # us in a build. log.msg("starting build %s.. pinging the slave %s" % (build, slavebuilder)) try: ping_success = yield slavebuilder.ping() except: log.err(failure.Failure(), 'while pinging slave before build:') ping_success = False if not ping_success: log.msg("slave ping failed; re-queueing the request") run_cleanups() defer.returnValue(False) return # The buildslave is ready to go. slavebuilder.buildStarted() sets its # state to BUILDING (so we won't try to use it for any other builds). # This gets set back to IDLE by the Build itself when it finishes. slavebuilder.buildStarted() cleanups.append(lambda : slavebuilder.buildFinished()) # tell the remote that it's starting a build, too try: yield slavebuilder.remote.callRemote("startBuild") except: log.err(failure.Failure(), 'while calling remote startBuild:') run_cleanups() defer.returnValue(False) return # create the BuildStatus object that goes with the Build bs = self.builder_status.newBuild() # record the build in the db - one row per buildrequest try: bids = [] for req in build.requests: bid = yield self.master.db.builds.addBuild(req.id, bs.number) bids.append(bid) except: log.err(failure.Failure(), 'while adding rows to build table:') run_cleanups() defer.returnValue(False) return # IMPORTANT: no yielding is allowed from here to the startBuild call! # it's possible that we lost the slave remote between the ping above # and now. If so, bail out. The build.startBuild call below transfers # responsibility for monitoring this connection to the Build instance, # so this check ensures we hand off a working connection. if not slavebuilder.remote: log.msg("slave disappeared before build could start") run_cleanups() defer.returnValue(False) return # let status know self.master.status.build_started(req.id, self.name, bs) # start the build. This will first set up the steps, then tell the # BuildStatus that it has started, which will announce it to the world # (through our BuilderStatus object, which is its parent). Finally it # will start the actual build process. This is done with a fresh # Deferred since _startBuildFor should not wait until the build is # finished. This uses `maybeDeferred` to ensure that any exceptions # raised by startBuild are treated as deferred errbacks (see # http://trac.buildbot.net/ticket/2428). d = defer.maybeDeferred(build.startBuild, bs, self.expectations, slavebuilder) d.addCallback(self.buildFinished, slavebuilder, bids) # this shouldn't happen. if it does, the slave will be wedged d.addErrback(log.err, 'from a running build; this is a ' 'serious error - please file a bug at http://buildbot.net') # make sure the builder's status is represented correctly self.updateBigStatus() defer.returnValue(True) def setupProperties(self, props): props.setProperty("buildername", self.name, "Builder") if len(self.config.properties) > 0: for propertyname in self.config.properties: props.setProperty(propertyname, self.config.properties[propertyname], "Builder") def buildFinished(self, build, sb, bids): """This is called when the Build has finished (either success or failure). Any exceptions during the build are reported with results=FAILURE, not with an errback.""" # by the time we get here, the Build has already released the slave, # which will trigger a check for any now-possible build requests # (maybeStartBuilds) # mark the builds as finished, although since nothing ever reads this # table, it's not too important that it complete successfully d = self.master.db.builds.finishBuilds(bids) d.addErrback(log.err, 'while marking builds as finished (ignored)') results = build.build_status.getResults() self.building.remove(build) if results == RETRY: self._resubmit_buildreqs(build).addErrback(log.err) else: brids = [br.id for br in build.requests] db = self.master.db d = db.buildrequests.completeBuildRequests(brids, results) d.addCallback( lambda _ : self._maybeBuildsetsComplete(build.requests)) # nothing in particular to do with this deferred, so just log it if # it fails.. d.addErrback(log.err, 'while marking build requests as completed') if sb.slave: sb.slave.releaseLocks() self.updateBigStatus() @defer.inlineCallbacks def _maybeBuildsetsComplete(self, requests): # inform the master that we may have completed a number of buildsets for br in requests: yield self.master.maybeBuildsetComplete(br.bsid) def _resubmit_buildreqs(self, build): brids = [br.id for br in build.requests] return self.master.db.buildrequests.unclaimBuildRequests(brids) def setExpectations(self, progress): """Mark the build as successful and update expectations for the next build. Only call this when the build did not fail in any way that would invalidate the time expectations generated by it. (if the compile failed and thus terminated early, we can't use the last build to predict how long the next one will take). """ if self.expectations: self.expectations.update(progress) else: # the first time we get a good build, create our Expectations # based upon its results self.expectations = Expectations(progress) log.msg("new expectations: %s seconds" % self.expectations.expectedBuildTime()) # Build Creation @defer.inlineCallbacks def maybeStartBuild(self, slavebuilder, breqs): # This method is called by the botmaster whenever this builder should # start a set of buildrequests on a slave. Do not call this method # directly - use master.botmaster.maybeStartBuildsForBuilder, or one of # the other similar methods if more appropriate # first, if we're not running, then don't start builds; stopService # uses this to ensure that any ongoing maybeStartBuild invocations # are complete before it stops. if not self.running: defer.returnValue(False) return # If the build fails from here on out (e.g., because a slave has failed), # it will be handled outside of this function. TODO: test that! build_started = yield self._startBuildFor(slavebuilder, breqs) defer.returnValue(build_started) # a few utility functions to make the maybeStartBuild a bit shorter and # easier to read def getMergeRequestsFn(self): """Helper function to determine which mergeRequests function to use from L{_mergeRequests}, or None for no merging""" # first, seek through builder, global, and the default mergeRequests_fn = self.config.mergeRequests if mergeRequests_fn is None: mergeRequests_fn = self.master.config.mergeRequests if mergeRequests_fn is None: mergeRequests_fn = True # then translate False and True properly if mergeRequests_fn is False: mergeRequests_fn = None elif mergeRequests_fn is True: mergeRequests_fn = Builder._defaultMergeRequestFn return mergeRequests_fn def _defaultMergeRequestFn(self, req1, req2): return req1.canBeMergedWith(req2) class BuilderControl: implements(interfaces.IBuilderControl) def __init__(self, builder, control): self.original = builder self.control = control def submitBuildRequest(self, ss, reason, props=None): d = ss.getSourceStampSetId(self.control.master) def add_buildset(sourcestampsetid): return self.control.master.addBuildset( builderNames=[self.original.name], sourcestampsetid=sourcestampsetid, reason=reason, properties=props) d.addCallback(add_buildset) def get_brs((bsid,brids)): brs = BuildRequestStatus(self.original.name, brids[self.original.name], self.control.master.status) return brs d.addCallback(get_brs) return d @defer.inlineCallbacks def rebuildBuild(self, bs, reason="", extraProperties=None): if not bs.isFinished(): return # Make a copy of the properties so as not to modify the original build. properties = Properties() # Don't include runtime-set properties in a rebuild request properties.updateFromPropertiesNoRuntime(bs.getProperties()) if extraProperties is None: properties.updateFromProperties(extraProperties) properties_dict = dict((k,(v,s)) for (k,v,s) in properties.asList()) ssList = bs.getSourceStamps(absolute=True) if ssList: sourcestampsetid = yield ssList[0].getSourceStampSetId(self.control.master) dl = [] for ss in ssList[1:]: # add defered to the list dl.append(ss.addSourceStampToDatabase(self.control.master, sourcestampsetid)) yield defer.gatherResults(dl) bsid, brids = yield self.control.master.addBuildset( builderNames=[self.original.name], sourcestampsetid=sourcestampsetid, reason=reason, properties=properties_dict) defer.returnValue((bsid, brids)) else: log.msg('Cannot start rebuild, rebuild has no sourcestamps for a new build') defer.returnValue(None) @defer.inlineCallbacks def getPendingBuildRequestControls(self): master = self.original.master brdicts = yield master.db.buildrequests.getBuildRequests( buildername=self.original.name, claimed=False) # convert those into BuildRequest objects buildrequests = [ ] for brdict in brdicts: br = yield buildrequest.BuildRequest.fromBrdict( self.control.master, brdict) buildrequests.append(br) # and return the corresponding control objects defer.returnValue([ buildrequest.BuildRequestControl(self.original, r) for r in buildrequests ]) def getBuild(self, number): return self.original.getBuild(number) def ping(self): if not self.original.slaves: self.original.builder_status.addPointEvent(["ping", "no slave"]) return defer.succeed(False) # interfaces.NoSlaveError dl = [] for s in self.original.slaves: dl.append(s.ping(self.original.builder_status)) d = defer.DeferredList(dl) d.addCallback(self._gatherPingResults) return d def _gatherPingResults(self, res): for ignored,success in res: if not success: return False return True buildbot-0.8.8/buildbot/process/buildrequest.py000066400000000000000000000235521222546025000217040ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import calendar from zope.interface import implements from twisted.python import log from twisted.internet import defer from buildbot import interfaces, sourcestamp from buildbot.process import properties from buildbot.status.results import FAILURE from buildbot.db import buildrequests class BuildRequest(object): """ A rolled-up encapsulation of all of the data relevant to a build request. This class is used by the C{nextBuild} and C{mergeRequests} configuration parameters, as well as in starting a build. Construction of a BuildRequest object is a heavyweight process involving a lot of database queries, so it should be avoided where possible. See bug #1894. Build requests have a SourceStamp which specifies what sources to build. This may specify a specific revision of the source tree (so source.branch, source.revision, and source.patch are used). The .patch attribute is either None or a tuple of (patchlevel, diff), consisting of a number to use in 'patch -pN', and a unified-format context diff. Alternatively, the SourceStamp may specify a set of Changes to be built, contained in source.changes. In this case, the requeset may be mergeable with other BuildRequests on the same branch. @type source: L{buildbot.sourcestamp.SourceStamp} @ivar source: the source stamp that this BuildRequest use @type reason: string @ivar reason: the reason this Build is being requested. Schedulers provide this, but for forced builds the user requesting the build will provide a string. It comes from the buildsets table. @type properties: L{properties.Properties} @ivar properties: properties that should be applied to this build, taken from the buildset containing this build request @ivar submittedAt: a timestamp (seconds since epoch) when this request was submitted to the Builder. This is used by the CVS step to compute a checkout timestamp, as well as by the master to prioritize build requests from oldest to newest. @ivar buildername: name of the requested builder @ivar priority: request priority @ivar id: build request ID @ivar bsid: ID of the parent buildset """ source = None sources = None submittedAt = None @classmethod def fromBrdict(cls, master, brdict): """ Construct a new L{BuildRequest} from a dictionary as returned by L{BuildRequestsConnectorComponent.getBuildRequest}. This method uses a cache, which may result in return of stale objects; for the most up-to-date information, use the database connector methods. @param master: current build master @param brdict: build request dictionary @returns: L{BuildRequest}, via Deferred """ cache = master.caches.get_cache("BuildRequests", cls._make_br) return cache.get(brdict['brid'], brdict=brdict, master=master) @classmethod @defer.inlineCallbacks def _make_br(cls, brid, brdict, master): buildrequest = cls() buildrequest.id = brid buildrequest.bsid = brdict['buildsetid'] buildrequest.buildername = brdict['buildername'] buildrequest.priority = brdict['priority'] dt = brdict['submitted_at'] buildrequest.submittedAt = dt and calendar.timegm(dt.utctimetuple()) buildrequest.master = master # fetch the buildset to get the reason buildset = yield master.db.buildsets.getBuildset(brdict['buildsetid']) assert buildset # schema should guarantee this buildrequest.reason = buildset['reason'] # fetch the buildset properties, and convert to Properties buildset_properties = yield master.db.buildsets.getBuildsetProperties(brdict['buildsetid']) buildrequest.properties = properties.Properties.fromDict(buildset_properties) # fetch the sourcestamp dictionary sslist = yield master.db.sourcestamps.getSourceStamps(buildset['sourcestampsetid']) assert len(sslist) > 0, "Empty sourcestampset: db schema enforces set to exist but cannot enforce a non empty set" # and turn it into a SourceStamps buildrequest.sources = {} def store_source(source): buildrequest.sources[source.codebase] = source dlist = [] for ssdict in sslist: d = sourcestamp.SourceStamp.fromSsdict(master, ssdict) d.addCallback(store_source) dlist.append(d) yield defer.gatherResults(dlist) if buildrequest.sources: buildrequest.source = buildrequest.sources.values()[0] defer.returnValue(buildrequest) def requestsHaveSameCodebases(self, other): self_codebases = set(self.sources.iterkeys()) other_codebases = set(other.sources.iterkeys()) return self_codebases == other_codebases def requestsHaveChangesForSameCodebases(self, other): # Merge can only be done if both requests have sourcestampsets containing # comparable sourcestamps, that means sourcestamps with the same codebase. # This means that both requests must have exact the same set of codebases # If not then merge cannot be performed. # The second requirement is that both request have the changes in the # same codebases. # # Normaly a scheduler always delivers the same set of codebases: # sourcestamps with and without changes # For the case a scheduler is not configured with a set of codebases # it delivers only a set with sourcestamps that have changes. self_codebases = set(self.sources.iterkeys()) other_codebases = set(other.sources.iterkeys()) if self_codebases != other_codebases: return False for c in self_codebases: # Check either both or neither have changes if ((len(self.sources[c].changes) > 0) != (len(other.sources[c].changes) > 0)): return False # all codebases tested, no differences found return True def canBeMergedWith(self, other): """ Returns if both requests can be merged """ if not self.requestsHaveChangesForSameCodebases(other): return False #get codebases from myself, they are equal to other self_codebases = set(self.sources.iterkeys()) for c in self_codebases: # check to prevent exception if c not in other.sources: return False if not self.sources[c].canBeMergedWith(other.sources[c]): return False return True def mergeSourceStampsWith(self, others): """ Returns one merged sourcestamp for every codebase """ #get all codebases from all requests all_codebases = set(self.sources.iterkeys()) for other in others: all_codebases |= set(other.sources.iterkeys()) all_merged_sources = {} # walk along the codebases for codebase in all_codebases: all_sources = [] if codebase in self.sources: all_sources.append(self.sources[codebase]) for other in others: if codebase in other.sources: all_sources.append(other.sources[codebase]) assert len(all_sources)>0, "each codebase should have atleast one sourcestamp" all_merged_sources[codebase] = all_sources[0].mergeWith(all_sources[1:]) return [source for source in all_merged_sources.itervalues()] def mergeReasons(self, others): """Return a reason for the merged build request.""" reasons = [] for req in [self] + others: if req.reason and req.reason not in reasons: reasons.append(req.reason) return ", ".join(reasons) def getSubmitTime(self): return self.submittedAt @defer.inlineCallbacks def cancelBuildRequest(self): # first, try to claim the request; if this fails, then it's too late to # cancel the build anyway try: yield self.master.db.buildrequests.claimBuildRequests([self.id]) except buildrequests.AlreadyClaimedError: log.msg("build request already claimed; cannot cancel") return # then complete it with 'FAILURE'; this is the closest we can get to # cancelling a request without running into trouble with dangling # references. yield self.master.db.buildrequests.completeBuildRequests([self.id], FAILURE) # and let the master know that the enclosing buildset may be complete yield self.master.maybeBuildsetComplete(self.bsid) class BuildRequestControl: implements(interfaces.IBuildRequestControl) def __init__(self, builder, request): self.original_builder = builder self.original_request = request self.brid = request.id def subscribe(self, observer): raise NotImplementedError def unsubscribe(self, observer): raise NotImplementedError def cancel(self): d = self.original_request.cancelBuildRequest() d.addErrback(log.err, 'while cancelling build request') buildbot-0.8.8/buildbot/process/buildrequestdistributor.py000066400000000000000000000466721222546025000242070ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.python import log from twisted.python.failure import Failure from twisted.internet import defer from twisted.application import service from buildbot.process import metrics from buildbot.process.buildrequest import BuildRequest from buildbot.db.buildrequests import AlreadyClaimedError import random class BuildChooserBase(object): # # WARNING: This API is experimental and in active development. # # This internal object selects a new build+slave pair. It acts as a # generator, initializing its state on creation and offering up new # pairs until exhaustion. The object can be destroyed at any time # (eg, before the list exhausts), and can be "restarted" by abandoning # an old instance and creating a new one. # # The entry point is: # * bc.chooseNextBuild() - get the next (slave, [breqs]) or (None, None) # # The default implementation of this class implements a default # chooseNextBuild() that delegates out to two other functions: # * bc.popNextBuild() - get the next (slave, breq) pair # * bc.mergeRequests(breq) - perform a merge for this breq and return # the list of breqs consumed by the merge (including breq itself) def __init__(self, bldr, master): self.bldr = bldr self.master = master self.breqCache = {} self.unclaimedBrdicts = None @defer.inlineCallbacks def chooseNextBuild(self): # Return the next build, as a (slave, [breqs]) pair slave, breq = yield self.popNextBuild() if not slave or not breq: defer.returnValue((None, None)) return breqs = yield self.mergeRequests(breq) for b in breqs: self._removeBuildRequest(b) defer.returnValue((slave, breqs)) # Must be implemented by subclass def popNextBuild(self): # Pick the next (slave, breq) pair; note this is pre-merge, so # it's just one breq raise NotImplementedError("Subclasses must implement this!") # Must be implemented by subclass def mergeRequests(self, breq): # Merge the chosen breq with any other breqs that are compatible # Returns a list of the breqs chosen (and should include the # original breq as well!) raise NotImplementedError("Subclasses must implement this!") # - Helper functions that are generally useful to all subclasses - @defer.inlineCallbacks def _fetchUnclaimedBrdicts(self): # Sets up a cache of all the unclaimed brdicts. The cache is # saved at self.unclaimedBrdicts cache. If the cache already # exists, this function does nothing. If a refetch is desired, set # the self.unclaimedBrdicts to None before calling.""" if self.unclaimedBrdicts is None: brdicts = yield self.master.db.buildrequests.getBuildRequests( buildername=self.bldr.name, claimed=False) # sort by submitted_at, so the first is the oldest brdicts.sort(key=lambda brd : brd['submitted_at']) self.unclaimedBrdicts = brdicts defer.returnValue(self.unclaimedBrdicts) @defer.inlineCallbacks def _getBuildRequestForBrdict(self, brdict): # Turn a brdict into a BuildRequest into a brdict. This is useful # for API like 'nextBuild', which operate on BuildRequest objects. breq = self.breqCache.get(brdict['brid']) if not breq: breq = yield BuildRequest.fromBrdict(self.master, brdict) if breq: self.breqCache[brdict['brid']] = breq defer.returnValue(breq) def _getBrdictForBuildRequest(self, breq): # Turn a BuildRequest back into a brdict. This operates from the # cache, which must be set up once via _fetchUnclaimedBrdicts if breq is None: return None brid = breq.id for brdict in self.unclaimedBrdicts: if brid == brdict['brid']: return brdict return None def _removeBuildRequest(self, breq): # Remove a BuildrRequest object (and its brdict) # from the caches if breq is None: return brdict = self._getBrdictForBuildRequest(breq) if brdict is not None: self.unclaimedBrdicts.remove(brdict) if breq.id in self.breqCache: del self.breqCache[breq.id] def _getUnclaimedBuildRequests(self): # Retrieve the list of BuildRequest objects for all unclaimed builds return defer.gatherResults([ self._getBuildRequestForBrdict(brdict) for brdict in self.unclaimedBrdicts ]) class BasicBuildChooser(BuildChooserBase): # BasicBuildChooser generates build pairs via the configuration points: # * config.nextSlave (or random.choice if not set) # * config.nextBuild (or "pop top" if not set) # # For N slaves, this will call nextSlave at most N times. If nextSlave # returns a slave that cannot satisfy the build chosen by nextBuild, # it will search for a slave that can satisfy the build. If one is found, # the slaves that cannot be used are "recycled" back into a list # to be tried, in order, for the next chosen build. # # There are two tests performed on the slave: # * can the slave start a generic build for the Builder? # * if so, can the slave start the chosen build on the Builder? # Slaves that cannot meet the first criterion are saved into the # self.rejectedSlaves list and will be used as a last resort. An example # of this test is whether the slave can grab the Builder's locks. # # If all slaves fail the first test, then the algorithm will assign the # slaves in the order originally generated. By setting self.rejectedSlaves # to None, the behavior will instead refuse to ever assign to a slave that # fails the generic test. def __init__(self, bldr, master): BuildChooserBase.__init__(self, bldr, master) self.nextSlave = self.bldr.config.nextSlave if not self.nextSlave: self.nextSlave = lambda _,slaves: random.choice(slaves) if slaves else None self.slavepool = self.bldr.getAvailableSlaves() # Pick slaves one at a time from the pool, and if the Builder says # they're usable (eg, locks can be satisfied), then prefer those slaves; # otherwise they go in the 'last resort' bucket, and we'll use them if # we need to. (Setting rejectedSlaves to None disables that feature) self.preferredSlaves = [] self.rejectedSlaves = [] self.nextBuild = self.bldr.config.nextBuild self.mergeRequestsFn = self.bldr.getMergeRequestsFn() @defer.inlineCallbacks def popNextBuild(self): nextBuild = (None, None) while 1: # 1. pick a slave slave = yield self._popNextSlave() if not slave: break # 2. pick a build breq = yield self._getNextUnclaimedBuildRequest() if not breq: break # either satisfy this build or we leave it for another day self._removeBuildRequest(breq) # 3. make sure slave+ is usable for the breq recycledSlaves = [] while slave: canStart = yield self.canStartBuild(slave, breq) if canStart: break # try a different slave recycledSlaves.append(slave) slave = yield self._popNextSlave() # recycle the slaves that we didnt use to the head of the queue # this helps ensure we run 'nextSlave' only once per slave choice if recycledSlaves: self._unpopSlaves(recycledSlaves) # 4. done? otherwise we will try another build if slave: nextBuild = (slave, breq) break defer.returnValue(nextBuild) @defer.inlineCallbacks def mergeRequests(self, breq): mergedRequests = [ breq ] # short circuit if there is no merging to do if not self.mergeRequestsFn or not self.unclaimedBrdicts: defer.returnValue(mergedRequests) return # we'll need BuildRequest objects, so get those first unclaimedBreqs = yield self._getUnclaimedBuildRequests() # gather the mergeable requests for req in unclaimedBreqs: canMerge = yield self.mergeRequestsFn(self.bldr, breq, req) if canMerge: mergedRequests.append(req) defer.returnValue(mergedRequests) @defer.inlineCallbacks def _getNextUnclaimedBuildRequest(self): # ensure the cache is there yield self._fetchUnclaimedBrdicts() if not self.unclaimedBrdicts: defer.returnValue(None) return if self.nextBuild: # nextBuild expects BuildRequest objects breqs = yield self._getUnclaimedBuildRequests() try: nextBreq = yield self.nextBuild(self.bldr, breqs) if nextBreq not in breqs: nextBreq = None except Exception: nextBreq = None else: # otherwise just return the first build brdict = self.unclaimedBrdicts[0] nextBreq = yield self._getBuildRequestForBrdict(brdict) defer.returnValue(nextBreq) @defer.inlineCallbacks def _popNextSlave(self): # use 'preferred' slaves first, if we have some ready if self.preferredSlaves: slave = self.preferredSlaves.pop(0) defer.returnValue(slave) return while self.slavepool: try: slave = yield self.nextSlave(self.bldr, self.slavepool) except Exception: slave = None if not slave or slave not in self.slavepool: # bad slave or no slave returned break self.slavepool.remove(slave) canStart = yield self.bldr.canStartWithSlavebuilder(slave) if canStart: defer.returnValue(slave) return # save as a last resort, just in case we need them later if self.rejectedSlaves is not None: self.rejectedSlaves.append(slave) # if we chewed through them all, use as last resort: if self.rejectedSlaves: slave = self.rejectedSlaves.pop(0) defer.returnValue(slave) return defer.returnValue(None) def _unpopSlaves(self, slaves): # push the slaves back to the front self.preferredSlaves[:0] = slaves def canStartBuild(self, slave, breq): return self.bldr.canStartBuild(slave, breq) class BuildRequestDistributor(service.Service): """ Special-purpose class to handle distributing build requests to builders by calling their C{maybeStartBuild} method. This takes account of the C{prioritizeBuilders} configuration, and is highly re-entrant; that is, if a new build request arrives while builders are still working on the previous build request, then this class will correctly re-prioritize invocations of builders' C{maybeStartBuild} methods. """ BuildChooser = BasicBuildChooser def __init__(self, botmaster): self.botmaster = botmaster self.master = botmaster.master # lock to ensure builders are only sorted once at any time self.pending_builders_lock = defer.DeferredLock() # sorted list of names of builders that need their maybeStartBuild # method invoked. self._pending_builders = [] self.activity_lock = defer.DeferredLock() self.active = False self._pendingMSBOCalls = [] @defer.inlineCallbacks def stopService(self): # Lots of stuff happens asynchronously here, so we need to let it all # quiesce. First, let the parent stopService succeed between # activities; then the loop will stop calling itself, since # self.running is false. yield self.activity_lock.run(service.Service.stopService, self) # now let any outstanding calls to maybeStartBuildsOn to finish, so # they don't get interrupted in mid-stride. This tends to be # particularly painful because it can occur when a generator is gc'd. if self._pendingMSBOCalls: yield defer.DeferredList(self._pendingMSBOCalls) def maybeStartBuildsOn(self, new_builders): """ Try to start any builds that can be started right now. This function returns immediately, and promises to trigger those builders eventually. @param new_builders: names of new builders that should be given the opportunity to check for new requests. """ if not self.running: return d = self._maybeStartBuildsOn(new_builders) self._pendingMSBOCalls.append(d) @d.addBoth def remove(x): self._pendingMSBOCalls.remove(d) return x d.addErrback(log.err, "while strting builds on %s" % (new_builders,)) def _maybeStartBuildsOn(self, new_builders): new_builders = set(new_builders) existing_pending = set(self._pending_builders) # if we won't add any builders, there's nothing to do if new_builders < existing_pending: return defer.succeed(None) # reset the list of pending builders @defer.inlineCallbacks def resetPendingBuildersList(new_builders): try: # re-fetch existing_pending, in case it has changed # while acquiring the lock existing_pending = set(self._pending_builders) # then sort the new, expanded set of builders self._pending_builders = \ yield self._sortBuilders( list(existing_pending | new_builders)) # start the activity loop, if we aren't already # working on that. if not self.active: self._activityLoop() except Exception: log.err(Failure(), "while attempting to start builds on %s" % self.name) return self.pending_builders_lock.run( resetPendingBuildersList, new_builders) @defer.inlineCallbacks def _defaultSorter(self, master, builders): timer = metrics.Timer("BuildRequestDistributor._defaultSorter()") timer.start() # perform an asynchronous schwarzian transform, transforming None # into sys.maxint so that it sorts to the end def xform(bldr): d = defer.maybeDeferred(lambda : bldr.getOldestRequestTime()) d.addCallback(lambda time : (((time is None) and None or time),bldr)) return d xformed = yield defer.gatherResults( [ xform(bldr) for bldr in builders ]) # sort the transformed list synchronously, comparing None to the end of # the list def nonecmp(a,b): if a[0] is None: return 1 if b[0] is None: return -1 return cmp(a,b) xformed.sort(cmp=nonecmp) # and reverse the transform rv = [ xf[1] for xf in xformed ] timer.stop() defer.returnValue(rv) @defer.inlineCallbacks def _sortBuilders(self, buildernames): timer = metrics.Timer("BuildRequestDistributor._sortBuilders()") timer.start() # note that this takes and returns a list of builder names # convert builder names to builders builders_dict = self.botmaster.builders builders = [ builders_dict.get(n) for n in buildernames if n in builders_dict ] # find a sorting function sorter = self.master.config.prioritizeBuilders if not sorter: sorter = self._defaultSorter # run it try: builders = yield defer.maybeDeferred(lambda : sorter(self.master, builders)) except Exception: log.err(Failure(), "prioritizing builders; order unspecified") # and return the names rv = [ b.name for b in builders ] timer.stop() defer.returnValue(rv) @defer.inlineCallbacks def _activityLoop(self): self.active = True timer = metrics.Timer('BuildRequestDistributor._activityLoop()') timer.start() while 1: yield self.activity_lock.acquire() # lock pending_builders, pop an element from it, and release yield self.pending_builders_lock.acquire() # bail out if we shouldn't keep looping if not self.running or not self._pending_builders: self.pending_builders_lock.release() self.activity_lock.release() break bldr_name = self._pending_builders.pop(0) self.pending_builders_lock.release() # get the actual builder object bldr = self.botmaster.builders.get(bldr_name) try: if bldr: yield self._maybeStartBuildsOnBuilder(bldr) except Exception: log.err(Failure(), "from maybeStartBuild for builder '%s'" % (bldr_name,)) self.activity_lock.release() timer.stop() self.active = False self._quiet() @defer.inlineCallbacks def _maybeStartBuildsOnBuilder(self, bldr): # create a chooser to give us our next builds # this object is temporary and will go away when we're done bc = self.createBuildChooser(bldr, self.master) while 1: slave, breqs = yield bc.chooseNextBuild() if not slave or not breqs: break # claim brid's brids = [ br.id for br in breqs ] try: yield self.master.db.buildrequests.claimBuildRequests(brids) except AlreadyClaimedError: # some brids were already claimed, so start over bc = self.createBuildChooser(bldr, self.master) continue buildStarted = yield bldr.maybeStartBuild(slave, breqs) if not buildStarted: yield self.master.db.buildrequests.unclaimBuildRequests(brids) # and try starting builds again. If we still have a working slave, # then this may re-claim the same buildrequests self.botmaster.maybeStartBuildsForBuilder(self.name) def createBuildChooser(self, bldr, master): # just instantiate the build chooser requested return self.BuildChooser(bldr, master) def _quiet(self): # shim for tests pass # pragma: no cover buildbot-0.8.8/buildbot/process/buildstep.py000066400000000000000000001072721222546025000211710ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import re from zope.interface import implements from twisted.internet import defer, error from twisted.protocols import basic from twisted.spread import pb from twisted.python import log, components from twisted.python.failure import Failure from twisted.web.util import formatFailure from twisted.python.reflect import accumulateClassList from buildbot import interfaces, util, config from buildbot.status import progress from buildbot.status.results import SUCCESS, WARNINGS, FAILURE, SKIPPED, \ EXCEPTION, RETRY, worst_status from buildbot.process import metrics, properties from buildbot.util.eventual import eventually class BuildStepFailed(Exception): pass class RemoteCommand(pb.Referenceable): # class-level unique identifier generator for command ids _commandCounter = 0 active = False rc = None debug = False def __init__(self, remote_command, args, ignore_updates=False, collectStdout=False, collectStderr=False, decodeRC={0:SUCCESS}): self.logs = {} self.delayedLogs = {} self._closeWhenFinished = {} self.collectStdout = collectStdout self.collectStderr = collectStderr self.stdout = '' self.stderr = '' self._startTime = None self._remoteElapsed = None self.remote_command = remote_command self.args = args self.ignore_updates = ignore_updates self.decodeRC = decodeRC def __repr__(self): return "" % (self.remote_command, id(self)) def run(self, step, remote): self.active = True self.step = step self.remote = remote # generate a new command id cmd_id = RemoteCommand._commandCounter RemoteCommand._commandCounter += 1 self.commandID = "%d" % cmd_id log.msg("%s: RemoteCommand.run [%s]" % (self, self.commandID)) self.deferred = defer.Deferred() d = defer.maybeDeferred(self._start) # _finished is called with an error for unknown commands, errors # that occur while the command is starting (including OSErrors in # exec()), StaleBroker (when the connection was lost before we # started), and pb.PBConnectionLost (when the slave isn't responding # over this connection, perhaps it had a power failure, or NAT # weirdness). If this happens, self.deferred is fired right away. d.addErrback(self._finished) # Connections which are lost while the command is running are caught # when our parent Step calls our .lostRemote() method. return self.deferred def useLog(self, log, closeWhenFinished=False, logfileName=None): assert interfaces.ILogFile.providedBy(log) if not logfileName: logfileName = log.getName() assert logfileName not in self.logs assert logfileName not in self.delayedLogs self.logs[logfileName] = log self._closeWhenFinished[logfileName] = closeWhenFinished def useLogDelayed(self, logfileName, activateCallBack, closeWhenFinished=False): assert logfileName not in self.logs assert logfileName not in self.delayedLogs self.delayedLogs[logfileName] = (activateCallBack, closeWhenFinished) def _start(self): self.updates = {} self._startTime = util.now() # This method only initiates the remote command. # We will receive remote_update messages as the command runs. # We will get a single remote_complete when it finishes. # We should fire self.deferred when the command is done. d = self.remote.callRemote("startCommand", self, self.commandID, self.remote_command, self.args) return d def _finished(self, failure=None): self.active = False # call .remoteComplete. If it raises an exception, or returns the # Failure that we gave it, our self.deferred will be errbacked. If # it does not (either it ate the Failure or there the step finished # normally and it didn't raise a new exception), self.deferred will # be callbacked. d = defer.maybeDeferred(self.remoteComplete, failure) # arrange for the callback to get this RemoteCommand instance # instead of just None d.addCallback(lambda r: self) # this fires the original deferred we returned from .run(), # with self as the result, or a failure d.addBoth(self.deferred.callback) def interrupt(self, why): log.msg("RemoteCommand.interrupt", self, why) if not self.active: log.msg(" but this RemoteCommand is already inactive") return defer.succeed(None) if not self.remote: log.msg(" but our .remote went away") return defer.succeed(None) if isinstance(why, Failure) and why.check(error.ConnectionLost): log.msg("RemoteCommand.disconnect: lost slave") self.remote = None self._finished(why) return defer.succeed(None) # tell the remote command to halt. Returns a Deferred that will fire # when the interrupt command has been delivered. d = defer.maybeDeferred(self.remote.callRemote, "interruptCommand", self.commandID, str(why)) # the slave may not have remote_interruptCommand d.addErrback(self._interruptFailed) return d def _interruptFailed(self, why): log.msg("RemoteCommand._interruptFailed", self) # TODO: forcibly stop the Command now, since we can't stop it # cleanly return None def remote_update(self, updates): """ I am called by the slave's L{buildbot.slave.bot.SlaveBuilder} so I can receive updates from the running remote command. @type updates: list of [object, int] @param updates: list of updates from the remote command """ self.buildslave.messageReceivedFromSlave() max_updatenum = 0 for (update, num) in updates: #log.msg("update[%d]:" % num) try: if self.active and not self.ignore_updates: self.remoteUpdate(update) except: # log failure, terminate build, let slave retire the update self._finished(Failure()) # TODO: what if multiple updates arrive? should # skip the rest but ack them all if num > max_updatenum: max_updatenum = num return max_updatenum def remote_complete(self, failure=None): """ Called by the slave's L{buildbot.slave.bot.SlaveBuilder} to notify me the remote command has finished. @type failure: L{twisted.python.failure.Failure} or None @rtype: None """ self.buildslave.messageReceivedFromSlave() # call the real remoteComplete a moment later, but first return an # acknowledgement so the slave can retire the completion message. if self.active: eventually(self._finished, failure) return None def addStdout(self, data): if 'stdio' in self.logs: self.logs['stdio'].addStdout(data) if self.collectStdout: self.stdout += data def addStderr(self, data): if 'stdio' in self.logs: self.logs['stdio'].addStderr(data) if self.collectStderr: self.stderr += data def addHeader(self, data): if 'stdio' in self.logs: self.logs['stdio'].addHeader(data) def addToLog(self, logname, data): # Activate delayed logs on first data. if logname in self.delayedLogs: (activateCallBack, closeWhenFinished) = self.delayedLogs[logname] del self.delayedLogs[logname] loog = activateCallBack(self) self.logs[logname] = loog self._closeWhenFinished[logname] = closeWhenFinished if logname in self.logs: self.logs[logname].addStdout(data) else: log.msg("%s.addToLog: no such log %s" % (self, logname)) @metrics.countMethod('RemoteCommand.remoteUpdate()') def remoteUpdate(self, update): if self.debug: for k,v in update.items(): log.msg("Update[%s]: %s" % (k,v)) if update.has_key('stdout'): # 'stdout': data self.addStdout(update['stdout']) if update.has_key('stderr'): # 'stderr': data self.addStderr(update['stderr']) if update.has_key('header'): # 'header': data self.addHeader(update['header']) if update.has_key('log'): # 'log': (logname, data) logname, data = update['log'] self.addToLog(logname, data) if update.has_key('rc'): rc = self.rc = update['rc'] log.msg("%s rc=%s" % (self, rc)) self.addHeader("program finished with exit code %d\n" % rc) if update.has_key('elapsed'): self._remoteElapsed = update['elapsed'] # TODO: these should be handled at the RemoteCommand level for k in update: if k not in ('stdout', 'stderr', 'header', 'rc'): if k not in self.updates: self.updates[k] = [] self.updates[k].append(update[k]) def remoteComplete(self, maybeFailure): if self._startTime and self._remoteElapsed: delta = (util.now() - self._startTime) - self._remoteElapsed metrics.MetricTimeEvent.log("RemoteCommand.overhead", delta) for name,loog in self.logs.items(): if self._closeWhenFinished[name]: if maybeFailure: loog.addHeader("\nremoteFailed: %s" % maybeFailure) else: log.msg("closing log %s" % loog) loog.finish() return maybeFailure def results(self): if self.rc in self.decodeRC: return self.decodeRC[self.rc] return FAILURE def didFail(self): return self.results() == FAILURE LoggedRemoteCommand = RemoteCommand class LogObserver: implements(interfaces.ILogObserver) def setStep(self, step): self.step = step def setLog(self, loog): assert interfaces.IStatusLog.providedBy(loog) loog.subscribe(self, True) def logChunk(self, build, step, log, channel, text): if channel == interfaces.LOG_CHANNEL_STDOUT: self.outReceived(text) elif channel == interfaces.LOG_CHANNEL_STDERR: self.errReceived(text) # TODO: add a logEnded method? er, stepFinished? def outReceived(self, data): """This will be called with chunks of stdout data. Override this in your observer.""" pass def errReceived(self, data): """This will be called with chunks of stderr data. Override this in your observer.""" pass class LogLineObserver(LogObserver): def __init__(self): self.stdoutParser = basic.LineOnlyReceiver() self.stdoutParser.delimiter = "\n" self.stdoutParser.lineReceived = self.outLineReceived self.stdoutParser.transport = self # for the .disconnecting attribute self.disconnecting = False self.stderrParser = basic.LineOnlyReceiver() self.stderrParser.delimiter = "\n" self.stderrParser.lineReceived = self.errLineReceived self.stderrParser.transport = self def setMaxLineLength(self, max_length): """ Set the maximum line length: lines longer than max_length are dropped. Default is 16384 bytes. Use sys.maxint for effective infinity. """ self.stdoutParser.MAX_LENGTH = max_length self.stderrParser.MAX_LENGTH = max_length def outReceived(self, data): self.stdoutParser.dataReceived(data) def errReceived(self, data): self.stderrParser.dataReceived(data) def outLineReceived(self, line): """This will be called with complete stdout lines (not including the delimiter). Override this in your observer.""" pass def errLineReceived(self, line): """This will be called with complete lines of stderr (not including the delimiter). Override this in your observer.""" pass class RemoteShellCommand(RemoteCommand): def __init__(self, workdir, command, env=None, want_stdout=1, want_stderr=1, timeout=20*60, maxTime=None, logfiles={}, usePTY="slave-config", logEnviron=True, collectStdout=False,collectStderr=False, interruptSignal=None, initialStdin=None, decodeRC={0:SUCCESS}): self.command = command # stash .command, set it later if env is not None: # avoid mutating the original master.cfg dictionary. Each # ShellCommand gets its own copy, any start() methods won't be # able to modify the original. env = env.copy() args = {'workdir': workdir, 'env': env, 'want_stdout': want_stdout, 'want_stderr': want_stderr, 'logfiles': logfiles, 'timeout': timeout, 'maxTime': maxTime, 'usePTY': usePTY, 'logEnviron': logEnviron, 'initial_stdin': initialStdin } if interruptSignal is not None: args['interruptSignal'] = interruptSignal RemoteCommand.__init__(self, "shell", args, collectStdout=collectStdout, collectStderr=collectStderr, decodeRC=decodeRC) def _start(self): self.args['command'] = self.command if self.remote_command == "shell": # non-ShellCommand slavecommands are responsible for doing this # fixup themselves if self.step.slaveVersion("shell", "old") == "old": self.args['dir'] = self.args['workdir'] what = "command '%s' in dir '%s'" % (self.args['command'], self.args['workdir']) log.msg(what) return RemoteCommand._start(self) def __repr__(self): return "" % repr(self.command) class _BuildStepFactory(util.ComparableMixin): """ This is a wrapper to record the arguments passed to as BuildStep subclass. We use an instance of this class, rather than a closure mostly to make it easier to test that the right factories are getting created. """ compare_attrs = ['factory', 'args', 'kwargs' ] implements(interfaces.IBuildStepFactory) def __init__(self, factory, *args, **kwargs): self.factory = factory self.args = args self.kwargs = kwargs def buildStep(self): try: return self.factory(*self.args, **self.kwargs) except: log.msg("error while creating step, factory=%s, args=%s, kwargs=%s" % (self.factory, self.args, self.kwargs)) raise class BuildStep(object, properties.PropertiesMixin): haltOnFailure = False flunkOnWarnings = False flunkOnFailure = False warnOnWarnings = False warnOnFailure = False alwaysRun = False doStepIf = True hideStepIf = False # properties set on a build step are, by nature, always runtime properties set_runtime_properties = True # 'parms' holds a list of all the parameters we care about, to allow # users to instantiate a subclass of BuildStep with a mixture of # arguments, some of which are for us, some of which are for the subclass # (or a delegate of the subclass, like how ShellCommand delivers many # arguments to the RemoteShellCommand that it creates). Such delegating # subclasses will use this list to figure out which arguments are meant # for us and which should be given to someone else. parms = ['name', 'locks', 'haltOnFailure', 'flunkOnWarnings', 'flunkOnFailure', 'warnOnWarnings', 'warnOnFailure', 'alwaysRun', 'progressMetrics', 'useProgress', 'doStepIf', 'hideStepIf', ] name = "generic" locks = [] progressMetrics = () # 'time' is implicit useProgress = True # set to False if step is really unpredictable build = None step_status = None progress = None def __init__(self, **kwargs): for p in self.__class__.parms: if kwargs.has_key(p): setattr(self, p, kwargs[p]) del kwargs[p] if kwargs: config.error("%s.__init__ got unexpected keyword argument(s) %s" \ % (self.__class__, kwargs.keys())) self._pendingLogObservers = [] if not isinstance(self.name, str): config.error("BuildStep name must be a string: %r" % (self.name,)) self._acquiringLock = None self.stopped = False def __new__(klass, *args, **kwargs): self = object.__new__(klass) self._factory = _BuildStepFactory(klass, *args, **kwargs) return self def describe(self, done=False): return [self.name] def setBuild(self, build): self.build = build def setBuildSlave(self, buildslave): self.buildslave = buildslave def setDefaultWorkdir(self, workdir): pass def addFactoryArguments(self, **kwargs): # this is here for backwards compatability pass def _getStepFactory(self): return self._factory def setStepStatus(self, step_status): self.step_status = step_status def setupProgress(self): if self.useProgress: sp = progress.StepProgress(self.name, self.progressMetrics) self.progress = sp self.step_status.setProgress(sp) return sp return None def setProgress(self, metric, value): if self.progress: self.progress.setProgress(metric, value) def startStep(self, remote): self.remote = remote self.deferred = defer.Deferred() # convert all locks into their real form self.locks = [(self.build.builder.botmaster.getLockByID(access.lockid), access) for access in self.locks ] # then narrow SlaveLocks down to the slave that this build is being # run on self.locks = [(l.getLock(self.build.slavebuilder.slave), la) for l, la in self.locks ] for l, la in self.locks: if l in self.build.locks: log.msg("Hey, lock %s is claimed by both a Step (%s) and the" " parent Build (%s)" % (l, self, self.build)) raise RuntimeError("lock claimed by both Step and Build") # Set the step's text here so that the stepStarted notification sees # the correct description self.step_status.setText(self.describe(False)) self.step_status.stepStarted() d = self.acquireLocks() d.addCallback(self._startStep_2) d.addErrback(self.failed) return self.deferred def acquireLocks(self, res=None): self._acquiringLock = None if not self.locks: return defer.succeed(None) if self.stopped: return defer.succeed(None) log.msg("acquireLocks(step %s, locks %s)" % (self, self.locks)) for lock, access in self.locks: if not lock.isAvailable(self, access): self.step_status.setWaitingForLocks(True) log.msg("step %s waiting for lock %s" % (self, lock)) d = lock.waitUntilMaybeAvailable(self, access) d.addCallback(self.acquireLocks) self._acquiringLock = (lock, access, d) return d # all locks are available, claim them all for lock, access in self.locks: lock.claim(self, access) self.step_status.setWaitingForLocks(False) return defer.succeed(None) def _startStep_2(self, res): if self.stopped: self.finished(EXCEPTION) return if self.progress: self.progress.start() if isinstance(self.doStepIf, bool): doStep = defer.succeed(self.doStepIf) else: doStep = defer.maybeDeferred(self.doStepIf, self) renderables = [] accumulateClassList(self.__class__, 'renderables', renderables) def setRenderable(res, attr): setattr(self, attr, res) dl = [ doStep ] for renderable in renderables: d = self.build.render(getattr(self, renderable)) d.addCallback(setRenderable, renderable) dl.append(d) dl = defer.gatherResults(dl) dl.addCallback(self._startStep_3) return dl @defer.inlineCallbacks def _startStep_3(self, doStep): doStep = doStep[0] try: if doStep: result = yield defer.maybeDeferred(self.start) if result == SKIPPED: doStep = False except: log.msg("BuildStep.startStep exception in .start") self.failed(Failure()) if not doStep: self.step_status.setText(self.describe(True) + ['skipped']) self.step_status.setSkipped(True) # this return value from self.start is a shortcut to finishing # the step immediately; we skip calling finished() as # subclasses may have overridden that an expect it to be called # after start() (bug #837) eventually(self._finishFinished, SKIPPED) def start(self): raise NotImplementedError("your subclass must implement this method") def interrupt(self, reason): self.stopped = True if self._acquiringLock: lock, access, d = self._acquiringLock lock.stopWaitingUntilAvailable(self, access, d) d.callback(None) def releaseLocks(self): log.msg("releaseLocks(%s): %s" % (self, self.locks)) for lock, access in self.locks: if lock.isOwner(self, access): lock.release(self, access) else: # This should only happen if we've been interrupted assert self.stopped def finished(self, results): if self.stopped and results != RETRY: # We handle this specially because we don't care about # the return code of an interrupted command; we know # that this should just be exception due to interrupt # At the same time we must respect RETRY status because it's used # to retry interrupted build due to some other issues for example # due to slave lost results = EXCEPTION self.step_status.setText(self.describe(True) + ["interrupted"]) self.step_status.setText2(["interrupted"]) self._finishFinished(results) def _finishFinished(self, results): # internal function to indicate that this step is done; this is separated # from finished() so that subclasses can override finished() if self.progress: self.progress.finish() try: hidden = self._maybeEvaluate(self.hideStepIf, results, self) except Exception: why = Failure() self.addHTMLLog("err.html", formatFailure(why)) self.addCompleteLog("err.text", why.getTraceback()) results = EXCEPTION hidden = False self.step_status.stepFinished(results) self.step_status.setHidden(hidden) self.releaseLocks() self.deferred.callback(results) def failed(self, why): # This can either be a BuildStepFailed exception/failure, meaning we # should call self.finished, or it can be a real exception, which should # be recorded as such. if why.check(BuildStepFailed): self.finished(FAILURE) return log.err(why, "BuildStep.failed; traceback follows") try: if self.progress: self.progress.finish() try: self.addCompleteLog("err.text", why.getTraceback()) self.addHTMLLog("err.html", formatFailure(why)) except Exception: log.err(Failure(), "error while formatting exceptions") # could use why.getDetailedTraceback() for more information self.step_status.setText([self.name, "exception"]) self.step_status.setText2([self.name]) self.step_status.stepFinished(EXCEPTION) hidden = self._maybeEvaluate(self.hideStepIf, EXCEPTION, self) self.step_status.setHidden(hidden) except Exception: log.err(Failure(), "exception during failure processing") # the progress stuff may still be whacked (the StepStatus may # think that it is still running), but the build overall will now # finish try: self.releaseLocks() except Exception: log.err(Failure(), "exception while releasing locks") log.msg("BuildStep.failed now firing callback") self.deferred.callback(EXCEPTION) # utility methods that BuildSteps may find useful def slaveVersion(self, command, oldversion=None): return self.build.getSlaveCommandVersion(command, oldversion) def slaveVersionIsOlderThan(self, command, minversion): sv = self.build.getSlaveCommandVersion(command, None) if sv is None: return True if map(int, sv.split(".")) < map(int, minversion.split(".")): return True return False def getSlaveName(self): return self.build.getSlaveName() def addLog(self, name): loog = self.step_status.addLog(name) self._connectPendingLogObservers() return loog def getLog(self, name): for l in self.step_status.getLogs(): if l.getName() == name: return l raise KeyError("no log named '%s'" % (name,)) def addCompleteLog(self, name, text): log.msg("addCompleteLog(%s)" % name) loog = self.step_status.addLog(name) size = loog.chunkSize for start in range(0, len(text), size): loog.addStdout(text[start:start+size]) loog.finish() self._connectPendingLogObservers() def addHTMLLog(self, name, html): log.msg("addHTMLLog(%s)" % name) self.step_status.addHTMLLog(name, html) self._connectPendingLogObservers() def addLogObserver(self, logname, observer): assert interfaces.ILogObserver.providedBy(observer) observer.setStep(self) self._pendingLogObservers.append((logname, observer)) self._connectPendingLogObservers() def _connectPendingLogObservers(self): if not self._pendingLogObservers: return if not self.step_status: return current_logs = {} for loog in self.step_status.getLogs(): current_logs[loog.getName()] = loog for logname, observer in self._pendingLogObservers[:]: if logname in current_logs: observer.setLog(current_logs[logname]) self._pendingLogObservers.remove((logname, observer)) def addURL(self, name, url): self.step_status.addURL(name, url) def runCommand(self, c): c.buildslave = self.buildslave d = c.run(self, self.remote) return d @staticmethod def _maybeEvaluate(value, *args, **kwargs): if callable(value): value = value(*args, **kwargs) return value components.registerAdapter( BuildStep._getStepFactory, BuildStep, interfaces.IBuildStepFactory) components.registerAdapter( lambda step : interfaces.IProperties(step.build), BuildStep, interfaces.IProperties) class OutputProgressObserver(LogObserver): length = 0 def __init__(self, name): self.name = name def logChunk(self, build, step, log, channel, text): self.length += len(text) self.step.setProgress(self.name, self.length) class LoggingBuildStep(BuildStep): progressMetrics = ('output',) logfiles = {} parms = BuildStep.parms + ['logfiles', 'lazylogfiles', 'log_eval_func'] cmd = None renderables = [ 'logfiles', 'lazylogfiles' ] def __init__(self, logfiles={}, lazylogfiles=False, log_eval_func=None, *args, **kwargs): BuildStep.__init__(self, *args, **kwargs) if logfiles and not isinstance(logfiles, dict): config.error( "the ShellCommand 'logfiles' parameter must be a dictionary") # merge a class-level 'logfiles' attribute with one passed in as an # argument self.logfiles = self.logfiles.copy() self.logfiles.update(logfiles) self.lazylogfiles = lazylogfiles if log_eval_func and not callable(log_eval_func): config.error( "the 'log_eval_func' paramater must be a callable") self.log_eval_func = log_eval_func self.addLogObserver('stdio', OutputProgressObserver("output")) def addLogFile(self, logname, filename): self.logfiles[logname] = filename def buildCommandKwargs(self): kwargs = dict() kwargs['logfiles'] = self.logfiles return kwargs def startCommand(self, cmd, errorMessages=[]): """ @param cmd: a suitable RemoteCommand which will be launched, with all output being put into our self.stdio_log LogFile """ log.msg("ShellCommand.startCommand(cmd=%s)" % (cmd,)) log.msg(" cmd.args = %r" % (cmd.args)) self.cmd = cmd # so we can interrupt it self.step_status.setText(self.describe(False)) # stdio is the first log self.stdio_log = stdio_log = self.addLog("stdio") cmd.useLog(stdio_log, True) for em in errorMessages: stdio_log.addHeader(em) # TODO: consider setting up self.stdio_log earlier, and have the # code that passes in errorMessages instead call # self.stdio_log.addHeader() directly. # there might be other logs self.setupLogfiles(cmd, self.logfiles) d = self.runCommand(cmd) # might raise ConnectionLost d.addCallback(lambda res: self.commandComplete(cmd)) d.addCallback(lambda res: self.createSummary(cmd.logs['stdio'])) d.addCallback(lambda res: self.evaluateCommand(cmd)) # returns results def _gotResults(results): self.setStatus(cmd, results) return results d.addCallback(_gotResults) # returns results d.addCallbacks(self.finished, self.checkDisconnect) d.addErrback(self.failed) def setupLogfiles(self, cmd, logfiles): for logname,remotefilename in logfiles.items(): if self.lazylogfiles: # Ask RemoteCommand to watch a logfile, but only add # it when/if we see any data. # # The dummy default argument local_logname is a work-around for # Python name binding; default values are bound by value, but # captured variables in the body are bound by name. callback = lambda cmd_arg, local_logname=logname: self.addLog(local_logname) cmd.useLogDelayed(logname, callback, True) else: # tell the BuildStepStatus to add a LogFile newlog = self.addLog(logname) # and tell the RemoteCommand to feed it cmd.useLog(newlog, True) def interrupt(self, reason): # TODO: consider adding an INTERRUPTED or STOPPED status to use # instead of FAILURE, might make the text a bit more clear. # 'reason' can be a Failure, or text BuildStep.interrupt(self, reason) if self.step_status.isWaitingForLocks(): self.addCompleteLog('interrupt while waiting for locks', str(reason)) else: self.addCompleteLog('interrupt', str(reason)) if self.cmd: d = self.cmd.interrupt(reason) d.addErrback(log.err, 'while interrupting command') def checkDisconnect(self, f): f.trap(error.ConnectionLost) self.step_status.setText(self.describe(True) + ["exception", "slave", "lost"]) self.step_status.setText2(["exception", "slave", "lost"]) return self.finished(RETRY) def commandComplete(self, cmd): pass def createSummary(self, stdio): pass def evaluateCommand(self, cmd): if self.log_eval_func: return self.log_eval_func(cmd, self.step_status) return cmd.results() def getText(self, cmd, results): if results == SUCCESS: return self.describe(True) elif results == WARNINGS: return self.describe(True) + ["warnings"] elif results == EXCEPTION: return self.describe(True) + ["exception"] else: return self.describe(True) + ["failed"] def getText2(self, cmd, results): return [self.name] def maybeGetText2(self, cmd, results): if results == SUCCESS: # successful steps do not add anything to the build's text pass elif results == WARNINGS: if (self.flunkOnWarnings or self.warnOnWarnings): # we're affecting the overall build, so tell them why return self.getText2(cmd, results) else: if (self.haltOnFailure or self.flunkOnFailure or self.warnOnFailure): # we're affecting the overall build, so tell them why return self.getText2(cmd, results) return [] def setStatus(self, cmd, results): # this is good enough for most steps, but it can be overridden to # get more control over the displayed text self.step_status.setText(self.getText(cmd, results)) self.step_status.setText2(self.maybeGetText2(cmd, results)) # Parses the logs for a list of regexs. Meant to be invoked like: # regexes = ((re.compile(...), FAILURE), (re.compile(...), WARNINGS)) # self.addStep(ShellCommand, # command=..., # ..., # log_eval_func=lambda c,s: regex_log_evaluator(c, s, regexs) # ) def regex_log_evaluator(cmd, step_status, regexes): worst = cmd.results() for err, possible_status in regexes: # worst_status returns the worse of the two status' passed to it. # we won't be changing "worst" unless possible_status is worse than it, # so we don't even need to check the log if that's the case if worst_status(worst, possible_status) == possible_status: if isinstance(err, (basestring)): err = re.compile(".*%s.*" % err, re.DOTALL) for l in cmd.logs.values(): if err.search(l.getText()): worst = possible_status return worst # (WithProperties used to be available in this module) from buildbot.process.properties import WithProperties _hush_pyflakes = [WithProperties] del _hush_pyflakes buildbot-0.8.8/buildbot/process/cache.py000066400000000000000000000056471222546025000202440ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.util import lru from buildbot import config from twisted.application import service class CacheManager(config.ReconfigurableServiceMixin, service.Service): """ A manager for a collection of caches, each for different types of objects and with potentially-overlapping key spaces. There is generally only one instance of this class, available at C{master.caches}. """ # a cache of length one still has many benefits: it collects objects that # remain referenced elsewhere; it collapses simultaneous misses into one # miss function; and it will optimize repeated fetches of the same object. DEFAULT_CACHE_SIZE = 1 def __init__(self): self.setName('caches') self.config = {} self._caches = {} def get_cache(self, cache_name, miss_fn): """ Get an L{AsyncLRUCache} object with the given name. If such an object does not exist, it will be created. Since the cache is permanent, this method can be called only once, e.g., in C{startService}, and it value stored indefinitely. @param cache_name: name of the cache (usually the name of the type of object it stores) @param miss_fn: miss function for the cache; see L{AsyncLRUCache} constructor. @returns: L{AsyncLRUCache} instance """ try: return self._caches[cache_name] except KeyError: max_size = self.config.get(cache_name, self.DEFAULT_CACHE_SIZE) assert max_size >= 1 c = self._caches[cache_name] = lru.AsyncLRUCache(miss_fn, max_size) return c def reconfigService(self, new_config): self.config = new_config.caches for name, cache in self._caches.iteritems(): cache.set_max_size(new_config.caches.get(name, self.DEFAULT_CACHE_SIZE)) return config.ReconfigurableServiceMixin.reconfigService(self, new_config) def get_metrics(self): return dict([ (n, dict(hits=c.hits, refhits=c.refhits, misses=c.misses, max_size=c.max_size)) for n, c in self._caches.iteritems()]) buildbot-0.8.8/buildbot/process/debug.py000066400000000000000000000111251222546025000202530ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.python import log from twisted.internet import defer from twisted.application import service from buildbot.pbutil import NewCredPerspective from buildbot.sourcestamp import SourceStamp from buildbot import interfaces, config from buildbot.process.properties import Properties class DebugServices(config.ReconfigurableServiceMixin, service.MultiService): def __init__(self, master): service.MultiService.__init__(self) self.setName('debug_services') self.master = master self.debug_port = None self.debug_password = None self.debug_registration = None self.manhole = None @defer.inlineCallbacks def reconfigService(self, new_config): # debug client config_changed = (self.debug_port != new_config.slavePortnum or self.debug_password != new_config.debugPassword) if not new_config.debugPassword or config_changed: if self.debug_registration: yield self.debug_registration.unregister() self.debug_registration = None if new_config.debugPassword and config_changed: factory = lambda mind, user : DebugPerspective(self.master) self.debug_registration = self.master.pbmanager.register( new_config.slavePortnum, "debug", new_config.debugPassword, factory) self.debug_password = new_config.debugPassword if self.debug_password: self.debug_port = new_config.slavePortnum else: self.debug_port = None # manhole if new_config.manhole != self.manhole: if self.manhole: yield defer.maybeDeferred(lambda : self.manhole.disownServiceParent()) self.manhole.master = None self.manhole = None if new_config.manhole: self.manhole = new_config.manhole self.manhole.master = self.master self.manhole.setServiceParent(self) # chain up yield config.ReconfigurableServiceMixin.reconfigService(self, new_config) @defer.inlineCallbacks def stopService(self): if self.debug_registration: yield self.debug_registration.unregister() self.debug_registration = None # manhole will get stopped as a sub-service yield defer.maybeDeferred(lambda : service.MultiService.stopService(self)) # clean up if self.manhole: self.manhole.master = None self.manhole = None class DebugPerspective(NewCredPerspective): def __init__(self, master): self.master = master def attached(self, mind): return self def detached(self, mind): pass def perspective_requestBuild(self, buildername, reason, branch, revision, properties={}): c = interfaces.IControl(self.master) bc = c.getBuilder(buildername) ss = SourceStamp(branch, revision) bpr = Properties() bpr.update(properties, "remote requestBuild") return bc.submitBuildRequest(ss, reason, bpr) def perspective_pingBuilder(self, buildername): c = interfaces.IControl(self.master) bc = c.getBuilder(buildername) bc.ping() def perspective_reload(self): log.msg("debug client - triggering master reconfig") self.master.reconfig() def perspective_pokeIRC(self): log.msg("saying something on IRC") from buildbot.status import words for s in self.master: if isinstance(s, words.IRC): bot = s.f for channel in bot.channels: print " channel", channel bot.p.msg(channel, "Ow, quit it") def perspective_print(self, msg): log.msg("debug %s" % msg) buildbot-0.8.8/buildbot/process/factory.py000066400000000000000000000173151222546025000206430ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import warnings from twisted.python import deprecate, versions from buildbot import interfaces, util from buildbot.process.build import Build from buildbot.process.buildstep import BuildStep from buildbot.steps.source import CVS, SVN from buildbot.steps.shell import Configure, Compile, Test, PerlModuleTest # deprecated, use BuildFactory.addStep @deprecate.deprecated(versions.Version("buildbot", 0, 8, 6)) def s(steptype, **kwargs): # convenience function for master.cfg files, to create step # specification tuples return interfaces.IBuildStepFactory(steptype(**kwargs)) class BuildFactory(util.ComparableMixin): """ @cvar buildClass: class to use when creating builds @type buildClass: L{buildbot.process.build.Build} """ buildClass = Build useProgress = 1 workdir = "build" compare_attrs = ['buildClass', 'steps', 'useProgress', 'workdir'] def __init__(self, steps=None): self.steps = [] if steps: self.addSteps(steps) def newBuild(self, requests): """Create a new Build instance. @param requests: a list of buildrequest dictionaries describing what is to be built """ b = self.buildClass(requests) b.useProgress = self.useProgress b.workdir = self.workdir b.setStepFactories(self.steps) return b def addStep(self, step, **kwargs): if kwargs or (type(step) == type(BuildStep) and issubclass(step, BuildStep)): warnings.warn( "Passing a BuildStep subclass to factory.addStep is " "deprecated. Please pass a BuildStep instance instead.", DeprecationWarning, stacklevel=2) step = step(**kwargs) self.steps.append(interfaces.IBuildStepFactory(step)) def addSteps(self, steps): for s in steps: self.addStep(s) # BuildFactory subclasses for common build tools class GNUAutoconf(BuildFactory): def __init__(self, source, configure="./configure", configureEnv={}, configureFlags=[], compile=["make", "all"], test=["make", "check"]): BuildFactory.__init__(self, [source]) if configure is not None: # we either need to wind up with a string (which will be # space-split), or with a list of strings (which will not). The # list of strings is the preferred form. if type(configure) is str: if configureFlags: assert not " " in configure # please use list instead command = [configure] + configureFlags else: command = configure else: assert isinstance(configure, (list, tuple)) command = configure + configureFlags self.addStep(Configure(command=command, env=configureEnv)) if compile is not None: self.addStep(Compile(command=compile)) if test is not None: self.addStep(Test(command=test)) class CPAN(BuildFactory): def __init__(self, source, perl="perl"): BuildFactory.__init__(self, [source]) self.addStep(Configure(command=[perl, "Makefile.PL"])) self.addStep(Compile(command=["make"])) self.addStep(PerlModuleTest(command=["make", "test"])) class Distutils(BuildFactory): def __init__(self, source, python="python", test=None): BuildFactory.__init__(self, [source]) self.addStep(Compile(command=[python, "./setup.py", "build"])) if test is not None: self.addStep(Test(command=test)) class Trial(BuildFactory): """Build a python module that uses distutils and trial. Set 'tests' to the module in which the tests can be found, or set useTestCaseNames=True to always have trial figure out which tests to run (based upon which files have been changed). See docs/factories.xhtml for usage samples. Not all of the Trial BuildStep options are available here, only the most commonly used ones. To get complete access, you will need to create a custom BuildFactory.""" trial = "trial" randomly = False recurse = False def __init__(self, source, buildpython=["python"], trialpython=[], trial=None, testpath=".", randomly=None, recurse=None, tests=None, useTestCaseNames=False, env=None): BuildFactory.__init__(self, [source]) assert tests or useTestCaseNames, "must use one or the other" if trial is not None: self.trial = trial if randomly is not None: self.randomly = randomly if recurse is not None: self.recurse = recurse from buildbot.steps.python_twisted import Trial buildcommand = buildpython + ["./setup.py", "build"] self.addStep(Compile(command=buildcommand, env=env)) self.addStep(Trial( python=trialpython, trial=self.trial, testpath=testpath, tests=tests, testChanges=useTestCaseNames, randomly=self.randomly, recurse=self.recurse, env=env, )) # compatibility classes, will go away. Note that these only offer # compatibility at the constructor level: if you have subclassed these # factories, your subclasses are unlikely to still work correctly. ConfigurableBuildFactory = BuildFactory class BasicBuildFactory(GNUAutoconf): # really a "GNU Autoconf-created tarball -in-CVS tree" builder def __init__(self, cvsroot, cvsmodule, configure=None, configureEnv={}, compile="make all", test="make check", cvsCopy=False): mode = "clobber" if cvsCopy: mode = "copy" source = CVS(cvsroot=cvsroot, cvsmodule=cvsmodule, mode=mode) GNUAutoconf.__init__(self, source, configure=configure, configureEnv=configureEnv, compile=compile, test=test) class QuickBuildFactory(BasicBuildFactory): useProgress = False def __init__(self, cvsroot, cvsmodule, configure=None, configureEnv={}, compile="make all", test="make check", cvsCopy=False): mode = "update" source = CVS(cvsroot=cvsroot, cvsmodule=cvsmodule, mode=mode) GNUAutoconf.__init__(self, source, configure=configure, configureEnv=configureEnv, compile=compile, test=test) class BasicSVN(GNUAutoconf): def __init__(self, svnurl, configure=None, configureEnv={}, compile="make all", test="make check"): source = SVN(svnurl=svnurl, mode="update") GNUAutoconf.__init__(self, source, configure=configure, configureEnv=configureEnv, compile=compile, test=test) buildbot-0.8.8/buildbot/process/metrics.py000066400000000000000000000355741222546025000206510ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement """ Buildbot metrics module Keeps track of counts and timings of various internal buildbot activities. Basic architecture: MetricEvent.log(...) || \/ MetricLogObserver || \/ MetricHandler || \/ MetricWatcher """ from collections import deque from twisted.python import log from twisted.internet.task import LoopingCall from twisted.internet import reactor from twisted.application import service from buildbot import util, config from collections import defaultdict import gc, os, sys # Make use of the resource module if we can try: import resource assert resource except ImportError: resource = None class MetricEvent(object): @classmethod def log(cls, *args, **kwargs): log.msg(metric=cls(*args, **kwargs)) class MetricCountEvent(MetricEvent): def __init__(self, counter, count=1, absolute=False): self.counter = counter self.count = count self.absolute = absolute class MetricTimeEvent(MetricEvent): def __init__(self, timer, elapsed): self.timer = timer self.elapsed = elapsed ALARM_OK, ALARM_WARN, ALARM_CRIT = range(3) ALARM_TEXT = ["OK", "WARN", "CRIT"] class MetricAlarmEvent(MetricEvent): def __init__(self, alarm, msg=None, level=ALARM_OK): self.alarm = alarm self.level = level self.msg = msg def countMethod(counter): def decorator(func): def wrapper(*args, **kwargs): MetricCountEvent.log(counter=counter) return func(*args, **kwargs) return wrapper return decorator class Timer(object): # For testing _reactor = None def __init__(self, name): self.name = name self.started = None def startTimer(self, func): def wrapper(*args, **kwargs): self.start() return func(*args, **kwargs) return wrapper def stopTimer(self, func): def wrapper(*args, **kwargs): try: return func(*args, **kwargs) finally: self.stop() return wrapper def start(self): self.started = util.now(self._reactor) def stop(self): if self.started is not None: elapsed = util.now(self._reactor) - self.started MetricTimeEvent.log(timer=self.name, elapsed=elapsed) self.started = None def timeMethod(name, _reactor=None): def decorator(func): t = Timer(name) t._reactor=_reactor def wrapper(*args, **kwargs): t.start() try: return func(*args, **kwargs) finally: t.stop() return wrapper return decorator class FiniteList(deque): def __init__(self, maxlen=10): self._maxlen = maxlen deque.__init__(self) def append(self, o): deque.append(self, o) if len(self) > self._maxlen: self.popleft() class AveragingFiniteList(FiniteList): def __init__(self, maxlen=10): FiniteList.__init__(self, maxlen) self.average = 0 def append(self, o): FiniteList.append(self, o) self._calc() def _calc(self): if len(self) == 0: self.average = 0 else: self.average = float(sum(self)) / len(self) return self.average class MetricHandler(object): def __init__(self, metrics): self.metrics = metrics self.watchers = [] self.reset() def addWatcher(self, watcher): self.watchers.append(watcher) def removeWatcher(self, watcher): self.watchers.remove(watcher) # For subclasses to define def reset(self): raise NotImplementedError def handle(self, eventDict, metric): raise NotImplementedError def get(self, metric): raise NotImplementedError def keys(self): raise NotImplementedError def report(self): raise NotImplementedError def asDict(self): raise NotImplementedError class MetricCountHandler(MetricHandler): _counters = None def reset(self): self._counters = defaultdict(int) def handle(self, eventDict, metric): if metric.absolute: self._counters[metric.counter] = metric.count else: self._counters[metric.counter] += metric.count def keys(self): return self._counters.keys() def get(self, counter): return self._counters[counter] def report(self): retval = [] for counter in sorted(self.keys()): retval.append("Counter %s: %i" % (counter, self.get(counter))) return "\n".join(retval) def asDict(self): retval = {} for counter in sorted(self.keys()): retval[counter] = self.get(counter) return dict(counters=retval) class MetricTimeHandler(MetricHandler): _timers = None def reset(self): self._timers = defaultdict(AveragingFiniteList) def handle(self, eventDict, metric): self._timers[metric.timer].append(metric.elapsed) def keys(self): return self._timers.keys() def get(self, timer): return self._timers[timer].average def report(self): retval = [] for timer in sorted(self.keys()): retval.append("Timer %s: %.3g" % (timer, self.get(timer))) return "\n".join(retval) def asDict(self): retval = {} for timer in sorted(self.keys()): retval[timer] = self.get(timer) return dict(timers=retval) class MetricAlarmHandler(MetricHandler): _alarms = None def reset(self): self._alarms = defaultdict(lambda x: ALARM_OK) def handle(self, eventDict, metric): self._alarms[metric.alarm] = (metric.level, metric.msg) def report(self): retval = [] for alarm, (level, msg) in sorted(self._alarms.items()): if msg: retval.append("%s %s: %s" % (ALARM_TEXT[level], alarm, msg)) else: retval.append("%s %s" % (ALARM_TEXT[level], alarm)) return "\n".join(retval) def asDict(self): retval = {} for alarm, (level, msg) in sorted(self._alarms.items()): retval[alarm] = (ALARM_TEXT[level], msg) return dict(alarms=retval) class PollerWatcher(object): def __init__(self, metrics): self.metrics = metrics def run(self): # Check if 'BuildMaster.pollDatabaseChanges()' and # 'BuildMaster.pollDatabaseBuildRequests()' are running fast enough h = self.metrics.getHandler(MetricTimeEvent) if not h: log.msg("Couldn't get MetricTimeEvent handler") MetricAlarmEvent.log('PollerWatcher', msg="Coudln't get MetricTimeEvent handler", level=ALARM_WARN) return for method in ('BuildMaster.pollDatabaseChanges()', 'BuildMaster.pollDatabaseBuildRequests()'): t = h.get(method) master = self.metrics.parent db_poll_interval = master.config.db['db_poll_interval'] if db_poll_interval: if t < 0.8 * db_poll_interval: level = ALARM_OK elif t < db_poll_interval: level = ALARM_WARN else: level = ALARM_CRIT MetricAlarmEvent.log(method, level=level) class AttachedSlavesWatcher(object): def __init__(self, metrics): self.metrics = metrics def run(self): # Check if 'BotMaster.attached_slaves' equals # 'AbstractBuildSlave.attached_slaves' h = self.metrics.getHandler(MetricCountEvent) if not h: log.msg("Couldn't get MetricCountEvent handler") MetricAlarmEvent.log('AttachedSlavesWatcher', msg="Coudln't get MetricCountEvent handler", level=ALARM_WARN) return botmaster_count = h.get('BotMaster.attached_slaves') buildslave_count = h.get('AbstractBuildSlave.attached_slaves') # We let these be off by one since they're counted at slightly # different times if abs(botmaster_count - buildslave_count) > 1: level = ALARM_WARN else: level = ALARM_OK MetricAlarmEvent.log('attached_slaves', msg='%s %s' % (botmaster_count, buildslave_count), level=level) def _get_rss(): if sys.platform == 'linux2': try: with open("/proc/%i/statm" % os.getpid()) as f: return int(f.read().split()[1]) except: return 0 return 0 def periodicCheck(_reactor=reactor): try: # Measure how much garbage we have garbage_count = len(gc.garbage) MetricCountEvent.log('gc.garbage', garbage_count, absolute=True) if garbage_count == 0: level = ALARM_OK else: level = ALARM_WARN MetricAlarmEvent.log('gc.garbage', level=level) if resource: r = resource.getrusage(resource.RUSAGE_SELF) attrs = ['ru_utime', 'ru_stime', 'ru_maxrss', 'ru_ixrss', 'ru_idrss', 'ru_isrss', 'ru_minflt', 'ru_majflt', 'ru_nswap', 'ru_inblock', 'ru_oublock', 'ru_msgsnd', 'ru_msgrcv', 'ru_nsignals', 'ru_nvcsw', 'ru_nivcsw'] for i,a in enumerate(attrs): # Linux versions prior to 2.6.32 didn't report this value, but we # can calculate it from /proc//statm v = r[i] if a == 'ru_maxrss' and v == 0: v = _get_rss() * resource.getpagesize() / 1024 MetricCountEvent.log('resource.%s' % a, v, absolute=True) MetricCountEvent.log('resource.pagesize', resource.getpagesize(), absolute=True) # Measure the reactor delay then = util.now(_reactor) dt = 0.1 def cb(): now = util.now(_reactor) delay = (now - then) - dt MetricTimeEvent.log("reactorDelay", delay) _reactor.callLater(dt, cb) except Exception: log.err(None, "while collecting VM metrics") class MetricLogObserver(config.ReconfigurableServiceMixin, service.MultiService): _reactor = reactor def __init__(self): service.MultiService.__init__(self) self.setName('metrics') self.enabled = False self.periodic_task = None self.periodic_interval = None self.log_task = None self.log_interval = None # Mapping of metric type to handlers for that type self.handlers = {} # Register our default handlers self.registerHandler(MetricCountEvent, MetricCountHandler(self)) self.registerHandler(MetricTimeEvent, MetricTimeHandler(self)) self.registerHandler(MetricAlarmEvent, MetricAlarmHandler(self)) # Make sure our changes poller is behaving self.getHandler(MetricTimeEvent).addWatcher(PollerWatcher(self)) self.getHandler(MetricCountEvent).addWatcher( AttachedSlavesWatcher(self)) def reconfigService(self, new_config): # first, enable or disable if new_config.metrics is None: self.disable() else: self.enable() metrics_config = new_config.metrics # Start up periodic logging log_interval = metrics_config.get('log_interval', 60) if log_interval != self.log_interval: if self.log_task: self.log_task.stop() self.log_task = None if log_interval: self.log_task = LoopingCall(self.report) self.log_task.clock = self._reactor self.log_task.start(log_interval) # same for the periodic task periodic_interval = metrics_config.get('periodic_interval', 10) if periodic_interval != self.periodic_interval: if self.periodic_task: self.periodic_task.stop() self.periodic_task = None if periodic_interval: self.periodic_task = LoopingCall(periodicCheck, self._reactor) self.periodic_task.clock = self._reactor self.periodic_task.start(periodic_interval) # upcall return config.ReconfigurableServiceMixin.reconfigService(self, new_config) def stopService(self): self.disable() service.MultiService.stopService(self) def enable(self): if self.enabled: return log.addObserver(self.emit) self.enabled = True def disable(self): if not self.enabled: return if self.periodic_task: self.periodic_task.stop() self.periodic_task = None if self.log_task: self.log_task.stop() self.log_task = None log.removeObserver(self.emit) self.enabled = False def registerHandler(self, interface, handler): old = self.getHandler(interface) self.handlers[interface] = handler return old def getHandler(self, interface): return self.handlers.get(interface) def emit(self, eventDict): # Ignore non-statistic events metric = eventDict.get('metric') if not metric or not isinstance(metric, MetricEvent): return if metric.__class__ not in self.handlers: return h = self.handlers[metric.__class__] h.handle(eventDict, metric) for w in h.watchers: w.run() def asDict(self): retval = {} for interface, handler in self.handlers.iteritems(): retval.update(handler.asDict()) return retval def report(self): try: for interface, handler in self.handlers.iteritems(): report = handler.report() if not report: continue for line in report.split("\n"): log.msg(line) except: log.err(None, "generating metric report") buildbot-0.8.8/buildbot/process/mtrlogobserver.py000066400000000000000000000431331222546025000222450ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sys import re from twisted.python import log from twisted.internet import defer from twisted.enterprise import adbapi from buildbot.process.buildstep import LogLineObserver from buildbot.steps.shell import Test class EqConnectionPool(adbapi.ConnectionPool): """This class works the same way as twisted.enterprise.adbapi.ConnectionPool. But it adds the ability to compare connection pools for equality (by comparing the arguments passed to the constructor). This is useful when passing the ConnectionPool to a BuildStep, as otherwise Buildbot will consider the buildstep (and hence the containing buildfactory) to have changed every time the configuration is reloaded. It also sets some defaults differently from adbapi.ConnectionPool that are more suitable for use in MTR. """ def __init__(self, *args, **kwargs): self._eqKey = (args, kwargs) return adbapi.ConnectionPool.__init__(self, cp_reconnect=True, cp_min=1, cp_max=3, *args, **kwargs) def __eq__(self, other): if isinstance(other, EqConnectionPool): return self._eqKey == other._eqKey else: return False def __ne__(self, other): return not self.__eq__(other) class MtrTestFailData: def __init__(self, testname, variant, result, info, text, callback): self.testname = testname self.variant = variant self.result = result self.info = info self.text = text self.callback = callback def add(self, line): self.text+= line def fireCallback(self): return self.callback(self.testname, self.variant, self.result, self.info, self.text) class MtrLogObserver(LogLineObserver): """ Class implementing a log observer (can be passed to BuildStep.addLogObserver(). It parses the output of mysql-test-run.pl as used in MySQL, MariaDB, Drizzle, etc. It counts number of tests run and uses it to provide more accurate completion estimates. It parses out test failures from the output and summarises the results on the Waterfall page. It also passes the information to methods that can be overridden in a subclass to do further processing on the information.""" _line_re = re.compile(r"^([-._0-9a-zA-z]+)( '[-_ a-zA-Z]+')?\s+(w[0-9]+\s+)?\[ (fail|pass) \]\s*(.*)$") _line_re2 = re.compile(r"^[-._0-9a-zA-z]+( '[-_ a-zA-Z]+')?\s+(w[0-9]+\s+)?\[ [-a-z]+ \]") _line_re3 = re.compile(r"^\*\*\*Warnings generated in error logs during shutdown after running tests: (.*)") _line_re4 = re.compile(r"^The servers were restarted [0-9]+ times$") _line_re5 = re.compile(r"^Only\s+[0-9]+\s+of\s+[0-9]+\s+completed.$") def __init__(self, textLimit=5, testNameLimit=16, testType=None): self.textLimit = textLimit self.testNameLimit = testNameLimit self.testType = testType self.numTests = 0 self.testFail = None self.failList = [] self.warnList = [] LogLineObserver.__init__(self) def setLog(self, loog): LogLineObserver.setLog(self, loog) d= loog.waitUntilFinished() d.addCallback(lambda l: self.closeTestFail()) def outLineReceived(self, line): stripLine = line.strip("\r\n") m = self._line_re.search(stripLine) if m: testname, variant, worker, result, info = m.groups() self.closeTestFail() self.numTests += 1 self.step.setProgress('tests', self.numTests) if result == "fail": if variant == None: variant = "" else: variant = variant[2:-1] self.openTestFail(testname, variant, result, info, stripLine + "\n") else: m = self._line_re3.search(stripLine) if m: stuff = m.group(1) self.closeTestFail() testList = stuff.split(" ") self.doCollectWarningTests(testList) elif (self._line_re2.search(stripLine) or self._line_re4.search(stripLine) or self._line_re5.search(stripLine) or stripLine == "Test suite timeout! Terminating..." or stripLine.startswith("mysql-test-run: *** ERROR: Not all tests completed") or (stripLine.startswith("------------------------------------------------------------") and self.testFail != None)): self.closeTestFail() else: self.addTestFailOutput(stripLine + "\n") def openTestFail(self, testname, variant, result, info, line): self.testFail = MtrTestFailData(testname, variant, result, info, line, self.doCollectTestFail) def addTestFailOutput(self, line): if self.testFail != None: self.testFail.add(line) def closeTestFail(self): if self.testFail != None: self.testFail.fireCallback() self.testFail = None def addToText(self, src, dst): lastOne = None count = 0 for t in src: if t != lastOne: dst.append(t) count += 1 if count >= self.textLimit: break def makeText(self, done): if done: text = ["test"] else: text = ["testing"] if self.testType: text.append(self.testType) fails = self.failList[:] fails.sort() self.addToText(fails, text) warns = self.warnList[:] warns.sort() self.addToText(warns, text) return text # Update waterfall status. def updateText(self): self.step.step_status.setText(self.makeText(False)) strip_re = re.compile(r"^[a-z]+\.") def displayTestName(self, testname): displayTestName = self.strip_re.sub("", testname) if len(displayTestName) > self.testNameLimit: displayTestName = displayTestName[:(self.testNameLimit-2)] + "..." return displayTestName def doCollectTestFail(self, testname, variant, result, info, text): self.failList.append("F:" + self.displayTestName(testname)) self.updateText() self.collectTestFail(testname, variant, result, info, text) def doCollectWarningTests(self, testList): for t in testList: self.warnList.append("W:" + self.displayTestName(t)) self.updateText() self.collectWarningTests(testList) # These two methods are overridden to actually do something with the data. def collectTestFail(self, testname, variant, result, info, text): pass def collectWarningTests(self, testList): pass class MTR(Test): """ Build step that runs mysql-test-run.pl, as used in MySQL, Drizzle, MariaDB, etc. It uses class MtrLogObserver to parse test results out from the output of mysql-test-run.pl, providing better completion time estimates and summarising test failures on the waterfall page. It also provides access to mysqld server error logs from the test run to help debugging any problems. Optionally, it can insert into a database data about the test run, including details of any test failures. Parameters: textLimit Maximum number of test failures to show on the waterfall page (to not flood the page in case of a large number of test failures. Defaults to 5. testNameLimit Maximum length of test names to show unabbreviated in the waterfall page, to avoid excessive column width. Defaults to 16. parallel Value of --parallel option used for mysql-test-run.pl (number of processes used to run the test suite in parallel). Defaults to 4. This is used to determine the number of server error log files to download from the slave. Specifying a too high value does not hurt (as nonexisting error logs will be ignored), however if using --parallel value greater than the default it needs to be specified, or some server error logs will be missing. dbpool An instance of twisted.enterprise.adbapi.ConnectionPool, or None. Defaults to None. If specified, results are inserted into the database using the ConnectionPool. The class process.mtrlogobserver.EqConnectionPool subclass of ConnectionPool can be useful to pass as value for dbpool, to avoid having config reloads think the Buildstep is changed just because it gets a new ConnectionPool instance (even though connection parameters are unchanged). autoCreateTables Boolean, defaults to False. If True (and dbpool is specified), the necessary database tables will be created automatically if they do not exist already. Alternatively, the tables can be created manually from the SQL statements found in the mtrlogobserver.py source file. test_type test_info Two descriptive strings that will be inserted in the database tables if dbpool is specified. The test_type string, if specified, will also appear on the waterfall page.""" renderables = [ 'mtr_subdir' ] def __init__(self, dbpool=None, test_type=None, test_info="", description=None, descriptionDone=None, autoCreateTables=False, textLimit=5, testNameLimit=16, parallel=4, logfiles = {}, lazylogfiles = True, warningPattern="MTR's internal check of the test case '.*' failed", mtr_subdir="mysql-test", **kwargs): if description is None: description = ["testing"] if test_type: description.append(test_type) if descriptionDone is None: descriptionDone = ["test"] if test_type: descriptionDone.append(test_type) Test.__init__(self, logfiles=logfiles, lazylogfiles=lazylogfiles, description=description, descriptionDone=descriptionDone, warningPattern=warningPattern, **kwargs) self.dbpool = dbpool self.test_type = test_type self.test_info = test_info self.autoCreateTables = autoCreateTables self.textLimit = textLimit self.testNameLimit = testNameLimit self.parallel = parallel self.mtr_subdir = mtr_subdir self.progressMetrics += ('tests',) def start(self): # Add mysql server logfiles. for mtr in range(0, self.parallel+1): for mysqld in range(1, 4+1): if mtr == 0: logname = "mysqld.%d.err" % mysqld filename = "var/log/mysqld.%d.err" % mysqld else: logname = "mysqld.%d.err.%d" % (mysqld, mtr) filename = "var/%d/log/mysqld.%d.err" % (mtr, mysqld) self.addLogFile(logname, self.mtr_subdir + "/" + filename) self.myMtr = self.MyMtrLogObserver(textLimit=self.textLimit, testNameLimit=self.testNameLimit, testType=self.test_type) self.addLogObserver("stdio", self.myMtr) # Insert a row for this test run into the database and set up # build properties, then start the command proper. d = self.registerInDB() d.addCallback(self.afterRegisterInDB) d.addErrback(self.failed) def getText(self, command, results): return self.myMtr.makeText(True) def runInteractionWithRetry(self, actionFn, *args, **kw): """ Run a database transaction with dbpool.runInteraction, but retry the transaction in case of a temporary error (like connection lost). This is needed to be robust against things like database connection idle timeouts. The passed callable that implements the transaction must be retryable, ie. it must not have any destructive side effects in the case where an exception is thrown and/or rollback occurs that would prevent it from functioning correctly when called again.""" def runWithRetry(txn, *args, **kw): retryCount = 0 while(True): try: return actionFn(txn, *args, **kw) except txn.OperationalError: retryCount += 1 if retryCount >= 5: raise excType, excValue, excTraceback = sys.exc_info() log.msg("Database transaction failed (caught exception %s(%s)), retrying ..." % (excType, excValue)) txn.close() txn.reconnect() txn.reopen() return self.dbpool.runInteraction(runWithRetry, *args, **kw) def runQueryWithRetry(self, *args, **kw): """ Run a database query, like with dbpool.runQuery, but retry the query in case of a temporary error (like connection lost). This is needed to be robust against things like database connection idle timeouts.""" def runQuery(txn, *args, **kw): txn.execute(*args, **kw) return txn.fetchall() return self.runInteractionWithRetry(runQuery, *args, **kw) def registerInDB(self): if self.dbpool: return self.runInteractionWithRetry(self.doRegisterInDB) else: return defer.succeed(0) # The real database work is done in a thread in a synchronous way. def doRegisterInDB(self, txn): # Auto create tables. # This is off by default, as it gives warnings in log file # about tables already existing (and I did not find the issue # important enough to find a better fix). if self.autoCreateTables: txn.execute(""" CREATE TABLE IF NOT EXISTS test_run( id INT PRIMARY KEY AUTO_INCREMENT, branch VARCHAR(100), revision VARCHAR(32) NOT NULL, platform VARCHAR(100) NOT NULL, dt TIMESTAMP NOT NULL, bbnum INT NOT NULL, typ VARCHAR(32) NOT NULL, info VARCHAR(255), KEY (branch, revision), KEY (dt), KEY (platform, bbnum) ) ENGINE=innodb """) txn.execute(""" CREATE TABLE IF NOT EXISTS test_failure( test_run_id INT NOT NULL, test_name VARCHAR(100) NOT NULL, test_variant VARCHAR(16) NOT NULL, info_text VARCHAR(255), failure_text TEXT, PRIMARY KEY (test_run_id, test_name, test_variant) ) ENGINE=innodb """) txn.execute(""" CREATE TABLE IF NOT EXISTS test_warnings( test_run_id INT NOT NULL, list_id INT NOT NULL, list_idx INT NOT NULL, test_name VARCHAR(100) NOT NULL, PRIMARY KEY (test_run_id, list_id, list_idx) ) ENGINE=innodb """) revision = self.getProperty("got_revision") if revision is None: revision = self.getProperty("revision") typ = "mtr" if self.test_type: typ = self.test_type txn.execute(""" INSERT INTO test_run(branch, revision, platform, dt, bbnum, typ, info) VALUES (%s, %s, %s, CURRENT_TIMESTAMP(), %s, %s, %s) """, (self.getProperty("branch"), revision, self.getProperty("buildername"), self.getProperty("buildnumber"), typ, self.test_info)) return txn.lastrowid def afterRegisterInDB(self, insert_id): self.setProperty("mtr_id", insert_id) self.setProperty("mtr_warn_id", 0) Test.start(self) def reportError(self, err): log.msg("Error in async insert into database: %s" % err) class MyMtrLogObserver(MtrLogObserver): def collectTestFail(self, testname, variant, result, info, text): # Insert asynchronously into database. dbpool = self.step.dbpool run_id = self.step.getProperty("mtr_id") if dbpool == None: return defer.succeed(None) if variant == None: variant = "" d = self.step.runQueryWithRetry(""" INSERT INTO test_failure(test_run_id, test_name, test_variant, info_text, failure_text) VALUES (%s, %s, %s, %s, %s) """, (run_id, testname, variant, info, text)) d.addErrback(self.step.reportError) return d def collectWarningTests(self, testList): # Insert asynchronously into database. dbpool = self.step.dbpool if dbpool == None: return defer.succeed(None) run_id = self.step.getProperty("mtr_id") warn_id = self.step.getProperty("mtr_warn_id") self.step.setProperty("mtr_warn_id", warn_id + 1) q = ("INSERT INTO test_warnings(test_run_id, list_id, list_idx, test_name) " + "VALUES " + ", ".join(map(lambda x: "(%s, %s, %s, %s)", testList))) v = [] idx = 0 for t in testList: v.extend([run_id, warn_id, idx, t]) idx = idx + 1 d = self.step.runQueryWithRetry(q, tuple(v)) d.addErrback(self.step.reportError) return d buildbot-0.8.8/buildbot/process/properties.py000066400000000000000000000543271222546025000213740ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import collections import re import warnings import weakref from buildbot import config, util from buildbot.util import json from buildbot.interfaces import IRenderable, IProperties from twisted.internet import defer from twisted.python.components import registerAdapter from zope.interface import implements class Properties(util.ComparableMixin): """ I represent a set of properties that can be interpolated into various strings in buildsteps. @ivar properties: dictionary mapping property values to tuples (value, source), where source is a string identifing the source of the property. Objects of this class can be read like a dictionary -- in this case, only the property value is returned. As a special case, a property value of None is returned as an empty string when used as a mapping. """ compare_attrs = ('properties',) implements(IProperties) def __init__(self, **kwargs): """ @param kwargs: initial property values (for testing) """ self.properties = {} # Track keys which are 'runtime', and should not be # persisted if a build is rebuilt self.runtime = set() self.build = None # will be set by the Build when starting if kwargs: self.update(kwargs, "TEST") @classmethod def fromDict(cls, propDict): properties = cls() for name, (value, source) in propDict.iteritems(): properties.setProperty(name, value, source) return properties def __getstate__(self): d = self.__dict__.copy() d['build'] = None return d def __setstate__(self, d): self.__dict__ = d if not hasattr(self, 'runtime'): self.runtime = set() def __contains__(self, name): return name in self.properties def __getitem__(self, name): """Just get the value for this property.""" rv = self.properties[name][0] return rv def __nonzero__(self): return not not self.properties def getPropertySource(self, name): return self.properties[name][1] def asList(self): """Return the properties as a sorted list of (name, value, source)""" l = [ (k, v[0], v[1]) for k,v in self.properties.iteritems() ] l.sort() return l def asDict(self): """Return the properties as a simple key:value dictionary""" return dict(self.properties) def __repr__(self): return ('Properties(**' + repr(dict((k,v[0]) for k,v in self.properties.iteritems())) + ')') def update(self, dict, source, runtime=False): """Update this object from a dictionary, with an explicit source specified.""" for k, v in dict.items(): self.setProperty(k, v, source, runtime=runtime) def updateFromProperties(self, other): """Update this object based on another object; the other object's """ self.properties.update(other.properties) self.runtime.update(other.runtime) def updateFromPropertiesNoRuntime(self, other): """Update this object based on another object, but don't include properties that were marked as runtime.""" for k,v in other.properties.iteritems(): if k not in other.runtime: self.properties[k] = v # IProperties methods def getProperty(self, name, default=None): return self.properties.get(name, (default,))[0] def hasProperty(self, name): return self.properties.has_key(name) has_key = hasProperty def setProperty(self, name, value, source, runtime=False): try: json.dumps(value) except TypeError: warnings.warn( "Non jsonable properties are not explicitly supported and" + "will be explicitly disallowed in a future version.", DeprecationWarning, stacklevel=2) self.properties[name] = (value, source) if runtime: self.runtime.add(name) def getProperties(self): return self def getBuild(self): return self.build def render(self, value): renderable = IRenderable(value) return defer.maybeDeferred(renderable.getRenderingFor, self) class PropertiesMixin: """ A mixin to add L{IProperties} methods to a class which does not implement the interface, but which can be coerced to the interface via an adapter. This is useful because L{IProperties} methods are often called on L{Build} and L{BuildStatus} objects without first coercing them. @ivar set_runtime_properties: the default value for the C{runtime} parameter of L{setProperty}. """ set_runtime_properties = False def getProperty(self, propname, default=None): props = IProperties(self) return props.getProperty(propname, default) def hasProperty(self, propname): props = IProperties(self) return props.hasProperty(propname) has_key = hasProperty def setProperty(self, propname, value, source='Unknown', runtime=None): # source is not optional in IProperties, but is optional here to avoid # breaking user-supplied code that fails to specify a source props = IProperties(self) if runtime is None: runtime = self.set_runtime_properties props.setProperty(propname, value, source, runtime=runtime) def getProperties(self): return IProperties(self) def render(self, value): props = IProperties(self) return props.render(value) class _PropertyMap(object): """ Privately-used mapping object to implement WithProperties' substitutions, including the rendering of None as ''. """ colon_minus_re = re.compile(r"(.*):-(.*)") colon_tilde_re = re.compile(r"(.*):~(.*)") colon_plus_re = re.compile(r"(.*):\+(.*)") def __init__(self, properties): # use weakref here to avoid a reference loop self.properties = weakref.ref(properties) self.temp_vals = {} def __getitem__(self, key): properties = self.properties() assert properties is not None def colon_minus(mo): # %(prop:-repl)s # if prop exists, use it; otherwise, use repl prop, repl = mo.group(1,2) if prop in self.temp_vals: return self.temp_vals[prop] elif properties.has_key(prop): return properties[prop] else: return repl def colon_tilde(mo): # %(prop:~repl)s # if prop exists and is true (nonempty), use it; otherwise, use repl prop, repl = mo.group(1,2) if prop in self.temp_vals and self.temp_vals[prop]: return self.temp_vals[prop] elif properties.has_key(prop) and properties[prop]: return properties[prop] else: return repl def colon_plus(mo): # %(prop:+repl)s # if prop exists, use repl; otherwise, an empty string prop, repl = mo.group(1,2) if properties.has_key(prop) or prop in self.temp_vals: return repl else: return '' for regexp, fn in [ ( self.colon_minus_re, colon_minus ), ( self.colon_tilde_re, colon_tilde ), ( self.colon_plus_re, colon_plus ), ]: mo = regexp.match(key) if mo: rv = fn(mo) break else: # If explicitly passed as a kwarg, use that, # otherwise, use the property value. if key in self.temp_vals: rv = self.temp_vals[key] else: rv = properties[key] # translate 'None' to an empty string if rv is None: rv = '' return rv def add_temporary_value(self, key, val): 'Add a temporary value (to support keyword arguments to WithProperties)' self.temp_vals[key] = val class WithProperties(util.ComparableMixin): """ This is a marker class, used fairly widely to indicate that we want to interpolate build properties. """ implements(IRenderable) compare_attrs = ('fmtstring', 'args', 'lambda_subs') def __init__(self, fmtstring, *args, **lambda_subs): self.fmtstring = fmtstring self.args = args if not self.args: self.lambda_subs = lambda_subs for key, val in self.lambda_subs.iteritems(): if not callable(val): raise ValueError('Value for lambda substitution "%s" must be callable.' % key) elif lambda_subs: raise ValueError('WithProperties takes either positional or keyword substitutions, not both.') def getRenderingFor(self, build): pmap = _PropertyMap(build.getProperties()) if self.args: strings = [] for name in self.args: strings.append(pmap[name]) s = self.fmtstring % tuple(strings) else: for k,v in self.lambda_subs.iteritems(): pmap.add_temporary_value(k, v(build)) s = self.fmtstring % pmap return s _notHasKey = object() ## Marker object for _Lookup(..., hasKey=...) default class _Lookup(util.ComparableMixin, object): implements(IRenderable) compare_attrs = ('value', 'index', 'default', 'defaultWhenFalse', 'hasKey', 'elideNoneAs') def __init__(self, value, index, default=None, defaultWhenFalse=True, hasKey=_notHasKey, elideNoneAs=None): self.value = value self.index = index self.default = default self.defaultWhenFalse = defaultWhenFalse self.hasKey = hasKey self.elideNoneAs = elideNoneAs def __repr__(self): return '_Lookup(%r, %r%s%s%s%s)' % ( self.value, self.index, ', default=%r' % (self.default,) if self.default is not None else '', ', defaultWhenFalse=False' if not self.defaultWhenFalse else '', ', hasKey=%r' % (self.hasKey,) if self.hasKey is not _notHasKey else '', ', elideNoneAs=%r'% (self.elideNoneAs,) if self.elideNoneAs is not None else '') @defer.inlineCallbacks def getRenderingFor(self, build): value = build.render(self.value) index = build.render(self.index) value, index = yield defer.gatherResults([value, index]) if not value.has_key(index): rv = yield build.render(self.default) else: if self.defaultWhenFalse: rv = yield build.render(value[index]) if not rv: rv = yield build.render(self.default) elif self.hasKey is not _notHasKey: rv = yield build.render(self.hasKey) elif self.hasKey is not _notHasKey: rv = yield build.render(self.hasKey) else: rv = yield build.render(value[index]) if rv is None: rv = yield build.render(self.elideNoneAs) defer.returnValue(rv) def _getInterpolationList(fmtstring): # TODO: Verify that no positial substitutions are requested dd = collections.defaultdict(str) fmtstring % dd return dd.keys() class _PropertyDict(object): implements(IRenderable) def getRenderingFor(self, build): return build.getProperties() _thePropertyDict = _PropertyDict() class _SourceStampDict(util.ComparableMixin, object): implements(IRenderable) compare_attrs = ('codebase',) def __init__(self, codebase): self.codebase = codebase def getRenderingFor(self, build): ss = build.getBuild().getSourceStamp(self.codebase) if ss: return ss.asDict() else: return {} class _Lazy(util.ComparableMixin, object): implements(IRenderable) compare_attrs = ('value',) def __init__(self, value): self.value = value def getRenderingFor(self, build): return self.value def __repr__(self): return '_Lazy(%r)' % self.value class Interpolate(util.ComparableMixin, object): """ This is a marker class, used fairly widely to indicate that we want to interpolate build properties. """ implements(IRenderable) compare_attrs = ('fmtstring', 'args', 'kwargs') identifier_re = re.compile('^[\w-]*$') def __init__(self, fmtstring, *args, **kwargs): self.fmtstring = fmtstring self.args = args self.kwargs = kwargs if self.args and self.kwargs: config.error("Interpolate takes either positional or keyword " "substitutions, not both.") if not self.args: self.interpolations = {} self._parse(fmtstring) # TODO: add case below for when there's no args or kwargs.. def __repr__(self): if self.args: return 'Interpolate(%r, *%r)' % (self.fmtstring, self.args) elif self.kwargs: return 'Interpolate(%r, **%r)' % (self.fmtstring, self.kwargs) else: return 'Interpolate(%r)' % (self.fmtstring,) @staticmethod def _parse_prop(arg): try: prop, repl = arg.split(":", 1) except ValueError: prop, repl = arg, None if not Interpolate.identifier_re.match(prop): config.error("Property name must be alphanumeric for prop Interpolation '%s'" % arg) prop = repl = None return _thePropertyDict, prop, repl @staticmethod def _parse_src(arg): ## TODO: Handle changes try: codebase, attr, repl = arg.split(":", 2) except ValueError: try: codebase, attr = arg.split(":",1) repl = None except ValueError: config.error("Must specify both codebase and attribute for src Interpolation '%s'" % arg) return {}, None, None if not Interpolate.identifier_re.match(codebase): config.error("Codebase must be alphanumeric for src Interpolation '%s'" % arg) codebase = attr = repl = None if not Interpolate.identifier_re.match(attr): config.error("Attribute must be alphanumeric for src Interpolation '%s'" % arg) codebase = attr = repl = None return _SourceStampDict(codebase), attr, repl def _parse_kw(self, arg): try: kw, repl = arg.split(":", 1) except ValueError: kw, repl = arg, None if not Interpolate.identifier_re.match(kw): config.error("Keyword must be alphanumeric for kw Interpolation '%s'" % arg) kw = repl = None return _Lazy(self.kwargs), kw, repl def _parseSubstitution(self, fmt): try: key, arg = fmt.split(":", 1) except ValueError: config.error("invalid Interpolate substitution without selector '%s'" % fmt) return fn = getattr(self, "_parse_" + key, None) if not fn: config.error("invalid Interpolate selector '%s'" % key) return None else: return fn(arg) @staticmethod def _splitBalancedParen(delim, arg): parenCount = 0 for i in range(0, len(arg)): if arg[i] == "(": parenCount += 1 if arg[i] == ")": parenCount -= 1 if parenCount < 0: raise ValueError if parenCount == 0 and arg[i] == delim: return arg[0:i], arg[i+1:] return arg def _parseColon_minus(self, d, kw, repl): return _Lookup(d, kw, default=Interpolate(repl, **self.kwargs), defaultWhenFalse=False, elideNoneAs='') def _parseColon_tilde(self, d, kw, repl): return _Lookup(d, kw, default=Interpolate(repl, **self.kwargs), defaultWhenFalse=True, elideNoneAs='') def _parseColon_plus(self, d, kw, repl): return _Lookup(d, kw, hasKey=Interpolate(repl, **self.kwargs), default='', defaultWhenFalse=False, elideNoneAs='') def _parseColon_ternary(self, d, kw, repl, defaultWhenFalse=False): delim = repl[0] if delim == '(': config.error("invalid Interpolate ternary delimiter '('") return None try: truePart, falsePart = self._splitBalancedParen(delim, repl[1:]) except ValueError: config.error("invalid Interpolate ternary expression '%s' with delimiter '%s'" % (repl[1:], repl[0])) return None return _Lookup(d, kw, hasKey=Interpolate(truePart, **self.kwargs), default=Interpolate(falsePart, **self.kwargs), defaultWhenFalse=defaultWhenFalse, elideNoneAs='') def _parseColon_ternary_hash(self, d, kw, repl): return self._parseColon_ternary(d, kw, repl, defaultWhenFalse=True) def _parse(self, fmtstring): keys = _getInterpolationList(fmtstring) for key in keys: if not self.interpolations.has_key(key): d, kw, repl = self._parseSubstitution(key) if repl is None: repl = '-' for pattern, fn in [ ( "-", self._parseColon_minus ), ( "~", self._parseColon_tilde ), ( "+", self._parseColon_plus ), ( "?", self._parseColon_ternary ), ( "#?", self._parseColon_ternary_hash ) ]: junk, matches, tail = repl.partition(pattern) if not junk and matches: self.interpolations[key] = fn(d, kw, tail) break if not self.interpolations.has_key(key): config.error("invalid Interpolate default type '%s'" % repl[0]) def getRenderingFor(self, props): props = props.getProperties() if self.args: d = props.render(self.args) d.addCallback(lambda args: self.fmtstring % tuple(args)) return d else: d = props.render(self.interpolations) d.addCallback(lambda res: self.fmtstring % res) return d class Property(util.ComparableMixin): """ An instance of this class renders a property of a build. """ implements(IRenderable) compare_attrs = ('key','default', 'defaultWhenFalse') def __init__(self, key, default=None, defaultWhenFalse=True): """ @param key: Property to render. @param default: Value to use if property isn't set. @param defaultWhenFalse: When true (default), use default value if property evaluates to False. Otherwise, use default value only when property isn't set. """ self.key = key self.default = default self.defaultWhenFalse = defaultWhenFalse def getRenderingFor(self, props): if self.defaultWhenFalse: d = props.render(props.getProperty(self.key)) @d.addCallback def checkDefault(rv): if rv: return rv else: return props.render(self.default) return d else: if props.hasProperty(self.key): return props.render(props.getProperty(self.key)) else: return props.render(self.default) class _Renderer(util.ComparableMixin, object): implements(IRenderable) compare_attrs = ('getRenderingFor',) def __init__(self, fn): self.getRenderingFor = fn def __repr__(self): return 'renderer(%r)' % (self.getRenderingFor,) def renderer(fn): return _Renderer(fn) class _DefaultRenderer(object): """ Default IRenderable adaptor. Calls .getRenderingFor if availble, otherwise returns argument unchanged. """ implements(IRenderable) def __init__(self, value): try: self.renderer = value.getRenderingFor except AttributeError: self.renderer = lambda _: value def getRenderingFor(self, build): return self.renderer(build) registerAdapter(_DefaultRenderer, object, IRenderable) class _ListRenderer(object): """ List IRenderable adaptor. Maps Build.render over the list. """ implements(IRenderable) def __init__(self, value): self.value = value def getRenderingFor(self, build): return defer.gatherResults([ build.render(e) for e in self.value ]) registerAdapter(_ListRenderer, list, IRenderable) class _TupleRenderer(object): """ Tuple IRenderable adaptor. Maps Build.render over the tuple. """ implements(IRenderable) def __init__(self, value): self.value = value def getRenderingFor(self, build): d = defer.gatherResults([ build.render(e) for e in self.value ]) d.addCallback(tuple) return d registerAdapter(_TupleRenderer, tuple, IRenderable) class _DictRenderer(object): """ Dict IRenderable adaptor. Maps Build.render over the keya and values in the dict. """ implements(IRenderable) def __init__(self, value): self.value = _ListRenderer([ _TupleRenderer((k,v)) for k,v in value.iteritems() ]) def getRenderingFor(self, build): d = self.value.getRenderingFor(build) d.addCallback(dict) return d registerAdapter(_DictRenderer, dict, IRenderable) buildbot-0.8.8/buildbot/process/slavebuilder.py000066400000000000000000000242771222546025000216620ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.spread import pb from twisted.internet import defer from twisted.python import log (ATTACHING, # slave attached, still checking hostinfo/etc IDLE, # idle, available for use PINGING, # build about to start, making sure it is still alive BUILDING, # build is running LATENT, # latent slave is not substantiated; similar to idle SUBSTANTIATING, ) = range(6) class AbstractSlaveBuilder(pb.Referenceable): """I am the master-side representative for one of the L{buildbot.slave.bot.SlaveBuilder} objects that lives in a remote buildbot. When a remote builder connects, I query it for command versions and then make it available to any Builds that are ready to run. """ def __init__(self): self.ping_watchers = [] self.state = None # set in subclass self.remote = None self.slave = None self.builder_name = None self.locks = None def __repr__(self): r = ["<", self.__class__.__name__] if self.builder_name: r.extend([" builder=", repr(self.builder_name)]) if self.slave: r.extend([" slave=", repr(self.slave.slavename)]) r.append(">") return ''.join(r) def setBuilder(self, b): self.builder = b self.builder_name = b.name def getSlaveCommandVersion(self, command, oldversion=None): if self.remoteCommands is None: # the slave is 0.5.0 or earlier return oldversion return self.remoteCommands.get(command) def isAvailable(self): # if this SlaveBuilder is busy, then it's definitely not available if self.isBusy(): return False # otherwise, check in with the BuildSlave if self.slave: return self.slave.canStartBuild() # no slave? not very available. return False def isBusy(self): return self.state not in (IDLE, LATENT) def buildStarted(self): self.state = BUILDING def buildFinished(self): self.state = IDLE if self.slave: self.slave.buildFinished(self) def attached(self, slave, remote, commands): """ @type slave: L{buildbot.buildslave.BuildSlave} @param slave: the BuildSlave that represents the buildslave as a whole @type remote: L{twisted.spread.pb.RemoteReference} @param remote: a reference to the L{buildbot.slave.bot.SlaveBuilder} @type commands: dict: string -> string, or None @param commands: provides the slave's version of each RemoteCommand """ self.state = ATTACHING self.remote = remote self.remoteCommands = commands # maps command name to version if self.slave is None: self.slave = slave self.slave.addSlaveBuilder(self) else: assert self.slave == slave log.msg("Buildslave %s attached to %s" % (slave.slavename, self.builder_name)) d = defer.succeed(None) d.addCallback(lambda _: self.remote.callRemote("setMaster", self)) d.addCallback(lambda _: self.remote.callRemote("print", "attached")) def setIdle(res): self.state = IDLE return self d.addCallback(setIdle) return d def prepare(self, builder_status, build): if not self.slave.acquireLocks(): return defer.succeed(False) return defer.succeed(True) def ping(self, status=None): """Ping the slave to make sure it is still there. Returns a Deferred that fires with True if it is. @param status: if you point this at a BuilderStatus, a 'pinging' event will be pushed. """ oldstate = self.state self.state = PINGING newping = not self.ping_watchers d = defer.Deferred() self.ping_watchers.append(d) if newping: if status: event = status.addEvent(["pinging"]) d2 = defer.Deferred() d2.addCallback(self._pong_status, event) self.ping_watchers.insert(0, d2) # I think it will make the tests run smoother if the status # is updated before the ping completes Ping().ping(self.remote).addCallback(self._pong) def reset_state(res): if self.state == PINGING: self.state = oldstate return res d.addCallback(reset_state) return d def _pong(self, res): watchers, self.ping_watchers = self.ping_watchers, [] for d in watchers: d.callback(res) def _pong_status(self, res, event): if res: event.text = ["ping", "success"] else: event.text = ["ping", "failed"] event.finish() def detached(self): log.msg("Buildslave %s detached from %s" % (self.slave.slavename, self.builder_name)) if self.slave: self.slave.removeSlaveBuilder(self) self.slave = None self.remote = None self.remoteCommands = None class Ping: running = False def ping(self, remote): assert not self.running if not remote: # clearly the ping must fail return defer.succeed(False) self.running = True log.msg("sending ping") self.d = defer.Deferred() # TODO: add a distinct 'ping' command on the slave.. using 'print' # for this purpose is kind of silly. remote.callRemote("print", "ping").addCallbacks(self._pong, self._ping_failed, errbackArgs=(remote,)) return self.d def _pong(self, res): log.msg("ping finished: success") self.d.callback(True) def _ping_failed(self, res, remote): log.msg("ping finished: failure") # the slave has some sort of internal error, disconnect them. If we # don't, we'll requeue a build and ping them again right away, # creating a nasty loop. remote.broker.transport.loseConnection() # TODO: except, if they actually did manage to get this far, they'll # probably reconnect right away, and we'll do this game again. Maybe # it would be better to leave them in the PINGING state. self.d.callback(False) class SlaveBuilder(AbstractSlaveBuilder): def __init__(self): AbstractSlaveBuilder.__init__(self) self.state = ATTACHING def detached(self): AbstractSlaveBuilder.detached(self) if self.slave: self.slave.removeSlaveBuilder(self) self.slave = None self.state = ATTACHING class LatentSlaveBuilder(AbstractSlaveBuilder): def __init__(self, slave, builder): AbstractSlaveBuilder.__init__(self) self.slave = slave self.state = LATENT self.setBuilder(builder) self.slave.addSlaveBuilder(self) log.msg("Latent buildslave %s attached to %s" % (slave.slavename, self.builder_name)) def prepare(self, builder_status, build): # If we can't lock, then don't bother trying to substantiate if not self.slave or not self.slave.acquireLocks(): return defer.succeed(False) log.msg("substantiating slave %s" % (self,)) d = self.substantiate(build) def substantiation_failed(f): builder_status.addPointEvent(['removing', 'latent', self.slave.slavename]) self.slave.disconnect() # TODO: should failover to a new Build return f def substantiation_cancelled(res): # if res is False, latent slave cancelled subtantiation if not res: self.state = LATENT return res d.addCallback(substantiation_cancelled) d.addErrback(substantiation_failed) return d def substantiate(self, build): self.state = SUBSTANTIATING d = self.slave.substantiate(self, build) if not self.slave.substantiated: event = self.builder.builder_status.addEvent( ["substantiating"]) def substantiated(res): msg = ["substantiate", "success"] if isinstance(res, basestring): msg.append(res) elif isinstance(res, (tuple, list)): msg.extend(res) event.text = msg event.finish() return res def substantiation_failed(res): event.text = ["substantiate", "failed"] # TODO add log of traceback to event event.finish() return res d.addCallbacks(substantiated, substantiation_failed) return d def detached(self): AbstractSlaveBuilder.detached(self) self.state = LATENT def buildStarted(self): AbstractSlaveBuilder.buildStarted(self) self.slave.buildStarted(self) def _attachFailure(self, why, where): self.state = LATENT return AbstractSlaveBuilder._attachFailure(self, why, where) def ping(self, status=None): if not self.slave.substantiated: if status: status.addEvent(["ping", "latent"]).finish() return defer.succeed(True) return AbstractSlaveBuilder.ping(self, status) buildbot-0.8.8/buildbot/process/subunitlogobserver.py000066400000000000000000000103101222546025000231230ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from unittest import TestResult from StringIO import StringIO from buildbot.process import buildstep from buildbot.status.testresult import TestResult as aTestResult from buildbot.status.results import SUCCESS, FAILURE, SKIPPED class SubunitLogObserver(buildstep.LogLineObserver, TestResult): """Observe a log that may contain subunit output. This class extends TestResult to receive the callbacks from the subunit parser in the most direct fashion. """ def __init__(self): buildstep.LogLineObserver.__init__(self) TestResult.__init__(self) try: from subunit import TestProtocolServer, PROGRESS_CUR, PROGRESS_SET from subunit import PROGRESS_PUSH, PROGRESS_POP except ImportError: raise ImportError("subunit is not importable, but is required for " "SubunitLogObserver support.") self.PROGRESS_CUR = PROGRESS_CUR self.PROGRESS_SET = PROGRESS_SET self.PROGRESS_PUSH = PROGRESS_PUSH self.PROGRESS_POP = PROGRESS_POP self.warningio = StringIO() self.protocol = TestProtocolServer(self, self.warningio) self.skips = [] self.seen_tags = set() #don't yet know what tags does in subunit def outLineReceived(self, line): """Process a received stdout line.""" # Impedance mismatch: subunit wants lines, observers get lines-no\n self.protocol.lineReceived(line + '\n') def errLineReceived(self, line): """same for stderr line.""" self.protocol.lineReceived(line + '\n') def stopTest(self, test): TestResult.stopTest(self, test) self.step.setProgress('tests', self.testsRun) def addSuccess(self, test): TestResult.addSuccess(self, test) self.addAResult(test, SUCCESS, 'SUCCESS') def addSkip(self, test, detail): if hasattr(TestResult,'addSkip'): TestResult.addSkip(self, test, detail) else: self.skips.append((test, detail)) self.addAResult(test, SKIPPED, 'SKIPPED', detail) def addError(self, test, err): TestResult.addError(self, test, err) self.issue(test, err) def addFailure(self, test, err): TestResult.addFailure(self, test, err) self.issue(test, err) def addAResult(self, test, result, text, log=""): tr = aTestResult(tuple(test.id().split('.')), result, text, log) self.step.build.build_status.addTestResult(tr) def issue(self, test, err): """An issue - failing, erroring etc test.""" self.addAResult(test, FAILURE, 'FAILURE', err) self.step.setProgress('tests failed', len(self.failures) + len(self.errors)) expectedTests = 0 contextLevel = 0 def progress(self, offset, whence): if not self.contextLevel: if whence == self.PROGRESS_CUR: self.expectedTests += offset elif whence == self.PROGRESS_SET: self.expectedTests = offset self.step.progress.setExpectations({'tests': self.expectedTests}) #TODO: properly support PUSH/POP if whence == self.PROGRESS_PUSH: self.contextLevel += 1 elif whence == self.PROGRESS_POP: self.contextLevel -= 1 def tags(self, new_tags, gone_tags): """Accumulate the seen tags.""" self.seen_tags.update(new_tags) # this used to be referenced here, so we keep a link for old time's sake import buildbot.steps.subunit SubunitShellCommand = buildbot.steps.subunit.SubunitShellCommand buildbot-0.8.8/buildbot/process/users/000077500000000000000000000000001222546025000177545ustar00rootroot00000000000000buildbot-0.8.8/buildbot/process/users/__init__.py000066400000000000000000000000001222546025000220530ustar00rootroot00000000000000buildbot-0.8.8/buildbot/process/users/manager.py000066400000000000000000000033771222546025000217520ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.internet import defer from twisted.application import service from buildbot import config class UserManagerManager(config.ReconfigurableServiceMixin, service.MultiService): # this class manages a fleet of user managers; hence the name.. def __init__(self, master): service.MultiService.__init__(self) self.setName('user_manager_manager') self.master = master @defer.inlineCallbacks def reconfigService(self, new_config): # this is easy - kick out all of the old managers, and add the # new ones. for mgr in list(self): yield defer.maybeDeferred(lambda : mgr.disownServiceParent()) mgr.master = None for mgr in new_config.user_managers: mgr.master = self.master mgr.setServiceParent(self) # reconfig any newly-added change sources, as well as existing yield config.ReconfigurableServiceMixin.reconfigService(self, new_config) buildbot-0.8.8/buildbot/process/users/manual.py000066400000000000000000000227131222546025000216100ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # this class is known to contain cruft and will be looked at later, so # no current implementation utilizes it aside from scripts.runner. from twisted.python import log from twisted.internet import defer from twisted.application import service from buildbot import pbutil class UsersBase(service.MultiService): """ Base class for services that manage users manually. This takes care of the service.MultiService work needed by all the services that subclass it. """ def __init__(self): service.MultiService.__init__(self) self.master = None def startService(self): service.MultiService.startService(self) def stopService(self): return service.MultiService.stopService(self) class CommandlineUserManagerPerspective(pbutil.NewCredPerspective): """ Perspective registered in buildbot.pbmanager and contains the real workings of `buildbot user` by working with the database when perspective_commandline is called. """ def __init__(self, master): self.master = master def formatResults(self, op, results): """ This formats the results of the database operations for printing back to the caller @param op: operation to perform (add, remove, update, get) @type op: string @param results: results from db queries in perspective_commandline @type results: list @returns: string containing formatted results """ formatted_results = "" if op == 'add': # list, alternating ident, uid formatted_results += "user(s) added:\n" for user in results: if isinstance(user, basestring): formatted_results += "identifier: %s\n" % user else: formatted_results += "uid: %d\n\n" % user elif op == 'remove': # list of dictionaries formatted_results += "user(s) removed:\n" for user in results: if user: formatted_results += "identifier: %s\n" % (user) elif op == 'update': # list, alternating ident, None formatted_results += "user(s) updated:\n" for user in results: if user: formatted_results += "identifier: %s\n" % (user) elif op == 'get': # list of dictionaries formatted_results += "user(s) found:\n" for user in results: if user: for key in user: if key != 'bb_password': formatted_results += "%s: %s\n" % (key, user[key]) formatted_results += "\n" else: formatted_results += "no match found\n" return formatted_results @defer.inlineCallbacks def perspective_commandline(self, op, bb_username, bb_password, ids, info): """ This performs the requested operations from the `buildbot user` call by calling the proper buildbot.db.users methods based on the operation. It yields a deferred instance with the results from the database methods. @param op: operation to perform (add, remove, update, get) @type op: string @param bb_username: username portion of auth credentials @type bb_username: string @param bb_password: hashed password portion of auth credentials @type bb_password: hashed string @param ids: user identifiers used to find existing users @type ids: list of strings or None @param info: type/value pairs for each user that will be added or updated in the database @type info: list of dictionaries or None @returns: results from db.users methods via deferred """ log.msg("perspective_commandline called") results = [] if ids: for user in ids: # get identifier, guaranteed to be in user from checks # done in C{scripts.runner} uid = yield self.master.db.users.identifierToUid( identifier=user) result = None if op == 'remove': if uid: yield self.master.db.users.removeUser(uid) result = user else: log.msg("Unable to find uid for identifier %s" % user) elif op == 'get': if uid: result = yield self.master.db.users.getUser(uid) else: log.msg("Unable to find uid for identifier %s" % user) results.append(result) else: for user in info: # get identifier, guaranteed to be in user from checks # done in C{scripts.runner} ident = user.pop('identifier') uid = yield self.master.db.users.identifierToUid( identifier=ident) # if only an identifier was in user, we're updating only # the bb_username and bb_password. if not user: if uid: result = yield self.master.db.users.updateUser( uid=uid, identifier=ident, bb_username=bb_username, bb_password=bb_password) results.append(ident) else: log.msg("Unable to find uid for identifier %s" % user) else: # when adding, we update the user after the first attr once_through = False for attr in user: if op == 'update' or once_through: if uid: result = yield self.master.db.users.updateUser( uid=uid, identifier=ident, bb_username=bb_username, bb_password=bb_password, attr_type=attr, attr_data=user[attr]) else: log.msg("Unable to find uid for identifier %s" % user) elif op == 'add': result = yield self.master.db.users.findUserByAttr( identifier=ident, attr_type=attr, attr_data=user[attr]) once_through = True results.append(ident) # result is None from updateUser calls if result: results.append(result) uid = result results = self.formatResults(op, results) defer.returnValue(results) class CommandlineUserManager(UsersBase): """ Service that runs to set up and register CommandlineUserManagerPerspective so `buildbot user` calls get to perspective_commandline. """ def __init__(self, username=None, passwd=None, port=None): UsersBase.__init__(self) assert username and passwd, ("A username and password pair must be given " "to connect and use `buildbot user`") self.username = username self.passwd = passwd assert port, "A port must be specified for a PB connection" self.port = port self.registration = None def startService(self): UsersBase.startService(self) # set up factory and register with buildbot.pbmanager def factory(mind, username): return CommandlineUserManagerPerspective(self.master) self.registration = self.master.pbmanager.register(self.port, self.username, self.passwd, factory) def stopService(self): d = defer.maybeDeferred(UsersBase.stopService, self) def unreg(_): if self.registration: return self.registration.unregister() d.addCallback(unreg) return d buildbot-0.8.8/buildbot/process/users/users.py000066400000000000000000000133261222546025000214740ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os from twisted.python import log from twisted.internet import defer from buildbot.util import flatten try: from hashlib import sha1 as sha assert sha except ImportError: # For Python 2.4 import sha srcs = ['git', 'svn', 'hg', 'cvs', 'darcs', 'bzr'] salt_len = 8 def createUserObject(master, author, src=None): """ Take a Change author and source and translate them into a User Object, storing the user in master.db, or returning None if the src is not specified. @param master: link to Buildmaster for database operations @type master: master.Buildmaster instance @param authors: Change author if string or Authz instance @type authors: string or status.web.authz instance @param src: source from which the User Object will be created @type src: string """ if not src: log.msg("No vcs information found, unable to create User Object") return defer.succeed(None) if src in srcs: usdict = dict(identifier=author, attr_type=src, attr_data=author) else: log.msg("Unrecognized source argument: %s" % src) return defer.succeed(None) return master.db.users.findUserByAttr( identifier=usdict['identifier'], attr_type=usdict['attr_type'], attr_data=usdict['attr_data']) def _extractContact(usdict, contact_types, uid): if usdict: for type in contact_types: contact = usdict.get(type) if contact: break else: contact = None if contact is None: log.msg(format="Unable to find any of %(contact_types)r for uid: %(uid)r", contact_types=contact_types, uid=uid) return contact def getUserContact(master, contact_types, uid): """ This is a simple getter function that returns a user attribute that matches the contact_types argument, or returns None if no uid/match is found. @param master: BuildMaster used to query the database @type master: BuildMaster instance @param contact_types: list of contact attributes to look for in in a given user, such as 'email' or 'nick' @type contact_types: list of strings @param uid: user that is searched for the contact_types match @type uid: integer @returns: string of contact information or None via deferred """ d = master.db.users.getUser(uid) d.addCallback(_extractContact, contact_types, uid) return d def _filter(contacts): def notNone(c): return c is not None return filter(notNone, contacts) def getUsersContacts(master, contact_types, uids): d = defer.gatherResults([getUserContact(master, contact_types, uid) for uid in uids]) d.addCallback(_filter) return d def getChangeContacts(master, change, contact_types): d = master.db.changes.getChangeUids(change.number) d.addCallback(lambda uids: getUsersContacts(master, contact_types, uids)) return d def getSourceStampContacts(master, ss, contact_types): dl = [getChangeContacts(master, change, contact_types) for change in ss.changes] if False and ss.patch_info: d = master.db.users.getUserByUsername(ss.patch_into[0]) d.addCallback(_extractContact, contact_types, ss.patch_info[0]) d.addCallback(lambda contact: filter(None, [contact])) dl.append(d) d = defer.gatherResults(dl) d.addCallback(flatten) return d def getBuildContacts(master, build, contact_types): dl = [] ss_list = build.getSourceStamps() for ss in ss_list: dl.append(getSourceStampContacts(master, ss, contact_types)) d = defer.gatherResults(dl) d.addCallback(flatten) @d.addCallback def addOwners(recipients): dl = [] for owner in build.getInterestedUsers(): d = master.db.users.getUserByUsername(owner) d.addCallback(_extractContact, contact_types, owner) dl.append(d) d = defer.gatherResults(dl) d.addCallback(_filter) d.addCallback(lambda owners: recipients + owners) return d return d def encrypt(passwd): """ Encrypts the incoming password after adding some salt to store it in the database. @param passwd: password portion of user credentials @type passwd: string @returns: encrypted/salted string """ try: m = sha() except TypeError: m = sha.new() salt = os.urandom(salt_len).encode('hex_codec') m.update(passwd + salt) crypted = salt + m.hexdigest() return crypted def check_passwd(guess, passwd): """ Tests to see if the guess, after salting and hashing, matches the passwd from the database. @param guess: incoming password trying to be used for authentication @param passwd: already encrypted password from the database @returns: boolean """ try: m = sha() except TypeError: m = sha.new() salt = passwd[:salt_len * 2] # salt_len * 2 due to encode('hex_codec') m.update(guess + salt) crypted_guess = salt + m.hexdigest() return (crypted_guess == passwd) buildbot-0.8.8/buildbot/revlinks.py000066400000000000000000000045441222546025000173530ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import re class RevlinkMatch(object): def __init__(self, repo_urls, revlink): if isinstance(repo_urls, str) or isinstance(repo_urls, unicode): repo_urls = [ repo_urls ] self.repo_urls = map(re.compile, repo_urls) self.revlink = revlink def __call__(self, rev, repo): for url in self.repo_urls: m = url.match(repo) if m: return m.expand(self.revlink) % rev GithubRevlink = RevlinkMatch( repo_urls = [ r'https://github.com/([^/]*)/([^/]*?)(?:\.git)?$', r'git://github.com/([^/]*)/([^/]*?)(?:\.git)?$', r'git@github.com:([^/]*)/([^/]*?)(?:\.git)?$', r'ssh://git@github.com/([^/]*)/([^/]*?)(?:\.git)?$' ], revlink = r'https://github.com/\1/\2/commit/%s') class GitwebMatch(RevlinkMatch): def __init__(self, repo_urls, revlink): RevlinkMatch.__init__(self, repo_urls = repo_urls, revlink = revlink + r'?p=\g;a=commit;h=%s') SourceforgeGitRevlink = GitwebMatch( repo_urls = [ r'^git://([^.]*).git.sourceforge.net/gitroot/(?P.*)$', r'[^@]*@([^.]*).git.sourceforge.net:gitroot/(?P.*)$', r'ssh://(?:[^@]*@)?([^.]*).git.sourceforge.net/gitroot/(?P.*)$', ], revlink = r'http://\1.git.sourceforge.net/git/gitweb.cgi') class RevlinkMultiplexer(object): def __init__(self, *revlinks): self.revlinks = revlinks def __call__(self, rev, repo): for revlink in self.revlinks: url = revlink(rev, repo) if url: return url default_revlink_matcher = RevlinkMultiplexer(GithubRevlink, SourceforgeGitRevlink) buildbot-0.8.8/buildbot/scheduler.py000066400000000000000000000022101222546025000174600ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.schedulers.basic import Scheduler, AnyBranchScheduler from buildbot.schedulers.dependent import Dependent from buildbot.schedulers.timed import Periodic, Nightly from buildbot.schedulers.triggerable import Triggerable from buildbot.schedulers.trysched import Try_Jobdir, Try_Userpass _hush_pyflakes = [Scheduler, AnyBranchScheduler, Dependent, Periodic, Nightly, Triggerable, Try_Jobdir, Try_Userpass] del _hush_pyflakes buildbot-0.8.8/buildbot/schedulers/000077500000000000000000000000001222546025000172765ustar00rootroot00000000000000buildbot-0.8.8/buildbot/schedulers/__init__.py000066400000000000000000000000001222546025000213750ustar00rootroot00000000000000buildbot-0.8.8/buildbot/schedulers/base.py000066400000000000000000000456731222546025000206010ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from twisted.python import failure, log from twisted.application import service from twisted.internet import defer from buildbot.process.properties import Properties from buildbot.util import ComparableMixin from buildbot import config, interfaces from buildbot.util.state import StateMixin class BaseScheduler(service.MultiService, ComparableMixin, StateMixin): """ Base class for all schedulers; this provides the equipment to manage reconfigurations and to handle basic scheduler state. It also provides utility methods to begin various sorts of builds. Subclasses should add any configuration-derived attributes to C{base.Scheduler.compare_attrs}. """ implements(interfaces.IScheduler) DefaultCodebases = {'':{}} compare_attrs = ('name', 'builderNames', 'properties', 'codebases') def __init__(self, name, builderNames, properties, codebases = DefaultCodebases): """ Initialize a Scheduler. @param name: name of this scheduler (used as a key for state) @type name: unicode @param builderNames: list of builders this scheduler may start @type builderNames: list of unicode @param properties: properties to add to builds triggered by this scheduler @type properties: dictionary @param codebases: codebases that are necessary to process the changes @type codebases: dict with following struct: key: '' value: {'repository':'', 'branch':'
', 'revision:''} @param consumeChanges: true if this scheduler wishes to be informed about the addition of new changes. Defaults to False. This should be passed explicitly from subclasses to indicate their interest in consuming changes. @type consumeChanges: boolean """ service.MultiService.__init__(self) self.name = name "name of this scheduler; used to identify replacements on reconfig" ok = True if not isinstance(builderNames, (list, tuple)): ok = False else: for b in builderNames: if not isinstance(b, basestring): ok = False if not ok: config.error( "The builderNames argument to a scheduler must be a list " "of Builder names.") self.builderNames = builderNames "list of builder names to start in each buildset" self.properties = Properties() "properties that are contributed to each buildset" self.properties.update(properties, "Scheduler") self.properties.setProperty("scheduler", name, "Scheduler") self.objectid = None self.master = None # Set the codebases that are necessary to process the changes # These codebases will always result in a sourcestamp with or without changes if codebases is not None: if not isinstance(codebases, dict): config.error("Codebases must be a dict of dicts") for codebase, codebase_attrs in codebases.iteritems(): if not isinstance(codebase_attrs, dict): config.error("Codebases must be a dict of dicts") if (codebases != BaseScheduler.DefaultCodebases and 'repository' not in codebase_attrs): config.error("The key 'repository' is mandatory in codebases") else: config.error("Codebases cannot be None") self.codebases = codebases # internal variables self._change_subscription = None self._change_consumption_lock = defer.DeferredLock() ## service handling def startService(self): service.MultiService.startService(self) def findNewSchedulerInstance(self, new_config): return new_config.schedulers[self.name] # should exist! def stopService(self): d = defer.maybeDeferred(self._stopConsumingChanges) d.addCallback(lambda _ : service.MultiService.stopService(self)) return d ## status queries # TODO: these aren't compatible with distributed schedulers def listBuilderNames(self): "Returns the list of builder names" return self.builderNames def getPendingBuildTimes(self): "Returns a list of the next times that builds are scheduled, if known." return [] ## change handling def startConsumingChanges(self, fileIsImportant=None, change_filter=None, onlyImportant=False): """ Subclasses should call this method from startService to register to receive changes. The BaseScheduler class will take care of filtering the changes (using change_filter) and (if fileIsImportant is not None) classifying them. See L{gotChange}. Returns a Deferred. @param fileIsImportant: a callable provided by the user to distinguish important and unimportant changes @type fileIsImportant: callable @param change_filter: a filter to determine which changes are even considered by this scheduler, or C{None} to consider all changes @type change_filter: L{buildbot.changes.filter.ChangeFilter} instance @param onlyImportant: If True, only important changes, as specified by fileIsImportant, will be added to the buildset. @type onlyImportant: boolean """ assert fileIsImportant is None or callable(fileIsImportant) # register for changes with master assert not self._change_subscription def changeCallback(change): # ignore changes delivered while we're not running if not self._change_subscription: return if change_filter and not change_filter.filter_change(change): return if change.codebase not in self.codebases: log.msg(format='change contains codebase %(codebase)s that is' 'not processed by scheduler %(scheduler)s', codebase=change.codebase, name=self.name) return if fileIsImportant: try: important = fileIsImportant(change) if not important and onlyImportant: return except: log.err(failure.Failure(), 'in fileIsImportant check for %s' % change) return else: important = True # use change_consumption_lock to ensure the service does not stop # while this change is being processed d = self._change_consumption_lock.run(self.gotChange, change, important) d.addErrback(log.err, 'while processing change') self._change_subscription = self.master.subscribeToChanges(changeCallback) return defer.succeed(None) def _stopConsumingChanges(self): # (note: called automatically in stopService) # acquire the lock change consumption lock to ensure that any change # consumption is complete before we are done stopping consumption def stop(): if self._change_subscription: self._change_subscription.unsubscribe() self._change_subscription = None return self._change_consumption_lock.run(stop) def gotChange(self, change, important): """ Called when a change is received; returns a Deferred. If the C{fileIsImportant} parameter to C{startConsumingChanges} was C{None}, then all changes are considered important. The C{codebase} of the change has always an entry in the C{codebases} dictionary of the scheduler. @param change: the new change object @type change: L{buildbot.changes.changes.Change} instance @param important: true if this is an important change, according to C{fileIsImportant}. @type important: boolean @returns: Deferred """ raise NotImplementedError ## starting bulids @defer.inlineCallbacks def addBuildsetForLatest(self, reason='', external_idstring=None, branch=None, repository='', project='', builderNames=None, properties=None): """ Add a buildset for the 'latest' source in the given branch, repository, and project. This will create a relative sourcestamp for the buildset. This method will add any properties provided to the scheduler constructor to the buildset, and will call the master's addBuildset method with the appropriate parameters. @param reason: reason for this buildset @type reason: unicode string @param external_idstring: external identifier for this buildset, or None @param branch: branch to build (note that None often has a special meaning) @param repository: repository name for sourcestamp @param project: project name for sourcestamp @param builderNames: builders to name in the buildset (defaults to C{self.builderNames}) @param properties: a properties object containing initial properties for the buildset @type properties: L{buildbot.process.properties.Properties} @returns: (buildset ID, buildrequest IDs) via Deferred """ # Define setid for this set of changed repositories setid = yield self.master.db.sourcestampsets.addSourceStampSet() # add a sourcestamp for each codebase for codebase, cb_info in self.codebases.iteritems(): ss_repository = cb_info.get('repository', repository) ss_branch = cb_info.get('branch', branch) ss_revision = cb_info.get('revision', None) yield self.master.db.sourcestamps.addSourceStamp( codebase=codebase, repository=ss_repository, branch=ss_branch, revision=ss_revision, project=project, changeids=set(), sourcestampsetid=setid) bsid,brids = yield self.addBuildsetForSourceStamp( setid=setid, reason=reason, external_idstring=external_idstring, builderNames=builderNames, properties=properties) defer.returnValue((bsid,brids)) @defer.inlineCallbacks def addBuildsetForSourceStampDetails(self, reason='', external_idstring=None, branch=None, repository='', project='', revision=None, builderNames=None, properties=None): """ Given details about the source code to build, create a source stamp and then add a buildset for it. @param reason: reason for this buildset @type reason: unicode string @param external_idstring: external identifier for this buildset, or None @param branch: branch to build (note that None often has a special meaning) @param repository: repository name for sourcestamp @param project: project name for sourcestamp @param revision: revision to build - default is latest @param builderNames: builders to name in the buildset (defaults to C{self.builderNames}) @param properties: a properties object containing initial properties for the buildset @type properties: L{buildbot.process.properties.Properties} @returns: (buildset ID, buildrequest IDs) via Deferred """ # Define setid for this set of changed repositories setid = yield self.master.db.sourcestampsets.addSourceStampSet() yield self.master.db.sourcestamps.addSourceStamp( branch=branch, revision=revision, repository=repository, project=project, sourcestampsetid=setid) rv = yield self.addBuildsetForSourceStamp( setid=setid, reason=reason, external_idstring=external_idstring, builderNames=builderNames, properties=properties) defer.returnValue(rv) @defer.inlineCallbacks def addBuildsetForSourceStampSetDetails(self, reason, sourcestamps, properties, builderNames=None): if sourcestamps is None: sourcestamps = {} # Define new setid for this set of sourcestamps new_setid = yield self.master.db.sourcestampsets.addSourceStampSet() # Merge codebases with the passed list of sourcestamps # This results in a new sourcestamp for each codebase for codebase in self.codebases: ss = self.codebases[codebase].copy() # apply info from passed sourcestamps onto the configured default # sourcestamp attributes for this codebase. ss.update(sourcestamps.get(codebase,{})) # add sourcestamp to the new setid yield self.master.db.sourcestamps.addSourceStamp( codebase=codebase, repository=ss.get('repository', ''), branch=ss.get('branch', None), revision=ss.get('revision', None), project=ss.get('project', ''), changeids=[c['number'] for c in ss.get('changes', [])], patch_body=ss.get('patch_body', None), patch_level=ss.get('patch_level', None), patch_author=ss.get('patch_author', None), patch_comment=ss.get('patch_comment', None), sourcestampsetid=new_setid) rv = yield self.addBuildsetForSourceStamp( setid=new_setid, reason=reason, properties=properties, builderNames=builderNames) defer.returnValue(rv) @defer.inlineCallbacks def addBuildsetForChanges(self, reason='', external_idstring=None, changeids=[], builderNames=None, properties=None): changesByCodebase = {} def get_last_change_for_codebase(codebase): return max(changesByCodebase[codebase],key = lambda change: change["changeid"]) # Define setid for this set of changed repositories setid = yield self.master.db.sourcestampsets.addSourceStampSet() # Changes are retrieved from database and grouped by their codebase for changeid in changeids: chdict = yield self.master.db.changes.getChange(changeid) # group change by codebase changesByCodebase.setdefault(chdict["codebase"], []).append(chdict) for codebase in self.codebases: args = {'codebase': codebase, 'sourcestampsetid': setid } if codebase not in changesByCodebase: # codebase has no changes # create a sourcestamp that has no changes args['repository'] = self.codebases[codebase]['repository'] args['branch'] = self.codebases[codebase].get('branch', None) args['revision'] = self.codebases[codebase].get('revision', None) args['changeids'] = set() args['project'] = '' else: #codebase has changes args['changeids'] = [c["changeid"] for c in changesByCodebase[codebase]] lastChange = get_last_change_for_codebase(codebase) for key in ['repository', 'branch', 'revision', 'project']: args[key] = lastChange[key] yield self.master.db.sourcestamps.addSourceStamp(**args) # add one buildset, this buildset is connected to the sourcestamps by the setid bsid,brids = yield self.addBuildsetForSourceStamp( setid=setid, reason=reason, external_idstring=external_idstring, builderNames=builderNames, properties=properties) defer.returnValue((bsid,brids)) @defer.inlineCallbacks def addBuildsetForSourceStamp(self, ssid=None, setid=None, reason='', external_idstring=None, properties=None, builderNames=None): """ Add a buildset for the given, already-existing sourcestamp. This method will add any properties provided to the scheduler constructor to the buildset, and will call the master's L{BuildMaster.addBuildset} method with the appropriate parameters, and return the same result. @param reason: reason for this buildset @type reason: unicode string @param external_idstring: external identifier for this buildset, or None @param properties: a properties object containing initial properties for the buildset @type properties: L{buildbot.process.properties.Properties} @param builderNames: builders to name in the buildset (defaults to C{self.builderNames}) @param setid: idenitification of a set of sourcestamps @returns: (buildset ID, buildrequest IDs) via Deferred """ assert (ssid is None and setid is not None) \ or (ssid is not None and setid is None), "pass a single sourcestamp OR set not both" # combine properties if properties: properties.updateFromProperties(self.properties) else: properties = self.properties # apply the default builderNames if not builderNames: builderNames = self.builderNames # translate properties object into a dict as required by the # addBuildset method properties_dict = properties.asDict() if setid == None: if ssid is not None: ssdict = yield self.master.db.sourcestamps.getSourceStamp(ssid) setid = ssdict['sourcestampsetid'] else: # no sourcestamp and no sets yield None rv = yield self.master.addBuildset(sourcestampsetid=setid, reason=reason, properties=properties_dict, builderNames=builderNames, external_idstring=external_idstring) defer.returnValue(rv) buildbot-0.8.8/buildbot/schedulers/basic.py000066400000000000000000000251631222546025000207400ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.internet import defer, reactor from twisted.python import log from buildbot import util, config from buildbot.util import NotABranch from collections import defaultdict from buildbot.changes import filter, changes from buildbot.schedulers import base, dependent class BaseBasicScheduler(base.BaseScheduler): """ @param onlyImportant: If True, only important changes will be added to the buildset. @type onlyImportant: boolean """ compare_attrs = (base.BaseScheduler.compare_attrs + ('treeStableTimer', 'change_filter', 'fileIsImportant', 'onlyImportant') ) _reactor = reactor # for tests fileIsImportant = None class NotSet: pass def __init__(self, name, shouldntBeSet=NotSet, treeStableTimer=None, builderNames=None, branch=NotABranch, branches=NotABranch, fileIsImportant=None, properties={}, categories=None, change_filter=None, onlyImportant=False, **kwargs): if shouldntBeSet is not self.NotSet: config.error( "pass arguments to schedulers using keyword arguments") if fileIsImportant and not callable(fileIsImportant): config.error( "fileIsImportant must be a callable") # initialize parent classes base.BaseScheduler.__init__(self, name, builderNames, properties, **kwargs) self.treeStableTimer = treeStableTimer if fileIsImportant is not None: self.fileIsImportant = fileIsImportant self.onlyImportant = onlyImportant self.change_filter = self.getChangeFilter(branch=branch, branches=branches, change_filter=change_filter, categories=categories) # the IDelayedCall used to wake up when this scheduler's # treeStableTimer expires. self._stable_timers = defaultdict(lambda : None) self._stable_timers_lock = defer.DeferredLock() def getChangeFilter(self, branch, branches, change_filter, categories): raise NotImplementedError def startService(self, _returnDeferred=False): base.BaseScheduler.startService(self) d = self.startConsumingChanges(fileIsImportant=self.fileIsImportant, change_filter=self.change_filter, onlyImportant=self.onlyImportant) # if treeStableTimer is False, then we don't care about classified # changes, so get rid of any hanging around from previous # configurations if not self.treeStableTimer: d.addCallback(lambda _ : self.master.db.schedulers.flushChangeClassifications( self.objectid)) # otherwise, if there are classified changes out there, start their # treeStableTimers again else: d.addCallback(lambda _ : self.scanExistingClassifiedChanges()) # handle Deferred errors, since startService does not return a Deferred d.addErrback(log.err, "while starting SingleBranchScheduler '%s'" % self.name) if _returnDeferred: return d # only used in tests def stopService(self): # the base stopService will unsubscribe from new changes d = base.BaseScheduler.stopService(self) @util.deferredLocked(self._stable_timers_lock) def cancel_timers(_): for timer in self._stable_timers.values(): if timer: timer.cancel() self._stable_timers.clear() d.addCallback(cancel_timers) return d @util.deferredLocked('_stable_timers_lock') def gotChange(self, change, important): if not self.treeStableTimer: # if there's no treeStableTimer, we can completely ignore # unimportant changes if not important: return defer.succeed(None) # otherwise, we'll build it right away return self.addBuildsetForChanges(reason='scheduler', changeids=[ change.number ]) timer_name = self.getTimerNameForChange(change) # if we have a treeStableTimer, then record the change's importance # and: # - for an important change, start the timer # - for an unimportant change, reset the timer if it is running d = self.master.db.schedulers.classifyChanges( self.objectid, { change.number : important }) def fix_timer(_): if not important and not self._stable_timers[timer_name]: return if self._stable_timers[timer_name]: self._stable_timers[timer_name].cancel() def fire_timer(): d = self.stableTimerFired(timer_name) d.addErrback(log.err, "while firing stable timer") self._stable_timers[timer_name] = self._reactor.callLater( self.treeStableTimer, fire_timer) d.addCallback(fix_timer) return d @defer.inlineCallbacks def scanExistingClassifiedChanges(self): # call gotChange for each classified change. This is called at startup # and is intended to re-start the treeStableTimer for any changes that # had not yet been built when the scheduler was stopped. # NOTE: this may double-call gotChange for changes that arrive just as # the scheduler starts up. In practice, this doesn't hurt anything. classifications = \ yield self.master.db.schedulers.getChangeClassifications( self.objectid) # call gotChange for each change, after first fetching it from the db for changeid, important in classifications.iteritems(): chdict = yield self.master.db.changes.getChange(changeid) if not chdict: continue change = yield changes.Change.fromChdict(self.master, chdict) yield self.gotChange(change, important) def getTimerNameForChange(self, change): raise NotImplementedError # see subclasses def getChangeClassificationsForTimer(self, objectid, timer_name): """similar to db.schedulers.getChangeClassifications, but given timer name""" raise NotImplementedError # see subclasses @util.deferredLocked('_stable_timers_lock') @defer.inlineCallbacks def stableTimerFired(self, timer_name): # if the service has already been stoppd then just bail out if not self._stable_timers[timer_name]: return # delete this now-fired timer del self._stable_timers[timer_name] classifications = \ yield self.getChangeClassificationsForTimer(self.objectid, timer_name) # just in case: databases do weird things sometimes! if not classifications: # pragma: no cover return changeids = sorted(classifications.keys()) yield self.addBuildsetForChanges(reason='scheduler', changeids=changeids) max_changeid = changeids[-1] # (changeids are sorted) yield self.master.db.schedulers.flushChangeClassifications( self.objectid, less_than=max_changeid+1) def getPendingBuildTimes(self): # This isn't locked, since the caller expects and immediate value, # and in any case, this is only an estimate. return [timer.getTime() for timer in self._stable_timers.values() if timer and timer.active()] class SingleBranchScheduler(BaseBasicScheduler): def getChangeFilter(self, branch, branches, change_filter, categories): if branch is NotABranch and not change_filter: config.error( "the 'branch' argument to SingleBranchScheduler is " + "mandatory unless change_filter is provided") elif branches is not NotABranch: config.error( "the 'branches' argument is not allowed for " + "SingleBranchScheduler") return filter.ChangeFilter.fromSchedulerConstructorArgs( change_filter=change_filter, branch=branch, categories=categories) def getTimerNameForChange(self, change): return "only" # this class only uses one timer def getChangeClassificationsForTimer(self, objectid, timer_name): return self.master.db.schedulers.getChangeClassifications( self.objectid) class Scheduler(SingleBranchScheduler): "alias for SingleBranchScheduler" def __init__(self, *args, **kwargs): log.msg("WARNING: the name 'Scheduler' is deprecated; use " + "buildbot.schedulers.basic.SingleBranchScheduler instead " + "(note that this may require you to change your import " + "statement)") SingleBranchScheduler.__init__(self, *args, **kwargs) class AnyBranchScheduler(BaseBasicScheduler): def getChangeFilter(self, branch, branches, change_filter, categories): assert branch is NotABranch return filter.ChangeFilter.fromSchedulerConstructorArgs( change_filter=change_filter, branch=branches, categories=categories) def getTimerNameForChange(self, change): # Py2.6+: could be a namedtuple return (change.codebase, change.project, change.repository, change.branch) def getChangeClassificationsForTimer(self, objectid, timer_name): codebase, project, repository, branch = timer_name # set in getTimerNameForChange return self.master.db.schedulers.getChangeClassifications( self.objectid, branch=branch, repository=repository, codebase=codebase, project=project) # now at buildbot.schedulers.dependent, but keep the old name alive Dependent = dependent.Dependent buildbot-0.8.8/buildbot/schedulers/dependent.py000066400000000000000000000135421222546025000216230ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.internet import defer from twisted.python import log from buildbot import util, interfaces, config from buildbot.status.results import SUCCESS, WARNINGS from buildbot.schedulers import base class Dependent(base.BaseScheduler): compare_attrs = base.BaseScheduler.compare_attrs + ('upstream_name',) def __init__(self, name, upstream, builderNames, properties={}, **kwargs): base.BaseScheduler.__init__(self, name, builderNames, properties, **kwargs) if not interfaces.IScheduler.providedBy(upstream): config.error( "upstream must be another Scheduler instance") self.upstream_name = upstream.name self._buildset_addition_subscr = None self._buildset_completion_subscr = None self._cached_upstream_bsids = None # the subscription lock makes sure that we're done inserting a # subcription into the DB before registering that the buildset is # complete. self._subscription_lock = defer.DeferredLock() def startService(self): self._buildset_addition_subscr = \ self.master.subscribeToBuildsets(self._buildsetAdded) self._buildset_completion_subscr = \ self.master.subscribeToBuildsetCompletions(self._buildsetCompleted) # check for any buildsets completed before we started d = self._checkCompletedBuildsets(None, None) d.addErrback(log.err, 'while checking for completed buildsets in start') def stopService(self): if self._buildset_addition_subscr: self._buildset_addition_subscr.unsubscribe() if self._buildset_completion_subscr: self._buildset_completion_subscr.unsubscribe() self._cached_upstream_bsids = None return defer.succeed(None) @util.deferredLocked('_subscription_lock') def _buildsetAdded(self, bsid=None, properties=None, **kwargs): # check if this was submitetted by our upstream by checking the # scheduler property submitter = properties.get('scheduler', (None, None))[0] if submitter != self.upstream_name: return # record our interest in this buildset d = self._addUpstreamBuildset(bsid) d.addErrback(log.err, 'while subscribing to buildset %d' % bsid) def _buildsetCompleted(self, bsid, result): d = self._checkCompletedBuildsets(bsid, result) d.addErrback(log.err, 'while checking for completed buildsets') @util.deferredLocked('_subscription_lock') @defer.inlineCallbacks def _checkCompletedBuildsets(self, bsid, result): subs = yield self._getUpstreamBuildsets() sub_bsids = [] for (sub_bsid, sub_sssetid, sub_complete, sub_results) in subs: # skip incomplete builds, handling the case where the 'complete' # column has not been updated yet if not sub_complete and sub_bsid != bsid: continue # build a dependent build if the status is appropriate if sub_results in (SUCCESS, WARNINGS): yield self.addBuildsetForSourceStamp(setid=sub_sssetid, reason='downstream') sub_bsids.append(sub_bsid) # and regardless of status, remove the subscriptions yield self._removeUpstreamBuildsets(sub_bsids) @defer.inlineCallbacks def _updateCachedUpstreamBuilds(self): if self._cached_upstream_bsids is None: bsids = yield self.master.db.state.getState(self.objectid, 'upstream_bsids', []) self._cached_upstream_bsids = bsids @defer.inlineCallbacks def _getUpstreamBuildsets(self): # get a list of (bsid, sssid, complete, results) for all # upstream buildsets yield self._updateCachedUpstreamBuilds() changed = False rv = [] for bsid in self._cached_upstream_bsids[:]: bsdict = yield self.master.db.buildsets.getBuildset(bsid) if not bsdict: self._cached_upstream_bsids.remove(bsid) changed = True continue rv.append((bsid, bsdict['sourcestampsetid'], bsdict['complete'], bsdict['results'])) if changed: yield self.master.db.state.setState(self.objectid, 'upstream_bsids', self._cached_upstream_bsids) defer.returnValue(rv) @defer.inlineCallbacks def _addUpstreamBuildset(self, bsid): yield self._updateCachedUpstreamBuilds() if bsid not in self._cached_upstream_bsids: self._cached_upstream_bsids.append(bsid) yield self.master.db.state.setState(self.objectid, 'upstream_bsids', self._cached_upstream_bsids) @defer.inlineCallbacks def _removeUpstreamBuildsets(self, bsids): yield self._updateCachedUpstreamBuilds() old = set(self._cached_upstream_bsids) self._cached_upstream_bsids = list(old - set(bsids)) yield self.master.db.state.setState(self.objectid, 'upstream_bsids', self._cached_upstream_bsids) buildbot-0.8.8/buildbot/schedulers/filter.py000066400000000000000000000015231222546025000211360ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # old (pre-0.8.4) location for ChangeFilter from buildbot.changes.filter import ChangeFilter _hush_pyflakes = ChangeFilter # keep pyflakes happy buildbot-0.8.8/buildbot/schedulers/forcesched.py000066400000000000000000000604221222546025000217610ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import traceback import re from twisted.internet import defer import email.utils as email_utils from buildbot.process.properties import Properties from buildbot.schedulers import base from buildbot import config class ValidationError(ValueError): pass DefaultField = object() # sentinel object to signal default behavior class BaseParameter(object): """ BaseParameter provides a base implementation for property customization """ name = "" parentName = None label = "" type = [] default = "" required = False multiple = False regex = None debug=True hide = False @property def fullName(self): """A full name, intended to uniquely identify a parameter""" # join with '_' if both are set if self.parentName and self.name: return self.parentName+'_'+self.name # otherwise just use the one that is set # (this allows empty name for "anonymous nests") return self.name or self.parentName def setParent(self, parent): self.parentName = parent.fullName if parent else None def __init__(self, name, label=None, regex=None, **kw): """ @param name: the name of the field, used during posting values back to the scheduler. This is not necessarily a UI value, and there may be restrictions on the characters allowed for this value. For example, HTML would require this field to avoid spaces and other punctuation ('-', '.', and '_' allowed) @type name: unicode @param label: (optional) the name of the field, used for UI display. @type label: unicode or None (to use 'name') @param regex: (optional) regex to validate the value with. Not used by all subclasses @type regex: unicode or regex """ self.name = name self.label = name if label is None else label if regex: self.regex = re.compile(regex) # all other properties are generically passed via **kw self.__dict__.update(kw) def getFromKwargs(self, kwargs): """Simple customization point for child classes that do not need the other parameters supplied to updateFromKwargs. Return the value for the property named 'self.name'. The default implementation converts from a list of items, validates using the optional regex field and calls 'parse_from_args' for the final conversion. """ args = kwargs.get(self.fullName, []) if len(args) == 0: if self.required: raise ValidationError("'%s' needs to be specified" % (self.label)) if self.multiple: args = self.default else: args = [self.default] if self.regex: for arg in args: if not self.regex.match(arg): raise ValidationError("%s:'%s' does not match pattern '%s'" % (self.label, arg, self.regex.pattern)) try: arg = self.parse_from_args(args) except Exception, e: # an exception will just display an alert in the web UI # also log the exception if self.debug: traceback.print_exc() raise e if arg is None: raise ValidationError("need %s: no default provided by config" % (self.fullName,)) return arg def updateFromKwargs(self, properties, kwargs, **unused): """Primary entry point to turn 'kwargs' into 'properties'""" properties[self.name] = self.getFromKwargs(kwargs) def parse_from_args(self, l): """Secondary customization point, called from getFromKwargs to turn a validated value into a single property value""" if self.multiple: return map(self.parse_from_arg, l) else: return self.parse_from_arg(l[0]) def parse_from_arg(self, s): return s class FixedParameter(BaseParameter): """A fixed parameter that cannot be modified by the user.""" type = ["fixed"] hide = True default = "" def parse_from_args(self, l): return self.default class StringParameter(BaseParameter): """A simple string parameter""" type = ["text"] size = 10 def parse_from_arg(self, s): return s class TextParameter(StringParameter): """A generic string parameter that may span multiple lines""" type = ["textarea"] cols = 80 rows = 20 def value_to_text(self, value): return str(value) class IntParameter(StringParameter): """An integer parameter""" type = ["int"] parse_from_arg = int # will throw an exception if parse fail class BooleanParameter(BaseParameter): """A boolean parameter""" type = ["bool"] def getFromKwargs(self, kwargs): return kwargs.get(self.fullName, None) == [True] class UserNameParameter(StringParameter): """A username parameter to supply the 'owner' of a build""" type = ["text"] default = "" size = 30 need_email = True def __init__(self, name="username", label="Your name:", **kw): BaseParameter.__init__(self, name, label, **kw) def parse_from_arg(self, s): if not s and not self.required: return s if self.need_email: e = email_utils.parseaddr(s) if e[0]=='' or e[1] == '': raise ValidationError("%s: please fill in email address in the " "form 'User '" % (self.name,)) return s class ChoiceStringParameter(BaseParameter): """A list of strings, allowing the selection of one of the predefined values. The 'strict' parameter controls whether values outside the predefined list of choices are allowed""" type = ["list"] choices = [] strict = True def parse_from_arg(self, s): if self.strict and not s in self.choices: raise ValidationError("'%s' does not belongs to list of available choices '%s'"%(s, self.choices)) return s def getChoices(self, master, scheduler, buildername): return self.choices class InheritBuildParameter(ChoiceStringParameter): """A parameter that takes its values from another build""" type = ChoiceStringParameter.type + ["inherit"] name = "inherit" compatible_builds = None def getChoices(self, master, scheduler, buildername): return self.compatible_builds(master.status, buildername) def getFromKwargs(self, kwargs): raise ValidationError("InheritBuildParameter can only be used by properties") def updateFromKwargs(self, master, properties, changes, kwargs, **unused): arg = kwargs.get(self.fullName, [""])[0] splitted_arg = arg.split(" ")[0].split("/") if len(splitted_arg) != 2: raise ValidationError("bad build: %s"%(arg)) builder, num = splitted_arg builder_status = master.status.getBuilder(builder) if not builder_status: raise ValidationError("unknown builder: %s in %s"%(builder, arg)) b = builder_status.getBuild(int(num)) if not b: raise ValidationError("unknown build: %d in %s"%(num, arg)) props = {self.name:(arg.split(" ")[0])} for name, value, source in b.getProperties().asList(): if source == "Force Build Form": if name == "owner": name = "orig_owner" props[name] = value properties.update(props) changes.extend(b.changes) class BuildslaveChoiceParameter(ChoiceStringParameter): """A parameter that lets the buildslave name be explicitly chosen. This parameter works in conjunction with 'buildbot.process.builder.enforceChosenSlave', which should be added as the 'canStartBuild' parameter to the Builder. The "anySentinel" parameter represents the sentinel value to specify that there is no buildslave preference. """ anySentinel = '-any-' label = 'Build slave' required = False strict = False def __init__(self, name='slavename', **kwargs): ChoiceStringParameter.__init__(self, name, **kwargs) def updateFromKwargs(self, kwargs, **unused): slavename = self.getFromKwargs(kwargs) if slavename==self.anySentinel: # no preference, so dont set a parameter at all return ChoiceStringParameter.updateFromKwargs(self, kwargs=kwargs, **unused) def getChoices(self, master, scheduler, buildername): if buildername is None: # this is the "Force All Builds" page slavenames = master.status.getSlaveNames() else: builderStatus = master.status.getBuilder(buildername) slavenames = [slave.getName() for slave in builderStatus.getSlaves()] slavenames.sort() slavenames.insert(0, self.anySentinel) return slavenames class NestedParameter(BaseParameter): """A 'parent' parameter for a set of related parameters. This provices a logical grouping for the child parameters. Typically, the 'fullName' of the child parameters mix in the parent's 'fullName'. This allows for a field to appear multiple times in a form (for example, two codebases each have a 'branch' field). If the 'name' of the parent is the empty string, then the parent's name does not mix in with the child 'fullName'. This is useful when a field will not appear multiple time in a scheduler but the logical grouping is helpful. The result of a NestedParameter is typically a dictionary, with the key/value being the name/value of the children. """ type = ['nested'] fields = None def __init__(self, name, fields, **kwargs): BaseParameter.__init__(self, fields=fields, name=name, **kwargs) # fix up the child nodes with the parent (use None for now): self.setParent(None) def setParent(self, parent): BaseParameter.setParent(self, parent) for field in self.fields: field.setParent(self) def collectChildProperties(self, kwargs, properties, **kw): """Collapse the child values into a dictionary. This is intended to be called by child classes to fix up the fullName->name conversions.""" childProperties = {} for field in self.fields: field.updateFromKwargs(kwargs=kwargs, properties=childProperties, **kw) kwargs[self.fullName] = childProperties def updateFromKwargs(self, kwargs, properties, **kw): """By default, the child values will be collapsed into a dictionary. If the parent is anonymous, this dictionary is the top-level properties.""" self.collectChildProperties(kwargs=kwargs, properties=properties, **kw) # default behavior is to set a property # -- use setdefault+update in order to collapse 'anonymous' nested # parameters correctly if self.name: d = properties.setdefault(self.name, {}) else: # if there's no name, collapse this nest all the way d = properties d.update(kwargs[self.fullName]) class AnyPropertyParameter(NestedParameter): """A generic property parameter, where both the name and value of the property must be given.""" type = NestedParameter.type + ["any"] def __init__(self, name, **kw): fields = [ StringParameter(name='name', label="Name:"), StringParameter(name='value', label="Value:"), ] NestedParameter.__init__(self, name, label='', fields=fields, **kw) def getFromKwargs(self, kwargs): raise ValidationError("AnyPropertyParameter can only be used by properties") def updateFromKwargs(self, master, properties, kwargs, **kw): self.collectChildProperties(master=master, properties=properties, kwargs=kwargs, **kw) pname = kwargs[self.fullName].get("name", "") pvalue = kwargs[self.fullName].get("value", "") if not pname: return validation = master.config.validation pname_validate = validation['property_name'] pval_validate = validation['property_value'] if not pname_validate.match(pname) \ or not pval_validate.match(pvalue): raise ValidationError("bad property name='%s', value='%s'" % (pname, pvalue)) properties[pname] = pvalue class CodebaseParameter(NestedParameter): """A parameter whose result is a codebase specification instead of a property""" type = NestedParameter.type + ["codebase"] codebase = '' def __init__(self, codebase, name=None, label=None, branch=DefaultField, revision=DefaultField, repository=DefaultField, project=DefaultField, **kwargs): """ A set of properties that will be used to generate a codebase dictionary. The branch/revision/repository/project should each be a parameter that will map to the corresponding value in the sourcestamp. Use None to disable the field. @param codebase: name of the codebase; used as key for the sourcestamp set @type codebase: unicode @param name: optional override for the name-currying for the subfields @type codebase: unicode @param label: optional override for the label for this set of parameters @type codebase: unicode """ name = name or codebase if label is None and codebase: label = "Codebase: " + codebase if branch is DefaultField: branch = StringParameter(name='branch', label="Branch:") if revision is DefaultField: revision = StringParameter(name='revision', label="Revision:") if repository is DefaultField: repository = StringParameter(name='repository', label="Repository:") if project is DefaultField: project = StringParameter(name='project', label="Project:") fields = filter(None, [branch, revision, repository, project]) NestedParameter.__init__(self, name=name, label=label, codebase=codebase, fields=fields, **kwargs) def createSourcestamp(self, properties, kwargs): # default, just return the things we put together return kwargs.get(self.fullName, {}) def updateFromKwargs(self, sourcestamps, kwargs, properties, **kw): self.collectChildProperties(sourcestamps=sourcestamps, properties=properties, kwargs=kwargs, **kw) # convert the "property" to a sourcestamp ss = self.createSourcestamp(properties, kwargs) if ss is not None: sourcestamps[self.codebase] = ss class ForceScheduler(base.BaseScheduler): """ ForceScheduler implements the backend for a UI to allow customization of builds. For example, a web form be populated to trigger a build. """ compare_attrs = ( 'name', 'builderNames', 'reason', 'username', 'forcedProperties' ) def __init__(self, name, builderNames, username=UserNameParameter(), reason=StringParameter(name="reason", default="force build", length=20), codebases=None, properties=[ NestedParameter(name='', fields=[ AnyPropertyParameter("property1"), AnyPropertyParameter("property2"), AnyPropertyParameter("property3"), AnyPropertyParameter("property4"), ]) ], # deprecated; use 'codebase' instead branch=None, revision=None, repository=None, project=None ): """ Initialize a ForceScheduler. The UI will provide a set of fields to the user; these fields are driven by a corresponding child class of BaseParameter. Use NestedParameter to provide logical groupings for parameters. The branch/revision/repository/project fields are deprecated and provided only for backwards compatibility. Using a Codebase(name='') will give the equivalent behavior. @param name: name of this scheduler (used as a key for state) @type name: unicode @param builderNames: list of builders this scheduler may start @type builderNames: list of unicode @param username: the "owner" for a build (may not be shown depending on the Auth configuration for the master) @type username: BaseParameter @param reason: the "reason" for a build @type reason: BaseParameter @param codebases: the codebases for a build @type codebases: list of string's or CodebaseParameter's; None will generate a default, but [] will remove all codebases @param properties: extra properties to configure the build @type properties: list of BaseParameter's """ if not self.checkIfType(name, str): config.error("ForceScheduler name must be a unicode string: %r" % name) if not name: config.error("ForceScheduler name must not be empty: %r " % name) if not self.checkIfListOfType(builderNames, str): config.error("ForceScheduler builderNames must be a list of strings: %r" % builderNames) if self.checkIfType(reason, BaseParameter): self.reason = reason else: config.error("ForceScheduler reason must be a StringParameter: %r" % reason) if not self.checkIfListOfType(properties, BaseParameter): config.error("ForceScheduler properties must be a list of BaseParameters: %r" % properties) if self.checkIfType(username, BaseParameter): self.username = username else: config.error("ForceScheduler username must be a StringParameter: %r" % username) self.forcedProperties = [] if any((branch, revision, repository, project)): if codebases: config.error("ForceScheduler: Must either specify 'codebases' or the 'branch/revision/repository/project' parameters: %r " % (codebases,)) codebases = [ CodebaseParameter(codebase='', branch=branch or DefaultField, revision=revision or DefaultField, repository=repository or DefaultField, project=project or DefaultField, ) ] # Use the default single codebase form if none are provided if codebases is None: codebases =[CodebaseParameter(codebase='')] elif not codebases: config.error("ForceScheduler: 'codebases' cannot be empty; use CodebaseParameter(codebase='', hide=True) if needed: %r " % (codebases,)) codebase_dict = {} for codebase in codebases: if isinstance(codebase, basestring): codebase = CodebaseParameter(codebase=codebase) elif not isinstance(codebase, CodebaseParameter): config.error("ForceScheduler: 'codebases' must be a list of strings or CodebaseParameter objects: %r" % (codebases,)) self.forcedProperties.append(codebase) codebase_dict[codebase.codebase] = dict(branch='',repository='',revision='') base.BaseScheduler.__init__(self, name=name, builderNames=builderNames, properties={}, codebases=codebase_dict) if properties: self.forcedProperties.extend(properties) # this is used to simplify the template self.all_fields = [ NestedParameter(name='', fields=[username, reason]) ] self.all_fields.extend(self.forcedProperties) def checkIfType(self, obj, chkType): return isinstance(obj, chkType) def checkIfListOfType(self, obj, chkType): isListOfType = True if self.checkIfType(obj, list): for item in obj: if not self.checkIfType(item, chkType): isListOfType = False break else: isListOfType = False return isListOfType def startService(self): pass def stopService(self): pass @defer.inlineCallbacks def gatherPropertiesAndChanges(self, **kwargs): properties = {} changeids = [] sourcestamps = {} for param in self.forcedProperties: yield defer.maybeDeferred(param.updateFromKwargs, master=self.master, properties=properties, changes=changeids, sourcestamps=sourcestamps, kwargs=kwargs) changeids = map(lambda a: type(a)==int and a or a.number, changeids) real_properties = Properties() for pname, pvalue in properties.items(): real_properties.setProperty(pname, pvalue, "Force Build Form") defer.returnValue((real_properties, changeids, sourcestamps)) @defer.inlineCallbacks def force(self, owner, builderNames=None, **kwargs): """ We check the parameters, and launch the build, if everything is correct """ if builderNames is None: builderNames = self.builderNames else: builderNames = set(builderNames).intersection(self.builderNames) if not builderNames: defer.returnValue(None) return # Currently the validation code expects all kwargs to be lists # I don't want to refactor that now so much sure we comply... kwargs = dict((k, [v]) if not isinstance(v, list) else (k,v) for k,v in kwargs.items()) # probably need to clean that out later as the IProperty is already a # validation mechanism reason = self.reason.getFromKwargs(kwargs) if owner is None: owner = self.username.getFromKwargs(kwargs) properties, changeids, sourcestamps = yield self.gatherPropertiesAndChanges(**kwargs) properties.setProperty("reason", reason, "Force Build Form") properties.setProperty("owner", owner, "Force Build Form") r = ("A build was forced by '%s': %s" % (owner, reason)) # everything is validated, we can create our source stamp, and buildrequest res = yield self.addBuildsetForSourceStampSetDetails( reason = r, sourcestamps = sourcestamps, properties = properties, builderNames = builderNames, ) defer.returnValue(res) buildbot-0.8.8/buildbot/schedulers/manager.py000066400000000000000000000072151222546025000212670ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.internet import defer from twisted.application import service from twisted.python import log, reflect from buildbot.process import metrics from buildbot import config, util class SchedulerManager(config.ReconfigurableServiceMixin, service.MultiService): def __init__(self, master): service.MultiService.__init__(self) self.setName('scheduler_manager') self.master = master @defer.inlineCallbacks def reconfigService(self, new_config): timer = metrics.Timer("SchedulerManager.reconfigService") timer.start() old_by_name = dict((sch.name, sch) for sch in self) old_set = set(old_by_name.iterkeys()) new_by_name = new_config.schedulers new_set = set(new_by_name.iterkeys()) removed_names, added_names = util.diffSets(old_set, new_set) # find any schedulers that don't know how to reconfig, and, if they # have changed, add them to both removed and added, so that we # run the new version. While we're at it, find any schedulers whose # fully qualified class name has changed, and consider those a removal # and re-add as well. for n in old_set & new_set: old = old_by_name[n] new = new_by_name[n] # detect changed class name if reflect.qual(old.__class__) != reflect.qual(new.__class__): removed_names.add(n) added_names.add(n) # compare using ComparableMixin if they don't support reconfig elif not hasattr(old, 'reconfigService'): if old != new: removed_names.add(n) added_names.add(n) # removals first for sch_name in removed_names: log.msg("removing scheduler '%s'" % (sch_name,)) sch = old_by_name[sch_name] yield defer.maybeDeferred(lambda : sch.disownServiceParent()) sch.master = None # .. then additions for sch_name in added_names: log.msg("adding scheduler '%s'" % (sch_name,)) sch = new_by_name[sch_name] # get the scheduler's objectid class_name = '%s.%s' % (sch.__class__.__module__, sch.__class__.__name__) objectid = yield self.master.db.state.getObjectId( sch.name, class_name) # set up the scheduler sch.objectid = objectid sch.master = self.master # *then* attacah and start it sch.setServiceParent(self) metrics.MetricCountEvent.log("num_schedulers", len(list(self)), absolute=True) # reconfig any newly-added schedulers, as well as existing yield config.ReconfigurableServiceMixin.reconfigService(self, new_config) timer.stop() buildbot-0.8.8/buildbot/schedulers/timed.py000066400000000000000000000400541222546025000207550ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from buildbot import util from buildbot.interfaces import ITriggerableScheduler from buildbot.process import buildstep, properties from buildbot.schedulers import base from twisted.internet import defer, reactor from twisted.python import log from buildbot import config from buildbot.changes import filter # Import croniter if available. # This is only required for Nightly schedulers, # so fail gracefully if it isn't present. try: from buildbot.util import croniter except ImportError: # Pyflakes doesn't like a redefinition here # Instead, we check if croniter is defined when we need it pass class Timed(base.BaseScheduler): """ Parent class for timed schedulers. This takes care of the (surprisingly subtle) mechanics of ensuring that each timed actuation runs to completion before the service stops. """ compare_attrs = base.BaseScheduler.compare_attrs def __init__(self, name, builderNames, properties={}, **kwargs): base.BaseScheduler.__init__(self, name, builderNames, properties, **kwargs) # tracking for when to start the next build self.lastActuated = None # A lock to make sure that each actuation occurs without interruption. # This lock governs actuateAt, actuateAtTimer, and actuateOk self.actuationLock = defer.DeferredLock() self.actuateOk = False self.actuateAt = None self.actuateAtTimer = None self._reactor = reactor # patched by tests def startService(self): base.BaseScheduler.startService(self) # no need to lock this; nothing else can run before the service is started self.actuateOk = True # get the scheduler's last_build time (note: only done at startup) d = self.getState('last_build', None) def set_last(lastActuated): self.lastActuated = lastActuated d.addCallback(set_last) # schedule the next build d.addCallback(lambda _ : self.scheduleNextBuild()) # give subclasses a chance to start up d.addCallback(lambda _ : self.startTimedSchedulerService()) # startService does not return a Deferred, so handle errors with a traceback d.addErrback(log.err, "while initializing %s '%s'" % (self.__class__.__name__, self.name)) def startTimedSchedulerService(self): """Hook for subclasses to participate in the L{startService} process; can return a Deferred""" def stopService(self): # shut down any pending actuation, and ensure that we wait for any # current actuation to complete by acquiring the lock. This ensures # that no build will be scheduled after stopService is complete. def stop_actuating(): self.actuateOk = False self.actuateAt = None if self.actuateAtTimer: self.actuateAtTimer.cancel() self.actuateAtTimer = None d = self.actuationLock.run(stop_actuating) # and chain to the parent class d.addCallback(lambda _ : base.BaseScheduler.stopService(self)) return d ## Scheduler methods def getPendingBuildTimes(self): # take the latest-calculated value of actuateAt as a reasonable # estimate return [ self.actuateAt ] ## Timed methods def startBuild(self): """The time has come to start a new build. Returns a Deferred. Override in subclasses.""" raise NotImplementedError def getNextBuildTime(self, lastActuation): """ Called by to calculate the next time to actuate a BuildSet. Override in subclasses. To trigger a fresh call to this method, use L{rescheduleNextBuild}. @param lastActuation: the time of the last actuation, or None for never @returns: a Deferred firing with the next time a build should occur (in the future), or None for never. """ raise NotImplementedError def scheduleNextBuild(self): """ Schedule the next build, re-invoking L{getNextBuildTime}. This can be called at any time, and it will avoid contention with builds being started concurrently. @returns: Deferred """ return self.actuationLock.run(self._scheduleNextBuild_locked) ## utilities def now(self): "Similar to util.now, but patchable by tests" return util.now(self._reactor) def _scheduleNextBuild_locked(self): # clear out the existing timer if self.actuateAtTimer: self.actuateAtTimer.cancel() self.actuateAtTimer = None # calculate the new time d = self.getNextBuildTime(self.lastActuated) # set up the new timer def set_timer(actuateAt): now = self.now() self.actuateAt = max(actuateAt, now) if actuateAt is not None: untilNext = self.actuateAt - now if untilNext == 0: log.msg(("%s: missed scheduled build time, so building " "immediately") % self.name) self.actuateAtTimer = self._reactor.callLater(untilNext, self._actuate) d.addCallback(set_timer) return d def _actuate(self): # called from the timer when it's time to start a build self.actuateAtTimer = None self.lastActuated = self.actuateAt @defer.inlineCallbacks def set_state_and_start(): # bail out if we shouldn't be actuating anymore if not self.actuateOk: return # mark the last build time self.actuateAt = None yield self.setState('last_build', self.lastActuated) # start the build yield self.startBuild() # schedule the next build (noting the lock is already held) yield self._scheduleNextBuild_locked() d = self.actuationLock.run(set_state_and_start) # this function can't return a deferred, so handle any failures via # log.err d.addErrback(log.err, 'while actuating') class Periodic(Timed): compare_attrs = Timed.compare_attrs + ('periodicBuildTimer', 'branch',) def __init__(self, name, builderNames, periodicBuildTimer, branch=None, properties={}, onlyImportant=False): Timed.__init__(self, name=name, builderNames=builderNames, properties=properties) if periodicBuildTimer <= 0: config.error( "periodicBuildTimer must be positive") self.periodicBuildTimer = periodicBuildTimer self.branch = branch self.reason = "The Periodic scheduler named '%s' triggered this build" % self.name def getNextBuildTime(self, lastActuated): if lastActuated is None: return defer.succeed(self.now()) # meaning "ASAP" else: return defer.succeed(lastActuated + self.periodicBuildTimer) def startBuild(self): return self.addBuildsetForLatest(reason=self.reason, branch=self.branch) class NightlyBase(Timed): compare_attrs = (Timed.compare_attrs + ('minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek')) def __init__(self, name, builderNames, minute=0, hour='*', dayOfMonth='*', month='*', dayOfWeek='*', properties={}, codebases=base.BaseScheduler.DefaultCodebases): Timed.__init__(self, name=name, builderNames=builderNames, properties=properties, codebases=codebases) self.minute = minute self.hour = hour self.dayOfMonth = dayOfMonth self.month = month self.dayOfWeek = dayOfWeek try: croniter except NameError: config.error("python-dateutil required for scheduler %s '%s'." % (self.__class__.__name__, self.name)) def _timeToCron(self, time, isDayOfWeek = False): if isinstance(time, int): if isDayOfWeek: time = (time + 1) % 7 # Convert from Mon = 0 format to Sun = 0 format for use in croniter return time if isinstance(time, basestring): return time if isDayOfWeek: time = [ (t + 1) % 7 for t in time ] # Conversion for croniter (see above) return ','.join([ str(s) for s in time ]) # Convert the list to a string def getNextBuildTime(self, lastActuated): dateTime = lastActuated or self.now() sched = '%s %s %s %s %s' % (self._timeToCron(self.minute), self._timeToCron(self.hour), self._timeToCron(self.dayOfMonth), self._timeToCron(self.month), self._timeToCron(self.dayOfWeek, True)) cron = croniter.croniter(sched, dateTime) nextdate = cron.get_next(float) return defer.succeed(nextdate) class Nightly(NightlyBase): compare_attrs = (NightlyBase.compare_attrs + ('branch', 'onlyIfChanged', 'fileIsImportant', 'change_filter', 'onlyImportant',)) class NoBranch: pass def __init__(self, name, builderNames, minute=0, hour='*', dayOfMonth='*', month='*', dayOfWeek='*', branch=NoBranch, fileIsImportant=None, onlyIfChanged=False, properties={}, change_filter=None, onlyImportant=False, codebases = base.BaseScheduler.DefaultCodebases): NightlyBase.__init__(self, name=name, builderNames=builderNames, minute=minute, hour=hour, dayOfWeek=dayOfWeek, dayOfMonth=dayOfMonth, properties=properties, codebases=codebases) # If True, only important changes will be added to the buildset. self.onlyImportant = onlyImportant if fileIsImportant and not callable(fileIsImportant): config.error( "fileIsImportant must be a callable") if branch is Nightly.NoBranch: config.error( "Nightly parameter 'branch' is required") self.branch = branch self.onlyIfChanged = onlyIfChanged self.fileIsImportant = fileIsImportant self.change_filter = filter.ChangeFilter.fromSchedulerConstructorArgs( change_filter=change_filter) self.reason = "The Nightly scheduler named '%s' triggered this build" % self.name def startTimedSchedulerService(self): if self.onlyIfChanged: return self.startConsumingChanges(fileIsImportant=self.fileIsImportant, change_filter=self.change_filter, onlyImportant=self.onlyImportant) else: return self.master.db.schedulers.flushChangeClassifications(self.objectid) def gotChange(self, change, important): # both important and unimportant changes on our branch are recorded, as # we will include all such changes in any buildsets we start. Note # that we must check the branch here because it is not included in the # change filter. if change.branch != self.branch: return defer.succeed(None) # don't care about this change return self.master.db.schedulers.classifyChanges( self.objectid, { change.number : important }) @defer.inlineCallbacks def startBuild(self): scheds = self.master.db.schedulers # if onlyIfChanged is True, then we will skip this build if no # important changes have occurred since the last invocation if self.onlyIfChanged: classifications = \ yield scheds.getChangeClassifications(self.objectid) # see if we have any important changes for imp in classifications.itervalues(): if imp: break else: log.msg(("Nightly Scheduler <%s>: skipping build " + "- No important changes on configured branch") % self.name) return changeids = sorted(classifications.keys()) yield self.addBuildsetForChanges(reason=self.reason, changeids=changeids) max_changeid = changeids[-1] # (changeids are sorted) yield scheds.flushChangeClassifications(self.objectid, less_than=max_changeid+1) else: # start a build of the latest revision, whatever that is yield self.addBuildsetForLatest(reason=self.reason, branch=self.branch) class NightlyTriggerable(NightlyBase): implements(ITriggerableScheduler) def __init__(self, name, builderNames, minute=0, hour='*', dayOfMonth='*', month='*', dayOfWeek='*', properties={}, codebases=base.BaseScheduler.DefaultCodebases): NightlyBase.__init__(self, name=name, builderNames=builderNames, minute=minute, hour=hour, dayOfWeek=dayOfWeek, dayOfMonth=dayOfMonth, properties=properties, codebases=codebases) self._lastTrigger = None self.reason = "The NightlyTriggerable scheduler named '%s' triggered this build" % self.name def startService(self): NightlyBase.startService(self) # get the scheduler's lastTrigger time (note: only done at startup) d = self.getState('lastTrigger', None) def setLast(lastTrigger): try: if lastTrigger: assert isinstance(lastTrigger[0], dict) self._lastTrigger = (lastTrigger[0], properties.Properties.fromDict(lastTrigger[1])) except: # If the lastTrigger isn't of the right format, ignore it log.msg("NightlyTriggerable Scheduler <%s>: bad lastTrigger: %r" % (self.name, lastTrigger)) d.addCallback(setLast) def trigger(self, sourcestamps, set_props=None): """Trigger this scheduler with the given sourcestamp ID. Returns a deferred that will fire when the buildset is finished.""" self._lastTrigger = (sourcestamps, set_props) # record the trigger in the db if set_props: propsDict = set_props.asDict() else: propsDict = {} d = self.setState('lastTrigger', (sourcestamps, propsDict)) ## Trigger expects a callback with the success of the triggered ## build, if waitForFinish is True. ## Just return SUCCESS, to indicate that the trigger was succesful, ## don't want for the nightly to run. return d.addCallback(lambda _: buildstep.SUCCESS) @defer.inlineCallbacks def startBuild(self): if self._lastTrigger is None: defer.returnValue(None) (sourcestamps, set_props) = self._lastTrigger self._lastTrigger = None yield self.setState('lastTrigger', None) # properties for this buildset are composed of our own properties, # potentially overridden by anything from the triggering build props = properties.Properties() props.updateFromProperties(self.properties) if set_props: props.updateFromProperties(set_props) yield self.addBuildsetForSourceStampSetDetails(reason=self.reason, sourcestamps=sourcestamps, properties=props) buildbot-0.8.8/buildbot/schedulers/triggerable.py000066400000000000000000000073551222546025000221510ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from twisted.python import failure from twisted.internet import defer from buildbot.interfaces import ITriggerableScheduler from buildbot.schedulers import base from buildbot.process.properties import Properties class Triggerable(base.BaseScheduler): implements(ITriggerableScheduler) compare_attrs = base.BaseScheduler.compare_attrs def __init__(self, name, builderNames, properties={}, **kwargs): base.BaseScheduler.__init__(self, name, builderNames, properties, **kwargs) self._waiters = {} self._bsc_subscription = None self.reason = "Triggerable(%s)" % name def trigger(self, sourcestamps = None, set_props=None): """Trigger this scheduler with the optional given list of sourcestamps Returns a deferred that will fire when the buildset is finished.""" # properties for this buildset are composed of our own properties, # potentially overridden by anything from the triggering build props = Properties() props.updateFromProperties(self.properties) if set_props: props.updateFromProperties(set_props) # note that this does not use the buildset subscriptions mechanism, as # the duration of interest to the caller is bounded by the lifetime of # this process. d = self.addBuildsetForSourceStampSetDetails(self.reason, sourcestamps, props) def setup_waiter((bsid,brids)): d = defer.Deferred() self._waiters[bsid] = (d, brids) self._updateWaiters() return d d.addCallback(setup_waiter) return d def stopService(self): # cancel any outstanding subscription if self._bsc_subscription: self._bsc_subscription.unsubscribe() self._bsc_subscription = None # and errback any outstanding deferreds if self._waiters: msg = 'Triggerable scheduler stopped before build was complete' for d, brids in self._waiters.values(): d.errback(failure.Failure(RuntimeError(msg))) self._waiters = {} return base.BaseScheduler.stopService(self) def _updateWaiters(self): if self._waiters and not self._bsc_subscription: self._bsc_subscription = \ self.master.subscribeToBuildsetCompletions( self._buildsetComplete) elif not self._waiters and self._bsc_subscription: self._bsc_subscription.unsubscribe() self._bsc_subscription = None def _buildsetComplete(self, bsid, result): if bsid not in self._waiters: return # pop this bsid from the waiters list, and potentially unsubscribe # from completion notifications d, brids = self._waiters.pop(bsid) self._updateWaiters() # fire the callback to indicate that the triggered build is complete d.callback((result, brids)) buildbot-0.8.8/buildbot/schedulers/trysched.py000066400000000000000000000263501222546025000215030ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os from twisted.internet import defer from twisted.python import log from twisted.protocols import basic from buildbot import pbutil from buildbot.util.maildir import MaildirService from buildbot.util import json from buildbot.util import netstrings from buildbot.process.properties import Properties from buildbot.schedulers import base from buildbot.status.buildset import BuildSetStatus class TryBase(base.BaseScheduler): def filterBuilderList(self, builderNames): """ Make sure that C{builderNames} is a subset of the configured C{self.builderNames}, returning an empty list if not. If C{builderNames} is empty, use C{self.builderNames}. @returns: list of builder names to build on """ # self.builderNames is the configured list of builders # available for try. If the user supplies a list of builders, # it must be restricted to the configured list. If not, build # on all of the configured builders. if builderNames: for b in builderNames: if not b in self.builderNames: log.msg("%s got with builder %s" % (self, b)) log.msg(" but that wasn't in our list: %s" % (self.builderNames,)) return [] else: builderNames = self.builderNames return builderNames class BadJobfile(Exception): pass class JobdirService(MaildirService): # NOTE: tightly coupled with Try_Jobdir, below def messageReceived(self, filename): f = self.moveToCurDir(filename) return self.parent.handleJobFile(filename, f) class Try_Jobdir(TryBase): compare_attrs = TryBase.compare_attrs + ('jobdir',) def __init__(self, name, builderNames, jobdir, properties={}): TryBase.__init__(self, name=name, builderNames=builderNames, properties=properties) self.jobdir = jobdir self.watcher = JobdirService() self.watcher.setServiceParent(self) def startService(self): # set the watcher's basedir now that we have a master jobdir = os.path.join(self.master.basedir, self.jobdir) self.watcher.setBasedir(jobdir) for subdir in "cur new tmp".split(): if not os.path.exists(os.path.join(jobdir, subdir)): os.mkdir(os.path.join(jobdir, subdir)) TryBase.startService(self) def parseJob(self, f): # jobfiles are serialized build requests. Each is a list of # serialized netstrings, in the following order: # format version number: # "1" the original # "2" introduces project and repository # "3" introduces who # "4" introduces comment # "5" introduces properties and JSON serialization of values after # version # jobid: arbitrary string, used to find the buildSet later # branch: branch name, "" for default-branch # baserev: revision, "" for HEAD # patch_level: usually "1" # patch_body: patch to be applied for build # repository # project # who: user requesting build # comment: comment from user about diff and/or build # builderNames: list of builder names # properties: dict of build properties p = netstrings.NetstringParser() f.seek(0,2) if f.tell() > basic.NetstringReceiver.MAX_LENGTH: raise BadJobfile("The patch size is greater that NetStringReceiver.MAX_LENGTH. Please Set this higher in the master.cfg") f.seek(0,0) try: p.feed(f.read()) except basic.NetstringParseError: raise BadJobfile("unable to parse netstrings") if not p.strings: raise BadJobfile("could not find any complete netstrings") ver = p.strings.pop(0) v1_keys = ['jobid', 'branch', 'baserev', 'patch_level', 'patch_body'] v2_keys = v1_keys + ['repository', 'project'] v3_keys = v2_keys + ['who'] v4_keys = v3_keys + ['comment'] keys = [v1_keys, v2_keys, v3_keys, v4_keys] # v5 introduces properties and uses JSON serialization parsed_job = {} def extract_netstrings(p, keys): for i, key in enumerate(keys): parsed_job[key] = p.strings[i] def postprocess_parsed_job(): # apply defaults and handle type casting parsed_job['branch'] = parsed_job['branch'] or None parsed_job['baserev'] = parsed_job['baserev'] or None parsed_job['patch_level'] = int(parsed_job['patch_level']) for key in 'repository project who comment'.split(): parsed_job[key] = parsed_job.get(key, '') parsed_job['properties'] = parsed_job.get('properties', {}) if ver <= "4": i = int(ver) - 1 extract_netstrings(p, keys[i]) parsed_job['builderNames'] = p.strings[len(keys[i]):] postprocess_parsed_job() elif ver == "5": try: parsed_job = json.loads(p.strings[0]) except ValueError: raise BadJobfile("unable to parse JSON") postprocess_parsed_job() else: raise BadJobfile("unknown version '%s'" % ver) return parsed_job def handleJobFile(self, filename, f): try: parsed_job = self.parseJob(f) builderNames = parsed_job['builderNames'] except BadJobfile: log.msg("%s reports a bad jobfile in %s" % (self, filename)) log.err() return defer.succeed(None) # Validate/fixup the builder names. builderNames = self.filterBuilderList(builderNames) if not builderNames: log.msg( "incoming Try job did not specify any allowed builder names") return defer.succeed(None) who = "" if parsed_job['who']: who = parsed_job['who'] comment = "" if parsed_job['comment']: comment = parsed_job['comment'] d = self.master.db.sourcestampsets.addSourceStampSet() def addsourcestamp(setid): self.master.db.sourcestamps.addSourceStamp( sourcestampsetid=setid, branch=parsed_job['branch'], revision=parsed_job['baserev'], patch_body=parsed_job['patch_body'], patch_level=parsed_job['patch_level'], patch_author=who, patch_comment=comment, patch_subdir='', # TODO: can't set this remotely - #1769 project=parsed_job['project'], repository=parsed_job['repository']) return setid d.addCallback(addsourcestamp) def create_buildset(setid): reason = "'try' job" if parsed_job['who']: reason += " by user %s" % parsed_job['who'] properties = parsed_job['properties'] requested_props = Properties() requested_props.update(properties, "try build") return self.addBuildsetForSourceStamp( ssid=None, setid=setid, reason=reason, external_idstring=parsed_job['jobid'], builderNames=builderNames, properties=requested_props) d.addCallback(create_buildset) return d class Try_Userpass_Perspective(pbutil.NewCredPerspective): def __init__(self, scheduler, username): self.scheduler = scheduler self.username = username @defer.inlineCallbacks def perspective_try(self, branch, revision, patch, repository, project, builderNames, who="", comment="", properties={}): db = self.scheduler.master.db log.msg("user %s requesting build on builders %s" % (self.username, builderNames)) # build the intersection of the request and our configured list builderNames = self.scheduler.filterBuilderList(builderNames) if not builderNames: return reason = "'try' job" if who: reason += " by user %s" % who if comment: reason += " (%s)" % comment sourcestampsetid = yield db.sourcestampsets.addSourceStampSet() yield db.sourcestamps.addSourceStamp( branch=branch, revision=revision, repository=repository, project=project, patch_level=patch[0], patch_body=patch[1], patch_subdir='', patch_author=who or '', patch_comment=comment or '', sourcestampsetid=sourcestampsetid) # note: no way to specify patch subdir - #1769 requested_props = Properties() requested_props.update(properties, "try build") (bsid, brids) = yield self.scheduler.addBuildsetForSourceStamp( setid=sourcestampsetid, reason=reason, properties=requested_props, builderNames=builderNames) # return a remotely-usable BuildSetStatus object bsdict = yield db.buildsets.getBuildset(bsid) bss = BuildSetStatus(bsdict, self.scheduler.master.status) from buildbot.status.client import makeRemote defer.returnValue(makeRemote(bss)) def perspective_getAvailableBuilderNames(self): # Return a list of builder names that are configured # for the try service # This is mostly intended for integrating try services # into other applications return self.scheduler.listBuilderNames() class Try_Userpass(TryBase): compare_attrs = ('name', 'builderNames', 'port', 'userpass', 'properties') def __init__(self, name, builderNames, port, userpass, properties={}): TryBase.__init__(self, name=name, builderNames=builderNames, properties=properties) self.port = port self.userpass = userpass def startService(self): TryBase.startService(self) # register each user/passwd with the pbmanager def factory(mind, username): return Try_Userpass_Perspective(self, username) self.registrations = [] for user, passwd in self.userpass: self.registrations.append( self.master.pbmanager.register( self.port, user, passwd, factory)) def stopService(self): d = defer.maybeDeferred(TryBase.stopService, self) def unreg(_): return defer.gatherResults( [reg.unregister() for reg in self.registrations]) d.addCallback(unreg) return d buildbot-0.8.8/buildbot/scripts/000077500000000000000000000000001222546025000166245ustar00rootroot00000000000000buildbot-0.8.8/buildbot/scripts/__init__.py000066400000000000000000000000001222546025000207230ustar00rootroot00000000000000buildbot-0.8.8/buildbot/scripts/base.py000066400000000000000000000152761222546025000201230ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import copy import stat from twisted.python import usage, runtime def isBuildmasterDir(dir): def print_error(error_message): print "%s\ninvalid buildmaster directory '%s'" % (error_message, dir) buildbot_tac = os.path.join(dir, "buildbot.tac") try: contents = open(buildbot_tac).read() except IOError, exception: print_error("error reading '%s': %s" % \ (buildbot_tac, exception.strerror)) return False if "Application('buildmaster')" not in contents: print_error("unexpected content in '%s'" % buildbot_tac) return False return True def getConfigFileFromTac(basedir): # execute the .tac file to see if its configfile location exists tacFile = os.path.join(basedir, 'buildbot.tac') if os.path.exists(tacFile): # don't mess with the global namespace, but set __file__ for relocatable buildmasters tacGlobals = {'__file__' : tacFile} execfile(tacFile, tacGlobals) return tacGlobals.get("configfile", "master.cfg") else: return "master.cfg" class SubcommandOptions(usage.Options): # subclasses should set this to a list-of-lists in order to source the # .buildbot/options file. Note that this *only* works with optParameters, # not optFlags. Example: # buildbotOptions = [ [ 'optfile-name', 'parameter-name' ], .. ] buildbotOptions = None # set this to options that must have non-None values requiredOptions = [] def __init__(self, *args): # for options in self.buildbotOptions, optParameters, and the options # file, change the default in optParameters to the value in the options # file, call through to the constructor, and then change it back. # Options uses reflect.accumulateClassList, so this *must* be a class # attribute; however, we do not want to permanently change the class. # So we patch it temporarily and restore it after. cls = self.__class__ if hasattr(cls, 'optParameters'): old_optParameters = cls.optParameters cls.optParameters = op = copy.deepcopy(cls.optParameters) if self.buildbotOptions: optfile = self.optionsFile = self.loadOptionsFile() for optfile_name, option_name in self.buildbotOptions: for i in range(len(op)): if (op[i][0] == option_name and optfile_name in optfile): op[i] = list(op[i]) op[i][2] = optfile[optfile_name] usage.Options.__init__(self, *args) if hasattr(cls, 'optParameters'): cls.optParameters = old_optParameters def loadOptionsFile(self, _here=None): """Find the .buildbot/options file. Crawl from the current directory up towards the root, and also look in ~/.buildbot . The first directory that's owned by the user and has the file we're looking for wins. Windows skips the owned-by-user test. @rtype: dict @return: a dictionary of names defined in the options file. If no options file was found, return an empty dict. """ here = _here or os.path.abspath(os.getcwd()) if runtime.platformType == 'win32': # never trust env-vars, use the proper API from win32com.shell import shellcon, shell appdata = shell.SHGetFolderPath(0, shellcon.CSIDL_APPDATA, 0, 0) home = os.path.join(appdata, "buildbot") else: home = os.path.expanduser("~/.buildbot") searchpath = [] toomany = 20 while True: searchpath.append(os.path.join(here, ".buildbot")) next = os.path.dirname(here) if next == here: break # we've hit the root here = next toomany -= 1 # just in case if toomany == 0: print ("I seem to have wandered up into the infinite glories " "of the heavens. Oops.") break searchpath.append(home) localDict = {} for d in searchpath: if os.path.isdir(d): if runtime.platformType != 'win32': if os.stat(d)[stat.ST_UID] != os.getuid(): print "skipping %s because you don't own it" % d continue # security, skip other people's directories optfile = os.path.join(d, "options") if os.path.exists(optfile): try: with open(optfile, "r") as f: options = f.read() exec options in localDict except: print "error while reading %s" % optfile raise break for k in localDict.keys(): if k.startswith("__"): del localDict[k] return localDict def postOptions(self): missing = [ k for k in self.requiredOptions if self[k] is None ] if missing: if len(missing) > 1: msg = 'Required arguments missing: ' + ', '.join(missing) else: msg = 'Required argument missing: ' + missing[0] raise usage.UsageError(msg) class BasedirMixin(object): """SubcommandOptions Mixin to handle subcommands that take a basedir argument""" def parseArgs(self, *args): if len(args) > 0: self['basedir'] = args[0] else: # Use the current directory if no basedir was specified. self['basedir'] = os.getcwd() if len(args) > 1: raise usage.UsageError("I wasn't expecting so many arguments") def postOptions(self): # get an unambiguous, epxnaed basedir, including expanding '~', which # may be useful in a .buildbot/config file self['basedir'] = os.path.abspath(os.path.expanduser(self['basedir'])) buildbot-0.8.8/buildbot/scripts/buildbot_tac.tmpl000066400000000000000000000023721222546025000221610ustar00rootroot00000000000000import os from twisted.application import service from buildbot.master import BuildMaster {% if relocatable -%} basedir = '.' {% else -%} basedir = {{ basedir|repr }} {%- endif %} {% if not no_logrotate -%} rotateLength = {{ log_size|repr }} maxRotatedFiles = {{ log_count|repr }} {%- endif %} configfile = {{ config|repr }} # Default umask for server umask = None # if this is a relocatable tac file, get the directory containing the TAC if basedir == '.': import os.path basedir = os.path.abspath(os.path.dirname(__file__)) # note: this line is matched against to check that this is a buildmaster # directory; do not edit it. application = service.Application('buildmaster') {% if not no_logrotate -%} from twisted.python.logfile import LogFile from twisted.python.log import ILogObserver, FileLogObserver logfile = LogFile.fromFullPath(os.path.join(basedir, "twistd.log"), rotateLength=rotateLength, maxRotatedFiles=maxRotatedFiles) application.setComponent(ILogObserver, FileLogObserver(logfile).emit) {%- endif %} m = BuildMaster(basedir, configfile, umask) m.setServiceParent(application) {% if not no_logrotate -%} m.log_rotation.rotateLength = rotateLength m.log_rotation.maxRotatedFiles = maxRotatedFiles {%- endif %} buildbot-0.8.8/buildbot/scripts/checkconfig.py000066400000000000000000000033751222546025000214510ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sys import os from buildbot import config from buildbot.scripts.base import getConfigFileFromTac def _loadConfig(basedir, configFile, quiet): try: config.MasterConfig.loadConfig( basedir, configFile) except config.ConfigErrors, e: if not quiet: print >> sys.stderr, "Configuration Errors:" for e in e.errors: print >> sys.stderr, " " + e return 1 if not quiet: print "Config file is good!" return 0 def checkconfig(config): quiet = config.get('quiet') configFile = config.get('configFile') if os.path.isdir(configFile): basedir = configFile try: configFile = getConfigFileFromTac(basedir) except (SyntaxError, ImportError), e: if not quiet: print "Unable to load 'buildbot.tac' from '%s':" % basedir print e return 1 else: basedir = os.getcwd() return _loadConfig(basedir=basedir, configFile=configFile, quiet=quiet) __all__ = ['checkconfig'] buildbot-0.8.8/buildbot/scripts/create_master.py000066400000000000000000000123451222546025000220210ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import jinja2 from twisted.python import util from twisted.internet import defer from buildbot.util import in_reactor from buildbot.db import connector from buildbot.master import BuildMaster from buildbot import config as config_module from buildbot import monkeypatches def makeBasedir(config): if os.path.exists(config['basedir']): if not config['quiet']: print "updating existing installation" return if not config['quiet']: print "mkdir", config['basedir'] os.mkdir(config['basedir']) def makeTAC(config): # render buildbot_tac.tmpl using the config loader = jinja2.FileSystemLoader(os.path.dirname(__file__)) env = jinja2.Environment(loader=loader, undefined=jinja2.StrictUndefined) env.filters['repr'] = repr tpl = env.get_template('buildbot_tac.tmpl') cxt = dict((k.replace('-', '_'), v) for k,v in config.iteritems()) contents = tpl.render(cxt) tacfile = os.path.join(config['basedir'], "buildbot.tac") if os.path.exists(tacfile): with open(tacfile, "rt") as f: oldcontents = f.read() if oldcontents == contents: if not config['quiet']: print "buildbot.tac already exists and is correct" return if not config['quiet']: print "not touching existing buildbot.tac" print "creating buildbot.tac.new instead" tacfile += ".new" with open(tacfile, "wt") as f: f.write(contents) def makeSampleConfig(config): source = util.sibpath(__file__, "sample.cfg") target = os.path.join(config['basedir'], "master.cfg.sample") if not config['quiet']: print "creating %s" % target with open(source, "rt") as f: config_sample = f.read() if config['db']: config_sample = config_sample.replace('sqlite:///state.sqlite', config['db']) with open(target, "wt") as f: f.write(config_sample) os.chmod(target, 0600) def makePublicHtml(config): files = { 'bg_gradient.jpg' : "../status/web/files/bg_gradient.jpg", 'default.css' : "../status/web/files/default.css", 'robots.txt' : "../status/web/files/robots.txt", 'favicon.ico' : "../status/web/files/favicon.ico", } webdir = os.path.join(config['basedir'], "public_html") if os.path.exists(webdir): if not config['quiet']: print "public_html/ already exists: not replacing" return else: os.mkdir(webdir) if not config['quiet']: print "populating public_html/" for target, source in files.iteritems(): source = util.sibpath(__file__, source) target = os.path.join(webdir, target) with open(target, "wt") as f: with open(source, "rt") as i: f.write(i.read()) def makeTemplatesDir(config): files = { 'README.txt' : "../status/web/files/templates_readme.txt", } template_dir = os.path.join(config['basedir'], "templates") if os.path.exists(template_dir): if not config['quiet']: print "templates/ already exists: not replacing" return else: os.mkdir(template_dir) if not config['quiet']: print "populating templates/" for target, source in files.iteritems(): source = util.sibpath(__file__, source) target = os.path.join(template_dir, target) with open(target, "wt") as f: with open(source, "rt") as i: f.write(i.read()) @defer.inlineCallbacks def createDB(config, _noMonkey=False): # apply the db monkeypatches (and others - no harm) if not _noMonkey: # pragma: no cover monkeypatches.patch_all() # create a master with the default configuration, but with db_url # overridden master_cfg = config_module.MasterConfig() master_cfg.db['db_url'] = config['db'] master = BuildMaster(config['basedir']) master.config = master_cfg db = connector.DBConnector(master, config['basedir']) yield db.setup(check_version=False, verbose=not config['quiet']) if not config['quiet']: print "creating database (%s)" % (master_cfg.db['db_url'],) yield db.model.upgrade() @in_reactor @defer.inlineCallbacks def createMaster(config): makeBasedir(config) makeTAC(config) makeSampleConfig(config) makePublicHtml(config) makeTemplatesDir(config) yield createDB(config) if not config['quiet']: print "buildmaster configured in %s" % (config['basedir'],) defer.returnValue(0) buildbot-0.8.8/buildbot/scripts/debugclient.py000066400000000000000000000015321222546025000214640ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members def debugclient(config): from buildbot.clients import debug d = debug.DebugWidget(config['master'], config['passwd']) d.run() return 0 buildbot-0.8.8/buildbot/scripts/logwatcher.py000066400000000000000000000073011222546025000213360ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os from twisted.python.failure import Failure from twisted.internet import defer, reactor, protocol, error from twisted.protocols.basic import LineOnlyReceiver class FakeTransport: disconnecting = False class BuildmasterTimeoutError(Exception): pass class ReconfigError(Exception): pass class TailProcess(protocol.ProcessProtocol): def outReceived(self, data): self.lw.dataReceived(data) def errReceived(self, data): print "ERR: '%s'" % (data,) class LogWatcher(LineOnlyReceiver): POLL_INTERVAL = 0.1 TIMEOUT_DELAY = 10.0 delimiter = os.linesep def __init__(self, logfile): self.logfile = logfile self.in_reconfig = False self.transport = FakeTransport() self.pp = TailProcess() self.pp.lw = self self.timer = None def start(self): # If the log file doesn't exist, create it now. if not os.path.exists(self.logfile): open(self.logfile, 'a').close() # return a Deferred that fires when the reconfig process has # finished. It errbacks with TimeoutError if the finish line has not # been seen within 10 seconds, and with ReconfigError if the error # line was seen. If the logfile could not be opened, it errbacks with # an IOError. self.p = reactor.spawnProcess(self.pp, "/usr/bin/tail", ("tail", "-f", "-n", "0", self.logfile), env=os.environ, ) self.running = True d = defer.maybeDeferred(self._start) return d def _start(self): self.d = defer.Deferred() self.timer = reactor.callLater(self.TIMEOUT_DELAY, self.timeout) return self.d def timeout(self): self.timer = None e = BuildmasterTimeoutError() self.finished(Failure(e)) def finished(self, results): try: self.p.signalProcess("KILL") except error.ProcessExitedAlready: pass if self.timer: self.timer.cancel() self.timer = None self.running = False self.in_reconfig = False self.d.callback(results) def lineReceived(self, line): if not self.running: return if "Log opened." in line: self.in_reconfig = True if "beginning configuration update" in line: self.in_reconfig = True if self.in_reconfig: print line if "message from master: attached" in line: return self.finished("buildslave") if "reconfig aborted" in line or 'reconfig partially applied' in line: return self.finished(Failure(ReconfigError())) if "Server Shut Down" in line: return self.finished(Failure(ReconfigError())) if "configuration update complete" in line: return self.finished("buildmaster") if "BuildMaster is running" in line: return self.finished("buildmaster") buildbot-0.8.8/buildbot/scripts/reconfig.py000066400000000000000000000060621222546025000207760ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import signal import platform from twisted.internet import reactor from buildbot.scripts.logwatcher import LogWatcher, BuildmasterTimeoutError, \ ReconfigError from buildbot.util import in_reactor class Reconfigurator: rc = 0 def run(self, basedir, quiet): # Returns "Microsoft" for Vista and "Windows" for other versions if platform.system() in ("Windows", "Microsoft"): print "Reconfig (through SIGHUP) is not supported on Windows." print "The 'buildbot debugclient' tool can trigger a reconfig" print "remotely, but requires Gtk+ libraries to run." return with open(os.path.join(basedir, "twistd.pid"), "rt") as f: self.pid = int(f.read().strip()) if quiet: os.kill(self.pid, signal.SIGHUP) return # keep reading twistd.log. Display all messages between "loading # configuration from ..." and "configuration update complete" or # "I will keep using the previous config file instead.", or until # 10 seconds have elapsed. self.sent_signal = False reactor.callLater(0.2, self.sighup) lw = LogWatcher(os.path.join(basedir, "twistd.log")) d = lw.start() d.addCallbacks(self.success, self.failure) d.addBoth(lambda _ : self.rc) return d def sighup(self): if self.sent_signal: return print "sending SIGHUP to process %d" % self.pid self.sent_signal = True os.kill(self.pid, signal.SIGHUP) def success(self, res): print """ Reconfiguration appears to have completed successfully. """ def failure(self, why): self.rc = 1 if why.check(BuildmasterTimeoutError): print "Never saw reconfiguration finish." elif why.check(ReconfigError): print """ Reconfiguration failed. Please inspect the master.cfg file for errors, correct them, then try 'buildbot reconfig' again. """ elif why.check(IOError): # we were probably unable to open the file in the first place self.sighup() else: print "Error while following twistd.log: %s" % why @in_reactor def reconfig(config): basedir = config['basedir'] quiet = config['quiet'] r = Reconfigurator() return r.run(basedir, quiet) buildbot-0.8.8/buildbot/scripts/restart.py000066400000000000000000000021061222546025000206610ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement from buildbot.scripts import base, stop, start def restart(config): basedir = config['basedir'] quiet = config['quiet'] if not base.isBuildmasterDir(basedir): return 1 if stop.stop(config, wait=True) != 0: return 1 if not quiet: print "now restarting buildbot process.." return start.start(config) buildbot-0.8.8/buildbot/scripts/runner.py000066400000000000000000000662631222546025000205240ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement # N.B.: don't import anything that might pull in a reactor yet. Some of our # subcommands want to load modules that need the gtk reactor. # # Also don't forget to mirror your changes on command-line options in manual # pages and texinfo documentation. from twisted.python import usage, reflect import re import sys from buildbot.scripts import base # Note that the terms 'options' and 'config' are used interchangeably here - in # fact, they are interchanged several times. Caveat legator. def validateMasterOption(master): """ Validate master (-m, --master) command line option. Checks that option is a string of the 'hostname:port' form, otherwise raises an UsageError exception. @type master: string @param master: master option @raise usage.UsageError: on invalid master option """ try: hostname, port = master.split(":") port = int(port) except: raise usage.UsageError("master must have the form 'hostname:port'") class UpgradeMasterOptions(base.BasedirMixin, base.SubcommandOptions): subcommandFunction = "buildbot.scripts.upgrade_master.upgradeMaster" optFlags = [ ["quiet", "q", "Do not emit the commands being run"], ["replace", "r", "Replace any modified files without confirmation."], ] optParameters = [ ] def getSynopsis(self): return "Usage: buildbot upgrade-master [options] []" longdesc = """ This command takes an existing buildmaster working directory and adds/modifies the files there to work with the current version of buildbot. When this command is finished, the buildmaster directory should look much like a brand-new one created by the 'create-master' command. Use this after you've upgraded your buildbot installation and before you restart the buildmaster to use the new version. If you have modified the files in your working directory, this command will leave them untouched, but will put the new recommended contents in a .new file (for example, if index.html has been modified, this command will create index.html.new). You can then look at the new version and decide how to merge its contents into your modified file. When upgrading from a pre-0.8.0 release (which did not use a database), this command will create the given database and migrate data from the old pickle files into it, then move the pickle files out of the way (e.g. to changes.pck.old). When upgrading the database, this command uses the database specified in the master configuration file. If you wish to use a database other than the default (sqlite), be sure to set that parameter before upgrading. """ class CreateMasterOptions(base.BasedirMixin, base.SubcommandOptions): subcommandFunction = "buildbot.scripts.create_master.createMaster" optFlags = [ ["quiet", "q", "Do not emit the commands being run"], ["force", "f", "Re-use an existing directory (will not overwrite master.cfg file)"], ["relocatable", "r", "Create a relocatable buildbot.tac"], ["no-logrotate", "n", "Do not permit buildmaster rotate logs by itself"] ] optParameters = [ ["config", "c", "master.cfg", "name of the buildmaster config file"], ["log-size", "s", "10000000", "size at which to rotate twisted log files"], ["log-count", "l", "10", "limit the number of kept old twisted log files"], ["db", None, "sqlite:///state.sqlite", "which DB to use for scheduler/status state. See below for syntax."], ] def getSynopsis(self): return "Usage: buildbot create-master [options] []" longdesc = """ This command creates a buildmaster working directory and buildbot.tac file. The master will live in and create various files there. If --relocatable is given, then the resulting buildbot.tac file will be written such that its containing directory is assumed to be the basedir. This is generally a good idea. At runtime, the master will read a configuration file (named 'master.cfg' by default) in its basedir. This file should contain python code which eventually defines a dictionary named 'BuildmasterConfig'. The elements of this dictionary are used to configure the Buildmaster. See doc/config.xhtml for details about what can be controlled through this interface. The --db string is evaluated to build the DB object, which specifies which database the buildmaster should use to hold scheduler state and status information. The default (which creates an SQLite database in BASEDIR/state.sqlite) is equivalent to: --db='sqlite:///state.sqlite' To use a remote MySQL database instead, use something like: --db='mysql://bbuser:bbpasswd@dbhost/bbdb' The --db string is stored verbatim in the buildbot.tac file, and evaluated as 'buildbot start' time to pass a DBConnector instance into the newly-created BuildMaster object. """ def postOptions(self): base.BasedirMixin.postOptions(self) if not re.match('^\d+$', self['log-size']): raise usage.UsageError("log-size parameter needs to be an int") if not re.match('^\d+$', self['log-count']) and \ self['log-count'] != 'None': raise usage.UsageError("log-count parameter needs to be an int "+ " or None") class StopOptions(base.BasedirMixin, base.SubcommandOptions): subcommandFunction = "buildbot.scripts.stop.stop" optFlags = [ ["quiet", "q", "Do not emit the commands being run"], ["clean", "c", "Clean shutdown master"], ] def getSynopsis(self): return "Usage: buildbot stop []" class RestartOptions(base.BasedirMixin, base.SubcommandOptions): subcommandFunction = "buildbot.scripts.restart.restart" optFlags = [ ['quiet', 'q', "Don't display startup log messages"], ['nodaemon', None, "Don't daemonize (stay in foreground)"], ["clean", "c", "Clean shutdown master"], ] def getSynopsis(self): return "Usage: buildbot restart []" class StartOptions(base.BasedirMixin, base.SubcommandOptions): subcommandFunction = "buildbot.scripts.start.start" optFlags = [ ['quiet', 'q', "Don't display startup log messages"], ['nodaemon', None, "Don't daemonize (stay in foreground)"], ] def getSynopsis(self): return "Usage: buildbot start []" class ReconfigOptions(base.BasedirMixin, base.SubcommandOptions): subcommandFunction = "buildbot.scripts.reconfig.reconfig" optFlags = [ ['quiet', 'q', "Don't display log messages about reconfiguration"], ] def getSynopsis(self): return "Usage: buildbot reconfig []" class DebugClientOptions(base.SubcommandOptions): subcommandFunction = "buildbot.scripts.debugclient.debugclient" optParameters = [ ["master", "m", None, "Location of the buildmaster's slaveport (host:port)"], ["passwd", "p", None, "Debug password to use"], ] buildbotOptions = [ [ 'master', 'master' ], [ 'debugMaster', 'master' ], ] requiredOptions = [ 'master', 'passwd' ] def getSynopsis(self): return "Usage: buildbot debugclient [options]" def parseArgs(self, *args): if len(args) > 0: self['master'] = args[0] if len(args) > 1: self['passwd'] = args[1] if len(args) > 2: raise usage.UsageError("I wasn't expecting so many arguments") def postOptions(self): base.SubcommandOptions.postOptions(self) validateMasterOption(self.get('master')) class BaseStatusClientOptions(base.SubcommandOptions): optFlags = [ ['help', 'h', "Display this message"], ] optParameters = [ ["master", "m", None, "Location of the buildmaster's status port (host:port)"], ["username", "u", "statusClient", "Username performing the trial build"], ["passwd", 'p', "clientpw", "password for PB authentication"], ] buildbotOptions = [ [ 'master', 'master' ], [ 'masterstatus', 'master' ], ] requiredOptions = [ 'master' ] def parseArgs(self, *args): if len(args) > 0: self['master'] = args[0] if len(args) > 1: raise usage.UsageError("I wasn't expecting so many arguments") def postOptions(self): base.SubcommandOptions.postOptions(self) validateMasterOption(self.get('master')) class StatusLogOptions(BaseStatusClientOptions): subcommandFunction = "buildbot.scripts.statuslog.statuslog" def getSynopsis(self): return "Usage: buildbot statuslog [options]" class StatusGuiOptions(BaseStatusClientOptions): subcommandFunction = "buildbot.scripts.statusgui.statusgui" def getSynopsis(self): return "Usage: buildbot statusgui [options]" class SendChangeOptions(base.SubcommandOptions): subcommandFunction = "buildbot.scripts.sendchange.sendchange" def __init__(self): base.SubcommandOptions.__init__(self) self['properties'] = {} optParameters = [ ("master", "m", None, "Location of the buildmaster's PBListener (host:port)"), # deprecated in 0.8.3; remove in 0.8.5 (bug #1711) ("auth", "a", 'change:changepw', "Authentication token - username:password, or prompt for password"), ("who", "W", None, "Author of the commit"), ("repository", "R", '', "Repository specifier"), ("vc", "s", None, "The VC system in use, one of: cvs, svn, darcs, hg, " "bzr, git, mtn, p4"), ("project", "P", '', "Project specifier"), ("branch", "b", None, "Branch specifier"), ("category", "C", None, "Category of repository"), ("codebase", None, None, "Codebase this change is in (requires 0.8.7 master or later)"), ("revision", "r", None, "Revision specifier"), ("revision_file", None, None, "Filename containing revision spec"), ("property", "p", None, "A property for the change, in the format: name:value"), ("comments", "c", None, "log message"), ("logfile", "F", None, "Read the log messages from this file (- for stdin)"), ("when", "w", None, "timestamp to use as the change time"), ("revlink", "l", '', "Revision link (revlink)"), ("encoding", "e", 'utf8', "Encoding of other parameters (default utf8)"), ] buildbotOptions = [ [ 'master', 'master' ], [ 'who', 'who' ], [ 'branch', 'branch' ], [ 'category', 'category' ], [ 'vc', 'vc' ], ] requiredOptions = [ 'who', 'master' ] def getSynopsis(self): return "Usage: buildbot sendchange [options] filenames.." def parseArgs(self, *args): self['files'] = args def opt_property(self, property): name,value = property.split(':', 1) self['properties'][name] = value def postOptions(self): base.SubcommandOptions.postOptions(self) if self.get("revision_file"): with open(self["revision_file"],"r") as f: self['revision'] = f.read() if self.get('when'): try: self['when'] = float(self['when']) except: raise usage.UsageError('invalid "when" value %s' % (self['when'],)) else: self['when'] = None if not self.get('comments') and self.get('logfile'): if self['logfile'] == "-": self['comments'] = sys.stdin.read() else: with open(self['logfile'], "rt") as f: self['comments'] = f.read() if self.get('comments') is None: self['comments'] = "" # fix up the auth with a password if none was given auth = self.get('auth') if ':' not in auth: import getpass pw = getpass.getpass("Enter password for '%s': " % auth) auth = "%s:%s" % (auth, pw) self['auth'] = tuple(auth.split(':', 1)) vcs = ['cvs', 'svn', 'darcs', 'hg', 'bzr', 'git', 'mtn', 'p4'] if self.get('vc') and self.get('vc') not in vcs: raise usage.UsageError("vc must be one of %s" % (', '.join(vcs))) validateMasterOption(self.get('master')) class TryOptions(base.SubcommandOptions): subcommandFunction = "buildbot.scripts.trycmd.trycmd" optParameters = [ ["connect", "c", None, "How to reach the buildmaster, either 'ssh' or 'pb'"], # for ssh, use --host, --username, and --jobdir ["host", None, None, "Hostname (used by ssh) for the buildmaster"], ["jobdir", None, None, "Directory (on the buildmaster host) where try jobs are deposited"], ["username", "u", None, "Username performing the try build"], # for PB, use --master, --username, and --passwd ["master", "m", None, "Location of the buildmaster's PBListener (host:port)"], ["passwd", None, None, "Password for PB authentication"], ["who", "w", None, "Who is responsible for the try build"], ["comment", "C", None, "A comment which can be used in notifications for this build"], # for ssh to accommodate running in a virtualenv on the buildmaster ["buildbotbin", None, "buildbot", "buildbot binary to use on the buildmaster host"], ["diff", None, None, "Filename of a patch to use instead of scanning a local tree. " "Use '-' for stdin."], ["patchlevel", "p", 0, "Number of slashes to remove from patch pathnames, " "like the -p option to 'patch'"], ["baserev", None, None, "Base revision to use instead of scanning a local tree."], ["vc", None, None, "The VC system in use, one of: bzr, cvs, darcs, git, hg, " "mtn, p4, svn"], ["branch", None, None, "The branch in use, for VC systems that can't figure it out " "themselves"], ["repository", None, None, "Repository to use, instead of path to working directory."], ["builder", "b", None, "Run the trial build on this Builder. Can be used multiple times."], ["properties", None, None, "A set of properties made available in the build environment, " "format is --properties=prop1=value1,prop2=value2,.. " "option can be specified multiple times."], ["property", None, None, "A property made available in the build environment, " "format:prop=value. Can be used multiple times."], ["topfile", None, None, "Name of a file at the top of the tree, used to find the top. " "Only needed for SVN and CVS."], ["topdir", None, None, "Path to the top of the working copy. Only needed for SVN and CVS."], ] optFlags = [ ["wait", None, "wait until the builds have finished"], ["dryrun", 'n', "Gather info, but don't actually submit."], ["get-builder-names", None, "Get the names of available builders. Doesn't submit anything. " "Only supported for 'pb' connections."], ["quiet", "q", "Don't print status of current builds while waiting."], ] # Mapping of .buildbot/options names to command-line options buildbotOptions = [ [ 'try_connect', 'connect' ], #[ 'try_builders', 'builders' ], <-- handled in postOptions [ 'try_vc', 'vc' ], [ 'try_branch', 'branch' ], [ 'try_repository', 'repository' ], [ 'try_topdir', 'topdir' ], [ 'try_topfile', 'topfile' ], [ 'try_host', 'host' ], [ 'try_username', 'username' ], [ 'try_jobdir', 'jobdir' ], [ 'try_buildbotbin', 'buildbotbin' ], [ 'try_passwd', 'passwd' ], [ 'try_master', 'master' ], [ 'try_who', 'who' ], [ 'try_comment', 'comment' ], #[ 'try_wait', 'wait' ], <-- handled in postOptions #[ 'try_quiet', 'quiet' ], <-- handled in postOptions # Deprecated command mappings from the quirky old days: [ 'try_masterstatus', 'master' ], [ 'try_dir', 'jobdir' ], [ 'try_password', 'passwd' ], ] def __init__(self): base.SubcommandOptions.__init__(self) self['builders'] = [] self['properties'] = {} def opt_builder(self, option): self['builders'].append(option) def opt_properties(self, option): # We need to split the value of this option into a dictionary of properties propertylist = option.split(",") for i in range(0,len(propertylist)): splitproperty = propertylist[i].split("=", 1) self['properties'][splitproperty[0]] = splitproperty[1] def opt_property(self, option): name, _, value = option.partition("=") self['properties'][name] = value def opt_patchlevel(self, option): self['patchlevel'] = int(option) def getSynopsis(self): return "Usage: buildbot try [options]" def postOptions(self): base.SubcommandOptions.postOptions(self) opts = self.optionsFile if not self['builders']: self['builders'] = opts.get('try_builders', []) if opts.get('try_wait', False): self['wait'] = True if opts.get('try_quiet', False): self['quiet'] = True # get the global 'masterstatus' option if it's set and no master # was specified otherwise if not self['master']: self['master'] = opts.get('masterstatus', None) if self['connect'] == 'pb': if not self['master']: raise usage.UsageError("master location must be specified" \ "for 'pb' connections") validateMasterOption(self['master']) class TryServerOptions(base.SubcommandOptions): subcommandFunction = "buildbot.scripts.tryserver.tryserver" optParameters = [ ["jobdir", None, None, "the jobdir (maildir) for submitting jobs"], ] requiredOptions = [ 'jobdir' ] def getSynopsis(self): return "Usage: buildbot tryserver [options]" def postOptions(self): if not self['jobdir']: raise usage.UsageError('jobdir is required') class CheckConfigOptions(base.SubcommandOptions): subcommandFunction = "buildbot.scripts.checkconfig.checkconfig" optFlags = [ ['quiet', 'q', "Don't display error messages or tracebacks"], ] def getSynopsis(self): return "Usage: buildbot checkconfig [configFile]\n" + \ " If not specified, 'master.cfg' will be used as 'configFile'" def parseArgs(self, *args): if len(args) >= 1: self['configFile'] = args[0] else: self['configFile'] = 'master.cfg' class UserOptions(base.SubcommandOptions): subcommandFunction = "buildbot.scripts.user.user" optParameters = [ ["master", "m", None, "Location of the buildmaster's PBListener (host:port)"], ["username", "u", None, "Username for PB authentication"], ["passwd", "p", None, "Password for PB authentication"], ["op", None, None, "User management operation: add, remove, update, get"], ["bb_username", None, None, "Username to set for a given user. Only availabe on 'update', " "and bb_password must be given as well."], ["bb_password", None, None, "Password to set for a given user. Only availabe on 'update', " "and bb_username must be given as well."], ["ids", None, None, "User's identifiers, used to find users in 'remove' and 'get' " "Can be specified multiple times (--ids=id1,id2,id3)"], ["info", None, None, "User information in the form: --info=type=value,type=value,.. " "Used in 'add' and 'update', can be specified multiple times. " "Note that 'update' requires --info=id:type=value..."] ] buildbotOptions = [ [ 'master', 'master' ], [ 'user_master', 'master' ], [ 'user_username', 'username' ], [ 'user_passwd', 'passwd' ], ] requiredOptions = [ 'master' ] longdesc = """ Currently implemented types for --info= are:\n git, svn, hg, cvs, darcs, bzr, email """ def __init__(self): base.SubcommandOptions.__init__(self) self['ids'] = [] self['info'] = [] def opt_ids(self, option): id_list = option.split(",") self['ids'].extend(id_list) def opt_info(self, option): # splits info into type/value dictionary, appends to info info_list = option.split(",") info_elem = {} if len(info_list) == 1 and '=' not in info_list[0]: info_elem["identifier"] = info_list[0] self['info'].append(info_elem) else: for i in range(0, len(info_list)): split_info = info_list[i].split("=", 1) # pull identifier from update --info if ":" in split_info[0]: split_id = split_info[0].split(":") info_elem["identifier"] = split_id[0] split_info[0] = split_id[1] info_elem[split_info[0]] = split_info[1] self['info'].append(info_elem) def getSynopsis(self): return "Usage: buildbot user [options]" def _checkValidTypes(self, info): from buildbot.process.users import users valid = set(['identifier', 'email'] + users.srcs) for user in info: for attr_type in user: if attr_type not in valid: raise usage.UsageError( "Type not a valid attr_type, must be in: %s" % ', '.join(valid)) def postOptions(self): base.SubcommandOptions.postOptions(self) validateMasterOption(self.get('master')) op = self.get('op') if not op: raise usage.UsageError("you must specify an operation: add, " "remove, update, get") if op not in ['add', 'remove', 'update', 'get']: raise usage.UsageError("bad op %r, use 'add', 'remove', 'update', " "or 'get'" % op) if not self.get('username') or not self.get('passwd'): raise usage.UsageError("A username and password must be given") bb_username = self.get('bb_username') bb_password = self.get('bb_password') if bb_username or bb_password: if op != 'update': raise usage.UsageError("bb_username and bb_password only work " "with update") if not bb_username or not bb_password: raise usage.UsageError("Must specify both bb_username and " "bb_password or neither.") info = self.get('info') ids = self.get('ids') # check for erroneous args if not info and not ids: raise usage.UsageError("must specify either --ids or --info") if op == 'add' or op == 'update': if ids: raise usage.UsageError("cannot use --ids with 'add' or " "'update'") self._checkValidTypes(info) if op == 'update': for user in info: if 'identifier' not in user: raise usage.UsageError("no ids found in update info; " "use: --info=id:type=value,type=value,..") if op == 'add': for user in info: if 'identifier' in user: raise usage.UsageError("identifier found in add info, " "use: --info=type=value,type=value,..") if op == 'remove' or op == 'get': if info: raise usage.UsageError("cannot use --info with 'remove' " "or 'get'") class Options(usage.Options): synopsis = "Usage: buildbot [command options]" subCommands = [ ['create-master', None, CreateMasterOptions, "Create and populate a directory for a new buildmaster"], ['upgrade-master', None, UpgradeMasterOptions, "Upgrade an existing buildmaster directory for the current version"], ['start', None, StartOptions, "Start a buildmaster"], ['stop', None, StopOptions, "Stop a buildmaster"], ['restart', None, RestartOptions, "Restart a buildmaster"], ['reconfig', None, ReconfigOptions, "SIGHUP a buildmaster to make it re-read the config file"], ['sighup', None, ReconfigOptions, "SIGHUP a buildmaster to make it re-read the config file"], ['sendchange', None, SendChangeOptions, "Send a change to the buildmaster"], ['debugclient', None, DebugClientOptions, "Launch a small debug panel GUI"], ['statuslog', None, StatusLogOptions, "Emit current builder status to stdout"], ['statusgui', None, StatusGuiOptions, "Display a small window showing current builder status"], ['try', None, TryOptions, "Run a build with your local changes"], ['tryserver', None, TryServerOptions, "buildmaster-side 'try' support function, not for users"], ['checkconfig', None, CheckConfigOptions, "test the validity of a master.cfg config file"], ['user', None, UserOptions, "Manage users in buildbot's database"] ] def opt_version(self): import buildbot print "Buildbot version: %s" % buildbot.version usage.Options.opt_version(self) def opt_verbose(self): from twisted.python import log log.startLogging(sys.stderr) def postOptions(self): if not hasattr(self, 'subOptions'): raise usage.UsageError("must specify a command") def run(): config = Options() try: config.parseOptions(sys.argv[1:]) except usage.error, e: print "%s: %s" % (sys.argv[0], e) print c = getattr(config, 'subOptions', config) print str(c) sys.exit(1) subconfig = config.subOptions subcommandFunction = reflect.namedObject(subconfig.subcommandFunction) sys.exit(subcommandFunction(subconfig)) buildbot-0.8.8/buildbot/scripts/sample.cfg000066400000000000000000000105671222546025000205770ustar00rootroot00000000000000# -*- python -*- # ex: set syntax=python: # This is a sample buildmaster config file. It must be installed as # 'master.cfg' in your buildmaster's base directory. # This is the dictionary that the buildmaster pays attention to. We also use # a shorter alias to save typing. c = BuildmasterConfig = {} ####### BUILDSLAVES # The 'slaves' list defines the set of recognized buildslaves. Each element is # a BuildSlave object, specifying a unique slave name and password. The same # slave name and password must be configured on the slave. from buildbot.buildslave import BuildSlave c['slaves'] = [BuildSlave("example-slave", "pass")] # 'slavePortnum' defines the TCP port to listen on for connections from slaves. # This must match the value configured into the buildslaves (with their # --master option) c['slavePortnum'] = 9989 ####### CHANGESOURCES # the 'change_source' setting tells the buildmaster how it should find out # about source code changes. Here we point to the buildbot clone of pyflakes. from buildbot.changes.gitpoller import GitPoller c['change_source'] = [] c['change_source'].append(GitPoller( 'git://github.com/buildbot/pyflakes.git', workdir='gitpoller-workdir', branch='master', pollinterval=300)) ####### SCHEDULERS # Configure the Schedulers, which decide how to react to incoming changes. In this # case, just kick off a 'runtests' build from buildbot.schedulers.basic import SingleBranchScheduler from buildbot.schedulers.forcesched import ForceScheduler from buildbot.changes import filter c['schedulers'] = [] c['schedulers'].append(SingleBranchScheduler( name="all", change_filter=filter.ChangeFilter(branch='master'), treeStableTimer=None, builderNames=["runtests"])) c['schedulers'].append(ForceScheduler( name="force", builderNames=["runtests"])) ####### BUILDERS # The 'builders' list defines the Builders, which tell Buildbot how to perform a build: # what steps, and which slaves can execute them. Note that any particular build will # only take place on one slave. from buildbot.process.factory import BuildFactory from buildbot.steps.source.git import Git from buildbot.steps.shell import ShellCommand factory = BuildFactory() # check out the source factory.addStep(Git(repourl='git://github.com/buildbot/pyflakes.git', mode='incremental')) # run the tests (note that this will require that 'trial' is installed) factory.addStep(ShellCommand(command=["trial", "pyflakes"])) from buildbot.config import BuilderConfig c['builders'] = [] c['builders'].append( BuilderConfig(name="runtests", slavenames=["example-slave"], factory=factory)) ####### STATUS TARGETS # 'status' is a list of Status Targets. The results of each build will be # pushed to these targets. buildbot/status/*.py has a variety to choose from, # including web pages, email senders, and IRC bots. c['status'] = [] from buildbot.status import html from buildbot.status.web import authz, auth authz_cfg=authz.Authz( # change any of these to True to enable; see the manual for more # options auth=auth.BasicAuth([("pyflakes","pyflakes")]), gracefulShutdown = False, forceBuild = 'auth', # use this to test your slave once it is set up forceAllBuilds = False, pingBuilder = False, stopBuild = False, stopAllBuilds = False, cancelPendingBuild = False, ) c['status'].append(html.WebStatus(http_port=8010, authz=authz_cfg)) ####### PROJECT IDENTITY # the 'title' string will appear at the top of this buildbot # installation's html.WebStatus home page (linked to the # 'titleURL') and is embedded in the title of the waterfall HTML page. c['title'] = "Pyflakes" c['titleURL'] = "https://launchpad.net/pyflakes" # the 'buildbotURL' string should point to the location where the buildbot's # internal web server (usually the html.WebStatus page) is visible. This # typically uses the port number set in the Waterfall 'status' entry, but # with an externally-visible host name which the buildbot cannot figure out # without some help. c['buildbotURL'] = "http://localhost:8010/" ####### DB URL c['db'] = { # This specifies what database buildbot uses to store its state. You can leave # this at its default for all but the largest installations. 'db_url' : "sqlite:///state.sqlite", } buildbot-0.8.8/buildbot/scripts/sendchange.py000066400000000000000000000040031222546025000212720ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sys import traceback from twisted.internet import defer from buildbot.clients import sendchange as sendchange_client from buildbot.util import in_reactor @in_reactor @defer.inlineCallbacks def sendchange(config): encoding = config.get('encoding', 'utf8') who = config.get('who') auth = config.get('auth') master = config.get('master') branch = config.get('branch') category = config.get('category') revision = config.get('revision') properties = config.get('properties', {}) repository = config.get('repository', '') vc = config.get('vc', None) project = config.get('project', '') revlink = config.get('revlink', '') when = config.get('when') comments = config.get('comments') files = config.get('files', ()) codebase = config.get('codebase', None) s = sendchange_client.Sender(master, auth, encoding=encoding) try: yield s.send(branch, revision, comments, files, who=who, category=category, when=when, properties=properties, repository=repository, vc=vc, project=project, revlink=revlink, codebase=codebase) except: print "change not sent:" traceback.print_exc(file=sys.stdout) defer.returnValue(1) else: print "change sent successfully" defer.returnValue(0) buildbot-0.8.8/buildbot/scripts/start.py000066400000000000000000000076401222546025000203420ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os, sys from buildbot.scripts import base from twisted.internet import reactor, protocol from twisted.python.runtime import platformType from buildbot.scripts.logwatcher import LogWatcher from buildbot.scripts.logwatcher import BuildmasterTimeoutError from buildbot.scripts.logwatcher import ReconfigError class Follower: def follow(self, basedir): self.rc = 0 print "Following twistd.log until startup finished.." lw = LogWatcher(os.path.join(basedir, "twistd.log")) d = lw.start() d.addCallbacks(self._success, self._failure) reactor.run() return self.rc def _success(self, _): print "The buildmaster appears to have (re)started correctly." self.rc = 0 reactor.stop() def _failure(self, why): if why.check(BuildmasterTimeoutError): print """ The buildmaster took more than 10 seconds to start, so we were unable to confirm that it started correctly. Please 'tail twistd.log' and look for a line that says 'configuration update complete' to verify correct startup. """ elif why.check(ReconfigError): print """ The buildmaster appears to have encountered an error in the master.cfg config file during startup. Please inspect and fix master.cfg, then restart the buildmaster. """ else: print """ Unable to confirm that the buildmaster started correctly. You may need to stop it, fix the config file, and restart. """ print why self.rc = 1 reactor.stop() def launchNoDaemon(config): os.chdir(config['basedir']) sys.path.insert(0, os.path.abspath(config['basedir'])) argv = ["twistd", "--no_save", '--nodaemon', "--logfile=twistd.log", # windows doesn't use the same default "--python=buildbot.tac"] sys.argv = argv # this is copied from bin/twistd. twisted-2.0.0 through 2.4.0 use # _twistw.run . Twisted-2.5.0 and later use twistd.run, even for # windows. from twisted.scripts import twistd twistd.run() def launch(config): os.chdir(config['basedir']) sys.path.insert(0, os.path.abspath(config['basedir'])) # see if we can launch the application without actually having to # spawn twistd, since spawning processes correctly is a real hassle # on windows. argv = [sys.executable, "-c", # this is copied from bin/twistd. twisted-2.0.0 through 2.4.0 use # _twistw.run . Twisted-2.5.0 and later use twistd.run, even for # windows. "from twisted.scripts import twistd; twistd.run()", "--no_save", "--logfile=twistd.log", # windows doesn't use the same default "--python=buildbot.tac"] # ProcessProtocol just ignores all output reactor.spawnProcess(protocol.ProcessProtocol(), sys.executable, argv, env=os.environ) def start(config): if not base.isBuildmasterDir(config['basedir']): return 1 if config['nodaemon']: launchNoDaemon(config) return 0 launch(config) # We don't have tail on windows if platformType == "win32" or config['quiet']: return 0 # this is the parent rc = Follower().follow(config['basedir']) return rc buildbot-0.8.8/buildbot/scripts/statusgui.py000066400000000000000000000020761222546025000212330ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # note that this cannot be run in tests for code coverage, as it requires a # different reactor than the default def statusgui(config): from buildbot.clients import gtkPanes master = config.get('master') passwd = config.get('passwd') username = config.get('username') c = gtkPanes.GtkClient(master, username=username, passwd=passwd) c.run() return 0 buildbot-0.8.8/buildbot/scripts/statuslog.py000066400000000000000000000020631222546025000212240ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # note that this cannot be run in tests for code coverage, as it requires a # different reactor than the default from buildbot.clients import text def statuslog(config): master = config.get('master') passwd = config.get('passwd') username = config.get('username') c = text.TextClient(master, username=username, passwd=passwd) c.run() return 0 buildbot-0.8.8/buildbot/scripts/stop.py000066400000000000000000000043461222546025000201720ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import time import os import errno import signal from buildbot.scripts import base def stop(config, signame="TERM", wait=False): basedir = config['basedir'] quiet = config['quiet'] if config['clean']: signame = 'USR1' if not base.isBuildmasterDir(config['basedir']): return 1 pidfile = os.path.join(basedir, 'twistd.pid') try: with open(pidfile, "rt") as f: pid = int(f.read().strip()) except: if not config['quiet']: print "buildmaster not running" return 0 signum = getattr(signal, "SIG"+signame) try: os.kill(pid, signum) except OSError, e: if e.errno != errno.ESRCH: raise else: if not config['quiet']: print "buildmaster not running" try: os.unlink(pidfile) except: pass return 0 if not wait: if not quiet: print "sent SIG%s to process" % signame return 0 time.sleep(0.1) # poll once per second until twistd.pid goes away, up to 10 seconds, # unless we're doing a clean stop, in which case wait forever count = 0 while count < 10 or config['clean']: try: os.kill(pid, 0) except OSError: if not quiet: print "buildbot process %d is dead" % pid return 0 time.sleep(1) count += 1 if not quiet: print "never saw process go away" return 1 buildbot-0.8.8/buildbot/scripts/trycmd.py000066400000000000000000000014701222546025000205020ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members def trycmd(config): from buildbot.clients import tryclient t = tryclient.Try(config) t.run() return 0 buildbot-0.8.8/buildbot/scripts/tryserver.py000066400000000000000000000026431222546025000212500ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import sys import time from hashlib import md5 def tryserver(config): jobdir = os.path.expanduser(config["jobdir"]) job = sys.stdin.read() # now do a 'safecat'-style write to jobdir/tmp, then move atomically to # jobdir/new . Rather than come up with a unique name randomly, I'm just # going to MD5 the contents and prepend a timestamp. timestring = "%d" % time.time() m = md5() m.update(job) jobhash = m.hexdigest() fn = "%s-%s" % (timestring, jobhash) tmpfile = os.path.join(jobdir, "tmp", fn) newfile = os.path.join(jobdir, "new", fn) with open(tmpfile, "w") as f: f.write(job) os.rename(tmpfile, newfile) return 0 buildbot-0.8.8/buildbot/scripts/upgrade_master.py000066400000000000000000000142701222546025000222040ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import sys import traceback from twisted.internet import defer from twisted.python import util, runtime from buildbot import config as config_module from buildbot import monkeypatches from buildbot.db import connector from buildbot.master import BuildMaster from buildbot.util import in_reactor from buildbot.scripts import base def checkBasedir(config): if not config['quiet']: print "checking basedir" if not base.isBuildmasterDir(config['basedir']): return False if runtime.platformType != 'win32': # no pids on win32 if not config['quiet']: print "checking for running master" pidfile = os.path.join(config['basedir'], 'twistd.pid') if os.path.exists(pidfile): print "'%s' exists - is this master still running?" % (pidfile,) return False return True def loadConfig(config, configFileName='master.cfg'): if not config['quiet']: print "checking %s" % configFileName try: master_cfg = config_module.MasterConfig.loadConfig( config['basedir'], configFileName) except config_module.ConfigErrors, e: print "Errors loading configuration:" for msg in e.errors: print " " + msg return except: print "Errors loading configuration:" traceback.print_exc(file=sys.stdout) return return master_cfg def installFile(config, target, source, overwrite=False): with open(source, "rt") as f: new_contents = f.read() if os.path.exists(target): with open(target, "rt") as f: old_contents = f.read() if old_contents != new_contents: if overwrite: if not config['quiet']: print "%s has old/modified contents" % target print " overwriting it with new contents" with open(target, "wt") as f: f.write(new_contents) else: if not config['quiet']: print "%s has old/modified contents" % target print " writing new contents to %s.new" % target with open(target + ".new", "wt") as f: f.write(new_contents) # otherwise, it's up to date else: if not config['quiet']: print "creating %s" % target with open(target, "wt") as f: f.write(new_contents) def upgradeFiles(config): if not config['quiet']: print "upgrading basedir" webdir = os.path.join(config['basedir'], "public_html") if not os.path.exists(webdir): if not config['quiet']: print "creating public_html" os.mkdir(webdir) templdir = os.path.join(config['basedir'], "templates") if not os.path.exists(templdir): if not config['quiet']: print "creating templates" os.mkdir(templdir) for file in ('bg_gradient.jpg', 'default.css', 'robots.txt', 'favicon.ico'): source = util.sibpath(__file__, "../status/web/files/%s" % (file,)) target = os.path.join(webdir, file) try: installFile(config, target, source) except IOError: print "Can't write '%s'." % (target,) installFile(config, os.path.join(config['basedir'], "master.cfg.sample"), util.sibpath(__file__, "sample.cfg"), overwrite=True) # if index.html exists, use it to override the root page tempalte index_html = os.path.join(webdir, "index.html") root_html = os.path.join(templdir, "root.html") if os.path.exists(index_html): if os.path.exists(root_html): print "Notice: %s now overrides %s" % (root_html, index_html) print " as the latter is not used by buildbot anymore." print " Decide which one you want to keep." else: try: print "Notice: Moving %s to %s." % (index_html, root_html) print " You can (and probably want to) remove it if " \ "you haven't modified this file." os.renames(index_html, root_html) except Exception, e: print "Error moving %s to %s: %s" % (index_html, root_html, str(e)) @defer.inlineCallbacks def upgradeDatabase(config, master_cfg): if not config['quiet']: print "upgrading database (%s)" % (master_cfg.db['db_url']) master = BuildMaster(config['basedir']) master.config = master_cfg db = connector.DBConnector(master, basedir=config['basedir']) yield db.setup(check_version=False, verbose=not config['quiet']) yield db.model.upgrade() @in_reactor @defer.inlineCallbacks def upgradeMaster(config, _noMonkey=False): if not _noMonkey: # pragma: no cover monkeypatches.patch_all() if not checkBasedir(config): defer.returnValue(1) return os.chdir(config['basedir']) try: configFile = base.getConfigFileFromTac(config['basedir']) except (SyntaxError, ImportError), e: print "Unable to load 'buildbot.tac' from '%s':" % config['basedir'] print e defer.returnValue(1) return master_cfg = loadConfig(config, configFile) if not master_cfg: defer.returnValue(1) return upgradeFiles(config) yield upgradeDatabase(config, master_cfg) if not config['quiet']: print "upgrade complete" defer.returnValue(0) buildbot-0.8.8/buildbot/scripts/user.py000066400000000000000000000032201222546025000201510ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.internet import defer from buildbot.clients import usersclient from buildbot.process.users import users from buildbot.util import in_reactor @in_reactor @defer.inlineCallbacks def user(config): master = config.get('master') op = config.get('op') username = config.get('username') passwd = config.get('passwd') master, port = master.split(":") port = int(port) bb_username = config.get('bb_username') bb_password = config.get('bb_password') if bb_username or bb_password: bb_password = users.encrypt(bb_password) info = config.get('info') ids = config.get('ids') # find identifier if op == add if info and op == 'add': for user in info: user['identifier'] = sorted(user.values())[0] uc = usersclient.UsersClient(master, username, passwd, port) output = yield uc.send(op, bb_username, bb_password, ids, info) if output: print output defer.returnValue(0) buildbot-0.8.8/buildbot/sourcestamp.py000066400000000000000000000331701222546025000200600ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from twisted.persisted import styles from twisted.internet import defer from buildbot.changes.changes import Change from buildbot import util, interfaces # TODO: kill this class, or at least make it less significant class SourceStamp(util.ComparableMixin, styles.Versioned): """This is a tuple of (branch, revision, patchspec, changes, project, repository). C{branch} is always valid, although it may be None to let the Source step use its default branch. There are three possibilities for the remaining elements: - (revision=REV, patchspec=None, changes=None): build REV. If REV is None, build the HEAD revision from the given branch. Note that REV must always be a string: SVN, Perforce, and other systems which use integers should provide a string here, but the Source checkout step will integerize it when making comparisons. - (revision=REV, patchspec=(LEVEL, DIFF), changes=None): checkout REV, then apply a patch to the source, with C{patch -pLEVEL 2: result['patch_subdir'] = self.patch[2] if self.patch_info: result['patch_author'] = self.patch_info[0] result['patch_comment'] = self.patch_info[1] result['branch'] = self.branch result['changes'] = [c.asDict() for c in getattr(self, 'changes', [])] result['project'] = self.project result['repository'] = self.repository result['codebase'] = self.codebase return result def __setstate__(self, d): styles.Versioned.__setstate__(self, d) self._addSourceStampToDatabase_lock = defer.DeferredLock(); def upgradeToVersion1(self): # version 0 was untyped; in version 1 and later, types matter. if self.branch is not None and not isinstance(self.branch, str): self.branch = str(self.branch) if self.revision is not None and not isinstance(self.revision, str): self.revision = str(self.revision) if self.patch is not None: self.patch = ( int(self.patch[0]), str(self.patch[1]) ) self.wasUpgraded = True def upgradeToVersion2(self): # version 1 did not have project or repository; just set them to a default '' self.project = '' self.repository = '' self.wasUpgraded = True def upgradeToVersion3(self): #The database has been upgraded where all existing sourcestamps got an #setid equal to its ssid self.sourcestampsetid = self.ssid #version 2 did not have codebase; set to '' self.codebase = '' self.wasUpgraded = True def getSourceStampSetId(self, master): "temporary; do not use widely!" if self.sourcestampsetid: return defer.succeed(self.sourcestampsetid) else: return self.addSourceStampToDatabase(master) @util.deferredLocked('_addSourceStampToDatabase_lock') def addSourceStampToDatabase(self, master, sourcestampsetid = None): # add it to the DB patch_body = None patch_level = None patch_subdir = None if self.patch: patch_level = self.patch[0] patch_body = self.patch[1] if len(self.patch) > 2: patch_subdir = self.patch[2] patch_author = None patch_comment = None if self.patch_info: patch_author, patch_comment = self.patch_info def get_setid(): if sourcestampsetid is not None: return defer.succeed( sourcestampsetid ) else: return master.db.sourcestampsets.addSourceStampSet() def set_setid(setid): self.sourcestampsetid = setid return setid def add_sourcestamp(setid): return master.db.sourcestamps.addSourceStamp( sourcestampsetid=setid, branch=self.branch, revision=self.revision, repository=self.repository, codebase=self.codebase, project=self.project, patch_body=patch_body, patch_level=patch_level, patch_author=patch_author, patch_comment=patch_comment, patch_subdir=patch_subdir, changeids=[c.number for c in self.changes]) def set_ssid(ssid): self.ssid = ssid return ssid d = get_setid() d.addCallback(set_setid) d.addCallback(add_sourcestamp) d.addCallback(set_ssid) d.addCallback(lambda _ : self.sourcestampsetid) return d buildbot-0.8.8/buildbot/status/000077500000000000000000000000001222546025000164605ustar00rootroot00000000000000buildbot-0.8.8/buildbot/status/__init__.py000066400000000000000000000016121222546025000205710ustar00rootroot00000000000000import build, builder, buildstep, buildset, testresult, logfile import slave, master, buildrequest # styles.Versioned requires this, as it keys the version numbers on the fully # qualified class name; see master/buildbot/test/regressions/test_unpickling.py buildstep.BuildStepStatus.__module__ = 'buildbot.status.builder' build.BuildStatus.__module__ = 'buildbot.status.builder' # add all of these classes to builder; this is a form of late binding to allow # circular module references among the status modules builder.BuildStepStatus = buildstep.BuildStepStatus builder.BuildSetStatus = buildset.BuildSetStatus builder.TestResult = testresult.TestResult builder.LogFile = logfile.LogFile builder.HTMLLogFile = logfile.HTMLLogFile builder.SlaveStatus = slave.SlaveStatus builder.Status = master.Status builder.BuildStatus = build.BuildStatus builder.BuildRequestStatus = buildrequest.BuildRequestStatus buildbot-0.8.8/buildbot/status/base.py000066400000000000000000000052161222546025000177500ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from twisted.application import service from buildbot.interfaces import IStatusReceiver from buildbot import util, pbutil class StatusReceiverBase: implements(IStatusReceiver) def requestSubmitted(self, request): pass def requestCancelled(self, builder, request): pass def buildsetSubmitted(self, buildset): pass def builderAdded(self, builderName, builder): pass def builderChangedState(self, builderName, state): pass def buildStarted(self, builderName, build): pass def buildETAUpdate(self, build, ETA): pass def changeAdded(self, change): pass def stepStarted(self, build, step): pass def stepTextChanged(self, build, step, text): pass def stepText2Changed(self, build, step, text2): pass def stepETAUpdate(self, build, step, ETA, expectations): pass def logStarted(self, build, step, log): pass def logChunk(self, build, step, log, channel, text): pass def logFinished(self, build, step, log): pass def stepFinished(self, build, step, results): pass def buildFinished(self, builderName, build, results): pass def builderRemoved(self, builderName): pass def slaveConnected(self, slaveName): pass def slaveDisconnected(self, slaveName): pass def checkConfig(self, otherStatusReceivers): pass class StatusReceiverMultiService(StatusReceiverBase, service.MultiService, util.ComparableMixin): def __init__(self): service.MultiService.__init__(self) class StatusReceiverService(StatusReceiverBase, service.Service, util.ComparableMixin): pass StatusReceiver = StatusReceiverService class StatusReceiverPerspective(StatusReceiver, pbutil.NewCredPerspective): implements(IStatusReceiver) buildbot-0.8.8/buildbot/status/build.py000066400000000000000000000413621222546025000201370ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os, shutil, re from cPickle import dump from zope.interface import implements from twisted.python import log, runtime, components from twisted.persisted import styles from twisted.internet import reactor, defer from buildbot import interfaces, util, sourcestamp from buildbot.process import properties from buildbot.status.buildstep import BuildStepStatus class BuildStatus(styles.Versioned, properties.PropertiesMixin): implements(interfaces.IBuildStatus, interfaces.IStatusEvent) persistenceVersion = 4 persistenceForgets = ( 'wasUpgraded', ) sources = None reason = None changes = [] blamelist = [] progress = None started = None finished = None currentStep = None text = [] results = None slavename = "???" set_runtime_properties = True # these lists/dicts are defined here so that unserialized instances have # (empty) values. They are set in __init__ to new objects to make sure # each instance gets its own copy. watchers = [] updates = {} finishedWatchers = [] testResults = {} def __init__(self, parent, master, number): """ @type parent: L{BuilderStatus} @type number: int """ assert interfaces.IBuilderStatus(parent) self.builder = parent self.master = master self.number = number self.watchers = [] self.updates = {} self.finishedWatchers = [] self.steps = [] self.testResults = {} self.properties = properties.Properties() def __repr__(self): return "<%s #%s>" % (self.__class__.__name__, self.number) # IBuildStatus def getBuilder(self): """ @rtype: L{BuilderStatus} """ return self.builder def getNumber(self): return self.number def getPreviousBuild(self): if self.number == 0: return None return self.builder.getBuild(self.number-1) def getAllGotRevisions(self): all_got_revisions = self.properties.getProperty('got_revision', {}) # For backwards compatibility all_got_revisions is a string if codebases # are not used. Convert to the default internal type (dict) if not isinstance(all_got_revisions, dict): all_got_revisions = {'': all_got_revisions} return all_got_revisions def getSourceStamps(self, absolute=False): sourcestamps = [] if not absolute: sourcestamps.extend(self.sources) else: all_got_revisions = self.getAllGotRevisions() or {} # always make a new instance for ss in self.sources: if ss.codebase in all_got_revisions: got_revision = all_got_revisions[ss.codebase] sourcestamps.append(ss.getAbsoluteSourceStamp(got_revision)) else: # No absolute revision information available # Probably build has been stopped before ending all sourcesteps # Return a clone with original revision sourcestamps.append(ss.clone()) return sourcestamps def getReason(self): return self.reason def getChanges(self): return self.changes def getRevisions(self): revs = [] for c in self.changes: rev = str(c.revision) if rev > 7: # for long hashes rev = rev[:7] revs.append(rev) return ", ".join(revs) def getResponsibleUsers(self): return self.blamelist def getInterestedUsers(self): # TODO: the Builder should add others: sheriffs, domain-owners return self.properties.getProperty('owners', []) def getSteps(self): """Return a list of IBuildStepStatus objects. For invariant builds (those which always use the same set of Steps), this should be the complete list, however some of the steps may not have started yet (step.getTimes()[0] will be None). For variant builds, this may not be complete (asking again later may give you more of them).""" return self.steps def getTimes(self): return (self.started, self.finished) _sentinel = [] # used as a sentinel to indicate unspecified initial_value def getSummaryStatistic(self, name, summary_fn, initial_value=_sentinel): """Summarize the named statistic over all steps in which it exists, using combination_fn and initial_value to combine multiple results into a single result. This translates to a call to Python's X{reduce}:: return reduce(summary_fn, step_stats_list, initial_value) """ step_stats_list = [ st.getStatistic(name) for st in self.steps if st.hasStatistic(name) ] if initial_value is self._sentinel: return reduce(summary_fn, step_stats_list) else: return reduce(summary_fn, step_stats_list, initial_value) def isFinished(self): return (self.finished is not None) def waitUntilFinished(self): if self.finished: d = defer.succeed(self) else: d = defer.Deferred() self.finishedWatchers.append(d) return d # while the build is running, the following methods make sense. # Afterwards they return None def getETA(self): if self.finished is not None: return None if not self.progress: return None eta = self.progress.eta() if eta is None: return None return eta - util.now() def getCurrentStep(self): return self.currentStep # Once you know the build has finished, the following methods are legal. # Before ths build has finished, they all return None. def getText(self): text = [] text.extend(self.text) for s in self.steps: text.extend(s.text2) return text def getResults(self): return self.results def getSlavename(self): return self.slavename def getTestResults(self): return self.testResults def getLogs(self): logs = [] for s in self.steps: for loog in s.getLogs(): logs.append(loog) return logs # subscription interface def subscribe(self, receiver, updateInterval=None): # will receive stepStarted and stepFinished messages # and maybe buildETAUpdate self.watchers.append(receiver) if updateInterval is not None: self.sendETAUpdate(receiver, updateInterval) def sendETAUpdate(self, receiver, updateInterval): self.updates[receiver] = None ETA = self.getETA() if ETA is not None: receiver.buildETAUpdate(self, self.getETA()) # they might have unsubscribed during buildETAUpdate if receiver in self.watchers: self.updates[receiver] = reactor.callLater(updateInterval, self.sendETAUpdate, receiver, updateInterval) def unsubscribe(self, receiver): if receiver in self.watchers: self.watchers.remove(receiver) if receiver in self.updates: if self.updates[receiver] is not None: self.updates[receiver].cancel() del self.updates[receiver] # methods for the base.Build to invoke def addStepWithName(self, name): """The Build is setting up, and has added a new BuildStep to its list. Create a BuildStepStatus object to which it can send status updates.""" s = BuildStepStatus(self, self.master, len(self.steps)) s.setName(name) self.steps.append(s) return s def addTestResult(self, result): self.testResults[result.getName()] = result def setSourceStamps(self, sourceStamps): self.sources = sourceStamps self.changes = [] for source in self.sources: self.changes.extend(source.changes) def setReason(self, reason): self.reason = reason def setBlamelist(self, blamelist): self.blamelist = blamelist def setProgress(self, progress): self.progress = progress def buildStarted(self, build): """The Build has been set up and is about to be started. It can now be safely queried, so it is time to announce the new build.""" self.started = util.now() # now that we're ready to report status, let the BuilderStatus tell # the world about us self.builder.buildStarted(self) def setSlavename(self, slavename): self.slavename = slavename def setText(self, text): assert isinstance(text, (list, tuple)) self.text = text def setResults(self, results): self.results = results def buildFinished(self): self.currentStep = None self.finished = util.now() for r in self.updates.keys(): if self.updates[r] is not None: self.updates[r].cancel() del self.updates[r] watchers = self.finishedWatchers self.finishedWatchers = [] for w in watchers: w.callback(self) # methods called by our BuildStepStatus children def stepStarted(self, step): self.currentStep = step for w in self.watchers: receiver = w.stepStarted(self, step) if receiver: if type(receiver) == type(()): step.subscribe(receiver[0], receiver[1]) else: step.subscribe(receiver) d = step.waitUntilFinished() d.addCallback(lambda step: step.unsubscribe(receiver)) step.waitUntilFinished().addCallback(self._stepFinished) def _stepFinished(self, step): results = step.getResults() for w in self.watchers: w.stepFinished(self, step, results) # methods called by our BuilderStatus parent def pruneSteps(self): # this build is very old: remove the build steps too self.steps = [] # persistence stuff def generateLogfileName(self, stepname, logname): """Return a filename (relative to the Builder's base directory) where the logfile's contents can be stored uniquely. The base filename is made by combining our build number, the Step's name, and the log's name, then removing unsuitable characters. The filename is then made unique by appending _0, _1, etc, until it does not collide with any other logfile. These files are kept in the Builder's basedir (rather than a per-Build subdirectory) because that makes cleanup easier: cron and find will help get rid of the old logs, but the empty directories are more of a hassle to remove.""" starting_filename = "%d-log-%s-%s" % (self.number, stepname, logname) starting_filename = re.sub(r'[^\w\.\-]', '_', starting_filename) # now make it unique unique_counter = 0 filename = starting_filename while filename in [l.filename for step in self.steps for l in step.getLogs() if l.filename]: filename = "%s_%d" % (starting_filename, unique_counter) unique_counter += 1 return filename def __getstate__(self): d = styles.Versioned.__getstate__(self) # for now, a serialized Build is always "finished". We will never # save unfinished builds. if not self.finished: d['finished'] = util.now() # TODO: push an "interrupted" step so it is clear that the build # was interrupted. The builder will have a 'shutdown' event, but # someone looking at just this build will be confused as to why # the last log is truncated. for k in [ 'builder', 'watchers', 'updates', 'finishedWatchers', 'master' ]: if k in d: del d[k] return d def __setstate__(self, d): styles.Versioned.__setstate__(self, d) self.watchers = [] self.updates = {} self.finishedWatchers = [] def setProcessObjects(self, builder, master): self.builder = builder self.master = master for step in self.steps: step.setProcessObjects(self, master) def upgradeToVersion1(self): if hasattr(self, "sourceStamp"): # the old .sourceStamp attribute wasn't actually very useful maxChangeNumber, patch = self.sourceStamp changes = getattr(self, 'changes', []) source = sourcestamp.SourceStamp(branch=None, revision=None, patch=patch, changes=changes) self.source = source self.changes = source.changes del self.sourceStamp self.wasUpgraded = True def upgradeToVersion2(self): self.properties = {} self.wasUpgraded = True def upgradeToVersion3(self): # in version 3, self.properties became a Properties object propdict = self.properties self.properties = properties.Properties() self.properties.update(propdict, "Upgrade from previous version") self.wasUpgraded = True def upgradeToVersion4(self): # buildstatus contains list of sourcestamps, convert single to list if hasattr(self, "source"): self.sources = [self.source] del self.source self.wasUpgraded = True def checkLogfiles(self): # check that all logfiles exist, and remove references to any that # have been deleted (e.g., by purge()) for s in self.steps: s.checkLogfiles() def saveYourself(self): filename = os.path.join(self.builder.basedir, "%d" % self.number) if os.path.isdir(filename): # leftover from 0.5.0, which stored builds in directories shutil.rmtree(filename, ignore_errors=True) tmpfilename = filename + ".tmp" try: with open(tmpfilename, "wb") as f: dump(self, f, -1) if runtime.platformType == 'win32': # windows cannot rename a file on top of an existing one, so # fall back to delete-first. There are ways this can fail and # lose the builder's history, so we avoid using it in the # general (non-windows) case if os.path.exists(filename): os.unlink(filename) os.rename(tmpfilename, filename) except: log.msg("unable to save build %s-#%d" % (self.builder.name, self.number)) log.err() def asDict(self): result = {} # Constant result['builderName'] = self.builder.name result['number'] = self.getNumber() result['sourceStamps'] = [ss.asDict() for ss in self.getSourceStamps()] result['reason'] = self.getReason() result['blame'] = self.getResponsibleUsers() # Transient result['properties'] = self.getProperties().asList() result['times'] = self.getTimes() result['text'] = self.getText() result['results'] = self.getResults() result['slave'] = self.getSlavename() # TODO(maruel): Add. #result['test_results'] = self.getTestResults() result['logs'] = [[l.getName(), self.builder.status.getURLForThing(l)] for l in self.getLogs()] result['eta'] = self.getETA() result['steps'] = [bss.asDict() for bss in self.steps] if self.getCurrentStep(): result['currentStep'] = self.getCurrentStep().asDict() else: result['currentStep'] = None return result components.registerAdapter(lambda build_status : build_status.properties, BuildStatus, interfaces.IProperties) buildbot-0.8.8/buildbot/status/builder.py000066400000000000000000000525641222546025000204740ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os, re, itertools from cPickle import load, dump from zope.interface import implements from twisted.python import log, runtime from twisted.persisted import styles from buildbot import interfaces, util from buildbot.util.lru import LRUCache from buildbot.status.event import Event from buildbot.status.build import BuildStatus from buildbot.status.buildrequest import BuildRequestStatus # user modules expect these symbols to be present here from buildbot.status.results import SUCCESS, WARNINGS, FAILURE, SKIPPED from buildbot.status.results import EXCEPTION, RETRY, Results, worst_status _hush_pyflakes = [ SUCCESS, WARNINGS, FAILURE, SKIPPED, EXCEPTION, RETRY, Results, worst_status ] class BuilderStatus(styles.Versioned): """I handle status information for a single process.build.Builder object. That object sends status changes to me (frequently as Events), and I provide them on demand to the various status recipients, like the HTML waterfall display and the live status clients. It also sends build summaries to me, which I log and provide to status clients who aren't interested in seeing details of the individual build steps. I am responsible for maintaining the list of historic Events and Builds, pruning old ones, and loading them from / saving them to disk. I live in the buildbot.process.build.Builder object, in the .builder_status attribute. @type category: string @ivar category: user-defined category this builder belongs to; can be used to filter on in status clients """ implements(interfaces.IBuilderStatus, interfaces.IEventSource) persistenceVersion = 1 persistenceForgets = ( 'wasUpgraded', ) category = None currentBigState = "offline" # or idle/waiting/interlocked/building basedir = None # filled in by our parent def __init__(self, buildername, category, master, description): self.name = buildername self.category = category self.description = description self.master = master self.slavenames = [] self.events = [] # these three hold Events, and are used to retrieve the current # state of the boxes. self.lastBuildStatus = None #self.currentBig = None #self.currentSmall = None self.currentBuilds = [] self.nextBuild = None self.watchers = [] self.buildCache = LRUCache(self.cacheMiss) # persistence def __getstate__(self): # when saving, don't record transient stuff like what builds are # currently running, because they won't be there when we start back # up. Nor do we save self.watchers, nor anything that gets set by our # parent like .basedir and .status d = styles.Versioned.__getstate__(self) d['watchers'] = [] del d['buildCache'] for b in self.currentBuilds: b.saveYourself() # TODO: push a 'hey, build was interrupted' event del d['currentBuilds'] d.pop('pendingBuilds', None) del d['currentBigState'] del d['basedir'] del d['status'] del d['nextBuildNumber'] del d['master'] return d def __setstate__(self, d): # when loading, re-initialize the transient stuff. Remember that # upgradeToVersion1 and such will be called after this finishes. styles.Versioned.__setstate__(self, d) self.buildCache = LRUCache(self.cacheMiss) self.currentBuilds = [] self.watchers = [] self.slavenames = [] # self.basedir must be filled in by our parent # self.status must be filled in by our parent # self.master must be filled in by our parent def upgradeToVersion1(self): if hasattr(self, 'slavename'): self.slavenames = [self.slavename] del self.slavename if hasattr(self, 'nextBuildNumber'): del self.nextBuildNumber # determineNextBuildNumber chooses this self.wasUpgraded = True def determineNextBuildNumber(self): """Scan our directory of saved BuildStatus instances to determine what our self.nextBuildNumber should be. Set it one larger than the highest-numbered build we discover. This is called by the top-level Status object shortly after we are created or loaded from disk. """ existing_builds = [int(f) for f in os.listdir(self.basedir) if re.match("^\d+$", f)] if existing_builds: self.nextBuildNumber = max(existing_builds) + 1 else: self.nextBuildNumber = 0 def saveYourself(self): for b in self.currentBuilds: if not b.isFinished: # interrupted build, need to save it anyway. # BuildStatus.saveYourself will mark it as interrupted. b.saveYourself() filename = os.path.join(self.basedir, "builder") tmpfilename = filename + ".tmp" try: with open(tmpfilename, "wb") as f: dump(self, f, -1) if runtime.platformType == 'win32': # windows cannot rename a file on top of an existing one if os.path.exists(filename): os.unlink(filename) os.rename(tmpfilename, filename) except: log.msg("unable to save builder %s" % self.name) log.err() # build cache management def setCacheSize(self, size): self.buildCache.set_max_size(size) def makeBuildFilename(self, number): return os.path.join(self.basedir, "%d" % number) def getBuildByNumber(self, number): return self.buildCache.get(number) def loadBuildFromFile(self, number): filename = self.makeBuildFilename(number) try: log.msg("Loading builder %s's build %d from on-disk pickle" % (self.name, number)) with open(filename, "rb") as f: build = load(f) build.setProcessObjects(self, self.master) # (bug #1068) if we need to upgrade, we probably need to rewrite # this pickle, too. We determine this by looking at the list of # Versioned objects that have been unpickled, and (after doUpgrade) # checking to see if any of them set wasUpgraded. The Versioneds' # upgradeToVersionNN methods all set this. versioneds = styles.versionedsToUpgrade styles.doUpgrade() if True in [ hasattr(o, 'wasUpgraded') for o in versioneds.values() ]: log.msg("re-writing upgraded build pickle") build.saveYourself() # check that logfiles exist build.checkLogfiles() return build except IOError: raise IndexError("no such build %d" % number) except EOFError: raise IndexError("corrupted build pickle %d" % number) def cacheMiss(self, number, **kwargs): # If kwargs['val'] exists, this is a new value being added to # the cache. Just return it. if 'val' in kwargs: return kwargs['val'] # first look in currentBuilds for b in self.currentBuilds: if b.number == number: return b # then fall back to loading it from disk return self.loadBuildFromFile(number) def prune(self, events_only=False): # begin by pruning our own events eventHorizon = self.master.config.eventHorizon self.events = self.events[-eventHorizon:] if events_only: return # get the horizons straight buildHorizon = self.master.config.buildHorizon if buildHorizon is not None: earliest_build = self.nextBuildNumber - buildHorizon else: earliest_build = 0 logHorizon = self.master.config.logHorizon if logHorizon is not None: earliest_log = self.nextBuildNumber - logHorizon else: earliest_log = 0 if earliest_log < earliest_build: earliest_log = earliest_build if earliest_build == 0: return # skim the directory and delete anything that shouldn't be there anymore build_re = re.compile(r"^([0-9]+)$") build_log_re = re.compile(r"^([0-9]+)-.*$") # if the directory doesn't exist, bail out here if not os.path.exists(self.basedir): return for filename in os.listdir(self.basedir): num = None mo = build_re.match(filename) is_logfile = False if mo: num = int(mo.group(1)) else: mo = build_log_re.match(filename) if mo: num = int(mo.group(1)) is_logfile = True if num is None: continue if num in self.buildCache.cache: continue if (is_logfile and num < earliest_log) or num < earliest_build: pathname = os.path.join(self.basedir, filename) log.msg("pruning '%s'" % pathname) try: os.unlink(pathname) except OSError: pass # IBuilderStatus methods def getName(self): # if builderstatus page does show not up without any reason then # str(self.name) may be a workaround return self.name def setDescription(self, description): # used during reconfig self.description = description def getDescription(self): return self.description def getState(self): return (self.currentBigState, self.currentBuilds) def getSlaves(self): return [self.status.getSlave(name) for name in self.slavenames] def getPendingBuildRequestStatuses(self): db = self.status.master.db d = db.buildrequests.getBuildRequests(claimed=False, buildername=self.name) def make_statuses(brdicts): return [BuildRequestStatus(self.name, brdict['brid'], self.status) for brdict in brdicts] d.addCallback(make_statuses) return d def getCurrentBuilds(self): return self.currentBuilds def getLastFinishedBuild(self): b = self.getBuild(-1) if not (b and b.isFinished()): b = self.getBuild(-2) return b def setCategory(self, category): # used during reconfig self.category = category def getCategory(self): return self.category def getBuild(self, number): if number < 0: number = self.nextBuildNumber + number if number < 0 or number >= self.nextBuildNumber: return None try: return self.getBuildByNumber(number) except IndexError: return None def getEvent(self, number): try: return self.events[number] except IndexError: return None def _getBuildBranches(self, build): return set([ ss.branch for ss in build.getSourceStamps() ]) def generateFinishedBuilds(self, branches=[], num_builds=None, max_buildnum=None, finished_before=None, results=None, max_search=200): got = 0 branches = set(branches) for Nb in itertools.count(1): if Nb > self.nextBuildNumber: break if Nb > max_search: break build = self.getBuild(-Nb) if build is None: continue if max_buildnum is not None: if build.getNumber() > max_buildnum: continue if not build.isFinished(): continue if finished_before is not None: start, end = build.getTimes() if end >= finished_before: continue # if we were asked to filter on branches, and none of the # sourcestamps match, skip this build if branches and not branches & self._getBuildBranches(build): continue if results is not None: if build.getResults() not in results: continue got += 1 yield build if num_builds is not None: if got >= num_builds: return def eventGenerator(self, branches=[], categories=[], committers=[], minTime=0): """This function creates a generator which will provide all of this Builder's status events, starting with the most recent and progressing backwards in time. """ # remember the oldest-to-earliest flow here. "next" means earlier. # TODO: interleave build steps and self.events by timestamp. # TODO: um, I think we're already doing that. # TODO: there's probably something clever we could do here to # interleave two event streams (one from self.getBuild and the other # from self.getEvent), which would be simpler than this control flow eventIndex = -1 e = self.getEvent(eventIndex) branches = set(branches) for Nb in range(1, self.nextBuildNumber+1): b = self.getBuild(-Nb) if not b: # HACK: If this is the first build we are looking at, it is # possible it's in progress but locked before it has written a # pickle; in this case keep looking. if Nb == 1: continue break if b.getTimes()[0] < minTime: break # if we were asked to filter on branches, and none of the # sourcestamps match, skip this build if branches and not branches & self._getBuildBranches(b): continue if categories and not b.getBuilder().getCategory() in categories: continue if committers and not [True for c in b.getChanges() if c.who in committers]: continue steps = b.getSteps() for Ns in range(1, len(steps)+1): if steps[-Ns].started: step_start = steps[-Ns].getTimes()[0] while e is not None and e.getTimes()[0] > step_start: yield e eventIndex -= 1 e = self.getEvent(eventIndex) yield steps[-Ns] yield b while e is not None: yield e eventIndex -= 1 e = self.getEvent(eventIndex) if e and e.getTimes()[0] < minTime: break def subscribe(self, receiver): # will get builderChangedState, buildStarted, buildFinished, # requestSubmitted, requestCancelled. Note that a request which is # resubmitted (due to a slave disconnect) will cause requestSubmitted # to be invoked multiple times. self.watchers.append(receiver) self.publishState(receiver) # our parent Status provides requestSubmitted and requestCancelled self.status._builder_subscribe(self.name, receiver) def unsubscribe(self, receiver): self.watchers.remove(receiver) self.status._builder_unsubscribe(self.name, receiver) ## Builder interface (methods called by the Builder which feeds us) def setSlavenames(self, names): self.slavenames = names def addEvent(self, text=[]): # this adds a duration event. When it is done, the user should call # e.finish(). They can also mangle it by modifying .text e = Event() e.started = util.now() e.text = text self.events.append(e) self.prune(events_only=True) return e # they are free to mangle it further def addPointEvent(self, text=[]): # this adds a point event, one which occurs as a single atomic # instant of time. e = Event() e.started = util.now() e.finished = 0 e.text = text self.events.append(e) self.prune(events_only=True) return e # for consistency, but they really shouldn't touch it def setBigState(self, state): needToUpdate = state != self.currentBigState self.currentBigState = state if needToUpdate: self.publishState() def publishState(self, target=None): state = self.currentBigState if target is not None: # unicast target.builderChangedState(self.name, state) return for w in self.watchers: try: w.builderChangedState(self.name, state) except: log.msg("Exception caught publishing state to %r" % w) log.err() def newBuild(self): """The Builder has decided to start a build, but the Build object is not yet ready to report status (it has not finished creating the Steps). Create a BuildStatus object that it can use.""" number = self.nextBuildNumber self.nextBuildNumber += 1 # TODO: self.saveYourself(), to make sure we don't forget about the # build number we've just allocated. This is not quite as important # as it was before we switch to determineNextBuildNumber, but I think # it may still be useful to have the new build save itself. s = BuildStatus(self, self.master, number) s.waitUntilFinished().addCallback(self._buildFinished) return s # buildStarted is called by our child BuildStatus instances def buildStarted(self, s): """Now the BuildStatus object is ready to go (it knows all of its Steps, its ETA, etc), so it is safe to notify our watchers.""" assert s.builder is self # paranoia assert s not in self.currentBuilds self.currentBuilds.append(s) self.buildCache.get(s.number, val=s) # now that the BuildStatus is prepared to answer queries, we can # announce the new build to all our watchers for w in self.watchers: # TODO: maybe do this later? callLater(0)? try: receiver = w.buildStarted(self.getName(), s) if receiver: if type(receiver) == type(()): s.subscribe(receiver[0], receiver[1]) else: s.subscribe(receiver) d = s.waitUntilFinished() d.addCallback(lambda s: s.unsubscribe(receiver)) except: log.msg("Exception caught notifying %r of buildStarted event" % w) log.err() def _buildFinished(self, s): assert s in self.currentBuilds s.saveYourself() self.currentBuilds.remove(s) name = self.getName() results = s.getResults() for w in self.watchers: try: w.buildFinished(name, s, results) except: log.msg("Exception caught notifying %r of buildFinished event" % w) log.err() self.prune() # conserve disk def asDict(self): result = {} # Constant # TODO(maruel): Fix me. We don't want to leak the full path. result['basedir'] = os.path.basename(self.basedir) result['category'] = self.category result['slaves'] = self.slavenames result['schedulers'] = [ s.name for s in self.status.master.allSchedulers() if self.name in s.builderNames ] #result['url'] = self.parent.getURLForThing(self) # TODO(maruel): Add cache settings? Do we care? # Transient # Collect build numbers. # Important: Only grab the *cached* builds numbers to reduce I/O. current_builds = [b.getNumber() for b in self.currentBuilds] cached_builds = list(set(self.buildCache.keys() + current_builds)) cached_builds.sort() result['cachedBuilds'] = cached_builds result['currentBuilds'] = current_builds result['state'] = self.getState()[0] # lies, but we don't have synchronous access to this info; use # asDict_async instead result['pendingBuilds'] = 0 return result def asDict_async(self): """Just like L{asDict}, but with a nonzero pendingBuilds.""" result = self.asDict() d = self.getPendingBuildRequestStatuses() def combine(statuses): result['pendingBuilds'] = len(statuses) return result d.addCallback(combine) return d def getMetrics(self): return self.botmaster.parent.metrics # vim: set ts=4 sts=4 sw=4 et: buildbot-0.8.8/buildbot/status/buildrequest.py000066400000000000000000000115761222546025000215540ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from twisted.python import log from twisted.internet import defer from buildbot import interfaces from buildbot.util.eventual import eventually class BuildRequestStatus: implements(interfaces.IBuildRequestStatus) def __init__(self, buildername, brid, status): self.buildername = buildername self.brid = brid self.status = status self.master = status.master self._buildrequest = None self._buildrequest_lock = defer.DeferredLock() @defer.inlineCallbacks def _getBuildRequest(self): """ Get the underlying BuildRequest object for this status. This is a slow operation! @returns: BuildRequest instance or None, via Deferred """ # late binding to avoid an import cycle from buildbot.process import buildrequest # this is only set once, so no need to lock if we already have it if self._buildrequest: defer.returnValue(self._buildrequest) return yield self._buildrequest_lock.acquire() try: if not self._buildrequest: brd = yield self.master.db.buildrequests.getBuildRequest( self.brid) br = yield buildrequest.BuildRequest.fromBrdict(self.master, brd) self._buildrequest = br except: # try/finally isn't allowed in generators in older Pythons self._buildrequest_lock.release() raise self._buildrequest_lock.release() defer.returnValue(self._buildrequest) def buildStarted(self, build): self.status._buildrequest_buildStarted(build.status) self.builds.append(build.status) # methods called by our clients @defer.inlineCallbacks def getBsid(self): br = yield self._getBuildRequest() defer.returnValue(br.bsid) @defer.inlineCallbacks def getBuildProperties(self): br = yield self._getBuildRequest() defer.returnValue(br.properties) @defer.inlineCallbacks def getSourceStamp(self): br = yield self._getBuildRequest() defer.returnValue(br.source) def getBuilderName(self): return self.buildername @defer.inlineCallbacks def getBuilds(self): builder = self.status.getBuilder(self.getBuilderName()) builds = [] bdicts = yield self.master.db.builds.getBuildsForRequest(self.brid) buildnums = sorted([ bdict['number'] for bdict in bdicts ]) for buildnum in buildnums: bs = builder.getBuild(buildnum) if bs: builds.append(bs) defer.returnValue(builds) def subscribe(self, observer): d = self.getBuilds() def notify_old(oldbuilds): for bs in oldbuilds: eventually(observer, bs) d.addCallback(notify_old) d.addCallback(lambda _ : self.status._buildrequest_subscribe(self.brid, observer)) d.addErrback(log.err, 'while notifying subscribers') def unsubscribe(self, observer): self.status._buildrequest_unsubscribe(self.brid, observer) @defer.inlineCallbacks def getSubmitTime(self): br = yield self._getBuildRequest() defer.returnValue(br.submittedAt) def asDict(self): result = {} # Constant result['source'] = None # not available sync, sorry result['builderName'] = self.buildername result['submittedAt'] = None # not availably sync, sorry # Transient result['builds'] = [] # not available async, sorry return result @defer.inlineCallbacks def asDict_async(self): result = {} ss = yield self.getSourceStamp() result['source'] = ss.asDict() props = yield self.getBuildProperties() result['properties'] = props.asList() result['builderName'] = self.getBuilderName() result['submittedAt'] = yield self.getSubmitTime() builds = yield self.getBuilds() result['builds'] = [ build.asDict() for build in builds ] defer.returnValue(result) buildbot-0.8.8/buildbot/status/buildset.py000066400000000000000000000044721222546025000206540ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from buildbot import interfaces from buildbot.status.buildrequest import BuildRequestStatus class BuildSetStatus: implements(interfaces.IBuildSetStatus) def __init__(self, bsdict, status): self.id = bsdict['bsid'] self.bsdict = bsdict self.status = status self.master = status.master # methods for our clients def getReason(self): return self.bsdict['reason'] def getResults(self): return self.bsdict['results'] def getID(self): return self.bsdict['external_idstring'] def isFinished(self): return self.bsdict['complete'] def getBuilderNamesAndBuildRequests(self): # returns a Deferred; undocumented method that may be removed # without warning d = self.master.db.buildrequests.getBuildRequests(bsid=self.id) def get_objects(brdicts): return dict([ (brd['buildername'], BuildRequestStatus(brd['buildername'], brd['brid'], self.status)) for brd in brdicts ]) d.addCallback(get_objects) return d def getBuilderNames(self): d = self.master.db.buildrequests.getBuildRequests(bsid=self.id) def get_names(brdicts): return sorted([ brd['buildername'] for brd in brdicts ]) d.addCallback(get_names) return d def waitUntilFinished(self): return self.status._buildset_waitUntilFinished(self.id) def asDict(self): d = dict(self.bsdict) d["submitted_at"] = str(self.bsdict["submitted_at"]) return d buildbot-0.8.8/buildbot/status/buildstep.py000066400000000000000000000307641222546025000210370ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os from zope.interface import implements from twisted.persisted import styles from twisted.python import log from twisted.internet import reactor, defer from buildbot import interfaces, util from buildbot.status.logfile import LogFile, HTMLLogFile class BuildStepStatus(styles.Versioned): """ I represent a collection of output status for a L{buildbot.process.step.BuildStep}. Statistics contain any information gleaned from a step that is not in the form of a logfile. As an example, steps that run tests might gather statistics about the number of passed, failed, or skipped tests. @type progress: L{buildbot.status.progress.StepProgress} @cvar progress: tracks ETA for the step @type text: list of strings @cvar text: list of short texts that describe the command and its status @type text2: list of strings @cvar text2: list of short texts added to the overall build description @type logs: dict of string -> L{buildbot.status.logfile.LogFile} @ivar logs: logs of steps @type statistics: dict @ivar statistics: results from running this step """ # note that these are created when the Build is set up, before each # corresponding BuildStep has started. implements(interfaces.IBuildStepStatus, interfaces.IStatusEvent) persistenceVersion = 4 persistenceForgets = ( 'wasUpgraded', ) started = None finished = None progress = None text = [] results = None text2 = [] watchers = [] updates = {} finishedWatchers = [] statistics = {} step_number = None hidden = False def __init__(self, parent, master, step_number): assert interfaces.IBuildStatus(parent) self.build = parent self.step_number = step_number self.hidden = False self.logs = [] self.urls = {} self.watchers = [] self.updates = {} self.finishedWatchers = [] self.statistics = {} self.skipped = False self.master = master self.waitingForLocks = False def getName(self): """Returns a short string with the name of this step. This string may have spaces in it.""" return self.name def getBuild(self): return self.build def getTimes(self): return (self.started, self.finished) def getExpectations(self): """Returns a list of tuples (name, current, target).""" if not self.progress: return [] ret = [] metrics = self.progress.progress.keys() metrics.sort() for m in metrics: t = (m, self.progress.progress[m], self.progress.expectations[m]) ret.append(t) return ret def getLogs(self): return self.logs def getURLs(self): return self.urls.copy() def isStarted(self): return (self.started is not None) def isSkipped(self): return self.skipped def isFinished(self): return (self.finished is not None) def isHidden(self): return self.hidden def waitUntilFinished(self): if self.finished: d = defer.succeed(self) else: d = defer.Deferred() self.finishedWatchers.append(d) return d # while the step is running, the following methods make sense. # Afterwards they return None def getETA(self): if self.started is None: return None # not started yet if self.finished is not None: return None # already finished if not self.progress: return None # no way to predict return self.progress.remaining() # Once you know the step has finished, the following methods are legal. # Before this step has finished, they all return None. def getText(self): """Returns a list of strings which describe the step. These are intended to be displayed in a narrow column. If more space is available, the caller should join them together with spaces before presenting them to the user.""" return self.text def getResults(self): """Return a tuple describing the results of the step. 'result' is one of the constants in L{buildbot.status.builder}: SUCCESS, WARNINGS, FAILURE, or SKIPPED. 'strings' is an optional list of strings that the step wants to append to the overall build's results. These strings are usually more terse than the ones returned by getText(): in particular, successful Steps do not usually contribute any text to the overall build. @rtype: tuple of int, list of strings @returns: (result, strings) """ return (self.results, self.text2) def hasStatistic(self, name): """Return true if this step has a value for the given statistic. """ return self.statistics.has_key(name) def getStatistic(self, name, default=None): """Return the given statistic, if present """ return self.statistics.get(name, default) def getStatistics(self): return self.statistics.copy() # subscription interface def subscribe(self, receiver, updateInterval=10): # will get logStarted, logFinished, stepETAUpdate assert receiver not in self.watchers self.watchers.append(receiver) self.sendETAUpdate(receiver, updateInterval) def sendETAUpdate(self, receiver, updateInterval): self.updates[receiver] = None # they might unsubscribe during stepETAUpdate receiver.stepETAUpdate(self.build, self, self.getETA(), self.getExpectations()) if receiver in self.watchers: self.updates[receiver] = reactor.callLater(updateInterval, self.sendETAUpdate, receiver, updateInterval) def unsubscribe(self, receiver): if receiver in self.watchers: self.watchers.remove(receiver) if receiver in self.updates: if self.updates[receiver] is not None: self.updates[receiver].cancel() del self.updates[receiver] # methods to be invoked by the BuildStep def setName(self, stepname): self.name = stepname def setColor(self, color): log.msg("BuildStepStatus.setColor is no longer supported -- ignoring color %s" % (color,)) def setProgress(self, stepprogress): self.progress = stepprogress def setHidden(self, hidden): self.hidden = hidden def stepStarted(self): self.started = util.now() if self.build: self.build.stepStarted(self) def addLog(self, name): assert self.started # addLog before stepStarted won't notify watchers logfilename = self.build.generateLogfileName(self.name, name) log = LogFile(self, name, logfilename) self.logs.append(log) for w in self.watchers: receiver = w.logStarted(self.build, self, log) if receiver: log.subscribe(receiver, True) d = log.waitUntilFinished() d.addCallback(lambda log: log.unsubscribe(receiver)) d = log.waitUntilFinished() d.addCallback(self.logFinished) return log def addHTMLLog(self, name, html): assert self.started # addLog before stepStarted won't notify watchers logfilename = self.build.generateLogfileName(self.name, name) log = HTMLLogFile(self, name, logfilename, html) self.logs.append(log) for w in self.watchers: w.logStarted(self.build, self, log) w.logFinished(self.build, self, log) def logFinished(self, log): for w in self.watchers: w.logFinished(self.build, self, log) def addURL(self, name, url): self.urls[name] = url def setText(self, text): self.text = text for w in self.watchers: w.stepTextChanged(self.build, self, text) def setText2(self, text): self.text2 = text for w in self.watchers: w.stepText2Changed(self.build, self, text) def setStatistic(self, name, value): """Set the given statistic. Usually called by subclasses. """ self.statistics[name] = value def setSkipped(self, skipped): self.skipped = skipped def stepFinished(self, results): self.finished = util.now() self.results = results cld = [] # deferreds for log compression logCompressionLimit = self.master.config.logCompressionLimit for loog in self.logs: if not loog.isFinished(): loog.finish() # if log compression is on, and it's a real LogFile, # HTMLLogFiles aren't files if logCompressionLimit is not False and \ isinstance(loog, LogFile): if os.path.getsize(loog.getFilename()) > logCompressionLimit: loog_deferred = loog.compressLog() if loog_deferred: cld.append(loog_deferred) for r in self.updates.keys(): if self.updates[r] is not None: self.updates[r].cancel() del self.updates[r] watchers = self.finishedWatchers self.finishedWatchers = [] for w in watchers: w.callback(self) if cld: return defer.DeferredList(cld) def checkLogfiles(self): # filter out logs that have been deleted self.logs = [ l for l in self.logs if l.hasContents() ] def isWaitingForLocks(self): return self.waitingForLocks def setWaitingForLocks(self, waiting): self.waitingForLocks = waiting # persistence def __getstate__(self): d = styles.Versioned.__getstate__(self) del d['build'] # filled in when loading if d.has_key('progress'): del d['progress'] del d['watchers'] del d['finishedWatchers'] del d['updates'] del d['master'] return d def __setstate__(self, d): styles.Versioned.__setstate__(self, d) # self.build must be filled in by our parent # point the logs to this object self.watchers = [] self.finishedWatchers = [] self.updates = {} def setProcessObjects(self, build, master): self.build = build self.master = master for loog in self.logs: loog.step = self loog.master = master def upgradeToVersion1(self): if not hasattr(self, "urls"): self.urls = {} self.wasUpgraded = True def upgradeToVersion2(self): if not hasattr(self, "statistics"): self.statistics = {} self.wasUpgraded = True def upgradeToVersion3(self): if not hasattr(self, "step_number"): self.step_number = 0 self.wasUpgraded = True def upgradeToVersion4(self): if not hasattr(self, "hidden"): self.hidden = False self.wasUpgraded = True def asDict(self): result = {} # Constant result['name'] = self.getName() # Transient result['text'] = self.getText() result['results'] = self.getResults() result['isStarted'] = self.isStarted() result['isFinished'] = self.isFinished() result['statistics'] = self.statistics result['times'] = self.getTimes() result['expectations'] = self.getExpectations() result['eta'] = self.getETA() result['urls'] = self.getURLs() result['step_number'] = self.step_number result['hidden'] = self.hidden result['logs'] = [[l.getName(), self.build.builder.status.getURLForThing(l)] for l in self.getLogs()] return result buildbot-0.8.8/buildbot/status/client.py000066400000000000000000000464311222546025000203200ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.spread import pb from twisted.python import components, log as twlog from twisted.application import strports from twisted.cred import portal, checkers from buildbot import interfaces from zope.interface import Interface, implements from buildbot.status import logfile, base from buildbot.changes import changes from buildbot.util.eventual import eventually class IRemote(Interface): pass def makeRemote(obj): # we want IRemote(None) to be None, but you can't really do that with # adapters, so we fake it if obj is None: return None return IRemote(obj) class RemoteBuildSet(pb.Referenceable): def __init__(self, buildset): self.b = buildset def remote_getSourceStamp(self): return self.b.getSourceStamp() def remote_getReason(self): return self.b.getReason() def remote_getID(self): return self.b.getID() def remote_getBuilderNames(self): return self.b.getBuilderNames() # note: passes along the Deferred def remote_getBuildRequests(self): """Returns a list of (builderName, BuildRequest) tuples.""" d = self.b.getBuilderNamesAndBuildRequests() def add_remote(buildrequests): for k,v in buildrequests.iteritems(): buildrequests[k] = IRemote(v) return buildrequests.items() d.addCallback(add_remote) return d def remote_isFinished(self): return self.b.isFinished() def remote_waitUntilFinished(self): d = self.b.waitUntilFinished() d.addCallback(makeRemote) return d def remote_getResults(self): return self.b.getResults() components.registerAdapter(RemoteBuildSet, interfaces.IBuildSetStatus, IRemote) class RemoteBuilder(pb.Referenceable): def __init__(self, builder): self.b = builder def remote_getName(self): return self.b.getName() def remote_getCategory(self): return self.b.getCategory() def remote_getState(self): state, builds = self.b.getState() return (state, None, # TODO: remove leftover ETA [makeRemote(b) for b in builds]) def remote_getSlaves(self): return [IRemote(s) for s in self.b.getSlaves()] def remote_getLastFinishedBuild(self): return makeRemote(self.b.getLastFinishedBuild()) def remote_getCurrentBuilds(self): return [IRemote(b) for b in self.b.getCurrentBuilds()] def remote_getBuild(self, number): return makeRemote(self.b.getBuild(number)) def remote_getEvent(self, number): return IRemote(self.b.getEvent(number)) components.registerAdapter(RemoteBuilder, interfaces.IBuilderStatus, IRemote) class RemoteBuildRequest(pb.Referenceable): def __init__(self, buildreq): self.b = buildreq # mapping of observers (RemoteReference instances) to local callable # objects that have been passed to BuildRequestStatus.subscribe self.observers = [] def remote_getSourceStamp(self): # note that this now returns a Deferred return self.b.getSourceStamp() def remote_getBuilderName(self): return self.b.getBuilderName() def remote_subscribe(self, observer): """The observer's remote_newbuild method will be called (with two arguments: the RemoteBuild object, and our builderName) for each new Build that is created to handle this BuildRequest.""" def send(bs): d = observer.callRemote("newbuild", IRemote(bs), self.b.getBuilderName()) d.addErrback(twlog.err, "while calling client-side remote_newbuild") self.observers.append((observer, send)) self.b.subscribe(send) def remote_unsubscribe(self, observer): for i, (obs, send) in enumerate(self.observers): if obs == observer: del self.observers[i] self.b.unsubscribe(send) break components.registerAdapter(RemoteBuildRequest, interfaces.IBuildRequestStatus, IRemote) class RemoteBuild(pb.Referenceable): def __init__(self, build): self.b = build self.observers = [] def remote_getBuilderName(self): return self.b.getBuilder().getName() def remote_getNumber(self): return self.b.getNumber() def remote_getReason(self): return self.b.getReason() def remote_getChanges(self): return [IRemote(c) for c in self.b.getChanges()] def remote_getRevisions(self): return self.b.getRevisions() def remote_getResponsibleUsers(self): return self.b.getResponsibleUsers() def remote_getSteps(self): return [IRemote(s) for s in self.b.getSteps()] def remote_getTimes(self): return self.b.getTimes() def remote_isFinished(self): return self.b.isFinished() def remote_waitUntilFinished(self): # the Deferred returned by callRemote() will fire when this build is # finished d = self.b.waitUntilFinished() d.addCallback(lambda res: self) return d def remote_getETA(self): return self.b.getETA() def remote_getCurrentStep(self): return makeRemote(self.b.getCurrentStep()) def remote_getText(self): return self.b.getText() def remote_getResults(self): return self.b.getResults() def remote_getLogs(self): logs = {} for name,log in self.b.getLogs().items(): logs[name] = IRemote(log) return logs def remote_subscribe(self, observer, updateInterval=None): """The observer will have remote_stepStarted(buildername, build, stepname, step), remote_stepFinished(buildername, build, stepname, step, results), and maybe remote_buildETAUpdate(buildername, build, eta)) messages sent to it.""" self.observers.append(observer) s = BuildSubscriber(observer) self.b.subscribe(s, updateInterval) def remote_unsubscribe(self, observer): # TODO: is the observer automatically unsubscribed when the build # finishes? Or are they responsible for unsubscribing themselves # anyway? How do we avoid a race condition here? for o in self.observers: if o == observer: self.observers.remove(o) components.registerAdapter(RemoteBuild, interfaces.IBuildStatus, IRemote) class BuildSubscriber: def __init__(self, observer): self.observer = observer def buildETAUpdate(self, build, eta): self.observer.callRemote("buildETAUpdate", build.getBuilder().getName(), IRemote(build), eta) def stepStarted(self, build, step): self.observer.callRemote("stepStarted", build.getBuilder().getName(), IRemote(build), step.getName(), IRemote(step)) return None def stepFinished(self, build, step, results): self.observer.callRemote("stepFinished", build.getBuilder().getName(), IRemote(build), step.getName(), IRemote(step), results) class RemoteBuildStep(pb.Referenceable): def __init__(self, step): self.s = step def remote_getName(self): return self.s.getName() def remote_getBuild(self): return IRemote(self.s.getBuild()) def remote_getTimes(self): return self.s.getTimes() def remote_getExpectations(self): return self.s.getExpectations() def remote_getLogs(self): logs = {} for log in self.s.getLogs(): logs[log.getName()] = IRemote(log) return logs def remote_isFinished(self): return self.s.isFinished() def remote_waitUntilFinished(self): return self.s.waitUntilFinished() # returns a Deferred def remote_getETA(self): return self.s.getETA() def remote_getText(self): return self.s.getText() def remote_getResults(self): return self.s.getResults() components.registerAdapter(RemoteBuildStep, interfaces.IBuildStepStatus, IRemote) class RemoteSlave: def __init__(self, slave): self.s = slave def remote_getName(self): return self.s.getName() def remote_getAdmin(self): return self.s.getAdmin() def remote_getHost(self): return self.s.getHost() def remote_isConnected(self): return self.s.isConnected() components.registerAdapter(RemoteSlave, interfaces.ISlaveStatus, IRemote) class RemoteEvent: def __init__(self, event): self.e = event def remote_getTimes(self): return self.s.getTimes() def remote_getText(self): return self.s.getText() components.registerAdapter(RemoteEvent, interfaces.IStatusEvent, IRemote) class RemoteLog(pb.Referenceable): def __init__(self, log): self.l = log def remote_getName(self): return self.l.getName() def remote_isFinished(self): return self.l.isFinished() def remote_waitUntilFinished(self): d = self.l.waitUntilFinished() d.addCallback(lambda res: self) return d def remote_getText(self): return self.l.getText() def remote_getTextWithHeaders(self): return self.l.getTextWithHeaders() def remote_getChunks(self): return self.l.getChunks() # TODO: subscription interface components.registerAdapter(RemoteLog, logfile.LogFile, IRemote) # TODO: something similar for builder.HTMLLogfile ? class RemoteChange: def __init__(self, change): self.c = change def getWho(self): return self.c.who def getFiles(self): return self.c.files def getComments(self): return self.c.comments components.registerAdapter(RemoteChange, changes.Change, IRemote) class StatusClientPerspective(base.StatusReceiverPerspective): subscribed = None client = None def __init__(self, status): self.status = status # the IStatus self.subscribed_to_builders = [] # Builders to which we're subscribed self.subscribed_to = [] # everything else we're subscribed to def __getstate__(self): d = self.__dict__.copy() d['client'] = None return d def attached(self, mind): #twlog.msg("StatusClientPerspective.attached") return self def detached(self, mind): twlog.msg("PB client detached") self.client = None for name in self.subscribed_to_builders: twlog.msg(" unsubscribing from Builder(%s)" % name) self.status.getBuilder(name).unsubscribe(self) for s in self.subscribed_to: twlog.msg(" unsubscribe from %s" % s) s.unsubscribe(self) self.subscribed = None def perspective_subscribe(self, mode, interval, target): """The remote client wishes to subscribe to some set of events. 'target' will be sent remote messages when these events happen. 'mode' indicates which events are desired: it is a string with one of the following values: 'builders': builderAdded, builderRemoved 'builds': those plus builderChangedState, buildStarted, buildFinished 'steps': all those plus buildETAUpdate, stepStarted, stepFinished 'logs': all those plus stepETAUpdate, logStarted, logFinished 'full': all those plus logChunk (with the log contents) Messages are defined by buildbot.interfaces.IStatusReceiver . 'interval' is used to specify how frequently ETAUpdate messages should be sent. Raising or lowering the subscription level will take effect starting with the next build or step.""" assert mode in ("builders", "builds", "steps", "logs", "full") assert target twlog.msg("PB subscribe(%s)" % mode) self.client = target self.subscribed = mode self.interval = interval self.subscribed_to.append(self.status) # wait a moment before subscribing, so the new-builder messages # won't appear before this remote method finishes eventually(self.status.subscribe, self) return None def perspective_unsubscribe(self): twlog.msg("PB unsubscribe") self.status.unsubscribe(self) self.subscribed_to.remove(self.status) self.client = None def perspective_getBuildSets(self): """This returns tuples of (buildset, bsid), because that is much more convenient for tryclient.""" d = self.status.getBuildSets() def make_remotes(buildsets): return [(IRemote(s), s.id) for s in buildsets] d.addCallback(make_remotes) return d def perspective_getBuilderNames(self): return self.status.getBuilderNames() def perspective_getBuilder(self, name): b = self.status.getBuilder(name) return IRemote(b) def perspective_getSlave(self, name): s = self.status.getSlave(name) return IRemote(s) def perspective_ping(self): """Ping method to allow pb clients to validate their connections.""" return "pong" # IStatusReceiver methods, invoked if we've subscribed # mode >= builder def builderAdded(self, name, builder): self.client.callRemote("builderAdded", name, IRemote(builder)) if self.subscribed in ("builds", "steps", "logs", "full"): self.subscribed_to_builders.append(name) return self return None def builderChangedState(self, name, state): self.client.callRemote("builderChangedState", name, state, None) # TODO: remove leftover ETA argument def builderRemoved(self, name): if name in self.subscribed_to_builders: self.subscribed_to_builders.remove(name) self.client.callRemote("builderRemoved", name) def buildsetSubmitted(self, buildset): # TODO: deliver to client, somehow pass # mode >= builds def buildStarted(self, name, build): self.client.callRemote("buildStarted", name, IRemote(build)) if self.subscribed in ("steps", "logs", "full"): self.subscribed_to.append(build) return (self, self.interval) return None def buildFinished(self, name, build, results): if build in self.subscribed_to: # we might have joined during the build self.subscribed_to.remove(build) self.client.callRemote("buildFinished", name, IRemote(build), results) # mode >= steps def buildETAUpdate(self, build, eta): self.client.callRemote("buildETAUpdate", build.getBuilder().getName(), IRemote(build), eta) def stepStarted(self, build, step): # we add some information here so the client doesn't have to do an # extra round-trip self.client.callRemote("stepStarted", build.getBuilder().getName(), IRemote(build), step.getName(), IRemote(step)) if self.subscribed in ("logs", "full"): self.subscribed_to.append(step) return (self, self.interval) return None def stepFinished(self, build, step, results): self.client.callRemote("stepFinished", build.getBuilder().getName(), IRemote(build), step.getName(), IRemote(step), results) if step in self.subscribed_to: # eventually (through some new subscription method) we could # join in the middle of the step self.subscribed_to.remove(step) # mode >= logs def stepETAUpdate(self, build, step, ETA, expectations): self.client.callRemote("stepETAUpdate", build.getBuilder().getName(), IRemote(build), step.getName(), IRemote(step), ETA, expectations) def logStarted(self, build, step, log): # TODO: make the HTMLLog adapter rlog = IRemote(log, None) if not rlog: print "hey, couldn't adapt %s to IRemote" % log self.client.callRemote("logStarted", build.getBuilder().getName(), IRemote(build), step.getName(), IRemote(step), log.getName(), IRemote(log, None)) if self.subscribed in ("full",): self.subscribed_to.append(log) return self return None def logFinished(self, build, step, log): self.client.callRemote("logFinished", build.getBuilder().getName(), IRemote(build), step.getName(), IRemote(step), log.getName(), IRemote(log, None)) if log in self.subscribed_to: self.subscribed_to.remove(log) # mode >= full def logChunk(self, build, step, log, channel, text): self.client.callRemote("logChunk", build.getBuilder().getName(), IRemote(build), step.getName(), IRemote(step), log.getName(), IRemote(log), channel, text) class PBListener(base.StatusReceiverMultiService): """I am a listener for PB-based status clients.""" compare_attrs = ["port", "cred"] implements(portal.IRealm) def __init__(self, port, user="statusClient", passwd="clientpw"): base.StatusReceiverMultiService.__init__(self) if type(port) is int: port = "tcp:%d" % port self.port = port self.cred = (user, passwd) p = portal.Portal(self) c = checkers.InMemoryUsernamePasswordDatabaseDontUse() c.addUser(user, passwd) p.registerChecker(c) f = pb.PBServerFactory(p) s = strports.service(port, f) s.setServiceParent(self) def setServiceParent(self, parent): base.StatusReceiverMultiService.setServiceParent(self, parent) self.status = parent def requestAvatar(self, avatarID, mind, interface): assert interface == pb.IPerspective p = StatusClientPerspective(self.status) p.attached(mind) # perhaps .callLater(0) ? return (pb.IPerspective, p, lambda p=p,mind=mind: p.detached(mind)) buildbot-0.8.8/buildbot/status/event.py000066400000000000000000000021561222546025000201570ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from buildbot import interfaces, util class Event: implements(interfaces.IStatusEvent) started = None finished = None text = [] # IStatusEvent methods def getTimes(self): return (self.started, self.finished) def getText(self): return self.text def getLogs(self): return [] def finish(self): self.finished = util.now() buildbot-0.8.8/buildbot/status/html.py000066400000000000000000000015631222546025000200030ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # compatibility wrapper. This is currently the preferred place for master.cfg # to import from. from buildbot.status.web.baseweb import WebStatus _hush_pyflakes = [WebStatus] buildbot-0.8.8/buildbot/status/logfile.py000066400000000000000000000607551222546025000204700ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os from cStringIO import StringIO from bz2 import BZ2File from gzip import GzipFile from zope.interface import implements from twisted.python import log, runtime from twisted.internet import defer, threads, reactor from buildbot.util import netstrings from buildbot.util.eventual import eventually from buildbot import interfaces STDOUT = interfaces.LOG_CHANNEL_STDOUT STDERR = interfaces.LOG_CHANNEL_STDERR HEADER = interfaces.LOG_CHANNEL_HEADER ChunkTypes = ["stdout", "stderr", "header"] class LogFileScanner(netstrings.NetstringParser): def __init__(self, chunk_cb, channels=[]): self.chunk_cb = chunk_cb self.channels = channels netstrings.NetstringParser.__init__(self) def stringReceived(self, line): channel = int(line[0]) if not self.channels or (channel in self.channels): self.chunk_cb((channel, line[1:])) class LogFileProducer: """What's the plan? the LogFile has just one FD, used for both reading and writing. Each time you add an entry, fd.seek to the end and then write. Each reader (i.e. Producer) keeps track of their own offset. The reader starts by seeking to the start of the logfile, and reading forwards. Between each hunk of file they yield chunks, so they must remember their offset before yielding and re-seek back to that offset before reading more data. When their read() returns EOF, they're finished with the first phase of the reading (everything that's already been written to disk). After EOF, the remaining data is entirely in the current entries list. These entries are all of the same channel, so we can do one "".join and obtain a single chunk to be sent to the listener. But since that involves a yield, and more data might arrive after we give up control, we have to subscribe them before yielding. We can't subscribe them any earlier, otherwise they'd get data out of order. We're using a generator in the first place so that the listener can throttle us, which means they're pulling. But the subscription means we're pushing. Really we're a Producer. In the first phase we can be either a PullProducer or a PushProducer. In the second phase we're only a PushProducer. So the client gives a LogFileConsumer to File.subscribeConsumer . This Consumer must have registerProducer(), unregisterProducer(), and writeChunk(), and is just like a regular twisted.interfaces.IConsumer, except that writeChunk() takes chunks (tuples of (channel,text)) instead of the normal write() which takes just text. The LogFileConsumer is allowed to call stopProducing, pauseProducing, and resumeProducing on the producer instance it is given. """ paused = False subscribed = False BUFFERSIZE = 2048 def __init__(self, logfile, consumer): self.logfile = logfile self.consumer = consumer self.chunkGenerator = self.getChunks() consumer.registerProducer(self, True) def getChunks(self): f = self.logfile.getFile() offset = 0 chunks = [] p = LogFileScanner(chunks.append) f.seek(offset) data = f.read(self.BUFFERSIZE) offset = f.tell() while data: p.dataReceived(data) while chunks: yield chunks.pop(0) f.seek(offset) data = f.read(self.BUFFERSIZE) offset = f.tell() del f # now subscribe them to receive new entries self.subscribed = True self.logfile.watchers.append(self) d = self.logfile.waitUntilFinished() # then give them the not-yet-merged data if self.logfile.runEntries: channel = self.logfile.runEntries[0][0] text = "".join([c[1] for c in self.logfile.runEntries]) yield (channel, text) # now we've caught up to the present. Anything further will come from # the logfile subscription. We add the callback *after* yielding the # data from runEntries, because the logfile might have finished # during the yield. d.addCallback(self.logfileFinished) def stopProducing(self): # TODO: should we still call consumer.finish? probably not. self.paused = True self.consumer = None self.done() def done(self): if self.chunkGenerator: self.chunkGenerator = None # stop making chunks if self.subscribed: self.logfile.watchers.remove(self) self.subscribed = False def pauseProducing(self): self.paused = True def resumeProducing(self): # Twisted-1.3.0 has a bug which causes hangs when resumeProducing # calls transport.write (there is a recursive loop, fixed in 2.0 in # t.i.abstract.FileDescriptor.doWrite by setting the producerPaused # flag *before* calling resumeProducing). To work around this, we # just put off the real resumeProducing for a moment. This probably # has a performance hit, but I'm going to assume that the log files # are not retrieved frequently enough for it to be an issue. eventually(self._resumeProducing) def _resumeProducing(self): self.paused = False if not self.chunkGenerator: return try: while not self.paused: chunk = self.chunkGenerator.next() self.consumer.writeChunk(chunk) # we exit this when the consumer says to stop, or we run out # of chunks except StopIteration: # if the generator finished, it will have done releaseFile self.chunkGenerator = None # now everything goes through the subscription, and they don't get to # pause anymore def logChunk(self, build, step, logfile, channel, chunk): if self.consumer: self.consumer.writeChunk((channel, chunk)) def logfileFinished(self, logfile): self.done() if self.consumer: self.consumer.unregisterProducer() self.consumer.finish() self.consumer = None class LogFile: """ A LogFile keeps all of its contents on disk, in a non-pickle format to which new entries can easily be appended. The file on disk has a name like 12-log-compile-output, under the Builder's directory. The actual filename is generated (before the LogFile is created) by L{BuildStatus.generateLogfileName}. @ivar length: length of the data in the logfile (sum of chunk sizes; not the length of the on-disk encoding) """ implements(interfaces.IStatusLog, interfaces.ILogFile) finished = False length = 0 nonHeaderLength = 0 tailLength = 0 chunkSize = 10*1000 runLength = 0 # No max size by default # Don't keep a tail buffer by default logMaxTailSize = None maxLengthExceeded = False runEntries = [] # provided so old pickled builds will getChunks() ok entries = None BUFFERSIZE = 2048 filename = None # relative to the Builder's basedir openfile = None def __init__(self, parent, name, logfilename): """ @type parent: L{BuildStepStatus} @param parent: the Step that this log is a part of @type name: string @param name: the name of this log, typically 'output' @type logfilename: string @param logfilename: the Builder-relative pathname for the saved entries """ self.step = parent self.master = parent.build.builder.master self.name = name self.filename = logfilename fn = self.getFilename() if os.path.exists(fn): # the buildmaster was probably stopped abruptly, before the # BuilderStatus could be saved, so BuilderStatus.nextBuildNumber # is out of date, and we're overlapping with earlier builds now. # Warn about it, but then overwrite the old pickle file log.msg("Warning: Overwriting old serialized Build at %s" % fn) dirname = os.path.dirname(fn) if not os.path.exists(dirname): os.makedirs(dirname) self.openfile = open(fn, "w+") self.runEntries = [] self.watchers = [] self.finishedWatchers = [] self.tailBuffer = [] def getFilename(self): """ Get the base (uncompressed) filename for this log file. @returns: filename """ return os.path.join(self.step.build.builder.basedir, self.filename) def hasContents(self): """ Return true if this logfile's contents are available. For a newly created logfile, this is always true, but for a L{LogFile} instance that has been persisted, the logfiles themselves may have been deleted, in which case this method will return False. @returns: boolean """ return os.path.exists(self.getFilename() + '.bz2') or \ os.path.exists(self.getFilename() + '.gz') or \ os.path.exists(self.getFilename()) def getName(self): """ Get this logfile's name @returns: string """ return self.name def getStep(self): """ Get the L{BuildStepStatus} instance containing this logfile @returns: L{BuildStepStatus} instance """ return self.step def isFinished(self): """ Return true if this logfile is finished (that is, if it will not receive any additional data @returns: boolean """ return self.finished def waitUntilFinished(self): """ Return a Deferred that will fire when this logfile is finished, or will fire immediately if the logfile is already finished. """ if self.finished: d = defer.succeed(self) else: d = defer.Deferred() self.finishedWatchers.append(d) return d def getFile(self): """ Get an open file object for this log. The file may also be in use for writing, so it should not be closed by the caller, and the caller should not rely on its file position remaining constant between asynchronous code segments. @returns: file object """ if self.openfile: # this is the filehandle we're using to write to the log, so # don't close it! return self.openfile # otherwise they get their own read-only handle # try a compressed log first try: return BZ2File(self.getFilename() + ".bz2", "r") except IOError: pass try: return GzipFile(self.getFilename() + ".gz", "r") except IOError: pass return open(self.getFilename(), "r") def getText(self): # this produces one ginormous string return "".join(self.getChunks([STDOUT, STDERR], onlyText=True)) def getTextWithHeaders(self): return "".join(self.getChunks(onlyText=True)) def getChunks(self, channels=[], onlyText=False): # generate chunks for everything that was logged at the time we were # first called, so remember how long the file was when we started. # Don't read beyond that point. The current contents of # self.runEntries will follow. # this returns an iterator, which means arbitrary things could happen # while we're yielding. This will faithfully deliver the log as it # existed when it was started, and not return anything after that # point. To use this in subscribe(catchup=True) without missing any # data, you must insure that nothing will be added to the log during # yield() calls. f = self.getFile() if not self.finished: offset = 0 f.seek(0, 2) remaining = f.tell() else: offset = 0 remaining = None leftover = None if self.runEntries and (not channels or (self.runEntries[0][0] in channels)): leftover = (self.runEntries[0][0], "".join([c[1] for c in self.runEntries])) # freeze the state of the LogFile by passing a lot of parameters into # a generator return self._generateChunks(f, offset, remaining, leftover, channels, onlyText) def _generateChunks(self, f, offset, remaining, leftover, channels, onlyText): chunks = [] p = LogFileScanner(chunks.append, channels) f.seek(offset) if remaining is not None: data = f.read(min(remaining, self.BUFFERSIZE)) remaining -= len(data) else: data = f.read(self.BUFFERSIZE) offset = f.tell() while data: p.dataReceived(data) while chunks: channel, text = chunks.pop(0) if onlyText: yield text else: yield (channel, text) f.seek(offset) if remaining is not None: data = f.read(min(remaining, self.BUFFERSIZE)) remaining -= len(data) else: data = f.read(self.BUFFERSIZE) offset = f.tell() del f if leftover: if onlyText: yield leftover[1] else: yield leftover def readlines(self): """Return an iterator that produces newline-terminated lines, excluding header chunks.""" alltext = "".join(self.getChunks([STDOUT], onlyText=True)) io = StringIO(alltext) return io.readlines() def subscribe(self, receiver, catchup): if self.finished: return self.watchers.append(receiver) if catchup: for channel, text in self.getChunks(): # TODO: add logChunks(), to send over everything at once? receiver.logChunk(self.step.build, self.step, self, channel, text) def unsubscribe(self, receiver): if receiver in self.watchers: self.watchers.remove(receiver) def subscribeConsumer(self, consumer): p = LogFileProducer(self, consumer) p.resumeProducing() # interface used by the build steps to add things to the log def _merge(self): # merge all .runEntries (which are all of the same type) into a # single chunk for .entries if not self.runEntries: return channel = self.runEntries[0][0] text = "".join([c[1] for c in self.runEntries]) assert channel < 10, "channel number must be a single decimal digit" f = self.openfile f.seek(0, 2) offset = 0 while offset < len(text): size = min(len(text)-offset, self.chunkSize) f.write("%d:%d" % (1 + size, channel)) f.write(text[offset:offset+size]) f.write(",") offset += size self.runEntries = [] self.runLength = 0 def addEntry(self, channel, text, _no_watchers=False): """ Add an entry to the logfile. The C{channel} is one of L{STDOUT}, L{STDERR}, or L{HEADER}. The C{text} is the text to add to the logfile, which can be a unicode string or a bytestring which is presumed to be encoded with utf-8. This method cannot be called after the logfile is finished. @param channel: channel to add a chunk for @param text: chunk of text @param _no_watchers: private """ assert not self.finished, "logfile is already finished" if isinstance(text, unicode): text = text.encode('utf-8') # notify watchers first, before the chunk gets munged, so that they get # a complete picture of the actual log output # TODO: is this right, or should the watchers get a picture of the chunks? if not _no_watchers: for w in self.watchers: w.logChunk(self.step.build, self.step, self, channel, text) if channel != HEADER: # Truncate the log if it's more than logMaxSize bytes logMaxSize = self.master.config.logMaxSize logMaxTailSize = self.master.config.logMaxTailSize if logMaxSize: self.nonHeaderLength += len(text) if self.nonHeaderLength > logMaxSize: # Add a message about what's going on and truncate this # chunk if necessary if not self.maxLengthExceeded: if self.runEntries and channel != self.runEntries[0][0]: self._merge() i = -(self.nonHeaderLength - logMaxSize) trunc, text = text[:i], text[i:] self.runEntries.append((channel, trunc)) self._merge() msg = ("\nOutput exceeded %i bytes, remaining output " "has been truncated\n" % logMaxSize) self.runEntries.append((HEADER, msg)) self.maxLengthExceeded = True # and track the tail of the text if logMaxTailSize and text: # Update the tail buffer self.tailBuffer.append((channel, text)) self.tailLength += len(text) while self.tailLength > logMaxTailSize: # Drop some stuff off the beginning of the buffer c,t = self.tailBuffer.pop(0) n = len(t) self.tailLength -= n assert self.tailLength >= 0 return # we only add to .runEntries here. _merge() is responsible for adding # merged chunks to .entries if self.runEntries and channel != self.runEntries[0][0]: self._merge() self.runEntries.append((channel, text)) self.runLength += len(text) if self.runLength >= self.chunkSize: self._merge() self.length += len(text) def addStdout(self, text): """ Shortcut to add stdout text to the logfile @param text: text to add to the logfile """ self.addEntry(STDOUT, text) def addStderr(self, text): """ Shortcut to add stderr text to the logfile @param text: text to add to the logfile """ self.addEntry(STDERR, text) def addHeader(self, text): """ Shortcut to add header text to the logfile @param text: text to add to the logfile """ self.addEntry(HEADER, text) def finish(self): """ Finish the logfile, flushing any buffers and preventing any further writes to the log. """ self._merge() if self.tailBuffer: msg = "\nFinal %i bytes follow below:\n" % self.tailLength tmp = self.runEntries self.runEntries = [(HEADER, msg)] self._merge() self.runEntries = self.tailBuffer self._merge() self.runEntries = tmp self._merge() self.tailBuffer = [] if self.openfile: # we don't do an explicit close, because there might be readers # shareing the filehandle. As soon as they stop reading, the # filehandle will be released and automatically closed. self.openfile.flush() self.openfile = None self.finished = True watchers = self.finishedWatchers self.finishedWatchers = [] for w in watchers: w.callback(self) self.watchers = [] def compressLog(self): logCompressionMethod = self.master.config.logCompressionMethod # bail out if there's no compression support if logCompressionMethod == "bz2": compressed = self.getFilename() + ".bz2.tmp" elif logCompressionMethod == "gz": compressed = self.getFilename() + ".gz.tmp" else: return defer.succeed(None) def _compressLog(): infile = self.getFile() if logCompressionMethod == "bz2": cf = BZ2File(compressed, 'w') elif logCompressionMethod == "gz": cf = GzipFile(compressed, 'w') bufsize = 1024*1024 while True: buf = infile.read(bufsize) cf.write(buf) if len(buf) < bufsize: break cf.close() d = threads.deferToThread(_compressLog) def _renameCompressedLog(rv): if logCompressionMethod == "bz2": filename = self.getFilename() + '.bz2' else: filename = self.getFilename() + '.gz' if runtime.platformType == 'win32': # windows cannot rename a file on top of an existing one, so # fall back to delete-first. There are ways this can fail and # lose the builder's history, so we avoid using it in the # general (non-windows) case if os.path.exists(filename): os.unlink(filename) os.rename(compressed, filename) _tryremove(self.getFilename(), 1, 5) d.addCallback(_renameCompressedLog) def _cleanupFailedCompress(failure): log.msg("failed to compress %s" % self.getFilename()) if os.path.exists(compressed): _tryremove(compressed, 1, 5) failure.trap() # reraise the failure d.addErrback(_cleanupFailedCompress) return d # persistence stuff def __getstate__(self): d = self.__dict__.copy() del d['step'] # filled in upon unpickling del d['watchers'] del d['finishedWatchers'] del d['master'] d['entries'] = [] # let 0.6.4 tolerate the saved log. TODO: really? if d.has_key('finished'): del d['finished'] if d.has_key('openfile'): del d['openfile'] return d def __setstate__(self, d): self.__dict__ = d self.watchers = [] # probably not necessary self.finishedWatchers = [] # same # self.step must be filled in by our parent self.finished = True class HTMLLogFile: implements(interfaces.IStatusLog) filename = None def __init__(self, parent, name, logfilename, html): self.step = parent self.name = name self.filename = logfilename self.html = html def getName(self): return self.name # set in BuildStepStatus.addLog def getStep(self): return self.step def isFinished(self): return True def waitUntilFinished(self): return defer.succeed(self) def hasContents(self): return True def getText(self): return self.html # looks kinda like text def getTextWithHeaders(self): return self.html def getChunks(self): return [(STDERR, self.html)] def subscribe(self, receiver, catchup): pass def unsubscribe(self, receiver): pass def finish(self): pass def __getstate__(self): d = self.__dict__.copy() del d['step'] if d.has_key('master'): del d['master'] return d def _tryremove(filename, timeout, retries): """Try to remove a file, and if failed, try again in timeout. Increases the timeout by a factor of 4, and only keeps trying for another retries-amount of times. """ try: os.unlink(filename) except OSError: if retries > 0: reactor.callLater(timeout, _tryremove, filename, timeout * 4, retries - 1) else: log.msg("giving up on removing %s after over %d seconds" % (filename, timeout)) buildbot-0.8.8/buildbot/status/mail.py000066400000000000000000000772271222546025000177730ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import re import types from email.Message import Message from email.Utils import formatdate from email.MIMEText import MIMEText from email.MIMENonMultipart import MIMENonMultipart from email.MIMEMultipart import MIMEMultipart from StringIO import StringIO import urllib from zope.interface import implements from twisted.internet import defer, reactor from twisted.python import log as twlog try: from twisted.mail.smtp import ESMTPSenderFactory ESMTPSenderFactory = ESMTPSenderFactory # for pyflakes except ImportError: ESMTPSenderFactory = None have_ssl = True try: from twisted.internet import ssl from OpenSSL.SSL import SSLv3_METHOD except ImportError: have_ssl = False # this incantation teaches email to output utf-8 using 7- or 8-bit encoding, # although it has no effect before python-2.7. from email import Charset Charset.add_charset('utf-8', Charset.SHORTEST, None, 'utf-8') from buildbot import interfaces, util, config from buildbot.process.users import users from buildbot.status import base from buildbot.status.results import FAILURE, SUCCESS, WARNINGS, EXCEPTION, Results VALID_EMAIL = re.compile("[a-zA-Z0-9\.\_\%\-\+]+@[a-zA-Z0-9\.\_\%\-]+.[a-zA-Z]{2,6}") ENCODING = 'utf8' LOG_ENCODING = 'utf-8' class Domain(util.ComparableMixin): implements(interfaces.IEmailLookup) compare_attrs = ["domain"] def __init__(self, domain): assert "@" not in domain self.domain = domain def getAddress(self, name): """If name is already an email address, pass it through.""" if '@' in name: return name return name + "@" + self.domain def defaultMessage(mode, name, build, results, master_status): """Generate a buildbot mail message and return a tuple of message text and type.""" ss_list = build.getSourceStamps() prev = build.getPreviousBuild() text = "" if results == FAILURE: if "change" in mode and prev and prev.getResults() != results or \ "problem" in mode and prev and prev.getResults() != FAILURE: text += "The Buildbot has detected a new failure" else: text += "The Buildbot has detected a failed build" elif results == WARNINGS: text += "The Buildbot has detected a problem in the build" elif results == SUCCESS: if "change" in mode and prev and prev.getResults() != results: text += "The Buildbot has detected a restored build" else: text += "The Buildbot has detected a passing build" elif results == EXCEPTION: text += "The Buildbot has detected a build exception" projects = [] if ss_list: for ss in ss_list: if ss.project and ss.project not in projects: projects.append(ss.project) if not projects: projects = [master_status.getTitle()] text += " on builder %s while building %s.\n" % (name, ', '.join(projects)) if master_status.getURLForThing(build): text += "Full details are available at:\n %s\n" % master_status.getURLForThing(build) text += "\n" if master_status.getBuildbotURL(): text += "Buildbot URL: %s\n\n" % urllib.quote(master_status.getBuildbotURL(), '/:') text += "Buildslave for this Build: %s\n\n" % build.getSlavename() text += "Build Reason: %s\n" % build.getReason() for ss in ss_list: source = "" if ss and ss.branch: source += "[branch %s] " % ss.branch if ss and ss.revision: source += str(ss.revision) else: source += "HEAD" if ss and ss.patch: source += " (plus patch)" discriminator = "" if ss.codebase: discriminator = " '%s'" % ss.codebase text += "Build Source Stamp%s: %s\n" % (discriminator, source) text += "Blamelist: %s\n" % ",".join(build.getResponsibleUsers()) text += "\n" t = build.getText() if t: t = ": " + " ".join(t) else: t = "" if results == SUCCESS: text += "Build succeeded!\n" elif results == WARNINGS: text += "Build Had Warnings%s\n" % t else: text += "BUILD FAILED%s\n" % t text += "\n" text += "sincerely,\n" text += " -The Buildbot\n" text += "\n" return { 'body' : text, 'type' : 'plain' } def defaultGetPreviousBuild(current_build): return current_build.getPreviousBuild() class MailNotifier(base.StatusReceiverMultiService): """This is a status notifier which sends email to a list of recipients upon the completion of each build. It can be configured to only send out mail for certain builds, and only send messages when the build fails, or when it transitions from success to failure. It can also be configured to include various build logs in each message. By default, the message will be sent to the Interested Users list, which includes all developers who made changes in the build. You can add additional recipients with the extraRecipients argument. To get a simple one-message-per-build (say, for a mailing list), use sendToInterestedUsers=False, extraRecipients=['listaddr@example.org'] Each MailNotifier sends mail to a single set of recipients. To send different kinds of mail to different recipients, use multiple MailNotifiers. """ implements(interfaces.IEmailSender) compare_attrs = ["extraRecipients", "lookup", "fromaddr", "mode", "categories", "builders", "addLogs", "relayhost", "subject", "sendToInterestedUsers", "customMesg", "messageFormatter", "extraHeaders"] possible_modes = ("change", "failing", "passing", "problem", "warnings", "exception") def __init__(self, fromaddr, mode=("failing", "passing", "warnings"), categories=None, builders=None, addLogs=False, relayhost="localhost", buildSetSummary=False, subject="buildbot %(result)s in %(title)s on %(builder)s", lookup=None, extraRecipients=[], sendToInterestedUsers=True, customMesg=None, messageFormatter=defaultMessage, extraHeaders=None, addPatch=True, useTls=False, smtpUser=None, smtpPassword=None, smtpPort=25, previousBuildGetter=defaultGetPreviousBuild): """ @type fromaddr: string @param fromaddr: the email address to be used in the 'From' header. @type sendToInterestedUsers: boolean @param sendToInterestedUsers: if True (the default), send mail to all of the Interested Users. If False, only send mail to the extraRecipients list. @type extraRecipients: tuple of strings @param extraRecipients: a list of email addresses to which messages should be sent (in addition to the InterestedUsers list, which includes any developers who made Changes that went into this build). It is a good idea to create a small mailing list and deliver to that, then let subscribers come and go as they please. The addresses in this list are used literally (they are not processed by lookup). @type subject: string @param subject: a string to be used as the subject line of the message. %(builder)s will be replaced with the name of the builder which provoked the message. @type mode: list of strings @param mode: a list of MailNotifer.possible_modes: - "change": send mail about builds which change status - "failing": send mail about builds which fail - "passing": send mail about builds which succeed - "problem": send mail about a build which failed when the previous build passed - "warnings": send mail if a build contain warnings - "exception": send mail if a build fails due to an exception - "all": always send mail Defaults to ("failing", "passing", "warnings"). @type builders: list of strings @param builders: a list of builder names for which mail should be sent. Defaults to None (send mail for all builds). Use either builders or categories, but not both. @type categories: list of strings @param categories: a list of category names to serve status information for. Defaults to None (all categories). Use either builders or categories, but not both. @type addLogs: boolean @param addLogs: if True, include all build logs as attachments to the messages. These can be quite large. This can also be set to a list of log names, to send a subset of the logs. Defaults to False. @type addPatch: boolean @param addPatch: if True, include the patch when the source stamp includes one. @type relayhost: string @param relayhost: the host to which the outbound SMTP connection should be made. Defaults to 'localhost' @type buildSetSummary: boolean @param buildSetSummary: if True, this notifier will only send a summary email when a buildset containing any of its watched builds completes @type lookup: implementor of {IEmailLookup} @param lookup: object which provides IEmailLookup, which is responsible for mapping User names for Interested Users (which come from the VC system) into valid email addresses. If not provided, the notifier will only be able to send mail to the addresses in the extraRecipients list. Most of the time you can use a simple Domain instance. As a shortcut, you can pass as string: this will be treated as if you had provided Domain(str). For example, lookup='twistedmatrix.com' will allow mail to be sent to all developers whose SVN usernames match their twistedmatrix.com account names. @type customMesg: func @param customMesg: (this function is deprecated) @type messageFormatter: func @param messageFormatter: function taking (mode, name, build, result, master_status) and returning a dictionary containing two required keys "body" and "type", with a third optional key, "subject". The "body" key gives a string that contains the complete text of the message. The "type" key is the message type ('plain' or 'html'). The 'html' type should be used when generating an HTML message. The optional "subject" key gives the subject for the email. @type extraHeaders: dict @param extraHeaders: A dict of extra headers to add to the mail. It's best to avoid putting 'To', 'From', 'Date', 'Subject', or 'CC' in here. Both the names and values may be WithProperties instances. @type useTls: boolean @param useTls: Send emails using TLS and authenticate with the smtp host. Defaults to False. @type smtpUser: string @param smtpUser: The user that will attempt to authenticate with the relayhost when useTls is True. @type smtpPassword: string @param smtpPassword: The password that smtpUser will use when authenticating with relayhost. @type smtpPort: int @param smtpPort: The port that will be used when connecting to the relayhost. Defaults to 25. @type previousBuildGetter: func @param previousBuildGetter: function taking a BuildStatus instance returning a BuildStatus of the build previous to the one passed in. This allows to implement a relative ordering between builds other than the default one, which is chronological. """ base.StatusReceiverMultiService.__init__(self) if not isinstance(extraRecipients, (list, tuple)): config.error("extraRecipients must be a list or tuple") else: for r in extraRecipients: if not isinstance(r, str) or not VALID_EMAIL.search(r): config.error( "extra recipient %r is not a valid email" % (r,)) self.extraRecipients = extraRecipients self.sendToInterestedUsers = sendToInterestedUsers self.fromaddr = fromaddr if isinstance(mode, basestring): if mode == "all": mode = ("failing", "passing", "warnings", "exception") elif mode == "warnings": mode = ("failing", "warnings") else: mode = (mode,) for m in mode: if m not in self.possible_modes: config.error( "mode %s is not a valid mode" % (m,)) self.mode = mode self.categories = categories self.builders = builders self.addLogs = addLogs self.relayhost = relayhost if '\n' in subject: config.error( 'Newlines are not allowed in email subjects') self.subject = subject if lookup is not None: if type(lookup) is str: lookup = Domain(lookup) assert interfaces.IEmailLookup.providedBy(lookup) self.lookup = lookup self.customMesg = customMesg self.messageFormatter = messageFormatter if extraHeaders: if not isinstance(extraHeaders, dict): config.error("extraHeaders must be a dictionary") self.extraHeaders = extraHeaders self.addPatch = addPatch self.useTls = useTls self.smtpUser = smtpUser self.smtpPassword = smtpPassword self.smtpPort = smtpPort self.buildSetSummary = buildSetSummary self.buildSetSubscription = None self.getPreviousBuild = previousBuildGetter self.watched = [] self.master_status = None # you should either limit on builders or categories, not both if self.builders != None and self.categories != None: config.error( "Please specify only builders or categories to include - " + "not both.") if customMesg: config.error( "customMesg is deprecated; use messageFormatter instead") def setServiceParent(self, parent): """ @type parent: L{buildbot.master.BuildMaster} """ base.StatusReceiverMultiService.setServiceParent(self, parent) self.master_status = self.parent self.master_status.subscribe(self) self.master = self.master_status.master def startService(self): if self.buildSetSummary: self.buildSetSubscription = \ self.master.subscribeToBuildsetCompletions(self.buildsetFinished) base.StatusReceiverMultiService.startService(self) def stopService(self): if self.buildSetSubscription is not None: self.buildSetSubscription.unsubscribe() self.buildSetSubscription = None return base.StatusReceiverMultiService.stopService(self) def disownServiceParent(self): self.master_status.unsubscribe(self) self.master_status = None for w in self.watched: w.unsubscribe(self) return base.StatusReceiverMultiService.disownServiceParent(self) def builderAdded(self, name, builder): # only subscribe to builders we are interested in if self.categories != None and builder.category not in self.categories: return None self.watched.append(builder) return self # subscribe to this builder def builderRemoved(self, name): pass def builderChangedState(self, name, state): pass def buildStarted(self, name, build): pass def isMailNeeded(self, build, results): # here is where we actually do something. builder = build.getBuilder() if self.builders is not None and builder.name not in self.builders: return False # ignore this build if self.categories is not None and \ builder.category not in self.categories: return False # ignore this build prev = self.getPreviousBuild(build) if "change" in self.mode: if prev and prev.getResults() != results: return True if "failing" in self.mode and results == FAILURE: return True if "passing" in self.mode and results == SUCCESS: return True if "problem" in self.mode and results == FAILURE: if prev and prev.getResults() != FAILURE: return True if "warnings" in self.mode and results == WARNINGS: return True if "exception" in self.mode and results == EXCEPTION: return True return False def buildFinished(self, name, build, results): if ( not self.buildSetSummary and self.isMailNeeded(build, results) ): # for testing purposes, buildMessage returns a Deferred that fires # when the mail has been sent. To help unit tests, we return that # Deferred here even though the normal IStatusReceiver.buildFinished # signature doesn't do anything with it. If that changes (if # .buildFinished's return value becomes significant), we need to # rearrange this. return self.buildMessage(name, [build], results) return None def _gotBuilds(self, res, buildset): builds = [] for (builddictlist, builder) in res: for builddict in builddictlist: build = builder.getBuild(builddict['number']) if build is not None and self.isMailNeeded(build, build.results): builds.append(build) if builds: self.buildMessage("Buildset Complete: " + buildset['reason'], builds, buildset['results']) def _gotBuildRequests(self, breqs, buildset): dl = [] for breq in breqs: buildername = breq['buildername'] builder = self.master_status.getBuilder(buildername) d = self.master.db.builds.getBuildsForRequest(breq['brid']) d.addCallback(lambda builddictlist, builder=builder: (builddictlist, builder)) dl.append(d) d = defer.gatherResults(dl) d.addCallback(self._gotBuilds, buildset) def _gotBuildSet(self, buildset, bsid): d = self.master.db.buildrequests.getBuildRequests(bsid=bsid) d.addCallback(self._gotBuildRequests, buildset) def buildsetFinished(self, bsid, result): d = self.master.db.buildsets.getBuildset(bsid=bsid) d.addCallback(self._gotBuildSet, bsid) return d def getCustomMesgData(self, mode, name, build, results, master_status): # # logs is a list of tuples that contain the log # name, log url, and the log contents as a list of strings. # logs = list() for logf in build.getLogs(): logStep = logf.getStep() stepName = logStep.getName() logStatus, dummy = logStep.getResults() logName = logf.getName() logs.append(('%s.%s' % (stepName, logName), '%s/steps/%s/logs/%s' % ( master_status.getURLForThing(build), stepName, logName), logf.getText().splitlines(), logStatus)) attrs = {'builderName': name, 'title': master_status.getTitle(), 'mode': mode, 'result': Results[results], 'buildURL': master_status.getURLForThing(build), 'buildbotURL': master_status.getBuildbotURL(), 'buildText': build.getText(), 'buildProperties': build.getProperties(), 'slavename': build.getSlavename(), 'reason': build.getReason().replace('\n', ''), 'responsibleUsers': build.getResponsibleUsers(), 'branch': "", 'revision': "", 'patch': "", 'patch_info': "", 'changes': [], 'logs': logs} ss = None ss_list = build.getSourceStamps() if ss_list: if len(ss_list) == 1: ss = ss_list[0] if ss: attrs['branch'] = ss.branch attrs['revision'] = ss.revision attrs['patch'] = ss.patch attrs['patch_info'] = ss.patch_info attrs['changes'] = ss.changes[:] else: for key in ['branch', 'revision', 'patch', 'patch_info', 'changes']: attrs[key] = {} for ss in ss_list: attrs['branch'][ss.codebase] = ss.branch attrs['revision'][ss.codebase] = ss.revision attrs['patch'][ss.codebase] = ss.patch attrs['patch_info'][ss.codebase] = ss.patch_info attrs['changes'][ss.codebase] = ss.changes[:] return attrs def patch_to_attachment(self, patch, index): # patches don't come with an encoding. If the patch is valid utf-8, # we'll attach it as MIMEText; otherwise, it gets attached as a binary # file. This will suit the vast majority of cases, since utf8 is by # far the most common encoding. if type(patch[1]) != types.UnicodeType: try: unicode = patch[1].decode('utf8') except UnicodeDecodeError: unicode = None else: unicode = patch[1] if unicode: a = MIMEText(unicode.encode(ENCODING), _charset=ENCODING) else: # MIMEApplication is not present in Python-2.4 :( a = MIMENonMultipart('application', 'octet-stream') a.set_payload(patch[1]) a.add_header('Content-Disposition', "attachment", filename="source patch " + str(index) ) return a def createEmail(self, msgdict, builderName, title, results, builds=None, patches=None, logs=None): text = msgdict['body'].encode(ENCODING) type = msgdict['type'] if 'subject' in msgdict: subject = msgdict['subject'].encode(ENCODING) else: subject = self.subject % { 'result': Results[results], 'projectName': title, 'title': title, 'builder': builderName, } assert '\n' not in subject, \ "Subject cannot contain newlines" assert type in ('plain', 'html'), \ "'%s' message type must be 'plain' or 'html'." % type if patches or logs: m = MIMEMultipart() m.attach(MIMEText(text, type, ENCODING)) else: m = Message() m.set_payload(text, ENCODING) m.set_type("text/%s" % type) m['Date'] = formatdate(localtime=True) m['Subject'] = subject m['From'] = self.fromaddr # m['To'] is added later if patches: for (i, patch) in enumerate(patches): a = self.patch_to_attachment(patch, i) m.attach(a) if logs: for log in logs: name = "%s.%s" % (log.getStep().getName(), log.getName()) if ( self._shouldAttachLog(log.getName()) or self._shouldAttachLog(name) ): text = log.getText() if not isinstance(text, unicode): # guess at the encoding, and use replacement symbols # for anything that's not in that encoding text = text.decode(LOG_ENCODING, 'replace') a = MIMEText(text.encode(ENCODING), _charset=ENCODING) a.add_header('Content-Disposition', "attachment", filename=name) m.attach(a) #@todo: is there a better way to do this? # Add any extra headers that were requested, doing WithProperties # interpolation if only one build was given if self.extraHeaders: if len(builds) == 1: d = builds[0].render(self.extraHeaders) else: d = defer.succeed(self.extraHeaders) @d.addCallback def addExtraHeaders(extraHeaders): for k,v in extraHeaders.items(): if k in m: twlog.msg("Warning: Got header " + k + " in self.extraHeaders " "but it already exists in the Message - " "not adding it.") m[k] = v d.addCallback(lambda _: m) return d return defer.succeed(m) def buildMessageDict(self, name, build, results): if self.customMesg: # the customMesg stuff can be *huge*, so we prefer not to load it attrs = self.getCustomMesgData(self.mode, name, build, results, self.master_status) text, type = self.customMesg(attrs) msgdict = { 'body' : text, 'type' : type } else: msgdict = self.messageFormatter(self.mode, name, build, results, self.master_status) return msgdict def buildMessage(self, name, builds, results): patches = [] logs = [] msgdict = {"body":""} for build in builds: ss_list = build.getSourceStamps() if self.addPatch: for ss in ss_list: if ss.patch: patches.append(ss.patch) if self.addLogs: logs.extend(build.getLogs()) tmp = self.buildMessageDict(name=build.getBuilder().name, build=build, results=build.results) msgdict['body'] += tmp['body'] msgdict['body'] += '\n\n' msgdict['type'] = tmp['type'] if "subject" in tmp: msgdict['subject'] = tmp['subject'] d = self.createEmail(msgdict, name, self.master_status.getTitle(), results, builds, patches, logs) @d.addCallback def getRecipients(m): # now, who is this message going to? if self.sendToInterestedUsers: dl = [] for build in builds: if self.lookup: d = self.useLookup(build) else: d = self.useUsers(build) dl.append(d) d = defer.gatherResults(dl) else: d = defer.succeed([]) d.addCallback(self._gotRecipients, m) return d def useLookup(self, build): dl = [] for u in build.getResponsibleUsers() + build.getInterestedUsers(): d = defer.maybeDeferred(self.lookup.getAddress, u) dl.append(d) return defer.gatherResults(dl) def useUsers(self, build): return users.getBuildContacts(self.master, build, ['email']) def _shouldAttachLog(self, logname): if type(self.addLogs) is bool: return self.addLogs return logname in self.addLogs def _gotRecipients(self, rlist, m): to_recipients = set() cc_recipients = set() for r in reduce(list.__add__, rlist, []): if r is None: # getAddress didn't like this address continue # Git can give emails like 'User' @foo.com so check # for two @ and chop the last if r.count('@') > 1: r = r[:r.rindex('@')] if VALID_EMAIL.search(r): to_recipients.add(r) else: twlog.msg("INVALID EMAIL: %r" + r) # If we're sending to interested users put the extras in the # CC list so they can tell if they are also interested in the # change: if self.sendToInterestedUsers and to_recipients: cc_recipients.update(self.extraRecipients) else: to_recipients.update(self.extraRecipients) m['To'] = ", ".join(sorted(to_recipients)) if cc_recipients: m['CC'] = ", ".join(sorted(cc_recipients)) return self.sendMessage(m, list(to_recipients | cc_recipients)) def sendmail(self, s, recipients): result = defer.Deferred() if have_ssl and self.useTls: client_factory = ssl.ClientContextFactory() client_factory.method = SSLv3_METHOD else: client_factory = None if self.smtpUser and self.smtpPassword: useAuth = True else: useAuth = False if not ESMTPSenderFactory: raise RuntimeError("twisted-mail is not installed - cannot " "send mail") sender_factory = ESMTPSenderFactory( self.smtpUser, self.smtpPassword, self.fromaddr, recipients, StringIO(s), result, contextFactory=client_factory, requireTransportSecurity=self.useTls, requireAuthentication=useAuth) reactor.connectTCP(self.relayhost, self.smtpPort, sender_factory) return result def sendMessage(self, m, recipients): s = m.as_string() twlog.msg("sending mail (%d bytes) to" % len(s), recipients) return self.sendmail(s, recipients) buildbot-0.8.8/buildbot/status/master.py000066400000000000000000000423141222546025000203310ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os, urllib from cPickle import load from twisted.python import log from twisted.persisted import styles from twisted.internet import defer from twisted.application import service from zope.interface import implements from buildbot import config, interfaces, util from buildbot.util import bbcollections from buildbot.util.eventual import eventually from buildbot.changes import changes from buildbot.status import buildset, builder, buildrequest class Status(config.ReconfigurableServiceMixin, service.MultiService): implements(interfaces.IStatus) def __init__(self, master): service.MultiService.__init__(self) self.master = master self.botmaster = master.botmaster self.basedir = master.basedir self.watchers = [] # No default limit to the log size self.logMaxSize = None self._builder_observers = bbcollections.KeyedSets() self._buildreq_observers = bbcollections.KeyedSets() self._buildset_finished_waiters = bbcollections.KeyedSets() self._buildset_completion_sub = None self._buildset_sub = None self._build_request_sub = None self._change_sub = None # service management def startService(self): # subscribe to the things we need to know about self._buildset_completion_sub = \ self.master.subscribeToBuildsetCompletions( self._buildsetCompletionCallback) self._buildset_sub = \ self.master.subscribeToBuildsets( self._buildsetCallback) self._build_request_sub = \ self.master.subscribeToBuildRequests( self._buildRequestCallback) self._change_sub = \ self.master.subscribeToChanges( self.changeAdded) return service.MultiService.startService(self) @defer.inlineCallbacks def reconfigService(self, new_config): # remove the old listeners, then add the new for sr in list(self): yield defer.maybeDeferred(lambda : sr.disownServiceParent()) # WebStatus instances tend to "hang around" longer than we'd like - # if there's an ongoing HTTP request, or even a connection held # open by keepalive, then users may still be talking to an old # WebStatus. So WebStatus objects get to keep their `master` # attribute, but all other status objects lose theirs. And we want # to test this without importing WebStatus, so we use name if not sr.__class__.__name__.endswith('WebStatus'): sr.master = None for sr in new_config.status: sr.master = self.master sr.setServiceParent(self) # reconfig any newly-added change sources, as well as existing yield config.ReconfigurableServiceMixin.reconfigService(self, new_config) def stopService(self): if self._buildset_completion_sub: self._buildset_completion_sub.unsubscribe() self._buildset_completion_sub = None if self._buildset_sub: self._buildset_sub.unsubscribe() self._buildset_sub = None if self._build_request_sub: self._build_request_sub.unsubscribe() self._build_request_sub = None if self._change_sub: self._change_sub.unsubscribe() self._change_sub = None return service.MultiService.stopService(self) # clean shutdown @property def shuttingDown(self): return self.botmaster.shuttingDown def cleanShutdown(self): return self.botmaster.cleanShutdown() def cancelCleanShutdown(self): return self.botmaster.cancelCleanShutdown() # methods called by our clients def getTitle(self): return self.master.config.title def getTitleURL(self): return self.master.config.titleURL def getBuildbotURL(self): return self.master.config.buildbotURL def getStatus(self): # some listeners expect their .parent to be a BuildMaster object, and # use this method to get the Status object. This is documented, so for # now keep it working. return self def getMetrics(self): return self.master.metrics def getURLForBuild(self, builder_name, build_number): prefix = self.getBuildbotURL() return prefix + "builders/%s/builds/%d" % ( urllib.quote(builder_name, safe=''), build_number) def getURLForThing(self, thing): prefix = self.getBuildbotURL() if not prefix: return None if interfaces.IStatus.providedBy(thing): return prefix if interfaces.ISchedulerStatus.providedBy(thing): pass if interfaces.IBuilderStatus.providedBy(thing): bldr = thing return prefix + "builders/%s" % ( urllib.quote(bldr.getName(), safe=''), ) if interfaces.IBuildStatus.providedBy(thing): build = thing bldr = build.getBuilder() return self.getURLForBuild(bldr.getName(), build.getNumber()) if interfaces.IBuildStepStatus.providedBy(thing): step = thing build = step.getBuild() bldr = build.getBuilder() return prefix + "builders/%s/builds/%d/steps/%s" % ( urllib.quote(bldr.getName(), safe=''), build.getNumber(), urllib.quote(step.getName(), safe='')) # IBuildSetStatus # IBuildRequestStatus # ISlaveStatus if interfaces.ISlaveStatus.providedBy(thing): slave = thing return prefix + "buildslaves/%s" % ( urllib.quote(slave.getName(), safe=''), ) # IStatusEvent if interfaces.IStatusEvent.providedBy(thing): # TODO: this is goofy, create IChange or something if isinstance(thing, changes.Change): change = thing return "%schanges/%d" % (prefix, change.number) if interfaces.IStatusLog.providedBy(thing): loog = thing step = loog.getStep() build = step.getBuild() bldr = build.getBuilder() logs = step.getLogs() for i in range(len(logs)): if loog is logs[i]: break else: return None return prefix + "builders/%s/builds/%d/steps/%s/logs/%s" % ( urllib.quote(bldr.getName(), safe=''), build.getNumber(), urllib.quote(step.getName(), safe=''), urllib.quote(loog.getName(), safe='')) def getChangeSources(self): return list(self.master.change_svc) def getChange(self, number): """Get a Change object; returns a deferred""" d = self.master.db.changes.getChange(number) def chdict2change(chdict): if not chdict: return None return changes.Change.fromChdict(self.master, chdict) d.addCallback(chdict2change) return d def getSchedulers(self): return self.master.allSchedulers() def getBuilderNames(self, categories=None): if categories == None: return util.naturalSort(self.botmaster.builderNames) # don't let them break it l = [] # respect addition order for name in self.botmaster.builderNames: bldr = self.botmaster.builders[name] if bldr.config.category in categories: l.append(name) return util.naturalSort(l) def getBuilder(self, name): """ @rtype: L{BuilderStatus} """ return self.botmaster.builders[name].builder_status def getSlaveNames(self): return self.botmaster.slaves.keys() def getSlave(self, slavename): return self.botmaster.slaves[slavename].slave_status def getBuildSets(self): d = self.master.db.buildsets.getBuildsets(complete=False) def make_status_objects(bsdicts): return [ buildset.BuildSetStatus(bsdict, self) for bsdict in bsdicts ] d.addCallback(make_status_objects) return d def generateFinishedBuilds(self, builders=[], branches=[], num_builds=None, finished_before=None, max_search=200): def want_builder(bn): if builders: return bn in builders return True builder_names = [bn for bn in self.getBuilderNames() if want_builder(bn)] # 'sources' is a list of generators, one for each Builder we're # using. When the generator is exhausted, it is replaced in this list # with None. sources = [] for bn in builder_names: b = self.getBuilder(bn) g = b.generateFinishedBuilds(branches, finished_before=finished_before, max_search=max_search) sources.append(g) # next_build the next build from each source next_build = [None] * len(sources) def refill(): for i,g in enumerate(sources): if next_build[i]: # already filled continue if not g: # already exhausted continue try: next_build[i] = g.next() except StopIteration: next_build[i] = None sources[i] = None got = 0 while True: refill() # find the latest build among all the candidates candidates = [(i, b, b.getTimes()[1]) for i,b in enumerate(next_build) if b is not None] candidates.sort(lambda x,y: cmp(x[2], y[2])) if not candidates: return # and remove it from the list i, build, finshed_time = candidates[-1] next_build[i] = None got += 1 yield build if num_builds is not None: if got >= num_builds: return def subscribe(self, target): self.watchers.append(target) for name in self.botmaster.builderNames: self.announceNewBuilder(target, name, self.getBuilder(name)) def unsubscribe(self, target): self.watchers.remove(target) # methods called by upstream objects def announceNewBuilder(self, target, name, builder_status): t = target.builderAdded(name, builder_status) if t: builder_status.subscribe(t) def builderAdded(self, name, basedir, category=None, description=None): """ @rtype: L{BuilderStatus} """ filename = os.path.join(self.basedir, basedir, "builder") log.msg("trying to load status pickle from %s" % filename) builder_status = None try: with open(filename, "rb") as f: builder_status = load(f) builder_status.master = self.master # (bug #1068) if we need to upgrade, we probably need to rewrite # this pickle, too. We determine this by looking at the list of # Versioned objects that have been unpickled, and (after doUpgrade) # checking to see if any of them set wasUpgraded. The Versioneds' # upgradeToVersionNN methods all set this. versioneds = styles.versionedsToUpgrade styles.doUpgrade() if True in [ hasattr(o, 'wasUpgraded') for o in versioneds.values() ]: log.msg("re-writing upgraded builder pickle") builder_status.saveYourself() except IOError: log.msg("no saved status pickle, creating a new one") except: log.msg("error while loading status pickle, creating a new one") log.msg("error follows:") log.err() if not builder_status: builder_status = builder.BuilderStatus(name, category, self.master, description) builder_status.addPointEvent(["builder", "created"]) log.msg("added builder %s in category %s" % (name, category)) # an unpickled object might not have category set from before, # so set it here to make sure builder_status.category = category builder_status.description = description builder_status.master = self.master builder_status.basedir = os.path.join(self.basedir, basedir) builder_status.name = name # it might have been updated builder_status.status = self if not os.path.isdir(builder_status.basedir): os.makedirs(builder_status.basedir) builder_status.determineNextBuildNumber() builder_status.setBigState("offline") for t in self.watchers: self.announceNewBuilder(t, name, builder_status) return builder_status def builderRemoved(self, name): for t in self.watchers: if hasattr(t, 'builderRemoved'): t.builderRemoved(name) def slaveConnected(self, name): for t in self.watchers: if hasattr(t, 'slaveConnected'): t.slaveConnected(name) def slaveDisconnected(self, name): for t in self.watchers: if hasattr(t, 'slaveDisconnected'): t.slaveDisconnected(name) def changeAdded(self, change): for t in self.watchers: if hasattr(t, 'changeAdded'): t.changeAdded(change) def asDict(self): result = {} # Constant result['title'] = self.getTitle() result['titleURL'] = self.getTitleURL() result['buildbotURL'] = self.getBuildbotURL() # TODO: self.getSchedulers() # self.getChangeSources() return result def build_started(self, brid, buildername, build_status): if brid in self._buildreq_observers: for o in self._buildreq_observers[brid]: eventually(o, build_status) def _buildrequest_subscribe(self, brid, observer): self._buildreq_observers.add(brid, observer) def _buildrequest_unsubscribe(self, brid, observer): self._buildreq_observers.discard(brid, observer) def _buildset_waitUntilFinished(self, bsid): d = defer.Deferred() self._buildset_finished_waiters.add(bsid, d) self._maybeBuildsetFinished(bsid) return d def _maybeBuildsetFinished(self, bsid): # check bsid to see if it's successful or finished, and notify anyone # who cares if bsid not in self._buildset_finished_waiters: return d = self.master.db.buildsets.getBuildset(bsid) def do_notifies(bsdict): bss = buildset.BuildSetStatus(bsdict, self) if bss.isFinished(): for d in self._buildset_finished_waiters.pop(bsid): eventually(d.callback, bss) d.addCallback(do_notifies) d.addErrback(log.err, 'while notifying for buildset finishes') def _builder_subscribe(self, buildername, watcher): # should get requestSubmitted and requestCancelled self._builder_observers.add(buildername, watcher) def _builder_unsubscribe(self, buildername, watcher): self._builder_observers.discard(buildername, watcher) def _buildsetCallback(self, **kwargs): bsid = kwargs['bsid'] d = self.master.db.buildsets.getBuildset(bsid) def do_notifies(bsdict): bss = buildset.BuildSetStatus(bsdict, self) for t in self.watchers: if hasattr(t, 'buildsetSubmitted'): t.buildsetSubmitted(bss) d.addCallback(do_notifies) d.addErrback(log.err, 'while notifying buildsetSubmitted') def _buildsetCompletionCallback(self, bsid, result): self._maybeBuildsetFinished(bsid) def _buildRequestCallback(self, notif): buildername = notif['buildername'] if buildername in self._builder_observers: brs = buildrequest.BuildRequestStatus(buildername, notif['brid'], self) for observer in self._builder_observers[buildername]: if hasattr(observer, 'requestSubmitted'): eventually(observer.requestSubmitted, brs) buildbot-0.8.8/buildbot/status/persistent_queue.py000066400000000000000000000302221222546025000224350ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement from collections import deque import os import cPickle as pickle from zope.interface import implements, Interface def ReadFile(path): with open(path, 'rb') as f: return f.read() def WriteFile(path, buf): with open(path, 'wb') as f: f.write(buf) class IQueue(Interface): """Abstraction of a queue.""" def pushItem(item): """Adds an individual item to the end of the queue. Returns an item if it was overflowed.""" def insertBackChunk(items): """Adds a list of items as the oldest entries. Normally called in case of failure to process the data, queue the data back so it can be retrieved at a later time. Returns a list of items if it was overflowed.""" def popChunk(nbItems=None): """Pop many items at once. Defaults to self.maxItems().""" def save(): """Save the queue to storage if implemented.""" def items(): """Returns items in the queue. Warning: Can be extremely slow for queue on disk.""" def nbItems(): """Returns the number of items in the queue.""" def maxItems(): """Returns the maximum number of items this queue can hold.""" class MemoryQueue(object): """Simple length bounded queue using deque. list.pop(0) operation is O(n) so for a 10000 items list, it can start to be real slow. On the contrary, deque.popleft() is O(1) most of the time. See http://docs.python.org/library/collections.html for more information. """ implements(IQueue) def __init__(self, maxItems=None): self._maxItems = maxItems if self._maxItems is None: self._maxItems = 10000 self._items = deque() def pushItem(self, item): ret = None if len(self._items) == self._maxItems: ret = self._items.popleft() self._items.append(item) return ret def insertBackChunk(self, chunk): ret = None excess = len(self._items) + len(chunk) - self._maxItems if excess > 0: ret = chunk[0:excess] chunk = chunk[excess:] self._items.extendleft(reversed(chunk)) return ret def popChunk(self, nbItems=None): if nbItems is None: nbItems = self._maxItems if nbItems > len(self._items): items = list(self._items) self._items = deque() else: items = [] for i in range(nbItems): items.append(self._items.popleft()) return items def save(self): pass def items(self): return list(self._items) def nbItems(self): return len(self._items) def maxItems(self): return self._maxItems class DiskQueue(object): """Keeps a list of abstract items and serializes it to the disk. Use pickle for serialization.""" implements(IQueue) def __init__(self, path, maxItems=None, pickleFn=pickle.dumps, unpickleFn=pickle.loads): """ @path: directory to save the items. @maxItems: maximum number of items to keep on disk, flush the older ones. @pickleFn: function used to pack the items to disk. @unpickleFn: function used to unpack items from disk. """ self.path = path self._maxItems = maxItems if self._maxItems is None: self._maxItems = 100000 if not os.path.isdir(self.path): os.mkdir(self.path) self.pickleFn = pickleFn self.unpickleFn = unpickleFn # Total number of items. self._nbItems = 0 # The actual items id start at one. self.firstItemId = 0 self.lastItemId = 0 self._loadFromDisk() def pushItem(self, item): ret = None if self._nbItems == self._maxItems: id = self._findNext(self.firstItemId) path = os.path.join(self.path, str(id)) ret = self.unpickleFn(ReadFile(path)) os.remove(path) self.firstItemId = id + 1 else: self._nbItems += 1 self.lastItemId += 1 path = os.path.join(self.path, str(self.lastItemId)) if os.path.exists(path): raise IOError('%s already exists.' % path) WriteFile(path, self.pickleFn(item)) return ret def insertBackChunk(self, chunk): ret = None excess = self._nbItems + len(chunk) - self._maxItems if excess > 0: ret = chunk[0:excess] chunk = chunk[excess:] for i in reversed(chunk): self.firstItemId -= 1 path = os.path.join(self.path, str(self.firstItemId)) if os.path.exists(path): raise IOError('%s already exists.' % path) WriteFile(path, self.pickleFn(i)) self._nbItems += 1 return ret def popChunk(self, nbItems=None): if nbItems is None: nbItems = self._maxItems ret = [] for i in range(nbItems): if self._nbItems == 0: break id = self._findNext(self.firstItemId) path = os.path.join(self.path, str(id)) ret.append(self.unpickleFn(ReadFile(path))) os.remove(path) self._nbItems -= 1 self.firstItemId = id + 1 return ret def save(self): pass def items(self): """Warning, very slow.""" ret = [] for id in range(self.firstItemId, self.lastItemId + 1): path = os.path.join(self.path, str(id)) if os.path.exists(path): ret.append(self.unpickleFn(ReadFile(path))) return ret def nbItems(self): return self._nbItems def maxItems(self): return self._maxItems #### Protected functions def _findNext(self, id): while True: path = os.path.join(self.path, str(id)) if os.path.isfile(path): return id id += 1 return None def _loadFromDisk(self): """Loads the queue from disk upto self.maxMemoryItems items into self.items. """ def SafeInt(item): try: return int(item) except ValueError: return None files = filter(None, [SafeInt(x) for x in os.listdir(self.path)]) files.sort() self._nbItems = len(files) if self._nbItems: self.firstItemId = files[0] self.lastItemId = files[-1] class PersistentQueue(object): """Keeps a list of abstract items and serializes it to the disk. It has 2 layers of queue, normally an in-memory queue and an on-disk queue. When the number of items in the primary queue gets too large, the new items are automatically saved to the secondary queue. The older items are kept in the primary queue. """ implements(IQueue) def __init__(self, primaryQueue=None, secondaryQueue=None, path=None): """ @primaryQueue: memory queue to use before buffering to disk. @secondaryQueue: disk queue to use as permanent buffer. @path: path is a shortcut when using default DiskQueue settings. """ self.primaryQueue = primaryQueue if self.primaryQueue is None: self.primaryQueue = MemoryQueue() self.secondaryQueue = secondaryQueue if self.secondaryQueue is None: self.secondaryQueue = DiskQueue(path) # Preload data from the secondary queue only if we know we won't start # using the secondary queue right away. if self.secondaryQueue.nbItems() < self.primaryQueue.maxItems(): self.primaryQueue.insertBackChunk( self.secondaryQueue.popChunk(self.primaryQueue.maxItems())) def pushItem(self, item): # If there is already items in secondaryQueue, we'd need to pop them # all to start inserting them into primaryQueue so don't bother and # just push it in secondaryQueue. if (self.secondaryQueue.nbItems() or self.primaryQueue.nbItems() == self.primaryQueue.maxItems()): item = self.secondaryQueue.pushItem(item) if item is None: return item # If item is not None, secondaryQueue overflowed. We need to push it # back to primaryQueue so the oldest item is dumped. # Or everything fit in the primaryQueue. return self.primaryQueue.pushItem(item) def insertBackChunk(self, chunk): ret = None # Overall excess excess = self.nbItems() + len(chunk) - self.maxItems() if excess > 0: ret = chunk[0:excess] chunk = chunk[excess:] # Memory excess excess = (self.primaryQueue.nbItems() + len(chunk) - self.primaryQueue.maxItems()) if excess > 0: chunk2 = [] for i in range(excess): chunk2.append(self.primaryQueue.items().pop()) chunk2.reverse() x = self.primaryQueue.insertBackChunk(chunk) assert x is None, "primaryQueue.insertBackChunk did not return None" if excess > 0: x = self.secondaryQueue.insertBackChunk(chunk2) assert x is None, ("secondaryQueue.insertBackChunk did not return " " None") return ret def popChunk(self, nbItems=None): if nbItems is None: nbItems = self.primaryQueue.maxItems() ret = self.primaryQueue.popChunk(nbItems) nbItems -= len(ret) if nbItems and self.secondaryQueue.nbItems(): ret.extend(self.secondaryQueue.popChunk(nbItems)) return ret def save(self): self.secondaryQueue.insertBackChunk(self.primaryQueue.popChunk()) def items(self): return self.primaryQueue.items() + self.secondaryQueue.items() def nbItems(self): return self.primaryQueue.nbItems() + self.secondaryQueue.nbItems() def maxItems(self): return self.primaryQueue.maxItems() + self.secondaryQueue.maxItems() class IndexedQueue(object): """Adds functionality to a IQueue object to track its usage. Adds a new member function getIndex() and modify popChunk() and insertBackChunk() to keep a virtual pointer to the queue's first entry index.""" implements(IQueue) def __init__(self, queue): # Copy all the member functions from the other object that this class # doesn't already define. assert IQueue.providedBy(queue) def Filter(m): return (m[0] != '_' and callable(getattr(queue, m)) and not hasattr(self, m)) for member in filter(Filter, dir(queue)): setattr(self, member, getattr(queue, member)) self.queue = queue self._index = 0 def getIndex(self): return self._index def popChunk(self, *args, **kwargs): items = self.queue.popChunk(*args, **kwargs) if items: self._index += len(items) return items def insertBackChunk(self, items): self._index -= len(items) ret = self.queue.insertBackChunk(items) if ret: self._index += len(ret) return ret def ToIndexedQueue(queue): """If the IQueue wasn't already a IndexedQueue, makes it an IndexedQueue.""" if not IQueue.providedBy(queue): raise TypeError("queue doesn't implement IQueue", queue) if isinstance(queue, IndexedQueue): return queue return IndexedQueue(queue) # vim: set ts=4 sts=4 sw=4 et: buildbot-0.8.8/buildbot/status/progress.py000066400000000000000000000273011222546025000207010ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.internet import reactor from twisted.spread import pb from twisted.python import log from buildbot import util from collections import defaultdict class StepProgress: """I keep track of how much progress a single BuildStep has made. Progress is measured along various axes. Time consumed is one that is available for all steps. Amount of command output is another, and may be better quantified by scanning the output for markers to derive number of files compiled, directories walked, tests run, etc. I am created when the build begins, and given to a BuildProgress object so it can track the overall progress of the whole build. """ startTime = None stopTime = None expectedTime = None buildProgress = None debug = False def __init__(self, name, metricNames): self.name = name self.progress = {} self.expectations = {} for m in metricNames: self.progress[m] = None self.expectations[m] = None def setBuildProgress(self, bp): self.buildProgress = bp def setExpectations(self, metrics): """The step can call this to explicitly set a target value for one of its metrics. E.g., ShellCommands knows how many commands it will execute, so it could set the 'commands' expectation.""" for metric, value in metrics.items(): self.expectations[metric] = value self.buildProgress.newExpectations() def setExpectedTime(self, seconds): self.expectedTime = seconds self.buildProgress.newExpectations() def start(self): if self.debug: print "StepProgress.start[%s]" % self.name self.startTime = util.now() def setProgress(self, metric, value): """The step calls this as progress is made along various axes.""" if self.debug: print "setProgress[%s][%s] = %s" % (self.name, metric, value) self.progress[metric] = value if self.debug: r = self.remaining() print " step remaining:", r self.buildProgress.newProgress() def finish(self): """This stops the 'time' metric and marks the step as finished overall. It should be called after the last .setProgress has been done for each axis.""" if self.debug: print "StepProgress.finish[%s]" % self.name self.stopTime = util.now() self.buildProgress.stepFinished(self.name) def totalTime(self): if self.startTime != None and self.stopTime != None: return self.stopTime - self.startTime def remaining(self): if self.startTime == None: return self.expectedTime if self.stopTime != None: return 0 # already finished # TODO: replace this with cleverness that graphs each metric vs. # time, then finds the inverse function. Will probably need to save # a timestamp with each setProgress update, when finished, go back # and find the 2% transition points, then save those 50 values in a # list. On the next build, do linear interpolation between the two # closest samples to come up with a percentage represented by that # metric. # TODO: If no other metrics are available, just go with elapsed # time. Given the non-time-uniformity of text output from most # steps, this would probably be better than the text-percentage # scheme currently implemented. percentages = [] for metric, value in self.progress.items(): expectation = self.expectations[metric] if value != None and expectation != None: p = 1.0 * value / expectation percentages.append(p) if percentages: avg = reduce(lambda x,y: x+y, percentages) / len(percentages) if avg > 1.0: # overdue avg = 1.0 if avg < 0.0: avg = 0.0 if percentages and self.expectedTime != None: return self.expectedTime - (avg * self.expectedTime) if self.expectedTime is not None: # fall back to pure time return self.expectedTime - (util.now() - self.startTime) return None # no idea class WatcherState: def __init__(self, interval): self.interval = interval self.timer = None self.needUpdate = 0 class BuildProgress(pb.Referenceable): """I keep track of overall build progress. I hold a list of StepProgress objects. """ def __init__(self, stepProgresses): self.steps = {} for s in stepProgresses: self.steps[s.name] = s s.setBuildProgress(self) self.finishedSteps = [] self.watchers = {} self.debug = 0 def setExpectationsFrom(self, exp): """Set our expectations from the builder's Expectations object.""" for name, metrics in exp.steps.items(): s = self.steps.get(name) if s: s.setExpectedTime(exp.times[name]) s.setExpectations(exp.steps[name]) def newExpectations(self): """Call this when one of the steps has changed its expectations. This should trigger us to update our ETA value and notify any subscribers.""" pass # subscribers are not implemented: they just poll def stepFinished(self, stepname): assert(stepname not in self.finishedSteps) self.finishedSteps.append(stepname) if len(self.finishedSteps) == len(self.steps.keys()): self.sendLastUpdates() def newProgress(self): r = self.remaining() if self.debug: print " remaining:", r if r != None: self.sendAllUpdates() def remaining(self): # sum eta of all steps sum = 0 for name, step in self.steps.items(): rem = step.remaining() if rem == None: return None # not sure sum += rem return sum def eta(self): left = self.remaining() if left == None: return None # not sure done = util.now() + left return done def remote_subscribe(self, remote, interval=5): # [interval, timer, needUpdate] # don't send an update more than once per interval self.watchers[remote] = WatcherState(interval) remote.notifyOnDisconnect(self.removeWatcher) self.updateWatcher(remote) self.startTimer(remote) log.msg("BuildProgress.remote_subscribe(%s)" % remote) def remote_unsubscribe(self, remote): # TODO: this doesn't work. I think 'remote' will always be different # than the object that appeared in _subscribe. log.msg("BuildProgress.remote_unsubscribe(%s)" % remote) self.removeWatcher(remote) #remote.dontNotifyOnDisconnect(self.removeWatcher) def removeWatcher(self, remote): #log.msg("removeWatcher(%s)" % remote) try: timer = self.watchers[remote].timer if timer: timer.cancel() del self.watchers[remote] except KeyError: log.msg("Weird, removeWatcher on non-existent subscriber:", remote) def sendAllUpdates(self): for r in self.watchers.keys(): self.updateWatcher(r) def updateWatcher(self, remote): # an update wants to go to this watcher. Send it if we can, otherwise # queue it for later w = self.watchers[remote] if not w.timer: # no timer, so send update now and start the timer self.sendUpdate(remote) self.startTimer(remote) else: # timer is running, just mark as needing an update w.needUpdate = 1 def startTimer(self, remote): w = self.watchers[remote] timer = reactor.callLater(w.interval, self.watcherTimeout, remote) w.timer = timer def sendUpdate(self, remote, last=0): self.watchers[remote].needUpdate = 0 #text = self.asText() # TODO: not text, duh try: remote.callRemote("progress", self.remaining()) if last: remote.callRemote("finished", self) except: log.deferr() self.removeWatcher(remote) def watcherTimeout(self, remote): w = self.watchers.get(remote, None) if not w: return # went away w.timer = None if w.needUpdate: self.sendUpdate(remote) self.startTimer(remote) def sendLastUpdates(self): for remote in self.watchers.keys(): self.sendUpdate(remote, 1) self.removeWatcher(remote) class Expectations: debug = False # decay=1.0 ignores all but the last build # 0.9 is short time constant. 0.1 is very long time constant # TODO: let decay be specified per-metric decay = 0.5 def __init__(self, buildprogress): """Create us from a successful build. We will expect each step to take as long as it did in that build.""" # .steps maps stepname to dict2 # dict2 maps metricname to final end-of-step value self.steps = defaultdict(dict) # .times maps stepname to per-step elapsed time self.times = {} for name, step in buildprogress.steps.items(): self.steps[name] = {} for metric, value in step.progress.items(): self.steps[name][metric] = value self.times[name] = None if step.startTime is not None and step.stopTime is not None: self.times[name] = step.stopTime - step.startTime def wavg(self, old, current): if old is None: return current if current is None: return old else: return (current * self.decay) + (old * (1 - self.decay)) def update(self, buildprogress): for name, stepprogress in buildprogress.steps.items(): old = self.times.get(name) current = stepprogress.totalTime() if current == None: log.msg("Expectations.update: current[%s] was None!" % name) continue new = self.wavg(old, current) self.times[name] = new if self.debug: print "new expected time[%s] = %s, old %s, cur %s" % \ (name, new, old, current) for metric, current in stepprogress.progress.items(): old = self.steps[name].get(metric) new = self.wavg(old, current) if self.debug: print "new expectation[%s][%s] = %s, old %s, cur %s" % \ (name, metric, new, old, current) self.steps[name][metric] = new def expectedBuildTime(self): if None in self.times.values(): return None #return sum(self.times.values()) # python-2.2 doesn't have 'sum'. TODO: drop python-2.2 support s = 0 for v in self.times.values(): s += v return s buildbot-0.8.8/buildbot/status/results.py000066400000000000000000000022311222546025000205310ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members SUCCESS, WARNINGS, FAILURE, SKIPPED, EXCEPTION, RETRY = range(6) Results = ["success", "warnings", "failure", "skipped", "exception", "retry"] def worst_status(a, b): # SUCCESS > WARNINGS > FAILURE > EXCEPTION > RETRY # Retry needs to be considered the worst so that conusmers don't have to # worry about other failures undermining the RETRY. for s in (RETRY, EXCEPTION, FAILURE, WARNINGS, SKIPPED, SUCCESS): if s in (a, b): return s buildbot-0.8.8/buildbot/status/slave.py000066400000000000000000000100121222546025000201360ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import time from zope.interface import implements from buildbot import interfaces from buildbot.util.eventual import eventually class SlaveStatus: implements(interfaces.ISlaveStatus) admin = None host = None access_uri = None version = None connected = False graceful_shutdown = False paused = False def __init__(self, name): self.name = name self._lastMessageReceived = 0 self.runningBuilds = [] self.graceful_callbacks = [] self.connect_times = [] def getName(self): return self.name def getAdmin(self): return self.admin def getHost(self): return self.host def getAccessURI(self): return self.access_uri def getVersion(self): return self.version def isConnected(self): return self.connected def isPaused(self): return self.paused def lastMessageReceived(self): return self._lastMessageReceived def getRunningBuilds(self): return self.runningBuilds def getConnectCount(self): then = time.time() - 3600 return len([ t for t in self.connect_times if t > then ]) def setAdmin(self, admin): self.admin = admin def setHost(self, host): self.host = host def setAccessURI(self, access_uri): self.access_uri = access_uri def setVersion(self, version): self.version = version def setConnected(self, isConnected): self.connected = isConnected def setLastMessageReceived(self, when): self._lastMessageReceived = when def setPaused(self, isPaused): self.paused = isPaused def recordConnectTime(self): # record this connnect, and keep data for the last hour now = time.time() self.connect_times = [ t for t in self.connect_times if t > now - 3600 ] + [ now ] def buildStarted(self, build): self.runningBuilds.append(build) def buildFinished(self, build): self.runningBuilds.remove(build) def getGraceful(self): """Return the graceful shutdown flag""" return self.graceful_shutdown def setGraceful(self, graceful): """Set the graceful shutdown flag, and notify all the watchers""" self.graceful_shutdown = graceful for cb in self.graceful_callbacks: eventually(cb, graceful) def addGracefulWatcher(self, watcher): """Add watcher to the list of watchers to be notified when the graceful shutdown flag is changed.""" if not watcher in self.graceful_callbacks: self.graceful_callbacks.append(watcher) def removeGracefulWatcher(self, watcher): """Remove watcher from the list of watchers to be notified when the graceful shutdown flag is changed.""" if watcher in self.graceful_callbacks: self.graceful_callbacks.remove(watcher) def asDict(self): result = {} # Constant result['name'] = self.getName() result['access_uri'] = self.getAccessURI() # Transient (since it changes when the slave reconnects) result['host'] = self.getHost() result['admin'] = self.getAdmin() result['version'] = self.getVersion() result['connected'] = self.isConnected() result['runningBuilds'] = [b.asDict() for b in self.getRunningBuilds()] return result buildbot-0.8.8/buildbot/status/status_gerrit.py000066400000000000000000000140321222546025000217310ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members """Push events to gerrit .""" from buildbot.status.base import StatusReceiverMultiService from buildbot.status.builder import Results, SUCCESS, RETRY from twisted.internet import reactor from twisted.internet.protocol import ProcessProtocol def defaultReviewCB(builderName, build, result, status, arg): if result == RETRY: return None, 0, 0 message = "Buildbot finished compiling your patchset\n" message += "on configuration: %s\n" % builderName message += "The result is: %s\n" % Results[result].upper() # message, verified, reviewed return message, (result == SUCCESS or -1), 0 class GerritStatusPush(StatusReceiverMultiService): """Event streamer to a gerrit ssh server.""" def __init__(self, server, username, reviewCB=defaultReviewCB, startCB=None, port=29418, reviewArg=None, startArg=None, **kwargs): """ @param server: Gerrit SSH server's address to use for push event notifications. @param username: Gerrit SSH server's username. @param reviewCB: Callback that is called each time a build is finished, and that is used to define the message and review approvals depending on the build result. @param startCB: Callback that is called each time a build is started. Used to define the message sent to Gerrit. @param port: Gerrit SSH server's port. @param reviewArg: Optional argument passed to the review callback. @param startArg: Optional argument passed to the start callback. """ StatusReceiverMultiService.__init__(self) # Parameters. self.gerrit_server = server self.gerrit_username = username self.gerrit_port = port self.reviewCB = reviewCB self.reviewArg = reviewArg self.startCB = startCB self.startArg = startArg class LocalPP(ProcessProtocol): def __init__(self, status): self.status = status def outReceived(self, data): print "gerritout:", data def errReceived(self, data): print "gerriterr:", data def processEnded(self, status_object): if status_object.value.exitCode: print "gerrit status: ERROR:", status_object else: print "gerrit status: OK" def startService(self): print """Starting up.""" StatusReceiverMultiService.startService(self) self.status = self.parent.getStatus() self.status.subscribe(self) def builderAdded(self, name, builder): return self # subscribe to this builder def buildStarted(self, builderName, build): if self.startCB is not None: message = self.startCB(builderName, build, self.startArg) self.sendCodeReviews(build, message) def buildFinished(self, builderName, build, result): """Do the SSH gerrit verify command to the server.""" message, verified, reviewed = self.reviewCB(builderName, build, result, self.status, self.reviewArg) self.sendCodeReviews(build, message, verified, reviewed) def sendCodeReviews(self, build, message, verified=0, reviewed=0): if message is None: return # Gerrit + Repo downloads = build.getProperty("repo_downloads") downloaded = build.getProperty("repo_downloaded") if downloads is not None and downloaded is not None: downloaded = downloaded.split(" ") if downloads and 2 * len(downloads) == len(downloaded): for i in range(0, len(downloads)): try: project, change1 = downloads[i].split(" ") except ValueError: return # something is wrong, abort change2 = downloaded[2 * i] revision = downloaded[2 * i + 1] if change1 == change2: self.sendCodeReview(project, revision, message, verified, reviewed) else: return # something is wrong, abort return # Gerrit + Git if build.getProperty("gerrit_branch") is not None: # used only to verify Gerrit source project = build.getProperty("project") revision = build.getProperty("got_revision") # review doesn't really work with multiple revisions, so let's # just assume it's None there if isinstance(revision, dict): revision = None if project is not None and revision is not None: self.sendCodeReview(project, revision, message, verified, reviewed) return def sendCodeReview(self, project, revision, message=None, verified=0, reviewed=0): command = ["ssh", self.gerrit_username + "@" + self.gerrit_server, "-p %d" % self.gerrit_port, "gerrit", "review", "--project %s" % str(project)] if message: command.append("--message '%s'" % message.replace("'","\"")) if verified: command.extend(["--verified %d" % int(verified)]) if reviewed: command.extend(["--code-review %d" % int(reviewed)]) command.append(str(revision)) print command reactor.spawnProcess(self.LocalPP(self), "ssh", command) # vim: set ts=4 sts=4 sw=4 et: buildbot-0.8.8/buildbot/status/status_push.py000066400000000000000000000407341222546025000214240ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement """Push events to an abstract receiver. Implements the HTTP receiver.""" import datetime import os import urllib import urlparse try: import simplejson as json assert json except ImportError: import json from buildbot import config from buildbot.status.base import StatusReceiverMultiService from buildbot.status.persistent_queue import DiskQueue, IndexedQueue, \ MemoryQueue, PersistentQueue from buildbot.status.web.status_json import FilterOut from twisted.internet import defer, reactor from twisted.python import log from twisted.web import client class StatusPush(StatusReceiverMultiService): """Event streamer to a abstract channel. It uses IQueue to batch push requests and queue the data when the receiver is down. When a PersistentQueue object is used, the items are saved to disk on master shutdown so they can be pushed back when the master is restarted. """ def __init__(self, serverPushCb, queue=None, path=None, filter=True, bufferDelay=1, retryDelay=5, blackList=None): """ @serverPushCb: callback to be used. It receives 'self' as parameter. It should call self.queueNextServerPush() when it's done to queue the next push. It is guaranteed that the queue is not empty when this function is called. @queue: a item queue that implements IQueue. @path: path to save config. @filter: when True (default), removes all "", None, False, [] or {} entries. @bufferDelay: amount of time events are queued before sending, to reduce the number of push requests rate. This is the delay between the end of a request to initializing a new one. @retryDelay: amount of time between retries when no items were pushed on last serverPushCb call. @blackList: events that shouldn't be sent. """ StatusReceiverMultiService.__init__(self) # Parameters. self.queue = queue if self.queue is None: self.queue = MemoryQueue() self.queue = IndexedQueue(self.queue) self.path = path self.filter = filter self.bufferDelay = bufferDelay self.retryDelay = retryDelay if not callable(serverPushCb): raise NotImplementedError('Please pass serverPushCb parameter.') def hookPushCb(): # Update the index so we know if the next push succeed or not, don't # update the value when the queue is empty. if not self.queue.nbItems(): return self.lastIndex = self.queue.getIndex() return serverPushCb(self) self.serverPushCb = hookPushCb self.blackList = blackList # Other defaults. # IDelayedCall object that represents the next queued push. self.task = None self.stopped = False self.lastIndex = -1 self.state = {} self.state['started'] = str(datetime.datetime.utcnow()) self.state['next_id'] = 1 self.state['last_id_pushed'] = 0 # Try to load back the state. if self.path and os.path.isdir(self.path): state_path = os.path.join(self.path, 'state') if os.path.isfile(state_path): with open(state_path, 'r') as f: self.state.update(json.load(f)) if self.queue.nbItems(): # Last shutdown was not clean, don't wait to send events. self.queueNextServerPush() def startService(self): """Starting up.""" StatusReceiverMultiService.startService(self) self.status = self.parent.getStatus() self.status.subscribe(self) self.initialPush() def wasLastPushSuccessful(self): """Returns if the "virtual pointer" in the queue advanced.""" return self.lastIndex <= self.queue.getIndex() def queueNextServerPush(self): """Queue the next push or call it immediately. Called to signal new items are available to be sent or on shutdown. A timer should be queued to trigger a network request or the callback should be called immediately. If a status push is already queued, ignore the current call.""" # Determine the delay. if self.wasLastPushSuccessful(): if self.stopped: # Shutting down. delay = 0 else: # Normal case. delay = self.bufferDelay else: if self.stopped: # Too bad, we can't do anything now, we're shutting down and the # receiver is also down. We'll just save the objects to disk. return else: # The server is inaccessible, retry less often. delay = self.retryDelay # Cleanup a previously queued task if necessary. if self.task: # Warning: we could be running inside the task. if self.task.active(): # There was already a task queue, don't requeue it, just let it # go. return else: if self.task.active(): # There was a task queued but it is requested to call it # *right now* so cancel it. self.task.cancel() # Otherwise, it was just a stray object. self.task = None # Do the queue/direct call. if delay: # Call in delay seconds. self.task = reactor.callLater(delay, self.serverPushCb) elif self.stopped: if not self.queue.nbItems(): return # Call right now, we're shutting down. @defer.inlineCallbacks def BlockForEverythingBeingSent(): yield self.serverPushCb() return BlockForEverythingBeingSent() else: # delay should never be 0. That can cause Buildbot to spin tightly # trying to push events that may not be received well by a status # listener. log.err('Did not expect delay to be 0, but it is.') return def stopService(self): """Shutting down.""" self.finalPush() self.stopped = True if (self.task and self.task.active()): # We don't have time to wait, force an immediate call. self.task.cancel() self.task = None d = self.queueNextServerPush() elif self.wasLastPushSuccessful(): d = self.queueNextServerPush() else: d = defer.succeed(None) # We're dying, make sure we save the results. self.queue.save() if self.path and os.path.isdir(self.path): state_path = os.path.join(self.path, 'state') with open(state_path, 'w') as f: json.dump(self.state, f, sort_keys=True, indent=2) # Make sure all Deferreds are called on time and in a sane order. defers = filter(None, [d, StatusReceiverMultiService.stopService(self)]) return defer.DeferredList(defers) def push(self, event, **objs): """Push a new event. The new event will be either: - Queued in memory to reduce network usage - Queued to disk when the sink server is down - Pushed (along the other queued items) to the server """ if self.blackList and event in self.blackList: return # First, generate the packet. packet = {} packet['id'] = self.state['next_id'] self.state['next_id'] += 1 packet['timestamp'] = str(datetime.datetime.utcnow()) packet['project'] = self.status.getTitle() packet['started'] = self.state['started'] packet['event'] = event packet['payload'] = {} for obj_name, obj in objs.items(): if hasattr(obj, 'asDict'): obj = obj.asDict() if self.filter: obj = FilterOut(obj) packet['payload'][obj_name] = obj self.queue.pushItem(packet) if self.task is None or not self.task.active(): # No task queued since it was probably idle, let's queue a task. return self.queueNextServerPush() #### Events def initialPush(self): # Push everything we want to push from the initial configuration. self.push('start', status=self.status) def finalPush(self): self.push('shutdown', status=self.status) def requestSubmitted(self, request): self.push('requestSubmitted', request=request) def requestCancelled(self, builder, request): self.push('requestCancelled', builder=builder, request=request) def buildsetSubmitted(self, buildset): self.push('buildsetSubmitted', buildset=buildset) def builderAdded(self, builderName, builder): self.push('builderAdded', builderName=builderName, builder=builder) return self def builderChangedState(self, builderName, state): self.push('builderChangedState', builderName=builderName, state=state) def buildStarted(self, builderName, build): self.push('buildStarted', build=build) return self def buildETAUpdate(self, build, ETA): self.push('buildETAUpdate', build=build, ETA=ETA) def stepStarted(self, build, step): self.push('stepStarted', properties=build.getProperties().asList(), step=step) def stepTextChanged(self, build, step, text): self.push('stepTextChanged', properties=build.getProperties().asList(), step=step, text=text) def stepText2Changed(self, build, step, text2): self.push('stepText2Changed', properties=build.getProperties().asList(), step=step, text2=text2) def stepETAUpdate(self, build, step, ETA, expectations): self.push('stepETAUpdate', properties=build.getProperties().asList(), step=step, ETA=ETA, expectations=expectations) def logStarted(self, build, step, log): self.push('logStarted', properties=build.getProperties().asList(), step=step) def logFinished(self, build, step, log): self.push('logFinished', properties=build.getProperties().asList(), step=step) def stepFinished(self, build, step, results): self.push('stepFinished', properties=build.getProperties().asList(), step=step) def buildFinished(self, builderName, build, results): self.push('buildFinished', build=build) def builderRemoved(self, builderName): self.push('buildedRemoved', builderName=builderName) def changeAdded(self, change): self.push('changeAdded', change=change) def slaveConnected(self, slavename): self.push('slaveConnected', slave=self.status.getSlave(slavename)) def slaveDisconnected(self, slavename): self.push('slaveDisconnected', slavename=slavename) class HttpStatusPush(StatusPush): """Event streamer to a HTTP server.""" def __init__(self, serverUrl, debug=None, maxMemoryItems=None, maxDiskItems=None, chunkSize=200, maxHttpRequestSize=2**20, extra_post_params=None, **kwargs): """ @serverUrl: Base URL to be used to push events notifications. @maxMemoryItems: Maximum number of items to keep queued in memory. @maxDiskItems: Maximum number of items to buffer to disk, if 0, doesn't use disk at all. @debug: Save the json with nice formatting. @chunkSize: maximum number of items to send in each at each HTTP POST. @maxHttpRequestSize: limits the size of encoded data for AE, the default is 1MB. """ if not serverUrl: raise config.ConfigErrors(['HttpStatusPush requires a serverUrl']) # Parameters. self.serverUrl = serverUrl self.extra_post_params = extra_post_params or {} self.debug = debug self.chunkSize = chunkSize self.lastPushWasSuccessful = True self.maxHttpRequestSize = maxHttpRequestSize if maxDiskItems != 0: # The queue directory is determined by the server url. path = ('events_' + urlparse.urlparse(self.serverUrl)[1].split(':')[0]) queue = PersistentQueue( primaryQueue=MemoryQueue(maxItems=maxMemoryItems), secondaryQueue=DiskQueue(path, maxItems=maxDiskItems)) else: path = None queue = MemoryQueue(maxItems=maxMemoryItems) # Use the unbounded method. StatusPush.__init__(self, serverPushCb=HttpStatusPush.pushHttp, queue=queue, path=path, **kwargs) def wasLastPushSuccessful(self): return self.lastPushWasSuccessful def popChunk(self): """Pops items from the pending list. They must be queued back on failure.""" if self.wasLastPushSuccessful(): chunkSize = self.chunkSize else: chunkSize = 1 while True: items = self.queue.popChunk(chunkSize) newitems = [] for item in items: if hasattr(item, 'asDict'): newitems.append(item.asDict()) else: newitems.append(item) if self.debug: packets = json.dumps(newitems, indent=2, sort_keys=True) else: packets = json.dumps(newitems, separators=(',',':')) params = {'packets': packets} params.update(self.extra_post_params) data = urllib.urlencode(params) if (not self.maxHttpRequestSize or len(data) < self.maxHttpRequestSize): return (data, items) if chunkSize == 1: # This packet is just too large. Drop this packet. log.msg("ERROR: packet %s was dropped, too large: %d > %d" % (items[0]['id'], len(data), self.maxHttpRequestSize)) chunkSize = self.chunkSize else: # Try with half the packets. chunkSize /= 2 self.queue.insertBackChunk(items) def pushHttp(self): """Do the HTTP POST to the server.""" (encoded_packets, items) = self.popChunk() def Success(result): """Queue up next push.""" log.msg('Sent %d events to %s' % (len(items), self.serverUrl)) self.lastPushWasSuccessful = True return self.queueNextServerPush() def Failure(result): """Insert back items not sent and queue up next push.""" # Server is now down. log.msg('Failed to push %d events to %s: %s' % (len(items), self.serverUrl, str(result))) self.queue.insertBackChunk(items) if self.stopped: # Bad timing, was being called on shutdown and the server died # on us. Make sure the queue is saved since we just queued back # items. self.queue.save() self.lastPushWasSuccessful = False return self.queueNextServerPush() # Trigger the HTTP POST request. headers = {'Content-Type': 'application/x-www-form-urlencoded'} connection = client.getPage(self.serverUrl, method='POST', postdata=encoded_packets, headers=headers, agent='buildbot') connection.addCallbacks(Success, Failure) return connection # vim: set ts=4 sts=4 sw=4 et: buildbot-0.8.8/buildbot/status/testresult.py000066400000000000000000000023211222546025000212460ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from buildbot import interfaces class TestResult: implements(interfaces.ITestResult) def __init__(self, name, results, text, logs): assert isinstance(name, tuple) self.name = name self.results = results self.text = text self.logs = logs def getName(self): return self.name def getResults(self): return self.results def getText(self): return self.text def getLogs(self): return self.logs buildbot-0.8.8/buildbot/status/tinderbox.py000066400000000000000000000273001222546025000210320ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from email.Message import Message from email.Utils import formatdate from zope.interface import implements from twisted.internet import defer from buildbot import interfaces from buildbot.status import mail from buildbot.status.results import SUCCESS, WARNINGS, EXCEPTION, RETRY from buildbot.steps.shell import WithProperties import gzip, bz2, base64, re, cStringIO # TODO: docs, maybe a test of some sort just to make sure it actually imports # and can format email without raising an exception. class TinderboxMailNotifier(mail.MailNotifier): """This is a Tinderbox status notifier. It can send e-mail to a number of different tinderboxes or people. E-mails are sent at the beginning and upon completion of each build. It can be configured to send out e-mails for only certain builds. The most basic usage is as follows:: TinderboxMailNotifier(fromaddr="buildbot@localhost", tree="MyTinderboxTree", extraRecipients=["tinderboxdaemon@host.org"]) The builder name (as specified in master.cfg) is used as the "build" tinderbox option. """ implements(interfaces.IEmailSender) compare_attrs = ["extraRecipients", "fromaddr", "categories", "builders", "addLogs", "relayhost", "subject", "binaryURL", "tree", "logCompression", "errorparser", "columnName", "useChangeTime"] def __init__(self, fromaddr, tree, extraRecipients, categories=None, builders=None, relayhost="localhost", subject="buildbot %(result)s in %(builder)s", binaryURL="", logCompression="", errorparser="unix", columnName=None, useChangeTime=False): """ @type fromaddr: string @param fromaddr: the email address to be used in the 'From' header. @type tree: string @param tree: The Tinderbox tree to post to. When tree is a WithProperties instance it will be interpolated as such. See WithProperties for more detail @type extraRecipients: tuple of string @param extraRecipients: E-mail addresses of recipients. This should at least include the tinderbox daemon. @type categories: list of strings @param categories: a list of category names to serve status information for. Defaults to None (all categories). Use either builders or categories, but not both. @type builders: list of strings @param builders: a list of builder names for which mail should be sent. Defaults to None (send mail for all builds). Use either builders or categories, but not both. @type relayhost: string @param relayhost: the host to which the outbound SMTP connection should be made. Defaults to 'localhost' @type subject: string @param subject: a string to be used as the subject line of the message. %(builder)s will be replaced with the name of the %builder which provoked the message. This parameter is not significant for the tinderbox daemon. @type binaryURL: string @param binaryURL: If specified, this should be the location where final binary for a build is located. (ie. http://www.myproject.org/nightly/08-08-2006.tgz) It will be posted to the Tinderbox. @type logCompression: string @param logCompression: The type of compression to use on the log. Valid options are"bzip2" and "gzip". gzip is only known to work on Python 2.4 and above. @type errorparser: string @param errorparser: The error parser that the Tinderbox server should use when scanning the log file. Default is "unix". @type columnName: string @param columnName: When columnName is None, use the buildername as the Tinderbox column name. When columnName is a string this exact string will be used for all builders that this TinderboxMailNotifier cares about (not recommended). When columnName is a WithProperties instance it will be interpolated as such. See WithProperties for more detail. @type useChangeTime: bool @param useChangeTime: When True, the time of the first Change for a build is used as the builddate. When False, the current time is used as the builddate. """ mail.MailNotifier.__init__(self, fromaddr, categories=categories, builders=builders, relayhost=relayhost, subject=subject, extraRecipients=extraRecipients, sendToInterestedUsers=False) assert isinstance(tree, basestring) \ or isinstance(tree, WithProperties), \ "tree must be a string or a WithProperties instance" self.tree = tree self.binaryURL = binaryURL self.logCompression = logCompression self.errorparser = errorparser self.useChangeTime = useChangeTime assert columnName is None or type(columnName) is str \ or isinstance(columnName, WithProperties), \ "columnName must be None, a string, or a WithProperties instance" self.columnName = columnName def buildStarted(self, name, build): builder = build.getBuilder() if self.builders is not None and name not in self.builders: return # ignore this Build if self.categories is not None and \ builder.category not in self.categories: return # ignore this build self.buildMessage(name, build, "building") @defer.inlineCallbacks def buildMessage(self, name, build, results): text = "" res = "" # shortform t = "tinderbox:" tree = yield build.render(self.tree) text += "%s tree: %s\n" % (t, tree) # the start time # getTimes() returns a fractioned time that tinderbox doesn't understand builddate = int(build.getTimes()[0]) # attempt to pull a Change time from this Build's Changes. # if that doesn't work, fall back on the current time if self.useChangeTime: try: builddate = build.getChanges()[-1].when except: pass text += "%s builddate: %s\n" % (t, builddate) text += "%s status: " % t if results == "building": res = "building" text += res elif results == SUCCESS: res = "success" text += res elif results == WARNINGS: res = "testfailed" text += res elif results in (EXCEPTION, RETRY): res = "exception" text += res else: res += "busted" text += res text += "\n"; if self.columnName is None: # use the builder name text += "%s build: %s\n" % (t, name) else: columnName = yield build.render(self.columnName) text += "%s build: %s\n" % (t, columnName) text += "%s errorparser: %s\n" % (t, self.errorparser) # if the build just started... if results == "building": text += "%s END\n" % t # if the build finished... else: text += "%s binaryurl: %s\n" % (t, self.binaryURL) text += "%s logcompression: %s\n" % (t, self.logCompression) # logs will always be appended logEncoding = "" tinderboxLogs = "" for bs in build.getSteps(): # Make sure that shortText is a regular string, so that bad # data in the logs don't generate UnicodeDecodeErrors shortText = "%s\n" % ' '.join(bs.getText()).encode('ascii', 'replace') # ignore steps that haven't happened if not re.match(".*[^\s].*", shortText): continue # we ignore TinderboxPrint's here so we can do things like: # ShellCommand(command=['echo', 'TinderboxPrint:', ...]) if re.match(".+TinderboxPrint.*", shortText): shortText = shortText.replace("TinderboxPrint", "Tinderbox Print") logs = bs.getLogs() tinderboxLogs += "======== BuildStep started ========\n" tinderboxLogs += shortText tinderboxLogs += "=== Output ===\n" for log in logs: logText = log.getTextWithHeaders() # Because we pull in the log headers here we have to ignore # some of them. Basically, if we're TinderboxPrint'ing in # a ShellCommand, the only valid one(s) are at the start # of a line. The others are prendeded by whitespace, quotes, # or brackets/parentheses for line in logText.splitlines(): if re.match(".+TinderboxPrint.*", line): line = line.replace("TinderboxPrint", "Tinderbox Print") tinderboxLogs += line + "\n" tinderboxLogs += "=== Output ended ===\n" tinderboxLogs += "======== BuildStep ended ========\n" if self.logCompression == "bzip2": cLog = bz2.compress(tinderboxLogs) tinderboxLogs = base64.encodestring(cLog) logEncoding = "base64" elif self.logCompression == "gzip": cLog = cStringIO.StringIO() gz = gzip.GzipFile(mode="w", fileobj=cLog) gz.write(tinderboxLogs) gz.close() cLog = cLog.getvalue() tinderboxLogs = base64.encodestring(cLog) logEncoding = "base64" text += "%s logencoding: %s\n" % (t, logEncoding) text += "%s END\n\n" % t text += tinderboxLogs text += "\n" m = Message() m.set_payload(text) m['Date'] = formatdate(localtime=True) m['Subject'] = self.subject % { 'result': res, 'builder': name, } m['From'] = self.fromaddr # m['To'] is added later d = defer.DeferredList([]) d.addCallback(self._gotRecipients, self.extraRecipients, m) defer.returnValue((yield d)) buildbot-0.8.8/buildbot/status/web/000077500000000000000000000000001222546025000172355ustar00rootroot00000000000000buildbot-0.8.8/buildbot/status/web/__init__.py000066400000000000000000000000001222546025000213340ustar00rootroot00000000000000buildbot-0.8.8/buildbot/status/web/about.py000066400000000000000000000025211222546025000207210ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.status.web.base import HtmlResource import buildbot import twisted import sys import jinja2 class AboutBuildbot(HtmlResource): pageTitle = "About this Buildbot" def content(self, request, cxt): cxt.update(dict(buildbot=buildbot.version, twisted=twisted.__version__, jinja=jinja2.__version__, python=sys.version, platform=sys.platform)) template = request.site.buildbot_service.templates.get_template("about.html") template.autoescape = True return template.render(**cxt) buildbot-0.8.8/buildbot/status/web/auth.py000066400000000000000000000164421222546025000205570ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os from zope.interface import Interface, Attribute, implements from buildbot.status.web.base import HtmlResource, ActionResource from buildbot.status.web.base import path_to_authfail from buildbot.process.users import users class IAuth(Interface): """ Represent an authentication method. Note that each IAuth instance contains a link to the BuildMaster that will be set once the IAuth instance is initialized. """ master = Attribute('master', "Link to BuildMaster, set when initialized") def authenticate(self, user, passwd): """Check whether C{user} / C{passwd} are valid.""" def getUserInfo(self, user): """return dict with user info. dict( fullName="", email="", groups=[]) """ def errmsg(self): """Get the reason authentication failed.""" class AuthBase: master = None # set in status.web.baseweb err = "" def errmsg(self): return self.err def getUserInfo(self, user): """default dummy impl""" return dict(userName=user, fullName=user, email=user+"@localhost", groups=[ user ]) class BasicAuth(AuthBase): implements(IAuth) """Implement basic authentication against a list of user/passwd.""" userpass = [] """List of user/pass tuples.""" def __init__(self, userpass): """C{userpass} is a list of (user, passwd).""" for item in userpass: assert isinstance(item, tuple) or isinstance(item, list) u, p = item assert isinstance(u, str) assert isinstance(p, str) self.userpass = userpass def authenticate(self, user, passwd): """Check that C{user}/C{passwd} is a valid user/pass tuple.""" if not self.userpass: self.err = "Bad self.userpass data" return False for u, p in self.userpass: if user == u and passwd == p: self.err = "" return True self.err = "Invalid username or password" return False class HTPasswdAuth(AuthBase): implements(IAuth) """Implement authentication against an .htpasswd file.""" file = "" """Path to the .htpasswd file to use.""" def __init__(self, file): """C{file} is a path to an .htpasswd file.""" assert os.path.exists(file) self.file = file def authenticate(self, user, passwd): """Authenticate C{user} and C{passwd} against an .htpasswd file""" if not os.path.exists(self.file): self.err = "No such file: " + self.file return False # Fetch each line from the .htpasswd file and split it into a # [user, passwd] array. lines = [l.rstrip().split(':', 1) for l in file(self.file).readlines()] # Keep only the line for this login lines = [l for l in lines if l[0] == user] if not lines: self.err = "Invalid user/passwd" return False hash = lines[0][1] res = self.validatePassword(passwd, hash) if res: self.err = "" else: self.err = "Invalid user/passwd" return res def validatePassword(self, passwd, hash): # This is the DES-hash of the password. The first two characters are # the salt used to introduce disorder in the DES algorithm. from crypt import crypt #@UnresolvedImport return hash == crypt(passwd, hash[0:2]) class HTPasswdAprAuth(HTPasswdAuth): implements(IAuth) """Implement authentication against an .htpasswd file based on libaprutil""" file = "" """Path to the .htpasswd file to use.""" def __init__(self, file): HTPasswdAuth.__init__(self, file) # Try to load libaprutil throug ctypes self.apr = None try: from ctypes import CDLL from ctypes.util import find_library lib = find_library("aprutil-1") if lib: self.apr = CDLL(lib) except: self.apr = None def validatePassword(self, passwd, hash): # Use apr_password_validate from libaprutil if libaprutil is available. # Fallback to DES only checking from HTPasswdAuth if self.apr: return self.apr.apr_password_validate(passwd, hash) == 0 else: return HTPasswdAuth.validatePassword(self, passwd, hash) class UsersAuth(AuthBase): """Implement authentication against users in database""" implements(IAuth) def authenticate(self, user, passwd): """ It checks for a matching uid in the database for the credentials and return True if a match is found, False otherwise. @param user: username portion of user credentials @type user: string @param passwd: password portion of user credentials @type passwd: string @returns: boolean via deferred. """ d = self.master.db.users.getUserByUsername(user) def check_creds(user): if user: if users.check_passwd(passwd, user['bb_password']): return True self.err = "no user found with those credentials" return False d.addCallback(check_creds) return d class AuthFailResource(HtmlResource): pageTitle = "Authentication Failed" def content(self, request, cxt): templates =request.site.buildbot_service.templates template = templates.get_template("authfail.html") return template.render(**cxt) class AuthzFailResource(HtmlResource): pageTitle = "Authorization Failed" def content(self, request, cxt): templates =request.site.buildbot_service.templates template = templates.get_template("authzfail.html") return template.render(**cxt) class LoginResource(ActionResource): def performAction(self, request): authz = self.getAuthz(request) d = authz.login(request) def on_login(res): if res: status = request.site.buildbot_service.master.status root = status.getBuildbotURL() return request.requestHeaders.getRawHeaders('referer', [root])[0] else: return path_to_authfail(request) d.addBoth(on_login) return d class LogoutResource(ActionResource): def performAction(self, request): authz = self.getAuthz(request) authz.logout(request) status = request.site.buildbot_service.master.status root = status.getBuildbotURL() return request.requestHeaders.getRawHeaders('referer',[root])[0] buildbot-0.8.8/buildbot/status/web/authz.py000066400000000000000000000151401222546025000207430ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.internet import defer from buildbot.status.web.auth import IAuth from buildbot.status.web.session import SessionManager COOKIE_KEY="BuildBotSession" class Authz(object): """Decide who can do what.""" knownActions = [ # If you add a new action here, be sure to also update the documentation # at docs/cfg-statustargets.texinfo 'gracefulShutdown', 'forceBuild', 'forceAllBuilds', 'pingBuilder', 'stopBuild', 'stopAllBuilds', 'cancelPendingBuild', 'stopChange', 'cleanShutdown', 'showUsersPage', 'pauseSlave', ] def __init__(self, default_action=False, auth=None, useHttpHeader=False, httpLoginUrl=False, **kwargs): self.auth = auth if auth: assert IAuth.providedBy(auth) self.useHttpHeader = useHttpHeader self.httpLoginUrl = httpLoginUrl self.config = dict( (a, default_action) for a in self.knownActions ) for act in self.knownActions: if act in kwargs: self.config[act] = kwargs[act] del kwargs[act] self.sessions = SessionManager() if kwargs: raise ValueError("unknown authorization action(s) " + ", ".join(kwargs.keys())) def session(self, request): if COOKIE_KEY in request.received_cookies: cookie = request.received_cookies[COOKIE_KEY] return self.sessions.get(cookie) return None def authenticated(self, request): if self.useHttpHeader: return request.getUser() != '' return self.session(request) != None def getUserInfo(self, user): if self.useHttpHeader: return dict(userName=user, fullName=user, email=user, groups=[ user ]) s = self.sessions.getUser(user) if s: return s.infos def getUsername(self, request): """Get the userid of the user""" if self.useHttpHeader: return request.getUser() s = self.session(request) if s: return s.user return request.args.get("username", [""])[0] def getUsernameHTML(self, request): """Get the user formatated in html (with possible link to email)""" if self.useHttpHeader: return request.getUser() s = self.session(request) if s: return s.userInfosHTML() return "not authenticated?!" def getUsernameFull(self, request): """Get the full username as fullname """ if self.useHttpHeader: return request.getUser() s = self.session(request) if s: return "%(fullName)s <%(email)s>"%(s.infos) else: return request.args.get("username", [""])[0] def getPassword(self, request): if self.useHttpHeader: return request.getPassword() return request.args.get("passwd", [""])[0] def advertiseAction(self, action, request): """Should the web interface even show the form for ACTION?""" if action not in self.knownActions: raise KeyError("unknown action") cfg = self.config.get(action, False) if cfg: if cfg == 'auth' or callable(cfg): return self.authenticated(request) return cfg def actionAllowed(self, action, request, *args): """Is this ACTION allowed, given this http REQUEST?""" if action not in self.knownActions: raise KeyError("unknown action") cfg = self.config.get(action, False) if cfg: if cfg == 'auth' or callable(cfg): if not self.auth: return defer.succeed(False) def check_authenticate(res): if callable(cfg) and not cfg(self.getUsername(request), *args): return False return True # retain old behaviour, if people have scripts # without cookie support passwd = self.getPassword(request) if self.authenticated(request): return defer.succeed(check_authenticate(None)) elif passwd != "": def check_login(cookie): ret = False if type(cookie) is str: ret = check_authenticate(None) self.sessions.remove(cookie) return ret d = self.login(request) d.addBoth(check_login) return d else: return defer.succeed(False) return defer.succeed(cfg) def login(self, request): """Login one user, and return session cookie""" if self.authenticated(request): return defer.succeed(False) user = request.args.get("username", [""])[0] passwd = request.args.get("passwd", [""])[0] if user == "" or passwd == "": return defer.succeed(False) if not self.auth: return defer.succeed(False) d = defer.maybeDeferred(self.auth.authenticate, user, passwd) def check_authenticate(res): if res: cookie, s = self.sessions.new(user, self.auth.getUserInfo(user)) request.addCookie(COOKIE_KEY, cookie, expires=s.getExpiration(),path="/") request.received_cookies = {COOKIE_KEY:cookie} return cookie else: return False d.addBoth(check_authenticate) return d def logout(self, request): if COOKIE_KEY in request.received_cookies: cookie = request.received_cookies[COOKIE_KEY] self.sessions.remove(cookie) buildbot-0.8.8/buildbot/status/web/base.py000066400000000000000000000677571222546025000205470ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import urlparse, urllib, time, re import os, cgi, sys, locale import jinja2 from zope.interface import Interface from twisted.internet import defer from twisted.web import resource, static, server from twisted.python import log from buildbot.status import builder, buildstep, build from buildbot.status.results import SUCCESS, WARNINGS, FAILURE, SKIPPED from buildbot.status.results import EXCEPTION, RETRY from buildbot import version, util from buildbot.process.properties import Properties class ITopBox(Interface): """I represent a box in the top row of the waterfall display: the one which shows the status of the last build for each builder.""" def getBox(self, request): """Return a Box instance, which can produce a cell. """ class ICurrentBox(Interface): """I represent the 'current activity' box, just above the builder name.""" def getBox(self, status): """Return a Box instance, which can produce a cell. """ class IBox(Interface): """I represent a box in the waterfall display.""" def getBox(self, request): """Return a Box instance, which wraps an Event and can produce a cell. """ class IHTMLLog(Interface): pass css_classes = {SUCCESS: "success", WARNINGS: "warnings", FAILURE: "failure", SKIPPED: "skipped", EXCEPTION: "exception", RETRY: "retry", None: "", } def getAndCheckProperties(req): """ Fetch custom build properties from the HTTP request of a "Force build" or "Resubmit build" HTML form. Check the names for valid strings, and return None if a problem is found. Return a new Properties object containing each property found in req. """ master = req.site.buildbot_service.master pname_validate = master.config.validation['property_name'] pval_validate = master.config.validation['property_value'] properties = Properties() i = 1 while True: pname = req.args.get("property%dname" % i, [""])[0] pvalue = req.args.get("property%dvalue" % i, [""])[0] if not pname: break if not pname_validate.match(pname) \ or not pval_validate.match(pvalue): log.msg("bad property name='%s', value='%s'" % (pname, pvalue)) return None properties.setProperty(pname, pvalue, "Force Build Form") i = i + 1 return properties def build_get_class(b): """ Return the class to use for a finished build or buildstep, based on the result. """ # FIXME: this getResults duplicity might need to be fixed result = b.getResults() if isinstance(b, build.BuildStatus): result = b.getResults() elif isinstance(b, buildstep.BuildStepStatus): result = b.getResults()[0] # after forcing a build, b.getResults() returns ((None, []), []), ugh if isinstance(result, tuple): result = result[0] else: raise TypeError, "%r is not a BuildStatus or BuildStepStatus" % b if result == None: # FIXME: this happens when a buildstep is running ? return "running" return builder.Results[result] def path_to_root(request): # /waterfall : ['waterfall'] -> './' # /somewhere/lower : ['somewhere', 'lower'] -> '../' # /somewhere/indexy/ : ['somewhere', 'indexy', ''] -> '../../' # / : [] -> './' if request.prepath: segs = len(request.prepath) - 1 else: segs = 0 root = "../" * segs if segs else './' return root def path_to_authfail(request): return path_to_root(request) + "authfail" def path_to_authzfail(request): return path_to_root(request) + "authzfail" def path_to_builder(request, builderstatus): return (path_to_root(request) + "builders/" + urllib.quote(builderstatus.getName(), safe='')) def path_to_build(request, buildstatus): return (path_to_builder(request, buildstatus.getBuilder()) + "/builds/%d" % buildstatus.getNumber()) def path_to_step(request, stepstatus): return (path_to_build(request, stepstatus.getBuild()) + "/steps/%s" % urllib.quote(stepstatus.getName(), safe='')) def path_to_slave(request, slave): return (path_to_root(request) + "buildslaves/" + urllib.quote(slave.getName(), safe='')) def path_to_change(request, change): return (path_to_root(request) + "changes/%s" % change.number) class Box: # a Box wraps an Event. The Box has HTML parameters that Events # lack, and it has a base URL to which each File's name is relative. # Events don't know about HTML. spacer = False def __init__(self, text=[], class_=None, urlbase=None, **parms): self.text = text self.class_ = class_ self.urlbase = urlbase self.show_idle = 0 if parms.has_key('show_idle'): del parms['show_idle'] self.show_idle = 1 self.parms = parms # parms is a dict of HTML parameters for the element that will # represent this Event in the waterfall display. def td(self, **props): props.update(self.parms) text = self.text if not text and self.show_idle: text = ["[idle]"] props['class'] = self.class_ props['text'] = text; return props class AccessorMixin(object): def getStatus(self, request): return request.site.buildbot_service.getStatus() def getPageTitle(self, request): return self.pageTitle def getAuthz(self, request): return request.site.buildbot_service.authz def getBuildmaster(self, request): return request.site.buildbot_service.master class ContextMixin(AccessorMixin): def getContext(self, request): status = self.getStatus(request) rootpath = path_to_root(request) locale_enc = locale.getdefaultlocale()[1] if locale_enc is not None: locale_tz = unicode(time.tzname[time.localtime()[-1]], locale_enc) else: locale_tz = unicode(time.tzname[time.localtime()[-1]]) return dict(title_url = status.getTitleURL(), title = status.getTitle(), stylesheet = rootpath + 'default.css', path_to_root = rootpath, version = version, time = time.strftime("%a %d %b %Y %H:%M:%S", time.localtime(util.now())), tz = locale_tz, metatags = [], pageTitle = self.getPageTitle(request), welcomeurl = rootpath, authz = self.getAuthz(request), request = request, alert_msg = request.args.get("alert_msg", [""])[0], ) class ActionResource(resource.Resource, AccessorMixin): """A resource that performs some action, then redirects to a new URL.""" isLeaf = 1 def getChild(self, name, request): return self def performAction(self, request): """ Perform the action, and return the URL to redirect to @param request: the web request @returns: URL via Deferred can also return (URL, alert_msg) to display simple feedback to user in case of failure """ def render(self, request): d = defer.maybeDeferred(lambda : self.performAction(request)) def redirect(url): if isinstance(url, tuple): url, alert_msg = url if alert_msg: url += "?alert_msg="+urllib.quote(alert_msg, safe='') request.redirect(url) request.write("see %s" % (url,url)) try: request.finish() except RuntimeError: # this occurs when the client has already disconnected; ignore # it (see #2027) log.msg("http client disconnected before results were sent") d.addCallback(redirect) def fail(f): request.processingFailed(f) return None # processingFailed will log this for us d.addErrback(fail) return server.NOT_DONE_YET class HtmlResource(resource.Resource, ContextMixin): # this is a cheap sort of template thingy contentType = "text/html; charset=utf-8" pageTitle = "Buildbot" addSlash = False # adapted from Nevow def getChild(self, path, request): if self.addSlash and path == "" and len(request.postpath) == 0: return self return resource.Resource.getChild(self, path, request) def content(self, req, context): """ Generate content using the standard layout and the result of the C{body} method. This is suitable for the case where a resource just wants to generate the body of a page. It depends on another method, C{body}, being defined to accept the request object and return a C{str}. C{render} will call this method and to generate the response body. """ body = self.body(req) context['content'] = body template = req.site.buildbot_service.templates.get_template( "empty.html") return template.render(**context) def render(self, request): # tell the WebStatus about the HTTPChannel that got opened, so they # can close it if we get reconfigured and the WebStatus goes away. # They keep a weakref to this, since chances are good that it will be # closed by the browser or by us before we get reconfigured. See # ticket #102 for details. if hasattr(request, "channel"): # web.distrib.Request has no .channel request.site.buildbot_service.registerChannel(request.channel) # Our pages no longer require that their URL end in a slash. Instead, # they all use request.childLink() or some equivalent which takes the # last path component into account. This clause is left here for # historical and educational purposes. if False and self.addSlash and request.prepath[-1] != '': # this is intended to behave like request.URLPath().child('') # but we need a relative URL, since we might be living behind a # reverse proxy # # note that the Location: header (as used in redirects) are # required to have absolute URIs, and my attempt to handle # reverse-proxies gracefully violates rfc2616. This frequently # works, but single-component paths sometimes break. The best # strategy is to avoid these redirects whenever possible by using # HREFs with trailing slashes, and only use the redirects for # manually entered URLs. url = request.prePathURL() scheme, netloc, path, query, fragment = urlparse.urlsplit(url) new_url = request.prepath[-1] + "/" if query: new_url += "?" + query request.redirect(new_url) return '' ctx = self.getContext(request) d = defer.maybeDeferred(lambda : self.content(request, ctx)) def handle(data): if isinstance(data, unicode): data = data.encode("utf-8") request.setHeader("content-type", self.contentType) if request.method == "HEAD": request.setHeader("content-length", len(data)) return '' return data d.addCallback(handle) def ok(data): request.write(data) try: request.finish() except RuntimeError: # this occurs when the client has already disconnected; ignore # it (see #2027) log.msg("http client disconnected before results were sent") def fail(f): request.processingFailed(f) return None # processingFailed will log this for us d.addCallbacks(ok, fail) return server.NOT_DONE_YET class StaticHTML(HtmlResource): def __init__(self, body, pageTitle): HtmlResource.__init__(self) self.bodyHTML = body self.pageTitle = pageTitle def content(self, request, cxt): cxt['content'] = self.bodyHTML cxt['pageTitle'] = self.pageTitle template = request.site.buildbot_service.templates.get_template("empty.html") return template.render(**cxt) class DirectoryLister(static.DirectoryLister, ContextMixin): """This variant of the static.DirectoryLister uses a template for rendering.""" pageTitle = 'BuildBot' def render(self, request): cxt = self.getContext(request) if self.dirs is None: directory = os.listdir(self.path) directory.sort() else: directory = self.dirs dirs, files = self._getFilesAndDirectories(directory) cxt['path'] = cgi.escape(urllib.unquote(request.uri)) cxt['directories'] = dirs cxt['files'] = files template = request.site.buildbot_service.templates.get_template("directory.html") data = template.render(**cxt) if isinstance(data, unicode): data = data.encode("utf-8") return data class StaticFile(static.File): """This class adds support for templated directory views.""" def directoryListing(self): return DirectoryLister(self.path, self.listNames(), self.contentTypes, self.contentEncodings, self.defaultType) MINUTE = 60 HOUR = 60*MINUTE DAY = 24*HOUR WEEK = 7*DAY MONTH = 30*DAY def plural(word, words, num): if int(num) == 1: return "%d %s" % (num, word) else: return "%d %s" % (num, words) def abbreviate_age(age): if age <= 90: return "%s ago" % plural("second", "seconds", age) if age < 90*MINUTE: return "about %s ago" % plural("minute", "minutes", age / MINUTE) if age < DAY: return "about %s ago" % plural("hour", "hours", age / HOUR) if age < 2*WEEK: return "about %s ago" % plural("day", "days", age / DAY) if age < 2*MONTH: return "about %s ago" % plural("week", "weeks", age / WEEK) return "a long time ago" class BuildLineMixin: LINE_TIME_FORMAT = "%b %d %H:%M" def get_line_values(self, req, build, include_builder=True): ''' Collect the data needed for each line display ''' builder_name = build.getBuilder().getName() results = build.getResults() text = build.getText() all_got_revision = build.getAllGotRevisions() css_class = css_classes.get(results, "") ss_list = build.getSourceStamps() if ss_list: repo = ss_list[0].repository if all_got_revision: if len(ss_list) == 1: rev = all_got_revision.get(ss_list[0].codebase, "??") else: rev = "multiple rev." else: rev = "??" else: repo = 'unknown, no information in build' rev = 'unknown' if type(text) == list: text = " ".join(text) values = {'class': css_class, 'builder_name': builder_name, 'buildnum': build.getNumber(), 'results': css_class, 'text': " ".join(build.getText()), 'buildurl': path_to_build(req, build), 'builderurl': path_to_builder(req, build.getBuilder()), 'rev': rev, 'rev_repo' : repo, 'time': time.strftime(self.LINE_TIME_FORMAT, time.localtime(build.getTimes()[0])), 'text': text, 'include_builder': include_builder } return values def map_branches(branches): # when the query args say "trunk", present that to things like # IBuilderStatus.generateFinishedBuilds as None, since that's the # convention in use. But also include 'trunk', because some VC systems # refer to it that way. In the long run we should clean this up better, # maybe with Branch objects or something. if "trunk" in branches: return branches + [None] return branches # jinja utilities def createJinjaEnv(revlink=None, changecommentlink=None, repositories=None, projects=None, jinja_loaders=None): ''' Create a jinja environment changecommentlink is used to render HTML in the WebStatus and for mail changes @type changecommentlink: C{None}, tuple (2 or 3 strings), dict (string -> 2- or 3-tuple) or callable @param changecommentlink: see changelinkfilter() @type revlink: C{None}, format-string, dict (repository -> format string) or callable @param revlink: see revlinkfilter() @type repositories: C{None} or dict (string -> url) @param repositories: an (optinal) mapping from repository identifiers (as given by Change sources) to URLs. Is used to create a link on every place where a repository is listed in the WebStatus. @type projects: C{None} or dict (string -> url) @param projects: similar to repositories, but for projects. ''' # See http://buildbot.net/trac/ticket/658 assert not hasattr(sys, "frozen"), 'Frozen config not supported with jinja (yet)' all_loaders = [jinja2.FileSystemLoader(os.path.join(os.getcwd(), 'templates'))] if jinja_loaders: all_loaders.extend(jinja_loaders) all_loaders.append(jinja2.PackageLoader('buildbot.status.web', 'templates')) loader = jinja2.ChoiceLoader(all_loaders) env = jinja2.Environment(loader=loader, extensions=['jinja2.ext.i18n'], trim_blocks=True, undefined=AlmostStrictUndefined) env.install_null_translations() # needed until we have a proper i18n backend env.tests['mapping'] = lambda obj : isinstance(obj, dict) env.filters.update(dict( urlencode = urllib.quote, email = emailfilter, user = userfilter, shortrev = shortrevfilter(revlink, env), revlink = revlinkfilter(revlink, env), changecomment = changelinkfilter(changecommentlink), repolink = dictlinkfilter(repositories), projectlink = dictlinkfilter(projects) )) return env def emailfilter(value): ''' Escape & obfuscate e-mail addresses replacing @ with ohnoyoudont@') output = user.replace('@', obfuscator) return output def userfilter(value): ''' Hide e-mail address from user name when viewing changes We still include the (obfuscated) e-mail so that we can show it on mouse-over or similar etc ''' r = re.compile('(.*) +<(.*)>') m = r.search(value) if m: user = jinja2.escape(m.group(1)) email = emailfilter(m.group(2)) return jinja2.Markup('
%s
' % (user, email)) else: return emailfilter(value) # filter for emails here for safety def _revlinkcfg(replace, templates): '''Helper function that returns suitable macros and functions for building revision links depending on replacement mechanism ''' assert not replace or callable(replace) or isinstance(replace, dict) or \ isinstance(replace, str) or isinstance(replace, unicode) if not replace: return lambda rev, repo: None else: if callable(replace): return lambda rev, repo: replace(rev, repo) elif isinstance(replace, dict): # TODO: test for [] instead def filter(rev, repo): url = replace.get(repo) if url: return url % urllib.quote(rev) else: return None return filter else: return lambda rev, repo: replace % urllib.quote(rev) assert False, '_replace has a bad type, but we should never get here' def _revlinkmacros(replace, templates): '''return macros for use with revision links, depending on whether revlinks are configured or not''' macros = templates.get_template("revmacros.html").module if not replace: id = macros.id short = macros.shorten else: id = macros.id_replace short = macros.shorten_replace return (id, short) def shortrevfilter(replace, templates): ''' Returns a function which shortens the revisison string to 12-chars (chosen as this is the Mercurial short-id length) and add link if replacement string is set. (The full id is still visible in HTML, for mouse-over events etc.) @param replace: see revlinkfilter() @param templates: a jinja2 environment ''' url_f = _revlinkcfg(replace, templates) def filter(rev, repo): if not rev: return u'' id_html, short_html = _revlinkmacros(replace, templates) rev = unicode(rev) url = url_f(rev, repo) rev = jinja2.escape(rev) shortrev = rev[:12] # TODO: customize this depending on vc type if shortrev == rev: if url: return id_html(rev=rev, url=url) else: return rev else: if url: return short_html(short=shortrev, rev=rev, url=url) else: return shortrev + '...' return filter def revlinkfilter(replace, templates): ''' Returns a function which adds an url link to a revision identifiers. Takes same params as shortrevfilter() @param replace: either a python format string with an %s, or a dict mapping repositories to format strings, or a callable taking (revision, repository) arguments and return an URL (or None, if no URL is available), or None, in which case revisions do not get decorated with links @param templates: a jinja2 environment ''' url_f = _revlinkcfg(replace, templates) def filter(rev, repo): if not rev: return u'' rev = unicode(rev) url = url_f(rev, repo) if url: id_html, _ = _revlinkmacros(replace, templates) return id_html(rev=rev, url=url) else: return jinja2.escape(rev) return filter def changelinkfilter(changelink): ''' Returns function that does regex search/replace in comments to add links to bug ids and similar. @param changelink: Either C{None} or: a tuple (2 or 3 elements) 1. a regex to match what we look for 2. an url with regex refs (\g<0>, \1, \2, etc) that becomes the 'href' attribute 3. (optional) an title string with regex ref regex or: a dict mapping projects to above tuples (no links will be added if the project isn't found) or: a callable taking (changehtml, project) args (where the changetext is HTML escaped in the form of a jinja2.Markup instance) and returning another jinja2.Markup instance with the same change text plus any HTML tags added to it. ''' assert not changelink or isinstance(changelink, dict) or \ isinstance(changelink, tuple) or callable(changelink) def replace_from_tuple(t): search, url_replace = t[:2] if len(t) == 3: title_replace = t[2] else: title_replace = '' search_re = re.compile(search) def replacement_unmatched(text): return jinja2.escape(text) def replacement_matched(mo): # expand things *after* application of the regular expressions url = jinja2.escape(mo.expand(url_replace)) title = jinja2.escape(mo.expand(title_replace)) body = jinja2.escape(mo.group()) if title: return '%s' % (url, title, body) else: return '%s' % (url, body) def filter(text, project): # now, we need to split the string into matched and unmatched portions, # quoting the unmatched portions directly and quoting the components of # the 'a' element for the matched portions. We can't use re.split here, # because the user-supplied patterns may have multiple groups. html = [] last_idx = 0 for mo in search_re.finditer(text): html.append(replacement_unmatched(text[last_idx:mo.start()])) html.append(replacement_matched(mo)) last_idx = mo.end() html.append(replacement_unmatched(text[last_idx:])) return jinja2.Markup(''.join(html)) return filter if not changelink: return lambda text, project: jinja2.escape(text) elif isinstance(changelink, dict): def dict_filter(text, project): # TODO: Optimize and cache return value from replace_from_tuple so # we only compile regex once per project, not per view t = changelink.get(project) if t: return replace_from_tuple(t)(text, project) else: return cgi.escape(text) return dict_filter elif isinstance(changelink, tuple): return replace_from_tuple(changelink) elif callable(changelink): def callable_filter(text, project): text = jinja2.escape(text) return changelink(text, project) return callable_filter assert False, 'changelink has unsupported type, but that is checked before' def dictlinkfilter(links): '''A filter that encloses the given value in a link tag given that the value exists in the dictionary''' assert not links or callable(links) or isinstance(links, dict) if not links: return jinja2.escape def filter(key): if callable(links): url = links(key) else: url = links.get(key) safe_key = jinja2.escape(key) if url: return jinja2.Markup(r'%s' % (url, safe_key)) else: return safe_key return filter class AlmostStrictUndefined(jinja2.StrictUndefined): ''' An undefined that allows boolean testing but fails properly on every other use. Much better than the default Undefined, but not fully as strict as StrictUndefined ''' def __nonzero__(self): return False _charsetRe = re.compile('charset=([^;]*)', re.I) def getRequestCharset(req): """Get the charset for an x-www-form-urlencoded request""" # per http://stackoverflow.com/questions/708915/detecting-the-character-encoding-of-an-http-post-request hdr = req.getHeader('Content-Type') if hdr: mo = _charsetRe.search(hdr) if mo: return mo.group(1).strip() return 'utf-8' # reasonable guess, works for ascii buildbot-0.8.8/buildbot/status/web/baseweb.py000066400000000000000000000703411222546025000212240ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os, weakref from zope.interface import implements from twisted.python import log from twisted.application import strports, service from twisted.internet import defer from twisted.web import server, distrib, static from twisted.spread import pb from twisted.web.util import Redirect from buildbot import config from buildbot.interfaces import IStatusReceiver from buildbot.status.web.base import StaticFile, createJinjaEnv from buildbot.status.web.feeds import Rss20StatusResource, \ Atom10StatusResource from buildbot.status.web.waterfall import WaterfallStatusResource from buildbot.status.web.console import ConsoleStatusResource from buildbot.status.web.olpb import OneLinePerBuild from buildbot.status.web.grid import GridStatusResource from buildbot.status.web.grid import TransposedGridStatusResource from buildbot.status.web.changes import ChangesResource from buildbot.status.web.builder import BuildersResource from buildbot.status.web.buildstatus import BuildStatusStatusResource from buildbot.status.web.slaves import BuildSlavesResource from buildbot.status.web.status_json import JsonStatusResource from buildbot.status.web.about import AboutBuildbot from buildbot.status.web.authz import Authz from buildbot.status.web.auth import AuthFailResource,AuthzFailResource, LoginResource, LogoutResource from buildbot.status.web.root import RootPage from buildbot.status.web.users import UsersResource from buildbot.status.web.change_hook import ChangeHookResource from twisted.cred.portal import IRealm, Portal from twisted.cred import strcred from twisted.cred.checkers import ICredentialsChecker from twisted.cred.credentials import IUsernamePassword from twisted.web import resource, guard # this class contains the WebStatus class. Basic utilities are in base.py, # and specific pages are each in their own module. class WebStatus(service.MultiService): implements(IStatusReceiver) # TODO: IStatusReceiver is really about things which subscribe to hear # about buildbot events. We need a different interface (perhaps a parent # of IStatusReceiver) for status targets that don't subscribe, like the # WebStatus class. buildbot.master.BuildMaster.loadConfig:737 asserts # that everything in c['status'] provides IStatusReceiver, but really it # should check that they provide IStatusTarget instead. """ The webserver provided by this class has the following resources: /waterfall : the big time-oriented 'waterfall' display, with links to individual changes, builders, builds, steps, and logs. A number of query-arguments can be added to influence the display. /rss : a rss feed summarizing all failed builds. The same query-arguments used by 'waterfall' can be added to influence the feed output. /atom : an atom feed summarizing all failed builds. The same query-arguments used by 'waterfall' can be added to influence the feed output. /grid : another summary display that shows a grid of builds, with sourcestamps on the x axis, and builders on the y. Query arguments similar to those for the waterfall can be added. /tgrid : similar to the grid display, but the commits are down the left side, and the build hosts are across the top. /builders/BUILDERNAME: a page summarizing the builder. This includes references to the Schedulers that feed it, any builds currently in the queue, which buildslaves are designated or attached, and a summary of the build process it uses. /builders/BUILDERNAME/builds/NUM: a page describing a single Build /builders/BUILDERNAME/builds/NUM/steps/STEPNAME: describes a single step /builders/BUILDERNAME/builds/NUM/steps/STEPNAME/logs/LOGNAME: a StatusLog /builders/_all/{force,stop}: force a build/stop building on all builders. /buildstatus?builder=...&number=...: an embedded iframe for the console /changes : summarize all ChangeSources /changes/CHANGENUM: a page describing a single Change /buildslaves : list all BuildSlaves /buildslaves/SLAVENAME : describe a single BuildSlave /one_line_per_build : summarize the last few builds, one line each /one_line_per_build/BUILDERNAME : same, but only for a single builder /about : describe this buildmaster (Buildbot and support library versions) /change_hook[/DIALECT] : accepts changes from external sources, optionally choosing the dialect that will be permitted (i.e. github format, etc..) and more! see the manual. All URLs for pages which are not defined here are used to look for files in PUBLIC_HTML, which defaults to BASEDIR/public_html. This means that /robots.txt or /favicon.ico can be placed in that directory This webserver uses the jinja2 template system to generate the web pages (see http://jinja.pocoo.org/2/) and by default loads pages from the buildbot.status.web.templates package. Any file here can be overridden by placing a corresponding file in the master's 'templates' directory. The main customization points are layout.html which loads style sheet (css) and provides header and footer content, and root.html, which generates the root page. All of the resources provided by this service use relative URLs to reach each other. The only absolute links are the c['titleURL'] links at the top and bottom of the page, and the buildbot home-page link at the bottom. Buildbot uses some generic classes to identify the type of object, and some more specific classes for the various kinds of those types. It does this by specifying both in the class attributes where applicable, separated by a space. It is important that in your CSS you declare the more generic class styles above the more specific ones. For example, first define a style for .Event, and below that for .SUCCESS The following CSS class names are used: - Activity, Event, BuildStep, LastBuild: general classes - waiting, interlocked, building, offline, idle: Activity states - start, running, success, failure, warnings, skipped, exception: LastBuild and BuildStep states - Change: box with change - Builder: box for builder name (at top) - Project - Time """ # we are not a ComparableMixin, and therefore the webserver will be # rebuilt every time we reconfig. This is because WebStatus.putChild() # makes it too difficult to tell whether two instances are the same or # not (we'd have to do a recursive traversal of all children to discover # all the changes). def __init__(self, http_port=None, distrib_port=None, allowForce=None, public_html="public_html", site=None, numbuilds=20, num_events=200, num_events_max=None, auth=None, order_console_by_time=False, changecommentlink=None, revlink=None, projects=None, repositories=None, authz=None, logRotateLength=None, maxRotatedFiles=None, change_hook_dialects = {}, provide_feeds=None, jinja_loaders=None, change_hook_auth=None): """Run a web server that provides Buildbot status. @type http_port: int or L{twisted.application.strports} string @param http_port: a strports specification describing which port the buildbot should use for its web server, with the Waterfall display as the root page. For backwards compatibility this can also be an int. Use 'tcp:8000' to listen on that port, or 'tcp:12345:interface=127.0.0.1' if you only want local processes to connect to it (perhaps because you are using an HTTP reverse proxy to make the buildbot available to the outside world, and do not want to make the raw port visible). @type distrib_port: int or L{twisted.application.strports} string @param distrib_port: Use this if you want to publish the Waterfall page using web.distrib instead. The most common case is to provide a string that is an absolute pathname to the unix socket on which the publisher should listen (C{os.path.expanduser(~/.twistd-web-pb)} will match the default settings of a standard twisted.web 'personal web server'). Another possibility is to pass an integer, which means the publisher should listen on a TCP socket, allowing the web server to be on a different machine entirely. Both forms are provided for backwards compatibility; the preferred form is a strports specification like 'unix:/home/buildbot/.twistd-web-pb'. Providing a non-absolute pathname will probably confuse the strports parser. @param allowForce: deprecated; use authz instead @param auth: deprecated; use with authz @param authz: a buildbot.status.web.authz.Authz instance giving the authorization parameters for this view @param public_html: the path to the public_html directory for this display, either absolute or relative to the basedir. The default is 'public_html', which selects BASEDIR/public_html. @type site: None or L{twisted.web.server.Site} @param site: Use this if you want to define your own object instead of using the default.` @type numbuilds: int @param numbuilds: Default number of entries in lists at the /one_line_per_build and /builders/FOO URLs. This default can be overriden both programatically --- by passing the equally named argument to constructors of OneLinePerBuildOneBuilder and OneLinePerBuild --- and via the UI, by tacking ?numbuilds=xy onto the URL. @type num_events: int @param num_events: Default number of events to show in the waterfall. @type num_events_max: int @param num_events_max: The maximum number of events that are allowed to be shown in the waterfall. The default value of C{None} will disable this check @type auth: a L{status.web.auth.IAuth} or C{None} @param auth: an object that performs authentication to restrict access to the C{allowForce} features. Ignored if C{allowForce} is not C{True}. If C{auth} is C{None}, people can force or stop builds without auth. @type order_console_by_time: bool @param order_console_by_time: Whether to order changes (commits) in the console view according to the time they were created (for VCS like Git) or according to their integer revision numbers (for VCS like SVN). @type changecommentlink: callable, dict, tuple (2 or 3 strings) or C{None} @param changecommentlink: adds links to ticket/bug ids in change comments, see buildbot.status.web.base.changecommentlink for details @type revlink: callable, dict, string or C{None} @param revlink: decorations revision ids with links to a web-view, see buildbot.status.web.base.revlink for details @type projects: callable, dict or c{None} @param projects: maps project identifiers to URLs, so that any project listed is automatically decorated with a link to it's front page. see buildbot.status.web.base.dictlink for details @type repositories: callable, dict or c{None} @param repositories: maps repository identifiers to URLs, so that any project listed is automatically decorated with a link to it's web view. see buildbot.status.web.base.dictlink for details @type logRotateLength: None or int @param logRotateLength: file size at which the http.log is rotated/reset. If not set, the value set in the buildbot.tac will be used, falling back to the BuildMaster's default value (1 Mb). @type maxRotatedFiles: None or int @param maxRotatedFiles: number of old http.log files to keep during log rotation. If not set, the value set in the buildbot.tac will be used, falling back to the BuildMaster's default value (10 files). @type change_hook_dialects: None or dict @param change_hook_dialects: If empty, disables change_hook support, otherwise whitelists valid dialects. In the format of {"dialect1": "Option1", "dialect2", None} Where the values are options that will be passed to the dialect To enable the DEFAULT handler, use a key of DEFAULT @type provide_feeds: None or list @param provide_feeds: If empty, provides atom, json, and rss feeds. Otherwise, a dictionary of strings of the type of feeds provided. Current possibilities are "atom", "json", and "rss" @type jinja_loaders: None or list @param jinja_loaders: If not empty, a list of additional Jinja2 loader objects to search for templates. """ service.MultiService.__init__(self) if type(http_port) is int: http_port = "tcp:%d" % http_port self.http_port = http_port if distrib_port is not None: if type(distrib_port) is int: distrib_port = "tcp:%d" % distrib_port if distrib_port[0] in "/~.": # pathnames distrib_port = "unix:%s" % distrib_port self.distrib_port = distrib_port self.num_events = num_events if num_events_max: if num_events_max < num_events: config.error( "num_events_max must be greater than num_events") self.num_events_max = num_events_max self.public_html = public_html # make up an authz if allowForce was given if authz: if allowForce is not None: config.error( "cannot use both allowForce and authz parameters") if auth: config.error( "cannot use both auth and authz parameters (pass " + "auth as an Authz parameter)") else: # invent an authz if allowForce and auth: authz = Authz(auth=auth, default_action="auth") elif allowForce: authz = Authz(default_action=True) else: if auth: log.msg("Warning: Ignoring authentication. Search for 'authorization'" " in the manual") authz = Authz() # no authorization for anything self.authz = authz # check for correctness of HTTP auth parameters if change_hook_auth is not None: self.change_hook_auth = [] for checker in change_hook_auth: if isinstance(checker, str): try: checker = strcred.makeChecker(checker) except Exception, error: config.error("Invalid change_hook checker description: %s" % (error,)) continue elif not ICredentialsChecker.providedBy(checker): config.error("change_hook checker doesn't provide ICredentialChecker: %r" % (checker,)) continue if IUsernamePassword not in checker.credentialInterfaces: config.error("change_hook checker doesn't support IUsernamePassword: %r" % (checker,)) continue self.change_hook_auth.append(checker) else: self.change_hook_auth = None self.orderConsoleByTime = order_console_by_time # If we were given a site object, go ahead and use it. (if not, we add one later) self.site = site # keep track of our child services self.http_svc = None self.distrib_svc = None # store the log settings until we create the site object self.logRotateLength = logRotateLength self.maxRotatedFiles = maxRotatedFiles # create the web site page structure self.childrenToBeAdded = {} self.setupUsualPages(numbuilds=numbuilds, num_events=num_events, num_events_max=num_events_max) self.revlink = revlink self.changecommentlink = changecommentlink self.repositories = repositories self.projects = projects # keep track of cached connections so we can break them when we shut # down. See ticket #102 for more details. self.channels = weakref.WeakKeyDictionary() # do we want to allow change_hook self.change_hook_dialects = {} if change_hook_dialects: self.change_hook_dialects = change_hook_dialects resource_obj = ChangeHookResource(dialects=self.change_hook_dialects) if self.change_hook_auth is not None: resource_obj = self.setupProtectedResource( resource_obj, self.change_hook_auth) self.putChild("change_hook", resource_obj) # Set default feeds if provide_feeds is None: self.provide_feeds = ["atom", "json", "rss"] else: self.provide_feeds = provide_feeds self.jinja_loaders = jinja_loaders def setupProtectedResource(self, resource_obj, checkers): class SimpleRealm(object): """ A realm which gives out L{ChangeHookResource} instances for authenticated users. """ implements(IRealm) def requestAvatar(self, avatarId, mind, *interfaces): if resource.IResource in interfaces: return (resource.IResource, resource_obj, lambda: None) raise NotImplementedError() portal = Portal(SimpleRealm(), checkers) credentialFactory = guard.BasicCredentialFactory('Protected area') wrapper = guard.HTTPAuthSessionWrapper(portal, [credentialFactory]) return wrapper def setupUsualPages(self, numbuilds, num_events, num_events_max): #self.putChild("", IndexOrWaterfallRedirection()) self.putChild("waterfall", WaterfallStatusResource(num_events=num_events, num_events_max=num_events_max)) self.putChild("grid", GridStatusResource()) self.putChild("console", ConsoleStatusResource( orderByTime=self.orderConsoleByTime)) self.putChild("tgrid", TransposedGridStatusResource()) self.putChild("builders", BuildersResource(numbuilds=numbuilds)) # has builds/steps/logs self.putChild("one_box_per_builder", Redirect("builders")) self.putChild("changes", ChangesResource()) self.putChild("buildslaves", BuildSlavesResource()) self.putChild("buildstatus", BuildStatusStatusResource()) self.putChild("one_line_per_build", OneLinePerBuild(numbuilds=numbuilds)) self.putChild("about", AboutBuildbot()) self.putChild("authfail", AuthFailResource()) self.putChild("authzfail", AuthzFailResource()) self.putChild("users", UsersResource()) self.putChild("login", LoginResource()) self.putChild("logout", LogoutResource()) def __repr__(self): if self.http_port is None: return "" % (self.distrib_port, hex(id(self))) if self.distrib_port is None: return "" % (self.http_port, hex(id(self))) return ("" % (self.http_port, self.distrib_port, hex(id(self)))) def setServiceParent(self, parent): # this class keeps a *separate* link to the buildmaster, rather than # just using self.parent, so that when we are "disowned" (and thus # parent=None), any remaining HTTP clients of this WebStatus will still # be able to get reasonable results. self.master = parent.master # set master in IAuth instance if self.authz.auth: self.authz.auth.master = self.master def either(a,b): # a if a else b for py2.4 if a: return a else: return b rotateLength = either(self.logRotateLength, self.master.log_rotation.rotateLength) maxRotatedFiles = either(self.maxRotatedFiles, self.master.log_rotation.maxRotatedFiles) # Set up the jinja templating engine. if self.revlink: revlink = self.revlink else: revlink = self.master.config.revlink self.templates = createJinjaEnv(revlink, self.changecommentlink, self.repositories, self.projects, self.jinja_loaders) if not self.site: class RotateLogSite(server.Site): def _openLogFile(self, path): try: from twisted.python.logfile import LogFile log.msg("Setting up http.log rotating %s files of %s bytes each" % (maxRotatedFiles, rotateLength)) if hasattr(LogFile, "fromFullPath"): # not present in Twisted-2.5.0 return LogFile.fromFullPath(path, rotateLength=rotateLength, maxRotatedFiles=maxRotatedFiles) else: log.msg("WebStatus: rotated http logs are not supported on this version of Twisted") except ImportError, e: log.msg("WebStatus: Unable to set up rotating http.log: %s" % e) # if all else fails, just call the parent method return server.Site._openLogFile(self, path) # this will be replaced once we've been attached to a parent (and # thus have a basedir and can reference BASEDIR) root = static.Data("placeholder", "text/plain") httplog = os.path.abspath(os.path.join(self.master.basedir, "http.log")) self.site = RotateLogSite(root, logPath=httplog) # the following items are accessed by HtmlResource when it renders # each page. self.site.buildbot_service = self if self.http_port is not None: self.http_svc = s = strports.service(self.http_port, self.site) s.setServiceParent(self) if self.distrib_port is not None: f = pb.PBServerFactory(distrib.ResourcePublisher(self.site)) self.distrib_svc = s = strports.service(self.distrib_port, f) s.setServiceParent(self) self.setupSite() service.MultiService.setServiceParent(self, parent) def setupSite(self): # this is responsible for creating the root resource. It isn't done # at __init__ time because we need to reference the parent's basedir. htmldir = os.path.abspath(os.path.join(self.master.basedir, self.public_html)) if os.path.isdir(htmldir): log.msg("WebStatus using (%s)" % htmldir) else: log.msg("WebStatus: warning: %s is missing. Do you need to run" " 'buildbot upgrade-master' on this buildmaster?" % htmldir) # all static pages will get a 404 until upgrade-master is used to # populate this directory. Create the directory, though, since # otherwise we get internal server errors instead of 404s. os.mkdir(htmldir) root = StaticFile(htmldir) root_page = RootPage() root.putChild("", root_page) root.putChild("shutdown", root_page) root.putChild("cancel_shutdown", root_page) for name, child_resource in self.childrenToBeAdded.iteritems(): root.putChild(name, child_resource) status = self.getStatus() if "rss" in self.provide_feeds: root.putChild("rss", Rss20StatusResource(status)) if "atom" in self.provide_feeds: root.putChild("atom", Atom10StatusResource(status)) if "json" in self.provide_feeds: root.putChild("json", JsonStatusResource(status)) self.site.resource = root def putChild(self, name, child_resource): """This behaves a lot like root.putChild() . """ self.childrenToBeAdded[name] = child_resource def registerChannel(self, channel): self.channels[channel] = 1 # weakrefs @defer.inlineCallbacks def stopService(self): for channel in self.channels: try: channel.transport.loseConnection() except: log.msg("WebStatus.stopService: error while disconnecting" " leftover clients") log.err() yield service.MultiService.stopService(self) # having shut them down, now remove our child services so they don't # start up again if we're re-started if self.http_svc: yield self.http_svc.disownServiceParent() self.http_svc = None if self.distrib_svc: yield self.distrib_svc.disownServiceParent() self.distrib_svc = None def getStatus(self): return self.master.getStatus() def getChangeSvc(self): return self.master.change_svc def getPortnum(self): # this is for the benefit of unit tests s = list(self)[0] return s._port.getHost().port # What happened to getControl?! # # instead of passing control objects all over the place in the web # code, at the few places where a control instance is required we # find the requisite object manually, starting at the buildmaster. # This is in preparation for removal of the IControl hierarchy # entirely. def checkConfig(self, otherStatusReceivers): duplicate_webstatus=0 for osr in otherStatusReceivers: if isinstance(osr,WebStatus): if osr is self: continue # compare against myself and complain if the settings conflict if self.http_port == osr.http_port: if duplicate_webstatus == 0: duplicate_webstatus = 2 else: duplicate_webstatus += 1 if duplicate_webstatus: config.error( "%d Webstatus objects have same port: %s" % (duplicate_webstatus, self.http_port), ) # resources can get access to the IStatus by calling # request.site.buildbot_service.getStatus() buildbot-0.8.8/buildbot/status/web/build.py000066400000000000000000000304521222546025000207120ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.web import html from twisted.internet import defer, reactor from twisted.web.util import Redirect, DeferredResource import urllib, time from twisted.python import log from buildbot.status.web.base import HtmlResource, \ css_classes, path_to_build, path_to_builder, path_to_slave, \ getAndCheckProperties, ActionResource, path_to_authzfail, \ getRequestCharset from buildbot.schedulers.forcesched import ForceScheduler, TextParameter from buildbot.status.web.step import StepsResource from buildbot.status.web.tests import TestsResource from buildbot import util, interfaces class ForceBuildActionResource(ActionResource): def __init__(self, build_status, builder): self.build_status = build_status self.builder = builder self.action = "forceBuild" @defer.inlineCallbacks def performAction(self, req): url = None authz = self.getAuthz(req) res = yield authz.actionAllowed(self.action, req, self.builder) if not res: url = path_to_authzfail(req) else: # get a control object c = interfaces.IControl(self.getBuildmaster(req)) bc = c.getBuilder(self.builder.getName()) b = self.build_status builder_name = self.builder.getName() log.msg("web rebuild of build %s:%s" % (builder_name, b.getNumber())) name =authz.getUsernameFull(req) comments = req.args.get("comments", [""])[0] comments.decode(getRequestCharset(req)) reason = ("The web-page 'rebuild' button was pressed by " "'%s': %s\n" % (name, comments)) msg = "" extraProperties = getAndCheckProperties(req) if not bc or not b.isFinished() or extraProperties is None: msg = "could not rebuild: " if b.isFinished(): msg += "build still not finished " if bc: msg += "could not get builder control" else: tup = yield bc.rebuildBuild(b, reason, extraProperties) # rebuildBuild returns None on error (?!) if not tup: msg = "rebuilding a build failed "+ str(tup) # we're at # http://localhost:8080/builders/NAME/builds/5/rebuild?[args] # Where should we send them? # # Ideally it would be to the per-build page that they just started, # but we don't know the build number for it yet (besides, it might # have to wait for a current build to finish). The next-most # preferred place is somewhere that the user can see tangible # evidence of their build starting (or to see the reason that it # didn't start). This should be the Builder page. url = path_to_builder(req, self.builder), msg defer.returnValue(url) class StopBuildActionResource(ActionResource): def __init__(self, build_status): self.build_status = build_status self.action = "stopBuild" @defer.inlineCallbacks def performAction(self, req): authz = self.getAuthz(req) res = yield authz.actionAllowed(self.action, req, self.build_status) if not res: defer.returnValue(path_to_authzfail(req)) return b = self.build_status log.msg("web stopBuild of build %s:%s" % \ (b.getBuilder().getName(), b.getNumber())) name = authz.getUsernameFull(req) comments = req.args.get("comments", [""])[0] comments.decode(getRequestCharset(req)) # html-quote both the username and comments, just to be safe reason = ("The web-page 'stop build' button was pressed by " "'%s': %s\n" % (html.escape(name), html.escape(comments))) c = interfaces.IControl(self.getBuildmaster(req)) bldrc = c.getBuilder(self.build_status.getBuilder().getName()) if bldrc: bldc = bldrc.getBuild(self.build_status.getNumber()) if bldc: bldc.stopBuild(reason) defer.returnValue(path_to_builder(req, self.build_status.getBuilder())) # /builders/$builder/builds/$buildnum class StatusResourceBuild(HtmlResource): addSlash = True def __init__(self, build_status): HtmlResource.__init__(self) self.build_status = build_status def getPageTitle(self, request): return ("Buildbot: %s Build #%d" % (self.build_status.getBuilder().getName(), self.build_status.getNumber())) def content(self, req, cxt): b = self.build_status status = self.getStatus(req) req.setHeader('Cache-Control', 'no-cache') cxt['b'] = b cxt['path_to_builder'] = path_to_builder(req, b.getBuilder()) if not b.isFinished(): step = b.getCurrentStep() if not step: cxt['current_step'] = "[waiting for Lock]" else: if step.isWaitingForLocks(): cxt['current_step'] = "%s [waiting for Lock]" % step.getName() else: cxt['current_step'] = step.getName() when = b.getETA() if when is not None: cxt['when'] = util.formatInterval(when) cxt['when_time'] = time.strftime("%H:%M:%S", time.localtime(time.time() + when)) else: cxt['result_css'] = css_classes[b.getResults()] if b.getTestResults(): cxt['tests_link'] = req.childLink("tests") ssList = b.getSourceStamps() sourcestamps = cxt['sourcestamps'] = ssList all_got_revisions = b.getAllGotRevisions() cxt['got_revisions'] = all_got_revisions try: cxt['slave_url'] = path_to_slave(req, status.getSlave(b.getSlavename())) except KeyError: pass cxt['steps'] = [] for s in b.getSteps(): step = {'name': s.getName() } if s.isFinished(): if s.isHidden(): continue step['css_class'] = css_classes[s.getResults()[0]] (start, end) = s.getTimes() step['time_to_run'] = util.formatInterval(end - start) elif s.isStarted(): if s.isWaitingForLocks(): step['css_class'] = "waiting" step['time_to_run'] = "waiting for locks" else: step['css_class'] = "running" step['time_to_run'] = "running" else: step['css_class'] = "not_started" step['time_to_run'] = "" cxt['steps'].append(step) step['link'] = req.childLink("steps/%s" % urllib.quote(s.getName(), safe='')) step['text'] = " ".join(s.getText()) step['urls'] = map(lambda x:dict(url=x[1],logname=x[0]), s.getURLs().items()) step['logs']= [] for l in s.getLogs(): logname = l.getName() step['logs'].append({ 'link': req.childLink("steps/%s/logs/%s" % (urllib.quote(s.getName(), safe=''), urllib.quote(logname, safe=''))), 'name': logname }) scheduler = b.getProperty("scheduler", None) parameters = {} master = self.getBuildmaster(req) for sch in master.allSchedulers(): if isinstance(sch, ForceScheduler) and scheduler == sch.name: for p in sch.all_fields: parameters[p.name] = p ps = cxt['properties'] = [] for name, value, source in b.getProperties().asList(): if not isinstance(value, dict): cxt_value = unicode(value) else: cxt_value = value p = { 'name': name, 'value': cxt_value, 'source': source} if len(cxt_value) > 500: p['short_value'] = cxt_value[:500] if name in parameters: param = parameters[name] if isinstance(param, TextParameter): p['text'] = param.value_to_text(value) p['cols'] = param.cols p['rows'] = param.rows p['label'] = param.label ps.append(p) cxt['responsible_users'] = list(b.getResponsibleUsers()) (start, end) = b.getTimes() cxt['start'] = time.ctime(start) if end: cxt['end'] = time.ctime(end) cxt['elapsed'] = util.formatInterval(end - start) else: now = util.now() cxt['elapsed'] = util.formatInterval(now - start) has_changes = False for ss in sourcestamps: has_changes = has_changes or ss.changes cxt['has_changes'] = has_changes cxt['build_url'] = path_to_build(req, b) cxt['authz'] = self.getAuthz(req) template = req.site.buildbot_service.templates.get_template("build.html") return template.render(**cxt) def stop(self, req, auth_ok=False): # check if this is allowed if not auth_ok: return StopBuildActionResource(self.build_status) b = self.build_status log.msg("web stopBuild of build %s:%s" % \ (b.getBuilder().getName(), b.getNumber())) name = self.getAuthz(req).getUsernameFull(req) comments = req.args.get("comments", [""])[0] comments.decode(getRequestCharset(req)) # html-quote both the username and comments, just to be safe reason = ("The web-page 'stop build' button was pressed by " "'%s': %s\n" % (html.escape(name), html.escape(comments))) c = interfaces.IControl(self.getBuildmaster(req)) bldrc = c.getBuilder(self.build_status.getBuilder().getName()) if bldrc: bldc = bldrc.getBuild(self.build_status.getNumber()) if bldc: bldc.stopBuild(reason) # we're at http://localhost:8080/svn-hello/builds/5/stop?[args] and # we want to go to: http://localhost:8080/svn-hello r = Redirect(path_to_builder(req, self.build_status.getBuilder())) d = defer.Deferred() reactor.callLater(1, d.callback, r) return DeferredResource(d) def rebuild(self, req): return ForceBuildActionResource(self.build_status, self.build_status.getBuilder()) def getChild(self, path, req): if path == "stop": return self.stop(req) if path == "rebuild": return self.rebuild(req) if path == "steps": return StepsResource(self.build_status) if path == "tests": return TestsResource(self.build_status) return HtmlResource.getChild(self, path, req) # /builders/$builder/builds class BuildsResource(HtmlResource): addSlash = True def __init__(self, builder_status): HtmlResource.__init__(self) self.builder_status = builder_status def content(self, req, cxt): return "subpages shows data for each build" def getChild(self, path, req): try: num = int(path) except ValueError: num = None if num is not None: build_status = self.builder_status.getBuild(num) if build_status: return StatusResourceBuild(build_status) return HtmlResource.getChild(self, path, req) buildbot-0.8.8/buildbot/status/web/builder.py000066400000000000000000000512731222546025000212450ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.web import html import urllib, time from twisted.python import log from twisted.internet import defer from buildbot import interfaces from buildbot.status.web.base import HtmlResource, BuildLineMixin, \ path_to_build, path_to_slave, path_to_builder, path_to_change, \ path_to_root, ICurrentBox, build_get_class, \ map_branches, path_to_authzfail, ActionResource, \ getRequestCharset from buildbot.schedulers.forcesched import ForceScheduler from buildbot.schedulers.forcesched import ValidationError from buildbot.status.web.build import BuildsResource, StatusResourceBuild from buildbot import util import collections class ForceAction(ActionResource): @defer.inlineCallbacks def force(self, req, builderNames): master = self.getBuildmaster(req) owner = self.getAuthz(req).getUsernameFull(req) schedulername = req.args.get("forcescheduler", [""])[0] if schedulername == "": defer.returnValue((path_to_builder(req, self.builder_status), "forcescheduler arg not found")) return args = {} # decode all of the args encoding = getRequestCharset(req) for name, argl in req.args.iteritems(): if name == "checkbox": # damn html's ungeneric checkbox implementation... for cb in argl: args[cb.decode(encoding)] = True else: args[name] = [ arg.decode(encoding) for arg in argl ] for sch in master.allSchedulers(): if schedulername == sch.name: try: yield sch.force(owner, builderNames, **args) msg = "" except ValidationError, e: msg = html.escape(e.message.encode('ascii','ignore')) break # send the user back to the builder page defer.returnValue(msg) class ForceAllBuildsActionResource(ForceAction): def __init__(self, status, selectedOrAll): self.status = status self.selectedOrAll = selectedOrAll self.action = "forceAllBuilds" @defer.inlineCallbacks def performAction(self, req): authz = self.getAuthz(req) res = yield authz.actionAllowed('forceAllBuilds', req) if not res: defer.returnValue(path_to_authzfail(req)) return if self.selectedOrAll == 'all': builderNames = None elif self.selectedOrAll == 'selected': builderNames = [b for b in req.args.get("selected", []) if b] msg = yield self.force(req, builderNames) # back to the welcome page defer.returnValue((path_to_root(req) + "builders", msg)) class StopAllBuildsActionResource(ActionResource): def __init__(self, status, selectedOrAll): self.status = status self.selectedOrAll = selectedOrAll self.action = "stopAllBuilds" @defer.inlineCallbacks def performAction(self, req): authz = self.getAuthz(req) res = yield authz.actionAllowed('stopAllBuilds', req) if not res: defer.returnValue(path_to_authzfail(req)) return builders = None if self.selectedOrAll == 'all': builders = self.status.getBuilderNames() elif self.selectedOrAll == 'selected': builders = [b for b in req.args.get("selected", []) if b] for bname in builders: builder_status = self.status.getBuilder(bname) (state, current_builds) = builder_status.getState() if state != "building": continue for b in current_builds: build_status = builder_status.getBuild(b.number) if not build_status: continue build = StatusResourceBuild(build_status) build.stop(req, auth_ok=True) # go back to the welcome page defer.returnValue(path_to_root(req)) class PingBuilderActionResource(ActionResource): def __init__(self, builder_status): self.builder_status = builder_status self.action = "pingBuilder" @defer.inlineCallbacks def performAction(self, req): log.msg("web ping of builder '%s'" % self.builder_status.getName()) res = yield self.getAuthz(req).actionAllowed('pingBuilder', req, self.builder_status) if not res: log.msg("..but not authorized") defer.returnValue(path_to_authzfail(req)) return c = interfaces.IControl(self.getBuildmaster(req)) bc = c.getBuilder(self.builder_status.getName()) bc.ping() # send the user back to the builder page defer.returnValue(path_to_builder(req, self.builder_status)) class ForceBuildActionResource(ForceAction): def __init__(self, builder_status): self.builder_status = builder_status self.action = "forceBuild" @defer.inlineCallbacks def performAction(self, req): # check if this is allowed res = yield self.getAuthz(req).actionAllowed(self.action, req, self.builder_status) if not res: log.msg("..but not authorized") defer.returnValue(path_to_authzfail(req)) return builderName = self.builder_status.getName() msg = yield self.force(req, [builderName]) # send the user back to the builder page defer.returnValue((path_to_builder(req, self.builder_status), msg)) def buildForceContextForField(req, default_props, sch, field, master, buildername): pname = "%s.%s"%(sch.name, field.fullName) default = field.default if "list" in field.type: choices = field.getChoices(master, sch, buildername) if choices: default = choices[0] default_props[pname+".choices"] = choices default = req.args.get(pname, [default])[0] if "bool" in field.type: default = "checked" if default else "" elif isinstance(default, unicode): # filter out unicode chars, and html stuff default = html.escape(default.encode('utf-8','ignore')) default_props[pname] = default if "nested" in field.type: for subfield in field.fields: buildForceContextForField(req, default_props, sch, subfield, master, buildername) def buildForceContext(cxt, req, master, buildername=None): force_schedulers = {} default_props = collections.defaultdict(str) for sch in master.allSchedulers(): if isinstance(sch, ForceScheduler) and (buildername is None or(buildername in sch.builderNames)): force_schedulers[sch.name] = sch for field in sch.all_fields: buildForceContextForField(req, default_props, sch, field, master, buildername) cxt['force_schedulers'] = force_schedulers cxt['default_props'] = default_props # /builders/$builder class StatusResourceBuilder(HtmlResource, BuildLineMixin): addSlash = True def __init__(self, builder_status, numbuilds=20): HtmlResource.__init__(self) self.builder_status = builder_status self.numbuilds = numbuilds def getPageTitle(self, request): return "Buildbot: %s" % self.builder_status.getName() def builder(self, build, req): b = {} b['num'] = build.getNumber() b['link'] = path_to_build(req, build) when = build.getETA() if when is not None: b['when'] = util.formatInterval(when) b['when_time'] = time.strftime("%H:%M:%S", time.localtime(time.time() + when)) step = build.getCurrentStep() # TODO: is this necessarily the case? if not step: b['current_step'] = "[waiting for Lock]" else: if step.isWaitingForLocks(): b['current_step'] = "%s [waiting for Lock]" % step.getName() else: b['current_step'] = step.getName() b['stop_url'] = path_to_build(req, build) + '/stop' return b @defer.inlineCallbacks def content(self, req, cxt): b = self.builder_status cxt['name'] = b.getName() cxt['description'] = b.getDescription() req.setHeader('Cache-Control', 'no-cache') slaves = b.getSlaves() connected_slaves = [s for s in slaves if s.isConnected()] cxt['current'] = [self.builder(x, req) for x in b.getCurrentBuilds()] cxt['pending'] = [] statuses = yield b.getPendingBuildRequestStatuses() for pb in statuses: changes = [] source = yield pb.getSourceStamp() submitTime = yield pb.getSubmitTime() bsid = yield pb.getBsid() properties = yield \ pb.master.db.buildsets.getBuildsetProperties(bsid) if source.changes: for c in source.changes: changes.append({ 'url' : path_to_change(req, c), 'who' : c.who, 'revision' : c.revision, 'repo' : c.repository }) cxt['pending'].append({ 'when': time.strftime("%b %d %H:%M:%S", time.localtime(submitTime)), 'delay': util.formatInterval(util.now() - submitTime), 'id': pb.brid, 'changes' : changes, 'num_changes' : len(changes), 'properties' : properties, }) numbuilds = cxt['numbuilds'] = int(req.args.get('numbuilds', [self.numbuilds])[0]) recent = cxt['recent'] = [] for build in b.generateFinishedBuilds(num_builds=int(numbuilds)): recent.append(self.get_line_values(req, build, False)) sl = cxt['slaves'] = [] connected_slaves = 0 for slave in slaves: s = {} sl.append(s) s['link'] = path_to_slave(req, slave) s['name'] = slave.getName() c = s['connected'] = slave.isConnected() s['paused'] = slave.isPaused() s['admin'] = unicode(slave.getAdmin() or '', 'utf-8') if c: connected_slaves += 1 cxt['connected_slaves'] = connected_slaves cxt['authz'] = self.getAuthz(req) cxt['builder_url'] = path_to_builder(req, b) buildForceContext(cxt, req, self.getBuildmaster(req), b.getName()) template = req.site.buildbot_service.templates.get_template("builder.html") defer.returnValue(template.render(**cxt)) def ping(self, req): return PingBuilderActionResource(self.builder_status) def getChild(self, path, req): if path == "force": return ForceBuildActionResource(self.builder_status) if path == "ping": return self.ping(req) if path == "cancelbuild": return CancelChangeResource(self.builder_status) if path == "stopchange": return StopChangeResource(self.builder_status) if path == "builds": return BuildsResource(self.builder_status) return HtmlResource.getChild(self, path, req) class CancelChangeResource(ActionResource): def __init__(self, builder_status): ActionResource.__init__(self) self.builder_status = builder_status @defer.inlineCallbacks def performAction(self, req): try: request_id = req.args.get("id", [None])[0] if request_id == "all": cancel_all = True else: cancel_all = False request_id = int(request_id) except: request_id = None authz = self.getAuthz(req) if request_id: c = interfaces.IControl(self.getBuildmaster(req)) builder_control = c.getBuilder(self.builder_status.getName()) brcontrols = yield builder_control.getPendingBuildRequestControls() for build_req in brcontrols: if cancel_all or (build_req.brid == request_id): log.msg("Cancelling %s" % build_req) res = yield authz.actionAllowed('cancelPendingBuild', req, build_req) if res: build_req.cancel() else: defer.returnValue(path_to_authzfail(req)) return if not cancel_all: break defer.returnValue(path_to_builder(req, self.builder_status)) class StopChangeMixin(object): @defer.inlineCallbacks def stopChangeForBuilder(self, req, builder_status, auth_ok=False): try: request_change = req.args.get("change", [None])[0] request_change = int(request_change) except: request_change = None authz = self.getAuthz(req) if request_change: c = interfaces.IControl(self.getBuildmaster(req)) builder_control = c.getBuilder(builder_status.getName()) brcontrols = yield builder_control.getPendingBuildRequestControls() build_controls = dict((x.brid, x) for x in brcontrols) build_req_statuses = yield \ builder_status.getPendingBuildRequestStatuses() for build_req in build_req_statuses: ss = yield build_req.getSourceStamp() if not ss.changes: continue for change in ss.changes: if change.number == request_change: control = build_controls[build_req.brid] log.msg("Cancelling %s" % control) res = yield authz.actionAllowed('stopChange', req, control) if (auth_ok or res): control.cancel() else: defer.returnValue(False) return defer.returnValue(True) class StopChangeResource(StopChangeMixin, ActionResource): def __init__(self, builder_status): ActionResource.__init__(self) self.builder_status = builder_status @defer.inlineCallbacks def performAction(self, req): """Cancel all pending builds that include a given numbered change.""" success = yield self.stopChangeForBuilder(req, self.builder_status) if not success: defer.returnValue(path_to_authzfail(req)) else: defer.returnValue(path_to_builder(req, self.builder_status)) class StopChangeAllResource(StopChangeMixin, ActionResource): def __init__(self, status): ActionResource.__init__(self) self.status = status @defer.inlineCallbacks def performAction(self, req): """Cancel all pending builds that include a given numbered change.""" authz = self.getAuthz(req) res = yield authz.actionAllowed('stopChange', req) if not res: defer.returnValue(path_to_authzfail(req)) return for bname in self.status.getBuilderNames(): builder_status = self.status.getBuilder(bname) res = yield self.stopChangeForBuilder(req, builder_status, auth_ok=True) if not res: defer.returnValue(path_to_authzfail(req)) return defer.returnValue(path_to_root(req)) # /builders/_all class StatusResourceAllBuilders(HtmlResource, BuildLineMixin): def __init__(self, status): HtmlResource.__init__(self) self.status = status def getChild(self, path, req): if path == "forceall": return self.forceall(req) if path == "stopall": return self.stopall(req) if path == "stopchangeall": return StopChangeAllResource(self.status) return HtmlResource.getChild(self, path, req) def forceall(self, req): return ForceAllBuildsActionResource(self.status, 'all') def stopall(self, req): return StopAllBuildsActionResource(self.status, 'all') # /builders/_selected class StatusResourceSelectedBuilders(HtmlResource, BuildLineMixin): def __init__(self, status): HtmlResource.__init__(self) self.status = status def getChild(self, path, req): if path == "forceselected": return self.forceselected(req) if path == "stopselected": return self.stopselected(req) return HtmlResource.getChild(self, path, req) def forceselected(self, req): return ForceAllBuildsActionResource(self.status, 'selected') def stopselected(self, req): return StopAllBuildsActionResource(self.status, 'selected') # /builders class BuildersResource(HtmlResource): pageTitle = "Builders" addSlash = True def __init__(self, numbuilds=20): HtmlResource.__init__(self) self.numbuilds = numbuilds @defer.inlineCallbacks def content(self, req, cxt): status = self.getStatus(req) encoding = getRequestCharset(req) builders = req.args.get("builder", status.getBuilderNames()) branches = [ b.decode(encoding) for b in req.args.get("branch", []) if b ] # get counts of pending builds for each builder brstatus_ds = [] brcounts = {} def keep_count(statuses, builderName): brcounts[builderName] = len(statuses) for builderName in builders: builder_status = status.getBuilder(builderName) d = builder_status.getPendingBuildRequestStatuses() d.addCallback(keep_count, builderName) brstatus_ds.append(d) yield defer.gatherResults(brstatus_ds) cxt['branches'] = branches bs = cxt['builders'] = [] building = 0 online = 0 base_builders_url = path_to_root(req) + "builders/" for bn in builders: bld = { 'link': base_builders_url + urllib.quote(bn, safe=''), 'name': bn } bs.append(bld) builder = status.getBuilder(bn) builds = list(builder.generateFinishedBuilds(map_branches(branches), num_builds=1)) if builds: b = builds[0] bld['build_url'] = (bld['link'] + "/builds/%d" % b.getNumber()) label = None all_got_revisions = b.getAllGotRevisions() # If len = 1 then try if revision can be used as label. if len(all_got_revisions) == 1: label = all_got_revisions[all_got_revisions.keys()[0]] if not label or len(str(label)) > 20: label = "#%d" % b.getNumber() bld['build_label'] = label bld['build_text'] = " ".join(b.getText()) bld['build_css_class'] = build_get_class(b) current_box = ICurrentBox(builder).getBox(status, brcounts) bld['current_box'] = current_box.td() builder_status = builder.getState()[0] if builder_status == "building": building += 1 online += 1 elif builder_status != "offline": online += 1 cxt['authz'] = self.getAuthz(req) cxt['num_building'] = building cxt['num_online'] = online buildForceContext(cxt, req, self.getBuildmaster(req)) template = req.site.buildbot_service.templates.get_template("builders.html") defer.returnValue(template.render(**cxt)) def getChild(self, path, req): s = self.getStatus(req) if path in s.getBuilderNames(): builder_status = s.getBuilder(path) return StatusResourceBuilder(builder_status, self.numbuilds) if path == "_all": return StatusResourceAllBuilders(self.getStatus(req)) if path == "_selected": return StatusResourceSelectedBuilders(self.getStatus(req)) return HtmlResource.getChild(self, path, req) buildbot-0.8.8/buildbot/status/web/buildstatus.py000066400000000000000000000050071222546025000221540ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.status.web.base import HtmlResource, IBox class BuildStatusStatusResource(HtmlResource): def __init__(self, categories=None): HtmlResource.__init__(self) def content(self, request, ctx): """Display a build in the same format as the waterfall page. The HTTP GET parameters are the builder name and the build number.""" status = self.getStatus(request) request.setHeader('Cache-Control', 'no-cache') # Get the parameters. name = request.args.get("builder", [None])[0] number = request.args.get("number", [None])[0] if not name or not number: return "builder and number parameter missing" number = int(number) # Check if the builder in parameter exists. try: builder = status.getBuilder(name) except: return "unknown builder" # Check if the build in parameter exists. build = builder.getBuild(int(number)) if not build: return "unknown build %s" % number rows = ctx['rows'] = [] # Display each step, starting by the last one. for i in range(len(build.getSteps()) - 1, -1, -1): step = build.getSteps()[i] if step.isStarted() and step.getText(): rows.append(IBox(step).getBox(request).td(align="center")) # Display the bottom box with the build number in it. ctx['build'] = IBox(build).getBox(request).td(align="center") template = request.site.buildbot_service.templates.get_template("buildstatus.html") data = template.render(**ctx) # We want all links to display in a new tab/window instead of in the # current one. # TODO: Move to template data = data.replace(' wrote the rest # but "the rest" is pretty minimal import re from twisted.web import resource, server from twisted.python.reflect import namedModule from twisted.python import log from twisted.internet import defer class ChangeHookResource(resource.Resource): # this is a cheap sort of template thingy contentType = "text/html; charset=utf-8" children = {} def __init__(self, dialects={}): """ The keys of 'dialects' select a modules to load under master/buildbot/status/web/hooks/ The value is passed to the module's getChanges function, providing configuration options to the dialect. """ self.dialects = dialects self.request_dialect = None def getChild(self, name, request): return self def render_GET(self, request): """ Reponds to events and starts the build process different implementations can decide on what methods they will accept """ return self.render_POST(request) def render_POST(self, request): """ Reponds to events and starts the build process different implementations can decide on what methods they will accept :arguments: request the http request object """ try: changes, src = self.getChanges( request ) except ValueError, err: request.setResponseCode(400, err.args[0]) return err.args[0] except Exception, e: log.err(e, "processing changes from web hook") msg = "Error processing changes." request.setResponseCode(500, msg) return msg log.msg("Payload: " + str(request.args)) if not changes: log.msg("No changes found") return "no changes found" d = self.submitChanges( changes, request, src ) def ok(_): request.setResponseCode(202) request.finish() def err(why): log.err(why, "adding changes from web hook") request.setResponseCode(500) request.finish() d.addCallbacks(ok, err) return server.NOT_DONE_YET def getChanges(self, request): """ Take the logic from the change hook, and then delegate it to the proper handler http://localhost/change_hook/DIALECT will load up buildmaster/status/web/hooks/DIALECT.py and call getChanges() the return value is a list of changes if DIALECT is unspecified, a sample implementation is provided """ uriRE = re.search(r'^/change_hook/?([a-zA-Z0-9_]*)', request.uri) if not uriRE: log.msg("URI doesn't match change_hook regex: %s" % request.uri) raise ValueError("URI doesn't match change_hook regex: %s" % request.uri) changes = [] src = None # Was there a dialect provided? if uriRE.group(1): dialect = uriRE.group(1) else: dialect = 'base' if dialect in self.dialects.keys(): log.msg("Attempting to load module buildbot.status.web.hooks." + dialect) tempModule = namedModule('buildbot.status.web.hooks.' + dialect) changes, src = tempModule.getChanges(request,self.dialects[dialect]) log.msg("Got the following changes %s" % changes) self.request_dialect = dialect else: m = "The dialect specified, '%s', wasn't whitelisted in change_hook" % dialect log.msg(m) log.msg("Note: if dialect is 'base' then it's possible your URL is malformed and we didn't regex it properly") raise ValueError(m) return (changes, src) @defer.inlineCallbacks def submitChanges(self, changes, request, src): master = request.site.buildbot_service.master for chdict in changes: change = yield master.addChange(src=src, **chdict) log.msg("injected change %s" % change) buildbot-0.8.8/buildbot/status/web/changes.py000066400000000000000000000052741222546025000212270ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from twisted.python import components from twisted.web.resource import NoResource from buildbot.changes.changes import Change from buildbot.status.web.base import HtmlResource, IBox, Box class ChangeResource(HtmlResource): def __init__(self, changeid): self.changeid = changeid self.pageTitle = "Change #%d" % changeid def content(self, req, cxt): d = self.getStatus(req).getChange(self.changeid) def cb(change): if not change: return "No change number %d" % self.changeid templates = req.site.buildbot_service.templates cxt['c'] = change.asDict() template = templates.get_template("change.html") data = template.render(cxt) return data d.addCallback(cb) return d # /changes/NN class ChangesResource(HtmlResource): def content(self, req, cxt): cxt['sources'] = self.getStatus(req).getChangeSources() template = req.site.buildbot_service.templates.get_template("change_sources.html") return template.render(**cxt) def getChild(self, path, req): try: changeid = int(path) except ValueError: return NoResource("Expected a change number") return ChangeResource(changeid) class ChangeBox(components.Adapter): implements(IBox) def getBox(self, req): url = req.childLink("../changes/%d" % self.original.number) template = req.site.buildbot_service.templates.get_template("change_macros.html") text = template.module.box_contents(url=url, who=self.original.getShortAuthor(), pageTitle=self.original.comments, revision=self.original.revision, project=self.original.project) return Box([text], class_="Change") components.registerAdapter(ChangeBox, Change, IBox) buildbot-0.8.8/buildbot/status/web/console.py000066400000000000000000000671561222546025000212700ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import time import operator import re import urllib from twisted.internet import defer from buildbot import util from buildbot.status import builder from buildbot.status.web.base import HtmlResource from buildbot.changes import changes class DoesNotPassFilter(Exception): pass # Used for filtering revs def getResultsClass(results, prevResults, inProgress): """Given the current and past results, return the class that will be used by the css to display the right color for a box.""" if inProgress: return "running" if results is None: return "notstarted" if results == builder.SUCCESS: return "success" if results == builder.WARNINGS: return "warnings" if results == builder.FAILURE: if not prevResults: # This is the bottom box. We don't know if the previous one failed # or not. We assume it did not. return "failure" if prevResults != builder.FAILURE: # This is a new failure. return "failure" else: # The previous build also failed. return "failure-again" # Any other results? Like EXCEPTION? return "exception" class ANYBRANCH: pass # a flag value, used below class DevRevision: """Helper class that contains all the information we need for a revision.""" def __init__(self, change): self.revision = change.revision self.comments = change.comments self.who = change.who self.date = change.getTime() self.revlink = getattr(change, 'revlink', None) self.when = change.when self.repository = change.repository self.project = change.project class DevBuild: """Helper class that contains all the information we need for a build.""" def __init__(self, revision, build, details): self.revision = revision self.results = build.getResults() self.number = build.getNumber() self.isFinished = build.isFinished() self.text = build.getText() self.eta = build.getETA() self.details = details self.when = build.getTimes()[0] #TODO: support multiple sourcestamps self.source = build.getSourceStamps()[0] class ConsoleStatusResource(HtmlResource): """Main console class. It displays a user-oriented status page. Every change is a line in the page, and it shows the result of the first build with this change for each slave.""" def __init__(self, orderByTime=False): HtmlResource.__init__(self) self.status = None if orderByTime: self.comparator = TimeRevisionComparator() else: self.comparator = IntegerRevisionComparator() def getPageTitle(self, request): status = self.getStatus(request) title = status.getTitle() if title: return "BuildBot: %s" % title else: return "BuildBot" def getChangeManager(self, request): return request.site.buildbot_service.parent.change_svc ## ## Data gathering functions ## def getHeadBuild(self, builder): """Get the most recent build for the given builder. """ build = builder.getBuild(-1) # HACK: Work around #601, the head build may be None if it is # locked. if build is None: build = builder.getBuild(-2) return build def fetchChangesFromHistory(self, status, max_depth, max_builds, debugInfo): """Look at the history of the builders and try to fetch as many changes as possible. We need this when the main source does not contain enough sourcestamps. max_depth defines how many builds we will parse for a given builder. max_builds defines how many builds total we want to parse. This is to limit the amount of time we spend in this function. This function is sub-optimal, but the information returned by this function is cached, so this function won't be called more than once. """ allChanges = list() build_count = 0 for builderName in status.getBuilderNames()[:]: if build_count > max_builds: break builder = status.getBuilder(builderName) build = self.getHeadBuild(builder) depth = 0 while build and depth < max_depth and build_count < max_builds: depth += 1 build_count += 1 sourcestamp = build.getSourceStamps()[0] allChanges.extend(sourcestamp.changes[:]) build = build.getPreviousBuild() debugInfo["source_fetch_len"] = len(allChanges) return allChanges @defer.inlineCallbacks def getAllChanges(self, request, status, debugInfo): master = request.site.buildbot_service.master chdicts = yield master.db.changes.getRecentChanges(25) # convert those to Change instances allChanges = yield defer.gatherResults([ changes.Change.fromChdict(master, chdict) for chdict in chdicts ]) allChanges.sort(key=self.comparator.getSortingKey()) # Remove the dups prevChange = None newChanges = [] for change in allChanges: rev = change.revision if not prevChange or rev != prevChange.revision: newChanges.append(change) prevChange = change allChanges = newChanges defer.returnValue(allChanges) def getBuildDetails(self, request, builderName, build): """Returns an HTML list of failures for a given build.""" details = {} if not build.getLogs(): return details for step in build.getSteps(): (result, reason) = step.getResults() if result == builder.FAILURE: name = step.getName() # Remove html tags from the error text. stripHtml = re.compile(r'<.*?>') strippedDetails = stripHtml.sub('', ' '.join(step.getText())) details['buildername'] = builderName details['status'] = strippedDetails details['reason'] = reason logs = details['logs'] = [] if step.getLogs(): for log in step.getLogs(): logname = log.getName() logurl = request.childLink( "../builders/%s/builds/%s/steps/%s/logs/%s" % (urllib.quote(builderName), build.getNumber(), urllib.quote(name), urllib.quote(logname))) logs.append(dict(url=logurl, name=logname)) return details def getBuildsForRevision(self, request, builder, builderName, codebase, lastRevision, numBuilds, debugInfo): """Return the list of all the builds for a given builder that we will need to be able to display the console page. We start by the most recent build, and we go down until we find a build that was built prior to the last change we are interested in.""" revision = lastRevision builds = [] build = self.getHeadBuild(builder) number = 0 while build and number < numBuilds: debugInfo["builds_scanned"] += 1 got_rev = None sourceStamps = build.getSourceStamps(absolute=True) # The console page cannot handle builds that have more than 1 revision if codebase is not None: # Get the last revision in this build for this codebase. for ss in sourceStamps: if ss.codebase == codebase: got_rev = ss.revision break elif len(sourceStamps) == 1: ss = sourceStamps[0] # Get the last revision in this build. got_rev = ss.revision # We ignore all builds that don't have last revisions. # TODO(nsylvain): If the build is over, maybe it was a problem # with the update source step. We need to find a way to tell the # user that his change might have broken the source update. if got_rev is not None: number += 1 details = self.getBuildDetails(request, builderName, build) devBuild = DevBuild(got_rev, build, details) builds.append(devBuild) # Now break if we have enough builds. current_revision = self.getChangeForBuild( build, revision) if self.comparator.isRevisionEarlier( devBuild, current_revision): break build = build.getPreviousBuild() return builds def getChangeForBuild(self, build, revision): if not build or not build.getChanges(): # Forced build return DevBuild(revision, build, None) for change in build.getChanges(): if change.revision == revision: return change # No matching change, return the last change in build. changes = list(build.getChanges()) changes.sort(key=self.comparator.getSortingKey()) return changes[-1] def getAllBuildsForRevision(self, status, request, codebase, lastRevision, numBuilds, categories, builders, debugInfo): """Returns a dictionary of builds we need to inspect to be able to display the console page. The key is the builder name, and the value is an array of build we care about. We also returns a dictionary of builders we care about. The key is it's category. codebase is the codebase to get revisions from lastRevision is the last revision we want to display in the page. categories is a list of categories to display. It is coming from the HTTP GET parameters. builders is a list of builders to display. It is coming from the HTTP GET parameters. """ allBuilds = dict() # List of all builders in the dictionary. builderList = dict() debugInfo["builds_scanned"] = 0 # Get all the builders. builderNames = status.getBuilderNames()[:] for builderName in builderNames: builder = status.getBuilder(builderName) # Make sure we are interested in this builder. if categories and builder.category not in categories: continue if builders and builderName not in builders: continue # We want to display this builder. category = builder.category or "default" # Strip the category to keep only the text before the first |. # This is a hack to support the chromium usecase where they have # multiple categories for each slave. We use only the first one. # TODO(nsylvain): Create another way to specify "display category" # in master.cfg. category = category.split('|')[0] if not builderList.get(category): builderList[category] = [] # Append this builder to the dictionary of builders. builderList[category].append(builderName) # Set the list of builds for this builder. allBuilds[builderName] = self.getBuildsForRevision(request, builder, builderName, codebase, lastRevision, numBuilds, debugInfo) return (builderList, allBuilds) ## ## Display functions ## def displayCategories(self, builderList, debugInfo): """Display the top category line.""" count = 0 for category in builderList: count += len(builderList[category]) categories = builderList.keys() categories.sort() cs = [] for category in categories: c = {} c["name"] = category # To be able to align the table correctly, we need to know # what percentage of space this category will be taking. This is # (#Builders in Category) / (#Builders Total) * 100. c["size"] = (len(builderList[category]) * 100) / count cs.append(c) return cs def displaySlaveLine(self, status, builderList, debugInfo): """Display a line the shows the current status for all the builders we care about.""" nbSlaves = 0 # Get the number of builders. for category in builderList: nbSlaves += len(builderList[category]) # Get the categories, and order them alphabetically. categories = builderList.keys() categories.sort() slaves = {} # For each category, we display each builder. for category in categories: slaves[category] = [] # For each builder in this category, we set the build info and we # display the box. for builder in builderList[category]: s = {} s["color"] = "notstarted" s["pageTitle"] = builder s["url"] = "./builders/%s" % urllib.quote(builder) state, builds = status.getBuilder(builder).getState() # Check if it's offline, if so, the box is purple. if state == "offline": s["color"] = "offline" else: # If not offline, then display the result of the last # finished build. build = self.getHeadBuild(status.getBuilder(builder)) while build and not build.isFinished(): build = build.getPreviousBuild() if build: s["color"] = getResultsClass(build.getResults(), None, False) slaves[category].append(s) return slaves def displayStatusLine(self, builderList, allBuilds, revision, debugInfo): """Display the boxes that represent the status of each builder in the first build "revision" was in. Returns an HTML list of errors that happened during these builds.""" details = [] nbSlaves = 0 for category in builderList: nbSlaves += len(builderList[category]) # Sort the categories. categories = builderList.keys() categories.sort() builds = {} # Display the boxes by category group. for category in categories: builds[category] = [] # Display the boxes for each builder in this category. for builder in builderList[category]: introducedIn = None firstNotIn = None # Find the first build that does not include the revision. for build in allBuilds[builder]: if self.comparator.isRevisionEarlier(build, revision): firstNotIn = build break else: introducedIn = build # Get the results of the first build with the revision, and the # first build that does not include the revision. results = None previousResults = None if introducedIn: results = introducedIn.results if firstNotIn: previousResults = firstNotIn.results isRunning = False if introducedIn and not introducedIn.isFinished: isRunning = True url = "./waterfall" pageTitle = builder tag = "" current_details = {} if introducedIn: current_details = introducedIn.details or "" url = "./buildstatus?builder=%s&number=%s" % (urllib.quote(builder), introducedIn.number) pageTitle += " " pageTitle += urllib.quote(' '.join(introducedIn.text), ' \n\\/:') builderStrip = builder.replace(' ', '') builderStrip = builderStrip.replace('(', '') builderStrip = builderStrip.replace(')', '') builderStrip = builderStrip.replace('.', '') tag = "Tag%s%s" % (builderStrip, introducedIn.number) if isRunning: pageTitle += ' ETA: %ds' % (introducedIn.eta or 0) resultsClass = getResultsClass(results, previousResults, isRunning) b = {} b["url"] = url b["pageTitle"] = pageTitle b["color"] = resultsClass b["tag"] = tag builds[category].append(b) # If the box is red, we add the explaination in the details # section. if current_details and resultsClass == "failure": details.append(current_details) return (builds, details) def filterRevisions(self, revisions, filter=None, max_revs=None): """Filter a set of revisions based on any number of filter criteria. If specified, filter should be a dict with keys corresponding to revision attributes, and values of 1+ strings""" if not filter: if max_revs is None: for rev in reversed(revisions): yield DevRevision(rev) else: for index,rev in enumerate(reversed(revisions)): if index >= max_revs: break yield DevRevision(rev) else: for index, rev in enumerate(reversed(revisions)): if max_revs and index >= max_revs: break try: for field,acceptable in filter.iteritems(): if not hasattr(rev, field): raise DoesNotPassFilter if type(acceptable) in (str, unicode): if getattr(rev, field) != acceptable: raise DoesNotPassFilter elif type(acceptable) in (list, tuple, set): if getattr(rev, field) not in acceptable: raise DoesNotPassFilter yield DevRevision(rev) except DoesNotPassFilter: pass def displayPage(self, request, status, builderList, allBuilds, codebase, revisions, categories, repository, project, branch, debugInfo): """Display the console page.""" # Build the main template directory with all the informations we have. subs = dict() subs["branch"] = branch or 'trunk' subs["repository"] = repository subs["project"] = project subs["codebase"] = codebase if categories: subs["categories"] = ' '.join(categories) subs["time"] = time.strftime("%a %d %b %Y %H:%M:%S", time.localtime(util.now())) subs["debugInfo"] = debugInfo subs["ANYBRANCH"] = ANYBRANCH if builderList: subs["categories"] = self.displayCategories(builderList, debugInfo) subs['slaves'] = self.displaySlaveLine(status, builderList, debugInfo) else: subs["categories"] = [] subs['revisions'] = [] # For each revision we show one line for revision in revisions: r = {} # Fill the dictionary with this new information r['id'] = revision.revision r['link'] = revision.revlink r['who'] = revision.who r['date'] = revision.date r['comments'] = revision.comments r['repository'] = revision.repository r['project'] = revision.project # Display the status for all builders. (builds, details) = self.displayStatusLine(builderList, allBuilds, revision, debugInfo) r['builds'] = builds r['details'] = details # Calculate the td span for the comment and the details. r["span"] = len(builderList) + 2 subs['revisions'].append(r) # # Display the footer of the page. # debugInfo["load_time"] = time.time() - debugInfo["load_time"] return subs def content(self, request, cxt): "This method builds the main console view display." reload_time = None # Check if there was an arg. Don't let people reload faster than # every 15 seconds. 0 means no reload. if "reload" in request.args: try: reload_time = int(request.args["reload"][0]) if reload_time != 0: reload_time = max(reload_time, 15) except ValueError: pass request.setHeader('Cache-Control', 'no-cache') # Sets the default reload time to 60 seconds. if not reload_time: reload_time = 60 # Append the tag to refresh the page. if reload_time is not None and reload_time != 0: cxt['refresh'] = reload_time # Debug information to display at the end of the page. debugInfo = cxt['debuginfo'] = dict() debugInfo["load_time"] = time.time() # get url parameters # Categories to show information for. categories = request.args.get("category", []) # List of all builders to show on the page. builders = request.args.get("builder", []) # Repo used to filter the changes shown. repository = request.args.get("repository", [None])[0] # Project used to filter the changes shown. project = request.args.get("project", [None])[0] # Branch used to filter the changes shown. branch = request.args.get("branch", [ANYBRANCH])[0] # Codebase used to filter the changes shown. codebase = request.args.get("codebase", [None])[0] # List of all the committers name to display on the page. devName = request.args.get("name", []) # and the data we want to render status = self.getStatus(request) # Keep only the revisions we care about. # By default we process the last 40 revisions. # If a dev name is passed, we look for the changes by this person in the # last 80 revisions. numRevs = int(request.args.get("revs", [40])[0]) if devName: numRevs *= 2 numBuilds = numRevs # Get all changes we can find. This is a DB operation, so it must use # a deferred. d = self.getAllChanges(request, status, debugInfo) def got_changes(allChanges): debugInfo["source_all"] = len(allChanges) revFilter = {} if branch != ANYBRANCH: revFilter['branch'] = branch if devName: revFilter['who'] = devName if repository: revFilter['repository'] = repository if project: revFilter['project'] = project if codebase is not None: revFilter['codebase'] = codebase revisions = list(self.filterRevisions(allChanges, max_revs=numRevs, filter=revFilter)) debugInfo["revision_final"] = len(revisions) # Fetch all the builds for all builders until we get the next build # after lastRevision. builderList = None allBuilds = None if revisions: lastRevision = revisions[len(revisions) - 1].revision debugInfo["last_revision"] = lastRevision (builderList, allBuilds) = self.getAllBuildsForRevision(status, request, codebase, lastRevision, numBuilds, categories, builders, debugInfo) debugInfo["added_blocks"] = 0 cxt.update(self.displayPage(request, status, builderList, allBuilds, codebase, revisions, categories, repository, project, branch, debugInfo)) templates = request.site.buildbot_service.templates template = templates.get_template("console.html") data = template.render(cxt) return data d.addCallback(got_changes) return d class RevisionComparator(object): """Used for comparing between revisions, as some VCS use a plain counter for revisions (like SVN) while others use different concepts (see Git). """ # TODO (avivby): Should this be a zope interface? def isRevisionEarlier(self, first_change, second_change): """Used for comparing 2 changes""" raise NotImplementedError def isValidRevision(self, revision): """Checks whether the revision seems like a VCS revision""" raise NotImplementedError def getSortingKey(self): raise NotImplementedError class TimeRevisionComparator(RevisionComparator): def isRevisionEarlier(self, first, second): return first.when < second.when def isValidRevision(self, revision): return True # No general way of determining def getSortingKey(self): return operator.attrgetter('when') class IntegerRevisionComparator(RevisionComparator): def isRevisionEarlier(self, first, second): try: return int(first.revision) < int(second.revision) except (TypeError, ValueError): return False def isValidRevision(self, revision): try: int(revision) return True except: return False def getSortingKey(self): return operator.attrgetter('revision') buildbot-0.8.8/buildbot/status/web/feeds.py000066400000000000000000000256361222546025000207110ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # This module enables ATOM and RSS feeds from webstatus. # # It is based on "feeder.py" which was part of the Buildbot # configuration for the Subversion project. The original file was # created by Lieven Gobaerts and later adjusted by API # (apinheiro@igalia.coma) and also here # http://code.google.com/p/pybots/source/browse/trunk/master/Feeder.py # # All subsequent changes to feeder.py where made by Chandan-Dutta # Chowdhury and Gareth Armstrong # . # # Those modifications are as follows: # 1) the feeds are usable from baseweb.WebStatus # 2) feeds are fully validated ATOM 1.0 and RSS 2.0 feeds, verified # with code from http://feedvalidator.org # 3) nicer xml output # 4) feeds can be filtered as per the /waterfall display with the # builder and category filters # 5) cleaned up white space and imports # # Finally, the code was directly integrated into these two files, # buildbot/status/web/feeds.py (you're reading it, ;-)) and # buildbot/status/web/baseweb.py. import os import re import time from twisted.web import resource from buildbot.status import results class XmlResource(resource.Resource): contentType = "text/xml; charset=UTF-8" docType = '' def getChild(self, name, request): return self def render(self, request): data = self.content(request) request.setHeader("content-type", self.contentType) if request.method == "HEAD": request.setHeader("content-length", len(data)) return '' return data _abbr_day = [ 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] _abbr_mon = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] def rfc822_time(tstamp): res = time.strftime("%%s, %d %%s %Y %H:%M:%S GMT", tstamp) res = res % (_abbr_day[tstamp.tm_wday], _abbr_mon[tstamp.tm_mon]) return res class FeedResource(XmlResource): pageTitle = None link = 'http://dummylink' language = 'en-us' description = 'Dummy rss' status = None def __init__(self, status, categories=None, pageTitle=None): self.status = status self.categories = categories self.pageTitle = pageTitle self.title = self.status.getTitle() self.link = self.status.getBuildbotURL() self.description = 'List of builds' self.pubdate = time.gmtime(int(time.time())) self.user = self.getEnv(['USER', 'USERNAME'], 'buildmaster') self.hostname = self.getEnv(['HOSTNAME', 'COMPUTERNAME'], 'buildmaster') self.children = {} def getEnv(self, keys, fallback): for key in keys: if key in os.environ: return os.environ[key] return fallback def getBuilds(self, request): builds = [] # THIS is lifted straight from the WaterfallStatusResource Class in # status/web/waterfall.py # # we start with all Builders available to this Waterfall: this is # limited by the config-file -time categories= argument, and defaults # to all defined Builders. allBuilderNames = self.status.getBuilderNames(categories=self.categories) builders = [self.status.getBuilder(name) for name in allBuilderNames] # but if the URL has one or more builder= arguments (or the old show= # argument, which is still accepted for backwards compatibility), we # use that set of builders instead. We still don't show anything # outside the config-file time set limited by categories=. showBuilders = request.args.get("show", []) showBuilders.extend(request.args.get("builder", [])) if showBuilders: builders = [b for b in builders if b.name in showBuilders] # now, if the URL has one or category= arguments, use them as a # filter: only show those builders which belong to one of the given # categories. showCategories = request.args.get("category", []) if showCategories: builders = [b for b in builders if b.category in showCategories] failures_only = request.args.get("failures_only", ["false"]) failures_only = failures_only[0] not in ('false', '0', 'no', 'off') maxFeeds = 25 # Copy all failed builds in a new list. # This could clearly be implemented much better if we had # access to a global list of builds. for b in builders: if failures_only: res = (results.FAILURE,) else: res = None builds.extend(b.generateFinishedBuilds(results=res, max_search=maxFeeds)) # Sort build list by date, youngest first. # To keep compatibility with python < 2.4, use this for sorting instead: # We apply Decorate-Sort-Undecorate deco = [(build.getTimes(), build) for build in builds] deco.sort() deco.reverse() builds = [build for (b1, build) in deco] if builds: builds = builds[:min(len(builds), maxFeeds)] return builds def content(self, request): builds = self.getBuilds(request) build_cxts = [] for build in builds: start, finished = build.getTimes() finishedTime = time.gmtime(int(finished)) link = re.sub(r'index.html', "", self.status.getURLForThing(build)) # title: trunk r22191 (plus patch) failed on # 'i686-debian-sarge1 shared gcc-3.3.5' ss_list = build.getSourceStamps() all_got_revisions = build.getAllGotRevisions() src_cxts = [] for ss in ss_list: sc = {} sc['codebase'] = ss.codebase if (ss.branch is None and ss.revision is None and ss.patch is None and not ss.changes): sc['repository'] = None sc['branch'] = None sc['revision'] = "Latest revision" else: sc['repository'] = ss.repository sc['branch'] = ss.branch got_revision = all_got_revisions.get(ss.codebase, None) if got_revision: sc['revision'] = got_revision else: sc['revision'] = str(ss.revision) if ss.patch: sc['revision'] += " (plus patch)" if ss.changes: pass src_cxts.append(sc) res = build.getResults() pageTitle = ('Builder "%s": %s' % (build.getBuilder().getName(), results.Results[res])) # Add information about the failing steps. failed_steps = [] log_lines = [] for s in build.getSteps(): res = s.getResults()[0] if res not in (results.SUCCESS, results.WARNINGS, results.SKIPPED): failed_steps.append(s.getName()) # Add the last 30 lines of each log. for log in s.getLogs(): log_lines.append('Last lines of build log "%s":' % log.getName()) log_lines.append([]) try: logdata = log.getText() except IOError: # Probably the log file has been removed logdata ='** log file not available **' unilist = list() for line in logdata.split('\n')[-30:]: unilist.append(unicode(line,'utf-8')) log_lines.extend(unilist) bc = {} bc['sources'] = src_cxts bc['date'] = rfc822_time(finishedTime) bc['summary_link'] = ('%sbuilders/%s' % (self.link, build.getBuilder().getName())) bc['name'] = build.getBuilder().getName() bc['number'] = build.getNumber() bc['responsible_users'] = build.getResponsibleUsers() bc['failed_steps'] = failed_steps bc['pageTitle'] = pageTitle bc['link'] = link bc['log_lines'] = log_lines if finishedTime is not None: bc['rfc822_pubdate'] = rfc822_time(finishedTime) bc['rfc3339_pubdate'] = time.strftime("%Y-%m-%dT%H:%M:%SZ", finishedTime) # Every RSS/Atom item must have a globally unique ID guid = ('tag:%s@%s,%s:%s' % (self.user, self.hostname, time.strftime("%Y-%m-%d", finishedTime), time.strftime("%Y%m%d%H%M%S", finishedTime))) bc['guid'] = guid build_cxts.append(bc) pageTitle = self.pageTitle if not pageTitle: pageTitle = 'Build status of %s' % self.title cxt = {} cxt['pageTitle'] = pageTitle cxt['title_url'] = self.link cxt['title'] = self.title cxt['language'] = self.language cxt['description'] = self.description if self.pubdate is not None: cxt['rfc822_pubdate'] = rfc822_time( self.pubdate) cxt['rfc3339_pubdate'] = time.strftime("%Y-%m-%dT%H:%M:%SZ", self.pubdate) cxt['builds'] = build_cxts template = request.site.buildbot_service.templates.get_template(self.template_file) return template.render(**cxt).encode('utf-8').strip() class Rss20StatusResource(FeedResource): # contentType = 'application/rss+xml' (browser dependent) template_file = 'feed_rss20.xml' def __init__(self, status, categories=None, pageTitle=None): FeedResource.__init__(self, status, categories, pageTitle) class Atom10StatusResource(FeedResource): # contentType = 'application/atom+xml' (browser dependent) template_file = 'feed_atom10.xml' def __init__(self, status, categories=None, pageTitle=None): FeedResource.__init__(self, status, categories, pageTitle) buildbot-0.8.8/buildbot/status/web/files/000077500000000000000000000000001222546025000203375ustar00rootroot00000000000000buildbot-0.8.8/buildbot/status/web/files/bg_gradient.jpg000066400000000000000000000034361222546025000233140ustar00rootroot00000000000000JFIFHHCC *!1AQaq"b,!1AQaq"2BR ?JATAʤرl!_^F+W9|g "1ԗO)&AGi+ȯz&z/^ ;~Ti(i!̖b{.=؋VT;'FA M_MЗbN2S޾ ?m ߓN#. QBP٪'C?O,WJ>w\`MW`J1Pi1_&4v ~xwNvi|2z΅uNb'':Ņ볢%,f3ٻV2DOI(,?q$h(crAwT"  &Dp}*tƒ6dίP %]4m@7!MȐOfİt;L

܆hƫh-;#ʗ ڕ -#cOO:wXzCUō-5iBdDgy ڋGVU 8Piڍ8U-x#:s6{Ҍ *}Juj+Er;M{t#(~q҈_uWv_vJZk#_{"|ɨ9{o}T'I~Q(b`ϴ*G\D5(\;'ࡓC\v^߹w/a:hover { color: black; } div.Announcement>div.Notice { background-color: #afdaff; padding: 0.5em; font-size: 16px; text-align: center; } div.Announcement>div.Open { border: 3px solid #8fdf5f; padding: 0.5em; font-size: 16px; text-align: center; } div.Announcement>div.Closed { border: 5px solid #e98080; padding: 0.5em; font-size: 24px; font-weight: bold; text-align: center; } td.Time { color: #000; border-bottom: 1px solid #aaa; background-color: #eee; } td.Activity,td.Change,td.Builder { color: #333333; background-color: #CCCCCC; } td.Change { border-radius: 5px; -webkit-border-radius: 5px; -moz-border-radius: 5px; } td.Event { color: #777; background-color: #ddd; border-radius: 5px; -webkit-border-radius: 5px; -moz-border-radius: 5px; } td.Activity { border-top-left-radius: 10px; -webkit-border-top-left-radius: 10px; -moz-border-radius-topleft: 10px; min-height: 20px; padding: 2px 0 2px 0; } td.idle,td.waiting,td.offline,td.building { border-top-left-radius: 0px; -webkit-border-top-left-radius: 0px; -moz-border-radius-topleft: 0px; } .LastBuild { border-top-left-radius: 5px; -webkit-border-top-left-radius: 5px; -moz-border-radius-topleft: 5px; border-top-right-radius: 5px; -webkit-border-top-right-radius: 5px; -moz-border-radius-topright: 5px; } /* Console view styles */ td.DevRev { padding: 4px 8px 4px 8px; color: #333333; border-top-left-radius: 5px; -webkit-border-top-left-radius: 5px; -moz-border-radius-topleft: 5px; background-color: #eee; width: 1%; } td.DevRevCollapse { border-bottom-left-radius: 5px; -webkit-border-bottom-left-radius: 5px; -moz-border-radius-bottomleft: 5px; } td.DevName { padding: 4px 8px 4px 8px; color: #333333; background-color: #eee; width: 1%; text-align: left; } td.DevStatus { padding: 4px 4px 4px 4px; color: #333333; background-color: #eee; } td.DevSlave { padding: 4px 4px 4px 4px; color: #333333; background-color: #eee; } td.first { border-top-left-radius: 5px; -webkit-border-top-left-radius: 5px; -moz-border-radius-topleft: 5px; } td.last { border-top-right-radius: 5px; -webkit-border-top-right-radius: 5px; -moz-border-radius-topright: 5px; } td.DevStatusCategory { border-radius: 5px; -webkit-border-radius: 5px; -moz-border-radius: 5px; border-width: 1px; border-style: solid; } td.DevStatusCollapse { border-bottom-right-radius: 5px; -webkit-border-bottom-right-radius: 5px; -moz-border-radius-bottomright: 5px; } td.DevDetails { font-weight: normal; padding: 8px 8px 8px 8px; color: #333333; background-color: #eee; text-align: left; } td.DevDetails li a { padding-right: 5px; } td.DevComment { font-weight: normal; padding: 8px 8px 8px 8px; color: #333333; background-color: #eee; text-align: left; } td.DevBottom { border-bottom-right-radius: 5px; -webkit-border-bottom-right-radius: 5px; -moz-border-radius-bottomright: 5px; border-bottom-left-radius: 5px; -webkit-border-bottom-left-radius: 5px; -moz-border-radius-bottomleft: 5px; } td.Alt { background-color: #ddd; } .legend { border-radius: 5px !important; -webkit-border-radius: 5px !important; -moz-border-radius: 5px !important; width: 100px; max-width: 100px; text-align: center; padding: 2px 2px 2px 2px; height: 14px; white-space: nowrap; } .DevStatusBox { text-align: center; height: 20px; padding: 0 2px; line-height: 0; white-space: nowrap; } .DevStatusBox a { opacity: 0.85; border-width: 1px; border-style: solid; border-radius: 4px; -webkit-border-radius: 4px; -moz-border-radius: 4px; display: block; width: 90%; height: 20px; line-height: 20px; margin-left: auto; margin-right: auto; } .DevSlaveBox { text-align: center; height: 10px; padding: 0 2px; line-height: 0; white-space: nowrap; } .DevSlaveBox a { opacity: 0.85; border-width: 1px; border-style: solid; border-radius: 4px; -webkit-border-radius: 4px; -moz-border-radius: 4px; display: block; width: 90%; height: 10px; line-height: 20px; margin-left: auto; margin-right: auto; } a.noround { border-radius: 0px; -webkit-border-radius: 0px; -moz-border-radius: 0px; position: relative; margin-top: -8px; margin-bottom: -8px; height: 36px; border-top-width: 0; border-bottom-width: 0; } a.begin { border-top-width: 1px; position: relative; margin-top: 0px; margin-bottom: -7px; height: 27px; border-top-left-radius: 4px; -webkit-border-top-left-radius: 4px; -moz-border-radius-topleft: 4px; border-top-right-radius: 4px; -webkit-border-top-right-radius: 4px; -moz-border-radius-topright: 4px; } a.end { border-bottom-width: 1px; position: relative; margin-top: -7px; margin-bottom: 0px; height: 27px; border-bottom-left-radius: 4px; -webkit-border-bottom-left-radius: 4px; -moz-border-radius-bottomleft: 4px; border-bottom-right-radius: 4px; -webkit-border-bottom-right-radius: 4px; -moz-border-radius-bottomright: 4px; } .center_align { text-align: center; } .right_align { text-align: right; } .left_align { text-align: left; } div.BuildWaterfall { border-radius: 7px; -webkit-border-radius: 7px; -moz-border-radius: 7px; position: absolute; left: 0px; top: 0px; background-color: #FFFFFF; padding: 4px 4px 4px 4px; float: left; display: none; border-width: 1px; border-style: solid; } /* LastBuild, BuildStep states */ .success { color: #000; background-color: #8d4; border-color: #4F8530; } .failure { color: #000; background-color: #e88; border-color: #A77272; } .failure-again { color: #000; background-color: #eA9; border-color: #A77272; } .warnings { color: #FFFFFF; background-color: #fa3; border-color: #C29D46; } .skipped { color: #000; background: #AADDEE; border-color: #AADDEE; } .exception,.retry { color: #FFFFFF; background-color: #c6c; border-color: #ACA0B3; } .start { color: #000; background-color: #ccc; border-color: #ccc; } .running,.waiting,td.building { color: #000; background-color: #fd3; border-color: #C5C56D; } .paused { color: #FFFFFF; background-color: #8080FF; border-color: #dddddd; } .offline,td.offline { color: #FFFFFF; background-color: #777777; border-color: #dddddd; } .start { border-bottom-left-radius: 10px; -webkit-border-bottom-left-radius: 10px; -moz-border-radius-bottomleft: 10px; border-bottom-right-radius: 10px; -webkit-border-bottom-right-radius: 10px; -moz-border-radius-bottomright: 10px; } .notstarted { border-width: 1px; border-style: solid; border-color: #aaa; background-color: #fff; } .closed { background-color: #ff0000; } .closed .large { font-size: 1.5em; font-weight: bolder; } td.Project a:hover,td.start a:hover { color: #000; } .mini-box { text-align: center; height: 20px; padding: 0 2px; line-height: 0; white-space: nowrap; } .mini-box a { border-radius: 0; -webkit-border-radius: 0; -moz-border-radius: 0; display: block; width: 100%; height: 20px; line-height: 20px; margin-top: -30px; } .mini-closed { -box-sizing: border-box; -webkit-box-sizing: border-box; border: 4px solid red; } /* grid styles */ table.Grid { border-collapse: collapse; } table.Grid tr td { padding: 0.2em; margin: 0px; text-align: center; } table.Grid tr td.title { font-size: 90%; border-right: 1px gray solid; border-bottom: 1px gray solid; } table.Grid tr td.sourcestamp { font-size: 90%; } table.Grid tr td.builder { text-align: right; font-size: 90%; } table.Grid tr td.build { border: 1px gray solid; } /* column container */ div.column { margin: 0 2em 2em 0; float: left; } /* info tables */ table.info { border-spacing: 1px; } table.info td { padding: 0.1em 1em 0.1em 1em; text-align: center; } table.info th { padding: 0.2em 1.5em 0.2em 1.5em; text-align: center; } table.info td.left { text-align: left } .alt { background-color: #f6f6f6; } li { padding: 0.1em 1em 0.1em 1em; } .result { padding: 0.3em 1em 0.3em 1em; } /* log view */ .log * { vlink: #800080; font-family: "Courier New", courier, monotype, monospace; } span.stdout { color: black; } span.stderr { color: red; } span.header { color: blue; } /* revision & email */ .revision .full { display: none; } .user .email { display: none; } pre { white-space: pre-wrap; } /* change comments (use regular colors here) */ pre.comments>a:link,pre.comments>a:visited { color: blue; } pre.comments>a:active { color: purple; } form.command_forcebuild { border-top: 1px solid black; padding: .5em; margin: .5em; } form.command_forcebuild > .row { border-top: 1px dotted gray; padding: .5em 0; } form.command_forcebuild .force-textarea > .label { display: block; } form.command_forcebuild .force-nested > .label { font-weight: bold; display: list-item; } form.command_forcebuild .force-any .force-text { display: inline; } buildbot-0.8.8/buildbot/status/web/files/favicon.ico000066400000000000000000000021761222546025000224660ustar00rootroot00000000000000 h(  f8f8f8f8\C4knqqssqpRpo!rJ0f8f8f8f8f8f8f8f8f8b[hlxywxyyxxwwxxuuvutsWh;f8f8f8f8YT`'|p|~}}}}~~}}~~}}||}}[f8f8f8UGEjdsǩf8f8]Yg{f8WQ].xf8wn}um|5y?!0qag͜_NPvSO\{|Й_}y^[ro{`]L= start: sourcestamps[key] = (ss, start) # now sort those and take the NUMBUILDS most recent sourcestamps = sorted(sourcestamps.itervalues(), key = lambda stamp: stamp[1]) sourcestamps = [stamp[0] for stamp in sourcestamps][-numBuilds:] return sourcestamps class GridStatusResource(HtmlResource, GridStatusMixin): # TODO: docs status = None changemaster = None @defer.inlineCallbacks def content(self, request, cxt): """This method builds the regular grid display. That is, build stamps across the top, build hosts down the left side """ # get url parameters numBuilds = int(request.args.get("width", [5])[0]) categories = request.args.get("category", []) branch = request.args.get("branch", [ANYBRANCH])[0] if branch == 'trunk': branch = None # and the data we want to render status = self.getStatus(request) stamps = self.getRecentSourcestamps(status, numBuilds, categories, branch) cxt['refresh'] = self.get_reload_time(request) cxt.update({'categories': categories, 'branch': branch, 'ANYBRANCH': ANYBRANCH, 'stamps': [map(SourceStamp.asDict, sstamp) for sstamp in stamps], }) sortedBuilderNames = sorted(status.getBuilderNames()) cxt['builders'] = [] for bn in sortedBuilderNames: builds = [None] * len(stamps) builder = status.getBuilder(bn) if categories and builder.category not in categories: continue for build in self.getRecentBuilds(builder, numBuilds, branch): ss = build.getSourceStamps(absolute=True) key = self.getSourceStampKey(ss) for i, sstamp in enumerate(stamps): if key == self.getSourceStampKey(sstamp) and builds[i] is None: builds[i] = build b = yield self.builder_cxt(request, builder) b['builds'] = [] for build in builds: b['builds'].append(self.build_cxt(request, build)) cxt['builders'].append(b) self.clearRecentBuildsCache() template = request.site.buildbot_service.templates.get_template("grid.html") defer.returnValue(template.render(**cxt)) class TransposedGridStatusResource(HtmlResource, GridStatusMixin): # TODO: docs status = None changemaster = None default_rev_order = "asc" @defer.inlineCallbacks def content(self, request, cxt): """This method builds the transposed grid display. That is, build hosts across the top, build stamps down the left side """ # get url parameters numBuilds = int(request.args.get("length", [5])[0]) categories = request.args.get("category", []) branch = request.args.get("branch", [ANYBRANCH])[0] if branch == 'trunk': branch = None rev_order = request.args.get("rev_order", [self.default_rev_order])[0] if rev_order not in ["asc", "desc"]: rev_order = self.default_rev_order cxt['refresh'] = self.get_reload_time(request) # and the data we want to render status = self.getStatus(request) stamps = self.getRecentSourcestamps(status, numBuilds, categories, branch) cxt.update({'categories': categories, 'branch': branch, 'ANYBRANCH': ANYBRANCH, 'stamps': [map(SourceStamp.asDict, sstamp) for sstamp in stamps], }) sortedBuilderNames = sorted(status.getBuilderNames()) cxt['sorted_builder_names'] = sortedBuilderNames cxt['builder_builds'] = builder_builds = [] cxt['builders'] = builders = [] cxt['range'] = range(len(stamps)) if rev_order == "desc": cxt['range'].reverse() for bn in sortedBuilderNames: builds = [None] * len(stamps) builder = status.getBuilder(bn) if categories and builder.category not in categories: continue for build in self.getRecentBuilds(builder, numBuilds, branch): #TODO: support multiple sourcestamps ss = build.getSourceStamps(absolute=True) key = self.getSourceStampKey(ss) for i, sstamp in enumerate(stamps): if key == self.getSourceStampKey(sstamp) and builds[i] is None: builds[i] = build b = yield self.builder_cxt(request, builder) builders.append(b) builder_builds.append(map(lambda b: self.build_cxt(request, b), builds)) self.clearRecentBuildsCache() template = request.site.buildbot_service.templates.get_template('grid_transposed.html') defer.returnValue(template.render(**cxt)) buildbot-0.8.8/buildbot/status/web/hooks/000077500000000000000000000000001222546025000203605ustar00rootroot00000000000000buildbot-0.8.8/buildbot/status/web/hooks/__init__.py000066400000000000000000000000061222546025000224650ustar00rootroot00000000000000# testbuildbot-0.8.8/buildbot/status/web/hooks/base.py000066400000000000000000000057761222546025000216630ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # code inspired/copied from contrib/github_buildbot # and inspired from code from the Chromium project # otherwise, Andrew Melo wrote the rest # but "the rest" is pretty minimal from buildbot.util import json def getChanges(request, options=None): """ Consumes a naive build notification (the default for now) basically, set POST variables to match commit object parameters: revision, revlink, comments, branch, who, files, links files, links and properties will be de-json'd, the rest are interpreted as strings """ def firstOrNothing( value ): """ Small helper function to return the first value (if value is a list) or return the whole thing otherwise """ if ( type(value) == type([])): return value[0] else: return value args = request.args # first, convert files, links and properties files = None if args.get('files'): files = json.loads( args.get('files')[0] ) else: files = [] properties = None if args.get('properties'): properties = json.loads( args.get('properties')[0] ) else: properties = {} revision = firstOrNothing(args.get('revision')) when = firstOrNothing(args.get('when')) if when is not None: when = float(when) author = firstOrNothing(args.get('author')) if not author: author = firstOrNothing(args.get('who')) comments = firstOrNothing(args.get('comments')) isdir = firstOrNothing(args.get('isdir',0)) branch = firstOrNothing(args.get('branch')) category = firstOrNothing(args.get('category')) revlink = firstOrNothing(args.get('revlink')) repository = firstOrNothing(args.get('repository')) project = firstOrNothing(args.get('project')) chdict = dict(author=author, files=files, comments=comments, isdir=isdir, revision=revision, when=when, branch=branch, category=category, revlink=revlink, properties=properties, repository=repository, project=project) return ([ chdict ], None) buildbot-0.8.8/buildbot/status/web/hooks/github.py000066400000000000000000000123631222546025000222210ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members #!/usr/bin/env python """ github_buildbot.py is based on git_buildbot.py github_buildbot.py will determine the repository information from the JSON HTTP POST it receives from github.com and build the appropriate repository. If your github repository is private, you must add a ssh key to the github repository for the user who initiated the build on the buildslave. """ import re import datetime from twisted.python import log import calendar try: import json assert json except ImportError: import simplejson as json # python is silly about how it handles timezones class fixedOffset(datetime.tzinfo): """ fixed offset timezone """ def __init__(self, minutes, hours, offsetSign = 1): self.minutes = int(minutes) * offsetSign self.hours = int(hours) * offsetSign self.offset = datetime.timedelta(minutes = self.minutes, hours = self.hours) def utcoffset(self, dt): return self.offset def dst(self, dt): return datetime.timedelta(0) def convertTime(myTestTimestamp): #"1970-01-01T00:00:00+00:00" matcher = re.compile(r'(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)([-+])(\d\d):(\d\d)') result = matcher.match(myTestTimestamp) (year, month, day, hour, minute, second, offsetsign, houroffset, minoffset) = \ result.groups() if offsetsign == '+': offsetsign = 1 else: offsetsign = -1 offsetTimezone = fixedOffset( minoffset, houroffset, offsetsign ) myDatetime = datetime.datetime( int(year), int(month), int(day), int(hour), int(minute), int(second), 0, offsetTimezone) return calendar.timegm( myDatetime.utctimetuple() ) def getChanges(request, options = None): """ Reponds only to POST events and starts the build process :arguments: request the http request object """ payload = json.loads(request.args['payload'][0]) user = payload['repository']['owner']['name'] repo = payload['repository']['name'] repo_url = payload['repository']['url'] project = request.args.get('project', None) if project: project = project[0] elif project is None: project = '' # This field is unused: #private = payload['repository']['private'] changes = process_change(payload, user, repo, repo_url, project) log.msg("Received %s changes from github" % len(changes)) return (changes, 'git') def process_change(payload, user, repo, repo_url, project): """ Consumes the JSON as a python object and actually starts the build. :arguments: payload Python Object that represents the JSON sent by GitHub Service Hook. """ changes = [] newrev = payload['after'] refname = payload['ref'] # We only care about regular heads, i.e. branches match = re.match(r"^refs\/heads\/(.+)$", refname) if not match: log.msg("Ignoring refname `%s': Not a branch" % refname) return [] branch = match.group(1) if re.match(r"^0*$", newrev): log.msg("Branch `%s' deleted, ignoring" % branch) return [] else: for commit in payload['commits']: files = [] if 'added' in commit: files.extend(commit['added']) if 'modified' in commit: files.extend(commit['modified']) if 'removed' in commit: files.extend(commit['removed']) when = convertTime( commit['timestamp']) log.msg("New revision: %s" % commit['id'][:8]) chdict = dict( who = commit['author']['name'] + " <" + commit['author']['email'] + ">", files = files, comments = commit['message'], revision = commit['id'], when = when, branch = branch, revlink = commit['url'], repository = repo_url, project = project) changes.append(chdict) return changes buildbot-0.8.8/buildbot/status/web/hooks/googlecode.py000066400000000000000000000060561222546025000230500ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright 2011, Louis Opter # Quite inspired from the github hook. import hmac from twisted.python import log from buildbot.util import json class GoogleCodeAuthFailed(Exception): pass class Payload(object): def __init__(self, headers, body, branch): self._auth_code = headers['Google-Code-Project-Hosting-Hook-Hmac'] self._body = body # we need to save it if we want to authenticate it self._branch = branch payload = json.loads(body) self.project = payload['project_name'] self.repository = payload['repository_path'] self.revisions = payload['revisions'] self.revision_count = payload['revision_count'] def authenticate(self, secret_key): m = hmac.new(secret_key) m.update(self._body) digest = m.hexdigest() return digest == self._auth_code def changes(self): changes = [] for r in self.revisions: files = set() files.update(r['added']) files.update(r['modified']) files.update(r['removed']) changes.append(dict( author=r['author'], files=list(files), comments=r['message'], revision=r['revision'], when=r['timestamp'], # Let's hope Google add the branch one day: branch=r.get('branch', self._branch), revlink=r['url'], repository=self.repository, project=self.project )) return changes def getChanges(request, options=None): headers = request.received_headers body = request.content.getvalue() # Instantiate a Payload object: this will parse the body, get the # authentication code from the headers and remember the branch picked # up by the user (Google Code doesn't send on which branch the changes # were made) payload = Payload(headers, body, options.get('branch', 'default')) if 'secret_key' in options: if not payload.authenticate(options['secret_key']): raise GoogleCodeAuthFailed() else: log.msg("Missing secret_key in the Google Code WebHook options: " "cannot authenticate the request!") log.msg('Received %d changes from Google Code' % (payload.revision_count,)) changes = payload.changes() return changes, 'Google Code' buildbot-0.8.8/buildbot/status/web/hooks/poller.py000066400000000000000000000034471222546025000222370ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # This change hook allows GitHub or a hand crafted curl inovcation to "knock on # the door" and trigger a change source to poll. from buildbot.changes.base import PollingChangeSource def getChanges(req, options=None): change_svc = req.site.buildbot_service.master.change_svc poll_all = not "poller" in req.args allow_all = True allowed = [] if isinstance(options, dict) and "allowed" in options: allow_all = False allowed = options["allowed"] pollers = [] for source in change_svc: if not isinstance(source, PollingChangeSource): continue if not hasattr(source, "name"): continue if not poll_all and not source.name in req.args['poller']: continue if not allow_all and not source.name in allowed: continue pollers.append(source) if not poll_all: missing = set(req.args['poller']) - set(s.name for s in pollers) if missing: raise ValueError("Could not find pollers: %s" % ",".join(missing)) for p in pollers: p.doPoll() return [], None buildbot-0.8.8/buildbot/status/web/logs.py000066400000000000000000000136221222546025000205570ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from twisted.python import components from twisted.spread import pb from twisted.web import server from twisted.web.resource import Resource, NoResource from buildbot import interfaces from buildbot.status import logfile from buildbot.status.web.base import IHTMLLog, HtmlResource, path_to_root class ChunkConsumer: implements(interfaces.IStatusLogConsumer) def __init__(self, original, textlog): self.original = original self.textlog = textlog def registerProducer(self, producer, streaming): self.producer = producer self.original.registerProducer(producer, streaming) def unregisterProducer(self): self.original.unregisterProducer() def writeChunk(self, chunk): formatted = self.textlog.content([chunk]) try: if isinstance(formatted, unicode): formatted = formatted.encode('utf-8') self.original.write(formatted) except pb.DeadReferenceError: self.producing.stopProducing() def finish(self): self.textlog.finished() # /builders/$builder/builds/$buildnum/steps/$stepname/logs/$logname class TextLog(Resource): # a new instance of this Resource is created for each client who views # it, so we can afford to track the request in the Resource. implements(IHTMLLog) asText = False subscribed = False def __init__(self, original): Resource.__init__(self) self.original = original def getChild(self, path, req): if path == "text": self.asText = True return self return Resource.getChild(self, path, req) def content(self, entries): html_entries = [] text_data = '' for type, entry in entries: if type >= len(logfile.ChunkTypes) or type < 0: # non-std channel, don't display continue is_header = type == logfile.HEADER if not self.asText: # jinja only works with unicode, or pure ascii, so assume utf-8 in logs if not isinstance(entry, unicode): entry = unicode(entry, 'utf-8', 'replace') html_entries.append(dict(type = logfile.ChunkTypes[type], text = entry, is_header = is_header)) elif not is_header: text_data += entry if self.asText: return text_data else: return self.template.module.chunks(html_entries) def render_HEAD(self, req): self._setContentType(req) # vague approximation, ignores markup req.setHeader("content-length", self.original.length) return '' def render_GET(self, req): self._setContentType(req) self.req = req if self.original.isFinished(): req.setHeader("Cache-Control", "max-age=604800") else: req.setHeader("Cache-Control", "no-cache") if not self.asText: self.template = req.site.buildbot_service.templates.get_template("logs.html") data = self.template.module.page_header( pageTitle = "Log File contents", texturl = req.childLink("text"), path_to_root = path_to_root(req)) data = data.encode('utf-8') req.write(data) self.original.subscribeConsumer(ChunkConsumer(req, self)) return server.NOT_DONE_YET def _setContentType(self, req): if self.asText: req.setHeader("content-type", "text/plain; charset=utf-8") else: req.setHeader("content-type", "text/html; charset=utf-8") def finished(self): if not self.req: return try: if not self.asText: data = self.template.module.page_footer() data = data.encode('utf-8') self.req.write(data) self.req.finish() except pb.DeadReferenceError: pass # break the cycle, the Request's .notifications list includes the # Deferred (from req.notifyFinish) that's pointing at us. self.req = None # release template self.template = None components.registerAdapter(TextLog, interfaces.IStatusLog, IHTMLLog) class HTMLLog(Resource): implements(IHTMLLog) def __init__(self, original): Resource.__init__(self) self.original = original def render(self, request): request.setHeader("content-type", "text/html") return self.original.html components.registerAdapter(HTMLLog, logfile.HTMLLogFile, IHTMLLog) class LogsResource(HtmlResource): addSlash = True def __init__(self, step_status): HtmlResource.__init__(self) self.step_status = step_status def getChild(self, path, req): for log in self.step_status.getLogs(): if path == log.getName(): if log.hasContents(): return IHTMLLog(interfaces.IStatusLog(log)) return NoResource("Empty Log '%s'" % path) return HtmlResource.getChild(self, path, req) buildbot-0.8.8/buildbot/status/web/olpb.py000066400000000000000000000103601222546025000205430ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.status.web.base import HtmlResource, BuildLineMixin, map_branches # /one_line_per_build # accepts builder=, branch=, numbuilds=, reload= class OneLinePerBuild(HtmlResource, BuildLineMixin): """This shows one line per build, combining all builders together. Useful query arguments: numbuilds=: how many lines to display builder=: show only builds for this builder. Multiple builder= arguments can be used to see builds from any builder in the set. reload=: reload the page after this many seconds """ pageTitle = "Recent Builds" def __init__(self, numbuilds=20): HtmlResource.__init__(self) self.numbuilds = numbuilds def getChild(self, path, req): status = self.getStatus(req) builder = status.getBuilder(path) return OneLinePerBuildOneBuilder(builder, numbuilds=self.numbuilds) def get_reload_time(self, request): if "reload" in request.args: try: reload_time = int(request.args["reload"][0]) return max(reload_time, 15) except ValueError: pass return None def content(self, req, cxt): status = self.getStatus(req) numbuilds = int(req.args.get("numbuilds", [self.numbuilds])[0]) builders = req.args.get("builder", []) branches = [b for b in req.args.get("branch", []) if b] g = status.generateFinishedBuilds(builders, map_branches(branches), numbuilds, max_search=numbuilds) cxt['refresh'] = self.get_reload_time(req) cxt['num_builds'] = numbuilds cxt['branches'] = branches cxt['builders'] = builders builds = cxt['builds'] = [] for build in g: builds.append(self.get_line_values(req, build)) cxt['authz'] = self.getAuthz(req) # get information on the builders - mostly just a count building = 0 online = 0 for bn in builders: builder = status.getBuilder(bn) builder_status = builder.getState()[0] if builder_status == "building": building += 1 online += 1 elif builder_status != "offline": online += 1 cxt['num_online'] = online cxt['num_building'] = building template = req.site.buildbot_service.templates.get_template('onelineperbuild.html') return template.render(**cxt) # /one_line_per_build/$BUILDERNAME # accepts branch=, numbuilds= class OneLinePerBuildOneBuilder(HtmlResource, BuildLineMixin): def __init__(self, builder, numbuilds=20): HtmlResource.__init__(self) self.builder = builder self.builder_name = builder.getName() self.numbuilds = numbuilds self.pageTitle = "Recent Builds of %s" % self.builder_name def content(self, req, cxt): numbuilds = int(req.args.get("numbuilds", [self.numbuilds])[0]) branches = [b for b in req.args.get("branch", []) if b] # walk backwards through all builds of a single builder g = self.builder.generateFinishedBuilds(map_branches(branches), numbuilds) cxt['builds'] = map(lambda b: self.get_line_values(req, b), g) cxt.update(dict(num_builds=numbuilds, builder_name=self.builder_name, branches=branches)) template = req.site.buildbot_service.templates.get_template('onelineperbuildonebuilder.html') return template.render(**cxt) buildbot-0.8.8/buildbot/status/web/root.py000066400000000000000000000043341222546025000205760ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.web.util import redirectTo from twisted.internet import defer from buildbot.status.web.base import HtmlResource, path_to_authzfail from buildbot.util.eventual import eventually class RootPage(HtmlResource): pageTitle = "Buildbot" @defer.inlineCallbacks def content(self, request, cxt): status = self.getStatus(request) res = yield self.getAuthz(request).actionAllowed("cleanShutdown", request) if request.path == '/shutdown': if res: eventually(status.cleanShutdown) defer.returnValue(redirectTo("/", request)) return else: defer.returnValue( redirectTo(path_to_authzfail(request), request)) return elif request.path == '/cancel_shutdown': if res: eventually(status.cancelCleanShutdown) defer.returnValue(redirectTo("/", request)) return else: defer.returnValue( redirectTo(path_to_authzfail(request), request)) return cxt.update( shutting_down = status.shuttingDown, shutdown_url = request.childLink("shutdown"), cancel_shutdown_url = request.childLink("cancel_shutdown"), ) template = request.site.buildbot_service.templates.get_template("root.html") defer.returnValue(template.render(**cxt)) buildbot-0.8.8/buildbot/status/web/session.py000066400000000000000000000072541222546025000213020ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # # Insipration, and some code, from: # :copyright: (c) 2011 by the Werkzeug Team, see Werkzeug's AUTHORS for more # details. try: from hashlib import sha1 sha1 = sha1 # make pyflakes happy except ImportError: from sha import new as sha1 from time import time from random import random from datetime import datetime, timedelta import os def _urandom(): if hasattr(os, 'urandom'): return os.urandom(30) return random() def generate_cookie(): return sha1('%s%s' % (time(), _urandom())).hexdigest() class Session(object): """I'm a user's session. Contains information about a user's session a user can have several session a session is associated with a cookie """ user = "" infos = {} def __init__(self, user, infos): self.user = user self.infos = infos self.renew() def renew(self): # one day expiration. hardcoded for now... self.expiration = datetime.now()+ timedelta(1) return self.expiration def expired(self): return datetime.now() > self.expiration def userInfosHTML(self): return ('%(fullName)s [%(email)s]' % (self.infos)) def getExpiration(self): delim = '-' d = self.expiration.utctimetuple() return '%s, %02d%s%s%s%s %02d:%02d:%02d GMT' % ( ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')[d.tm_wday], d.tm_mday, delim, ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')[d.tm_mon - 1], delim, str(d.tm_year), d.tm_hour, d.tm_min, d.tm_sec ) class SessionManager(object): """I'm the session manager. Holding the current sessions managing cookies, and their expiration KISS version for the moment: The sessions are stored in RAM so that you have to relogin after buildbot reboot Old sessions are searched at every connection, which is not very good for scaling """ # borg pattern (similar to singleton) not too loose sessions with reconfig __shared_state = dict(sessions={},users={}) def __init__(self): self.__dict__ = self.__shared_state def new(self, user, infos): cookie = generate_cookie() user = infos["userName"] self.users[user] = self.sessions[cookie] = s = Session(user, infos) return cookie, s def gc(self): """remove old cookies""" expired = [] for cookie in self.sessions: s = self.sessions[cookie] if s.expired(): expired.append(cookie) for cookie in expired: del self.sessions[cookie] def get(self, cookie): self.gc() if cookie in self.sessions: return self.sessions[cookie] return None def remove(self, cookie): if cookie in self.sessions: del self.sessions[cookie] def getUser(self, user): return self.users.get(user) buildbot-0.8.8/buildbot/status/web/slaves.py000066400000000000000000000171211222546025000211060ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import time, urllib from twisted.web import html from twisted.web.util import Redirect from twisted.web.resource import NoResource from twisted.internet import defer from buildbot.status.web.base import HtmlResource, abbreviate_age, \ BuildLineMixin, ActionResource, path_to_slave, path_to_authzfail from buildbot import util class ShutdownActionResource(ActionResource): def __init__(self, slave): self.slave = slave self.action = "gracefulShutdown" @defer.inlineCallbacks def performAction(self, request): res = yield self.getAuthz(request).actionAllowed(self.action, request, self.slave) url = None if res: self.slave.setGraceful(True) url = path_to_slave(request, self.slave) else: url = path_to_authzfail(request) defer.returnValue(url) class PauseActionResource(ActionResource): def __init__(self, slave, state): self.slave = slave self.action = "pauseSlave" self.state = state @defer.inlineCallbacks def performAction(self, request): res = yield self.getAuthz(request).actionAllowed(self.action, request, self.slave) url = None if res: self.slave.setPaused(self.state) url = path_to_slave(request, self.slave) else: url = path_to_authzfail(request) defer.returnValue(url) # /buildslaves/$slavename class OneBuildSlaveResource(HtmlResource, BuildLineMixin): addSlash = False def __init__(self, slavename): HtmlResource.__init__(self) self.slavename = slavename def getPageTitle(self, req): return "Buildbot: %s" % self.slavename def getChild(self, path, req): s = self.getStatus(req) slave = s.getSlave(self.slavename) if path == "shutdown": return ShutdownActionResource(slave) if path == "pause" or path == "unpause": return PauseActionResource(slave, path == "pause") return Redirect(path_to_slave(req, slave)) def content(self, request, ctx): s = self.getStatus(request) slave = s.getSlave(self.slavename) my_builders = [] for bname in s.getBuilderNames(): b = s.getBuilder(bname) for bs in b.getSlaves(): if bs.getName() == self.slavename: my_builders.append(b) # Current builds current_builds = [] for b in my_builders: for cb in b.getCurrentBuilds(): if cb.getSlavename() == self.slavename: current_builds.append(self.get_line_values(request, cb)) try: max_builds = int(request.args.get('numbuilds')[0]) except: max_builds = 10 recent_builds = [] n = 0 for rb in s.generateFinishedBuilds(builders=[b.getName() for b in my_builders]): if rb.getSlavename() == self.slavename: n += 1 recent_builds.append(self.get_line_values(request, rb)) if n > max_builds: break # connects over the last hour slave = s.getSlave(self.slavename) connect_count = slave.getConnectCount() if slave.isPaused(): pause_url = request.childLink("unpause") else: pause_url = request.childLink("pause") ctx.update(dict(slave=slave, slavename = self.slavename, current = current_builds, recent = recent_builds, shutdown_url = request.childLink("shutdown"), pause_url = pause_url, authz = self.getAuthz(request), this_url = "../../../" + path_to_slave(request, slave), access_uri = slave.getAccessURI()), admin = unicode(slave.getAdmin() or '', 'utf-8'), host = unicode(slave.getHost() or '', 'utf-8'), slave_version = slave.getVersion(), show_builder_column = True, connect_count = connect_count) template = request.site.buildbot_service.templates.get_template("buildslave.html") data = template.render(**ctx) return data # /buildslaves class BuildSlavesResource(HtmlResource): pageTitle = "BuildSlaves" addSlash = True def content(self, request, ctx): s = self.getStatus(request) #?no_builders=1 disables build column show_builder_column = not (request.args.get('no_builders', '0')[0])=='1' ctx['show_builder_column'] = show_builder_column used_by_builder = {} for bname in s.getBuilderNames(): b = s.getBuilder(bname) for bs in b.getSlaves(): slavename = bs.getName() if slavename not in used_by_builder: used_by_builder[slavename] = [] used_by_builder[slavename].append(bname) slaves = ctx['slaves'] = [] for name in util.naturalSort(s.getSlaveNames()): info = {} slaves.append(info) slave = s.getSlave(name) slave_status = s.botmaster.slaves[name].slave_status info['running_builds'] = len(slave_status.getRunningBuilds()) info['link'] = request.childLink(urllib.quote(name,'')) info['name'] = name if show_builder_column: info['builders'] = [] for b in used_by_builder.get(name, []): info['builders'].append(dict(link=request.childLink("../builders/%s" % b), name=b)) info['version'] = slave.getVersion() info['connected'] = slave.isConnected() info['connectCount'] = slave.getConnectCount() info['paused'] = slave.isPaused() info['admin'] = unicode(slave.getAdmin() or '', 'utf-8') last = slave.lastMessageReceived() if last: info['last_heard_from_age'] = abbreviate_age(time.time() - last) info['last_heard_from_time'] = time.strftime("%Y-%b-%d %H:%M:%S", time.localtime(last)) template = request.site.buildbot_service.templates.get_template("buildslaves.html") data = template.render(**ctx) return data def getChild(self, path, req): try: self.getStatus(req).getSlave(path) return OneBuildSlaveResource(path) except KeyError: return NoResource("No such slave '%s'" % html.escape(path)) buildbot-0.8.8/buildbot/status/web/status_json.py000066400000000000000000000641411222546025000221710ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members # Original Copyright (c) 2010 The Chromium Authors. """Simple JSON exporter.""" import datetime import os import re from twisted.internet import defer from twisted.web import html, resource, server from buildbot.status.web.base import HtmlResource from buildbot.util import json _IS_INT = re.compile('^[-+]?\d+$') FLAGS = """\ - as_text - By default, application/json is used. Setting as_text=1 change the type to text/plain and implicitly sets compact=0 and filter=1. Mainly useful to look at the result in a web browser. - compact - By default, the json data is compact and defaults to 1. For easier to read indented output, set compact=0. - select - By default, most children data is listed. You can do a random selection of data by using select= multiple times to coagulate data. "select=" includes the actual url otherwise it is skipped. - numbuilds - By default, only in memory cached builds are listed. You can as for more data by using numbuilds=. - filter - Filters out null, false, and empty string, list and dict. This reduce the amount of useless data sent. - callback - Enable uses of JSONP as described in http://en.wikipedia.org/wiki/JSONP. Note that Access-Control-Allow-Origin:* is set in the HTTP response header so you can use this in compatible browsers. """ EXAMPLES = """\ - /json - Root node, that *doesn't* mean all the data. Many things (like logs) must be explicitly queried for performance reasons. - /json/builders/ - All builders. - /json/builders/ - A specific builder as compact text. - /json/builders//builds - All *cached* builds. - /json/builders//builds/_all - All builds. Warning, reads all previous build data. - /json/builders//builds/ - Where is either positive, a build number, or negative, a past build. - /json/builders//builds/-1/source_stamp/changes - Build changes - /json/builders//builds?select=-1&select=-2 - Two last builds on '' builder. - /json/builders//builds?select=-1/source_stamp/changes&select=-2/source_stamp/changes - Changes of the two last builds on '' builder. - /json/builders//slaves - Slaves associated to this builder. - /json/builders/?select=&select=slaves - Builder information plus details information about its slaves. Neat eh? - /json/slaves/ - A specific slave. - /json?select=slaves//&select=project&select=builders//builds/ - A selection of random unrelated stuff as an random example. :) """ def RequestArg(request, arg, default): return request.args.get(arg, [default])[0] def RequestArgToBool(request, arg, default): value = RequestArg(request, arg, default) if value in (False, True): return value value = value.lower() if value in ('1', 'true'): return True if value in ('0', 'false'): return False # Ignore value. return default def FilterOut(data): """Returns a copy with None, False, "", [], () and {} removed. Warning: converts tuple to list.""" if isinstance(data, (list, tuple)): # Recurse in every items and filter them out. items = map(FilterOut, data) if not filter(lambda x: not x in ('', False, None, [], {}, ()), items): return None return items elif isinstance(data, dict): return dict(filter(lambda x: not x[1] in ('', False, None, [], {}, ()), [(k, FilterOut(v)) for (k, v) in data.iteritems()])) else: return data class JsonResource(resource.Resource): """Base class for json data.""" contentType = "application/json" cache_seconds = 60 help = None pageTitle = None level = 0 def __init__(self, status): """Adds transparent lazy-child initialization.""" resource.Resource.__init__(self) # buildbot.status.builder.Status self.status = status def getChildWithDefault(self, path, request): """Adds transparent support for url ending with /""" if path == "" and len(request.postpath) == 0: return self if path == 'help' and self.help: pageTitle = '' if self.pageTitle: pageTitle = self.pageTitle + ' help' return HelpResource(self.help, pageTitle=pageTitle, parent_node=self) # Equivalent to resource.Resource.getChildWithDefault() if self.children.has_key(path): return self.children[path] return self.getChild(path, request) def putChild(self, name, res): """Adds the resource's level for help links generation.""" def RecurseFix(res, level): res.level = level + 1 for c in res.children.itervalues(): RecurseFix(c, res.level) RecurseFix(res, self.level) resource.Resource.putChild(self, name, res) def render_GET(self, request): """Renders a HTTP GET at the http request level.""" d = defer.maybeDeferred(lambda : self.content(request)) def handle(data): if isinstance(data, unicode): data = data.encode("utf-8") request.setHeader("Access-Control-Allow-Origin", "*") if RequestArgToBool(request, 'as_text', False): request.setHeader("content-type", 'text/plain') else: request.setHeader("content-type", self.contentType) request.setHeader("content-disposition", "attachment; filename=\"%s.json\"" % request.path) # Make sure we get fresh pages. if self.cache_seconds: now = datetime.datetime.utcnow() expires = now + datetime.timedelta(seconds=self.cache_seconds) request.setHeader("Expires", expires.strftime("%a, %d %b %Y %H:%M:%S GMT")) request.setHeader("Pragma", "no-cache") return data d.addCallback(handle) def ok(data): request.write(data) request.finish() def fail(f): request.processingFailed(f) return None # processingFailed will log this for us d.addCallbacks(ok, fail) return server.NOT_DONE_YET @defer.inlineCallbacks def content(self, request): """Renders the json dictionaries.""" # Supported flags. select = request.args.get('select') as_text = RequestArgToBool(request, 'as_text', False) filter_out = RequestArgToBool(request, 'filter', as_text) compact = RequestArgToBool(request, 'compact', not as_text) callback = request.args.get('callback') # Implement filtering at global level and every child. if select is not None: del request.args['select'] # Do not render self.asDict()! data = {} # Remove superfluous / select = [s.strip('/') for s in select] select.sort(cmp=lambda x,y: cmp(x.count('/'), y.count('/')), reverse=True) for item in select: # Start back at root. node = data # Implementation similar to twisted.web.resource.getChildForRequest # but with a hacked up request. child = self prepath = request.prepath[:] postpath = request.postpath[:] request.postpath = filter(None, item.split('/')) while request.postpath and not child.isLeaf: pathElement = request.postpath.pop(0) node[pathElement] = {} node = node[pathElement] request.prepath.append(pathElement) child = child.getChildWithDefault(pathElement, request) # some asDict methods return a Deferred, so handle that # properly if hasattr(child, 'asDict'): child_dict = yield defer.maybeDeferred(lambda : child.asDict(request)) else: child_dict = { 'error' : 'Not available', } node.update(child_dict) request.prepath = prepath request.postpath = postpath else: data = yield defer.maybeDeferred(lambda : self.asDict(request)) if filter_out: data = FilterOut(data) if compact: data = json.dumps(data, sort_keys=True, separators=(',',':')) else: data = json.dumps(data, sort_keys=True, indent=2) if callback: # Only accept things that look like identifiers for now callback = callback[0] if re.match(r'^[a-zA-Z$][a-zA-Z$0-9.]*$', callback): data = '%s(%s);' % (callback, data) defer.returnValue(data) @defer.inlineCallbacks def asDict(self, request): """Generates the json dictionary. By default, renders every childs.""" if self.children: data = {} for name in self.children: child = self.getChildWithDefault(name, request) if isinstance(child, JsonResource): data[name] = yield defer.maybeDeferred(lambda : child.asDict(request)) # else silently pass over non-json resources. defer.returnValue(data) else: raise NotImplementedError() def ToHtml(text): """Convert a string in a wiki-style format into HTML.""" indent = 0 in_item = False output = [] for line in text.splitlines(False): match = re.match(r'^( +)\- (.*)$', line) if match: if indent < len(match.group(1)): output.append('

    ') indent = len(match.group(1)) elif indent > len(match.group(1)): while indent > len(match.group(1)): output.append('
') indent -= 2 if in_item: # Close previous item output.append('') output.append('
  • ') in_item = True line = match.group(2) elif indent: if line.startswith((' ' * indent) + ' '): # List continuation line = line.strip() else: # List is done if in_item: output.append('
  • ') in_item = False while indent > 0: output.append('') indent -= 2 if line.startswith('/'): if not '?' in line: line_full = line + '?as_text=1' else: line_full = line + '&as_text=1' output.append('' + html.escape(line) + '') else: output.append(html.escape(line).replace(' ', '  ')) if not in_item: output.append('
    ') if in_item: output.append('') while indent > 0: output.append('') indent -= 2 return '\n'.join(output) class HelpResource(HtmlResource): def __init__(self, text, pageTitle, parent_node): HtmlResource.__init__(self) self.text = text self.pageTitle = pageTitle self.parent_level = parent_node.level self.parent_children = parent_node.children.keys() def content(self, request, cxt): cxt['level'] = self.parent_level cxt['text'] = ToHtml(self.text) cxt['children'] = [ n for n in self.parent_children if n != 'help' ] cxt['flags'] = ToHtml(FLAGS) cxt['examples'] = ToHtml(EXAMPLES).replace( 'href="/json', 'href="../%sjson' % (self.parent_level * '../')) template = request.site.buildbot_service.templates.get_template("jsonhelp.html") return template.render(**cxt) class BuilderPendingBuildsJsonResource(JsonResource): help = """Describe pending builds for a builder. """ pageTitle = 'Builder' def __init__(self, status, builder_status): JsonResource.__init__(self, status) self.builder_status = builder_status def asDict(self, request): # buildbot.status.builder.BuilderStatus d = self.builder_status.getPendingBuildRequestStatuses() def to_dict(statuses): return defer.gatherResults( [ b.asDict_async() for b in statuses ]) d.addCallback(to_dict) return d class BuilderJsonResource(JsonResource): help = """Describe a single builder. """ pageTitle = 'Builder' def __init__(self, status, builder_status): JsonResource.__init__(self, status) self.builder_status = builder_status self.putChild('builds', BuildsJsonResource(status, builder_status)) self.putChild('slaves', BuilderSlavesJsonResources(status, builder_status)) self.putChild( 'pendingBuilds', BuilderPendingBuildsJsonResource(status, builder_status)) def asDict(self, request): # buildbot.status.builder.BuilderStatus return self.builder_status.asDict_async() class BuildersJsonResource(JsonResource): help = """List of all the builders defined on a master. """ pageTitle = 'Builders' def __init__(self, status): JsonResource.__init__(self, status) for builder_name in self.status.getBuilderNames(): self.putChild(builder_name, BuilderJsonResource(status, status.getBuilder(builder_name))) class BuilderSlavesJsonResources(JsonResource): help = """Describe the slaves attached to a single builder. """ pageTitle = 'BuilderSlaves' def __init__(self, status, builder_status): JsonResource.__init__(self, status) self.builder_status = builder_status for slave_name in self.builder_status.slavenames: self.putChild(slave_name, SlaveJsonResource(status, self.status.getSlave(slave_name))) class BuildJsonResource(JsonResource): help = """Describe a single build. """ pageTitle = 'Build' def __init__(self, status, build_status): JsonResource.__init__(self, status) self.build_status = build_status # TODO: support multiple sourcestamps sourcestamp = build_status.getSourceStamps()[0] self.putChild('source_stamp', SourceStampJsonResource(status, sourcestamp)) self.putChild('steps', BuildStepsJsonResource(status, build_status)) def asDict(self, request): return self.build_status.asDict() class AllBuildsJsonResource(JsonResource): help = """All the builds that were run on a builder. """ pageTitle = 'AllBuilds' def __init__(self, status, builder_status): JsonResource.__init__(self, status) self.builder_status = builder_status def getChild(self, path, request): # Dynamic childs. if isinstance(path, int) or _IS_INT.match(path): build_status = self.builder_status.getBuild(int(path)) if build_status: return BuildJsonResource(self.status, build_status) return JsonResource.getChild(self, path, request) def asDict(self, request): results = {} # If max > buildCacheSize, it'll trash the cache... cache_size = self.builder_status.master.config.caches['Builds'] max = int(RequestArg(request, 'max', cache_size)) for i in range(0, max): child = self.getChildWithDefault(-i, request) if not isinstance(child, BuildJsonResource): continue results[child.build_status.getNumber()] = child.asDict(request) return results class BuildsJsonResource(AllBuildsJsonResource): help = """Builds that were run on a builder. """ pageTitle = 'Builds' def __init__(self, status, builder_status): AllBuildsJsonResource.__init__(self, status, builder_status) self.putChild('_all', AllBuildsJsonResource(status, builder_status)) def getChild(self, path, request): # Transparently redirects to _all if path is not ''. return self.children['_all'].getChildWithDefault(path, request) def asDict(self, request): # This would load all the pickles and is way too heavy, especially that # it would trash the cache: # self.children['builds'].asDict(request) # TODO(maruel) This list should also need to be cached but how? builds = dict([ (int(file), None) for file in os.listdir(self.builder_status.basedir) if _IS_INT.match(file) ]) return builds class BuildStepJsonResource(JsonResource): help = """A single build step. """ pageTitle = 'BuildStep' def __init__(self, status, build_step_status): # buildbot.status.buildstep.BuildStepStatus JsonResource.__init__(self, status) self.build_step_status = build_step_status # TODO self.putChild('logs', LogsJsonResource()) def asDict(self, request): return self.build_step_status.asDict() class BuildStepsJsonResource(JsonResource): help = """A list of build steps that occurred during a build. """ pageTitle = 'BuildSteps' def __init__(self, status, build_status): JsonResource.__init__(self, status) self.build_status = build_status # The build steps are constantly changing until the build is done so # keep a reference to build_status instead def getChild(self, path, request): # Dynamic childs. build_step_status = None if isinstance(path, int) or _IS_INT.match(path): build_step_status = self.build_status.getSteps()[int(path)] else: steps_dict = dict([(step.getName(), step) for step in self.build_status.getSteps()]) build_step_status = steps_dict.get(path) if build_step_status: # Create it on-demand. child = BuildStepJsonResource(self.status, build_step_status) # Cache it. index = self.build_status.getSteps().index(build_step_status) self.putChild(str(index), child) self.putChild(build_step_status.getName(), child) return child return JsonResource.getChild(self, path, request) def asDict(self, request): # Only use the number and not the names! results = {} index = 0 for step in self.build_status.getSteps(): results[index] = step.asDict() index += 1 return results class ChangeJsonResource(JsonResource): help = """Describe a single change that originates from a change source. """ pageTitle = 'Change' def __init__(self, status, change): # buildbot.changes.changes.Change JsonResource.__init__(self, status) self.change = change def asDict(self, request): return self.change.asDict() class ChangesJsonResource(JsonResource): help = """List of changes. """ pageTitle = 'Changes' def __init__(self, status, changes): JsonResource.__init__(self, status) for c in changes: # c.number can be None or clash another change if the change was # generated inside buildbot or if using multiple pollers. if c.number is not None and str(c.number) not in self.children: self.putChild(str(c.number), ChangeJsonResource(status, c)) else: # Temporary hack since it creates information exposure. self.putChild(str(id(c)), ChangeJsonResource(status, c)) def asDict(self, request): """Don't throw an exception when there is no child.""" if not self.children: return {} return JsonResource.asDict(self, request) class ChangeSourcesJsonResource(JsonResource): help = """Describe a change source. """ pageTitle = 'ChangeSources' def asDict(self, request): result = {} n = 0 for c in self.status.getChangeSources(): # buildbot.changes.changes.ChangeMaster change = {} change['description'] = c.describe() result[n] = change n += 1 return result class ProjectJsonResource(JsonResource): help = """Project-wide settings. """ pageTitle = 'Project' def asDict(self, request): return self.status.asDict() class SlaveJsonResource(JsonResource): help = """Describe a slave. """ pageTitle = 'Slave' def __init__(self, status, slave_status): JsonResource.__init__(self, status) self.slave_status = slave_status self.name = self.slave_status.getName() self.builders = None def getBuilders(self): if self.builders is None: # Figure out all the builders to which it's attached self.builders = [] for builderName in self.status.getBuilderNames(): if self.name in self.status.getBuilder(builderName).slavenames: self.builders.append(builderName) return self.builders def asDict(self, request): results = self.slave_status.asDict() # Enhance it by adding more informations. results['builders'] = {} for builderName in self.getBuilders(): builds = [] builder_status = self.status.getBuilder(builderName) cache_size = builder_status.master.config.caches['Builds'] numbuilds = int(request.args.get('numbuilds', [cache_size - 1])[0]) for i in range(1, numbuilds): build_status = builder_status.getBuild(-i) if not build_status or not build_status.isFinished(): # If not finished, it will appear in runningBuilds. break if build_status.getSlavename() == self.name: builds.append(build_status.getNumber()) results['builders'][builderName] = builds return results class SlavesJsonResource(JsonResource): help = """List the registered slaves. """ pageTitle = 'Slaves' def __init__(self, status): JsonResource.__init__(self, status) for slave_name in status.getSlaveNames(): self.putChild(slave_name, SlaveJsonResource(status, status.getSlave(slave_name))) class SourceStampJsonResource(JsonResource): help = """Describe the sources for a SourceStamp. """ pageTitle = 'SourceStamp' def __init__(self, status, source_stamp): # buildbot.sourcestamp.SourceStamp JsonResource.__init__(self, status) self.source_stamp = source_stamp self.putChild('changes', ChangesJsonResource(status, source_stamp.changes)) # TODO(maruel): Should redirect to the patch's url instead. #if source_stamp.patch: # self.putChild('patch', StaticHTML(source_stamp.path)) def asDict(self, request): return self.source_stamp.asDict() class MetricsJsonResource(JsonResource): help = """Master metrics. """ title = "Metrics" def asDict(self, request): metrics = self.status.getMetrics() if metrics: return metrics.asDict() else: # Metrics are disabled return None class JsonStatusResource(JsonResource): """Retrieves all json data.""" help = """JSON status Root page to give a fair amount of information in the current buildbot master status. You may want to use a child instead to reduce the load on the server. For help on any sub directory, use url /child/help """ pageTitle = 'Buildbot JSON' def __init__(self, status): JsonResource.__init__(self, status) self.level = 1 self.putChild('builders', BuildersJsonResource(status)) self.putChild('change_sources', ChangeSourcesJsonResource(status)) self.putChild('project', ProjectJsonResource(status)) self.putChild('slaves', SlavesJsonResource(status)) self.putChild('metrics', MetricsJsonResource(status)) # This needs to be called before the first HelpResource().body call. self.hackExamples() def content(self, request): result = JsonResource.content(self, request) # This is done to hook the downloaded filename. request.path = 'buildbot' return result def hackExamples(self): global EXAMPLES # Find the first builder with a previous build or select the last one. builder = None for b in self.status.getBuilderNames(): builder = self.status.getBuilder(b) if builder.getBuild(-1): break if not builder: return EXAMPLES = EXAMPLES.replace('', builder.getName()) build = builder.getBuild(-1) if build: EXAMPLES = EXAMPLES.replace('', str(build.getNumber())) if builder.slavenames: EXAMPLES = EXAMPLES.replace('', builder.slavenames[0]) # vim: set ts=4 sts=4 sw=4 et: buildbot-0.8.8/buildbot/status/web/step.py000066400000000000000000000070141222546025000205640ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import urllib from buildbot.status.web.base import HtmlResource, path_to_builder, \ path_to_build, css_classes from buildbot.status.web.logs import LogsResource from buildbot import util from time import ctime # /builders/$builder/builds/$buildnum/steps/$stepname class StatusResourceBuildStep(HtmlResource): pageTitle = "Build Step" addSlash = True def __init__(self, build_status, step_status): HtmlResource.__init__(self) self.status = build_status self.step_status = step_status def content(self, req, cxt): s = self.step_status b = s.getBuild() logs = cxt['logs'] = [] for l in s.getLogs(): # FIXME: If the step name has a / in it, this is broken # either way. If we quote it but say '/'s are safe, # it chops up the step name. If we quote it and '/'s # are not safe, it escapes the / that separates the # step name from the log number. logs.append({'has_contents': l.hasContents(), 'name': l.getName(), 'link': req.childLink("logs/%s" % urllib.quote(l.getName())) }) stepStatistics = s.getStatistics() statistics = cxt['statistics'] = [] for stat in stepStatistics: statistics.append({'name': stat, 'value': stepStatistics[stat]}) start, end = s.getTimes() if start: cxt['start'] = ctime(start) if end: cxt['end'] = ctime(end) cxt['elapsed'] = util.formatInterval(end - start) else: cxt['end'] = "Not Finished" cxt['elapsed'] = util.formatInterval(util.now() - start) cxt.update(dict(builder_link = path_to_builder(req, b.getBuilder()), build_link = path_to_build(req, b), b = b, s = s, result_css = css_classes[s.getResults()[0]])) template = req.site.buildbot_service.templates.get_template("buildstep.html"); return template.render(**cxt) def getChild(self, path, req): if path == "logs": return LogsResource(self.step_status) return HtmlResource.getChild(self, path, req) # /builders/$builder/builds/$buildnum/steps class StepsResource(HtmlResource): addSlash = True def __init__(self, build_status): HtmlResource.__init__(self) self.build_status = build_status def content(self, req, ctx): return "subpages show data for each step" def getChild(self, path, req): for s in self.build_status.getSteps(): if s.getName() == path: return StatusResourceBuildStep(self.build_status, s) return HtmlResource.getChild(self, path, req) buildbot-0.8.8/buildbot/status/web/templates/000077500000000000000000000000001222546025000212335ustar00rootroot00000000000000buildbot-0.8.8/buildbot/status/web/templates/about.html000066400000000000000000000015361222546025000232400ustar00rootroot00000000000000{% extends "layout.html" %} {% block content %}

    About this Buildbot

    Version Information

      {% set item_class=cycler('alt', '') %}
    • Buildbot: {{ buildbot }}
    • Twisted: {{ twisted }}
    • Jinja: {{ jinja }}
    • Python: {{ python }}
    • Buildmaster platform: {{ platform }}

    Source code

    Buildbot is a free software project, released under the terms of the GNU GPL.

    Please visit the Buildbot Home Page for more information, including documentation, bug reports, and source downloads.

    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/authfail.html000066400000000000000000000003031222546025000237120ustar00rootroot00000000000000{% extends "layout.html" %} {% block content %}

    Authentication Failed

    The username or password you entered were not correct. Please go back and try again.

    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/authzfail.html000066400000000000000000000002251222546025000241070ustar00rootroot00000000000000{% extends "layout.html" %} {% block content %}

    Authorization Failed

    You are not allowed to perform this action.

    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/box_macros.html000066400000000000000000000020711222546025000242550ustar00rootroot00000000000000{% macro box(text=[], comment=None) -%} {%- if comment -%}{%- endif -%} {%- if text is string -%} {{ text }} {%- else -%} {{- text|join("
    ") -}} {%- endif -%} {% endmacro %} {# this is currently just the text part of the boxes #} {% macro build_box(reason, url, number) -%} Build {{ number }} {%- endmacro %} {% macro step_box(text, logs, urls, stepinfo) -%} {%- if text is string -%} {{ text }} {%- else -%} {{- text|join("
    ") -}} {%- endif -%}
    {%- for l in logs %} {{ l.name|e }}
    {%- endfor -%} {%- for u in urls %} [{{ u.name|e }}]
    {%- endfor -%} {%- endmacro %} buildbot-0.8.8/buildbot/status/web/templates/build.html000066400000000000000000000147641222546025000232340ustar00rootroot00000000000000{% extends "layout.html" %} {% import 'forms.html' as forms %} {% from "change_macros.html" import change with context %} {% block content %}

    Builder {{ b.getBuilder().getName() }} Build #{{ b.getNumber() }}

    {% if not b.isFinished() %}

    Build In Progress:

    {% if when_time %}

    ETA: {{ when_time }} [{{ when }}]

    {% endif %} {{ current_step }} {% if authz.advertiseAction('stopBuild', request) %}

    Stop Build

    {{ forms.stop_build(build_url+"/stop", authz, on_all=False, short=False, label='This Build') }} {% endif %} {% else %}

    Results:

    {{ b.getText()|join(' ')|capitalize }}

    {% if b.getTestResults() %}

    {% endif %} {% endif %}

    {% if sourcestamps|count == 1 %} SourceStamp: {% else %} SourceStamps: {% endif %}

    {% for ss in sourcestamps %}

    {{ ss.codebase }}

    {% set ss_class = cycler('alt','') %} {% if ss.project %} {% endif %} {% if ss.repository %} {% endif %} {% if ss.branch %} {% endif %} {% if ss.revision %} {% endif %} {% if got_revisions[ss.codebase] %} {% endif %} {% if ss.patch %} {% endif %} {% if ss.changes %} {% endif %} {% if not ss.branch and not ss.revision and not ss.patch and not ss.changes %} {% endif %}
    Project{{ ss.project|projectlink }}
    Repository{{ ss.repository|repolink }}
    Branch{{ ss.branch|e }}
    Revision{{ ss.revision|revlink(ss.repository) }}
    Got Revision{{ got_revisions[ss.codebase]|revlink(ss.repository) }}
    PatchYES
    Changes{{ ss.changes|count }} change{{ 's' if ss.changes|count > 1 else '' }}
    Build of most recent revision
    {% endfor %} {# # TODO: turn this into a table, or some other sort of definition-list # that doesn't take up quite so much vertical space #}

    BuildSlave:

    {% if slave_url %}
    {{ b.getSlavename()|e }} {% else %} {{ b.getSlavename()|e }} {% endif %}

    Reason:

    {{ b.getReason()|e }}

    Steps and Logfiles:

    {# # TODO: # urls = self.original.getURLs() # ex_url_class = "BuildStep external" # for name, target in urls.items(): # text.append('[%s]' % # (target, ex_url_class, html.escape(name))) #}
      {% for s in steps %}
    1. {{ s.name }} {{ s.text }} {{ '( ' + s.time_to_run + ' )' if s.time_to_run else '' }}
        {% set item_class = cycler('alt', '') %} {% for l in s.logs %}
      1. {{ l.name }}
      2. {% else %}
      3. - no logs -
      4. {% endfor %} {% for u in s.urls %}
      5. {{ u.logname }}
      6. {% endfor %}
    2. {% endfor %}

    Build Properties:

    {% for p in properties %} {% if p.source != "Force Build Form" %} {% if p.short_value %} {% else %} {% if p.value is not mapping %} {% else %} {% endif %} {% endif %} {% endif %} {% endfor %}
    NameValueSource
    {{ p.name|e }}{{ p.short_value|e }} .. [property value too long]{{ p.value|e }} {%- for key, value in p.value.items() recursive %} {% endfor %}
    {{ key|e }}{{ value|e }}
    {{ p.source|e }}

    Forced Build Properties:

    {% for p in properties %} {% if p.source == "Force Build Form" %} {% if p.text %} {% else %} {% endif %} {% endif %} {% endfor %}
    NameLabelValue
    {{ p.name|e }} {% if p.label %} {{ p.label }} {% endif %} {{ p.value|e }}

    Responsible Users:

    {% if responsible_users %}
      {% for u in responsible_users %}
    1. {{ u|user }}
    2. {% endfor %}
    {% else %}

    no responsible users

    {% endif %}

    Timing:

    {% if end %} {% endif %}
    Start{{ start }}
    End{{ end }}
    Elapsed{{ elapsed }}
    {% if authz.advertiseAction('forceBuild', request) %}

    Resubmit Build:

    {{ forms.rebuild_build(build_url+"/rebuild", authz, sourcestamps[0]) }} {% endif %}

    {% if has_changes %}

    All Changes:

    {% for ss in sourcestamps %} {% if ss.changes %}

    {{ ss.codebase }}:

      {% for c in ss.changes %}
    1. Change #{{ c.number }}

      {{ change(c.asDict()) }}
    2. {% endfor %}
    {% endif %} {% endfor %}
    {% endif %} {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/build_line.html000066400000000000000000000024101222546025000242240ustar00rootroot00000000000000{% macro build_line(b, include_builder=False) %} ({{ b.time }}) Rev: {{ b.rev|shortrev(b.rev_repo) }} {{ b.results }} {% if include_builder %} {{ b.builder_name }} {% endif %} #{{ b.buildnum }} - {{ b.text|capitalize }} {% endmacro %} {% macro build_tr(b, include_builder=False, loop=None) %} {{ b.time }} {{ b.rev|shortrev(b.rev_repo) }} {{ b.results }} {%- if include_builder %} {{ b.builder_name }} {% endif %} #{{ b.buildnum }} {{ b.text|capitalize }} {% endmacro %} {% macro build_table(builds, include_builder=False) %} {% if builds %} {%- if include_builder %} {% endif %} {% for b in builds %} {{ build_tr(b, include_builder, loop) }} {% endfor %}
    Time Revision ResultBuilderBuild # Info
    {% else %} No matching builds found {% endif %} {% endmacro %} buildbot-0.8.8/buildbot/status/web/templates/builder.html000066400000000000000000000060061222546025000235510ustar00rootroot00000000000000{% from 'build_line.html' import build_table %} {% import 'forms.html' as forms %} {% extends "layout.html" %} {% block content %}

    Builder {{ name }}

    (view in waterfall)

    {% if description %}
    {{ description }}
    {% endif %}
    {% if current %}

    Current Builds:

      {% for b in current %}
    • {{ b.num }} {% if b.when %} ETA: {{ b.when_time }} [{{ b.when }}] {% endif %} {{ b.current_step }} {% if authz.advertiseAction('stopBuild', request) %} {{ forms.stop_build(b.stop_url, authz, on_all=False, short=True, label='Build') }} {% endif %}
    • {% endfor %}
    {% else %}

    No current builds

    {% endif %} {% if pending %}

    Pending Build Requests:

      {% for b in pending %}
    • ({{ b.when }}, waiting {{ b.delay }}) {% if authz.advertiseAction('cancelPendingBuild', request) %} {{ forms.cancel_pending_build(builder_url+"/cancelbuild", authz, short=True, id=b.id) }} {% endif %} {% if b.num_changes < 4 %} {% for c in b.changes %}{{ c.revision|shortrev(c.repo) }} ({{ c.who|email }}){% if not loop.last %},{% endif %} {% endfor %} {% else %} ({{ b.num_changes }} changes) {% endif %} {% if 'owner' in b.properties %} Forced build by {{b.properties['owner'][0]}} {{b.properties['reason'][0]}} {% endif %}
    • {% endfor %}
    {% if authz.advertiseAction('cancelPendingBuild', request) %} {{ forms.cancel_pending_build(builder_url+"/cancelbuild", authz, short=False, id='all') }} {% endif %} {% else %}

    No Pending Build Requests

    {% endif %}

    Recent Builds:

    {{ build_table(recent) }} Show more

    Buildslaves:

    {% if slaves %} {% endif %} {% for s in slaves %} {% if s.connected %} {% if s.paused %} {% else %} {% endif %} {% else %} {% endif %} {% else %} {% endfor %}
    Name Status Admin
    {{ s.name|e }}pausedconnectedoffline{{ s.admin|email if s.admin else ""}}
    no slaves attached
    {% if authz.advertiseAction('pingBuilder', request) %}

    Ping slaves

    {{ forms.ping_builder(builder_url+"/ping", authz) }} {% endif %} {% if authz.advertiseAction('forceBuild', request) and force_schedulers != {} %}

    Force build

    {{ forms.force_build(builder_url+"/force", authz, request, False, force_schedulers=force_schedulers,default_props=default_props) }} {% endif %}
    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/builders.html000066400000000000000000000031701222546025000237330ustar00rootroot00000000000000{% extends 'layout.html' %} {% import 'forms.html' as forms %} {% from "box_macros.html" import box %} {% block content %}

    Builders: {{ branches|join(', ')|e }}

    {% for b in builders %} {% if b.build_url %} {% else %} {% endif %} {{ box(**b.current_box) }} {% endfor %}
    {{ b.name|e }} {{ b.build_label }}
    {{ b.build_text }}
    no build
    {% if num_building > 0 %} {% if authz.advertiseAction('stopAllBuilds', request) or authz.advertiseAction('stopBuild', request) %}

    Stop Selected Builds

    {{ forms.stop_build(path_to_root+"builders/_selected/stopselected", authz, on_selected=True, builders=builders, label='Selected Builds') }}

    Stop All Builds

    {{ forms.stop_build(path_to_root+"builders/_all/stopall", authz, on_all=True, label='All Builds') }} {% endif %} {% endif %} {% if num_online > 0 %} {% if authz.advertiseAction('forceAllBuilds', request) or authz.advertiseAction('forceBuild', request) %}

    Force Selected Builds

    {{ forms.force_build(path_to_root+"builders/_selected/forceselected", authz, request, on_selected=True, builders=builders, force_schedulers=force_schedulers, default_props=default_props) }}

    Force All Builds

    {{ forms.force_build(path_to_root+"builders/_all/forceall", authz,request, on_all=True, force_schedulers=force_schedulers, default_props=default_props) }} {% endif %} {% endif %} {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/buildslave.html000066400000000000000000000031701222546025000242540ustar00rootroot00000000000000{% from 'build_line.html' import build_table, build_line %} {% import 'forms.html' as forms %} {% extends "layout.html" %} {% block content %}

    Buildslave: {{ slavename|e }}

    {% if current %}

    Currently building:

      {% for b in current %}
    • {{ build_line(b, True) }}
    • {% endfor %}
    {% else %}

    No current builds

    {% endif %}

    Recent builds

    {{ build_table(recent, True) }}
    {% if access_uri %} Click to Access Slave {% endif %} {% if admin %}

    Administrator

    {{ admin|email }}

    {% endif %} {% if host %}

    Slave information

    Buildbot-Slave {{ slave_version }}
    {{ host|e }}
    {% endif %}

    Connection Status

    {{ connect_count }} connection(s) in the last hour {% if not slave.isConnected() %} (not currently connected) {% else %}

    {% if authz.advertiseAction('gracefulShutdown', request) %}

    Graceful Shutdown

    {% if slave.getGraceful() %}

    Slave will shut down gracefully when it is idle.

    {% else %} {{ forms.graceful_shutdown(shutdown_url, authz) }} {% endif %} {% endif %} {% if authz.advertiseAction('pauseSlave', request) %}

    Pause Slave

    {{ forms.pause_slave(pause_url, authz, slave.isPaused()) }} {% endif %} {% endif %}
    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/buildslaves.html000066400000000000000000000026601222546025000244420ustar00rootroot00000000000000{% extends "layout.html" %} {% block content %}

    Buildslaves

    {%- if show_builder_column %} {%- endif %} {% for s in slaves %} {%- if show_builder_column %} {%- endif %} {%- if s.admin -%} {%- else -%} {%- endif -%} {% if s.connected %} {% if s.running_builds %} {% elif s.paused %} {% else %} {% endif %} {% else %} {% endif %} {% endfor %}
    NameBuildersBuildBot Admin Last heard from Connects/Hour Status
    {{ s.name }} {%- if s.builders %} {%- for b in s.builders %} {{ b.name }} {%- endfor %} {%- else %} no builders {%- endif -%} {{ (s.version or '-')|e }}{{ s.admin|email }}- {%- if s.last_heard_from_age -%} {{ s.last_heard_from_age }} ({{ s.last_heard_from_time }}) {%- endif -%} {{ s.connectCount }} Running {{ s.running_builds }} build(s)PausedIdleNot connected
    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/buildstatus.html000066400000000000000000000004441222546025000244660ustar00rootroot00000000000000{% extends "layout.html" %} {% from "box_macros.html" import box %} {% block header %} {% endblock %} {% block barecontent %} {% for r in rows %} {{ box(**r) }} {% endfor %} {{ box(**build) }}
    {% endblock %} {% block footer %} {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/buildstep.html000066400000000000000000000032161222546025000241160ustar00rootroot00000000000000{% extends "layout.html" %} {% block content %}

    Builder {{ b.getBuilder().getName() }} build #{{ b.getNumber() }} step {{ s.getName() }}

    {% if s.isFinished() %}

    Finished

    {%- set text = s.getText() -%} {%- if text is string %}{{ text|e }} {%- else %}{{ text|join(" ")|e }}{% endif -%}

    {% else %}

    Not Finished

    ETA {{ s.getETA()|e }} seconds

    {% endif %} {% set exp = s.getExpectations() %} {% if exp %}

    Expectations

      {% for e in exp %}
    • {{ e[0]|e }}: current={{ e[1] }}, target={{ e[2] }}
    • {% endfor %}
    {% endif %}

    Timing

    {% if start %}
    Start{{ start }}
    End{{ end or "Not finished" }}
    Elapsed{{ elapsed }}
    {% else %} Not started {% endif %}

    Logs

      {% for l in logs %}
    • {% if l.has_contents %} {{ l.name|e }} {% else %} {{ l.name|e }} {% endif %}
    • {% else %}
    • - No logs -
    • {% endfor %}
    {% if statistics %}

    Statistics

    {% for stat in statistics %} {% endfor %}
    NameValue
    {{ stat.name|e }}{{ stat.value|e }}
    {% endif %}
    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/change.html000066400000000000000000000006421222546025000233500ustar00rootroot00000000000000{% extends "layout.html" %} {% from "change_macros.html" import change with context %} {% import 'forms.html' as forms %} {% block content %}

    {{ pageTitle }}

    {{ change(c) }} {% if authz.advertiseAction('stopChange', request) %}

    Cancel Builds For Change:

    {{ forms.stop_change_builds("/builders/_all/stopchangeall", c.number, authz) }} {% endif %}
    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/change_macros.html000066400000000000000000000037361222546025000247230ustar00rootroot00000000000000{% macro change(c) %} {% set row_class=cycler('alt','') %} {% if c.repository %} {% endif %} {% if c.project %} {% endif %} {% if c.branch %} {% endif %} {% if c.rev %} {% endif %}
    Category {{ c.category }}
    Changed by {{ c.who|email }}
    Changed at {{ c.at }}
    Repository {{ c.repository|repolink }}
    Project {{ c.project|projectlink }}
    Branch {{ c.branch|e }}
    Revision {%- if c.revlink -%}{{ c.rev|e }} {%- else -%}{{ c.rev|revlink(c.repository) }} {%- endif -%}
    {% if c.comments %}

    Comments

    {{ c.comments|changecomment(c.project) }}
    {% endif %}

    Changed files

      {% for f in c.files -%}
    • {%- if f.url %}{{ f.name|e }}
    • {%- else %} {{ f.name|e }} {%- endif -%} {% else %}
    • no files
    • {% endfor %}
    {% if c.properties %}

    Properties

    {% for p in c.properties %} {% endfor %}
    {{ p[0]|capitalize|e }} {{ p[1]|e }}
    {% endif %} {%- endmacro %} {% macro box_contents(who, url, pageTitle, revision, project) -%} {{ who|user }} {%- endmacro %} buildbot-0.8.8/buildbot/status/web/templates/change_sources.html000066400000000000000000000004611222546025000251120ustar00rootroot00000000000000{% extends "layout.html" %} {% block content %}

    Changesources

    {% if sources %}
      {% for s in sources -%}
    1. {{ s.describe() }}
    2. {% endfor -%}
    {% else %} none (push only) {% endif %}
    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/console.html000066400000000000000000000203241222546025000235640ustar00rootroot00000000000000{% extends "layout.html" %} {% block head %} {{ super() }} {% endblock %} {% block content %}

    Console View

    {% if categories|length > 1 %}
    Categories: {% for c in categories %}{{ c.name|e }} {% endfor %} {% endif %} {% if codebase %}
    Codebase: {{ codebase|e }} {% endif %} {% if repository %}
    Repository: {{ repository|e }} {% endif %} {% if project %}
    Project: {{ project|e }} {% endif %} {% if branch != ANYBRANCH %}
    Branch: {{ branch|e }} {% endif %}
    Legend:   Passed Failed Warnings Failed Again Running Exception Offline No data

    {% set alt_class = cycler('', 'Alt') %}
    {% if categories|length > 1 %} {% for c in categories %} {% endfor %} {% endif %} {% if slaves %} {% for c in categories %} {% endfor %} {% endif %} {% for r in revisions %} {% set alt = alt_class.next() %} {% set firstrev = "first" if loop.first else '' %} {% for c in categories %} {% set last = "last" if loop.last else "" %} {% endfor %} {% if r.details %} {% endif %} {% else %} {% endfor %}
    {{ c.name|e }}
    {% for s in slaves[c.name] %} {% endfor %}
    {{ r.id|shortrev(r.repository) }} {{ r.who|user }} {% for b in r.builds[c.name] %} {% endfor %}
    {{ r.comments|changecomment(r.project or None)|replace('\n', '
    ')|replace(' ','  ') }}
      {% for d in r.details %}
    • {{ d.buildername }}: {{ d.status }} -   {%- for l in d.logs -%} {{ l.name }} {%- endfor -%}
    • {% endfor %}
    No revisions available
    {% endblock %} {% block footer %} {{ super() }} {#

    Debug info: {{ debuginfo }}

    #} {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/directory.html000066400000000000000000000015141222546025000241260ustar00rootroot00000000000000{% extends "layout.html" %} {% block content %}

    Directory listing for {{ path }}

    {% set row_class = cycler('alt', '') %} {% for d in directories %} {% endfor %} {% for f in files %} {% endfor %}
    Name Size Type Encoding
    {{ d.text }} {{ d.size }} {{ d.type }} {{ d.encoding }}
    {{ f.text }} {{ f.size }} {{ f.type }} {{ f.encoding }}
    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/empty.html000066400000000000000000000001161222546025000232550ustar00rootroot00000000000000{% extends "layout.html" %} {% block content %} {{ content }} {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/feed_atom10.xml000066400000000000000000000020611222546025000240400ustar00rootroot00000000000000{% from 'feed_description.html' import item_desc %} {{ title_url }} {{ pageTitle|e }} {% if project_url -%} {% endif %} {%- if description -%} {{ description }} {% endif %} {%- if rfc3339_pubdate -%} {{ rfc3339_pubdate }} {% endif -%} BuildBot {% for b in builds -%} {{ b.pageTitle }}
    {{ item_desc(b, title_url, title)|indent(6) }}
    {{ b.log_lines|join('\n')|e }}
    {% if b.rfc3339_pubdate -%} {{ b.rfc3339_pubdate }} {{ b.guid }} {% endif -%} Buildbot
    {% endfor -%}
    buildbot-0.8.8/buildbot/status/web/templates/feed_description.html000066400000000000000000000011451222546025000254300ustar00rootroot00000000000000{% from 'feed_sources.html' import srcs_desc %} {% macro item_desc(b, title_url, title) -%}

    Date: {{ b.date }}
    Project home: {{ title|e }}
    Builder summary: {{ b.name }}
    Build details: Build {{ b.number }}
    Author list: {{ b.responsible_users|join(', ') }}
    Failed step(s): {{ b.failed_steps|join(', ') }}

    {% for src in b.sources %} {{ srcs_desc(src) }} {% endfor %}

    Last lines of the build log:

    {%- endmacro %} buildbot-0.8.8/buildbot/status/web/templates/feed_rss20.xml000066400000000000000000000020571222546025000237150ustar00rootroot00000000000000{% from 'feed_description.html' import item_desc %} {{ pageTitle|e }} {{ title_url }} {% if language -%} {{ language }} {% endif %} {%- if description -%} {{ description }} {% endif %} {%- if rfc822_pubdate -%} {{ rfc822_pubdate }} {% endif %} {% for b in builds -%} {{ b.pageTitle }} {{ b.link }} {{ b.log_lines|join('\n')|e }} ]]> {% if b.rfc822_pubdate -%} {{ b.rfc822_pubdate }} {{ b.guid }} {%- endif %} {% endfor %} buildbot-0.8.8/buildbot/status/web/templates/feed_sources.html000066400000000000000000000005571222546025000245760ustar00rootroot00000000000000{% macro srcs_desc(src) -%}
    {%- if src.codebase -%} Codebase: {{ src.codebase }}
    {% endif %}

    Repository: {{ src.repository }}
    {%- if src.branch -%} Branch: {{ src.branch }}
    {% endif %} {%- if src.revision -%} Revision: {{ src.revision }}
    {% endif %}

    {%- endmacro %} buildbot-0.8.8/buildbot/status/web/templates/footer.html000066400000000000000000000005401222546025000234160ustar00rootroot00000000000000
    buildbot-0.8.8/buildbot/status/web/templates/forms.html000066400000000000000000000214041222546025000232500ustar00rootroot00000000000000 {% macro cancel_pending_build(cancel_url, authz, short=False, id='all') %}
    {% if not short %} {% if id == 'all' %}

    To cancel all builds, fill out the following fields and push the 'Cancel' button

    To cancel individual builds, click the 'Cancel' buttons above.

    {% else %}

    To cancel this build, fill out the following fields and push the 'Cancel' button

    {% endif %} {% endif %}
    {% endmacro %} {% macro stop_change_builds(stopchange_url, changenum, authz) %} {% if not changenum %}
    {% if changenum %}

    To cancel all builds for this change, push the 'Cancel' button

    {% else %}

    To cancel builds for this builder for a given change, fill out all fields and push the 'Cancel' button

    {% endif %} {% if changenum %} {% else %}
    Change #:
    {% endif %}
    {% endif %} {% endmacro %} {% macro stop_build(stop_url, authz, on_all=False, on_selected=False, builders=[], short=False, label="Build") %} {% if not short %}
    {% if not short %} {% if on_all %}

    To stop all builds, fill out the following field and push the Stop {{ label }} button

    {% elif on_selected %}

    To stop selected builds, select the builders, fill out the following field and push the Stop {{ label }} button

    {% for b in builders %} {% endfor %}
    {{ b.name|e }}
    {% else %}

    To stop this build, fill out the following field and push the Stop {{ label }} button

    {% endif %} {% endif %} {% if not short %}
    Reason:
    {% endif %}
    {% endif %} {% endmacro %} {% macro force_build_scheduler_parameter(f, authz, request, sch, default_props) %} {% if f and not f.hide and (f.fullName != "username" or not authz.authenticated(request)) %}
    {% if 'text' in f.type or 'int' in f.type %} {{f.label}} {% elif 'bool' in f.type%} {{f.label}} {% elif 'textarea' in f.type %} {{f.label}} {% elif 'list' in f.type %} {{f.label}} {% elif 'nested' in f.type %} {% if f.label %}{{f.label}}{% endif %} {% for subfield in f.fields %} {{ force_build_scheduler_parameter(subfield, authz, request, sch, default_props) }} {% endfor %} {% endif %}
    {% endif %} {% endmacro %} {% macro force_build_one_scheduler(force_url, authz, request, on_all, on_selected, builders, sch, default_props) %}

    {{ sch.name|e }}

    {% if on_all %}

    To force a build on all Builders, fill out the following fields and push the 'Force Build' button

    {% elif on_selected %}

    To force a build on certain Builders, select the builders, fill out the following fields and push the 'Force Build' button

    {% for b in builders %} {% if b.name in sch.builderNames %} {% endif %} {% endfor %}
    {{ b.name|e }}
    {% else %}

    To force a build, fill out the following fields and push the 'Force Build' button

    {% endif %} {% for f in sch.all_fields %} {{ force_build_scheduler_parameter(f, authz, request, sch, default_props) }} {% endfor %}
    {% endmacro %} {% macro force_build(force_url, authz, request, on_all=False, on_selected=False, builders=[], force_schedulers={},default_props={}) %} {% for name, sch in force_schedulers.items() | sort %} {{ force_build_one_scheduler(force_url, authz, request, on_all, on_selected, builders, sch, default_props=default_props) }} {% endfor %} {% endmacro %} {% macro graceful_shutdown(shutdown_url, authz) %}

    To cause this slave to shut down gracefully when it is idle, push the 'Graceful Shutdown' button

    {% endmacro %} {% macro pause_slave(pause_url, authz, paused) %}
    {% if paused %}

    To cause this slave to start running new builds again, push the 'Unpause Slave' button

    {% else %}

    To cause this slave to stop running new builds, push the 'Pause Slave' button

    {% endif %} {% if paused %} {% else %} {% endif %}
    {% endmacro %} {% macro clean_shutdown(shutdown_url, authz) %}

    To cause this master to shut down cleanly, push the 'Clean Shutdown' button.

    No other builds will be started on this master, and the master will stop once all current builds are finished.

    {% endmacro %} {% macro cancel_clean_shutdown(cancel_shutdown_url, authz) %}

    To cancel a previously initiated shutdown, push the 'Cancel Shutdown' button.

    {% endmacro %} {% macro ping_builder(ping_url, authz) %}

    To ping the buildslave(s), push the 'Ping' button

    {% endmacro %} {% macro rebuild_build(rebuild_url, authz, ss) %}
    {% if on_all %}

    To force a build on all Builders, fill out the following fields and push the 'Force Build' button

    {% else %}

    To force a build, fill out the following fields and push the 'Force Build' button

    {% endif %}
    Reason for re-running build:
    {% endmacro %} {% macro show_users(users_url, authz) %}

    To show users, press the 'Show Users' button

    {% endmacro %} buildbot-0.8.8/buildbot/status/web/templates/grid.html000066400000000000000000000010321222546025000230420ustar00rootroot00000000000000{% extends "layout.html" %} {% import 'grid_macros.html' as grid with context %} {% block content %}

    Grid View

    {% for s in stamps %} {{ grid.stamp_td(s) }} {% endfor %} {% for builder in builders %} {{ grid.builder_td(builder) }} {% for build in builder.builds %} {{ grid.build_td(build) }} {% endfor %} {% endfor %}
    {{ title }} {{ grid.category_title() }}
    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/grid_macros.html000066400000000000000000000026161222546025000244170ustar00rootroot00000000000000{% macro category_title() -%} {% if categories %}
    {% trans categories=categories %} Category:
    {% pluralize categories %} Categories:
    {% endtrans %} {% for c in categories %} {{ c|e }}
    {% endfor %} {% endif %} {% if branch != ANYBRANCH %}
    Branch: {{ branch|e or "trunk" }} {% endif %} {%- endmacro %} {% macro stamp_td(sourcestamps) -%} {% for ss in sourcestamps %} {%- if ss.codebase %}{{ ss.codebase|e }}: {% endif %} {%- if ss.revision -%} {{ ss.revision|shortrev(ss.repository) }} {%- else %}latest{% endif %} {%- if ss.branch %} in {{ ss.branch|e }}{% endif %} {%- if ss.hasPatch %} [patch]{% endif -%}
    {%- endfor %} {%- endmacro %} {% macro builder_td(b) -%} {{ b.name }} {%- if b.state != 'idle' or b.n_pending > 0 -%}
    ({{ b.state }} {%- if b.n_pending > 0 -%} , plus {{ b.n_pending }} {%- endif -%} ) {%- endif -%} {%- endmacro %} {% macro build_td(build) -%} {% if build %} {{ build.text|join('
    ') }}
    {% else %}   {% endif %} {%- endmacro %} buildbot-0.8.8/buildbot/status/web/templates/grid_transposed.html000066400000000000000000000010461222546025000253110ustar00rootroot00000000000000{% extends "layout.html" %} {% import 'grid_macros.html' as grid with context %} {% block content %}

    Transposed Grid View

    {% for builder in builders %} {{ grid.builder_td(builder) }} {% endfor %} {% for i in range %} {{ grid.stamp_td(stamps[i]) }} {% for b in builder_builds %} {{ grid.build_td(b[i]) }} {% endfor %} {% endfor %}
    {{ title }} {{ grid.category_title() }}
    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/jsonhelp.html000066400000000000000000000007231222546025000237450ustar00rootroot00000000000000{% extends "layout.html" %} {% block content %}

    {{ text }}

    More Help:

    {% if level != 1 %}

    Parent's Help

    {% endif %} {% if children %}

    Child Nodes

    {% endif %}

    Flags:

    {{ flags }}

    Examples:

    {{ examples }} {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/layout.html000066400000000000000000000056311222546025000234430ustar00rootroot00000000000000{%- block doctype -%} {% endblock %} {% block head %} {% if metatags %} {{ metatags }} {% endif %} {% if refresh %} {% endif %} {{ pageTitle|e }} {% endblock %} {% block header -%}
    Home - Waterfall Grid T-Grid Console Builders Recent Builds Buildslaves Changesources {% if authz.advertiseAction('showUsersPage', request) %} Users {% endif %} - JSON API - About
    {% if authz.authenticated(request) %} {{ authz.getUsernameHTML(request) }} |Logout {% elif authz.useHttpHeader and authz.httpLoginUrl %} Login {% elif authz.auth %}
    {% endif %}
    {% endblock %} {%- block barecontent -%}
    {% if alert_msg != "" %}
    {{ alert_msg }}
    {% endif %}
    {%- block content -%} {%- endblock -%}
    {%- endblock -%} {%- block footer -%} {% endblock -%} buildbot-0.8.8/buildbot/status/web/templates/logs.html000066400000000000000000000012111222546025000230600ustar00rootroot00000000000000{%- macro page_header(pageTitle, path_to_root, texturl) -%} {{ pageTitle }} (view as text)
      
    {%- endmacro -%}
    
    {%- macro chunks(entries) -%}
    {%- for entry in entries -%}
        {{ entry.text|e }}
    {%- endfor -%}
    {%- endmacro -%}
    
    {%- macro page_footer() -%}
    
    {%- endmacro -%} buildbot-0.8.8/buildbot/status/web/templates/onelineperbuild.html000066400000000000000000000016171222546025000253060ustar00rootroot00000000000000{% extends "layout.html" %} {% from 'build_line.html' import build_table %} {% import 'forms.html' as forms %} {% block content %}

    Last {{ num_builds }} finished builds: {{ branches|join(', ')|e }}

    {% if builders %}

    of builders: {{ builders|join(", ")|e }}

    {% endif %}
    {{ build_table(builds, True) }}
    {% if num_building > 0 %} {% if authz.advertiseAction('stopBuild', request) %}

    Stop All Builds

    {{ forms.stop_build("builders/_all/stopall", authz, on_all=True, label='All Builds') }} {% endif %} {% endif %} {% if num_online > 0 %} {% if authz.advertiseAction('forceAllBuilds', request) %}

    Force All Builds

    {{ forms.force_build("builders/_all/forceall", authz, request, True, force_schedulers=force_schedulers, default_props=default_props) }} {% endif %} {% endif %}
    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/onelineperbuildonebuilder.html000066400000000000000000000005271222546025000273560ustar00rootroot00000000000000{% extends "layout.html" %} {% from 'build_line.html' import build_line %} {% block content %}

    Last {{ num_builds }} builds of builder {{ builder_name|e }}: {{ branches|join(', ')|e }}

      {% for b in builds %}
    • {{ build_line(b) }}
    • {% else %}
    • No matching builds found
    • {% endfor %}
    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/revmacros.html000066400000000000000000000017021222546025000241220ustar00rootroot00000000000000{# both macro pairs must have the same signature #} {% macro id_replace(rev, url) -%} {%- if rev|length > 40 %}{{ rev[:40] }}... {%- else %}{{ rev }} {%- endif -%} {%- endmacro %} {% macro shorten_replace(short, rev, url) %} {% endmacro %} {% macro id(rev, url) -%} {%- if rev|length > 40 %}{{ rev[:40] }}... {%- else %}{{ rev }} {%- endif -%} {%- endmacro %} {% macro shorten(short, rev, url) %}
    {{ short }}...
    {{ rev }}
    {% endmacro %} buildbot-0.8.8/buildbot/status/web/templates/root.html000066400000000000000000000035661222546025000231160ustar00rootroot00000000000000{% extends 'layout.html' %} {% import 'forms.html' as forms %} {% block content %}

    Welcome to the Buildbot {%- if title -%}  for the  {%- if title_url -%} {{ title }} {%- else -%} {{ title }} {%- endif -%}  project {%- endif -%} !

    {%- if authz.advertiseAction('cleanShutdown', request) -%} {%- if shutting_down -%} Master is shutting down
    {{ forms.cancel_clean_shutdown(cancel_shutdown_url, authz) }} {%- else -%} {{ forms.clean_shutdown(shutdown_url, authz) }} {%- endif -%} {%- endif -%}

    This and other pages can be overridden and customized.

    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/testresult.html000066400000000000000000000012711222546025000243400ustar00rootroot00000000000000{% extends "layout.html" %} {% block content %}

    Builder {{ b.getBuilder().getName() }} build #{{ b.getNumber() }} test {{ '.'.join(tr.getName()) }}

    Result

    {{ result_word }} {%- set text = tr.getText() -%} {%- if text is string %}{{ text|e }} {%- else %}{{ text|join(" ")|e }}{% endif -%}

    Logs

      {% for l in logs %}

      Log: {{ l.name|e }}

      {{ l.log|e }}

      {% else %}
    • - No logs -
    • {% endfor %}
    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/user.html000066400000000000000000000005671222546025000231070ustar00rootroot00000000000000{% extends "layout.html" %} {% block content %}

    User: {{ user_identifier|e }}

    {% for attr in user %} {% endfor %}
    Attribute Type Attribute Value
    {{ attr|e }} {{ user[attr]|e }}
    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/users.html000066400000000000000000000005531222546025000232650ustar00rootroot00000000000000{% extends "layout.html" %} {% block content %}

    Users

    {% for user in users %} {% endfor %}
    Uid Identifier
    {{ user.uid }} {{ user.identifier|e }}
    {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/waterfall.html000066400000000000000000000026261222546025000241100ustar00rootroot00000000000000{% extends "layout.html" %} {% from "box_macros.html" import box %} {% block content %}

    Waterfall

    waterfall help
    {% for b in builders %} {% endfor %} {% for b in builders %} {% endfor %} {% for b in builders %} {% endfor %} {# waterfall contents goes here #} {% for i in range(gridlen) -%} {% for strip in grid -%} {%- if strip[i] -%}{{ box(**strip[i]) }} {%- elif no_bubble -%}{{ box() }} {%- endif -%} {%- endfor -%} {% endfor %}
    last build {{ b.name }}
    {{ " ".join(b.top) }}
    current activity {{ "
    ".join(b.status) }}
    {{ tz }} changes{{ b.name }}
    {% if nextpage %} next page {% endif %} {% if no_reload_page %} Stop Reloading {% endif %} {% endblock %} buildbot-0.8.8/buildbot/status/web/templates/waterfallhelp.html000066400000000000000000000116051222546025000247560ustar00rootroot00000000000000{% extends "layout.html" %} {% block content %}

    The Waterfall Display

    The Waterfall display can be controlled by adding query arguments to the URL. For example, if your Waterfall is accessed via the URL http://buildbot.example.org:8080, then you could add a branch= argument (described below) by going to http://buildbot.example.org:8080?branch=beta4 instead. Remember that query arguments are separated from each other with ampersands, but they are separated from the main URL with a question mark, so to add a branch= and two builder= arguments, you would use http://buildbot.example.org:8080?branch=beta4&builder=unix&builder=macos.

    Limiting the Displayed Interval

    The last_time= argument is a unix timestamp (seconds since the start of 1970) that will be used as an upper bound on the interval of events displayed: nothing will be shown that is more recent than the given time. When no argument is provided, all events up to and including the most recent steps are included.

    The first_time= argument provides the lower bound. No events will be displayed that occurred before this timestamp. Instead of providing first_time=, you can provide show_time=: in this case, first_time will be set equal to last_time minus show_time. show_time overrides first_time.

    The display normally shows the latest 200 events that occurred in the given interval, where each timestamp on the left hand edge counts as a single event. You can add a num_events= argument to override this this.

    Showing non-Build events

    By passing show_events=true, you can add the "buildslave attached", "buildslave detached", and "builder reconfigured" events that appear in-between the actual builds.

    Show non-Build events

    Showing only Certain Branches

    If you provide one or more branch= arguments, the display will be limited to builds that used one of the given branches. If no branch= arguments are given, builds from all branches will be displayed.

    Erase the text from these "Show Branch:" boxes to remove that branch filter. {% if branches %} {% for b in branches %} {% endfor %}
    Show Branch:
    {% endif %}

    Limiting the Builders that are Displayed

    By adding one or more builder= arguments, the display will be limited to showing builds that ran on the given builders. This serves to limit the display to the specific named columns. If no builder= arguments are provided, all Builders will be displayed.

    To view a Waterfall page with only a subset of Builders displayed, select the Builders you are interested in here.

    {% for bn in all_builders %} {% endfor %}
    {{bn}}

    Limiting the Builds that are Displayed

    By adding one or more committer= arguments, the display will be limited to showing builds that were started by the given committer. If no committer= arguments are provided, all builds will be displayed.

    To view a Waterfall page with only a subset of Builds displayed, select the committers your are interested in here.

    Erase the text from these "Show Committer:" boxes to remove that filter. {% if committers %} {% for cn in committers %} {% endfor %}
    Show Committer:
    {% endif %}

    Showing only the Builders with failures

    By adding the failures_only=true argument, the display will be limited to showing builders that are currently failing. A builder is considered failing if the last finished build was not successful, a step in the current build(s) failed, or if the builder is offline.

    Show failures only

    Auto-reloading the Page

    Adding a reload= argument will cause the page to automatically reload itself after that many seconds.

    {% for value, name in times %} {% endfor %}
    {{ name|e }}

    Reload Waterfall Page

    {% endblock %} buildbot-0.8.8/buildbot/status/web/tests.py000066400000000000000000000057351222546025000207630ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import urllib from buildbot.status.web.base import HtmlResource, path_to_builder, \ path_to_build, css_classes from buildbot.status.builder import Results # /builders/$builder/builds/$buildnum/steps/$stepname class StatusResourceBuildTest(HtmlResource): pageTitle = "Test Result" addSlash = True def __init__(self, build_status, test_result): HtmlResource.__init__(self) self.status = build_status self.test_result = test_result def content(self, req, cxt): tr = self.test_result b = self.status cxt['b'] = self.status logs = cxt['logs'] = [] for lname, log in tr.getLogs().items(): if isinstance(log, str): log = log.decode('utf-8') logs.append({'name': lname, 'log': log, 'link': req.childLink("logs/%s" % urllib.quote(lname)) }) cxt['text'] = tr.text cxt['result_word'] = Results[tr.getResults()] cxt.update(dict(builder_link = path_to_builder(req, b.getBuilder()), build_link = path_to_build(req, b), result_css = css_classes[tr.getResults()], b = b, tr = tr)) template = req.site.buildbot_service.templates.get_template("testresult.html") return template.render(**cxt) def getChild(self, path, req): # if path == "logs": # return LogsResource(self.step_status) #TODO we need another class return HtmlResource.getChild(self, path, req) # /builders/$builder/builds/$buildnum/steps class TestsResource(HtmlResource): addSlash = True nameDelim = '.' # Test result have names like a.b.c def __init__(self, build_status): HtmlResource.__init__(self) self.build_status = build_status def content(self, req, ctx): # TODO list the tests return "subpages show data for each test" def getChild(self, path, req): tpath = None if path: tpath = tuple(path.split(self.nameDelim)) if tpath: tr = self.build_status.getTestResults().get(tpath) if tr: return StatusResourceBuildTest(self.build_status, tr) return HtmlResource.getChild(self, path, req) buildbot-0.8.8/buildbot/status/web/users.py000066400000000000000000000057651222546025000207650ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import urllib from twisted.internet import defer from twisted.web.util import redirectTo from buildbot.status.web.base import HtmlResource, path_to_authzfail, \ path_to_root, ActionResource class UsersActionResource(ActionResource): def __init__(self): self.action = "showUsersPage" @defer.inlineCallbacks def performAction(self, req): res = yield self.getAuthz(req).actionAllowed('showUsersPage', req) if not res: defer.returnValue(path_to_authzfail(req)) return # show the table defer.returnValue(path_to_root(req) + "users") # /users/$uid class OneUserResource(HtmlResource): addSlash = False def __init__(self, uid): HtmlResource.__init__(self) self.uid = int(uid) def getPageTitle (self, req): return "Buildbot User: %s" % self.uid def content(self, request, ctx): status = self.getStatus(request) d = status.master.db.users.getUser(self.uid) def cb(usdict): ctx['user_identifier'] = usdict['identifier'] user = ctx['user'] = {} for attr in usdict: if attr not in ['uid', 'identifier', 'bb_password']: user[attr] = usdict[attr] template = request.site.buildbot_service.templates.get_template("user.html") data = template.render(**ctx) return data d.addCallback(cb) return d # /users class UsersResource(HtmlResource): pageTitle = "Users" addSlash = True def __init__(self): HtmlResource.__init__(self) def getChild(self, path, req): return OneUserResource(path) @defer.inlineCallbacks def content(self, req, ctx): res = yield self.getAuthz(req).actionAllowed('showUsersPage', req) if not res: defer.returnValue(redirectTo(path_to_authzfail(req), req)) return s = self.getStatus(req) usdicts = yield s.master.db.users.getUsers() users = ctx['users'] = usdicts for user in users: user['user_link'] = req.childLink(urllib.quote(str(user['uid']), '')) template = req.site.buildbot_service.templates.get_template( "users.html") defer.returnValue(template.render(**ctx)) buildbot-0.8.8/buildbot/status/web/waterfall.py000066400000000000000000001004271222546025000215740ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from twisted.python import log, components from twisted.internet import defer import urllib import time, locale import operator from buildbot import interfaces, util from buildbot.status import builder, buildstep, build from buildbot.changes import changes from buildbot.status.web.base import Box, HtmlResource, IBox, ICurrentBox, \ ITopBox, build_get_class, path_to_build, path_to_step, path_to_root, \ map_branches def earlier(old, new): # minimum of two things, but "None" counts as +infinity if old: if new < old: return new return old return new def later(old, new): # maximum of two things, but "None" counts as -infinity if old: if new > old: return new return old return new class CurrentBox(components.Adapter): # this provides the "current activity" box, just above the builder name implements(ICurrentBox) def formatETA(self, prefix, eta): if eta is None: return [] if eta < 60: return ["< 1 min"] eta_parts = ["~"] eta_secs = eta if eta_secs > 3600: eta_parts.append("%d hrs" % (eta_secs / 3600)) eta_secs %= 3600 if eta_secs > 60: eta_parts.append("%d mins" % (eta_secs / 60)) eta_secs %= 60 abstime = time.strftime("%H:%M", time.localtime(util.now()+eta)) return [prefix, " ".join(eta_parts), "at %s" % abstime] def getBox(self, status, brcounts): # getState() returns offline, idle, or building state, builds = self.original.getState() # look for upcoming builds. We say the state is "waiting" if the # builder is otherwise idle and there is a scheduler which tells us a # build will be performed some time in the near future. TODO: this # functionality used to be in BuilderStatus.. maybe this code should # be merged back into it. upcoming = [] builderName = self.original.getName() for s in status.getSchedulers(): if builderName in s.listBuilderNames(): upcoming.extend(s.getPendingBuildTimes()) if state == "idle" and upcoming: state = "waiting" if state == "building": text = ["building"] if builds: for b in builds: eta = b.getETA() text.extend(self.formatETA("ETA in", eta)) elif state == "offline": text = ["offline"] elif state == "idle": text = ["idle"] elif state == "waiting": text = ["waiting"] else: # just in case I add a state and forget to update this text = [state] # TODO: for now, this pending/upcoming stuff is in the "current # activity" box, but really it should go into a "next activity" row # instead. The only times it should show up in "current activity" is # when the builder is otherwise idle. # are any builds pending? (waiting for a slave to be free) brcount = brcounts[builderName] if brcount: text.append("%d pending" % brcount) for t in sorted(upcoming): if t is not None: eta = t - util.now() text.extend(self.formatETA("next in", eta)) return Box(text, class_="Activity " + state) components.registerAdapter(CurrentBox, builder.BuilderStatus, ICurrentBox) class BuildTopBox(components.Adapter): # this provides a per-builder box at the very top of the display, # showing the results of the most recent build implements(IBox) def getBox(self, req): assert interfaces.IBuilderStatus(self.original) branches = [b for b in req.args.get("branch", []) if b] builder = self.original builds = list(builder.generateFinishedBuilds(map_branches(branches), num_builds=1)) if not builds: return Box(["none"], class_="LastBuild") b = builds[0] url = path_to_build(req, b) text = b.getText() tests_failed = b.getSummaryStatistic('tests-failed', operator.add, 0) if tests_failed: text.extend(["Failed tests: %d" % tests_failed]) # TODO: maybe add logs? class_ = build_get_class(b) return Box(text, urlbase=url, class_="LastBuild %s" % class_) components.registerAdapter(BuildTopBox, builder.BuilderStatus, ITopBox) class BuildBox(components.Adapter): # this provides the yellow "starting line" box for each build implements(IBox) def getBox(self, req): b = self.original number = b.getNumber() url = path_to_build(req, b) reason = b.getReason() template = req.site.buildbot_service.templates.get_template("box_macros.html") text = template.module.build_box(reason=reason,url=url,number=number) class_ = "start" if b.isFinished() and not b.getSteps(): # the steps have been pruned, so there won't be any indication # of whether it succeeded or failed. class_ = build_get_class(b) return Box([text], class_="BuildStep " + class_) components.registerAdapter(BuildBox, build.BuildStatus, IBox) class StepBox(components.Adapter): implements(IBox) def getBox(self, req): urlbase = path_to_step(req, self.original) text = self.original.getText() if text is None: log.msg("getText() gave None", urlbase) text = [] text = text[:] logs = self.original.getLogs() cxt = dict(text=text, logs=[], urls=[], stepinfo=self) for num in range(len(logs)): name = logs[num].getName() if logs[num].hasContents(): url = urlbase + "/logs/%s" % urllib.quote(name) else: url = None cxt['logs'].append(dict(name=name, url=url)) for name, target in self.original.getURLs().items(): cxt['urls'].append(dict(link=target,name=name)) template = req.site.buildbot_service.templates.get_template("box_macros.html") text = template.module.step_box(**cxt) class_ = "BuildStep " + build_get_class(self.original) return Box(text, class_=class_) components.registerAdapter(StepBox, buildstep.BuildStepStatus, IBox) class EventBox(components.Adapter): implements(IBox) def getBox(self, req): text = self.original.getText() class_ = "Event" return Box(text, class_=class_) components.registerAdapter(EventBox, builder.Event, IBox) class Spacer: implements(interfaces.IStatusEvent) def __init__(self, start, finish): self.started = start self.finished = finish def getTimes(self): return (self.started, self.finished) def getText(self): return [] class SpacerBox(components.Adapter): implements(IBox) def getBox(self, req): #b = Box(["spacer"], "white") b = Box([]) b.spacer = True return b components.registerAdapter(SpacerBox, Spacer, IBox) def insertGaps(g, showEvents, lastEventTime, idleGap=2): debug = False e = g.next() starts, finishes = e.getTimes() if debug: log.msg("E0", starts, finishes) if finishes == 0: finishes = starts if debug: log.msg("E1 finishes=%s, gap=%s, lET=%s" % \ (finishes, idleGap, lastEventTime)) if finishes is not None and finishes + idleGap < lastEventTime: if debug: log.msg(" spacer0") yield Spacer(finishes, lastEventTime) followingEventStarts = starts if debug: log.msg(" fES0", starts) yield e while 1: e = g.next() if not showEvents and isinstance(e, builder.Event): continue starts, finishes = e.getTimes() if debug: log.msg("E2", starts, finishes) if finishes == 0: finishes = starts if finishes is not None and finishes + idleGap < followingEventStarts: # there is a gap between the end of this event and the beginning # of the next one. Insert an idle event so the waterfall display # shows a gap here. if debug: log.msg(" finishes=%s, gap=%s, fES=%s" % \ (finishes, idleGap, followingEventStarts)) yield Spacer(finishes, followingEventStarts) yield e followingEventStarts = starts if debug: log.msg(" fES1", starts) class WaterfallHelp(HtmlResource): pageTitle = "Waterfall Help" def __init__(self, categories=None): HtmlResource.__init__(self) self.categories = categories def content(self, request, cxt): status = self.getStatus(request) cxt['show_events_checked'] = request.args.get("show_events", ["false"])[0].lower() == "true" cxt['branches'] = [b for b in request.args.get("branch", []) if b] cxt['failures_only'] = request.args.get("failures_only", ["false"])[0].lower() == "true" cxt['committers'] = [c for c in request.args.get("committer", []) if c] # this has a set of toggle-buttons to let the user choose the # builders show_builders = request.args.get("show", []) show_builders.extend(request.args.get("builder", [])) cxt['show_builders'] = show_builders cxt['all_builders'] = status.getBuilderNames(categories=self.categories) # a couple of radio-button selectors for refresh time will appear # just after that text times = [("none", "None"), ("60", "60 seconds"), ("300", "5 minutes"), ("600", "10 minutes"), ] current_reload_time = request.args.get("reload", ["none"]) if current_reload_time: current_reload_time = current_reload_time[0] if current_reload_time not in [t[0] for t in times]: times.insert(0, (current_reload_time, current_reload_time) ) cxt['times'] = times cxt['current_reload_time'] = current_reload_time template = request.site.buildbot_service.templates.get_template("waterfallhelp.html") return template.render(**cxt) class ChangeEventSource(object): "A wrapper around a list of changes to supply the IEventSource interface" def __init__(self, changes): self.changes = changes # we want them in newest-to-oldest order self.changes.reverse() def eventGenerator(self, branches, categories, committers, minTime): for change in self.changes: if branches and change.branch not in branches: continue if categories and change.category not in categories: continue if committers and change.author not in committers: continue if minTime and change.when < minTime: continue yield change class WaterfallStatusResource(HtmlResource): """This builds the main status page, with the waterfall display, and all child pages.""" def __init__(self, categories=None, num_events=200, num_events_max=None): HtmlResource.__init__(self) self.categories = categories self.num_events=num_events self.num_events_max=num_events_max self.putChild("help", WaterfallHelp(categories)) def getPageTitle(self, request): status = self.getStatus(request) p = status.getTitle() if p: return "BuildBot: %s" % p else: return "BuildBot" def getChangeManager(self, request): # TODO: this wants to go away, access it through IStatus return request.site.buildbot_service.getChangeSvc() def get_reload_time(self, request): if "reload" in request.args: try: reload_time = int(request.args["reload"][0]) return max(reload_time, 15) except ValueError: pass return None def isSuccess(self, builderStatus): # Helper function to return True if the builder is not failing. # The function will return false if the current state is "offline", # the last build was not successful, or if a step from the current # build(s) failed. # Make sure the builder is online. if builderStatus.getState()[0] == 'offline': return False # Look at the last finished build to see if it was success or not. lastBuild = builderStatus.getLastFinishedBuild() if lastBuild and lastBuild.getResults() != builder.SUCCESS: return False # Check all the current builds to see if one step is already # failing. currentBuilds = builderStatus.getCurrentBuilds() if currentBuilds: for build in currentBuilds: for step in build.getSteps(): if step.getResults()[0] == builder.FAILURE: return False # The last finished build was successful, and all the current builds # don't have any failed steps. return True def content(self, request, ctx): status = self.getStatus(request) master = request.site.buildbot_service.master # before calling content_with_db_data, make a bunch of database # queries. This is a sick hack, but beats rewriting the entire # waterfall around asynchronous calls results = {} # recent changes changes_d = master.db.changes.getRecentChanges(40) def to_changes(chdicts): return defer.gatherResults([ changes.Change.fromChdict(master, chdict) for chdict in chdicts ]) changes_d.addCallback(to_changes) def keep_changes(changes): results['changes'] = changes changes_d.addCallback(keep_changes) # build request counts for each builder allBuilderNames = status.getBuilderNames(categories=self.categories) brstatus_ds = [] brcounts = {} def keep_count(statuses, builderName): brcounts[builderName] = len(statuses) for builderName in allBuilderNames: builder_status = status.getBuilder(builderName) d = builder_status.getPendingBuildRequestStatuses() d.addCallback(keep_count, builderName) brstatus_ds.append(d) # wait for it all to finish d = defer.gatherResults([ changes_d ] + brstatus_ds) def call_content(_): return self.content_with_db_data(results['changes'], brcounts, request, ctx) d.addCallback(call_content) return d def content_with_db_data(self, changes, brcounts, request, ctx): status = self.getStatus(request) ctx['refresh'] = self.get_reload_time(request) # we start with all Builders available to this Waterfall: this is # limited by the config-file -time categories= argument, and defaults # to all defined Builders. allBuilderNames = status.getBuilderNames(categories=self.categories) builders = [status.getBuilder(name) for name in allBuilderNames] # but if the URL has one or more builder= arguments (or the old show= # argument, which is still accepted for backwards compatibility), we # use that set of builders instead. We still don't show anything # outside the config-file time set limited by categories=. showBuilders = request.args.get("show", []) showBuilders.extend(request.args.get("builder", [])) if showBuilders: builders = [b for b in builders if b.name in showBuilders] # now, if the URL has one or category= arguments, use them as a # filter: only show those builders which belong to one of the given # categories. showCategories = request.args.get("category", []) if showCategories: builders = [b for b in builders if b.category in showCategories] # If the URL has the failures_only=true argument, we remove all the # builders that are not currently red or won't be turning red at the end # of their current run. failuresOnly = request.args.get("failures_only", ["false"])[0] if failuresOnly.lower() == "true": builders = [b for b in builders if not self.isSuccess(b)] (changeNames, builderNames, timestamps, eventGrid, sourceEvents) = \ self.buildGrid(request, builders, changes) # start the table: top-header material locale_enc = locale.getdefaultlocale()[1] if locale_enc is not None: locale_tz = unicode(time.tzname[time.localtime()[-1]], locale_enc) else: locale_tz = unicode(time.tzname[time.localtime()[-1]]) ctx['tz'] = locale_tz ctx['changes_url'] = request.childLink("../changes") bn = ctx['builders'] = [] for name in builderNames: builder = status.getBuilder(name) top_box = ITopBox(builder).getBox(request) current_box = ICurrentBox(builder).getBox(status, brcounts) bn.append({'name': name, 'url': request.childLink("../builders/%s" % urllib.quote(name, safe='')), 'top': top_box.text, 'top_class': top_box.class_, 'status': current_box.text, 'status_class': current_box.class_, }) ctx.update(self.phase2(request, changeNames + builderNames, timestamps, eventGrid, sourceEvents)) def with_args(req, remove_args=[], new_args=[], new_path=None): # sigh, nevow makes this sort of manipulation easier newargs = req.args.copy() for argname in remove_args: newargs[argname] = [] if "branch" in newargs: newargs["branch"] = [b for b in newargs["branch"] if b] for k,v in new_args: if k in newargs: newargs[k].append(v) else: newargs[k] = [v] newquery = "&".join(["%s=%s" % (urllib.quote(k), urllib.quote(v)) for k in newargs for v in newargs[k] ]) if new_path: new_url = new_path elif req.prepath: new_url = req.prepath[-1] else: new_url = '' if newquery: new_url += "?" + newquery return new_url if timestamps: bottom = timestamps[-1] ctx['nextpage'] = with_args(request, ["last_time"], [("last_time", str(int(bottom)))]) helpurl = path_to_root(request) + "waterfall/help" ctx['help_url'] = with_args(request, new_path=helpurl) if self.get_reload_time(request) is not None: ctx['no_reload_page'] = with_args(request, remove_args=["reload"]) template = request.site.buildbot_service.templates.get_template("waterfall.html") data = template.render(**ctx) return data def buildGrid(self, request, builders, changes): debug = False # TODO: see if we can use a cached copy showEvents = False if request.args.get("show_events", ["false"])[0].lower() == "true": showEvents = True filterCategories = request.args.get('category', []) filterBranches = [b for b in request.args.get("branch", []) if b] filterBranches = map_branches(filterBranches) filterCommitters = [c for c in request.args.get("committer", []) if c] maxTime = int(request.args.get("last_time", [util.now()])[0]) if "show_time" in request.args: minTime = maxTime - int(request.args["show_time"][0]) elif "first_time" in request.args: minTime = int(request.args["first_time"][0]) elif filterBranches or filterCommitters: minTime = util.now() - 24 * 60 * 60 else: minTime = 0 spanLength = 10 # ten-second chunks req_events=int(request.args.get("num_events", [self.num_events])[0]) if self.num_events_max and req_events > self.num_events_max: maxPageLen = self.num_events_max else: maxPageLen = req_events # first step is to walk backwards in time, asking each column # (commit, all builders) if they have any events there. Build up the # array of events, and stop when we have a reasonable number. commit_source = ChangeEventSource(changes) lastEventTime = util.now() sources = [commit_source] + builders changeNames = ["changes"] builderNames = map(lambda builder: builder.getName(), builders) sourceNames = changeNames + builderNames sourceEvents = [] sourceGenerators = [] def get_event_from(g): try: while True: e = g.next() # e might be buildstep.BuildStepStatus, # builder.BuildStatus, builder.Event, # waterfall.Spacer(builder.Event), or changes.Change . # The showEvents=False flag means we should hide # builder.Event . if not showEvents and isinstance(e, builder.Event): continue if isinstance(e, buildstep.BuildStepStatus): # unfinished steps are always shown if e.isFinished() and e.isHidden(): continue break event = interfaces.IStatusEvent(e) if debug: log.msg("gen %s gave1 %s" % (g, event.getText())) except StopIteration: event = None return event for s in sources: gen = insertGaps(s.eventGenerator(filterBranches, filterCategories, filterCommitters, minTime), showEvents, lastEventTime) sourceGenerators.append(gen) # get the first event sourceEvents.append(get_event_from(gen)) eventGrid = [] timestamps = [] lastEventTime = 0 for e in sourceEvents: if e and e.getTimes()[0] > lastEventTime: lastEventTime = e.getTimes()[0] if lastEventTime == 0: lastEventTime = util.now() spanStart = lastEventTime - spanLength debugGather = 0 while 1: if debugGather: log.msg("checking (%s,]" % spanStart) # the tableau of potential events is in sourceEvents[]. The # window crawls backwards, and we examine one source at a time. # If the source's top-most event is in the window, is it pushed # onto the events[] array and the tableau is refilled. This # continues until the tableau event is not in the window (or is # missing). spanEvents = [] # for all sources, in this span. row of eventGrid firstTimestamp = None # timestamp of first event in the span lastTimestamp = None # last pre-span event, for next span for c in range(len(sourceGenerators)): events = [] # for this source, in this span. cell of eventGrid event = sourceEvents[c] while event and spanStart < event.getTimes()[0]: # to look at windows that don't end with the present, # condition the .append on event.time <= spanFinish if not IBox(event, None): log.msg("BAD EVENT", event, event.getText()) assert 0 if debug: log.msg("pushing", event.getText(), event) events.append(event) starts, finishes = event.getTimes() firstTimestamp = earlier(firstTimestamp, starts) event = get_event_from(sourceGenerators[c]) if debug: log.msg("finished span") if event: # this is the last pre-span event for this source lastTimestamp = later(lastTimestamp, event.getTimes()[0]) if debugGather: log.msg(" got %s from %s" % (events, sourceNames[c])) sourceEvents[c] = event # refill the tableau spanEvents.append(events) # only show events older than maxTime. This makes it possible to # visit a page that shows what it would be like to scroll off the # bottom of this one. if firstTimestamp is not None and firstTimestamp <= maxTime: eventGrid.append(spanEvents) timestamps.append(firstTimestamp) if lastTimestamp: spanStart = lastTimestamp - spanLength else: # no more events break if minTime is not None and lastTimestamp < minTime: break if len(timestamps) > maxPageLen: break # now loop # loop is finished. now we have eventGrid[] and timestamps[] if debugGather: log.msg("finished loop") assert(len(timestamps) == len(eventGrid)) return (changeNames, builderNames, timestamps, eventGrid, sourceEvents) def phase2(self, request, sourceNames, timestamps, eventGrid, sourceEvents): if not timestamps: return dict(grid=[], gridlen=0) # first pass: figure out the height of the chunks, populate grid grid = [] for i in range(1+len(sourceNames)): grid.append([]) # grid is a list of columns, one for the timestamps, and one per # event source. Each column is exactly the same height. Each element # of the list is a single box. lastDate = time.strftime("%d %b %Y", time.localtime(util.now())) for r in range(0, len(timestamps)): chunkstrip = eventGrid[r] # chunkstrip is a horizontal strip of event blocks. Each block # is a vertical list of events, all for the same source. assert(len(chunkstrip) == len(sourceNames)) maxRows = reduce(lambda x,y: max(x,y), map(lambda x: len(x), chunkstrip)) for i in range(maxRows): if i != maxRows-1: grid[0].append(None) else: # timestamp goes at the bottom of the chunk stuff = [] # add the date at the beginning (if it is not the same as # today's date), and each time it changes todayday = time.strftime("%a", time.localtime(timestamps[r])) today = time.strftime("%d %b %Y", time.localtime(timestamps[r])) if today != lastDate: stuff.append(todayday) stuff.append(today) lastDate = today stuff.append( time.strftime("%H:%M:%S", time.localtime(timestamps[r]))) grid[0].append(Box(text=stuff, class_="Time", valign="bottom", align="center")) # at this point the timestamp column has been populated with # maxRows boxes, most None but the last one has the time string for c in range(0, len(chunkstrip)): block = chunkstrip[c] assert(block != None) # should be [] instead for i in range(maxRows - len(block)): # fill top of chunk with blank space grid[c+1].append(None) for i in range(len(block)): # so the events are bottom-justified b = IBox(block[i]).getBox(request) b.parms['valign'] = "top" b.parms['align'] = "center" grid[c+1].append(b) # now all the other columns have maxRows new boxes too # populate the last row, if empty gridlen = len(grid[0]) for i in range(len(grid)): strip = grid[i] assert(len(strip) == gridlen) if strip[-1] == None: if sourceEvents[i-1]: filler = IBox(sourceEvents[i-1]).getBox(request) else: # this can happen if you delete part of the build history filler = Box(text=["?"], align="center") strip[-1] = filler strip[-1].parms['rowspan'] = 1 # second pass: bubble the events upwards to un-occupied locations # Every square of the grid that has a None in it needs to have # something else take its place. noBubble = request.args.get("nobubble",['0']) noBubble = int(noBubble[0]) if not noBubble: for col in range(len(grid)): strip = grid[col] if col == 1: # changes are handled differently for i in range(2, len(strip)+1): # only merge empty boxes. Don't bubble commit boxes. if strip[-i] == None: next = strip[-i+1] assert(next) if next: #if not next.event: if next.spacer: # bubble the empty box up strip[-i] = next strip[-i].parms['rowspan'] += 1 strip[-i+1] = None else: # we are above a commit box. Leave it # be, and turn the current box into an # empty one strip[-i] = Box([], rowspan=1, comment="commit bubble") strip[-i].spacer = True else: # we are above another empty box, which # somehow wasn't already converted. # Shouldn't happen pass else: for i in range(2, len(strip)+1): # strip[-i] will go from next-to-last back to first if strip[-i] == None: # bubble previous item up assert(strip[-i+1] != None) strip[-i] = strip[-i+1] strip[-i].parms['rowspan'] += 1 strip[-i+1] = None else: strip[-i].parms['rowspan'] = 1 # convert to dicts for i in range(gridlen): for strip in grid: if strip[i]: strip[i] = strip[i].td() return dict(grid=grid, gridlen=gridlen, no_bubble=noBubble, time=lastDate) buildbot-0.8.8/buildbot/status/words.py000066400000000000000000001172241222546025000201770ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import re, shlex, random from string import join, capitalize, lower from zope.interface import implements from twisted.internet import protocol, reactor from twisted.words.protocols import irc from twisted.python import usage, log from twisted.application import internet from twisted.internet import defer, task from buildbot import config, interfaces, util from buildbot import version from buildbot.interfaces import IStatusReceiver from buildbot.sourcestamp import SourceStamp from buildbot.status import base from buildbot.status.results import SUCCESS, WARNINGS, FAILURE, EXCEPTION, RETRY from buildbot.process.properties import Properties # twisted.internet.ssl requires PyOpenSSL, so be resilient if it's missing try: from twisted.internet import ssl have_ssl = True except ImportError: have_ssl = False def maybeColorize(text, color, useColors): irc_colors = [ 'WHITE', 'BLACK', 'NAVY_BLUE', 'GREEN', 'RED', 'BROWN', 'PURPLE', 'OLIVE', 'YELLOW', 'LIME_GREEN', 'TEAL', 'AQUA_LIGHT', 'ROYAL_BLUE', 'HOT_PINK', 'DARK_GRAY', 'LIGHT_GRAY' ] if useColors: return "%c%d%s%c" % (3, irc_colors.index(color), text, 3) else: return text class UsageError(ValueError): def __init__(self, string = "Invalid usage", *more): ValueError.__init__(self, string, *more) class ForceOptions(usage.Options): optParameters = [ ["builder", None, None, "which Builder to start"], ["branch", None, None, "which branch to build"], ["revision", None, None, "which revision to build"], ["reason", None, None, "the reason for starting the build"], ["props", None, None, "A set of properties made available in the build environment, " "format is --properties=prop1=value1,prop2=value2,.. " "option can be specified multiple times."], ] def parseArgs(self, *args): args = list(args) if len(args) > 0: if self['builder'] is not None: raise UsageError("--builder provided in two ways") self['builder'] = args.pop(0) if len(args) > 0: if self['reason'] is not None: raise UsageError("--reason provided in two ways") self['reason'] = " ".join(args) class IrcBuildRequest: hasStarted = False timer = None def __init__(self, parent, useRevisions=False, useColors=True): self.parent = parent self.useRevisions = useRevisions self.useColors = useColors self.timer = reactor.callLater(5, self.soon) def soon(self): del self.timer if not self.hasStarted: self.parent.send("The build has been queued, I'll give a shout" " when it starts") def started(self, s): self.hasStarted = True if self.timer: self.timer.cancel() del self.timer eta = s.getETA() if self.useRevisions: response = "build containing revision(s) [%s] forced" % s.getRevisions() else: response = "build #%d forced" % s.getNumber() if eta is not None: response = "build forced [ETA %s]" % self.parent.convertTime(eta) self.parent.send(response) self.parent.send("I'll give a shout when the build finishes") d = s.waitUntilFinished() d.addCallback(self.parent.watchedBuildFinished) class IRCContact(base.StatusReceiver): implements(IStatusReceiver) """I hold the state for a single user's interaction with the buildbot. There will be one instance of me for each user who interacts personally with the buildbot. There will be an additional instance for each 'broadcast contact' (chat rooms, IRC channels as a whole). """ def __init__(self, bot, dest): self.bot = bot self.master = bot.master self.notify_events = {} self.subscribed = 0 self.muted = False self.useRevisions = bot.useRevisions self.useColors = bot.useColors self.reported_builds = [] # tuples (when, buildername, buildnum) self.add_notification_events(bot.notify_events) # when people send us public messages ("buildbot: command"), # self.dest is the name of the channel ("#twisted"). When they send # us private messages (/msg buildbot command), self.dest is their # username. self.dest = dest # silliness silly = { "What happen ?": [ "Somebody set up us the bomb." ], "It's You !!": ["How are you gentlemen !!", "All your base are belong to us.", "You are on the way to destruction."], "What you say !!": ["You have no chance to survive make your time.", "HA HA HA HA ...."], } def doSilly(self, message): response = self.silly[message] when = 0.5 for r in response: reactor.callLater(when, self.send, r) when += 2.5 def getBuilder(self, which): try: b = self.bot.status.getBuilder(which) except KeyError: raise UsageError, "no such builder '%s'" % which return b def getControl(self, which): if not self.bot.control: raise UsageError("builder control is not enabled") try: bc = self.bot.control.getBuilder(which) except KeyError: raise UsageError("no such builder '%s'" % which) return bc def getAllBuilders(self): """ @rtype: list of L{buildbot.process.builder.Builder} """ names = self.bot.status.getBuilderNames(categories=self.bot.categories) names.sort() builders = [self.bot.status.getBuilder(n) for n in names] return builders def convertTime(self, seconds): if seconds < 60: return "%d seconds" % seconds minutes = int(seconds / 60) seconds = seconds - 60*minutes if minutes < 60: return "%dm%02ds" % (minutes, seconds) hours = int(minutes / 60) minutes = minutes - 60*hours return "%dh%02dm%02ds" % (hours, minutes, seconds) def reportBuild(self, builder, buildnum): """Returns True if this build should be reported for this contact (eliminating duplicates), and also records the report for later""" for w, b, n in self.reported_builds: if b == builder and n == buildnum: return False self.reported_builds.append([util.now(), builder, buildnum]) # clean the reported builds horizon = util.now() - 60 while self.reported_builds and self.reported_builds[0][0] < horizon: self.reported_builds.pop(0) # and return True, since this is a new one return True def splitArgs(self, args): """Returns list of arguments parsed by shlex.split() or raise UsageError if failed""" try: return shlex.split(args) except ValueError, e: raise UsageError(e) def command_HELLO(self, args, who): self.send("yes?") def command_VERSION(self, args, who): self.send("buildbot-%s at your service" % version) def command_LIST(self, args, who): args = self.splitArgs(args) if len(args) == 0: raise UsageError, "try 'list builders'" if args[0] == 'builders': builders = self.getAllBuilders() str = "Configured builders: " for b in builders: str += b.name state = b.getState()[0] if state == 'offline': str += "[offline]" str += " " str.rstrip() self.send(str) return command_LIST.usage = "list builders - List configured builders" def command_STATUS(self, args, who): args = self.splitArgs(args) if len(args) == 0: which = "all" elif len(args) == 1: which = args[0] else: raise UsageError, "try 'status '" if which == "all": builders = self.getAllBuilders() for b in builders: self.emit_status(b.name) return self.emit_status(which) command_STATUS.usage = "status [] - List status of a builder (or all builders)" def validate_notification_event(self, event): if not re.compile("^(started|finished|success|failure|exception|warnings|(success|warnings|exception|failure)To(Failure|Success|Warnings|Exception))$").match(event): raise UsageError("try 'notify on|off '") def list_notified_events(self): self.send( "The following events are being notified: %r" % self.notify_events.keys() ) def notify_for(self, *events): for event in events: if self.notify_events.has_key(event): return 1 return 0 def subscribe_to_build_events(self): self.bot.status.subscribe(self) self.subscribed = 1 def unsubscribe_from_build_events(self): self.bot.status.unsubscribe(self) self.subscribed = 0 def add_notification_events(self, events): for event in events: self.validate_notification_event(event) self.notify_events[event] = 1 if not self.subscribed: self.subscribe_to_build_events() def remove_notification_events(self, events): for event in events: self.validate_notification_event(event) del self.notify_events[event] if len(self.notify_events) == 0 and self.subscribed: self.unsubscribe_from_build_events() def remove_all_notification_events(self): self.notify_events = {} if self.subscribed: self.unsubscribe_from_build_events() def command_NOTIFY(self, args, who): args = self.splitArgs(args) if not args: raise UsageError("try 'notify on|off|list '") action = args.pop(0) events = args if action == "on": if not events: events = ('started','finished') self.add_notification_events(events) self.list_notified_events() elif action == "off": if events: self.remove_notification_events(events) else: self.remove_all_notification_events() self.list_notified_events() elif action == "list": self.list_notified_events() return else: raise UsageError("try 'notify on|off '") command_NOTIFY.usage = "notify on|off|list [] ... - Notify me about build events. event should be one or more of: 'started', 'finished', 'failure', 'success', 'exception' or 'xToY' (where x and Y are one of success, warnings, failure, exception, but Y is capitalized)" def command_WATCH(self, args, who): args = self.splitArgs(args) if len(args) != 1: raise UsageError("try 'watch '") which = args[0] b = self.getBuilder(which) builds = b.getCurrentBuilds() if not builds: self.send("there are no builds currently running") return for build in builds: assert not build.isFinished() d = build.waitUntilFinished() d.addCallback(self.watchedBuildFinished) if self.useRevisions: r = "watching build %s containing revision(s) [%s] until it finishes" \ % (which, build.getRevisions()) else: r = "watching build %s #%d until it finishes" \ % (which, build.getNumber()) eta = build.getETA() if eta is not None: r += " [%s]" % self.convertTime(eta) r += ".." self.send(r) command_WATCH.usage = "watch - announce the completion of an active build" def builderAdded(self, builderName, builder): if (self.bot.categories != None and builder.category not in self.bot.categories): return log.msg('[Contact] Builder %s added' % (builder)) builder.subscribe(self) def builderRemoved(self, builderName): log.msg('[Contact] Builder %s removed' % (builderName)) def buildStarted(self, builderName, build): builder = build.getBuilder() log.msg('[Contact] Builder %r in category %s started' % (builder, builder.category)) # only notify about builders we are interested in if (self.bot.categories != None and builder.category not in self.bot.categories): log.msg('Not notifying for a build in the wrong category') return if not self.notify_for('started'): return if self.useRevisions: r = "build containing revision(s) [%s] on %s started" % \ (build.getRevisions(), builder.getName()) else: r = "build #%d of %s started, including [%s]" % \ (build.getNumber(), builder.getName(), ", ".join([str(c.revision) for c in build.getChanges()]) ) self.send(r) results_descriptions = { SUCCESS: ("Success", 'GREEN'), WARNINGS: ("Warnings", 'YELLOW'), FAILURE: ("Failure", 'RED'), EXCEPTION: ("Exception", 'PURPLE'), RETRY: ("Retry", 'AQUA_LIGHT'), } def getResultsDescriptionAndColor(self, results): return self.results_descriptions.get(results, ("??",'RED')) def buildFinished(self, builderName, build, results): builder = build.getBuilder() if (self.bot.categories != None and builder.category not in self.bot.categories): return if not self.notify_for_finished(build): return builder_name = builder.getName() buildnum = build.getNumber() buildrevs = build.getRevisions() results = self.getResultsDescriptionAndColor(build.getResults()) if self.reportBuild(builder_name, buildnum): if self.useRevisions: r = "build containing revision(s) [%s] on %s is complete: %s" % \ (buildrevs, builder_name, results[0]) else: r = "build #%d of %s is complete: %s" % \ (buildnum, builder_name, results[0]) r += ' [%s]' % maybeColorize(" ".join(build.getText()), results[1], self.useColors) buildurl = self.bot.status.getURLForThing(build) if buildurl: r += " Build details are at %s" % buildurl if self.bot.showBlameList and build.getResults() != SUCCESS and len(build.changes) != 0: r += ' blamelist: ' + ', '.join(list(set([c.who for c in build.changes]))) self.send(r) def notify_for_finished(self, build): results = build.getResults() if self.notify_for('finished'): return True if self.notify_for(lower(self.results_descriptions.get(results)[0])): return True prevBuild = build.getPreviousBuild() if prevBuild: prevResult = prevBuild.getResults() required_notification_control_string = join((lower(self.results_descriptions.get(prevResult)[0]), \ 'To', \ capitalize(self.results_descriptions.get(results)[0])), \ '') if (self.notify_for(required_notification_control_string)): return True return False def watchedBuildFinished(self, b): # only notify about builders we are interested in builder = b.getBuilder() if (self.bot.categories != None and builder.category not in self.bot.categories): return builder_name = builder.getName() buildnum = b.getNumber() buildrevs = b.getRevisions() results = self.getResultsDescriptionAndColor(b.getResults()) if self.reportBuild(builder_name, buildnum): if self.useRevisions: r = "Hey! build %s containing revision(s) [%s] is complete: %s" % \ (builder_name, buildrevs, results[0]) else: r = "Hey! build %s #%d is complete: %s" % \ (builder_name, buildnum, results[0]) r += ' [%s]' % maybeColorize(" ".join(b.getText()), results[1], self.useColors) self.send(r) buildurl = self.bot.status.getURLForThing(b) if buildurl: self.send("Build details are at %s" % buildurl) def command_FORCE(self, args, who): errReply = "try 'force build [--branch=BRANCH] [--revision=REVISION] [--props=PROP1=VAL1,PROP2=VAL2...] '" args = self.splitArgs(args) if not args: raise UsageError(errReply) what = args.pop(0) if what != "build": raise UsageError(errReply) opts = ForceOptions() opts.parseOptions(args) which = opts['builder'] branch = opts['branch'] revision = opts['revision'] reason = opts['reason'] props = opts['props'] if which is None: raise UsageError("you must provide a Builder, " + errReply) # keep weird stuff out of the branch, revision, and properties args. branch_validate = self.master.config.validation['branch'] revision_validate = self.master.config.validation['revision'] pname_validate = self.master.config.validation['property_name'] pval_validate = self.master.config.validation['property_value'] if branch and not branch_validate.match(branch): log.msg("bad branch '%s'" % branch) self.send("sorry, bad branch '%s'" % branch) return if revision and not revision_validate.match(revision): log.msg("bad revision '%s'" % revision) self.send("sorry, bad revision '%s'" % revision) return properties = Properties() if props: # split props into name:value dict pdict = {} propertylist = props.split(",") for i in range(0,len(propertylist)): splitproperty = propertylist[i].split("=", 1) pdict[splitproperty[0]] = splitproperty[1] # set properties for prop in pdict: pname = prop pvalue = pdict[prop] if not pname_validate.match(pname) \ or not pval_validate.match(pvalue): log.msg("bad property name='%s', value='%s'" % (pname, pvalue)) self.send("sorry, bad property name='%s', value='%s'" % (pname, pvalue)) return properties.setProperty(pname, pvalue, "Force Build IRC") bc = self.getControl(which) reason = "forced: by %s: %s" % (self.describeUser(who), reason) ss = SourceStamp(branch=branch, revision=revision) d = bc.submitBuildRequest(ss, reason, props=properties.asDict()) def subscribe(buildreq): ireq = IrcBuildRequest(self, self.useRevisions) buildreq.subscribe(ireq.started) d.addCallback(subscribe) d.addErrback(log.err, "while forcing a build") command_FORCE.usage = "force build [--branch=branch] [--revision=revision] [--props=prop1=val1,prop2=val2...] - Force a build" def command_STOP(self, args, who): args = self.splitArgs(args) if len(args) < 3 or args[0] != 'build': raise UsageError, "try 'stop build WHICH '" which = args[1] reason = args[2] buildercontrol = self.getControl(which) r = "stopped: by %s: %s" % (self.describeUser(who), reason) # find an in-progress build builderstatus = self.getBuilder(which) builds = builderstatus.getCurrentBuilds() if not builds: self.send("sorry, no build is currently running") return for build in builds: num = build.getNumber() revs = build.getRevisions() # obtain the BuildControl object buildcontrol = buildercontrol.getBuild(num) # make it stop buildcontrol.stopBuild(r) if self.useRevisions: response = "build containing revision(s) [%s] interrupted" % revs else: response = "build %d interrupted" % num self.send(response) command_STOP.usage = "stop build - Stop a running build" def emit_status(self, which): b = self.getBuilder(which) str = "%s: " % which state, builds = b.getState() str += state if state == "idle": last = b.getLastFinishedBuild() if last: start,finished = last.getTimes() str += ", last build %s ago: %s" % \ (self.convertTime(int(util.now() - finished)), " ".join(last.getText())) if state == "building": t = [] for build in builds: step = build.getCurrentStep() if step: s = "(%s)" % " ".join(step.getText()) else: s = "(no current step)" ETA = build.getETA() if ETA is not None: s += " [ETA %s]" % self.convertTime(ETA) t.append(s) str += ", ".join(t) self.send(str) def command_LAST(self, args, who): args = self.splitArgs(args) if len(args) == 0: which = "all" elif len(args) == 1: which = args[0] else: raise UsageError, "try 'last '" def emit_last(which): last = self.getBuilder(which).getLastFinishedBuild() if not last: str = "(no builds run since last restart)" else: start,finish = last.getTimes() str = "%s ago: " % (self.convertTime(int(util.now() - finish))) str += " ".join(last.getText()) self.send("last build [%s]: %s" % (which, str)) if which == "all": builders = self.getAllBuilders() for b in builders: emit_last(b.name) return emit_last(which) command_LAST.usage = "last - list last build status for builder " def build_commands(self): commands = [] for k in dir(self): if k.startswith('command_'): commands.append(k[8:].lower()) commands.sort() return commands def describeUser(self, user): if self.dest[0] == '#': return "IRC user <%s> on channel %s" % (user, self.dest) return "IRC user <%s> (privmsg)" % user # commands def command_MUTE(self, args, who): # The order of these is important! ;) self.send("Shutting up for now.") self.muted = True command_MUTE.usage = "mute - suppress all messages until a corresponding 'unmute' is issued" def command_UNMUTE(self, args, who): if self.muted: # The order of these is important! ;) self.muted = False self.send("I'm baaaaaaaaaaack!") else: self.send("You hadn't told me to be quiet, but it's the thought that counts, right?") command_UNMUTE.usage = "unmute - disable a previous 'mute'" def command_HELP(self, args, who): args = self.splitArgs(args) if len(args) == 0: self.send("Get help on what? (try 'help ', 'help , " "or 'commands' for a command list)") return command = args[0] meth = self.getCommandMethod(command) if not meth: raise UsageError, "no such command '%s'" % command usage = getattr(meth, 'usage', None) if isinstance(usage, dict): if len(args) == 1: k = None # command elif len(args) == 2: k = args[1] # command arg else: k = tuple(args[1:]) # command arg subarg ... usage = usage.get(k, None) if usage: self.send("Usage: %s" % usage) else: self.send("No usage info for " + ' '.join(["'%s'" % arg for arg in args])) command_HELP.usage = "help [ [ ...]] - Give help for or one of it's arguments" def command_SOURCE(self, args, who): self.send("My source can be found at " "https://github.com/buildbot/buildbot") command_SOURCE.usage = "source - the source code for Buildbot" def command_COMMANDS(self, args, who): commands = self.build_commands() str = "buildbot commands: " + ", ".join(commands) self.send(str) command_COMMANDS.usage = "commands - List available commands" def command_DESTROY(self, args, who): if self.bot.nickname not in args: self.act("readies phasers") def command_DANCE(self, args, who): reactor.callLater(1.0, self.send, "<(^.^<)") reactor.callLater(2.0, self.send, "<(^.^)>") reactor.callLater(3.0, self.send, "(>^.^)>") reactor.callLater(3.5, self.send, "(7^.^)7") reactor.callLater(5.0, self.send, "(>^.^<)") def command_SHUTDOWN(self, args, who): if args not in ('check','start','stop','now'): raise UsageError("try 'shutdown check|start|stop|now'") if not self.bot.factory.allowShutdown: raise UsageError("shutdown control is not enabled") botmaster = self.master.botmaster shuttingDown = botmaster.shuttingDown if args == 'check': if shuttingDown: self.send("Status: buildbot is shutting down") else: self.send("Status: buildbot is running") elif args == 'start': if shuttingDown: self.send("Already started") else: self.send("Starting clean shutdown") botmaster.cleanShutdown() elif args == 'stop': if not shuttingDown: self.send("Nothing to stop") else: self.send("Stopping clean shutdown") botmaster.cancelCleanShutdown() elif args == 'now': self.send("Stopping buildbot") reactor.stop() command_SHUTDOWN.usage = { None: "shutdown check|start|stop|now - shutdown the buildbot master", "check": "shutdown check - check if the buildbot master is running or shutting down", "start": "shutdown start - start a clean shutdown", "stop": "shutdown cancel - stop the clean shutdown", "now": "shutdown now - shutdown immediately without waiting for the builders to finish"} # communication with the user def send(self, message): if not self.muted: self.bot.msgOrNotice(self.dest, message.encode("ascii", "replace")) def act(self, action): if not self.muted: self.bot.describe(self.dest, action.encode("ascii", "replace")) # main dispatchers for incoming messages def getCommandMethod(self, command): return getattr(self, 'command_' + command.upper(), None) def handleMessage(self, message, who): # a message has arrived from 'who'. For broadcast contacts (i.e. when # people do an irc 'buildbot: command'), this will be a string # describing the sender of the message in some useful-to-log way, and # a single Contact may see messages from a variety of users. For # unicast contacts (i.e. when people do an irc '/msg buildbot # command'), a single Contact will only ever see messages from a # single user. message = message.lstrip() if self.silly.has_key(message): self.doSilly(message) return defer.succeed(None) parts = message.split(' ', 1) if len(parts) == 1: parts = parts + [''] cmd, args = parts log.msg("irc command", cmd) meth = self.getCommandMethod(cmd) if not meth and message[-1] == '!': self.send("What you say!") return defer.succeed(None) if meth: d = defer.maybeDeferred(meth, args.strip(), who) @d.addErrback def usageError(f): f.trap(UsageError) self.send(str(f.value)) @d.addErrback def logErr(f): log.err(f) self.send("Something bad happened (see logs)") d.addErrback(log.err) return d return defer.succeed(None) def handleAction(self, data, user): # this is sent when somebody performs an action that mentions the # buildbot (like '/me kicks buildbot'). 'user' is the name/nick/id of # the person who performed the action, so if their action provokes a # response, they can be named. This is 100% silly. if not data.endswith("s "+ self.bot.nickname): return words = data.split() verb = words[-2] if verb == "kicks": response = "%s back" % verb else: response = "%s %s too" % (verb, user) self.act(response) class IrcStatusBot(irc.IRCClient): """I represent the buildbot to an IRC server. """ contactClass = IRCContact def __init__(self, nickname, password, channels, pm_to_nicks, status, categories, notify_events, noticeOnChannel=False, useRevisions=False, showBlameList=False, useColors=True): self.nickname = nickname self.channels = channels self.pm_to_nicks = pm_to_nicks self.password = password self.status = status self.master = status.master self.categories = categories self.notify_events = notify_events self.hasQuit = 0 self.contacts = {} self.noticeOnChannel = noticeOnChannel self.useColors = useColors self.useRevisions = useRevisions self.showBlameList = showBlameList self._keepAliveCall = task.LoopingCall(lambda: self.ping(self.nickname)) def connectionMade(self): irc.IRCClient.connectionMade(self) self._keepAliveCall.start(60) def connectionLost(self, reason): if self._keepAliveCall.running: self._keepAliveCall.stop() irc.IRCClient.connectionLost(self, reason) def msgOrNotice(self, dest, message): if self.noticeOnChannel and dest[0] == '#': self.notice(dest, message) else: self.msg(dest, message) def getContact(self, name): name = name.lower() # nicknames and channel names are case insensitive if name in self.contacts: return self.contacts[name] new_contact = self.contactClass(self, name) self.contacts[name] = new_contact return new_contact def log(self, msg): log.msg("%s: %s" % (self, msg)) # the following irc.IRCClient methods are called when we have input def privmsg(self, user, channel, message): user = user.split('!', 1)[0] # rest is ~user@hostname # channel is '#twisted' or 'buildbot' (for private messages) if channel == self.nickname: # private message contact = self.getContact(user) contact.handleMessage(message, user) return # else it's a broadcast message, maybe for us, maybe not. 'channel' # is '#twisted' or the like. contact = self.getContact(channel) if message.startswith("%s:" % self.nickname) or message.startswith("%s," % self.nickname): message = message[len("%s:" % self.nickname):] contact.handleMessage(message, user) def action(self, user, channel, data): user = user.split('!', 1)[0] # rest is ~user@hostname # somebody did an action (/me actions) in the broadcast channel contact = self.getContact(channel) if self.nickname in data: contact.handleAction(data, user) def signedOn(self): if self.password: self.msg("Nickserv", "IDENTIFY " + self.password) for c in self.channels: if isinstance(c, dict): channel = c.get('channel', None) password = c.get('password', None) else: channel = c password = None self.join(channel=channel, key=password) for c in self.pm_to_nicks: self.getContact(c) def joined(self, channel): self.log("I have joined %s" % (channel,)) # trigger contact contructor, which in turn subscribes to notify events self.getContact(channel) def left(self, channel): self.log("I have left %s" % (channel,)) def kickedFrom(self, channel, kicker, message): self.log("I have been kicked from %s by %s: %s" % (channel, kicker, message)) class ThrottledClientFactory(protocol.ClientFactory): lostDelay = random.randint(1, 5) failedDelay = random.randint(45, 60) def __init__(self, lostDelay=None, failedDelay=None): if lostDelay is not None: self.lostDelay = lostDelay if failedDelay is not None: self.failedDelay = failedDelay def clientConnectionLost(self, connector, reason): reactor.callLater(self.lostDelay, connector.connect) def clientConnectionFailed(self, connector, reason): reactor.callLater(self.failedDelay, connector.connect) class IrcStatusFactory(ThrottledClientFactory): protocol = IrcStatusBot status = None control = None shuttingDown = False p = None def __init__(self, nickname, password, channels, pm_to_nicks, categories, notify_events, noticeOnChannel=False, useRevisions=False, showBlameList=False, lostDelay=None, failedDelay=None, useColors=True, allowShutdown=False): ThrottledClientFactory.__init__(self, lostDelay=lostDelay, failedDelay=failedDelay) self.status = None self.nickname = nickname self.password = password self.channels = channels self.pm_to_nicks = pm_to_nicks self.categories = categories self.notify_events = notify_events self.noticeOnChannel = noticeOnChannel self.useRevisions = useRevisions self.showBlameList = showBlameList self.useColors = useColors self.allowShutdown = allowShutdown def __getstate__(self): d = self.__dict__.copy() del d['p'] return d def shutdown(self): self.shuttingDown = True if self.p: self.p.quit("buildmaster reconfigured: bot disconnecting") def buildProtocol(self, address): p = self.protocol(self.nickname, self.password, self.channels, self.pm_to_nicks, self.status, self.categories, self.notify_events, noticeOnChannel = self.noticeOnChannel, useColors = self.useColors, useRevisions = self.useRevisions, showBlameList = self.showBlameList) p.factory = self p.status = self.status p.control = self.control self.p = p return p # TODO: I think a shutdown that occurs while the connection is being # established will make this explode def clientConnectionLost(self, connector, reason): if self.shuttingDown: log.msg("not scheduling reconnection attempt") return ThrottledClientFactory.clientConnectionLost(self, connector, reason) def clientConnectionFailed(self, connector, reason): if self.shuttingDown: log.msg("not scheduling reconnection attempt") return ThrottledClientFactory.clientConnectionFailed(self, connector, reason) class IRC(base.StatusReceiverMultiService): implements(IStatusReceiver) in_test_harness = False compare_attrs = ["host", "port", "nick", "password", "channels", "pm_to_nicks", "allowForce", "useSSL", "useRevisions", "categories", "useColors", "lostDelay", "failedDelay", "allowShutdown"] def __init__(self, host, nick, channels, pm_to_nicks=[], port=6667, allowForce=False, categories=None, password=None, notify_events={}, noticeOnChannel = False, showBlameList = True, useRevisions=False, useSSL=False, lostDelay=None, failedDelay=None, useColors=True, allowShutdown=False): base.StatusReceiverMultiService.__init__(self) if allowForce not in (True, False): config.error("allowForce must be boolean, not %r" % (allowForce,)) if allowShutdown not in (True, False): config.error("allowShutdown must be boolean, not %r" % (allowShutdown,)) # need to stash these so we can detect changes later self.host = host self.port = port self.nick = nick self.channels = channels self.pm_to_nicks = pm_to_nicks self.password = password self.allowForce = allowForce self.useRevisions = useRevisions self.categories = categories self.notify_events = notify_events self.allowShutdown = allowShutdown self.f = IrcStatusFactory(self.nick, self.password, self.channels, self.pm_to_nicks, self.categories, self.notify_events, noticeOnChannel = noticeOnChannel, useRevisions = useRevisions, showBlameList = showBlameList, lostDelay = lostDelay, failedDelay = failedDelay, useColors = useColors, allowShutdown = allowShutdown) if useSSL: # SSL client needs a ClientContextFactory for some SSL mumbo-jumbo if not have_ssl: raise RuntimeError("useSSL requires PyOpenSSL") cf = ssl.ClientContextFactory() c = internet.SSLClient(self.host, self.port, self.f, cf) else: c = internet.TCPClient(self.host, self.port, self.f) c.setServiceParent(self) def setServiceParent(self, parent): base.StatusReceiverMultiService.setServiceParent(self, parent) self.f.status = parent if self.allowForce: self.f.control = interfaces.IControl(self.master) def stopService(self): # make sure the factory will stop reconnecting self.f.shutdown() return base.StatusReceiverMultiService.stopService(self) buildbot-0.8.8/buildbot/steps/000077500000000000000000000000001222546025000162735ustar00rootroot00000000000000buildbot-0.8.8/buildbot/steps/__init__.py000066400000000000000000000000001222546025000203720ustar00rootroot00000000000000buildbot-0.8.8/buildbot/steps/master.py000066400000000000000000000176221222546025000201500ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os, types, re from twisted.python import runtime from twisted.internet import reactor from buildbot.process.buildstep import BuildStep from buildbot.process.buildstep import SUCCESS, FAILURE from twisted.internet import error from twisted.internet.protocol import ProcessProtocol import pprint class MasterShellCommand(BuildStep): """ Run a shell command locally - on the buildmaster. The shell command COMMAND is specified just as for a RemoteShellCommand. Note that extra logfiles are not supported. """ name='MasterShellCommand' description='Running' descriptionDone='Ran' descriptionSuffix = None renderables = [ 'command', 'env', 'description', 'descriptionDone', 'descriptionSuffix' ] haltOnFailure = True flunkOnFailure = True def __init__(self, command, description=None, descriptionDone=None, descriptionSuffix=None, env=None, path=None, usePTY=0, interruptSignal="KILL", **kwargs): BuildStep.__init__(self, **kwargs) self.command=command if description: self.description = description if isinstance(self.description, str): self.description = [self.description] if descriptionDone: self.descriptionDone = descriptionDone if isinstance(self.descriptionDone, str): self.descriptionDone = [self.descriptionDone] if descriptionSuffix: self.descriptionSuffix = descriptionSuffix if isinstance(self.descriptionSuffix, str): self.descriptionSuffix = [self.descriptionSuffix] self.env=env self.path=path self.usePTY=usePTY self.interruptSignal = interruptSignal class LocalPP(ProcessProtocol): def __init__(self, step): self.step = step def outReceived(self, data): self.step.stdio_log.addStdout(data) def errReceived(self, data): self.step.stdio_log.addStderr(data) def processEnded(self, status_object): if status_object.value.exitCode is not None: self.step.stdio_log.addHeader("exit status %d\n" % status_object.value.exitCode) if status_object.value.signal is not None: self.step.stdio_log.addHeader("signal %s\n" % status_object.value.signal) self.step.processEnded(status_object) def start(self): # render properties command = self.command # set up argv if type(command) in types.StringTypes: if runtime.platformType == 'win32': argv = os.environ['COMSPEC'].split() # allow %COMSPEC% to have args if '/c' not in argv: argv += ['/c'] argv += [command] else: # for posix, use /bin/sh. for other non-posix, well, doesn't # hurt to try argv = ['/bin/sh', '-c', command] else: if runtime.platformType == 'win32': argv = os.environ['COMSPEC'].split() # allow %COMSPEC% to have args if '/c' not in argv: argv += ['/c'] argv += list(command) else: argv = command self.stdio_log = stdio_log = self.addLog("stdio") if type(command) in types.StringTypes: stdio_log.addHeader(command.strip() + "\n\n") else: stdio_log.addHeader(" ".join(command) + "\n\n") stdio_log.addHeader("** RUNNING ON BUILDMASTER **\n") stdio_log.addHeader(" in dir %s\n" % os.getcwd()) stdio_log.addHeader(" argv: %s\n" % (argv,)) self.step_status.setText(self.describe()) if self.env is None: env = os.environ else: assert isinstance(self.env, dict) env = self.env for key, v in self.env.iteritems(): if isinstance(v, list): # Need to do os.pathsep translation. We could either do that # by replacing all incoming ':'s with os.pathsep, or by # accepting lists. I like lists better. # If it's not a string, treat it as a sequence to be # turned in to a string. self.env[key] = os.pathsep.join(self.env[key]) # do substitution on variable values matching pattern: ${name} p = re.compile('\${([0-9a-zA-Z_]*)}') def subst(match): return os.environ.get(match.group(1), "") newenv = {} for key, v in env.iteritems(): if v is not None: if not isinstance(v, basestring): raise RuntimeError("'env' values must be strings or " "lists; key '%s' is incorrect" % (key,)) newenv[key] = p.sub(subst, env[key]) env = newenv stdio_log.addHeader(" env: %r\n" % (env,)) # TODO add a timeout? self.process = reactor.spawnProcess(self.LocalPP(self), argv[0], argv, path=self.path, usePTY=self.usePTY, env=env ) # (the LocalPP object will call processEnded for us) def processEnded(self, status_object): if status_object.value.signal is not None: self.descriptionDone = ["killed (%s)" % status_object.value.signal] self.step_status.setText(self.describe(done=True)) self.finished(FAILURE) elif status_object.value.exitCode != 0: self.descriptionDone = ["failed (%d)" % status_object.value.exitCode] self.step_status.setText(self.describe(done=True)) self.finished(FAILURE) else: self.step_status.setText(self.describe(done=True)) self.finished(SUCCESS) def describe(self, done=False): desc = self.descriptionDone if done else self.description if self.descriptionSuffix: desc = desc[:] desc.extend(self.descriptionSuffix) return desc def interrupt(self, reason): try: self.process.signalProcess(self.interruptSignal) except KeyError: # Process not started yet pass except error.ProcessExitedAlready: pass BuildStep.interrupt(self, reason) class SetProperty(BuildStep): name='SetProperty' description=['Setting'] descriptionDone=['Set'] renderables = [ 'value' ] def __init__(self, property, value, **kwargs): BuildStep.__init__(self, **kwargs) self.property = property self.value = value def start(self): properties = self.build.getProperties() properties.setProperty(self.property, self.value, self.name, runtime=True) self.step_status.setText(self.describe(done=True)) self.finished(SUCCESS) class LogRenderable(BuildStep): name='LogRenderable' description=['Logging'] descriptionDone=['Logged'] renderables = ['content'] def __init__(self, content, **kwargs): BuildStep.__init__(self, **kwargs) self.content = content def start(self): content = pprint.pformat(self.content) self.addCompleteLog(name='Output', text=content) self.step_status.setText(self.describe(done=True)) self.finished(SUCCESS) buildbot-0.8.8/buildbot/steps/maxq.py000066400000000000000000000032611222546025000176150ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.steps.shell import ShellCommand from buildbot.status.results import SUCCESS, FAILURE from buildbot import config class MaxQ(ShellCommand): flunkOnFailure = True name = "maxq" def __init__(self, testdir=None, **kwargs): if not testdir: config.error("please pass testdir") kwargs['command'] = 'run_maxq.py %s' % (testdir,) ShellCommand.__init__(self, **kwargs) def commandComplete(self, cmd): output = cmd.logs['stdio'].getText() self.failures = output.count('\nTEST FAILURE:') def evaluateCommand(self, cmd): # treat a nonzero exit status as a failure, if no other failures are # detected if not self.failures and cmd.didFail(): self.failures = 1 if self.failures: return FAILURE return SUCCESS def getText(self, cmd, results): if self.failures: return [ str(self.failures), 'maxq', 'failures' ] return ['maxq', 'tests'] buildbot-0.8.8/buildbot/steps/package/000077500000000000000000000000001222546025000176665ustar00rootroot00000000000000buildbot-0.8.8/buildbot/steps/package/__init__.py000066400000000000000000000014771222546025000220100ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members # Portions Copyright Steve 'Ashcrow' Milner """ Steps specific to package formats. """ buildbot-0.8.8/buildbot/steps/package/deb/000077500000000000000000000000001222546025000204205ustar00rootroot00000000000000buildbot-0.8.8/buildbot/steps/package/deb/__init__.py000066400000000000000000000000001222546025000225170ustar00rootroot00000000000000buildbot-0.8.8/buildbot/steps/package/deb/lintian.py000066400000000000000000000054611222546025000224360ustar00rootroot00000000000000# 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members # Portions Copyright Marius Rieder """ Steps and objects related to lintian """ from buildbot.steps.shell import ShellCommand from buildbot.status.results import SUCCESS, WARNINGS, FAILURE from buildbot import config class DebLintian(ShellCommand): name = "lintian" description = ["Lintian running"] descriptionDone = ["Lintian"] fileloc = None suppressTags = [] warnCount = 0 errCount = 0 flunkOnFailure=False warnOnFailure=True def __init__(self, fileloc=None, suppressTags=None, **kwargs): """ Create the DebLintian object. @type fileloc: str @param fileloc: Location of the .deb or .changes to test. @type suppressTags: list @param suppressTags: List of tags to suppress. @type kwargs: dict @param kwargs: all other keyword arguments. """ ShellCommand.__init__(self, **kwargs) if fileloc: self.fileloc = fileloc if suppressTags: self.suppressTags = suppressTags if not self.fileloc: config.error("You must specify a fileloc") self.command = ["lintian", "-v", self.fileloc] if self.suppressTags: for tag in self.suppressTags: self.command += ['--suppress-tags', tag] def createSummary(self, log): """ Create nice summary logs. @param log: log to create summary off of. """ warnings = [] errors = [] for line in log.readlines(): if 'W: ' in line: warnings.append(line) elif 'E: ' in line: errors.append(line) if warnings: self.addCompleteLog('%d Warnings' % len(warnings), "".join(warnings)) self.warnCount = len(warnings) if errors: self.addCompleteLog('%d Errors' % len(errors), "".join(errors)) self.errCount = len(errors) def evaluateCommand(self, cmd): if ( cmd.rc != 0 or self.errCount): return FAILURE if self.warnCount: return WARNINGS return SUCCESS buildbot-0.8.8/buildbot/steps/package/deb/pbuilder.py000066400000000000000000000171031222546025000226020ustar00rootroot00000000000000# 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members # Portions Copyright Marius Rieder """ Steps and objects related to pbuilder """ import re import stat import time from twisted.python import log from buildbot.steps.shell import WarningCountingShellCommand from buildbot.process import buildstep from buildbot.process.buildstep import FAILURE from buildbot import config class DebPbuilder(WarningCountingShellCommand): """Build a debian package with pbuilder inside of a chroot.""" name = "pbuilder" haltOnFailure = 1 flunkOnFailure = 1 description = ["pdebuilding"] descriptionDone = ["pdebuild"] warningPattern = ".*(warning[: ]|\sW: ).*" architecture = None distribution = 'stable' basetgz = "/var/cache/pbuilder/%(distribution)s-%(architecture)s-buildbot.tgz" mirror = "http://cdn.debian.net/debian/" extrapackages = [] keyring = None components = None maxAge = 60*60*24*7 pbuilder = '/usr/sbin/pbuilder' baseOption = '--basetgz' def __init__(self, architecture=None, distribution=None, basetgz=None, mirror=None, extrapackages=None, keyring=None, components=None, **kwargs): """ Creates the DebPbuilder object. @type architecture: str @param architecture: the name of the architecture to build @type distribution: str @param distribution: the man of the distribution to use @type basetgz: str @param basetgz: the path or path template of the basetgz @type mirror: str @param mirror: the mirror for building basetgz @type extrapackages: list @param extrapackages: adds packages specified to buildroot @type keyring: str @param keyring: keyring file to use for verification @type components: str @param components: components to use for chroot creation @type kwargs: dict @param kwargs: All further keyword arguments. """ WarningCountingShellCommand.__init__(self, **kwargs) if architecture: self.architecture = architecture if distribution: self.distribution = distribution if mirror: self.mirror = mirror if extrapackages: self.extrapackages = extrapackages if keyring: self.keyring = keyring if components: self.components = components if self.architecture: kwargs['architecture'] = self.architecture else: kwargs['architecture'] = 'local' kwargs['distribution'] = self.distribution if basetgz: self.basetgz = basetgz % kwargs else: self.basetgz = self.basetgz % kwargs if not self.distribution: config.error("You must specify a distribution.") self.command = ['pdebuild', '--buildresult', '.', '--pbuilder', self.pbuilder] if self.architecture: self.command += ['--architecture', self.architecture] self.command += ['--', '--buildresult', '.', self.baseOption, self.basetgz] if self.extrapackages: self.command += ['--extrapackages', " ".join(self.extrapackages)] self.suppressions.append((None, re.compile("\.pbuilderrc does not exist"), None, None)) # Check for Basetgz def start(self): cmd = buildstep.RemoteCommand('stat', {'file': self.basetgz}) d = self.runCommand(cmd) d.addCallback(lambda res: self.checkBasetgz(cmd)) d.addErrback(self.failed) return d def checkBasetgz(self, cmd): if cmd.rc != 0: log.msg("basetgz not found, initializing it.") command = ['sudo', self.pbuilder, '--create', self.baseOption, self.basetgz, '--distribution', self.distribution, '--mirror', self.mirror] if self.architecture: command += ['--architecture', self.architecture] if self.extrapackages: command += ['--extrapackages', " ".join(self.extrapackages)] if self.keyring: command += ['--debootstrapopts', "--keyring=%s" % self.keyring] if self.components: command += ['--components', self.components] cmd = buildstep.RemoteShellCommand(self.getWorkdir(), command) stdio_log = stdio_log = self.addLog("pbuilder") cmd.useLog(stdio_log, True, "stdio") d = self.runCommand(cmd) self.step_status.setText(["PBuilder create."]) d.addCallback(lambda res: self.startBuild(cmd)) return d s = cmd.updates["stat"][-1] # basetgz will be a file when running in pbuilder # and a directory in case of cowbuilder if stat.S_ISREG(s[stat.ST_MODE]) or stat.S_ISDIR(s[stat.ST_MODE]): log.msg("%s found." % self.basetgz) age = time.time() - s[stat.ST_MTIME] if age >= self.maxAge: log.msg("basetgz outdated, updating") command = ['sudo', self.pbuilder, '--update', self.baseOption, self.basetgz] cmd = buildstep.RemoteShellCommand(self.getWorkdir(), command) stdio_log = stdio_log = self.addLog("pbuilder") cmd.useLog(stdio_log, True, "stdio") d = self.runCommand(cmd) self.step_status.setText(["PBuilder update."]) d.addCallback(lambda res: self.startBuild(cmd)) return d return self.startBuild(cmd) else: log.msg("%s is not a file or a directory." % self.basetgz) self.finished(FAILURE) def startBuild(self, cmd): if cmd.rc != 0: log.msg("Failure when running %s." % cmd) self.finished(FAILURE) else: return WarningCountingShellCommand.start(self) def commandComplete(self, cmd): out = cmd.logs['stdio'].getText() m = re.search(r"dpkg-genchanges >\.\./(.+\.changes)", out) if m: self.setProperty("deb-changes", m.group(1), "DebPbuilder") class DebCowbuilder(DebPbuilder): """Build a debian package with cowbuilder inside of a chroot.""" name = "cowbuilder" description = ["pdebuilding"] descriptionDone = ["pdebuild"] basetgz = "/var/cache/pbuilder/%(distribution)s-%(architecture)s-buildbot.cow/" pbuilder = '/usr/sbin/cowbuilder' baseOption = '--basepath' class UbuPbuilder(DebPbuilder): """Build a Ubuntu package with pbuilder inside of a chroot.""" distribution = None mirror = "http://archive.ubuntu.com/ubuntu/" components = "main universe" class UbuCowbuilder(DebCowbuilder): """Build a Ubuntu package with cowbuilder inside of a chroot.""" distribution = None mirror = "http://archive.ubuntu.com/ubuntu/" components = "main universe" buildbot-0.8.8/buildbot/steps/package/rpm/000077500000000000000000000000001222546025000204645ustar00rootroot00000000000000buildbot-0.8.8/buildbot/steps/package/rpm/__init__.py000066400000000000000000000021751222546025000226020ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members # Portions Copyright Steve 'Ashcrow' Milner """ Steps specific to the rpm format. """ from buildbot.steps.package.rpm.rpmbuild import RpmBuild from buildbot.steps.package.rpm.rpmspec import RpmSpec from buildbot.steps.package.rpm.rpmlint import RpmLint from buildbot.steps.package.rpm.mock import MockBuildSRPM, MockRebuild __all__ = ['RpmBuild', 'RpmSpec', 'RpmLint', 'MockBuildSRPM', 'MockRebuild'] buildbot-0.8.8/buildbot/steps/package/rpm/mock.py000066400000000000000000000124541222546025000217750ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members # Portions Copyright Marius Rieder """ Steps and objects related to mock building. """ import re from buildbot.steps.shell import ShellCommand from buildbot.process import buildstep from buildbot import config class MockStateObserver(buildstep.LogLineObserver): _line_re = re.compile(r'^.*State Changed: (.*)$') def outLineReceived(self, line): m = self._line_re.search(line.strip()) if m: state = m.group(1) if not state == 'end': self.step.descriptionSuffix = ["[%s]"%m.group(1)] else: self.step.descriptionSuffix = None self.step.step_status.setText(self.step.describe(False)) class Mock(ShellCommand): """Add the mock logfiles and clean them if they already exist. Add support for the root and resultdir parameter of mock.""" name = "mock" haltOnFailure = 1 flunkOnFailure = 1 mock_logfiles = ['build.log', 'root.log', 'state.log'] root = None resultdir = None def __init__(self, root=None, resultdir=None, **kwargs): """ Creates the Mock object. @type root: str @param root: the name of the mock buildroot @type resultdir: str @param resultdir: the path of the result dir @type kwargs: dict @param kwargs: All further keyword arguments. """ ShellCommand.__init__(self, **kwargs) if root: self.root = root if resultdir: self.resultdir = resultdir if not self.root: config.error("You must specify a mock root") self.command = ['mock', '--root', self.root] if self.resultdir: self.command += ['--resultdir', self.resultdir] def start(self): """ Try to remove the old mock logs first. """ if self.resultdir: for lname in self.mock_logfiles: self.logfiles[lname] = self.build.path_module.join(self.resultdir, lname) else: for lname in self.mock_logfiles: self.logfiles[lname] = lname self.addLogObserver('state.log', MockStateObserver()) cmd = buildstep.RemoteCommand('rmdir', {'dir': map(lambda l: self.build.path_module.join('build', self.logfiles[l]), self.mock_logfiles)}) d = self.runCommand(cmd) def removeDone(cmd): ShellCommand.start(self) d.addCallback(removeDone) d.addErrback(self.failed) class MockBuildSRPM(Mock): """Build a srpm within a mock. Requires a spec file and a sources dir.""" name = "mockbuildsrpm" description = ["mock buildsrpm"] descriptionDone = ["mock buildsrpm"] spec = None sources = '.' def __init__(self, spec=None, sources=None, **kwargs): """ Creates the MockBuildSRPM object. @type spec: str @param spec: the path of the specfiles. @type sources: str @param sources: the path of the sources dir. @type kwargs: dict @param kwargs: All further keyword arguments. """ Mock.__init__(self, **kwargs) if spec: self.spec = spec if sources: self.sources = sources if not self.spec: config.error("You must specify a spec file") if not self.sources: config.error("You must specify a sources dir") self.command += ['--buildsrpm', '--spec', self.spec, '--sources', self.sources] def commandComplete(self, cmd): out = cmd.logs['build.log'].getText() m = re.search(r"Wrote: .*/([^/]*.src.rpm)", out) if m: self.setProperty("srpm", m.group(1), 'MockBuildSRPM') class MockRebuild(Mock): """Rebuild a srpm within a mock. Requires a srpm file.""" name = "mock" description = ["mock rebuilding srpm"] descriptionDone = ["mock rebuild srpm"] srpm = None def __init__(self, srpm=None, **kwargs): """ Creates the MockRebuildRPM object. @type srpm: str @param srpm: the path of the srpm file. @type kwargs: dict @param kwargs: All further keyword arguments. """ Mock.__init__(self, **kwargs) if srpm: self.srpm = srpm if not self.srpm: config.error("You must specify a srpm") self.command += ['--rebuild', self.srpm] buildbot-0.8.8/buildbot/steps/package/rpm/rpmbuild.py000066400000000000000000000120351222546025000226550ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members from __future__ import with_statement # Portions Copyright Dan Radez # Portions Copyright Steve 'Ashcrow' Milner import os from buildbot.steps.shell import ShellCommand from buildbot.process import buildstep from buildbot import config class RpmBuild(ShellCommand): """ RpmBuild build step. """ name = "rpmbuilder" haltOnFailure = 1 flunkOnFailure = 1 description = ["RPMBUILD"] descriptionDone = ["RPMBUILD"] def __init__(self, specfile=None, topdir='`pwd`', builddir='`pwd`', rpmdir='`pwd`', sourcedir='`pwd`', specdir='`pwd`', srcrpmdir='`pwd`', dist='.el5', autoRelease=False, vcsRevision=False, **kwargs): """ Create the RpmBuild object. @type specfile: str @param specfile: location of the specfile to build @type topdir: str @param topdir: define the _topdir rpm parameter @type builddir: str @param builddir: define the _builddir rpm parameter @type rpmdir: str @param rpmdir: define the _rpmdir rpm parameter @type sourcedir: str @param sourcedir: define the _sourcedir rpm parameter @type specdir: str @param specdir: define the _specdir rpm parameter @type srcrpmdir: str @param srcrpmdir: define the _srcrpmdir rpm parameter @type dist: str @param dist: define the dist string. @type autoRelease: boolean @param autoRelease: Use auto incrementing release numbers. @type vcsRevision: boolean @param vcsRevision: Use vcs version number as revision number. """ ShellCommand.__init__(self, **kwargs) self.rpmbuild = ( 'rpmbuild --define "_topdir %s" --define "_builddir %s"' ' --define "_rpmdir %s" --define "_sourcedir %s"' ' --define "_specdir %s" --define "_srcrpmdir %s"' ' --define "dist %s"' % (topdir, builddir, rpmdir, sourcedir, specdir, srcrpmdir, dist)) self.specfile = specfile self.autoRelease = autoRelease self.vcsRevision = vcsRevision if not self.specfile: config.error("You must specify a specfile") def start(self): if self.autoRelease: relfile = '%s.release' % ( os.path.basename(self.specfile).split('.')[0]) try: with open(relfile, 'r') as rfile: rel = int(rfile.readline().strip()) except: rel = 0 self.rpmbuild = self.rpmbuild + ' --define "_release %s"' % rel with open(relfile, 'w') as rfile: rfile.write(str(rel+1)) if self.vcsRevision: revision = self.getProperty('got_revision') # only do this in the case where there's a single codebase if revision and not isinstance(revision, dict): self.rpmbuild = (self.rpmbuild + ' --define "_revision %s"' % revision) self.rpmbuild = self.rpmbuild + ' -ba %s' % self.specfile self.command = self.rpmbuild # create the actual RemoteShellCommand instance now kwargs = self.remote_kwargs kwargs['command'] = self.command cmd = buildstep.RemoteShellCommand(**kwargs) self.setupEnvironment(cmd) self.startCommand(cmd) def createSummary(self, log): rpm_prefixes = ['Provides:', 'Requires(', 'Requires:', 'Checking for unpackaged', 'Wrote:', 'Executing(%', '+ ', 'Processing files:'] rpm_err_pfx = [' ', 'RPM build errors:', 'error: '] rpmcmdlog = [] rpmerrors = [] for line in log.getText().splitlines(True): for pfx in rpm_prefixes: if line.startswith(pfx): rpmcmdlog.append(line) break for err in rpm_err_pfx: if line.startswith(err): rpmerrors.append(line) break self.addCompleteLog('RPM Command Log', "".join(rpmcmdlog)) if rpmerrors: self.addCompleteLog('RPM Errors', "".join(rpmerrors)) buildbot-0.8.8/buildbot/steps/package/rpm/rpmlint.py000066400000000000000000000045251222546025000225310ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members # Portions Copyright Steve 'Ashcrow' Milner """ Steps and objects related to rpmlint. """ from buildbot.steps.shell import Test class RpmLint(Test): """ Rpmlint build step. """ name = "rpmlint" description = ["Checking for RPM/SPEC issues"] descriptionDone = ["Finished checking RPM/SPEC issues"] fileloc = '.' config = None def __init__(self, fileloc=None, config=None, **kwargs): """ Create the Rpmlint object. @type fileloc: str @param fileloc: Location glob of the specs or rpms. @type config: str @param config: path to the rpmlint user config. @type kwargs: dict @param fileloc: all other keyword arguments. """ Test.__init__(self, **kwargs) if fileloc: self.fileloc = fileloc if config: self.config = config self.command = ["rpmlint", "-i"] if self.config: self.command += ['-f', self.config] self.command.append(self.fileloc) def createSummary(self, log): """ Create nice summary logs. @param log: log to create summary off of. """ warnings = [] errors = [] for line in log.readlines(): if ' W: ' in line: warnings.append(line) elif ' E: ' in line: errors.append(line) if warnings: self.addCompleteLog('%d Warnings'%len(warnings), "".join(warnings)) if errors: self.addCompleteLog('%d Errors'%len(errors), "".join(errors)) buildbot-0.8.8/buildbot/steps/package/rpm/rpmspec.py000066400000000000000000000047301222546025000225130ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members # Portions Copyright Dan Radez # Portions Copyright Steve 'Ashcrow' Milner """ library to populate parameters from and rpmspec file into a memory structure """ import re from buildbot.steps.shell import ShellCommand class RpmSpec(ShellCommand): """ read parameters out of an rpm spec file """ #initialize spec info vars and get them from the spec file n_regex = re.compile('^Name:[ ]*([^\s]*)') v_regex = re.compile('^Version:[ ]*([0-9\.]*)') def __init__(self, specfile=None, **kwargs): """ Creates the RpmSpec object. @type specfile: str @param specfile: the name of the specfile to get the package name and version from @type kwargs: dict @param kwargs: All further keyword arguments. """ self.specfile = specfile self._pkg_name = None self._pkg_version = None self._loaded = False def load(self): """ call this function after the file exists to populate properties """ # If we are given a string, open it up else assume it's something we # can call read on. if isinstance(self.specfile, str): f = open(self.specfile, 'r') else: f = self.specfile for line in f: if self.v_regex.match(line): self._pkg_version = self.v_regex.match(line).group(1) if self.n_regex.match(line): self._pkg_name = self.n_regex.match(line).group(1) f.close() self._loaded = True # Read-only properties loaded = property(lambda self: self._loaded) pkg_name = property(lambda self: self._pkg_name) pkg_version = property(lambda self: self._pkg_version) buildbot-0.8.8/buildbot/steps/python.py000066400000000000000000000247121222546025000201740ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import re from buildbot.status.results import SUCCESS, FAILURE, WARNINGS from buildbot.steps.shell import ShellCommand from buildbot import config try: import cStringIO StringIO = cStringIO.StringIO except ImportError: from StringIO import StringIO class BuildEPYDoc(ShellCommand): name = "epydoc" command = ["make", "epydocs"] description = ["building", "epydocs"] descriptionDone = ["epydoc"] def createSummary(self, log): import_errors = 0 warnings = 0 errors = 0 for line in StringIO(log.getText()): if line.startswith("Error importing "): import_errors += 1 if line.find("Warning: ") != -1: warnings += 1 if line.find("Error: ") != -1: errors += 1 self.descriptionDone = self.descriptionDone[:] if import_errors: self.descriptionDone.append("ierr=%d" % import_errors) if warnings: self.descriptionDone.append("warn=%d" % warnings) if errors: self.descriptionDone.append("err=%d" % errors) self.import_errors = import_errors self.warnings = warnings self.errors = errors def evaluateCommand(self, cmd): if cmd.didFail(): return FAILURE if self.warnings or self.errors: return WARNINGS return SUCCESS class PyFlakes(ShellCommand): name = "pyflakes" command = ["make", "pyflakes"] description = ["running", "pyflakes"] descriptionDone = ["pyflakes"] flunkOnFailure = False flunkingIssues = ["undefined"] # any pyflakes lines like this cause FAILURE MESSAGES = ("unused", "undefined", "redefs", "import*", "misc") def __init__(self, *args, **kwargs): # PyFlakes return 1 for both warnings and errors. We # categorize this initially as WARNINGS so that # evaluateCommand below can inspect the results more closely. kwargs['decodeRC'] = {0: SUCCESS, 1: WARNINGS} ShellCommand.__init__(self, *args, **kwargs) def createSummary(self, log): counts = {} summaries = {} for m in self.MESSAGES: counts[m] = 0 summaries[m] = [] first = True for line in StringIO(log.getText()).readlines(): # the first few lines might contain echoed commands from a 'make # pyflakes' step, so don't count these as warnings. Stop ignoring # the initial lines as soon as we see one with a colon. if first: if line.find(":") != -1: # there's the colon, this is the first real line first = False # fall through and parse the line else: # skip this line, keep skipping non-colon lines continue if line.find("imported but unused") != -1: m = "unused" elif line.find("*' used; unable to detect undefined names") != -1: m = "import*" elif line.find("undefined name") != -1: m = "undefined" elif line.find("redefinition of unused") != -1: m = "redefs" else: m = "misc" summaries[m].append(line) counts[m] += 1 self.descriptionDone = self.descriptionDone[:] for m in self.MESSAGES: if counts[m]: self.descriptionDone.append("%s=%d" % (m, counts[m])) self.addCompleteLog(m, "".join(summaries[m])) self.setProperty("pyflakes-%s" % m, counts[m], "pyflakes") self.setProperty("pyflakes-total", sum(counts.values()), "pyflakes") def evaluateCommand(self, cmd): if cmd.didFail(): return FAILURE for m in self.flunkingIssues: if self.getProperty("pyflakes-%s" % m): return FAILURE if self.getProperty("pyflakes-total"): return WARNINGS return SUCCESS class PyLint(ShellCommand): '''A command that knows about pylint output. It is a good idea to add --output-format=parseable to your command, since it includes the filename in the message. ''' name = "pylint" description = ["running", "pylint"] descriptionDone = ["pylint"] # pylint's return codes (see pylint(1) for details) # 1 - 16 will be bit-ORed RC_OK = 0 RC_FATAL = 1 RC_ERROR = 2 RC_WARNING = 4 RC_REFACTOR = 8 RC_CONVENTION = 16 RC_USAGE = 32 # Using the default text output, the message format is : # MESSAGE_TYPE: LINE_NUM:[OBJECT:] MESSAGE # with --output-format=parseable it is: (the outer brackets are literal) # FILE_NAME:LINE_NUM: [MESSAGE_TYPE[, OBJECT]] MESSAGE # message type consists of the type char and 4 digits # The message types: MESSAGES = { 'C': "convention", # for programming standard violation 'R': "refactor", # for bad code smell 'W': "warning", # for python specific problems 'E': "error", # for much probably bugs in the code 'F': "fatal", # error prevented pylint from further processing. 'I': "info", } flunkingIssues = ["F", "E"] # msg categories that cause FAILURE _re_groupname = 'errtype' _msgtypes_re_str = '(?P<%s>[%s])' % (_re_groupname, ''.join(MESSAGES.keys())) _default_line_re = re.compile(r'^%s(\d{4})?: *\d+(,\d+)?:.+' % _msgtypes_re_str) _parseable_line_re = re.compile(r'[^:]+:\d+: \[%s(\d{4})?[,\]] .+' % _msgtypes_re_str) def createSummary(self, log): counts = {} summaries = {} for m in self.MESSAGES: counts[m] = 0 summaries[m] = [] line_re = None # decide after first match for line in StringIO(log.getText()).readlines(): if not line_re: # need to test both and then decide on one if self._parseable_line_re.match(line): line_re = self._parseable_line_re elif self._default_line_re.match(line): line_re = self._default_line_re else: # no match yet continue mo = line_re.match(line) if mo: msgtype = mo.group(self._re_groupname) assert msgtype in self.MESSAGES summaries[msgtype].append(line) counts[msgtype] += 1 self.descriptionDone = self.descriptionDone[:] for msg, fullmsg in self.MESSAGES.items(): if counts[msg]: self.descriptionDone.append("%s=%d" % (fullmsg, counts[msg])) self.addCompleteLog(fullmsg, "".join(summaries[msg])) self.setProperty("pylint-%s" % fullmsg, counts[msg]) self.setProperty("pylint-total", sum(counts.values())) def evaluateCommand(self, cmd): if cmd.rc & (self.RC_FATAL|self.RC_ERROR|self.RC_USAGE): return FAILURE for msg in self.flunkingIssues: if self.getProperty("pylint-%s" % self.MESSAGES[msg]): return FAILURE if self.getProperty("pylint-total"): return WARNINGS return SUCCESS class Sphinx(ShellCommand): ''' A Step to build sphinx documentation ''' name = "sphinx" description = ["running", "sphinx"] descriptionDone = ["sphinx"] haltOnFailure = True def __init__(self, sphinx_sourcedir='.', sphinx_builddir=None, sphinx_builder=None, sphinx = 'sphinx-build', tags = [], defines = {}, mode='incremental', **kwargs): if sphinx_builddir is None: # Who the heck is not interested in the built doc ? config.error("Sphinx argument sphinx_builddir is required") if mode not in ('incremental', 'full'): config.error("Sphinx argument mode has to be 'incremental' or" + "'full' is required") self.warnings = 0 self.success = False ShellCommand.__init__(self, **kwargs) # build the command command = [sphinx] if sphinx_builder is not None: command.extend(['-b', sphinx_builder]) for tag in tags: command.extend(['-t', tag]) for key in sorted(defines): if defines[key] is None: command.extend(['-D', key]) elif isinstance(defines[key], bool): command.extend(['-D', '%s=%d' % (key, defines[key] and 1 or 0)]) else: command.extend(['-D', '%s=%s' % (key, defines[key])]) if mode == 'full': command.extend(['-E']) # Don't use a saved environment command.extend([sphinx_sourcedir, sphinx_builddir]) self.setCommand(command) def createSummary(self, log): msgs = ['WARNING', 'ERROR', 'SEVERE'] warnings = [] for line in log.getText().split('\n'): if (line.startswith('build succeeded') or line.startswith('no targets are out of date.')): self.success = True else: for msg in msgs: if msg in line: warnings.append(line) self.warnings += 1 if self.warnings > 0: self.addCompleteLog('warnings', "\n".join(warnings)) self.step_status.setStatistic('warnings', self.warnings) def evaluateCommand(self, cmd): if self.success: if self.warnings == 0: return SUCCESS else: return WARNINGS else: return FAILURE def describe(self, done=False): if not done: return ["building"] description = [self.name] description.append('%d warnings' % self.warnings) return description buildbot-0.8.8/buildbot/steps/python_twisted.py000066400000000000000000000576711222546025000217510ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.python import log from buildbot.status import testresult from buildbot.status.results import SUCCESS, FAILURE, WARNINGS, SKIPPED from buildbot.process.buildstep import LogLineObserver, OutputProgressObserver from buildbot.steps.shell import ShellCommand try: import cStringIO StringIO = cStringIO except ImportError: import StringIO import re # BuildSteps that are specific to the Twisted source tree class HLint(ShellCommand): """I run a 'lint' checker over a set of .xhtml files. Any deviations from recommended style is flagged and put in the output log. This step looks at .changes in the parent Build to extract a list of Lore XHTML files to check.""" name = "hlint" description = ["running", "hlint"] descriptionDone = ["hlint"] warnOnWarnings = True warnOnFailure = True # TODO: track time, but not output warnings = 0 def __init__(self, python=None, **kwargs): ShellCommand.__init__(self, **kwargs) self.python = python def start(self): # create the command htmlFiles = {} for f in self.build.allFiles(): if f.endswith(".xhtml") and not f.startswith("sandbox/"): htmlFiles[f] = 1 # remove duplicates hlintTargets = htmlFiles.keys() hlintTargets.sort() if not hlintTargets: return SKIPPED self.hlintFiles = hlintTargets c = [] if self.python: c.append(self.python) c += ["bin/lore", "-p", "--output", "lint"] + self.hlintFiles self.setCommand(c) # add an extra log file to show the .html files we're checking self.addCompleteLog("files", "\n".join(self.hlintFiles)+"\n") ShellCommand.start(self) def commandComplete(self, cmd): # TODO: remove the 'files' file (a list of .xhtml files that were # submitted to hlint) because it is available in the logfile and # mostly exists to give the user an idea of how long the step will # take anyway). lines = cmd.logs['stdio'].getText().split("\n") warningLines = filter(lambda line:':' in line, lines) if warningLines: self.addCompleteLog("warnings", "".join(warningLines)) warnings = len(warningLines) self.warnings = warnings def evaluateCommand(self, cmd): # warnings are in stdout, rc is always 0, unless the tools break if cmd.didFail(): return FAILURE if self.warnings: return WARNINGS return SUCCESS def getText2(self, cmd, results): if cmd.didFail(): return ["hlint"] return ["%d hlin%s" % (self.warnings, self.warnings == 1 and 't' or 'ts')] def countFailedTests(output): # start scanning 10kb from the end, because there might be a few kb of # import exception tracebacks between the total/time line and the errors # line chunk = output[-10000:] lines = chunk.split("\n") lines.pop() # blank line at end # lines[-3] is "Ran NN tests in 0.242s" # lines[-2] is blank # lines[-1] is 'OK' or 'FAILED (failures=1, errors=12)' # or 'FAILED (failures=1)' # or "PASSED (skips=N, successes=N)" (for Twisted-2.0) # there might be other lines dumped here. Scan all the lines. res = {'total': None, 'failures': 0, 'errors': 0, 'skips': 0, 'expectedFailures': 0, 'unexpectedSuccesses': 0, } for l in lines: out = re.search(r'Ran (\d+) tests', l) if out: res['total'] = int(out.group(1)) if (l.startswith("OK") or l.startswith("FAILED ") or l.startswith("PASSED")): # the extra space on FAILED_ is to distinguish the overall # status from an individual test which failed. The lack of a # space on the OK is because it may be printed without any # additional text (if there are no skips,etc) out = re.search(r'failures=(\d+)', l) if out: res['failures'] = int(out.group(1)) out = re.search(r'errors=(\d+)', l) if out: res['errors'] = int(out.group(1)) out = re.search(r'skips=(\d+)', l) if out: res['skips'] = int(out.group(1)) out = re.search(r'expectedFailures=(\d+)', l) if out: res['expectedFailures'] = int(out.group(1)) out = re.search(r'unexpectedSuccesses=(\d+)', l) if out: res['unexpectedSuccesses'] = int(out.group(1)) # successes= is a Twisted-2.0 addition, and is not currently used out = re.search(r'successes=(\d+)', l) if out: res['successes'] = int(out.group(1)) return res class TrialTestCaseCounter(LogLineObserver): _line_re = re.compile(r'^(?:Doctest: )?([\w\.]+) \.\.\. \[([^\]]+)\]$') numTests = 0 finished = False def outLineReceived(self, line): # different versions of Twisted emit different per-test lines with # the bwverbose reporter. # 2.0.0: testSlave (buildbot.test.test_runner.Create) ... [OK] # 2.1.0: buildbot.test.test_runner.Create.testSlave ... [OK] # 2.4.0: buildbot.test.test_runner.Create.testSlave ... [OK] # Let's just handle the most recent version, since it's the easiest. # Note that doctests create lines line this: # Doctest: viff.field.GF ... [OK] if self.finished: return if line.startswith("=" * 40): self.finished = True return m = self._line_re.search(line.strip()) if m: testname, result = m.groups() self.numTests += 1 self.step.setProgress('tests', self.numTests) UNSPECIFIED=() # since None is a valid choice class Trial(ShellCommand): """ There are some class attributes which may be usefully overridden by subclasses. 'trialMode' and 'trialArgs' can influence the trial command line. """ name = "trial" progressMetrics = ('output', 'tests', 'test.log') # note: the slash only works on unix buildslaves, of course, but we have # no way to know what the buildslave uses as a separator. # TODO: figure out something clever. logfiles = {"test.log": "_trial_temp/test.log"} # we use test.log to track Progress at the end of __init__() renderables = ['tests', 'jobs'] flunkOnFailure = True python = None trial = "trial" trialMode = ["--reporter=bwverbose"] # requires Twisted-2.1.0 or newer # for Twisted-2.0.0 or 1.3.0, use ["-o"] instead trialArgs = [] jobs = None testpath = UNSPECIFIED # required (but can be None) testChanges = False # TODO: needs better name recurse = False reactor = None randomly = False tests = None # required def __init__(self, reactor=UNSPECIFIED, python=None, trial=None, testpath=UNSPECIFIED, tests=None, testChanges=None, recurse=None, randomly=None, trialMode=None, trialArgs=None, jobs=None, **kwargs): """ @type testpath: string @param testpath: use in PYTHONPATH when running the tests. If None, do not set PYTHONPATH. Setting this to '.' will cause the source files to be used in-place. @type python: string (without spaces) or list @param python: which python executable to use. Will form the start of the argv array that will launch trial. If you use this, you should set 'trial' to an explicit path (like /usr/bin/trial or ./bin/trial). Defaults to None, which leaves it out entirely (running 'trial args' instead of 'python ./bin/trial args'). Likely values are 'python', ['python2.2'], ['python', '-Wall'], etc. @type trial: string @param trial: which 'trial' executable to run. Defaults to 'trial', which will cause $PATH to be searched and probably find /usr/bin/trial . If you set 'python', this should be set to an explicit path (because 'python2.3 trial' will not work). @type trialMode: list of strings @param trialMode: a list of arguments to pass to trial, specifically to set the reporting mode. This defaults to ['-to'] which means 'verbose colorless output' to the trial that comes with Twisted-2.0.x and at least -2.1.0 . Newer versions of Twisted may come with a trial that prefers ['--reporter=bwverbose']. @type trialArgs: list of strings @param trialArgs: a list of arguments to pass to trial, available to turn on any extra flags you like. Defaults to []. @type jobs: integer @param jobs: integer to be used as trial -j/--jobs option (for running tests on several workers). Only supported since Twisted-12.3.0. @type tests: list of strings @param tests: a list of test modules to run, like ['twisted.test.test_defer', 'twisted.test.test_process']. If this is a string, it will be converted into a one-item list. @type testChanges: boolean @param testChanges: if True, ignore the 'tests' parameter and instead ask the Build for all the files that make up the Changes going into this build. Pass these filenames to trial and ask it to look for test-case-name tags, running just the tests necessary to cover the changes. @type recurse: boolean @param recurse: If True, pass the --recurse option to trial, allowing test cases to be found in deeper subdirectories of the modules listed in 'tests'. This does not appear to be necessary when using testChanges. @type reactor: string @param reactor: which reactor to use, like 'gtk' or 'java'. If not provided, the Twisted's usual platform-dependent default is used. @type randomly: boolean @param randomly: if True, add the --random=0 argument, which instructs trial to run the unit tests in a random order each time. This occasionally catches problems that might be masked when one module always runs before another (like failing to make registerAdapter calls before lookups are done). @type kwargs: dict @param kwargs: parameters. The following parameters are inherited from L{ShellCommand} and may be useful to set: workdir, haltOnFailure, flunkOnWarnings, flunkOnFailure, warnOnWarnings, warnOnFailure, want_stdout, want_stderr, timeout. """ ShellCommand.__init__(self, **kwargs) if python: self.python = python if self.python is not None: if type(self.python) is str: self.python = [self.python] for s in self.python: if " " in s: # this is not strictly an error, but I suspect more # people will accidentally try to use python="python2.3 # -Wall" than will use embedded spaces in a python flag log.msg("python= component '%s' has spaces") log.msg("To add -Wall, use python=['python', '-Wall']") why = "python= value has spaces, probably an error" raise ValueError(why) if trial: self.trial = trial if " " in self.trial: raise ValueError("trial= value has spaces") if trialMode is not None: self.trialMode = trialMode if trialArgs is not None: self.trialArgs = trialArgs if jobs is not None: self.jobs = jobs if testpath is not UNSPECIFIED: self.testpath = testpath if self.testpath is UNSPECIFIED: raise ValueError("You must specify testpath= (it can be None)") assert isinstance(self.testpath, str) or self.testpath is None if reactor is not UNSPECIFIED: self.reactor = reactor if tests is not None: self.tests = tests if type(self.tests) is str: self.tests = [self.tests] if testChanges is not None: self.testChanges = testChanges #self.recurse = True # not sure this is necessary if not self.testChanges and self.tests is None: raise ValueError("Must either set testChanges= or provide tests=") if recurse is not None: self.recurse = recurse if randomly is not None: self.randomly = randomly # build up most of the command, then stash it until start() command = [] if self.python: command.extend(self.python) command.append(self.trial) command.extend(self.trialMode) if self.recurse: command.append("--recurse") if self.reactor: command.append("--reactor=%s" % reactor) if self.randomly: command.append("--random=0") command.extend(self.trialArgs) self.command = command if self.reactor: self.description = ["testing", "(%s)" % self.reactor] self.descriptionDone = ["tests"] # commandComplete adds (reactorname) to self.text else: self.description = ["testing"] self.descriptionDone = ["tests"] # this counter will feed Progress along the 'test cases' metric self.addLogObserver('stdio', TrialTestCaseCounter()) def setupEnvironment(self, cmd): ShellCommand.setupEnvironment(self, cmd) if self.testpath != None: e = cmd.args['env'] if e is None: cmd.args['env'] = {'PYTHONPATH': self.testpath} else: #this bit produces a list, which can be used #by buildslave.runprocess.RunProcess ppath = e.get('PYTHONPATH', self.testpath) if isinstance(ppath, str): ppath = [ppath] if self.testpath not in ppath: ppath.insert(0, self.testpath) e['PYTHONPATH'] = ppath def start(self): # choose progressMetrics and logfiles based on whether trial is being # run with multiple workers or not. output_observer = OutputProgressObserver('test.log') if self.jobs is not None: self.jobs = int(self.jobs) self.command.append("--jobs=%d" % self.jobs) # using -j/--jobs flag produces more than one test log. self.logfiles = {} for i in xrange(self.jobs): self.logfiles['test.%d.log' % i] = '_trial_temp/%d/test.log' % i self.logfiles['err.%d.log' % i] = '_trial_temp/%d/err.log' % i self.logfiles['out.%d.log' % i] = '_trial_temp/%d/out.log' % i self.addLogObserver('test.%d.log' % i, output_observer) else: # this one just measures bytes of output in _trial_temp/test.log self.addLogObserver('test.log', output_observer) # now that self.build.allFiles() is nailed down, finish building the # command if self.testChanges: for f in self.build.allFiles(): if f.endswith(".py"): self.command.append("--testmodule=%s" % f) else: self.command.extend(self.tests) log.msg("Trial.start: command is", self.command) ShellCommand.start(self) def commandComplete(self, cmd): # figure out all status, then let the various hook functions return # different pieces of it # 'cmd' is the original trial command, so cmd.logs['stdio'] is the # trial output. We don't have access to test.log from here. output = cmd.logs['stdio'].getText() counts = countFailedTests(output) total = counts['total'] failures, errors = counts['failures'], counts['errors'] parsed = (total != None) text = [] text2 = "" if not cmd.didFail(): if parsed: results = SUCCESS if total: text += ["%d %s" % \ (total, total == 1 and "test" or "tests"), "passed"] else: text += ["no tests", "run"] else: results = FAILURE text += ["testlog", "unparseable"] text2 = "tests" else: # something failed results = FAILURE if parsed: text.append("tests") if failures: text.append("%d %s" % \ (failures, failures == 1 and "failure" or "failures")) if errors: text.append("%d %s" % \ (errors, errors == 1 and "error" or "errors")) count = failures + errors text2 = "%d tes%s" % (count, (count == 1 and 't' or 'ts')) else: text += ["tests", "failed"] text2 = "tests" if counts['skips']: text.append("%d %s" % \ (counts['skips'], counts['skips'] == 1 and "skip" or "skips")) if counts['expectedFailures']: text.append("%d %s" % \ (counts['expectedFailures'], counts['expectedFailures'] == 1 and "todo" or "todos")) if 0: # TODO results = WARNINGS if not text2: text2 = "todo" if 0: # ignore unexpectedSuccesses for now, but it should really mark # the build WARNING if counts['unexpectedSuccesses']: text.append("%d surprises" % counts['unexpectedSuccesses']) results = WARNINGS if not text2: text2 = "tests" if self.reactor: text.append(self.rtext('(%s)')) if text2: text2 = "%s %s" % (text2, self.rtext('(%s)')) self.results = results self.text = text self.text2 = [text2] def rtext(self, fmt='%s'): if self.reactor: rtext = fmt % self.reactor return rtext.replace("reactor", "") return "" def addTestResult(self, testname, results, text, tlog): if self.reactor is not None: testname = (self.reactor,) + testname tr = testresult.TestResult(testname, results, text, logs={'log': tlog}) #self.step_status.build.addTestResult(tr) self.build.build_status.addTestResult(tr) def createSummary(self, loog): output = loog.getText() problems = "" sio = StringIO.StringIO(output) warnings = {} while 1: line = sio.readline() if line == "": break if line.find(" exceptions.DeprecationWarning: ") != -1: # no source warning = line # TODO: consider stripping basedir prefix here warnings[warning] = warnings.get(warning, 0) + 1 elif (line.find(" DeprecationWarning: ") != -1 or line.find(" UserWarning: ") != -1): # next line is the source warning = line + sio.readline() warnings[warning] = warnings.get(warning, 0) + 1 elif line.find("Warning: ") != -1: warning = line warnings[warning] = warnings.get(warning, 0) + 1 if line.find("=" * 60) == 0 or line.find("-" * 60) == 0: problems += line problems += sio.read() break if problems: self.addCompleteLog("problems", problems) # now parse the problems for per-test results pio = StringIO.StringIO(problems) pio.readline() # eat the first separator line testname = None done = False while not done: while 1: line = pio.readline() if line == "": done = True break if line.find("=" * 60) == 0: break if line.find("-" * 60) == 0: # the last case has --- as a separator before the # summary counts are printed done = True break if testname is None: # the first line after the === is like: # EXPECTED FAILURE: testLackOfTB (twisted.test.test_failure.FailureTestCase) # SKIPPED: testRETR (twisted.test.test_ftp.TestFTPServer) # FAILURE: testBatchFile (twisted.conch.test.test_sftp.TestOurServerBatchFile) r = re.search(r'^([^:]+): (\w+) \(([\w\.]+)\)', line) if not r: # TODO: cleanup, if there are no problems, # we hit here continue result, name, case = r.groups() testname = tuple(case.split(".") + [name]) results = {'SKIPPED': SKIPPED, 'EXPECTED FAILURE': SUCCESS, 'UNEXPECTED SUCCESS': WARNINGS, 'FAILURE': FAILURE, 'ERROR': FAILURE, 'SUCCESS': SUCCESS, # not reported }.get(result, WARNINGS) text = result.lower().split() loog = line # the next line is all dashes loog += pio.readline() else: # the rest goes into the log loog += line if testname: self.addTestResult(testname, results, text, loog) testname = None if warnings: lines = warnings.keys() lines.sort() self.addCompleteLog("warnings", "".join(lines)) def evaluateCommand(self, cmd): return self.results def getText(self, cmd, results): return self.text def getText2(self, cmd, results): return self.text2 class RemovePYCs(ShellCommand): name = "remove-.pyc" command = ['find', '.', '-name', '*.pyc', '-exec', 'rm', '{}', ';'] description = ["removing", ".pyc", "files"] descriptionDone = ["remove", ".pycs"] buildbot-0.8.8/buildbot/steps/shell.py000066400000000000000000000703511222546025000177620ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import re import inspect from twisted.python import log, failure from twisted.spread import pb from twisted.python.deprecate import deprecatedModuleAttribute from twisted.python.versions import Version from buildbot.process import buildstep from buildbot.status.results import SUCCESS, WARNINGS, FAILURE from buildbot.status.logfile import STDOUT, STDERR from buildbot import config # for existing configurations that import WithProperties from here. We like # to move this class around just to keep our readers guessing. from buildbot.process.properties import WithProperties _hush_pyflakes = [WithProperties] del _hush_pyflakes class ShellCommand(buildstep.LoggingBuildStep): """I run a single shell command on the buildslave. I return FAILURE if the exit code of that command is non-zero, SUCCESS otherwise. To change this behavior, override my .evaluateCommand method, or customize decodeRC argument By default, a failure of this step will mark the whole build as FAILURE. To override this, give me an argument of flunkOnFailure=False . I create a single Log named 'log' which contains the output of the command. To create additional summary Logs, override my .createSummary method. The shell command I run (a list of argv strings) can be provided in several ways: - a class-level .command attribute - a command= parameter to my constructor (overrides .command) - set explicitly with my .setCommand() method (overrides both) @ivar command: a list of renderable objects (typically strings or WithProperties instances). This will be used by start() to create a RemoteShellCommand instance. @ivar logfiles: a dict mapping log NAMEs to workdir-relative FILENAMEs of their corresponding logfiles. The contents of the file named FILENAME will be put into a LogFile named NAME, ina something approximating real-time. (note that logfiles= is actually handled by our parent class LoggingBuildStep) @ivar lazylogfiles: Defaults to False. If True, logfiles will be tracked `lazily', meaning they will only be added when and if they are written to. Empty or nonexistent logfiles will be omitted. (Also handled by class LoggingBuildStep.) """ name = "shell" renderables = buildstep.LoggingBuildStep.renderables + [ 'slaveEnvironment', 'remote_kwargs', 'command', 'description', 'descriptionDone', 'descriptionSuffix'] description = None # set this to a list of short strings to override descriptionDone = None # alternate description when the step is complete descriptionSuffix = None # extra information to append to suffix command = None # set this to a command, or set in kwargs # logfiles={} # you can also set 'logfiles' to a dictionary, and it # will be merged with any logfiles= argument passed in # to __init__ # override this on a specific ShellCommand if you want to let it fail # without dooming the entire build to a status of FAILURE flunkOnFailure = True def __init__(self, workdir=None, description=None, descriptionDone=None, descriptionSuffix=None, command=None, usePTY="slave-config", **kwargs): # most of our arguments get passed through to the RemoteShellCommand # that we create, but first strip out the ones that we pass to # BuildStep (like haltOnFailure and friends), and a couple that we # consume ourselves. if description: self.description = description if isinstance(self.description, str): self.description = [self.description] if descriptionDone: self.descriptionDone = descriptionDone if isinstance(self.descriptionDone, str): self.descriptionDone = [self.descriptionDone] if descriptionSuffix: self.descriptionSuffix = descriptionSuffix if isinstance(self.descriptionSuffix, str): self.descriptionSuffix = [self.descriptionSuffix] if command: self.setCommand(command) # pull out the ones that LoggingBuildStep wants, then upcall buildstep_kwargs = {} for k in kwargs.keys()[:]: if k in self.__class__.parms: buildstep_kwargs[k] = kwargs[k] del kwargs[k] buildstep.LoggingBuildStep.__init__(self, **buildstep_kwargs) # check validity of arguments being passed to RemoteShellCommand invalid_args = [] valid_rsc_args = inspect.getargspec(buildstep.RemoteShellCommand.__init__)[0] for arg in kwargs.keys(): if arg not in valid_rsc_args: invalid_args.append(arg) # Raise Configuration error in case invalid arguments are present if invalid_args: config.error("Invalid argument(s) passed to RemoteShellCommand: " + ', '.join(invalid_args)) # everything left over goes to the RemoteShellCommand kwargs['workdir'] = workdir # including a copy of 'workdir' kwargs['usePTY'] = usePTY self.remote_kwargs = kwargs def setBuild(self, build): buildstep.LoggingBuildStep.setBuild(self, build) # Set this here, so it gets rendered when we start the step self.slaveEnvironment = self.build.slaveEnvironment def setStepStatus(self, step_status): buildstep.LoggingBuildStep.setStepStatus(self, step_status) def setDefaultWorkdir(self, workdir): rkw = self.remote_kwargs rkw['workdir'] = rkw['workdir'] or workdir def getWorkdir(self): """ Get the current notion of the workdir. Note that this may change between instantiation of the step and C{start}, as it is based on the build's default workdir, and may even be C{None} before that point. """ return self.remote_kwargs['workdir'] def setCommand(self, command): self.command = command def _flattenList(self, mainlist, commands): for x in commands: if isinstance(x, (list, tuple)): if x != []: self._flattenList(mainlist, x) else: mainlist.append(x) def describe(self, done=False): desc = self._describe(done) if self.descriptionSuffix: desc = desc[:] desc.extend(self.descriptionSuffix) return desc def _describe(self, done=False): """Return a list of short strings to describe this step, for the status display. This uses the first few words of the shell command. You can replace this by setting .description in your subclass, or by overriding this method to describe the step better. @type done: boolean @param done: whether the command is complete or not, to improve the way the command is described. C{done=False} is used while the command is still running, so a single imperfect-tense verb is appropriate ('compiling', 'testing', ...) C{done=True} is used when the command has finished, and the default getText() method adds some text, so a simple noun is appropriate ('compile', 'tests' ...) """ try: if done and self.descriptionDone is not None: return self.descriptionDone if self.description is not None: return self.description # we may have no command if this is a step that sets its command # name late in the game (e.g., in start()) if not self.command: return ["???"] words = self.command if isinstance(words, (str, unicode)): words = words.split() try: len(words) except AttributeError: # WithProperties and Property don't have __len__ return ["???"] # flatten any nested lists tmp = [] self._flattenList(tmp, words) words = tmp # strip instances and other detritus (which can happen if a # description is requested before rendering) words = [ w for w in words if isinstance(w, (str, unicode)) ] if len(words) < 1: return ["???"] if len(words) == 1: return ["'%s'" % words[0]] if len(words) == 2: return ["'%s" % words[0], "%s'" % words[1]] return ["'%s" % words[0], "%s" % words[1], "...'"] except: log.err(failure.Failure(), "Error describing step") return ["???"] def setupEnvironment(self, cmd): # merge in anything from Build.slaveEnvironment # This can be set from a Builder-level environment, or from earlier # BuildSteps. The latter method is deprecated and superceded by # BuildProperties. # Environment variables passed in by a BuildStep override # those passed in at the Builder level. slaveEnv = self.slaveEnvironment if slaveEnv: if cmd.args['env'] is None: cmd.args['env'] = {} fullSlaveEnv = slaveEnv.copy() fullSlaveEnv.update(cmd.args['env']) cmd.args['env'] = fullSlaveEnv # note that each RemoteShellCommand gets its own copy of the # dictionary, so we shouldn't be affecting anyone but ourselves. def buildCommandKwargs(self, warnings): kwargs = buildstep.LoggingBuildStep.buildCommandKwargs(self) kwargs.update(self.remote_kwargs) tmp = [] if isinstance(self.command, list): self._flattenList(tmp, self.command) else: tmp = self.command kwargs['command'] = tmp # check for the usePTY flag if kwargs.has_key('usePTY') and kwargs['usePTY'] != 'slave-config': if self.slaveVersionIsOlderThan("svn", "2.7"): warnings.append("NOTE: slave does not allow master to override usePTY\n") del kwargs['usePTY'] # check for the interruptSignal flag if kwargs.has_key('interruptSignal') and self.slaveVersionIsOlderThan("shell", "2.15"): warnings.append("NOTE: slave does not allow master to specify interruptSignal\n") del kwargs['interruptSignal'] return kwargs def start(self): # this block is specific to ShellCommands. subclasses that don't need # to set up an argv array, an environment, or extra logfiles= (like # the Source subclasses) can just skip straight to startCommand() warnings = [] # create the actual RemoteShellCommand instance now kwargs = self.buildCommandKwargs(warnings) cmd = buildstep.RemoteShellCommand(**kwargs) self.setupEnvironment(cmd) self.startCommand(cmd, warnings) class TreeSize(ShellCommand): name = "treesize" command = ["du", "-s", "-k", "."] description = "measuring tree size" descriptionDone = "tree size measured" kib = None def commandComplete(self, cmd): out = cmd.logs['stdio'].getText() m = re.search(r'^(\d+)', out) if m: self.kib = int(m.group(1)) self.setProperty("tree-size-KiB", self.kib, "treesize") def evaluateCommand(self, cmd): if cmd.didFail(): return FAILURE if self.kib is None: return WARNINGS # not sure how 'du' could fail, but whatever return SUCCESS def getText(self, cmd, results): if self.kib is not None: return ["treesize", "%d KiB" % self.kib] return ["treesize", "unknown"] class SetPropertyFromCommand(ShellCommand): name = "setproperty" renderables = [ 'property' ] def __init__(self, property=None, extract_fn=None, strip=True, **kwargs): self.property = property self.extract_fn = extract_fn self.strip = strip if not ((property is not None) ^ (extract_fn is not None)): config.error( "Exactly one of property and extract_fn must be set") ShellCommand.__init__(self, **kwargs) self.property_changes = {} def commandComplete(self, cmd): if self.property: if cmd.didFail(): return result = cmd.logs['stdio'].getText() if self.strip: result = result.strip() propname = self.property self.setProperty(propname, result, "SetProperty Step") self.property_changes[propname] = result else: log = cmd.logs['stdio'] new_props = self.extract_fn(cmd.rc, ''.join(log.getChunks([STDOUT], onlyText=True)), ''.join(log.getChunks([STDERR], onlyText=True))) for k,v in new_props.items(): self.setProperty(k, v, "SetProperty Step") self.property_changes = new_props def createSummary(self, log): if self.property_changes: props_set = [ "%s: %r" % (k,v) for k,v in self.property_changes.items() ] self.addCompleteLog('property changes', "\n".join(props_set)) def getText(self, cmd, results): if len(self.property_changes) > 1: return [ "%d properties set" % len(self.property_changes) ] elif len(self.property_changes) == 1: return [ "property '%s' set" % self.property_changes.keys()[0] ] else: # let ShellCommand describe return ShellCommand.getText(self, cmd, results) SetProperty = SetPropertyFromCommand deprecatedModuleAttribute(Version("Buildbot", 0, 8, 8), "It has been renamed to SetPropertyFromCommand", "buildbot.steps.shell", "SetProperty") class Configure(ShellCommand): name = "configure" haltOnFailure = 1 flunkOnFailure = 1 description = ["configuring"] descriptionDone = ["configure"] command = ["./configure"] class StringFileWriter(pb.Referenceable): """ FileWriter class that just puts received data into a buffer. Used to upload a file from slave for inline processing rather than writing into a file on master. """ def __init__(self): self.buffer = "" def remote_write(self, data): self.buffer += data def remote_close(self): pass class WarningCountingShellCommand(ShellCommand): renderables = [ 'suppressionFile' ] warnCount = 0 warningPattern = '.*warning[: ].*' # The defaults work for GNU Make. directoryEnterPattern = (u"make.*: Entering directory " u"[\u2019\"`'](.*)[\u2019'`\"]") directoryLeavePattern = "make.*: Leaving directory" suppressionFile = None commentEmptyLineRe = re.compile(r"^\s*(\#.*)?$") suppressionLineRe = re.compile(r"^\s*(.+?)\s*:\s*(.+?)\s*(?:[:]\s*([0-9]+)(?:-([0-9]+))?\s*)?$") def __init__(self, warningPattern=None, warningExtractor=None, maxWarnCount=None, directoryEnterPattern=None, directoryLeavePattern=None, suppressionFile=None, **kwargs): # See if we've been given a regular expression to use to match # warnings. If not, use a default that assumes any line with "warning" # present is a warning. This may lead to false positives in some cases. if warningPattern: self.warningPattern = warningPattern if directoryEnterPattern: self.directoryEnterPattern = directoryEnterPattern if directoryLeavePattern: self.directoryLeavePattern = directoryLeavePattern if suppressionFile: self.suppressionFile = suppressionFile if warningExtractor: self.warningExtractor = warningExtractor else: self.warningExtractor = WarningCountingShellCommand.warnExtractWholeLine self.maxWarnCount = maxWarnCount # And upcall to let the base class do its work ShellCommand.__init__(self, **kwargs) self.suppressions = [] self.directoryStack = [] def addSuppression(self, suppressionList): """ This method can be used to add patters of warnings that should not be counted. It takes a single argument, a list of patterns. Each pattern is a 4-tuple (FILE-RE, WARN-RE, START, END). FILE-RE is a regular expression (string or compiled regexp), or None. If None, the pattern matches all files, else only files matching the regexp. If directoryEnterPattern is specified in the class constructor, matching is against the full path name, eg. src/main.c. WARN-RE is similarly a regular expression matched against the text of the warning, or None to match all warnings. START and END form an inclusive line number range to match against. If START is None, there is no lower bound, similarly if END is none there is no upper bound.""" for fileRe, warnRe, start, end in suppressionList: if fileRe != None and isinstance(fileRe, basestring): fileRe = re.compile(fileRe) if warnRe != None and isinstance(warnRe, basestring): warnRe = re.compile(warnRe) self.suppressions.append((fileRe, warnRe, start, end)) def warnExtractWholeLine(self, line, match): """ Extract warning text as the whole line. No file names or line numbers.""" return (None, None, line) def warnExtractFromRegexpGroups(self, line, match): """ Extract file name, line number, and warning text as groups (1,2,3) of warningPattern match.""" file = match.group(1) lineNo = match.group(2) if lineNo != None: lineNo = int(lineNo) text = match.group(3) return (file, lineNo, text) def maybeAddWarning(self, warnings, line, match): if self.suppressions: (file, lineNo, text) = self.warningExtractor(self, line, match) lineNo = lineNo and int(lineNo) if file != None and file != "" and self.directoryStack: currentDirectory = '/'.join(self.directoryStack) if currentDirectory != None and currentDirectory != "": file = "%s/%s" % (currentDirectory, file) # Skip adding the warning if any suppression matches. for fileRe, warnRe, start, end in self.suppressions: if not (file == None or fileRe == None or fileRe.match(file)): continue if not (warnRe == None or warnRe.search(text)): continue if not ((start == None and end == None) or (lineNo != None and start <= lineNo and end >= lineNo)): continue return warnings.append(line) self.warnCount += 1 def start(self): if self.suppressionFile == None: return ShellCommand.start(self) self.myFileWriter = StringFileWriter() args = { 'slavesrc': self.suppressionFile, 'workdir': self.getWorkdir(), 'writer': self.myFileWriter, 'maxsize': None, 'blocksize': 32*1024, } cmd = buildstep.RemoteCommand('uploadFile', args, ignore_updates=True) d = self.runCommand(cmd) d.addCallback(self.uploadDone) d.addErrback(self.failed) def uploadDone(self, dummy): lines = self.myFileWriter.buffer.split("\n") del(self.myFileWriter) list = [] for line in lines: if self.commentEmptyLineRe.match(line): continue match = self.suppressionLineRe.match(line) if (match): file, test, start, end = match.groups() if (end != None): end = int(end) if (start != None): start = int(start) if end == None: end = start list.append((file, test, start, end)) self.addSuppression(list) return ShellCommand.start(self) def createSummary(self, log): """ Match log lines against warningPattern. Warnings are collected into another log for this step, and the build-wide 'warnings-count' is updated.""" self.warnCount = 0 # Now compile a regular expression from whichever warning pattern we're # using wre = self.warningPattern if isinstance(wre, str): wre = re.compile(wre) directoryEnterRe = self.directoryEnterPattern if (directoryEnterRe != None and isinstance(directoryEnterRe, basestring)): directoryEnterRe = re.compile(directoryEnterRe) directoryLeaveRe = self.directoryLeavePattern if (directoryLeaveRe != None and isinstance(directoryLeaveRe, basestring)): directoryLeaveRe = re.compile(directoryLeaveRe) # Check if each line in the output from this command matched our # warnings regular expressions. If did, bump the warnings count and # add the line to the collection of lines with warnings warnings = [] # TODO: use log.readlines(), except we need to decide about stdout vs # stderr for line in log.getText().split("\n"): if directoryEnterRe: match = directoryEnterRe.search(line) if match: self.directoryStack.append(match.group(1)) continue if (directoryLeaveRe and self.directoryStack and directoryLeaveRe.search(line)): self.directoryStack.pop() continue match = wre.match(line) if match: self.maybeAddWarning(warnings, line, match) # If there were any warnings, make the log if lines with warnings # available if self.warnCount: self.addCompleteLog("warnings (%d)" % self.warnCount, "\n".join(warnings) + "\n") warnings_stat = self.step_status.getStatistic('warnings', 0) self.step_status.setStatistic('warnings', warnings_stat + self.warnCount) old_count = self.getProperty("warnings-count", 0) self.setProperty("warnings-count", old_count + self.warnCount, "WarningCountingShellCommand") def evaluateCommand(self, cmd): if ( cmd.didFail() or ( self.maxWarnCount != None and self.warnCount > self.maxWarnCount ) ): return FAILURE if self.warnCount: return WARNINGS return SUCCESS class Compile(WarningCountingShellCommand): name = "compile" haltOnFailure = 1 flunkOnFailure = 1 description = ["compiling"] descriptionDone = ["compile"] command = ["make", "all"] class Test(WarningCountingShellCommand): name = "test" warnOnFailure = 1 description = ["testing"] descriptionDone = ["test"] command = ["make", "test"] def setTestResults(self, total=0, failed=0, passed=0, warnings=0): """ Called by subclasses to set the relevant statistics; this actually adds to any statistics already present """ total += self.step_status.getStatistic('tests-total', 0) self.step_status.setStatistic('tests-total', total) failed += self.step_status.getStatistic('tests-failed', 0) self.step_status.setStatistic('tests-failed', failed) warnings += self.step_status.getStatistic('tests-warnings', 0) self.step_status.setStatistic('tests-warnings', warnings) passed += self.step_status.getStatistic('tests-passed', 0) self.step_status.setStatistic('tests-passed', passed) def describe(self, done=False): description = WarningCountingShellCommand.describe(self, done) if done: description = description[:] # make a private copy if self.step_status.hasStatistic('tests-total'): total = self.step_status.getStatistic("tests-total", 0) failed = self.step_status.getStatistic("tests-failed", 0) passed = self.step_status.getStatistic("tests-passed", 0) warnings = self.step_status.getStatistic("tests-warnings", 0) if not total: total = failed + passed + warnings if total: description.append('%d tests' % total) if passed: description.append('%d passed' % passed) if warnings: description.append('%d warnings' % warnings) if failed: description.append('%d failed' % failed) return description class PerlModuleTest(Test): command=["prove", "--lib", "lib", "-r", "t"] total = 0 def evaluateCommand(self, cmd): # Get stdio, stripping pesky newlines etc. lines = map( lambda line : line.replace('\r\n','').replace('\r','').replace('\n',''), self.getLog('stdio').readlines() ) total = 0 passed = 0 failed = 0 rc = SUCCESS if cmd.didFail(): rc = FAILURE # New version of Test::Harness? if "Test Summary Report" in lines: test_summary_report_index = lines.index("Test Summary Report") del lines[0:test_summary_report_index + 2] re_test_result = re.compile("^Result: (PASS|FAIL)$|Tests: \d+ Failed: (\d+)\)|Files=\d+, Tests=(\d+)") mos = map(lambda line: re_test_result.search(line), lines) test_result_lines = [mo.groups() for mo in mos if mo] for line in test_result_lines: if line[0] == 'FAIL': rc = FAILURE if line[1]: failed += int(line[1]) if line[2]: total = int(line[2]) else: # Nope, it's the old version re_test_result = re.compile("^(All tests successful)|(\d+)/(\d+) subtests failed|Files=\d+, Tests=(\d+),") mos = map(lambda line: re_test_result.search(line), lines) test_result_lines = [mo.groups() for mo in mos if mo] if test_result_lines: test_result_line = test_result_lines[0] success = test_result_line[0] if success: failed = 0 test_totals_line = test_result_lines[1] total_str = test_totals_line[3] else: failed_str = test_result_line[1] failed = int(failed_str) total_str = test_result_line[2] rc = FAILURE total = int(total_str) warnings = 0 if self.warningPattern: wre = self.warningPattern if isinstance(wre, str): wre = re.compile(wre) warnings = len([l for l in lines if wre.search(l)]) # Because there are two paths that are used to determine # the success/fail result, I have to modify it here if # there were warnings. if rc == SUCCESS and warnings: rc = WARNINGS if total: passed = total - failed self.setTestResults(total=total, failed=failed, passed=passed, warnings=warnings) return rc buildbot-0.8.8/buildbot/steps/slave.py000066400000000000000000000223521222546025000177630ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import stat from twisted.internet import defer from buildbot.process import buildstep from buildbot.status.results import SUCCESS, FAILURE from buildbot.interfaces import BuildSlaveTooOldError class SlaveBuildStep(buildstep.BuildStep): def describe(self, done=False): return self.descriptionDone if done else self.description class SetPropertiesFromEnv(SlaveBuildStep): """ Sets properties from envirionment variables on the slave. Note this is transfered when the slave first connects """ name='SetPropertiesFromEnv' description=['Setting'] descriptionDone=['Set'] def __init__(self, variables, source="SlaveEnvironment", **kwargs): buildstep.BuildStep.__init__(self, **kwargs) self.variables = variables self.source = source def start(self): # on Windows, environment variables are case-insensitive, but we have # a case-sensitive dictionary in slave_environ. Fortunately, that # dictionary is also folded to uppercase, so we can simply fold the # variable names to uppercase to duplicate the case-insensitivity. fold_to_uppercase = (self.buildslave.slave_system == 'win32') properties = self.build.getProperties() environ = self.buildslave.slave_environ variables = self.variables log = [] if isinstance(variables, str): variables = [self.variables] for variable in variables: key = variable if fold_to_uppercase: key = variable.upper() value = environ.get(key, None) if value: # note that the property is not uppercased properties.setProperty(variable, value, self.source, runtime=True) log.append("%s = %r" % (variable, value)) self.addCompleteLog("properties", "\n".join(log)) self.step_status.setText(self.describe(done=True)) self.finished(SUCCESS) class FileExists(SlaveBuildStep): """ Check for the existence of a file on the slave. """ name='FileExists' description='Checking' descriptionDone='Checked' renderables = [ 'file' ] haltOnFailure = True flunkOnFailure = True def __init__(self, file, **kwargs): buildstep.BuildStep.__init__(self, **kwargs) self.file = file def start(self): slavever = self.slaveVersion('stat') if not slavever: raise BuildSlaveTooOldError("slave is too old, does not know " "about stat") cmd = buildstep.RemoteCommand('stat', {'file': self.file }) d = self.runCommand(cmd) d.addCallback(lambda res: self.commandComplete(cmd)) d.addErrback(self.failed) def commandComplete(self, cmd): if cmd.didFail(): self.step_status.setText(["File not found."]) self.finished(FAILURE) return s = cmd.updates["stat"][-1] if stat.S_ISREG(s[stat.ST_MODE]): self.step_status.setText(["File found."]) self.finished(SUCCESS) else: self.step_status.setText(["Not a file."]) self.finished(FAILURE) class CopyDirectory(SlaveBuildStep): """ Copy a directory tree on the slave. """ name='CopyDirectory' description=['Copying'] descriptionDone=['Copied'] renderables = [ 'src', 'dest' ] haltOnFailure = True flunkOnFailure = True def __init__(self, src, dest, timeout=None, maxTime=None, **kwargs): buildstep.BuildStep.__init__(self, **kwargs) self.src = src self.dest = dest self.timeout = timeout self.maxTime = maxTime def start(self): slavever = self.slaveVersion('cpdir') if not slavever: raise BuildSlaveTooOldError("slave is too old, does not know " "about cpdir") args = {'fromdir': self.src, 'todir': self.dest } if self.timeout: args['timeout'] = self.timeout if self.maxTime: args['maxTime'] = self.maxTime cmd = buildstep.RemoteCommand('cpdir', args) d = self.runCommand(cmd) d.addCallback(lambda res: self.commandComplete(cmd)) d.addErrback(self.failed) def commandComplete(self, cmd): if cmd.didFail(): self.step_status.setText(["Copying", self.src, "to", self.dest, "failed."]) self.finished(FAILURE) return self.step_status.setText(self.describe(done=True)) self.finished(SUCCESS) def describe(self, done=False): desc = self.descriptionDone if done else self.description desc = desc[:] desc.extend([self.src, "to", self.dest]) return desc class RemoveDirectory(SlaveBuildStep): """ Remove a directory tree on the slave. """ name='RemoveDirectory' description=['Deleting'] descriptionDone=['Deleted'] renderables = [ 'dir' ] haltOnFailure = True flunkOnFailure = True def __init__(self, dir, **kwargs): buildstep.BuildStep.__init__(self, **kwargs) self.dir = dir def start(self): slavever = self.slaveVersion('rmdir') if not slavever: raise BuildSlaveTooOldError("slave is too old, does not know " "about rmdir") cmd = buildstep.RemoteCommand('rmdir', {'dir': self.dir }) d = self.runCommand(cmd) d.addCallback(lambda res: self.commandComplete(cmd)) d.addErrback(self.failed) def commandComplete(self, cmd): if cmd.didFail(): self.step_status.setText(["Delete failed."]) self.finished(FAILURE) return self.step_status.setText(self.describe(done=True)) self.finished(SUCCESS) class MakeDirectory(SlaveBuildStep): """ Create a directory on the slave. """ name='MakeDirectory' description=['Creating'] descriptionDone=['Created'] renderables = [ 'dir' ] haltOnFailure = True flunkOnFailure = True def __init__(self, dir, **kwargs): buildstep.BuildStep.__init__(self, **kwargs) self.dir = dir def start(self): slavever = self.slaveVersion('mkdir') if not slavever: raise BuildSlaveTooOldError("slave is too old, does not know " "about mkdir") cmd = buildstep.RemoteCommand('mkdir', {'dir': self.dir }) d = self.runCommand(cmd) d.addCallback(lambda res: self.commandComplete(cmd)) d.addErrback(self.failed) def commandComplete(self, cmd): if cmd.didFail(): self.step_status.setText(["Create failed."]) self.finished(FAILURE) return self.step_status.setText(self.describe(done=True)) self.finished(SUCCESS) class CompositeStepMixin(): """I define utils for composite steps, factorizing basic remote commands""" def addLogForRemoteCommands(self, logname): """This method must be called by user classes composite steps could create several logs, this mixin functions will write to the last one. """ self.rc_log = self.addLog(logname) return self.rc_log def runRemoteCommand(self, cmd, args, abandonOnFailure=True): """generic RemoteCommand boilerplate""" cmd = buildstep.RemoteCommand(cmd, args) cmd.useLog(self.rc_log, False) d = self.runCommand(cmd) def commandComplete(cmd): if abandonOnFailure and cmd.didFail(): raise buildstep.BuildStepFailed() return cmd.didFail() d.addCallback(lambda res: commandComplete(cmd)) return d def runRmdir(self, dir, **kwargs): """ remove a directory from the slave """ return self.runRemoteCommand('rmdir', {'dir': dir, 'logEnviron': self.logEnviron }, **kwargs) @defer.inlineCallbacks def pathExists(self, path): """ test whether path exists""" res = yield self.runRemoteCommand('stat', {'file': path, 'logEnviron': self.logEnviron,}, abandonOnFailure=False) defer.returnValue(not res) def runMkdir(self, _dir, **kwargs): """ create a directory and its parents""" return self.runRemoteCommand('mkdir', {'dir': _dir, 'logEnviron': self.logEnviron,}, **kwargs) buildbot-0.8.8/buildbot/steps/source/000077500000000000000000000000001222546025000175735ustar00rootroot00000000000000buildbot-0.8.8/buildbot/steps/source/__init__.py000066400000000000000000000017011222546025000217030ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.steps.source.base import Source from buildbot.steps.source.oldsource import CVS, \ SVN, Git, Darcs, Repo, Bzr, Mercurial, P4, Monotone, BK _hush_pyflakes = [ Source, CVS, SVN, \ Git, Darcs, Repo, Bzr, Mercurial, P4, Monotone, BK ] buildbot-0.8.8/buildbot/steps/source/base.py000066400000000000000000000233261222546025000210650ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.python import log from buildbot.process.buildstep import LoggingBuildStep from buildbot.status.builder import SKIPPED, FAILURE from buildbot.steps.slave import CompositeStepMixin class Source(LoggingBuildStep, CompositeStepMixin): """This is a base class to generate a source tree in the buildslave. Each version control system has a specialized subclass, and is expected to override __init__ and implement computeSourceRevision() and startVC(). The class as a whole builds up the self.args dictionary, then starts a RemoteCommand with those arguments. """ renderables = LoggingBuildStep.renderables + [ 'description', 'descriptionDone', 'descriptionSuffix', 'workdir' ] description = None # set this to a list of short strings to override descriptionDone = None # alternate description when the step is complete descriptionSuffix = None # extra information to append to suffix # if the checkout fails, there's no point in doing anything else haltOnFailure = True flunkOnFailure = True notReally = False branch = None # the default branch, should be set in __init__ def __init__(self, workdir=None, mode='update', alwaysUseLatest=False, timeout=20*60, retry=None, env=None, logEnviron=True, description=None, descriptionDone=None, descriptionSuffix=None, codebase='', **kwargs): """ @type workdir: string @param workdir: local directory (relative to the Builder's root) where the tree should be placed @type alwaysUseLatest: boolean @param alwaysUseLatest: whether to always update to the most recent available sources for this build. Normally the Source step asks its Build for a list of all Changes that are supposed to go into the build, then computes a 'source stamp' (revision number or timestamp) that will cause exactly that set of changes to be present in the checked out tree. This is turned into, e.g., 'cvs update -D timestamp', or 'svn update -r revnum'. If alwaysUseLatest=True, bypass this computation and always update to the latest available sources for each build. The source stamp helps avoid a race condition in which someone commits a change after the master has decided to start a build but before the slave finishes checking out the sources. At best this results in a build which contains more changes than the buildmaster thinks it has (possibly resulting in the wrong person taking the blame for any problems that result), at worst is can result in an incoherent set of sources (splitting a non-atomic commit) which may not build at all. @type logEnviron: boolean @param logEnviron: If this option is true (the default), then the step's logfile will describe the environment variables on the slave. In situations where the environment is not relevant and is long, it may be easier to set logEnviron=False. @type codebase: string @param codebase: Specifies which changes in a build are processed by the step. The default codebase value is ''. The codebase must correspond to a codebase assigned by the codebaseGenerator. If no codebaseGenerator is defined in the master then codebase doesn't need to be set, the default value will then match all changes. """ LoggingBuildStep.__init__(self, **kwargs) # This will get added to args later, after properties are rendered self.workdir = workdir self.sourcestamp = None self.codebase = codebase if self.codebase: self.name = ' '.join((self.name, self.codebase)) self.alwaysUseLatest = alwaysUseLatest self.logEnviron = logEnviron self.env = env self.timeout = timeout descriptions_for_mode = { "clobber": "checkout", "export": "exporting"} descriptionDones_for_mode = { "clobber": "checkout", "export": "export"} if description: self.description = description else: self.description = [ descriptions_for_mode.get(mode, "updating")] if isinstance(self.description, str): self.description = [self.description] if descriptionDone: self.descriptionDone = descriptionDone else: self.descriptionDone = [ descriptionDones_for_mode.get(mode, "update")] if isinstance(self.descriptionDone, str): self.descriptionDone = [self.descriptionDone] if descriptionSuffix: self.descriptionSuffix = descriptionSuffix else: self.descriptionSuffix = self.codebase or None # want None in lieu of '' if isinstance(self.descriptionSuffix, str): self.descriptionSuffix = [self.descriptionSuffix] def updateSourceProperty(self, name, value, source=''): """ Update a property, indexing the property by codebase if codebase is not ''. Source steps should generally use this instead of setProperty. """ # pick a decent source name if source == '': source = self.__class__.__name__ if self.codebase != '': assert not isinstance(self.getProperty(name, None), str), \ "Sourcestep %s has a codebase, other sourcesteps don't" \ % self.name property_dict = self.getProperty(name, {}) property_dict[self.codebase] = value LoggingBuildStep.setProperty(self, name, property_dict, source) else: assert not isinstance(self.getProperty(name, None), dict), \ "Sourcestep %s does not have a codebase, other sourcesteps do" \ % self.name LoggingBuildStep.setProperty(self, name, value, source) def setStepStatus(self, step_status): LoggingBuildStep.setStepStatus(self, step_status) def setDefaultWorkdir(self, workdir): self.workdir = self.workdir or workdir def describe(self, done=False): desc = self.descriptionDone if done else self.description if self.descriptionSuffix: desc = desc[:] desc.extend(self.descriptionSuffix) return desc def computeSourceRevision(self, changes): """Each subclass must implement this method to do something more precise than -rHEAD every time. For version control systems that use repository-wide change numbers (SVN, P4), this can simply take the maximum such number from all the changes involved in this build. For systems that do not (CVS), it needs to create a timestamp based upon the latest Change, the Build's treeStableTimer, and an optional self.checkoutDelay value.""" return None def start(self): if self.notReally: log.msg("faking %s checkout/update" % self.name) self.step_status.setText(["fake", self.name, "successful"]) self.addCompleteLog("log", "Faked %s checkout/update 'successful'\n" \ % self.name) return SKIPPED if not self.alwaysUseLatest: # what source stamp would this step like to use? s = self.build.getSourceStamp(self.codebase) self.sourcestamp = s if self.sourcestamp: # if branch is None, then use the Step's "default" branch branch = s.branch or self.branch # if revision is None, use the latest sources (-rHEAD) revision = s.revision if not revision: revision = self.computeSourceRevision(s.changes) # the revision property is currently None, so set it to something # more interesting if revision is not None: self.updateSourceProperty('revision', str(revision)) # if patch is None, then do not patch the tree after checkout # 'patch' is None or a tuple of (patchlevel, diff, root) # root is optional. patch = s.patch if patch: self.addCompleteLog("patch", patch[1]) else: log.msg("No sourcestamp found in build for codebase '%s'" % self.codebase) self.step_status.setText(["Codebase", '%s' % self.codebase ,"not", "in", "build" ]) self.addCompleteLog("log", "No sourcestamp found in build for codebase '%s'" \ % self.codebase) self.finished(FAILURE) return FAILURE else: revision = None branch = self.branch patch = None self.startVC(branch, revision, patch) buildbot-0.8.8/buildbot/steps/source/bzr.py000066400000000000000000000205161222546025000207460ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os from twisted.python import log from twisted.internet import defer from buildbot.process import buildstep from buildbot.steps.source.base import Source from buildbot.interfaces import BuildSlaveTooOldError class Bzr(Source): name = 'bzr' renderables = [ 'repourl', 'baseURL' ] def __init__(self, repourl=None, baseURL=None, mode='incremental', method=None, defaultBranch=None, **kwargs): self.repourl = repourl self.baseURL = baseURL self.branch = defaultBranch self.mode = mode self.method = method Source.__init__(self, **kwargs) if repourl and baseURL: raise ValueError("you must provide exactly one of repourl and" " baseURL") if repourl is None and baseURL is None: raise ValueError("you must privide at least one of repourl and" " baseURL") if baseURL is not None and defaultBranch is None: raise ValueError("you must provide defaultBranch with baseURL") assert self.mode in ['incremental', 'full'] if self.mode == 'full': assert self.method in ['clean', 'fresh', 'clobber', 'copy', None] def startVC(self, branch, revision, patch): if branch: self.branch = branch self.revision = revision self.method = self._getMethod() self.stdio_log = self.addLogForRemoteCommands("stdio") if self.repourl is None: self.repourl = os.path.join(self.baseURL, self.branch) d = self.checkBzr() def checkInstall(bzrInstalled): if not bzrInstalled: raise BuildSlaveTooOldError("bzr is not installed on slave") return 0 d.addCallback(checkInstall) if self.mode == 'full': d.addCallback(lambda _: self.full()) elif self.mode == 'incremental': d.addCallback(lambda _: self.incremental()) d.addCallback(self.parseGotRevision) d.addCallback(self.finish) d.addErrback(self.failed) return d def incremental(self): d = self._sourcedirIsUpdatable() def _cmd(updatable): if updatable: command = ['update'] else: command = ['checkout', self.repourl, '.'] if self.revision: command.extend(['-r', self.revision]) return command d.addCallback(_cmd) d.addCallback(self._dovccmd) return d @defer.inlineCallbacks def full(self): if self.method == 'clobber': yield self.clobber() return elif self.method == 'copy': self.workdir = 'source' yield self.copy() return updatable = self._sourcedirIsUpdatable() if not updatable: log.msg("No bzr repo present, making full checkout") yield self._doFull() elif self.method == 'clean': yield self.clean() elif self.method == 'fresh': yield self.fresh() else: raise ValueError("Unknown method, check your configuration") def clobber(self): cmd = buildstep.RemoteCommand('rmdir', {'dir': self.workdir, 'logEnviron': self.logEnviron,}) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) def checkRemoval(res): if res != 0: raise RuntimeError("Failed to delete directory") return res d.addCallback(lambda _: checkRemoval(cmd.rc)) d.addCallback(lambda _: self._doFull()) return d def copy(self): cmd = buildstep.RemoteCommand('rmdir', {'dir': 'build', 'logEnviron': self.logEnviron,}) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) d.addCallback(lambda _: self.incremental()) def copy(_): cmd = buildstep.RemoteCommand('cpdir', {'fromdir': 'source', 'todir':'build', 'logEnviron': self.logEnviron,}) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) return d d.addCallback(copy) return d def clean(self): d = self._dovccmd(['clean-tree', '--ignored', '--force']) command = ['update'] if self.revision: command.extend(['-r', self.revision]) d.addCallback(lambda _: self._dovccmd(command)) return d def fresh(self): d = self._dovccmd(['clean-tree', '--force']) command = ['update'] if self.revision: command.extend(['-r', self.revision]) d.addCallback(lambda _: self._dovccmd(command)) return d def _doFull(self): command = ['checkout', self.repourl, '.'] if self.revision: command.extend(['-r', self.revision]) d = self._dovccmd(command) return d def finish(self, res): d = defer.succeed(res) def _gotResults(results): self.setStatus(self.cmd, results) log.msg("Closing log, sending result of the command %s " % \ (self.cmd)) return results d.addCallback(_gotResults) d.addCallbacks(self.finished, self.checkDisconnect) return d def _sourcedirIsUpdatable(self): return self.pathExists(self.build.path_module.join(self.workdir, '.bzr')) def computeSourceRevision(self, changes): if not changes: return None lastChange = max([int(c.revision) for c in changes]) return lastChange def _dovccmd(self, command, abandonOnFailure=True, collectStdout=False): cmd = buildstep.RemoteShellCommand(self.workdir, ['bzr'] + command, env=self.env, logEnviron=self.logEnviron, timeout=self.timeout, collectStdout=collectStdout) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) def evaluateCommand(cmd): if abandonOnFailure and cmd.didFail(): log.msg("Source step failed while running command %s" % cmd) raise buildstep.BuildStepFailed() if collectStdout: return cmd.stdout else: return cmd.rc d.addCallback(lambda _: evaluateCommand(cmd)) return d def checkBzr(self): d = self._dovccmd(['--version']) def check(res): if res == 0: return True return False d.addCallback(check) return d def _getMethod(self): if self.method is not None and self.mode != 'incremental': return self.method elif self.mode == 'incremental': return None elif self.method is None and self.mode == 'full': return 'fresh' def parseGotRevision(self, _): d = self._dovccmd(["version-info", "--custom", "--template='{revno}"], collectStdout=True) def setrev(stdout): revision = stdout.strip("'") try: int(revision) except ValueError: log.msg("Invalid revision number") raise buildstep.BuildStepFailed() log.msg("Got Git revision %s" % (revision, )) self.updateSourceProperty('got_revision', revision) return 0 d.addCallback(setrev) return d buildbot-0.8.8/buildbot/steps/source/cvs.py000066400000000000000000000244341222546025000207470ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from email.Utils import formatdate import time import re from twisted.python import log from twisted.internet import defer from buildbot.process import buildstep from buildbot.steps.shell import StringFileWriter from buildbot.steps.source.base import Source from buildbot.interfaces import BuildSlaveTooOldError class CVS(Source): name = "cvs" renderables = [ "cvsroot" ] def __init__(self, cvsroot=None, cvsmodule='', mode='incremental', method=None, branch=None, global_options=[], extra_options=[], login=None, **kwargs): self.cvsroot = cvsroot self.cvsmodule = cvsmodule self.branch = branch self.global_options = global_options self.extra_options = extra_options self.login = login self.mode = mode self.method = method self.srcdir = 'source' Source.__init__(self, **kwargs) def startVC(self, branch, revision, patch): self.branch = branch self.revision = revision self.stdio_log = self.addLog("stdio") self.method = self._getMethod() d = self.checkCvs() def checkInstall(cvsInstalled): if not cvsInstalled: raise BuildSlaveTooOldError("CVS is not installed on slave") return 0 d.addCallback(checkInstall) d.addCallback(self.checkLogin) if self.mode == 'incremental': d.addCallback(lambda _: self.incremental()) elif self.mode == 'full': d.addCallback(lambda _: self.full()) d.addCallback(self.parseGotRevision) d.addCallback(self.finish) d.addErrback(self.failed) return d @defer.inlineCallbacks def incremental(self): updatable = yield self._sourcedirIsUpdatable() if updatable: rv = yield self.doUpdate() else: rv = yield self.clobber() defer.returnValue(rv) @defer.inlineCallbacks def full(self): if self.method == 'clobber': rv = yield self.clobber() defer.returnValue(rv) return elif self.method == 'copy': rv = yield self.copy() defer.returnValue(rv) return updatable = yield self._sourcedirIsUpdatable() if not updatable: log.msg("CVS repo not present, making full checkout") rv = yield self.doCheckout(self.workdir) elif self.method == 'clean': rv = yield self.clean() elif self.method == 'fresh': rv = yield self.fresh() else: raise ValueError("Unknown method, check your configuration") defer.returnValue(rv) def clobber(self): cmd = buildstep.RemoteCommand('rmdir', {'dir': self.workdir, 'logEnviron': self.logEnviron}) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) def checkRemoval(res): if res != 0: raise RuntimeError("Failed to delete directory") return res d.addCallback(lambda _: checkRemoval(cmd.rc)) d.addCallback(lambda _: self.doCheckout(self.workdir)) return d def fresh(self, ): d = self.purge(True) d.addCallback(lambda _: self.doUpdate()) return d def clean(self, ): d = self.purge(False) d.addCallback(lambda _: self.doUpdate()) return d def copy(self): cmd = buildstep.RemoteCommand('rmdir', {'dir': self.workdir, 'logEnviron': self.logEnviron}) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) self.workdir = 'source' d.addCallback(lambda _: self.incremental()) def copy(_): cmd = buildstep.RemoteCommand('cpdir', {'fromdir': 'source', 'todir':'build', 'logEnviron': self.logEnviron,}) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) return d d.addCallback(copy) def resetWorkdir(_): self.workdir = 'build' return 0 d.addCallback(resetWorkdir) return d def purge(self, ignore_ignores): command = ['cvsdiscard'] if ignore_ignores: command += ['--ignore'] cmd = buildstep.RemoteShellCommand(self.workdir, command, env=self.env, logEnviron=self.logEnviron, timeout=self.timeout) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) def evaluate(cmd): if cmd.didFail(): raise buildstep.BuildStepFailed() return cmd.rc d.addCallback(evaluate) return d def doCheckout(self, dir): command = ['-d', self.cvsroot, '-z3', 'checkout', '-d', dir ] command = self.global_options + command + self.extra_options if self.branch: command += ['-r', self.branch] if self.revision: command += ['-D', self.revision] command += [ self.cvsmodule ] d = self._dovccmd(command, '') return d def doUpdate(self): command = ['-z3', 'update', '-dP'] branch = self.branch # special case. 'cvs update -r HEAD -D today' gives no files; see #2351 if branch == 'HEAD' and self.revision: branch = None if branch: command += ['-r', self.branch] if self.revision: command += ['-D', self.revision] d = self._dovccmd(command) return d def finish(self, res): d = defer.succeed(res) def _gotResults(results): self.setStatus(self.cmd, results) return results d.addCallback(_gotResults) d.addCallbacks(self.finished, self.checkDisconnect) return d def checkLogin(self, _): if self.login: d = defer.succeed(0) else: d = self._dovccmd(['-d', self.cvsroot, 'login']) def setLogin(res): # this happens only if the login command succeeds. self.login = True return res d.addCallback(setLogin) return d def _dovccmd(self, command, workdir=None): if workdir is None: workdir = self.workdir if not command: raise ValueError("No command specified") cmd = buildstep.RemoteShellCommand(workdir, ['cvs'] + command, env=self.env, timeout=self.timeout, logEnviron=self.logEnviron) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) def evaluateCommand(cmd): if cmd.rc != 0: log.msg("Source step failed while running command %s" % cmd) raise buildstep.BuildStepFailed() return cmd.rc d.addCallback(lambda _: evaluateCommand(cmd)) return d @defer.inlineCallbacks def _sourcedirIsUpdatable(self): myFileWriter = StringFileWriter() args = { 'workdir': self.build.path_module.join(self.workdir, 'CVS'), 'writer': myFileWriter, 'maxsize': None, 'blocksize': 32*1024, } cmd = buildstep.RemoteCommand('uploadFile', dict(slavesrc='Root', **args), ignore_updates=True) yield self.runCommand(cmd) if cmd.rc is not None and cmd.rc != 0: defer.returnValue(False) return # on Windows, the cvsroot may not contain the password, so compare to # both cvsroot_without_pw = re.sub("(:pserver:[^:]*):[^@]*(@.*)", r"\1\2", self.cvsroot) if myFileWriter.buffer.strip() not in (self.cvsroot, cvsroot_without_pw): defer.returnValue(False) return myFileWriter.buffer = "" cmd = buildstep.RemoteCommand('uploadFile', dict(slavesrc='Repository', **args), ignore_updates=True) yield self.runCommand(cmd) if cmd.rc is not None and cmd.rc != 0: defer.returnValue(False) return if myFileWriter.buffer.strip() != self.cvsmodule: defer.returnValue(False) return defer.returnValue(True) def parseGotRevision(self, res): revision = time.strftime("%Y-%m-%d %H:%M:%S +0000", time.gmtime()) self.updateSourceProperty('got_revision', revision) return res def checkCvs(self): d = self._dovccmd(['--version']) def check(res): if res == 0: return True return False d.addCallback(check) return d def _getMethod(self): if self.method is not None and self.mode != 'incremental': return self.method elif self.mode == 'incremental': return None elif self.method is None and self.mode == 'full': return 'fresh' def computeSourceRevision(self, changes): if not changes: return None lastChange = max([c.when for c in changes]) lastSubmit = max([br.submittedAt for br in self.build.requests]) when = (lastChange + lastSubmit) / 2 return formatdate(when) buildbot-0.8.8/buildbot/steps/source/git.py000066400000000000000000000431141222546025000207330ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.python import log from twisted.internet import defer from buildbot import config as bbconfig from buildbot.process import buildstep from buildbot.steps.source.base import Source from buildbot.interfaces import BuildSlaveTooOldError def isTrueOrIsExactlyZero(v): # nonzero values are true... if v: return True # ... and True for the number zero, but we have to # explicitly guard against v==False, since # isinstance(False, int) is surprisingly True if isinstance(v, int) and v is not False: return True # all other false-ish values are false return False git_describe_flags = [ # on or off ('all', lambda v: ['--all'] if v else None), ('always', lambda v: ['--always'] if v else None), ('contains', lambda v: ['--contains'] if v else None), ('debug', lambda v: ['--debug'] if v else None), ('long', lambda v: ['--long'] if v else None), ('exact-match', lambda v: ['--exact-match'] if v else None), ('tags', lambda v: ['--tags'] if v else None), # string parameter ('match', lambda v: ['--match', v] if v else None), # numeric parameter ('abbrev', lambda v: ['--abbrev=%s' % v] if isTrueOrIsExactlyZero(v) else None), ('candidates', lambda v: ['--candidates=%s' % v] if isTrueOrIsExactlyZero(v) else None), # optional string parameter ('dirty', lambda v: ['--dirty'] if (v is True or v=='') else None), ('dirty', lambda v: ['--dirty=%s' % v] if (v and v is not True) else None), ] class Git(Source): """ Class for Git with all the smarts """ name='git' renderables = [ "repourl"] def __init__(self, repourl=None, branch='HEAD', mode='incremental', method=None, submodules=False, shallow=False, progress=False, retryFetch=False, clobberOnFailure=False, getDescription=False, config=None, **kwargs): """ @type repourl: string @param repourl: the URL which points at the git repository @type branch: string @param branch: The branch or tag to check out by default. If a build specifies a different branch, it will be used instead of this. @type submodules: boolean @param submodules: Whether or not to update (and initialize) git submodules. @type mode: string @param mode: Type of checkout. Described in docs. @type method: string @param method: Full builds can be done is different ways. This parameter specifies which method to use. @type progress: boolean @param progress: Pass the --progress option when fetching. This can solve long fetches getting killed due to lack of output, but requires Git 1.7.2+. @type shallow: boolean @param shallow: Use a shallow or clone, if possible @type retryFetch: boolean @param retryFetch: Retry fetching before failing source checkout. @type getDescription: boolean or dict @param getDescription: Use 'git describe' to describe the fetched revision @type config: dict @param config: Git configuration options to enable when running git """ if not getDescription and not isinstance(getDescription, dict): getDescription = False self.branch = branch self.method = method self.prog = progress self.repourl = repourl self.retryFetch = retryFetch self.submodules = submodules self.shallow = shallow self.fetchcount = 0 self.clobberOnFailure = clobberOnFailure self.mode = mode self.getDescription = getDescription self.config = config Source.__init__(self, **kwargs) if self.mode not in ['incremental', 'full']: bbconfig.error("Git: mode must be 'incremental' or 'full'.") if not self.repourl: bbconfig.error("Git: must provide repourl.") if (self.mode == 'full' and self.method not in ['clean', 'fresh', 'clobber', 'copy', None]): bbconfig.error("Git: invalid method for mode 'full'.") if self.shallow and (self.mode != 'full' or self.method != 'clobber'): bbconfig.error("Git: shallow only possible with mode 'full' and method 'clobber'.") if not isinstance(self.getDescription, (bool, dict)): bbconfig.error("Git: getDescription must be a boolean or a dict.") def startVC(self, branch, revision, patch): self.branch = branch or 'HEAD' self.revision = revision self.method = self._getMethod() self.stdio_log = self.addLogForRemoteCommands("stdio") d = self.checkGit() def checkInstall(gitInstalled): if not gitInstalled: raise BuildSlaveTooOldError("git is not installed on slave") return 0 d.addCallback(checkInstall) if self.mode == 'incremental': d.addCallback(lambda _: self.incremental()) elif self.mode == 'full': d.addCallback(lambda _: self.full()) if patch: d.addCallback(self.patch, patch) d.addCallback(self.parseGotRevision) d.addCallback(self.parseCommitDescription) d.addCallback(self.finish) d.addErrback(self.failed) return d @defer.inlineCallbacks def full(self): if self.method == 'clobber': yield self.clobber() return elif self.method == 'copy': yield self.copy() return updatable = yield self._sourcedirIsUpdatable() if not updatable: log.msg("No git repo present, making full clone") yield self._fullCloneOrFallback() elif self.method == 'clean': yield self.clean() elif self.method == 'fresh': yield self.fresh() else: raise ValueError("Unknown method, check your configuration") @defer.inlineCallbacks def incremental(self): updatable = yield self._sourcedirIsUpdatable() # if not updateable, do a full checkout if not updatable: yield self._fullCloneOrFallback() return # test for existence of the revision; rc=1 indicates it does not exist if self.revision: rc = yield self._dovccmd(['cat-file', '-e', self.revision], abandonOnFailure=False) else: rc = 1 # if revision exists checkout to that revision # else fetch and update if rc == 0: yield self._dovccmd(['reset', '--hard', self.revision, '--']) if self.branch != 'HEAD': yield self._dovccmd(['branch', '-M', self.branch], abandonOnFailure=False) else: yield self._fetchOrFallback(None) yield self._updateSubmodule(None) def clean(self): command = ['clean', '-f', '-d'] d = self._dovccmd(command) d.addCallback(self._fetchOrFallback) d.addCallback(self._updateSubmodule) d.addCallback(self._cleanSubmodule) return d def clobber(self): d = self._doClobber() d.addCallback(lambda _: self._fullClone(shallowClone=self.shallow)) return d def fresh(self): command = ['clean', '-f', '-d', '-x'] d = self._dovccmd(command) d.addCallback(self._fetchOrFallback) d.addCallback(self._updateSubmodule) d.addCallback(self._cleanSubmodule) return d def copy(self): cmd = buildstep.RemoteCommand('rmdir', {'dir': self.workdir, 'logEnviron': self.logEnviron, 'timeout': self.timeout,}) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) self.workdir = 'source' d.addCallback(lambda _: self.incremental()) def copy(_): cmd = buildstep.RemoteCommand('cpdir', {'fromdir': 'source', 'todir':'build', 'logEnviron': self.logEnviron, 'timeout': self.timeout,}) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) return d d.addCallback(copy) def resetWorkdir(_): self.workdir = 'build' return 0 d.addCallback(resetWorkdir) return d def finish(self, res): d = defer.succeed(res) def _gotResults(results): self.setStatus(self.cmd, results) log.msg("Closing log, sending result of the command %s " % \ (self.cmd)) return results d.addCallback(_gotResults) d.addCallbacks(self.finished, self.checkDisconnect) return d @defer.inlineCallbacks def parseGotRevision(self, _=None): stdout = yield self._dovccmd(['rev-parse', 'HEAD'], collectStdout=True) revision = stdout.strip() if len(revision) != 40: raise buildstep.BuildStepFailed() log.msg("Got Git revision %s" % (revision, )) self.updateSourceProperty('got_revision', revision) defer.returnValue(0) @defer.inlineCallbacks def parseCommitDescription(self, _=None): if self.getDescription==False: # dict() should not return here defer.returnValue(0) return cmd = ['describe'] if isinstance(self.getDescription, dict): for opt, arg in git_describe_flags: opt = self.getDescription.get(opt, None) arg = arg(opt) if arg: cmd.extend(arg) cmd.append('HEAD') try: stdout = yield self._dovccmd(cmd, collectStdout=True) desc = stdout.strip() self.updateSourceProperty('commit-description', desc) except: pass defer.returnValue(0) def _dovccmd(self, command, abandonOnFailure=True, collectStdout=False, initialStdin=None): full_command = ['git'] if self.config is not None: for name, value in self.config.iteritems(): full_command.append('-c') full_command.append('%s=%s' % (name, value)) full_command.extend(command) cmd = buildstep.RemoteShellCommand(self.workdir, full_command, env=self.env, logEnviron=self.logEnviron, timeout=self.timeout, collectStdout=collectStdout, initialStdin=initialStdin) cmd.useLog(self.stdio_log, False) log.msg("Starting git command : git %s" % (" ".join(command), )) d = self.runCommand(cmd) def evaluateCommand(cmd): if abandonOnFailure and cmd.didFail(): log.msg("Source step failed while running command %s" % cmd) raise buildstep.BuildStepFailed() if collectStdout: return cmd.stdout else: return cmd.rc d.addCallback(lambda _: evaluateCommand(cmd)) return d def _fetch(self, _): command = ['fetch', '-t', self.repourl, self.branch] # If the 'progress' option is set, tell git fetch to output # progress information to the log. This can solve issues with # long fetches killed due to lack of output, but only works # with Git 1.7.2 or later. if self.prog: command.append('--progress') d = self._dovccmd(command) def checkout(_): if self.revision: rev = self.revision else: rev = 'FETCH_HEAD' command = ['reset', '--hard', rev, '--'] abandonOnFailure = not self.retryFetch and not self.clobberOnFailure return self._dovccmd(command, abandonOnFailure) d.addCallback(checkout) def renameBranch(res): if res != 0: return res d = self._dovccmd(['branch', '-M', self.branch], abandonOnFailure=False) # Ignore errors d.addCallback(lambda _: res) return d if self.branch != 'HEAD': d.addCallback(renameBranch) return d @defer.inlineCallbacks def _fetchOrFallback(self, _): """ Handles fallbacks for failure of fetch, wrapper for self._fetch """ res = yield self._fetch(None) if res == 0: defer.returnValue(res) return elif self.retryFetch: yield self._fetch(None) elif self.clobberOnFailure: yield self._doClobber() yield self._fullClone() else: raise buildstep.BuildStepFailed() def _fullClone(self, shallowClone=False): """Perform full clone and checkout to the revision if specified In the case of shallow clones if any of the step fail abort whole build step. """ args = [] if self.branch != 'HEAD': args += ['--branch', self.branch] if shallowClone: args += ['--depth', '1'] command = ['clone'] + args + [self.repourl, '.'] #Fix references if self.prog: command.append('--progress') # If it's a shallow clone abort build step d = self._dovccmd(command, shallowClone) # If revision specified checkout that revision if self.revision: d.addCallback(lambda _: self._dovccmd(['reset', '--hard', self.revision, '--'], shallowClone)) # init and update submodules, recurisively. If there's not recursion # it will not do it. if self.submodules: d.addCallback(lambda _: self._dovccmd(['submodule', 'update', '--init', '--recursive'], shallowClone)) return d def _fullCloneOrFallback(self): """Wrapper for _fullClone(). In the case of failure, if clobberOnFailure is set to True remove the build directory and try a full clone again. """ d = self._fullClone() def clobber(res): if res != 0: if self.clobberOnFailure: d = self._doClobber() d.addCallback(lambda _: self._fullClone()) return d else: raise buildstep.BuildStepFailed() else: return res d.addCallback(clobber) return d def _doClobber(self): """Remove the work directory""" cmd = buildstep.RemoteCommand('rmdir', {'dir': self.workdir, 'logEnviron': self.logEnviron, 'timeout': self.timeout,}) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) def checkRemoval(res): if res != 0: raise RuntimeError("Failed to delete directory") return res d.addCallback(lambda _: checkRemoval(cmd.rc)) return d def computeSourceRevision(self, changes): if not changes: return None return changes[-1].revision def _sourcedirIsUpdatable(self): return self.pathExists(self.build.path_module.join(self.workdir, '.git')) def _updateSubmodule(self, _): if self.submodules: return self._dovccmd(['submodule', 'update', '--recursive']) else: return defer.succeed(0) def _cleanSubmodule(self, _): if self.submodules: command = ['submodule', 'foreach', 'git', 'clean', '-f', '-d'] if self.mode == 'full' and self.method == 'fresh': command.append('-x') return self._dovccmd(command) else: return defer.succeed(0) def _getMethod(self): if self.method is not None and self.mode != 'incremental': return self.method elif self.mode == 'incremental': return None elif self.method is None and self.mode == 'full': return 'fresh' def checkGit(self): d = self._dovccmd(['--version']) def check(res): if res == 0: return True return False d.addCallback(check) return d def patch(self, _, patch): d = self._dovccmd(['apply', '--index', '-p', str(patch[0])], initialStdin=patch[1]) return d buildbot-0.8.8/buildbot/steps/source/mercurial.py000066400000000000000000000316331222546025000221360ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members ## Source step code for mercurial from twisted.python import log from twisted.internet import defer from buildbot.process import buildstep from buildbot.steps.source.base import Source from buildbot.interfaces import BuildSlaveTooOldError from buildbot.config import ConfigErrors from buildbot.status.results import SUCCESS class Mercurial(Source): """ Class for Mercurial with all the smarts """ name = "hg" renderables = [ "repourl" ] possible_modes = ('incremental', 'full') possible_methods = (None, 'clean', 'fresh', 'clobber') possible_branchTypes = ('inrepo', 'dirname') def __init__(self, repourl=None, mode='incremental', method=None, defaultBranch=None, branchType='dirname', clobberOnBranchChange=True, **kwargs): """ @type repourl: string @param repourl: the URL which points at the Mercurial repository. if 'dirname' branches are enabled, this is the base URL to which a branch name will be appended. It should probably end in a slash. @param defaultBranch: if branches are enabled, this is the branch to use if the Build does not specify one explicitly. For 'dirname' branches, It will simply be appended to C{repourl} and the result handed to the 'hg update' command. For 'inrepo' branches, this specifies the named revision to which the tree will update after a clone. @param branchType: either 'dirname' or 'inrepo' depending on whether the branch name should be appended to the C{repourl} or the branch is a mercurial named branch and can be found within the C{repourl} @param clobberOnBranchChange: boolean, defaults to True. If set and using inrepos branches, clobber the tree at each branch change. Otherwise, just update to the branch. """ self.repourl = repourl self.defaultBranch = self.branch = defaultBranch self.branchType = branchType self.method = method self.clobberOnBranchChange = clobberOnBranchChange self.mode = mode Source.__init__(self, **kwargs) errors = [] if self.mode not in self.possible_modes: errors.append("mode %s is not one of %s" % (self.mode, self.possible_modes)) if self.method not in self.possible_methods: errors.append("method %s is not one of %s" % (self.method, self.possible_methods)) if self.branchType not in self.possible_branchTypes: errors.append("branchType %s is not one of %s" % (self.branchType, self.possible_branchTypes)) if repourl is None: errors.append("you must provide a repourl") if errors: raise ConfigErrors(errors) def startVC(self, branch, revision, patch): self.revision = revision self.method = self._getMethod() self.stdio_log = self.addLogForRemoteCommands("stdio") d = self.checkHg() def checkInstall(hgInstalled): if not hgInstalled: raise BuildSlaveTooOldError("Mercurial is not installed on slave") return 0 d.addCallback(checkInstall) if self.branchType == 'dirname': self.repourl = self.repourl + (branch or '') self.branch = self.defaultBranch self.update_branch = branch elif self.branchType == 'inrepo': self.update_branch = (branch or 'default') if self.mode == 'full': d.addCallback(lambda _: self.full()) elif self.mode == 'incremental': d.addCallback(lambda _: self.incremental()) if patch: d.addCallback(self.patch, patch) d.addCallback(self.parseGotRevision) d.addCallback(self.finish) d.addErrback(self.failed) @defer.inlineCallbacks def full(self): if self.method == 'clobber': yield self.clobber(None) return updatable = yield self._sourcedirIsUpdatable() if not updatable: yield self._dovccmd(['clone', self.repourl, '.']) elif self.method == 'clean': yield self.clean(None) elif self.method == 'fresh': yield self.fresh(None) else: raise ValueError("Unknown method, check your configuration") def incremental(self): if self.method is not None: raise ValueError(self.method) d = self._sourcedirIsUpdatable() def _cmd(updatable): if updatable: command = ['pull', self.repourl] else: command = ['clone', self.repourl, '.', '--noupdate'] return command d.addCallback(_cmd) d.addCallback(self._dovccmd) d.addCallback(self._checkBranchChange) return d def clean(self, _): command = ['--config', 'extensions.purge=', 'purge'] d = self._dovccmd(command) d.addCallback(self._pullUpdate) return d def clobber(self, _): cmd = buildstep.RemoteCommand('rmdir', {'dir': self.workdir, 'logEnviron':self.logEnviron}) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) d.addCallback(lambda _: self._dovccmd(['clone', '--noupdate' , self.repourl, "."])) d.addCallback(self._update) return d def fresh(self, _): command = ['--config', 'extensions.purge=', 'purge', '--all'] d = self._dovccmd(command) d.addCallback(self._pullUpdate) return d def finish(self, res): d = defer.succeed(res) def _gotResults(results): self.setStatus(self.cmd, results) return results d.addCallback(_gotResults) d.addCallbacks(self.finished, self.checkDisconnect) return d def parseGotRevision(self, _): d = self._dovccmd(['parents', '--template', '{node}\\n'], collectStdout=True) def _setrev(stdout): revision = stdout.strip() if len(revision) != 40: raise ValueError("Incorrect revision id") log.msg("Got Mercurial revision %s" % (revision, )) self.updateSourceProperty('got_revision', revision) return 0 d.addCallback(_setrev) return d @defer.inlineCallbacks def _checkBranchChange(self, _): current_branch = yield self._getCurrentBranch() msg = "Working dir is on in-repo branch '%s' and build needs '%s'." % \ (current_branch, self.update_branch) if current_branch != self.update_branch and self.clobberOnBranchChange: msg += ' Clobbering.' log.msg(msg) yield self.clobber(None) return msg += ' Updating.' log.msg(msg) yield self._removeAddedFilesAndUpdate(None) def _pullUpdate(self, res): command = ['pull' , self.repourl] if self.revision: command.extend(['--rev', self.revision]) d = self._dovccmd(command) d.addCallback(self._checkBranchChange) return d def _dovccmd(self, command, collectStdout=False, initialStdin=None, decodeRC={0:SUCCESS}): if not command: raise ValueError("No command specified") cmd = buildstep.RemoteShellCommand(self.workdir, ['hg', '--verbose'] + command, env=self.env, logEnviron=self.logEnviron, timeout=self.timeout, collectStdout=collectStdout, initialStdin=initialStdin, decodeRC=decodeRC) cmd.useLog(self.stdio_log, False) log.msg("Starting mercurial command : hg %s" % (" ".join(command), )) d = self.runCommand(cmd) def evaluateCommand(cmd): if cmd.didFail(): log.msg("Source step failed while running command %s" % cmd) raise buildstep.BuildStepFailed() if collectStdout: return cmd.stdout else: return cmd.rc d.addCallback(lambda _: evaluateCommand(cmd)) return d def computeSourceRevision(self, changes): if not changes: return None # without knowing the revision ancestry graph, we can't sort the # changes at all. So for now, assume they were given to us in sorted # order, and just pay attention to the last one. See ticket #103 for # more details. if len(changes) > 1: log.msg("Mercurial.computeSourceRevision: warning: " "there are %d changes here, assuming the last one is " "the most recent" % len(changes)) return changes[-1].revision def patch(self, _, patch): d = self._dovccmd(['import', '--no-commit', '-p', str(patch[0]), '-'], initialStdin=patch[1]) return d def _getCurrentBranch(self): if self.branchType == 'dirname': return defer.succeed(self.branch) else: d = self._dovccmd(['identify', '--branch'], collectStdout=True) def _getbranch(stdout): return stdout.strip() d.addCallback(_getbranch).addErrback return d def _getMethod(self): if self.method is not None and self.mode != 'incremental': return self.method elif self.mode == 'incremental': return None elif self.method is None and self.mode == 'full': return 'fresh' def _sourcedirIsUpdatable(self): return self.pathExists(self.build.path_module.join(self.workdir, '.hg')) def _removeAddedFilesAndUpdate(self, _): command = ['locate', 'set:added()'] d = self._dovccmd(command, collectStdout=True, decodeRC={0:SUCCESS,1:SUCCESS}) def parseAndRemove(stdout): files = [] for filename in stdout.splitlines() : filename = self.workdir+'/'+filename files.append(filename) if len(files) == 0: d = defer.succeed(0) else: if self.slaveVersionIsOlderThan('rmdir', '2.14'): d = self.removeFiles(files) else: cmd = buildstep.RemoteCommand('rmdir', {'dir': files, 'logEnviron': self.logEnviron,}) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) d.addCallback(lambda _: cmd.rc) return d d.addCallback(parseAndRemove) d.addCallback(self._update) return d @defer.inlineCallbacks def removeFiles(self, files): for filename in files: cmd = buildstep.RemoteCommand('rmdir', {'dir': filename, 'logEnviron': self.logEnviron,}) cmd.useLog(self.stdio_log, False) yield self.runCommand(cmd) if cmd.rc != 0: defer.returnValue(cmd.rc) return defer.returnValue(0) def _update(self, _): command = ['update', '--clean'] if self.revision: command += ['--rev', self.revision] elif self.branchType == 'inrepo': command += ['--rev', self.update_branch] d = self._dovccmd(command) return d def checkHg(self): d = self._dovccmd(['--version']) def check(res): if res == 0: return True return False d.addCallback(check) return d buildbot-0.8.8/buildbot/steps/source/oldsource.py000066400000000000000000001377471222546025000221670ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from warnings import warn from email.Utils import formatdate from twisted.python import log from twisted.internet import defer from zope.interface import implements from buildbot.process.buildstep import RemoteCommand from buildbot.interfaces import BuildSlaveTooOldError, IRenderable from buildbot.steps.source.base import Source class _ComputeRepositoryURL(object): implements(IRenderable) def __init__(self, step, repository): self.step = step self.repository = repository def getRenderingFor(self, props): ''' Helper function that the repository URL based on the parameter the source step took and the Change 'repository' property ''' build = props.getBuild() assert build is not None, "Build should be available *during* a build?" s = build.getSourceStamp(self.step.codebase) repository = self.repository if not repository: return str(s.repository) else: if callable(repository): d = props.render(repository(s.repository)) elif isinstance(repository, dict): d = props.render(repository.get(s.repository)) elif isinstance(repository, str) or isinstance(repository, unicode): try: return str(repository % s.repository) except TypeError: # that's the backward compatibility case d = props.render(repository) else: d = props.render(repository) d.addCallback(str) return d class SlaveSource(Source): def __init__(self, mode='update', retry=None, **kwargs): """ @type mode: string @param mode: the kind of VC operation that is desired: - 'update': specifies that the checkout/update should be performed directly into the workdir. Each build is performed in the same directory, allowing for incremental builds. This minimizes disk space, bandwidth, and CPU time. However, it may encounter problems if the build process does not handle dependencies properly (if you must sometimes do a 'clean build' to make sure everything gets compiled), or if source files are deleted but generated files can influence test behavior (e.g. python's .pyc files), or when source directories are deleted but generated files prevent CVS from removing them. When used with a patched checkout, from a previous buildbot try for instance, it will try to "revert" the changes first and will do a clobber if it is unable to get a clean checkout. The behavior is SCM-dependent. - 'copy': specifies that the source-controlled workspace should be maintained in a separate directory (called the 'copydir'), using checkout or update as necessary. For each build, a new workdir is created with a copy of the source tree (rm -rf workdir; cp -R -P -p copydir workdir). This doubles the disk space required, but keeps the bandwidth low (update instead of a full checkout). A full 'clean' build is performed each time. This avoids any generated-file build problems, but is still occasionally vulnerable to problems such as a CVS repository being manually rearranged (causing CVS errors on update) which are not an issue with a full checkout. - 'clobber': specifies that the working directory should be deleted each time, necessitating a full checkout for each build. This insures a clean build off a complete checkout, avoiding any of the problems described above, but is bandwidth intensive, as the whole source tree must be pulled down for each build. - 'export': is like 'clobber', except that e.g. the 'cvs export' command is used to create the working directory. This command removes all VC metadata files (the CVS/.svn/{arch} directories) from the tree, which is sometimes useful for creating source tarballs (to avoid including the metadata in the tar file). Not all VC systems support export. @type retry: tuple of ints (delay, repeats) (or None) @param retry: if provided, VC update failures are re-attempted up to REPEATS times, with DELAY seconds between each attempt. Some users have slaves with poor connectivity to their VC repository, and they say that up to 80% of their build failures are due to transient network failures that could be handled by simply retrying a couple times. """ Source.__init__(self, **kwargs) assert mode in ("update", "copy", "clobber", "export") if retry: delay, repeats = retry assert isinstance(repeats, int) assert repeats > 0 self.args = {'mode': mode, 'retry': retry, } def start(self): self.args['workdir'] = self.workdir self.args['logEnviron'] = self.logEnviron self.args['env'] = self.env self.args['timeout'] = self.timeout Source.start(self) def commandComplete(self, cmd): if not cmd.updates.has_key("got_revision"): return got_revision = cmd.updates["got_revision"][-1] if got_revision is None: return self.updateSourceProperty('got_revision', str(got_revision)) class BK(SlaveSource): """I perform BitKeeper checkout/update operations.""" name = 'bk' renderables = [ 'bkurl', 'baseURL' ] def __init__(self, bkurl=None, baseURL=None, directory=None, extra_args=None, **kwargs): """ @type bkurl: string @param bkurl: the URL which points to the BitKeeper server. @type baseURL: string @param baseURL: if branches are enabled, this is the base URL to which a branch name will be appended. It should probably end in a slash. Use exactly one of C{bkurl} and C{baseURL}. """ self.bkurl = _ComputeRepositoryURL(bkurl) self.baseURL = _ComputeRepositoryURL(baseURL) self.extra_args = extra_args Source.__init__(self, **kwargs) if bkurl and baseURL: raise ValueError("you must use exactly one of bkurl and baseURL") def computeSourceRevision(self, changes): return changes.revision def startVC(self, branch, revision, patch): warnings = [] slavever = self.slaveVersion("bk") if not slavever: m = "slave does not have the 'bk' command" raise BuildSlaveTooOldError(m) if self.bkurl: assert not branch # we need baseURL= to use branches self.args['bkurl'] = self.bkurl else: self.args['bkurl'] = self.baseURL + branch self.args['revision'] = revision self.args['patch'] = patch self.args['branch'] = branch if self.extra_args is not None: self.args['extra_args'] = self.extra_args revstuff = [] revstuff.append("[branch]") if revision is not None: revstuff.append("r%s" % revision) if patch is not None: revstuff.append("[patch]") self.description.extend(revstuff) self.descriptionDone.extend(revstuff) cmd = RemoteCommand("bk", self.args) self.startCommand(cmd, warnings) class CVS(SlaveSource): """I do CVS checkout/update operations. Note: if you are doing anonymous/pserver CVS operations, you will need to manually do a 'cvs login' on each buildslave before the slave has any hope of success. XXX: fix then, take a cvs password as an argument and figure out how to do a 'cvs login' on each build """ name = "cvs" renderables = [ "cvsroot" ] #progressMetrics = ('output',) # # additional things to track: update gives one stderr line per directory # (starting with 'cvs server: Updating ') (and is fairly stable if files # is empty), export gives one line per directory (starting with 'cvs # export: Updating ') and another line per file (starting with U). Would # be nice to track these, requires grepping LogFile data for lines, # parsing each line. Might be handy to have a hook in LogFile that gets # called with each complete line. def __init__(self, cvsroot=None, cvsmodule="", global_options=[], branch=None, checkoutDelay=None, checkout_options=[], export_options=[], extra_options=[], login=None, **kwargs): """ @type cvsroot: string @param cvsroot: CVS Repository from which the source tree should be obtained. '/home/warner/Repository' for local or NFS-reachable repositories, ':pserver:anon@foo.com:/cvs' for anonymous CVS, 'user@host.com:/cvs' for non-anonymous CVS or CVS over ssh. Lots of possibilities, check the CVS documentation for more. @type cvsmodule: string @param cvsmodule: subdirectory of CVS repository that should be retrieved @type login: string or None @param login: if not None, a string which will be provided as a password to the 'cvs login' command, used when a :pserver: method is used to access the repository. This login is only needed once, but must be run each time (just before the CVS operation) because there is no way for the buildslave to tell whether it was previously performed or not. @type branch: string @param branch: the default branch name, will be used in a '-r' argument to specify which branch of the source tree should be used for this checkout. Defaults to None, which means to use 'HEAD'. @type checkoutDelay: int or None @param checkoutDelay: if not None, the number of seconds to put between the last known Change and the timestamp given to the -D argument. This defaults to exactly half of the parent Build's .treeStableTimer, but it could be set to something else if your CVS change notification has particularly weird latency characteristics. @type global_options: list of strings @param global_options: these arguments are inserted in the cvs command line, before the 'checkout'/'update' command word. See 'cvs --help-options' for a list of what may be accepted here. ['-r'] will make the checked out files read only. ['-r', '-R'] will also assume the repository is read-only (I assume this means it won't use locks to insure atomic access to the ,v files). @type checkout_options: list of strings @param checkout_options: these arguments are inserted in the cvs command line, after 'checkout' but before branch or revision specifiers. @type export_options: list of strings @param export_options: these arguments are inserted in the cvs command line, after 'export' but before branch or revision specifiers. @type extra_options: list of strings @param extra_options: these arguments are inserted in the cvs command line, after 'checkout' or 'export' but before branch or revision specifiers. """ self.checkoutDelay = checkoutDelay self.branch = branch self.cvsroot = _ComputeRepositoryURL(self, cvsroot) SlaveSource.__init__(self, **kwargs) self.args.update({'cvsmodule': cvsmodule, 'global_options': global_options, 'checkout_options':checkout_options, 'export_options':export_options, 'extra_options':extra_options, 'login': login, }) def computeSourceRevision(self, changes): if not changes: return None lastChange = max([c.when for c in changes]) if self.checkoutDelay is not None: when = lastChange + self.checkoutDelay else: lastSubmit = max([br.submittedAt for br in self.build.requests]) when = (lastChange + lastSubmit) / 2 return formatdate(when) def startVC(self, branch, revision, patch): if self.slaveVersionIsOlderThan("cvs", "1.39"): # the slave doesn't know to avoid re-using the same sourcedir # when the branch changes. We have no way of knowing which branch # the last build used, so if we're using a non-default branch and # either 'update' or 'copy' modes, it is safer to refuse to # build, and tell the user they need to upgrade the buildslave. if (branch != self.branch and self.args['mode'] in ("update", "copy")): m = ("This buildslave (%s) does not know about multiple " "branches, and using mode=%s would probably build the " "wrong tree. " "Refusing to build. Please upgrade the buildslave to " "buildbot-0.7.0 or newer." % (self.build.slavename, self.args['mode'])) log.msg(m) raise BuildSlaveTooOldError(m) if self.slaveVersionIsOlderThan("cvs", "2.10"): if self.args['extra_options'] or self.args['export_options']: m = ("This buildslave (%s) does not support export_options " "or extra_options arguments to the CVS step." % (self.build.slavename)) log.msg(m) raise BuildSlaveTooOldError(m) # the unwanted args are empty, and will probably be ignored by # the slave, but delete them just to be safe del self.args['export_options'] del self.args['extra_options'] if branch is None: branch = "HEAD" self.args['cvsroot'] = self.cvsroot self.args['branch'] = branch self.args['revision'] = revision self.args['patch'] = patch if self.args['branch'] == "HEAD" and self.args['revision']: # special case. 'cvs update -r HEAD -D today' gives no files # TODO: figure out why, see if it applies to -r BRANCH self.args['branch'] = None # deal with old slaves warnings = [] slavever = self.slaveVersion("cvs", "old") if slavever == "old": # 0.5.0 if self.args['mode'] == "export": self.args['export'] = 1 elif self.args['mode'] == "clobber": self.args['clobber'] = 1 elif self.args['mode'] == "copy": self.args['copydir'] = "source" self.args['tag'] = self.args['branch'] assert not self.args['patch'] # 0.5.0 slave can't do patch cmd = RemoteCommand("cvs", self.args) self.startCommand(cmd, warnings) class SVN(SlaveSource): """I perform Subversion checkout/update operations.""" name = 'svn' branch_placeholder = '%%BRANCH%%' renderables = [ 'svnurl', 'baseURL' ] def __init__(self, svnurl=None, baseURL=None, defaultBranch=None, directory=None, username=None, password=None, extra_args=None, keep_on_purge=None, ignore_ignores=None, always_purge=None, depth=None, **kwargs): """ @type svnurl: string @param svnurl: the URL which points to the Subversion server, combining the access method (HTTP, ssh, local file), the repository host/port, the repository path, the sub-tree within the repository, and the branch to check out. Use exactly one of C{svnurl} and C{baseURL}. @param baseURL: if branches are enabled, this is the base URL to which a branch name will be appended. It should probably end in a slash. Use exactly one of C{svnurl} and C{baseURL}. @param defaultBranch: if branches are enabled, this is the branch to use if the Build does not specify one explicitly. It will simply be appended to C{baseURL} and the result handed to the SVN command. @type username: string @param username: username to pass to svn's --username @type password: string @param password: password to pass to svn's --password """ if not 'workdir' in kwargs and directory is not None: # deal with old configs warn("Please use workdir=, not directory=", DeprecationWarning) kwargs['workdir'] = directory self.svnurl = svnurl and _ComputeRepositoryURL(self, svnurl) self.baseURL = _ComputeRepositoryURL(self, baseURL) self.branch = defaultBranch self.username = username self.password = password self.extra_args = extra_args self.keep_on_purge = keep_on_purge self.ignore_ignores = ignore_ignores self.always_purge = always_purge self.depth = depth SlaveSource.__init__(self, **kwargs) if svnurl and baseURL: raise ValueError("you must use either svnurl OR baseURL") def computeSourceRevision(self, changes): if not changes or None in [c.revision for c in changes]: return None lastChange = max([int(c.revision) for c in changes]) return lastChange def checkCompatibility(self): ''' Handle compatibility between old slaves/svn clients ''' slavever = self.slaveVersion("svn", "old") if not slavever: m = "slave does not have the 'svn' command" raise BuildSlaveTooOldError(m) if self.slaveVersionIsOlderThan("svn", "1.39"): # the slave doesn't know to avoid re-using the same sourcedir # when the branch changes. We have no way of knowing which branch # the last build used, so if we're using a non-default branch and # either 'update' or 'copy' modes, it is safer to refuse to # build, and tell the user they need to upgrade the buildslave. if (self.args['branch'] != self.branch and self.args['mode'] in ("update", "copy")): m = ("This buildslave (%s) does not know about multiple " "branches, and using mode=%s would probably build the " "wrong tree. " "Refusing to build. Please upgrade the buildslave to " "buildbot-0.7.0 or newer." % (self.build.slavename, self.args['mode'])) raise BuildSlaveTooOldError(m) if (self.depth is not None) and self.slaveVersionIsOlderThan("svn","2.9"): m = ("This buildslave (%s) does not support svn depth " "arguments. Refusing to build. " "Please upgrade the buildslave." % (self.build.slavename)) raise BuildSlaveTooOldError(m) if (self.username is not None or self.password is not None) \ and self.slaveVersionIsOlderThan("svn", "2.8"): m = ("This buildslave (%s) does not support svn usernames " "and passwords. " "Refusing to build. Please upgrade the buildslave to " "buildbot-0.7.10 or newer." % (self.build.slavename,)) raise BuildSlaveTooOldError(m) def getSvnUrl(self, branch): ''' Compute the svn url that will be passed to the svn remote command ''' if self.svnurl: return self.svnurl else: if branch is None: m = ("The SVN source step belonging to builder '%s' does not know " "which branch to work with. This means that the change source " "did not specify a branch and that defaultBranch is None." \ % self.build.builder.name) raise RuntimeError(m) computed = self.baseURL if self.branch_placeholder in self.baseURL: return computed.replace(self.branch_placeholder, branch) else: return computed + branch def startVC(self, branch, revision, patch): warnings = [] self.checkCompatibility() self.args['svnurl'] = self.getSvnUrl(branch) self.args['revision'] = revision self.args['patch'] = patch self.args['always_purge'] = self.always_purge #Set up depth if specified if self.depth is not None: self.args['depth'] = self.depth if self.username is not None: self.args['username'] = self.username if self.password is not None: self.args['password'] = self.password if self.extra_args is not None: self.args['extra_args'] = self.extra_args revstuff = [] #revstuff.append(self.args['svnurl']) if self.args['svnurl'].find('trunk') == -1: revstuff.append("[branch]") if revision is not None: revstuff.append("r%s" % revision) if patch is not None: revstuff.append("[patch]") self.description.extend(revstuff) self.descriptionDone.extend(revstuff) cmd = RemoteCommand("svn", self.args) self.startCommand(cmd, warnings) class Darcs(SlaveSource): """Check out a source tree from a Darcs repository at 'repourl'. Darcs has no concept of file modes. This means the eXecute-bit will be cleared on all source files. As a result, you may need to invoke configuration scripts with something like: C{s(step.Configure, command=['/bin/sh', './configure'])} """ name = "darcs" renderables = [ 'repourl', 'baseURL' ] def __init__(self, repourl=None, baseURL=None, defaultBranch=None, **kwargs): """ @type repourl: string @param repourl: the URL which points at the Darcs repository. This is used as the default branch. Using C{repourl} does not enable builds of alternate branches: use C{baseURL} to enable this. Use either C{repourl} or C{baseURL}, not both. @param baseURL: if branches are enabled, this is the base URL to which a branch name will be appended. It should probably end in a slash. Use exactly one of C{repourl} and C{baseURL}. @param defaultBranch: if branches are enabled, this is the branch to use if the Build does not specify one explicitly. It will simply be appended to C{baseURL} and the result handed to the 'darcs pull' command. """ self.repourl = _ComputeRepositoryURL(self, repourl) self.baseURL = _ComputeRepositoryURL(self, baseURL) self.branch = defaultBranch SlaveSource.__init__(self, **kwargs) assert self.args['mode'] != "export", \ "Darcs does not have an 'export' mode" if repourl and baseURL: raise ValueError("you must provide exactly one of repourl and" " baseURL") def startVC(self, branch, revision, patch): slavever = self.slaveVersion("darcs") if not slavever: m = "slave is too old, does not know about darcs" raise BuildSlaveTooOldError(m) if self.slaveVersionIsOlderThan("darcs", "1.39"): if revision: # TODO: revisit this once we implement computeSourceRevision m = "0.6.6 slaves can't handle args['revision']" raise BuildSlaveTooOldError(m) # the slave doesn't know to avoid re-using the same sourcedir # when the branch changes. We have no way of knowing which branch # the last build used, so if we're using a non-default branch and # either 'update' or 'copy' modes, it is safer to refuse to # build, and tell the user they need to upgrade the buildslave. if (branch != self.branch and self.args['mode'] in ("update", "copy")): m = ("This buildslave (%s) does not know about multiple " "branches, and using mode=%s would probably build the " "wrong tree. " "Refusing to build. Please upgrade the buildslave to " "buildbot-0.7.0 or newer." % (self.build.slavename, self.args['mode'])) raise BuildSlaveTooOldError(m) if self.repourl: assert not branch # we need baseURL= to use branches self.args['repourl'] = self.repourl else: self.args['repourl'] = self.baseURL + branch self.args['revision'] = revision self.args['patch'] = patch revstuff = [] if branch is not None and branch != self.branch: revstuff.append("[branch]") self.description.extend(revstuff) self.descriptionDone.extend(revstuff) cmd = RemoteCommand("darcs", self.args) self.startCommand(cmd) class Git(SlaveSource): """Check out a source tree from a git repository 'repourl'.""" name = "git" renderables = [ 'repourl' ] def __init__(self, repourl=None, branch="master", submodules=False, ignore_ignores=None, reference=None, shallow=False, progress=False, **kwargs): """ @type repourl: string @param repourl: the URL which points at the git repository @type branch: string @param branch: The branch or tag to check out by default. If a build specifies a different branch, it will be used instead of this. @type submodules: boolean @param submodules: Whether or not to update (and initialize) git submodules. @type reference: string @param reference: The path to a reference repository to obtain objects from, if any. @type shallow: boolean @param shallow: Use a shallow or clone, if possible @type progress: boolean @param progress: Pass the --progress option when fetching. This can solve long fetches getting killed due to lack of output, but requires Git 1.7.2+. """ SlaveSource.__init__(self, **kwargs) self.repourl = _ComputeRepositoryURL(self, repourl) self.branch = branch self.args.update({'submodules': submodules, 'ignore_ignores': ignore_ignores, 'reference': reference, 'shallow': shallow, 'progress': progress, }) def computeSourceRevision(self, changes): if not changes: return None return changes[-1].revision def startVC(self, branch, revision, patch): self.args['branch'] = branch self.args['repourl'] = self.repourl self.args['revision'] = revision self.args['patch'] = patch # check if there is any patchset we should fetch from Gerrit if self.build.hasProperty("event.patchSet.ref"): # GerritChangeSource self.args['gerrit_branch'] = self.build.getProperty("event.patchSet.ref") self.updateSourceProperty("gerrit_branch", self.args['gerrit_branch']) else: try: # forced build change = self.build.getProperty("gerrit_change", '').split('/') if len(change) == 2: self.args['gerrit_branch'] = "refs/changes/%2.2d/%d/%d" \ % (int(change[0]) % 100, int(change[0]), int(change[1])) self.updateSourceProperty("gerrit_branch", self.args['gerrit_branch']) except: pass slavever = self.slaveVersion("git") if not slavever: raise BuildSlaveTooOldError("slave is too old, does not know " "about git") cmd = RemoteCommand("git", self.args) self.startCommand(cmd) class Repo(SlaveSource): """Check out a source tree from a repo repository described by manifest.""" name = "repo" renderables = [ "manifest_url" ] def __init__(self, manifest_url=None, manifest_branch="master", manifest_file="default.xml", tarball=None, jobs=None, **kwargs): """ @type manifest_url: string @param manifest_url: The URL which points at the repo manifests repository. @type manifest_branch: string @param manifest_branch: The manifest branch to check out by default. @type manifest_file: string @param manifest_file: The manifest to use for sync. """ SlaveSource.__init__(self, **kwargs) self.manifest_url = _ComputeRepositoryURL(self, manifest_url) self.args.update({'manifest_branch': manifest_branch, 'manifest_file': manifest_file, 'tarball': tarball, 'manifest_override_url': None, 'jobs': jobs }) def computeSourceRevision(self, changes): if not changes: return None return changes[-1].revision def parseDownloadProperty(self, s): """ lets try to be nice in the format we want can support several instances of "repo download proj number/patch" (direct copy paste from gerrit web site) or several instances of "proj number/patch" (simpler version) This feature allows integrator to build with several pending interdependant changes. returns list of repo downloads sent to the buildslave """ import re if s == None: return [] re1 = re.compile("repo download ([^ ]+) ([0-9]+/[0-9]+)") re2 = re.compile("([^ ]+) ([0-9]+/[0-9]+)") re3 = re.compile("([^ ]+)/([0-9]+/[0-9]+)") ret = [] for cur_re in [re1, re2, re3]: res = cur_re.search(s) while res: ret.append("%s %s" % (res.group(1), res.group(2))) s = s[:res.start(0)] + s[res.end(0):] res = cur_re.search(s) return ret def buildDownloadList(self): """taken the changesource and forcebuild property, build the repo download command to send to the slave making this a defereable allow config to tweak this in order to e.g. manage dependancies """ downloads = self.build.getProperty("repo_downloads", []) # download patches based on GerritChangeSource events for change in self.build.allChanges(): if (change.properties.has_key("event.type") and change.properties["event.type"] == "patchset-created"): downloads.append("%s %s/%s"% (change.properties["event.change.project"], change.properties["event.change.number"], change.properties["event.patchSet.number"])) # download patches based on web site forced build properties: # "repo_d", "repo_d0", .., "repo_d9" # "repo_download", "repo_download0", .., "repo_download9" for propName in ["repo_d"] + ["repo_d%d" % i for i in xrange(0,10)] + \ ["repo_download"] + ["repo_download%d" % i for i in xrange(0,10)]: s = self.build.getProperty(propName) if s is not None: downloads.extend(self.parseDownloadProperty(s)) if downloads: self.args["repo_downloads"] = downloads self.updateSourceProperty("repo_downloads", downloads) return defer.succeed(None) def startVC(self, branch, revision, patch): self.args['manifest_url'] = self.manifest_url # manifest override self.args['manifest_override_url'] = None try: self.args['manifest_override_url'] = self.build.getProperty("manifest_override_url") except KeyError: pass # only master has access to properties, so we must implement this here. d = self.buildDownloadList() d.addCallback(self.continueStartVC, branch, revision, patch) d.addErrback(self.failed) def continueStartVC(self, ignored, branch, revision, patch): slavever = self.slaveVersion("repo") if not slavever: raise BuildSlaveTooOldError("slave is too old, does not know " "about repo") cmd = RemoteCommand("repo", self.args) self.startCommand(cmd) def commandComplete(self, cmd): repo_downloaded = [] if cmd.updates.has_key("repo_downloaded"): repo_downloaded = cmd.updates["repo_downloaded"][-1] if repo_downloaded: self.updateSourceProperty("repo_downloaded", str(repo_downloaded)) else: repo_downloaded = [] orig_downloads = self.getProperty("repo_downloads") or [] if len(orig_downloads) != len(repo_downloaded): self.step_status.setText(["repo download issues"]) class Bzr(SlaveSource): """Check out a source tree from a bzr (Bazaar) repository at 'repourl'. """ name = "bzr" renderables = [ 'repourl', 'baseURL' ] def __init__(self, repourl=None, baseURL=None, defaultBranch=None, forceSharedRepo=None, **kwargs): """ @type repourl: string @param repourl: the URL which points at the bzr repository. This is used as the default branch. Using C{repourl} does not enable builds of alternate branches: use C{baseURL} to enable this. Use either C{repourl} or C{baseURL}, not both. @param baseURL: if branches are enabled, this is the base URL to which a branch name will be appended. It should probably end in a slash. Use exactly one of C{repourl} and C{baseURL}. @param defaultBranch: if branches are enabled, this is the branch to use if the Build does not specify one explicitly. It will simply be appended to C{baseURL} and the result handed to the 'bzr checkout pull' command. @param forceSharedRepo: Boolean, defaults to False. If set to True, the working directory will be made into a bzr shared repository if it is not already. Shared repository greatly reduces the amount of history data that needs to be downloaded if not using update/copy mode, or if using update/copy mode with multiple branches. """ self.repourl = _ComputeRepositoryURL(self, repourl) self.baseURL = _ComputeRepositoryURL(self, baseURL) self.branch = defaultBranch SlaveSource.__init__(self, **kwargs) self.args.update({'forceSharedRepo': forceSharedRepo}) if repourl and baseURL: raise ValueError("you must provide exactly one of repourl and" " baseURL") def computeSourceRevision(self, changes): if not changes: return None lastChange = max([int(c.revision) for c in changes]) return lastChange def startVC(self, branch, revision, patch): slavever = self.slaveVersion("bzr") if not slavever: m = "slave is too old, does not know about bzr" raise BuildSlaveTooOldError(m) if self.repourl: assert not branch # we need baseURL= to use branches self.args['repourl'] = self.repourl else: self.args['repourl'] = self.baseURL + branch self.args['revision'] = revision self.args['patch'] = patch revstuff = [] if branch is not None and branch != self.branch: revstuff.append("[" + branch + "]") if revision is not None: revstuff.append("r%s" % revision) self.description.extend(revstuff) self.descriptionDone.extend(revstuff) cmd = RemoteCommand("bzr", self.args) self.startCommand(cmd) class Mercurial(SlaveSource): """Check out a source tree from a mercurial repository 'repourl'.""" name = "hg" renderables = [ 'repourl', 'baseURL' ] def __init__(self, repourl=None, baseURL=None, defaultBranch=None, branchType='dirname', clobberOnBranchChange=True, **kwargs): """ @type repourl: string @param repourl: the URL which points at the Mercurial repository. This uses the 'default' branch unless defaultBranch is specified below and the C{branchType} is set to 'inrepo'. It is an error to specify a branch without setting the C{branchType} to 'inrepo'. @param baseURL: if 'dirname' branches are enabled, this is the base URL to which a branch name will be appended. It should probably end in a slash. Use exactly one of C{repourl} and C{baseURL}. @param defaultBranch: if branches are enabled, this is the branch to use if the Build does not specify one explicitly. For 'dirname' branches, It will simply be appended to C{baseURL} and the result handed to the 'hg update' command. For 'inrepo' branches, this specifies the named revision to which the tree will update after a clone. @param branchType: either 'dirname' or 'inrepo' depending on whether the branch name should be appended to the C{baseURL} or the branch is a mercurial named branch and can be found within the C{repourl} @param clobberOnBranchChange: boolean, defaults to True. If set and using inrepos branches, clobber the tree at each branch change. Otherwise, just update to the branch. """ self.repourl = _ComputeRepositoryURL(self, repourl) self.baseURL = _ComputeRepositoryURL(self, baseURL) self.branch = defaultBranch self.branchType = branchType self.clobberOnBranchChange = clobberOnBranchChange SlaveSource.__init__(self, **kwargs) if repourl and baseURL: raise ValueError("you must provide exactly one of repourl and" " baseURL") def startVC(self, branch, revision, patch): slavever = self.slaveVersion("hg") if not slavever: raise BuildSlaveTooOldError("slave is too old, does not know " "about hg") if self.repourl: # we need baseURL= to use dirname branches assert self.branchType == 'inrepo' or not branch self.args['repourl'] = self.repourl if branch: self.args['branch'] = branch else: self.args['repourl'] = self.baseURL + (branch or '') self.args['revision'] = revision self.args['patch'] = patch self.args['clobberOnBranchChange'] = self.clobberOnBranchChange self.args['branchType'] = self.branchType revstuff = [] if branch is not None and branch != self.branch: revstuff.append("[branch]") self.description.extend(revstuff) self.descriptionDone.extend(revstuff) cmd = RemoteCommand("hg", self.args) self.startCommand(cmd) def computeSourceRevision(self, changes): if not changes: return None # without knowing the revision ancestry graph, we can't sort the # changes at all. So for now, assume they were given to us in sorted # order, and just pay attention to the last one. See ticket #103 for # more details. if len(changes) > 1: log.msg("Mercurial.computeSourceRevision: warning: " "there are %d changes here, assuming the last one is " "the most recent" % len(changes)) return changes[-1].revision class P4(SlaveSource): """ P4 is a class for accessing perforce revision control""" name = "p4" renderables = [ 'p4base' ] def __init__(self, p4base=None, defaultBranch=None, p4port=None, p4user=None, p4passwd=None, p4extra_views=[], p4line_end='local', p4client='buildbot_%(slave)s_%(builder)s', **kwargs): """ @type p4base: string @param p4base: A view into a perforce depot, typically "//depot/proj/" @type defaultBranch: string @param defaultBranch: Identify a branch to build by default. Perforce is a view based branching system. So, the branch is normally the name after the base. For example, branch=1.0 is view=//depot/proj/1.0/... branch=1.1 is view=//depot/proj/1.1/... @type p4port: string @param p4port: Specify the perforce server to connection in the format :. Example "perforce.example.com:1666" @type p4user: string @param p4user: The perforce user to run the command as. @type p4passwd: string @param p4passwd: The password for the perforce user. @type p4extra_views: list of tuples @param p4extra_views: Extra views to be added to the client that is being used. @type p4line_end: string @param p4line_end: value of the LineEnd client specification property @type p4client: string @param p4client: The perforce client to use for this buildslave. """ self.p4base = _ComputeRepositoryURL(self, p4base) self.branch = defaultBranch SlaveSource.__init__(self, **kwargs) self.args['p4port'] = p4port self.args['p4user'] = p4user self.args['p4passwd'] = p4passwd self.args['p4extra_views'] = p4extra_views self.args['p4line_end'] = p4line_end self.p4client = p4client def setBuild(self, build): SlaveSource.setBuild(self, build) self.args['p4client'] = self.p4client % { 'slave': build.slavename, 'builder': build.builder.name, } def computeSourceRevision(self, changes): if not changes: return None lastChange = max([int(c.revision) for c in changes]) return lastChange def startVC(self, branch, revision, patch): slavever = self.slaveVersion("p4") assert slavever, "slave is too old, does not know about p4" args = dict(self.args) args['p4base'] = self.p4base args['branch'] = branch or self.branch args['revision'] = revision args['patch'] = patch cmd = RemoteCommand("p4", args) self.startCommand(cmd) class Monotone(SlaveSource): """Check out a source tree from a monotone repository 'repourl'.""" name = "mtn" renderables = [ 'repourl' ] def __init__(self, repourl=None, branch=None, progress=False, **kwargs): """ @type repourl: string @param repourl: the URI which points at the monotone repository. @type branch: string @param branch: The branch or tag to check out by default. If a build specifies a different branch, it will be used instead of this. @type progress: boolean @param progress: Pass the --ticker=dot option when pulling. This can solve long fetches getting killed due to lack of output. """ SlaveSource.__init__(self, **kwargs) self.repourl = _ComputeRepositoryURL(self, repourl) if (not repourl): raise ValueError("you must provide a repository uri in 'repourl'") if (not branch): raise ValueError("you must provide a default branch in 'branch'") self.args.update({'branch': branch, 'progress': progress, }) def startVC(self, branch, revision, patch): slavever = self.slaveVersion("mtn") if not slavever: raise BuildSlaveTooOldError("slave is too old, does not know " "about mtn") self.args['repourl'] = self.repourl if branch: self.args['branch'] = branch self.args['revision'] = revision self.args['patch'] = patch cmd = RemoteCommand("mtn", self.args) self.startCommand(cmd) def computeSourceRevision(self, changes): if not changes: return None # without knowing the revision ancestry graph, we can't sort the # changes at all. So for now, assume they were given to us in sorted # order, and just pay attention to the last one. See ticket #103 for # more details. if len(changes) > 1: log.msg("Monotone.computeSourceRevision: warning: " "there are %d changes here, assuming the last one is " "the most recent" % len(changes)) return changes[-1].revision buildbot-0.8.8/buildbot/steps/source/p4.py000066400000000000000000000314441222546025000204760ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # Portions Copyright 2013 Bad Dog Consulting import re import os from twisted.python import log from twisted.internet import defer from buildbot import interfaces, config from buildbot.process import buildstep from buildbot.steps.source import Source from buildbot.interfaces import BuildSlaveTooOldError from buildbot.process.properties import Interpolate from types import StringType # Notes: # see # http://perforce.com/perforce/doc.current/manuals/cmdref/o.gopts.html#1040647 # for getting p4 command to output marshalled python dictionaries as output # for commands. # Perhaps switch to using 'p4 -G' : From URL above: # -G Causes all output (and batch input for form commands with -i) to be # formatted as marshalled Python dictionary objects. This is most often used # when scripting. debug_logging = False class P4(Source): """Perform Perforce checkout/update operations.""" name = 'p4' renderables = ['p4base', 'p4client', 'p4viewspec', 'p4branch'] possible_modes = ('incremental', 'full') def __init__(self, mode='incremental', method=None, p4base=None, p4branch=None, p4port=None, p4user=None, p4passwd=None, p4extra_views=(), p4line_end='local', p4viewspec=None, p4client=Interpolate('buildbot_%(prop:slavename)s_%(prop:buildername)s'), p4bin='p4', **kwargs): self.method = method self.mode = mode self.p4branch = p4branch self.p4bin = p4bin self.p4base = p4base self.p4port = p4port self.p4user = p4user self.p4passwd = p4passwd self.p4extra_views = p4extra_views self.p4viewspec = p4viewspec self.p4line_end = p4line_end self.p4client = p4client Source.__init__(self, **kwargs) if self.mode not in self.possible_modes: config.error("mode %s is not one of %s" % (self.mode, self.possible_modes)) if not p4viewspec and p4base is None: config.error("You must provide p4base or p4viewspec") if p4viewspec and (p4base or p4branch or p4extra_views): config.error("Either provide p4viewspec or p4base and p4branch (and optionally p4extra_views") if p4viewspec and type(p4viewspec) is StringType: config.error("p4viewspec must not be a string, and should be a sequence of 2 element sequences") if not interfaces.IRenderable.providedBy(p4base) and p4base and p4base.endswith('/'): config.error('p4base should not end with a trailing / [p4base = %s]' % p4base) if not interfaces.IRenderable.providedBy(p4branch) and p4branch and p4branch.endswith('/'): config.error('p4branch should not end with a trailing / [p4branch = %s]' % p4branch) if (p4branch or p4extra_views) and not p4base: config.error('If you specify either p4branch or p4extra_views you must also specify p4base') def startVC(self, branch, revision, patch): if debug_logging: log.msg('in startVC') self.revision = revision self.method = self._getMethod() self.stdio_log = self.addLogForRemoteCommands("stdio") d = self.checkP4() def checkInstall(p4Installed): if not p4Installed: raise BuildSlaveTooOldError("p4 is not installed on slave") return 0 d.addCallback(checkInstall) if self.mode == 'full': d.addCallback(self.full) elif self.mode == 'incremental': d.addCallback(self.incremental) d.addCallback(self.parseGotRevision) d.addCallback(self.finish) d.addErrback(self.failed) return d @defer.inlineCallbacks def full(self, _): if debug_logging: log.msg("P4:full()..") # First we need to create the client yield self._createClientSpec() # Then p4 sync #none yield self._dovccmd(['sync', '#none']) # Then remove directory. yield self.runRmdir(self.workdir) # Then we need to sync the client if self.revision: if debug_logging: log.msg("P4: full() sync command based on :base:%s changeset:%d", self.p4base, int(self.revision)) yield self._dovccmd(['sync', '%s...@%d' % (self.p4base, int(self.revision))], collectStdout=True) else: if debug_logging: log.msg("P4: full() sync command based on :base:%s no revision", self.p4base) yield self._dovccmd(['sync'], collectStdout=True) if debug_logging: log.msg("P4: full() sync done.") @defer.inlineCallbacks def incremental(self, _): if debug_logging: log.msg("P4:incremental()") # First we need to create the client yield self._createClientSpec() # and plan to do a checkout command = ['sync', ] if self.revision: command.extend(['%s...@%d' % (self.p4base, int(self.revision))]) if debug_logging: log.msg("P4:incremental() command:%s revision:%s", command, self.revision) yield self._dovccmd(command) def finish(self, res): d = defer.succeed(res) def _gotResults(results): self.setStatus(self.cmd, results) return results d.addCallback(_gotResults) d.addCallbacks(self.finished, self.checkDisconnect) return d def _buildVCCommand(self, doCommand): assert doCommand, "No command specified" command = [self.p4bin, ] if self.p4port: command.extend(['-p', self.p4port]) if self.p4user: command.extend(['-u', self.p4user]) if self.p4passwd: # Need to find out if there's a way to obfuscate this command.extend(['-P', self.p4passwd]) if self.p4client: command.extend(['-c', self.p4client]) command.extend(doCommand) command = [c.encode('utf-8') for c in command] return command def _dovccmd(self, command, collectStdout=False, initialStdin=None): command = self._buildVCCommand(command) if debug_logging: log.msg("P4:_dovccmd():workdir->%s" % self.workdir) cmd = buildstep.RemoteShellCommand(self.workdir, command, env=self.env, logEnviron=self.logEnviron, collectStdout=collectStdout, initialStdin=initialStdin,) cmd.useLog(self.stdio_log, False) if debug_logging: log.msg("Starting p4 command : p4 %s" % (" ".join(command),)) d = self.runCommand(cmd) def evaluateCommand(cmd): if cmd.rc != 0: if debug_logging: log.msg("P4:_dovccmd():Source step failed while running command %s" % cmd) raise buildstep.BuildStepFailed() if collectStdout: return cmd.stdout else: return cmd.rc d.addCallback(lambda _: evaluateCommand(cmd)) return d def _getMethod(self): if self.method is not None and self.mode != 'incremental': return self.method elif self.mode == 'incremental': return None elif self.method is None and self.mode == 'full': return 'fresh' def _sourcedirIsUpdatable(self): # In general you should always be able to write to the directory # You just specified as the root of your client # So just return. # If we find a case where this is no longer true, then this # needs to be implemented return defer.succeed(True) @defer.inlineCallbacks def _createClientSpec(self): builddir = self.getProperty('builddir') if debug_logging: log.msg("P4:_createClientSpec() builddir:%s" % builddir) log.msg("P4:_createClientSpec() SELF.workdir:%s" % self.workdir) prop_dict = self.getProperties().asDict() prop_dict['p4client'] = self.p4client client_spec = '' client_spec += "Client: %s\n\n" % self.p4client client_spec += "Owner: %s\n\n" % self.p4user client_spec += "Description:\n\tCreated by %s\n\n" % self.p4user client_spec += "Root:\t%s\n\n" % os.path.join(builddir, self.workdir) client_spec += "Options:\tallwrite rmdir\n\n" if self.p4line_end: client_spec += "LineEnd:\t%s\n\n" % self.p4line_end else: client_spec += "LineEnd:\tlocal\n\n" # Setup a view client_spec += "View:\n" if self.p4viewspec: # uses only p4viewspec array of tuples to build view # If the user specifies a viewspec via an array of tuples then # Ignore any specified p4base,p4branch, and/or p4extra_views for k, v in self.p4viewspec: if debug_logging: log.msg('P4:_createClientSpec():key:%s value:%s' % (k, v)) client_spec += '\t%s... //%s/%s...\n' % (k, self.p4client, v) else: # Uses p4base, p4branch, p4extra_views client_spec += "\t%s" % (self.p4base) if self.p4branch: client_spec += "/%s" % (self.p4branch) client_spec += "/... //%s/...\n" % (self.p4client) if self.p4extra_views: for k, v in self.p4extra_views: client_spec += "\t%s/... //%s/%s/...\n" % (k, self.p4client, v) client_spec = client_spec.encode('utf-8') # resolve unicode issues if debug_logging: log.msg(client_spec) stdout = yield self._dovccmd(['client', '-i'], collectStdout=True, initialStdin=client_spec) mo = re.search(r'Client (\S+) (.+)$', stdout, re.M) defer.returnValue(mo and (mo.group(2) == 'saved.' or mo.group(2) == 'not changed.')) def parseGotRevision(self, _): command = self._buildVCCommand(['changes', '-m1', '#have']) cmd = buildstep.RemoteShellCommand(self.workdir, command, env=self.env, logEnviron=self.logEnviron, collectStdout=True) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) def _setrev(_): stdout = cmd.stdout.strip() # Example output from p4 changes -m1 #have # Change 212798 on 2012/04/13 by user@user-unix-bldng2 'change to pickup build' revision = stdout.split()[1] try: int(revision) except ValueError: msg = ("p4.parseGotRevision unable to parse output " "of 'p4 changes -m1 \"#have\"': '%s'" % stdout) log.msg(msg) raise buildstep.BuildStepFailed() if debug_logging: log.msg("Got p4 revision %s" % (revision,)) self.updateSourceProperty('got_revision', revision) return 0 d.addCallback(lambda _: _setrev(cmd.rc)) return d def purge(self, ignore_ignores): """Delete everything that shown up on status.""" command = ['sync', '#none'] if ignore_ignores: command.append('--no-ignore') d = self._dovccmd(command, collectStdout=True) # add deferred to rm tree # then add defer to sync to revision return d def checkP4(self): cmd = buildstep.RemoteShellCommand(self.workdir, ['p4', '-V'], env=self.env, logEnviron=self.logEnviron) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) def evaluate(cmd): if cmd.rc != 0: return False return True d.addCallback(lambda _: evaluate(cmd)) return d def computeSourceRevision(self, changes): if not changes or None in [c.revision for c in changes]: return None lastChange = max([int(c.revision) for c in changes]) return lastChange buildbot-0.8.8/buildbot/steps/source/repo.py000066400000000000000000000444431222546025000211230ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import re import textwrap from twisted.internet import defer, reactor from buildbot import util from buildbot.process import buildstep from buildbot.steps.source.base import Source from buildbot.interfaces import IRenderable from zope.interface import implements class RepoDownloadsFromProperties(util.ComparableMixin, object): implements(IRenderable) parse_download_re = (re.compile(r"repo download ([^ ]+) ([0-9]+/[0-9]+)"), re.compile(r"([^ ]+) ([0-9]+/[0-9]+)"), re.compile(r"([^ ]+)/([0-9]+/[0-9]+)"), ) compare_attrs = ('names',) def __init__(self, names): self.names = names def getRenderingFor(self, props): downloads = [] for propName in self.names: s = props.getProperty(propName) if s is not None: downloads.extend(self.parseDownloadProperty(s)) return downloads def parseDownloadProperty(self, s): """ lets try to be nice in the format we want can support several instances of "repo download proj number/patch" (direct copy paste from gerrit web site) or several instances of "proj number/patch" (simpler version) This feature allows integrator to build with several pending interdependant changes. returns list of repo downloads sent to the buildslave """ if s is None: return [] ret = [] for cur_re in self.parse_download_re: res = cur_re.search(s) while res: ret.append("%s %s" % (res.group(1), res.group(2))) s = s[:res.start(0)] + s[res.end(0):] res = cur_re.search(s) return ret class RepoDownloadsFromChangeSource(util.ComparableMixin, object): implements(IRenderable) compare_attrs = ('codebase',) def __init__(self, codebase=None): self.codebase = codebase def getRenderingFor(self, props): downloads = [] if self.codebase is None: changes = props.getBuild().allChanges() else: changes = props.getBuild().getSourceStamp(self.codebase).changes for change in changes: if ("event.type" in change.properties and change.properties["event.type"] == "patchset-created"): downloads.append("%s %s/%s" % (change.properties["event.change.project"], change.properties["event.change.number"], change.properties["event.patchSet.number"])) return downloads class Repo(Source): """ Class for Repo with all the smarts """ name = 'repo' renderables = ["manifestURL", "manifestFile", "tarball", "jobs", "syncAllBranches", "updateTarballAge", "manifestOverrideUrl", "repoDownloads"] ref_not_found_re = re.compile(r"fatal: Couldn't find remote ref") cherry_pick_error_re = re.compile(r"|".join([r"Automatic cherry-pick failed", r"error: " r"fatal: " r"possibly due to conflict resolution."])) re_change = re.compile(r".* refs/changes/\d\d/(\d+)/(\d+) -> FETCH_HEAD$") re_head = re.compile(r"^HEAD is now at ([0-9a-f]+)...") mirror_sync_retry = 10 # number of retries, if we detect mirror desynchronization mirror_sync_sleep = 60 # wait 1min between retries (thus default total retry time is 10min) def __init__(self, manifestURL=None, manifestBranch="master", manifestFile="default.xml", tarball=None, jobs=None, syncAllBranches=False, updateTarballAge=7*24.0*3600.0, manifestOverrideUrl=None, repoDownloads=None, **kwargs): """ @type manifestURL: string @param manifestURL: The URL which points at the repo manifests repository. @type manifestBranch: string @param manifestBranch: The manifest branch to check out by default. @type manifestFile: string @param manifestFile: The manifest to use for sync. @type syncAllBranches: bool. @param syncAllBranches: true, then we must slowly synchronize all branches. @type updateTarballAge: float @param updateTarballAge: renderable to determine the update tarball policy, given properties Returns: max age of tarball in seconds, or None, if we want to skip tarball update @type manifestOverrideUrl: string @param manifestOverrideUrl: optional http URL for overriding the manifest usually coming from Property setup by a ForceScheduler @type repoDownloads: list of strings @param repoDownloads: optional repo download to perform after the repo sync """ self.manifestURL = manifestURL self.manifestBranch = manifestBranch self.manifestFile = manifestFile self.tarball = tarball self.jobs = jobs self.syncAllBranches = syncAllBranches self.updateTarballAge = updateTarballAge self.manifestOverrideUrl = manifestOverrideUrl if repoDownloads is None: repoDownloads = [] self.repoDownloads = repoDownloads Source.__init__(self, **kwargs) assert self.manifestURL is not None def computeSourceRevision(self, changes): if not changes: return None return changes[-1].revision def filterManifestPatches(self): """ Patches to manifest projects are a bit special. repo does not support a way to download them automatically, so we need to implement the boilerplate manually. This code separates the manifest patches from the other patches, and generates commands to import those manifest patches. """ manifest_unrelated_downloads = [] manifest_related_downloads = [] for download in self.repoDownloads: project, ch_ps = download.split(" ")[-2:] if (self.manifestURL.endswith("/"+project) or self.manifestURL.endswith("/"+project+".git")): ch, ps = map(int, ch_ps.split("/")) branch = "refs/changes/%02d/%d/%d" % (ch % 100, ch, ps) manifest_related_downloads.append( ["git", "fetch", self.manifestURL, branch]) manifest_related_downloads.append( ["git", "cherry-pick", "FETCH_HEAD"]) else: manifest_unrelated_downloads.append(download) self.repoDownloads = manifest_unrelated_downloads self.manifestDownloads = manifest_related_downloads def _repoCmd(self, command, abandonOnFailure=True, **kwargs): return self._Cmd(["repo"]+command, abandonOnFailure=abandonOnFailure, **kwargs) def _Cmd(self, command, abandonOnFailure=True, workdir=None, **kwargs): if workdir is None: workdir = self.workdir self.cmd = cmd = buildstep.RemoteShellCommand(workdir, command, env=self.env, logEnviron=self.logEnviron, timeout=self.timeout, **kwargs) # does not make sense to logEnviron for each command (just for first) self.logEnviron = False cmd.useLog(self.stdio_log, False) self.stdio_log.addHeader("Starting command: %s\n" % (" ".join(command), )) self.step_status.setText(["%s" % (" ".join(command[:2]))]) d = self.runCommand(cmd) def evaluateCommand(cmd): if abandonOnFailure and cmd.didFail(): self.step_status.setText(["repo failed at: %s" % (" ".join(command[:2]))]) self.stdio_log.addStderr("Source step failed while running command %s\n" % cmd) raise buildstep.BuildStepFailed() return cmd.rc d.addCallback(lambda _: evaluateCommand(cmd)) return d def repoDir(self): return self.build.path_module.join(self.workdir, ".repo") def sourcedirIsUpdateable(self): return self.pathExists(self.repoDir()) def startVC(self, branch, revision, patch): d = self.doStartVC() d.addErrback(self.failed) @defer.inlineCallbacks def doStartVC(self): self.stdio_log = self.addLogForRemoteCommands("stdio") self.filterManifestPatches() if self.repoDownloads: self.stdio_log.addHeader("will download:\n" + "repo download " + "\nrepo download ".join(self.repoDownloads) + "\n") self.willRetryInCaseOfFailure = True d = self.doRepoSync() def maybeRetry(why): # in case the tree was corrupted somehow because of previous build # we clobber one time, and retry everything if why.check(buildstep.BuildStepFailed) and self.willRetryInCaseOfFailure: self.stdio_log.addStderr("got issue at first try:\n" + str(why) + "\nRetry after clobber...") return self.doRepoSync(forceClobber=True) return why # propagate to self.failed d.addErrback(maybeRetry) yield d yield self.maybeUpdateTarball() # starting from here, clobbering will not help yield self.doRepoDownloads() self.setStatus(self.cmd, 0) yield self.finished(0) @defer.inlineCallbacks def doClobberStart(self): yield self.runRmdir(self.workdir) yield self.runMkdir(self.workdir) yield self.maybeExtractTarball() @defer.inlineCallbacks def doRepoSync(self, forceClobber=False): updatable = yield self.sourcedirIsUpdateable() if not updatable or forceClobber: # no need to re-clobber in case of failure self.willRetryInCaseOfFailure = False yield self.doClobberStart() yield self.doCleanup() yield self._repoCmd(['init', '-u', self.manifestURL, '-b', self.manifestBranch, '-m', self.manifestFile]) if self.manifestOverrideUrl: self.stdio_log.addHeader("overriding manifest with %s\n" % (self.manifestOverrideUrl)) local_file = yield self.pathExists(self.build.path_module.join(self.workdir, self.manifestOverrideUrl)) if local_file: yield self._Cmd(["cp", "-f", self.manifestOverrideUrl, "manifest_override.xml"]) else: yield self._Cmd(["wget", self.manifestOverrideUrl, "-O", "manifest_override.xml"]) yield self._Cmd(["ln", "-sf", "../manifest_override.xml", "manifest.xml"], workdir=self.build.path_module.join(self.workdir, ".repo")) for command in self.manifestDownloads: yield self._Cmd(command, workdir=self.build.path_module.join(self.workdir, ".repo", "manifests")) command = ['sync'] if self.jobs: command.append('-j' + str(self.jobs)) if not self.syncAllBranches: command.append('-c') self.step_status.setText(["repo sync"]) self.stdio_log.addHeader("synching manifest %s from branch %s from %s\n" % (self.manifestFile, self.manifestBranch, self.manifestURL)) yield self._repoCmd(command) command = ['manifest', '-r', '-o', 'manifest-original.xml'] yield self._repoCmd(command) # check whether msg matches one of the # compiled regexps in self.re_error_messages def _findErrorMessages(self, error_re): for logname in ['stderr', 'stdout']: if not hasattr(self.cmd, logname): continue msg = getattr(self.cmd, logname) if not (re.search(error_re, msg) is None): return True return False def _sleep(self, delay): d = defer.Deferred() reactor.callLater(delay, d.callback, 1) return d @defer.inlineCallbacks def doRepoDownloads(self): self.repo_downloaded = "" for download in self.repoDownloads: command = ['download'] + download.split(' ') self.stdio_log.addHeader("downloading changeset %s\n" % (download)) retry = self.mirror_sync_retry + 1 while retry > 0: yield self._repoCmd(command, abandonOnFailure=False, collectStdout=True, collectStderr=True) if not self._findErrorMessages(self.ref_not_found_re): break retry -= 1 self.stdio_log.addStderr("failed downloading changeset %s\n" % (download)) self.stdio_log.addHeader("wait one minute for mirror sync\n") yield self._sleep(self.mirror_sync_sleep) if retry == 0: self.step_status.setText(["repo: change %s does not exist" % download]) self.step_status.setText2(["repo: change %s does not exist" % download]) raise buildstep.BuildStepFailed() if self.cmd.didFail() or self._findErrorMessages(self.cherry_pick_error_re): # cherry pick error! We create a diff with status current workdir # in stdout, which reveals the merge errors and exit command = ['forall', '-c', 'git', 'diff', 'HEAD'] yield self._repoCmd(command, abandonOnFailure=False) self.step_status.setText(["download failed: %s" % download]) raise buildstep.BuildStepFailed() if hasattr(self.cmd, 'stderr'): lines = self.cmd.stderr.split("\n") match1 = match2 = False for line in lines: if not match1: match1 = self.re_change.match(line) if not match2: match2 = self.re_head.match(line) if match1 and match2: self.repo_downloaded += "%s/%s %s " % (match1.group(1), match1.group(2), match2.group(1)) self.setProperty("repo_downloaded", self.repo_downloaded, "Source") def computeTarballOptions(self): # Keep in mind that the compression part of tarball generation # can be non negligible tar = ['tar'] if self.tarball.endswith("gz"): tar.append('-z') if self.tarball.endswith("bz2") or self.tarball.endswith("bz"): tar.append('-j') if self.tarball.endswith("lzma"): tar.append('--lzma') if self.tarball.endswith("lzop"): tar.append('--lzop') return tar @defer.inlineCallbacks def maybeExtractTarball(self): if self.tarball: tar = self.computeTarballOptions() + ['-xvf', self.tarball] res = yield self._Cmd(tar, abandonOnFailure=False) if res: # error with tarball.. erase repo dir and tarball yield self._Cmd(["rm", "-f", self.tarball], abandonOnFailure=False) yield self.runRmdir(self.repoDir(), abandonOnFailure=False) @defer.inlineCallbacks def maybeUpdateTarball(self): if not self.tarball or self.updateTarballAge is None: return # tarball path is absolute, so we cannot use slave's stat command # stat -c%Y gives mtime in second since epoch res = yield self._Cmd(["stat", "-c%Y", self.tarball], collectStdout=True, abandonOnFailure=False) if not res: tarball_mtime = int(self.cmd.stdout) yield self._Cmd(["stat", "-c%Y", "."], collectStdout=True) now_mtime = int(self.cmd.stdout) age = now_mtime - tarball_mtime if res or age > self.updateTarballAge: tar = self.computeTarballOptions() + ['-cvf', self.tarball, ".repo"] res = yield self._Cmd(tar, abandonOnFailure=False) if res: # error with tarball.. erase tarball, but dont fail yield self._Cmd(["rm", "-f", self.tarball], abandonOnFailure=False) # a simple shell script to gather all cleanup tweaks... # doing them one by one just complicate the stuff # and messup the stdio log def _getCleanupCommand(self): """also used by tests for expectations""" return textwrap.dedent("""\ set -v if [ -d .repo/manifests ] then # repo just refuse to run if manifest is messed up # so ensure we are in a known state cd .repo/manifests rm -f .git/index.lock git fetch origin git reset --hard remotes/origin/%(manifestBranch)s git config branch.default.merge %(manifestBranch)s cd .. ln -sf manifests/%(manifestFile)s manifest.xml cd .. fi repo forall -c rm -f .git/index.lock repo forall -c git clean -f -d -x 2>/dev/null repo forall -c git reset --hard HEAD 2>/dev/null rm -f %(workdir)s/.repo/project.list """) % self.__dict__ def doCleanup(self): command = self._getCleanupCommand() return self._Cmd(["bash", "-c", command], abandonOnFailure=False) buildbot-0.8.8/buildbot/steps/source/svn.py000066400000000000000000000333401222546025000207560ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import xml.dom.minidom import xml.parsers.expat from twisted.python import log from twisted.internet import defer from buildbot.process import buildstep from buildbot.steps.source.base import Source from buildbot.interfaces import BuildSlaveTooOldError from buildbot.config import ConfigErrors class SVN(Source): """I perform Subversion checkout/update operations.""" name = 'svn' renderables = [ 'repourl' ] possible_modes = ('incremental', 'full') possible_methods = ('clean', 'fresh', 'clobber', 'copy', 'export', None) def __init__(self, repourl=None, mode='incremental', method=None, username=None, password=None, extra_args=None, keep_on_purge=None, depth=None, preferLastChangedRev=False, **kwargs): self.repourl = repourl self.username = username self.password = password self.extra_args = extra_args self.keep_on_purge = keep_on_purge or [] self.depth = depth self.method = method self.mode = mode self.preferLastChangedRev = preferLastChangedRev Source.__init__(self, **kwargs) errors = [] if self.mode not in self.possible_modes: errors.append("mode %s is not one of %s" % (self.mode, self.possible_modes)) if self.method not in self.possible_methods: errors.append("method %s is not one of %s" % (self.method, self.possible_methods)) if repourl is None: errors.append("you must provide repourl") if errors: raise ConfigErrors(errors) def startVC(self, branch, revision, patch): self.revision = revision self.method = self._getMethod() self.stdio_log = self.addLogForRemoteCommands("stdio") d = self.checkSvn() def checkInstall(svnInstalled): if not svnInstalled: raise BuildSlaveTooOldError("SVN is not installed on slave") return 0 d.addCallback(checkInstall) if self.mode == 'full': d.addCallback(self.full) elif self.mode == 'incremental': d.addCallback(self.incremental) d.addCallback(self.parseGotRevision) d.addCallback(self.finish) d.addErrback(self.failed) return d @defer.inlineCallbacks def full(self, _): if self.method == 'clobber': yield self.clobber() return elif self.method in ['copy', 'export']: yield self.copy() return updatable = yield self._sourcedirIsUpdatable() if not updatable: # blow away the old (un-updatable) directory yield self.runRmdir(self.workdir) # then do a checkout checkout_cmd = ['checkout', self.repourl, '.'] if self.revision: checkout_cmd.extend(["--revision", str(self.revision)]) yield self._dovccmd(checkout_cmd) elif self.method == 'clean': yield self.clean() elif self.method == 'fresh': yield self.fresh() @defer.inlineCallbacks def incremental(self, _): updatable = yield self._sourcedirIsUpdatable() if not updatable: # blow away the old (un-updatable) directory yield self.runRmdir(self.workdir) # and plan to do a checkout command = ['checkout', self.repourl, '.'] else: # otherwise, do an update command = ['update'] if self.revision: command.extend(['--revision', str(self.revision)]) yield self._dovccmd(command) @defer.inlineCallbacks def clobber(self): yield self.runRmdir(self.workdir) checkout_cmd = ['checkout', self.repourl, '.'] if self.revision: checkout_cmd.extend(["--revision", str(self.revision)]) yield self._dovccmd(checkout_cmd) def fresh(self): d = self.purge(True) cmd = ['update'] if self.revision: cmd.extend(['--revision', str(self.revision)]) d.addCallback(lambda _: self._dovccmd(cmd)) return d def clean(self): d = self.purge(False) cmd = ['update'] if self.revision: cmd.extend(['--revision', str(self.revision)]) d.addCallback(lambda _: self._dovccmd(cmd)) return d @defer.inlineCallbacks def copy(self): yield self.runRmdir(self.workdir) # temporarily set workdir = 'source' and do an incremental checkout try: old_workdir = self.workdir self.workdir = 'source' yield self.incremental(None) except: # finally doesn't work in python-2.4 self.workdir = old_workdir raise self.workdir = old_workdir # if we're copying, copy; otherwise, export from source to build if self.method == 'copy': cmd = buildstep.RemoteCommand('cpdir', { 'fromdir': 'source', 'todir':self.workdir, 'logEnviron': self.logEnviron }) else: export_cmd = ['svn', 'export'] if self.revision: export_cmd.extend(["--revision", str(self.revision)]) export_cmd.extend(['source', self.workdir]) cmd = buildstep.RemoteShellCommand('', export_cmd, env=self.env, logEnviron=self.logEnviron, timeout=self.timeout) cmd.useLog(self.stdio_log, False) yield self.runCommand(cmd) if cmd.didFail(): raise buildstep.BuildStepFailed() def finish(self, res): d = defer.succeed(res) def _gotResults(results): self.setStatus(self.cmd, results) return results d.addCallback(_gotResults) d.addCallbacks(self.finished, self.checkDisconnect) return d def _dovccmd(self, command, collectStdout=False): assert command, "No command specified" command.extend(['--non-interactive', '--no-auth-cache']) if self.username: command.extend(['--username', self.username]) if self.password: command.extend(['--password', self.password]) if self.depth: command.extend(['--depth', self.depth]) if self.extra_args: command.extend(self.extra_args) cmd = buildstep.RemoteShellCommand(self.workdir, ['svn'] + command, env=self.env, logEnviron=self.logEnviron, timeout=self.timeout, collectStdout=collectStdout) cmd.useLog(self.stdio_log, False) log.msg("Starting SVN command : svn %s" % (" ".join(command), )) d = self.runCommand(cmd) def evaluateCommand(cmd): if cmd.didFail(): log.msg("Source step failed while running command %s" % cmd) raise buildstep.BuildStepFailed() if collectStdout: return cmd.stdout else: return cmd.rc d.addCallback(lambda _: evaluateCommand(cmd)) return d def _getMethod(self): if self.method is not None and self.mode != 'incremental': return self.method elif self.mode == 'incremental': return None elif self.method is None and self.mode == 'full': return 'fresh' @defer.inlineCallbacks def _sourcedirIsUpdatable(self): # first, perform a stat to ensure that this is really an svn directory res = yield self.pathExists(self.build.path_module.join(self.workdir, '.svn')) if not res: defer.returnValue(False) return # then run 'svn info --xml' to check that the URL matches our repourl stdout = yield self._dovccmd(['info', '--xml'], collectStdout=True) try: stdout_xml = xml.dom.minidom.parseString(stdout) extractedurl = stdout_xml.getElementsByTagName('url')[0].firstChild.nodeValue except xml.parsers.expat.ExpatError: msg = "Corrupted xml, aborting step" self.stdio_log.addHeader(msg) raise buildstep.BuildStepFailed() defer.returnValue(extractedurl == self.repourl) return @defer.inlineCallbacks def parseGotRevision(self, _): # if this was a full/export, then we need to check svnversion in the # *source* directory, not the build directory svnversion_dir = self.workdir if self.mode == 'full' and self.method == 'export': svnversion_dir = 'source' cmd = buildstep.RemoteShellCommand(svnversion_dir, ['svn', 'info', '--xml'], env=self.env, logEnviron=self.logEnviron, timeout=self.timeout, collectStdout=True) cmd.useLog(self.stdio_log, False) yield self.runCommand(cmd) stdout = cmd.stdout try: stdout_xml = xml.dom.minidom.parseString(stdout) except xml.parsers.expat.ExpatError: msg = "Corrupted xml, aborting step" self.stdio_log.addHeader(msg) raise buildstep.BuildStepFailed() revision = None if self.preferLastChangedRev: try: revision = stdout_xml.getElementsByTagName('commit')[0].attributes['revision'].value except (KeyError, IndexError): msg =("SVN.parseGotRevision unable to detect Last Changed Rev in" " output of svn info") log.msg(msg) # fall through and try to get 'Revision' instead if revision is None: try: revision = stdout_xml.getElementsByTagName('entry')[0].attributes['revision'].value except (KeyError, IndexError): msg =("SVN.parseGotRevision unable to detect revision in" " output of svn info") log.msg(msg) raise buildstep.BuildStepFailed() msg = "Got SVN revision %s" % (revision, ) self.stdio_log.addHeader(msg) self.updateSourceProperty('got_revision', revision) defer.returnValue(cmd.rc) def purge(self, ignore_ignores): """Delete everything that shown up on status.""" command = ['status', '--xml'] if ignore_ignores: command.append('--no-ignore') d = self._dovccmd(command, collectStdout=True) def parseAndRemove(stdout): files = [] for filename in self.getUnversionedFiles(stdout, self.keep_on_purge): filename = self.workdir+'/'+str(filename) files.append(filename) if len(files) == 0: d = defer.succeed(0) else: if self.slaveVersionIsOlderThan('rmdir', '2.14'): d = self.removeFiles(files) else: d = self.runRmdir(files, abandonOnFailure=False) return d d.addCallback(parseAndRemove) def evaluateCommand(rc): if rc != 0: log.msg("Failed removing files") raise buildstep.BuildStepFailed() return rc d.addCallback(evaluateCommand) return d @staticmethod def getUnversionedFiles(xmlStr, keep_on_purge): try: result_xml = xml.dom.minidom.parseString(xmlStr) except xml.parsers.expat.ExpatError: log.err("Corrupted xml, aborting step") raise buildstep.BuildStepFailed() for entry in result_xml.getElementsByTagName('entry'): (wc_status,) = entry.getElementsByTagName('wc-status') if wc_status.getAttribute('item') == 'external': continue if wc_status.getAttribute('item') == 'missing': continue filename = entry.getAttribute('path') if filename in keep_on_purge or filename == '': continue yield filename @defer.inlineCallbacks def removeFiles(self, files): for filename in files: res = yield self.runRmdir(filename, abandonOnFailure=False) if res: defer.returnValue(res) return defer.returnValue(0) def checkSvn(self): cmd = buildstep.RemoteShellCommand(self.workdir, ['svn', '--version'], env=self.env, logEnviron=self.logEnviron, timeout=self.timeout) cmd.useLog(self.stdio_log, False) d = self.runCommand(cmd) def evaluate(cmd): if cmd.rc != 0: return False return True d.addCallback(lambda _: evaluate(cmd)) return d def computeSourceRevision(self, changes): if not changes or None in [c.revision for c in changes]: return None lastChange = max([int(c.revision) for c in changes]) return lastChange buildbot-0.8.8/buildbot/steps/subunit.py000066400000000000000000000066701222546025000203470ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.steps.shell import ShellCommand from buildbot.status.results import SUCCESS, FAILURE class SubunitShellCommand(ShellCommand): """A ShellCommand that sniffs subunit output. """ def __init__(self, failureOnNoTests=False, *args, **kwargs): ShellCommand.__init__(self, *args, **kwargs) self.failureOnNoTests = failureOnNoTests # importing here gets around an import loop from buildbot.process import subunitlogobserver self.ioObverser = subunitlogobserver.SubunitLogObserver() self.addLogObserver('stdio', self.ioObverser) self.progressMetrics = self.progressMetrics + ('tests', 'tests failed') def commandComplete(self, cmd): # figure out all statistics about the run ob = self.ioObverser failures = len(ob.failures) errors = len(ob.errors) skips = len(ob.skips) total = ob.testsRun count = failures + errors text = [self.name] text2 = "" if not count: results = SUCCESS if total: text += ["%d %s" % \ (total, total == 1 and "test" or "tests"), "passed"] else: if self.failureOnNoTests: results = FAILURE text += ["no tests", "run"] else: results = FAILURE text.append("Total %d test(s)" % total) if failures: text.append("%d %s" % \ (failures, failures == 1 and "failure" or "failures")) if errors: text.append("%d %s" % \ (errors, errors == 1 and "error" or "errors")) text2 = "%d %s" % (count, (count == 1 and 'test' or 'tests')) if skips: text.append("%d %s" % (skips, skips == 1 and "skip" or "skips")) #TODO: expectedFailures/unexpectedSuccesses self.results = results self.text = text self.text2 = [text2] def evaluateCommand(self, cmd): if cmd.didFail(): return FAILURE return self.results def createSummary(self, loog): ob = self.ioObverser problems = "" for test, err in ob.errors + ob.failures: problems += "%s\n%s" % (test.id(), err) if problems: self.addCompleteLog("problems", problems) warnings = ob.warningio.getvalue() if warnings: self.addCompleteLog("warnings", warnings) def getText(self, cmd, results): return self.text def getText2(self, cmd, results): return self.text2 buildbot-0.8.8/buildbot/steps/transfer.py000066400000000000000000000437141222546025000205020ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os.path, tarfile, tempfile try: from cStringIO import StringIO assert StringIO except ImportError: from StringIO import StringIO from twisted.spread import pb from twisted.python import log from buildbot.process import buildstep from buildbot.process.buildstep import BuildStep from buildbot.process.buildstep import SUCCESS, FAILURE, SKIPPED from buildbot.interfaces import BuildSlaveTooOldError from buildbot.util import json from buildbot.util.eventual import eventually from buildbot import config class _FileWriter(pb.Referenceable): """ Helper class that acts as a file-object with write access """ def __init__(self, destfile, maxsize, mode): # Create missing directories. destfile = os.path.abspath(destfile) dirname = os.path.dirname(destfile) if not os.path.exists(dirname): os.makedirs(dirname) self.destfile = destfile self.mode = mode fd, self.tmpname = tempfile.mkstemp(dir=dirname) self.fp = os.fdopen(fd, 'wb') self.remaining = maxsize def remote_write(self, data): """ Called from remote slave to write L{data} to L{fp} within boundaries of L{maxsize} @type data: C{string} @param data: String of data to write """ if self.remaining is not None: if len(data) > self.remaining: data = data[:self.remaining] self.fp.write(data) self.remaining = self.remaining - len(data) else: self.fp.write(data) def remote_utime(self, accessed_modified): os.utime(self.destfile,accessed_modified) def remote_close(self): """ Called by remote slave to state that no more data will be transfered """ self.fp.close() self.fp = None # on windows, os.rename does not automatically unlink, so do it manually if os.path.exists(self.destfile): os.unlink(self.destfile) os.rename(self.tmpname, self.destfile) self.tmpname = None if self.mode is not None: os.chmod(self.destfile, self.mode) def cancel(self): # unclean shutdown, the file is probably truncated, so delete it # altogether rather than deliver a corrupted file fp = getattr(self, "fp", None) if fp: fp.close() os.unlink(self.destfile) if self.tmpname and os.path.exists(self.tmpname): os.unlink(self.tmpname) def _extractall(self, path=".", members=None): """Fallback extractall method for TarFile, in case it doesn't have its own.""" import copy directories = [] if members is None: members = self for tarinfo in members: if tarinfo.isdir(): # Extract directories with a safe mode. directories.append(tarinfo) tarinfo = copy.copy(tarinfo) tarinfo.mode = 0700 self.extract(tarinfo, path) # Reverse sort directories. directories.sort(lambda a, b: cmp(a.name, b.name)) directories.reverse() # Set correct owner, mtime and filemode on directories. for tarinfo in directories: dirpath = os.path.join(path, tarinfo.name) try: self.chown(tarinfo, dirpath) self.utime(tarinfo, dirpath) self.chmod(tarinfo, dirpath) except tarfile.ExtractError, e: if self.errorlevel > 1: raise else: self._dbg(1, "tarfile: %s" % e) class _DirectoryWriter(_FileWriter): """ A DirectoryWriter is implemented as a FileWriter, with an added post-processing step to unpack the archive, once the transfer has completed. """ def __init__(self, destroot, maxsize, compress, mode): self.destroot = destroot self.compress = compress self.fd, self.tarname = tempfile.mkstemp() os.close(self.fd) _FileWriter.__init__(self, self.tarname, maxsize, mode) def remote_unpack(self): """ Called by remote slave to state that no more data will be transfered """ # Make sure remote_close is called, otherwise atomic rename wont happen self.remote_close() # Map configured compression to a TarFile setting if self.compress == 'bz2': mode='r|bz2' elif self.compress == 'gz': mode='r|gz' else: mode = 'r' # Support old python if not hasattr(tarfile.TarFile, 'extractall'): tarfile.TarFile.extractall = _extractall # Unpack archive and clean up after self archive = tarfile.open(name=self.tarname, mode=mode) archive.extractall(path=self.destroot) archive.close() os.remove(self.tarname) def makeStatusRemoteCommand(step, remote_command, args): self = buildstep.RemoteCommand(remote_command, args, decodeRC={None:SUCCESS, 0:SUCCESS}) callback = lambda arg: step.step_status.addLog('stdio') self.useLogDelayed('stdio', callback, True) return self class _TransferBuildStep(BuildStep): """ Base class for FileUpload and FileDownload to factor out common functionality. """ DEFAULT_WORKDIR = "build" # is this redundant? renderables = [ 'workdir' ] haltOnFailure = True flunkOnFailure = True def setDefaultWorkdir(self, workdir): if self.workdir is None: self.workdir = workdir def _getWorkdir(self): if self.workdir is None: workdir = self.DEFAULT_WORKDIR else: workdir = self.workdir return workdir def interrupt(self, reason): self.addCompleteLog('interrupt', str(reason)) if self.cmd: d = self.cmd.interrupt(reason) return d def finished(self, result): # Subclasses may choose to skip a transfer. In those cases, self.cmd # will be None, and we should just let BuildStep.finished() handle # the rest if result == SKIPPED: return BuildStep.finished(self, SKIPPED) if self.cmd.didFail(): return BuildStep.finished(self, FAILURE) return BuildStep.finished(self, SUCCESS) class FileUpload(_TransferBuildStep): name = 'upload' renderables = [ 'slavesrc', 'masterdest', 'url' ] def __init__(self, slavesrc, masterdest, workdir=None, maxsize=None, blocksize=16*1024, mode=None, keepstamp=False, url=None, **buildstep_kwargs): BuildStep.__init__(self, **buildstep_kwargs) self.slavesrc = slavesrc self.masterdest = masterdest self.workdir = workdir self.maxsize = maxsize self.blocksize = blocksize if not isinstance(mode, (int, type(None))): config.error( 'mode must be an integer or None') self.mode = mode self.keepstamp = keepstamp self.url = url def start(self): version = self.slaveVersion("uploadFile") if not version: m = "slave is too old, does not know about uploadFile" raise BuildSlaveTooOldError(m) source = self.slavesrc masterdest = self.masterdest # we rely upon the fact that the buildmaster runs chdir'ed into its # basedir to make sure that relative paths in masterdest are expanded # properly. TODO: maybe pass the master's basedir all the way down # into the BuildStep so we can do this better. masterdest = os.path.expanduser(masterdest) log.msg("FileUpload started, from slave %r to master %r" % (source, masterdest)) self.step_status.setText(['uploading', os.path.basename(source)]) if self.url is not None: self.addURL(os.path.basename(masterdest), self.url) # we use maxsize to limit the amount of data on both sides fileWriter = _FileWriter(masterdest, self.maxsize, self.mode) if self.keepstamp and self.slaveVersionIsOlderThan("uploadFile","2.13"): m = ("This buildslave (%s) does not support preserving timestamps. " "Please upgrade the buildslave." % self.build.slavename ) raise BuildSlaveTooOldError(m) # default arguments args = { 'slavesrc': source, 'workdir': self._getWorkdir(), 'writer': fileWriter, 'maxsize': self.maxsize, 'blocksize': self.blocksize, 'keepstamp': self.keepstamp, } self.cmd = makeStatusRemoteCommand(self, 'uploadFile', args) d = self.runCommand(self.cmd) @d.addErrback def cancel(res): fileWriter.cancel() return res d.addCallback(self.finished).addErrback(self.failed) class DirectoryUpload(_TransferBuildStep): name = 'upload' renderables = [ 'slavesrc', 'masterdest', 'url' ] def __init__(self, slavesrc, masterdest, workdir=None, maxsize=None, blocksize=16*1024, compress=None, url=None, **buildstep_kwargs): BuildStep.__init__(self, **buildstep_kwargs) self.slavesrc = slavesrc self.masterdest = masterdest self.workdir = workdir self.maxsize = maxsize self.blocksize = blocksize if compress not in (None, 'gz', 'bz2'): config.error( "'compress' must be one of None, 'gz', or 'bz2'") self.compress = compress self.url = url def start(self): version = self.slaveVersion("uploadDirectory") if not version: m = "slave is too old, does not know about uploadDirectory" raise BuildSlaveTooOldError(m) source = self.slavesrc masterdest = self.masterdest # we rely upon the fact that the buildmaster runs chdir'ed into its # basedir to make sure that relative paths in masterdest are expanded # properly. TODO: maybe pass the master's basedir all the way down # into the BuildStep so we can do this better. masterdest = os.path.expanduser(masterdest) log.msg("DirectoryUpload started, from slave %r to master %r" % (source, masterdest)) self.step_status.setText(['uploading', os.path.basename(source)]) if self.url is not None: self.addURL(os.path.basename(masterdest), self.url) # we use maxsize to limit the amount of data on both sides dirWriter = _DirectoryWriter(masterdest, self.maxsize, self.compress, 0600) # default arguments args = { 'slavesrc': source, 'workdir': self._getWorkdir(), 'writer': dirWriter, 'maxsize': self.maxsize, 'blocksize': self.blocksize, 'compress': self.compress } self.cmd = makeStatusRemoteCommand(self, 'uploadDirectory', args) d = self.runCommand(self.cmd) @d.addErrback def cancel(res): dirWriter.cancel() return res d.addCallback(self.finished).addErrback(self.failed) def finished(self, result): # Subclasses may choose to skip a transfer. In those cases, self.cmd # will be None, and we should just let BuildStep.finished() handle # the rest if result == SKIPPED: return BuildStep.finished(self, SKIPPED) if self.cmd.didFail(): return BuildStep.finished(self, FAILURE) return BuildStep.finished(self, SUCCESS) class _FileReader(pb.Referenceable): """ Helper class that acts as a file-object with read access """ def __init__(self, fp): self.fp = fp def remote_read(self, maxlength): """ Called from remote slave to read at most L{maxlength} bytes of data @type maxlength: C{integer} @param maxlength: Maximum number of data bytes that can be returned @return: Data read from L{fp} @rtype: C{string} of bytes read from file """ if self.fp is None: return '' data = self.fp.read(maxlength) return data def remote_close(self): """ Called by remote slave to state that no more data will be transfered """ if self.fp is not None: self.fp.close() self.fp = None class FileDownload(_TransferBuildStep): name = 'download' renderables = [ 'mastersrc', 'slavedest' ] def __init__(self, mastersrc, slavedest, workdir=None, maxsize=None, blocksize=16*1024, mode=None, **buildstep_kwargs): BuildStep.__init__(self, **buildstep_kwargs) self.mastersrc = mastersrc self.slavedest = slavedest self.workdir = workdir self.maxsize = maxsize self.blocksize = blocksize if not isinstance(mode, (int, type(None))): config.error( 'mode must be an integer or None') self.mode = mode def start(self): version = self.slaveVersion("downloadFile") if not version: m = "slave is too old, does not know about downloadFile" raise BuildSlaveTooOldError(m) # we are currently in the buildmaster's basedir, so any non-absolute # paths will be interpreted relative to that source = os.path.expanduser(self.mastersrc) slavedest = self.slavedest log.msg("FileDownload started, from master %r to slave %r" % (source, slavedest)) self.step_status.setText(['downloading', "to", os.path.basename(slavedest)]) # setup structures for reading the file try: fp = open(source, 'rb') except IOError: # if file does not exist, bail out with an error self.addCompleteLog('stderr', 'File %r not available at master' % source) # TODO: once BuildStep.start() gets rewritten to use # maybeDeferred, just re-raise the exception here. eventually(BuildStep.finished, self, FAILURE) return fileReader = _FileReader(fp) # default arguments args = { 'slavedest': slavedest, 'maxsize': self.maxsize, 'reader': fileReader, 'blocksize': self.blocksize, 'workdir': self._getWorkdir(), 'mode': self.mode, } self.cmd = makeStatusRemoteCommand(self, 'downloadFile', args) d = self.runCommand(self.cmd) d.addCallback(self.finished).addErrback(self.failed) class StringDownload(_TransferBuildStep): name = 'string_download' renderables = [ 'slavedest', 's' ] def __init__(self, s, slavedest, workdir=None, maxsize=None, blocksize=16*1024, mode=None, **buildstep_kwargs): BuildStep.__init__(self, **buildstep_kwargs) self.s = s self.slavedest = slavedest self.workdir = workdir self.maxsize = maxsize self.blocksize = blocksize if not isinstance(mode, (int, type(None))): config.error( 'mode must be an integer or None') self.mode = mode def start(self): version = self.slaveVersion("downloadFile") if not version: m = "slave is too old, does not know about downloadFile" raise BuildSlaveTooOldError(m) # we are currently in the buildmaster's basedir, so any non-absolute # paths will be interpreted relative to that slavedest = self.slavedest log.msg("StringDownload started, from master to slave %r" % slavedest) self.step_status.setText(['downloading', "to", os.path.basename(slavedest)]) # setup structures for reading the file fp = StringIO(self.s) fileReader = _FileReader(fp) # default arguments args = { 'slavedest': slavedest, 'maxsize': self.maxsize, 'reader': fileReader, 'blocksize': self.blocksize, 'workdir': self._getWorkdir(), 'mode': self.mode, } self.cmd = makeStatusRemoteCommand(self, 'downloadFile', args) d = self.runCommand(self.cmd) d.addCallback(self.finished).addErrback(self.failed) class JSONStringDownload(StringDownload): name = "json_download" def __init__(self, o, slavedest, **buildstep_kwargs): if 's' in buildstep_kwargs: del buildstep_kwargs['s'] s = json.dumps(o) StringDownload.__init__(self, s=s, slavedest=slavedest, **buildstep_kwargs) class JSONPropertiesDownload(StringDownload): name = "json_properties_download" def __init__(self, slavedest, **buildstep_kwargs): self.super_class = StringDownload if 's' in buildstep_kwargs: del buildstep_kwargs['s'] StringDownload.__init__(self, s=None, slavedest=slavedest, **buildstep_kwargs) def start(self): properties = self.build.getProperties() props = {} for key, value, source in properties.asList(): props[key] = value self.s = json.dumps(dict( properties=props, sourcestamp=self.build.getSourceStamp().asDict(), ), ) return self.super_class.start(self) buildbot-0.8.8/buildbot/steps/trigger.py000066400000000000000000000176241222546025000203220ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.interfaces import ITriggerableScheduler from buildbot.process.buildstep import LoggingBuildStep, SUCCESS, FAILURE, EXCEPTION from buildbot.process.properties import Properties, Property from twisted.python import log from twisted.internet import defer from buildbot import config class Trigger(LoggingBuildStep): name = "trigger" renderables = [ 'set_properties', 'schedulerNames', 'sourceStamps', 'updateSourceStamp', 'alwaysUseLatest' ] flunkOnFailure = True def __init__(self, schedulerNames=[], sourceStamp = None, sourceStamps = None, updateSourceStamp=None, alwaysUseLatest=False, waitForFinish=False, set_properties={}, copy_properties=[], **kwargs): if not schedulerNames: config.error( "You must specify a scheduler to trigger") if (sourceStamp or sourceStamps) and (updateSourceStamp is not None): config.error( "You can't specify both sourceStamps and updateSourceStamp") if (sourceStamp or sourceStamps) and alwaysUseLatest: config.error( "You can't specify both sourceStamps and alwaysUseLatest") if alwaysUseLatest and (updateSourceStamp is not None): config.error( "You can't specify both alwaysUseLatest and updateSourceStamp" ) self.schedulerNames = schedulerNames self.sourceStamps = sourceStamps or [] if sourceStamp: self.sourceStamps.append(sourceStamp) if updateSourceStamp is not None: self.updateSourceStamp = updateSourceStamp else: self.updateSourceStamp = not (alwaysUseLatest or self.sourceStamps) self.alwaysUseLatest = alwaysUseLatest self.waitForFinish = waitForFinish properties = {} properties.update(set_properties) for i in copy_properties: properties[i] = Property(i) self.set_properties = properties self.running = False self.ended = False LoggingBuildStep.__init__(self, **kwargs) def interrupt(self, reason): if self.running and not self.ended: self.step_status.setText(["interrupted"]) return self.end(EXCEPTION) def end(self, result): if not self.ended: self.ended = True return self.finished(result) # Create the properties that are used for the trigger def createTriggerProperties(self): # make a new properties object from a dict rendered by the old # properties object trigger_properties = Properties() trigger_properties.update(self.set_properties, "Trigger") return trigger_properties # Get all scheduler instances that were configured # A tuple of (triggerables, invalidnames) is returned def getSchedulers(self): all_schedulers = self.build.builder.botmaster.parent.allSchedulers() all_schedulers = dict([(sch.name, sch) for sch in all_schedulers]) invalid_schedulers = [] triggered_schedulers = [] # don't fire any schedulers if we discover an unknown one for scheduler in self.schedulerNames: scheduler = scheduler if all_schedulers.has_key(scheduler): sch = all_schedulers[scheduler] if ITriggerableScheduler.providedBy(sch): triggered_schedulers.append(sch) else: invalid_schedulers.append(scheduler) else: invalid_schedulers.append(scheduler) return (triggered_schedulers, invalid_schedulers) def prepareSourcestampListForTrigger(self): if self.sourceStamps: ss_for_trigger = {} for ss in self.sourceStamps: codebase = ss.get('codebase','') assert codebase not in ss_for_trigger, "codebase specified multiple times" ss_for_trigger[codebase] = ss return ss_for_trigger if self.alwaysUseLatest: return {} # start with the sourcestamps from current build ss_for_trigger = {} objs_from_build = self.build.getAllSourceStamps() for ss in objs_from_build: ss_for_trigger[ss.codebase] = ss.asDict() # overrule revision in sourcestamps with got revision if self.updateSourceStamp: got = self.build.build_status.getAllGotRevisions() for codebase in ss_for_trigger: if codebase in got: ss_for_trigger[codebase]['revision'] = got[codebase] return ss_for_trigger @defer.inlineCallbacks def start(self): # Get all triggerable schedulers and check if there are invalid schedules (triggered_schedulers, invalid_schedulers) = self.getSchedulers() if invalid_schedulers: self.step_status.setText(['not valid scheduler:'] + invalid_schedulers) self.end(FAILURE) return self.running = True props_to_set = self.createTriggerProperties() ss_for_trigger = self.prepareSourcestampListForTrigger() dl = [] triggered_names = [] for sch in triggered_schedulers: dl.append(sch.trigger(ss_for_trigger, set_props=props_to_set)) triggered_names.append(sch.name) self.step_status.setText(['triggered'] + triggered_names) if self.waitForFinish: rclist = yield defer.DeferredList(dl, consumeErrors=1) else: # do something to handle errors for d in dl: d.addErrback(log.err, '(ignored) while invoking Triggerable schedulers:') rclist = None self.end(SUCCESS) return was_exception = was_failure = False brids = {} for was_cb, results in rclist: if isinstance(results, tuple): results, some_brids = results brids.update(some_brids) if not was_cb: was_exception = True log.err(results) continue if results==FAILURE: was_failure = True if was_exception: result = EXCEPTION elif was_failure: result = FAILURE else: result = SUCCESS if brids: master = self.build.builder.botmaster.parent def add_links(res): # reverse the dictionary lookup for brid to builder name brid_to_bn = dict((_brid,_bn) for _bn,_brid in brids.iteritems()) for was_cb, builddicts in res: if was_cb: for build in builddicts: bn = brid_to_bn[build['brid']] num = build['number'] url = master.status.getURLForBuild(bn, num) self.step_status.addURL("%s #%d" % (bn,num), url) return self.end(result) builddicts = [master.db.builds.getBuildsForRequest(br) for br in brids.values()] dl = defer.DeferredList(builddicts, consumeErrors=1) dl.addCallback(add_links) self.end(result) return buildbot-0.8.8/buildbot/steps/vstudio.py000066400000000000000000000336341222546025000203530ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # Visual studio steps from buildbot.steps.shell import ShellCommand from buildbot.process.buildstep import LogLineObserver from buildbot import config from buildbot.status.results import SUCCESS, WARNINGS, FAILURE import re def addEnvPath(env, name, value): """ concat a path for this name """ try: oldval = env[name] if not oldval.endswith(';'): oldval = oldval + ';' except KeyError: oldval = "" if not value.endswith(';'): value = value + ';' env[name] = oldval + value class MSLogLineObserver(LogLineObserver): _re_delimiter = re.compile(r'^(\d+>)?-{5}.+-{5}$') _re_file = re.compile(r'^(\d+>)?[^ ]+\.(cpp|c)$') _re_warning = re.compile(r' ?: warning [A-Z]+[0-9]+:') _re_error = re.compile(r' ?error ([A-Z]+[0-9]+)?\s?: ') nbFiles = 0 nbProjects = 0 nbWarnings = 0 nbErrors = 0 logwarnings = None logerrors = None def __init__(self, logwarnings, logerrors, **kwargs): LogLineObserver.__init__(self, **kwargs) self.logwarnings = logwarnings self.logerrors = logerrors self.stdoutParser.delimiter = "\r\n" self.stderrParser.delimiter = "\r\n" def outLineReceived(self, line): if self._re_delimiter.search(line): self.nbProjects += 1 self.logwarnings.addStdout("%s\n" % line) self.logerrors.addStdout("%s\n" % line) self.step.setProgress('projects', self.nbProjects) elif self._re_file.search(line): self.nbFiles += 1 self.step.setProgress('files', self.nbFiles) elif self._re_warning.search(line): self.nbWarnings += 1 self.logwarnings.addStdout("%s\n" % line) self.step.setProgress('warnings', self.nbWarnings) elif self._re_error.search("%s\n" % line): # error has no progress indication self.nbErrors += 1 self.logerrors.addStderr("%s\n" % line) class VisualStudio(ShellCommand): # an *abstract* base class, which will not itself work as a buildstep name = "compile" description = "compiling" descriptionDone = "compile" progressMetrics = ( ShellCommand.progressMetrics + ('projects', 'files','warnings',)) logobserver = None installdir = None default_installdir = None # One of build, or rebuild mode = "rebuild" projectfile = None config = None useenv = False project = None PATH = [] INCLUDE = [] LIB = [] renderables = [ 'projectfile', 'config', 'project' ] def __init__(self, installdir = None, mode = "rebuild", projectfile = None, config = 'release', useenv = False, project = None, INCLUDE = [], LIB = [], PATH = [], **kwargs): self.installdir = installdir self.mode = mode self.projectfile = projectfile self.config = config self.useenv = useenv self.project = project if len(INCLUDE) > 0: self.INCLUDE = INCLUDE self.useenv = True if len(LIB) > 0: self.LIB = LIB self.useenv = True if len(PATH) > 0: self.PATH = PATH # always upcall ! ShellCommand.__init__(self, **kwargs) def setupLogfiles(self, cmd, logfiles): logwarnings = self.addLog("warnings") logerrors = self.addLog("errors") self.logobserver = MSLogLineObserver(logwarnings, logerrors) self.addLogObserver('stdio', self.logobserver) ShellCommand.setupLogfiles(self, cmd, logfiles) def setupInstalldir(self): if not self.installdir: self.installdir = self.default_installdir def setupEnvironment(self, cmd): ShellCommand.setupEnvironment(self, cmd) if cmd.args['env'] is None: cmd.args['env'] = {} # setup the custom one, those one goes first for path in self.PATH: addEnvPath(cmd.args['env'], "PATH", path) for path in self.INCLUDE: addEnvPath(cmd.args['env'], "INCLUDE", path) for path in self.LIB: addEnvPath(cmd.args['env'], "LIB", path) self.setupInstalldir() def describe(self, done=False): description = ShellCommand.describe(self, done) if done: description.append('%d projects' % self.step_status.getStatistic('projects', 0)) description.append('%d files' % self.step_status.getStatistic('files', 0)) warnings = self.step_status.getStatistic('warnings', 0) if warnings > 0: description.append('%d warnings' % warnings) errors = self.step_status.getStatistic('errors', 0) if errors > 0: description.append('%d errors' % errors) return description def createSummary(self, log): self.step_status.setStatistic('projects', self.logobserver.nbProjects) self.step_status.setStatistic('files', self.logobserver.nbFiles) self.step_status.setStatistic('warnings', self.logobserver.nbWarnings) self.step_status.setStatistic('errors', self.logobserver.nbErrors) def evaluateCommand(self, cmd): if cmd.didFail(): return FAILURE if self.logobserver.nbErrors > 0: return FAILURE if self.logobserver.nbWarnings > 0: return WARNINGS else: return SUCCESS def finished(self, result): self.getLog("warnings").finish() self.getLog("errors").finish() ShellCommand.finished(self, result) class VC6(VisualStudio): default_installdir = 'C:\\Program Files\\Microsoft Visual Studio' def setupEnvironment(self, cmd): VisualStudio.setupEnvironment(self, cmd) # Root of Visual Developer Studio Common files. VSCommonDir = self.installdir + '\\Common' MSVCDir = self.installdir + '\\VC98' MSDevDir = VSCommonDir + '\\msdev98' addEnvPath(cmd.args['env'], "PATH", MSDevDir + '\\BIN') addEnvPath(cmd.args['env'], "PATH", MSVCDir + '\\BIN') addEnvPath(cmd.args['env'], "PATH", VSCommonDir + '\\TOOLS\\WINNT') addEnvPath(cmd.args['env'], "PATH", VSCommonDir + '\\TOOLS') addEnvPath(cmd.args['env'], "INCLUDE", MSVCDir + '\\INCLUDE') addEnvPath(cmd.args['env'], "INCLUDE", MSVCDir + '\\ATL\\INCLUDE') addEnvPath(cmd.args['env'], "INCLUDE", MSVCDir + '\\MFC\\INCLUDE') addEnvPath(cmd.args['env'], "LIB", MSVCDir + '\\LIB') addEnvPath(cmd.args['env'], "LIB", MSVCDir + '\\MFC\\LIB') def start(self): command = ["msdev"] command.append(self.projectfile) command.append("/MAKE") if self.project is not None: command.append(self.project + " - " + self.config) else: command.append("ALL - " + self.config) if self.mode == "rebuild": command.append("/REBUILD") elif self.mode == "clean": command.append("/CLEAN") else: command.append("/BUILD") if self.useenv: command.append("/USEENV") self.setCommand(command) return VisualStudio.start(self) class VC7(VisualStudio): default_installdir = 'C:\\Program Files\\Microsoft Visual Studio .NET 2003' def setupEnvironment(self, cmd): VisualStudio.setupEnvironment(self, cmd) VSInstallDir = self.installdir + '\\Common7\\IDE' VCInstallDir = self.installdir MSVCDir = self.installdir + '\\VC7' addEnvPath(cmd.args['env'], "PATH", VSInstallDir) addEnvPath(cmd.args['env'], "PATH", MSVCDir + '\\BIN') addEnvPath(cmd.args['env'], "PATH", VCInstallDir + '\\Common7\\Tools') addEnvPath(cmd.args['env'], "PATH", VCInstallDir + '\\Common7\\Tools\\bin') addEnvPath(cmd.args['env'], "INCLUDE", MSVCDir + '\\INCLUDE') addEnvPath(cmd.args['env'], "INCLUDE", MSVCDir + '\\ATLMFC\\INCLUDE') addEnvPath(cmd.args['env'], "INCLUDE", MSVCDir + '\\PlatformSDK\\include') addEnvPath(cmd.args['env'], "INCLUDE", VCInstallDir + '\\SDK\\v1.1\\include') addEnvPath(cmd.args['env'], "LIB", MSVCDir + '\\LIB') addEnvPath(cmd.args['env'], "LIB", MSVCDir + '\\ATLMFC\\LIB') addEnvPath(cmd.args['env'], "LIB", MSVCDir + '\\PlatformSDK\\lib') addEnvPath(cmd.args['env'], "LIB", VCInstallDir + '\\SDK\\v1.1\\lib') def start(self): command = ["devenv.com"] command.append(self.projectfile) if self.mode == "rebuild": command.append("/Rebuild") elif self.mode == "clean": command.append("/Clean") else: command.append("/Build") command.append(self.config) if self.useenv: command.append("/UseEnv") if self.project is not None: command.append("/Project") command.append(self.project) self.setCommand(command) return VisualStudio.start(self) #alias VC7 as VS2003 VS2003 = VC7 class VC8(VC7): # Our ones arch = None default_installdir = 'C:\\Program Files\\Microsoft Visual Studio 8' renderables = ['arch'] def __init__(self, arch = "x86", **kwargs): self.arch = arch # always upcall ! VisualStudio.__init__(self, **kwargs) def setupEnvironment(self, cmd): VisualStudio.setupEnvironment(self, cmd) VSInstallDir = self.installdir VCInstallDir = self.installdir + '\\VC' addEnvPath(cmd.args['env'], "PATH", VSInstallDir + '\\Common7\\IDE') if self.arch == "x64": addEnvPath(cmd.args['env'], "PATH", VCInstallDir + '\\BIN\\x86_amd64') addEnvPath(cmd.args['env'], "PATH", VCInstallDir + '\\BIN') addEnvPath(cmd.args['env'], "PATH", VSInstallDir + '\\Common7\\Tools') addEnvPath(cmd.args['env'], "PATH", VSInstallDir + '\\Common7\\Tools\\bin') addEnvPath(cmd.args['env'], "PATH", VCInstallDir + '\\PlatformSDK\\bin') addEnvPath(cmd.args['env'], "PATH", VSInstallDir + '\\SDK\\v2.0\\bin') addEnvPath(cmd.args['env'], "PATH", VCInstallDir + '\\VCPackages') addEnvPath(cmd.args['env'], "PATH", r'${PATH}') addEnvPath(cmd.args['env'], "INCLUDE", VCInstallDir + '\\INCLUDE') addEnvPath(cmd.args['env'], "INCLUDE", VCInstallDir + '\\ATLMFC\\include') addEnvPath(cmd.args['env'], "INCLUDE", VCInstallDir + '\\PlatformSDK\\include') archsuffix = '' if self.arch == "x64": archsuffix = '\\amd64' addEnvPath(cmd.args['env'], "LIB", VCInstallDir + '\\LIB' + archsuffix) addEnvPath(cmd.args['env'], "LIB", VCInstallDir + '\\ATLMFC\\LIB' + archsuffix) addEnvPath(cmd.args['env'], "LIB", VCInstallDir + '\\PlatformSDK\\lib' + archsuffix) addEnvPath(cmd.args['env'], "LIB", VSInstallDir + '\\SDK\\v2.0\\lib' + archsuffix) #alias VC8 as VS2005 VS2005 = VC8 class VCExpress9(VC8): def start(self): command = ["vcexpress"] command.append(self.projectfile) if self.mode == "rebuild": command.append("/Rebuild") elif self.mode == "clean": command.append("/Clean") else: command.append("/Build") command.append(self.config) if self.useenv: command.append("/UseEnv") if self.project is not None: command.append("/Project") command.append(self.project) self.setCommand(command) return VisualStudio.start(self) # Add first support for VC9 (Same as VC8, with a different installdir) class VC9(VC8): default_installdir = 'C:\\Program Files\\Microsoft Visual Studio 9.0' VS2008 = VC9 # VC10 doesn't look like it needs extra stuff. class VC10(VC9): default_installdir = 'C:\\Program Files\\Microsoft Visual Studio 10.0' VS2010 = VC10 # VC11 doesn't look like it needs extra stuff. class VC11(VC10): default_installdir = 'C:\\Program Files\\Microsoft Visual Studio 11.0' VS2012 = VC11 class MsBuild(VisualStudio): platform = None def __init__(self, platform, **kwargs): self.platform = platform VisualStudio.__init__(self, **kwargs) def setupEnvironment(self, cmd): VisualStudio.setupEnvironment(self, cmd) cmd.args['env']['VCENV_BAT'] = "\"${VS110COMNTOOLS}..\\..\\VC\\vcvarsall.bat\"" def describe(self, done=False): rv = [] if done: rv.append("built") else: rv.append("building") if self.project is not None: rv.append("%s for" % (self.project)) else: rv.append("solution for") rv.append("%s|%s" % (self.config, self.platform)) return rv def start(self): if self.platform is None: config.error('platform is mandatory. Please specify a string such as "Win32"') command = ["%VCENV_BAT%", "x86", "&&", "msbuild", self.projectfile, "/p:Configuration=%s" % (self.config), "/p:Platform=%s" % (self.platform)] if self.project is not None: command.append("/t:%s" % (self.project)) self.setCommand(command) return VisualStudio.start(self) buildbot-0.8.8/buildbot/test/000077500000000000000000000000001222546025000161145ustar00rootroot00000000000000buildbot-0.8.8/buildbot/test/__init__.py000066400000000000000000000020461222546025000202270ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # apply the same patches the buildmaster does when it starts from buildbot import monkeypatches monkeypatches.patch_all(for_tests=True) # import mock so we bail out early if it's not installed try: import mock mock = mock except ImportError: raise ImportError("Buildbot tests require the 'mock' module; " "try 'pip install mock'") buildbot-0.8.8/buildbot/test/fake/000077500000000000000000000000001222546025000170225ustar00rootroot00000000000000buildbot-0.8.8/buildbot/test/fake/__init__.py000066400000000000000000000000001222546025000211210ustar00rootroot00000000000000buildbot-0.8.8/buildbot/test/fake/botmaster.py000066400000000000000000000027451222546025000214040ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.application import service class FakeBotMaster(service.MultiService): def __init__(self, master): service.MultiService.__init__(self) self.setName("fake-botmaster") self.master = master self.locks = {} def getLockByID(self, lockid): if not lockid in self.locks: self.locks[lockid] = lockid.lockClass(lockid) # if the master.cfg file has changed maxCount= on the lock, the next # time a build is started, they'll get a new RealLock instance. Note # that this requires that MasterLock and SlaveLock (marker) instances # be hashable and that they should compare properly. return self.locks[lockid] def getLockFromLockAccess(self, access): return self.getLockByID(access.lockid) buildbot-0.8.8/buildbot/test/fake/fakebuild.py000066400000000000000000000035421222546025000213260ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock import posixpath from twisted.python import components from buildbot.process import properties from buildbot import interfaces class FakeBuildStatus(properties.PropertiesMixin, mock.Mock): # work around http://code.google.com/p/mock/issues/detail?id=105 def _get_child_mock(self, **kw): return mock.Mock(**kw) def getInterestedUsers(self): return [] components.registerAdapter( lambda build_status : build_status.properties, FakeBuildStatus, interfaces.IProperties) class FakeBuild(properties.PropertiesMixin): def __init__(self, props=None): self.build_status = FakeBuildStatus() self.builder = mock.Mock(name='build.builder') self.path_module = posixpath self.sources = {} if props is None: props = properties.Properties() props.build = self self.build_status.properties = props def getSourceStamp(self, codebase): if codebase in self.sources: return self.sources[codebase] return None components.registerAdapter( lambda build : build.build_status.properties, FakeBuild, interfaces.IProperties) buildbot-0.8.8/buildbot/test/fake/fakedb.py000066400000000000000000001135731222546025000206220ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members """ A complete re-implementation of the database connector components, but without using a database. These classes should pass the same tests as are applied to the real connector components. """ import base64 from buildbot.util import json, epoch2datetime, datetime2epoch from twisted.python import failure from twisted.internet import defer, reactor from buildbot.db import buildrequests # Fake DB Rows class Row(object): """ Parent class for row classes, which are used to specify test data for database-related tests. @cvar defaults: default values for columns @type defaults: dictionary @cvar table: the table name @cvar id_column: specify a column that should be assigned an auto-incremented id. Auto-assigned id's begin at 1000, so any explicitly specified ID's should be less than 1000. @cvar required_columns: a tuple of columns that must be given in the constructor @ivar values: the values to be inserted into this row """ id_column = () required_columns = () lists = () dicts = () def __init__(self, **kwargs): self.values = self.defaults.copy() self.values.update(kwargs) if self.id_column: if self.values[self.id_column] is None: self.values[self.id_column] = self.nextId() for col in self.required_columns: assert col in kwargs, "%s not specified" % col for col in self.lists: setattr(self, col, []) for col in self.dicts: setattr(self, col, {}) for col in kwargs.keys(): assert col in self.defaults, "%s is not a valid column" % col # make the values appear as attributes self.__dict__.update(self.values) def nextId(self): if not hasattr(self.__class__, '_next_id'): self.__class__._next_id = 1000 else: self.__class__._next_id += 1 return self.__class__._next_id class BuildRequest(Row): table = "buildrequests" defaults = dict( id = None, buildsetid = None, buildername = "bldr", priority = 0, complete = 0, results = -1, submitted_at = 0, complete_at = 0, ) id_column = 'id' required_columns = ('buildsetid',) class BuildRequestClaim(Row): table = "buildrequest_claims" defaults = dict( brid = None, objectid = None, claimed_at = None ) required_columns = ('brid', 'objectid', 'claimed_at') class Change(Row): table = "changes" defaults = dict( changeid = None, author = 'frank', comments = 'test change', is_dir = 0, branch = 'master', revision = 'abcd', revlink = 'http://vc/abcd', when_timestamp = 1200000, category = 'cat', repository = 'repo', codebase = '', project = 'proj', ) lists = ('files',) dicts = ('properties',) id_column = 'changeid' class ChangeFile(Row): table = "change_files" defaults = dict( changeid = None, filename = None, ) required_columns = ('changeid',) class ChangeProperty(Row): table = "change_properties" defaults = dict( changeid = None, property_name = None, property_value = None, ) required_columns = ('changeid',) class ChangeUser(Row): table = "change_users" defaults = dict( changeid = None, uid = None, ) required_columns = ('changeid',) class Patch(Row): table = "patches" defaults = dict( id = None, patchlevel = 0, patch_base64 = 'aGVsbG8sIHdvcmxk', # 'hello, world', patch_author = None, patch_comment = None, subdir = None, ) id_column = 'id' class SourceStampChange(Row): table = "sourcestamp_changes" defaults = dict( sourcestampid = None, changeid = None, ) required_columns = ('sourcestampid', 'changeid') class SourceStampSet(Row): table = "sourcestampsets" defaults = dict( id = None, ) id_column = 'id' class SourceStamp(Row): table = "sourcestamps" defaults = dict( id = None, branch = 'master', revision = 'abcd', patchid = None, repository = 'repo', codebase = '', project = 'proj', sourcestampsetid = None, ) id_column = 'id' class SchedulerChange(Row): table = "scheduler_changes" defaults = dict( objectid = None, changeid = None, important = 1, ) required_columns = ( 'objectid', 'changeid' ) class Buildset(Row): table = "buildsets" defaults = dict( id = None, external_idstring = 'extid', reason = 'because', sourcestampsetid = None, submitted_at = 12345678, complete = 0, complete_at = None, results = -1, ) id_column = 'id' required_columns = ( 'sourcestampsetid', ) class BuildsetProperty(Row): table = "buildset_properties" defaults = dict( buildsetid = None, property_name = 'prop', property_value = '[22, "fakedb"]', ) required_columns = ( 'buildsetid', ) class Object(Row): table = "objects" defaults = dict( id = None, name = 'nam', class_name = 'cls', ) id_column = 'id' class ObjectState(Row): table = "object_state" defaults = dict( objectid = None, name = 'nam', value_json = '{}', ) required_columns = ( 'objectid', ) class User(Row): table = "users" defaults = dict( uid = None, identifier = 'soap', bb_username = None, bb_password = None, ) id_column = 'uid' class UserInfo(Row): table = "users_info" defaults = dict( uid = None, attr_type = 'git', attr_data = 'Tyler Durden ', ) required_columns = ( 'uid', ) class Build(Row): table = "builds" defaults = dict( id = None, number = 29, brid = 39, start_time = 1304262222, finish_time = None) id_column = 'id' # Fake DB Components # TODO: test these using the same test methods as are used against the real # database class FakeDBComponent(object): def __init__(self, db, testcase): self.db = db self.t = testcase self.setUp() class FakeChangesComponent(FakeDBComponent): def setUp(self): self.changes = {} def insertTestData(self, rows): for row in rows: if isinstance(row, Change): self.changes[row.changeid] = row elif isinstance(row, ChangeFile): ch = self.changes[row.changeid] ch.files.append(row.filename) elif isinstance(row, ChangeProperty): ch = self.changes[row.changeid] n, vs = row.property_name, row.property_value v, s = json.loads(vs) ch.properties.setProperty(n, v, s) elif isinstance(row, ChangeUser): ch = self.changes[row.changeid] ch.uid = row.uid # component methods def addChange(self, author=None, files=None, comments=None, is_dir=0, revision=None, when_timestamp=None, branch=None, category=None, revlink='', properties={}, repository='', project='', codebase='', uid=None): if self.changes: changeid = max(self.changes.iterkeys()) + 1 else: changeid = 500 self.changes[changeid] = ch = Change( changeid=changeid, author=author, comments=comments, is_dir=is_dir, revision=revision, when_timestamp=datetime2epoch(when_timestamp), branch=branch, category=category, revlink=revlink, repository=repository, project=project, codebase=codebase) ch.files = files ch.properties = properties return defer.succeed(changeid) def getLatestChangeid(self): if self.changes: return defer.succeed(max(self.changes.iterkeys())) return defer.succeed(None) def getChange(self, changeid): try: row = self.changes[changeid] except KeyError: return defer.succeed(None) chdict = dict( changeid=row.changeid, author=row.author, files=row.files, comments=row.comments, is_dir=row.is_dir, revision=row.revision, when_timestamp=epoch2datetime(row.when_timestamp), branch=row.branch, category=row.category, revlink=row.revlink, properties=row.properties, repository=row.repository, codebase=row.codebase, project=row.project) return defer.succeed(chdict) def getChangeUids(self, changeid): try: ch_uids = [self.changes[changeid].uid] except KeyError: ch_uids = [] return defer.succeed(ch_uids) # TODO: getRecentChanges # fake methods def fakeAddChangeInstance(self, change): if not hasattr(change, 'number') or not change.number: if self.changes: changeid = max(self.changes.iterkeys()) + 1 else: changeid = 500 else: changeid = change.number # make a row from the change row = dict( changeid=changeid, author=change.who, files=change.files, comments=change.comments, is_dir=change.isdir, revision=change.revision, when_timestamp=change.when, branch=change.branch, category=change.category, revlink=change.revlink, properties=change.properties, repository=change.repository, codebase=change.codebase, project=change.project) self.changes[changeid] = row class FakeSchedulersComponent(FakeDBComponent): def setUp(self): self.states = {} self.classifications = {} def insertTestData(self, rows): for row in rows: if isinstance(row, SchedulerChange): cls = self.classifications.setdefault(row.objectid, {}) cls[row.changeid] = row.important # component methods def classifyChanges(self, objectid, classifications): self.classifications.setdefault(objectid, {}).update(classifications) return defer.succeed(None) def flushChangeClassifications(self, objectid, less_than=None): if less_than is not None: classifications = self.classifications.setdefault(objectid, {}) for changeid in classifications.keys(): if changeid < less_than: del classifications[changeid] else: self.classifications[objectid] = {} return defer.succeed(None) def getChangeClassifications(self, objectid, branch=-1, repository=-1, project=-1, codebase=-1): classifications = self.classifications.setdefault(objectid, {}) sentinel = dict(branch=object(), repository=object(), project=object(), codebase=object()) if branch != -1: # filter out the classifications for the requested branch classifications = dict( (k,v) for (k,v) in classifications.iteritems() if self.db.changes.changes.get(k, sentinel)['branch'] == branch ) if repository != -1: # filter out the classifications for the requested branch classifications = dict( (k,v) for (k,v) in classifications.iteritems() if self.db.changes.changes.get(k, sentinel)['repository'] == repository ) if project != -1: # filter out the classifications for the requested branch classifications = dict( (k,v) for (k,v) in classifications.iteritems() if self.db.changes.changes.get(k, sentinel)['project'] == project ) if codebase != -1: # filter out the classifications for the requested branch classifications = dict( (k,v) for (k,v) in classifications.iteritems() if self.db.changes.changes.get(k, sentinel)['codebase'] == codebase ) return defer.succeed(classifications) # fake methods def fakeClassifications(self, objectid, classifications): """Set the set of classifications for a scheduler""" self.classifications[objectid] = classifications # assertions def assertClassifications(self, objectid, classifications): self.t.assertEqual( self.classifications.get(objectid, {}), classifications) class FakeSourceStampSetsComponent(FakeDBComponent): def setUp(self): self.sourcestampsets = {} def insertTestData(self, rows): for row in rows: if isinstance(row, SourceStampSet): self.sourcestampsets[row.id] = dict() def addSourceStampSet(self): id = len(self.sourcestampsets) + 100 while id in self.sourcestampsets: id += 1 self.sourcestampsets[id] = dict() return defer.succeed(id) class FakeSourceStampsComponent(FakeDBComponent): def setUp(self): self.sourcestamps = {} self.patches = {} def insertTestData(self, rows): for row in rows: if isinstance(row, Patch): self.patches[row.id] = dict( patch_level=row.patchlevel, patch_body=base64.b64decode(row.patch_base64), patch_author=row.patch_author, patch_comment=row.patch_comment, patch_subdir=row.subdir) for row in rows: if isinstance(row, SourceStamp): ss = self.sourcestamps[row.id] = row.values.copy() ss['changeids'] = set() for row in rows: if isinstance(row, SourceStampChange): ss = self.sourcestamps[row.sourcestampid] ss['changeids'].add(row.changeid) # component methods def addSourceStamp(self, branch, revision, repository, project, sourcestampsetid, codebase = '', patch_body=None, patch_level=0, patch_author=None, patch_comment=None, patch_subdir=None, changeids=[]): id = len(self.sourcestamps) + 100 while id in self.sourcestamps: id += 1 changeids = set(changeids) if patch_body: patchid = len(self.patches) + 100 while patchid in self.patches: patchid += 1 self.patches[patchid] = dict( patch_level=patch_level, patch_body=patch_body, patch_subdir=patch_subdir, patch_author=patch_author, patch_comment=patch_comment ) else: patchid = None self.sourcestamps[id] = dict(id=id, sourcestampsetid=sourcestampsetid, branch=branch, revision=revision, codebase=codebase, patchid=patchid, repository=repository, project=project, changeids=changeids) return defer.succeed(id) def getSourceStamp(self, ssid): return defer.succeed(self._getSourceStamp(ssid)) def _getSourceStamp(self, ssid): if ssid in self.sourcestamps: ssdict = self.sourcestamps[ssid].copy() del ssdict['id'] ssdict['ssid'] = ssid patchid = ssdict['patchid'] if patchid: ssdict.update(self.patches[patchid]) else: ssdict['patch_body'] = None ssdict['patch_level'] = None ssdict['patch_subdir'] = None ssdict['patch_author'] = None ssdict['patch_comment'] = None del ssdict['patchid'] return ssdict else: return None def getSourceStamps(self, sourcestampsetid): sslist = [] for ssdict in self.sourcestamps.itervalues(): if ssdict['sourcestampsetid'] == sourcestampsetid: ssdictcpy = self._getSourceStamp(ssdict['id']) sslist.append(ssdictcpy) return defer.succeed(sslist) class FakeBuildsetsComponent(FakeDBComponent): def setUp(self): self.buildsets = {} self.completed_bsids = set() self.buildset_subs = [] def insertTestData(self, rows): for row in rows: if isinstance(row, Buildset): bs = self.buildsets[row.id] = row.values.copy() bs['properties'] = {} for row in rows: if isinstance(row, BuildsetProperty): assert row.buildsetid in self.buildsets n = row.property_name v, src = tuple(json.loads(row.property_value)) self.buildsets[row.buildsetid]['properties'][n] = (v, src) # component methods def _newBsid(self): bsid = 200 while bsid in self.buildsets: bsid += 1 return bsid def addBuildset(self, sourcestampsetid, reason, properties, builderNames, external_idstring=None, _reactor=reactor): bsid = self._newBsid() br_rows = [] for buildername in builderNames: br_rows.append( BuildRequest(buildsetid=bsid, buildername=buildername)) self.db.buildrequests.insertTestData(br_rows) # make up a row and keep its dictionary, with the properties tacked on bsrow = Buildset(sourcestampsetid=sourcestampsetid, reason=reason, external_idstring=external_idstring) self.buildsets[bsid] = bsrow.values.copy() self.buildsets[bsid]['properties'] = properties return defer.succeed((bsid, dict([ (br.buildername, br.id) for br in br_rows ]))) def completeBuildset(self, bsid, results, complete_at=None, _reactor=reactor): self.buildsets[bsid]['results'] = results self.buildsets[bsid]['complete'] = 1 self.buildsets[bsid]['complete_at'] = complete_at or _reactor.seconds() return defer.succeed(None) def getBuildset(self, bsid): if bsid not in self.buildsets: return defer.succeed(None) row = self.buildsets[bsid] return defer.succeed(self._row2dict(row)) def getBuildsets(self, complete=None): rv = [] for bs in self.buildsets.itervalues(): if complete is not None: if complete and bs['complete']: rv.append(self._row2dict(bs)) elif not complete and not bs['complete']: rv.append(self._row2dict(bs)) else: rv.append(self._row2dict(bs)) return defer.succeed(rv) def _row2dict(self, row): row = row.copy() if row['complete_at']: row['complete_at'] = epoch2datetime(row['complete_at']) else: row['complete_at'] = None row['submitted_at'] = row['submitted_at'] and \ epoch2datetime(row['submitted_at']) row['complete'] = bool(row['complete']) row['bsid'] = row['id'] del row['id'] return row def getBuildsetProperties(self, buildsetid): if buildsetid in self.buildsets: return defer.succeed( self.buildsets[buildsetid]['properties']) else: return defer.succeed({}) # fake methods def fakeBuildsetCompletion(self, bsid, result): assert bsid in self.buildsets self.buildsets[bsid]['results'] = result self.completed_bsids.add(bsid) def flushBuildsets(self): """ Flush the set of buildsets, for example after C{assertBuildset} """ self.buildsets = {} self.completed_bsids = set() # assertions def assertBuildsets(self, count): """Assert that exactly COUNT buildsets were added""" self.t.assertEqual(len(self.buildsets), count, "buildsets are %r" % (self.buildsets,)) def assertBuildset(self, bsid, expected_buildset, expected_sourcestamps): """Assert that the buildset and its attached sourcestamp look as expected; the ssid parameter of the buildset is omitted. Properties are converted with asList and sorted. Sourcestamp patches are inlined (patch_body, patch_level, patch_subdir), and changeids are represented as a set, but omitted if empty. If bsid is '?', then assert there is only one new buildset, and use that.""" if bsid == '?': self.assertBuildsets(1) bsid = self.buildsets.keys()[0] else: self.t.assertIn(bsid, self.buildsets) buildset = self.buildsets[bsid].copy() dictOfssDict= {} for sourcestamp in self.db.sourcestamps.sourcestamps.itervalues(): if sourcestamp['sourcestampsetid'] == buildset['sourcestampsetid']: ssdict = sourcestamp.copy() ss_repository = ssdict['codebase'] dictOfssDict[ss_repository] = ssdict if 'id' in buildset: del buildset['id'] # clear out some columns if the caller doesn't care for col in 'complete complete_at submitted_at results'.split(): if col not in expected_buildset: del buildset[col] if buildset['properties']: buildset['properties'] = sorted(buildset['properties'].items()) # only add brids if we're expecting them (sometimes they're unknown) if 'brids' in expected_buildset: buildset['brids'] = self.allBuildRequests(bsid) if 'builders' in expected_buildset: buildset['builders'] = self.allBuildRequests(bsid).keys() for ss in dictOfssDict.itervalues(): if 'id' in ss: del ss['id'] if not ss['changeids']: del ss['changeids'] # incorporate patch info if we have it if 'patchid' in ss and ss['patchid']: ss.update(self.db.sourcestamps.patches[ss['patchid']]) del ss['patchid'] self.t.assertEqual( dict(buildset=buildset, sourcestamps=dictOfssDict), dict(buildset=expected_buildset, sourcestamps=expected_sourcestamps)) return bsid def allBuildsetIds(self): return self.buildsets.keys() def allBuildRequests(self, bsid=None): if bsid is not None: is_same_bsid = lambda br: br.buildsetid==bsid else: is_same_bsid = lambda br: True return dict([ (br.buildername, br.id) for br in self.db.buildrequests.reqs.values() if is_same_bsid(br) ]) class FakeStateComponent(FakeDBComponent): def setUp(self): self.objects = {} self.states = {} def insertTestData(self, rows): for row in rows: if isinstance(row, Object): self.objects[(row.name, row.class_name)] = row.id self.states[row.id] = {} for row in rows: if isinstance(row, ObjectState): assert row.objectid in self.objects.values() self.states[row.objectid][row.name] = row.value_json # component methods def _newId(self): id = 100 while id in self.states: id += 1 return id def getObjectId(self, name, class_name): try: id = self.objects[(name, class_name)] except: # invent a new id and add it id = self.objects[(name, class_name)] = self._newId() self.states[id] = {} return defer.succeed(id) def getState(self, objectid, name, default=object): try: json_value = self.states[objectid][name] except KeyError: if default is not object: return defer.succeed(default) raise return defer.succeed(json.loads(json_value)) def setState(self, objectid, name, value): self.states[objectid][name] = json.dumps(value) return defer.succeed(None) # fake methods def fakeState(self, name, class_name, **kwargs): id = self.objects[(name, class_name)] = self._newId() self.objects[(name, class_name)] = id self.states[id] = dict( (k, json.dumps(v)) for k,v in kwargs.iteritems() ) return id # assertions def assertState(self, objectid, missing_keys=[], **kwargs): state = self.states[objectid] for k in missing_keys: self.t.assertFalse(k in state, "%s in %s" % (k, state)) for k,v in kwargs.iteritems(): self.t.assertIn(k, state) self.t.assertEqual(json.loads(state[k]), v, "state is %r" % (state,)) def assertStateByClass(self, name, class_name, **kwargs): objectid = self.objects[(name, class_name)] state = self.states[objectid] for k,v in kwargs.iteritems(): self.t.assertIn(k, state) self.t.assertEqual(json.loads(state[k]), v, "state is %r" % (state,)) class FakeBuildRequestsComponent(FakeDBComponent): # for use in determining "my" requests MASTER_ID = 824 # override this to set reactor.seconds _reactor = reactor def setUp(self): self.reqs = {} self.claims = {} def insertTestData(self, rows): for row in rows: if isinstance(row, BuildRequest): self.reqs[row.id] = row if isinstance(row, BuildRequestClaim): self.claims[row.brid] = row # component methods def getBuildRequest(self, brid): try: return defer.succeed(self._brdictFromRow(self.reqs[brid])) except: return defer.succeed(None) def getBuildRequests(self, buildername=None, complete=None, claimed=None, bsid=None): rv = [] for br in self.reqs.itervalues(): if buildername and br.buildername != buildername: continue if complete is not None: if complete and not br.complete: continue if not complete and br.complete: continue if claimed is not None: claim_row = self.claims.get(br.id) if claimed == "mine": if not claim_row or claim_row.objectid != self.MASTER_ID: continue elif claimed: if not claim_row: continue else: if claim_row: continue if bsid is not None: if br.buildsetid != bsid: continue rv.append(self._brdictFromRow(br)) return defer.succeed(rv) def claimBuildRequests(self, brids, claimed_at=None): for brid in brids: if brid not in self.reqs or brid in self.claims: return defer.fail( failure.Failure(buildrequests.AlreadyClaimedError)) claimed_at = datetime2epoch(claimed_at) if not claimed_at: claimed_at = self._reactor.seconds() # now that we've thrown any necessary exceptions, get started for brid in brids: self.claims[brid] = BuildRequestClaim(brid=brid, objectid=self.MASTER_ID, claimed_at=claimed_at) return defer.succeed(None) def reclaimBuildRequests(self, brids): for brid in brids: if brid not in self.claims: print "trying to reclaim brid %d, but it's not claimed" % brid return defer.fail( failure.Failure(buildrequests.AlreadyClaimedError)) # now that we've thrown any necessary exceptions, get started for brid in brids: self.claims[brid] = BuildRequestClaim(brid=brid, objectid=self.MASTER_ID, claimed_at=self._reactor.seconds()) return defer.succeed(None) def unclaimBuildRequests(self, brids): for brid in brids: try: self.claims.pop(brid) except KeyError: print "trying to unclaim brid %d, but it's not claimed" % brid return defer.fail( failure.Failure(buildrequests.AlreadyClaimedError)) return defer.succeed(None) # Code copied from buildrequests.BuildRequestConnectorComponent def _brdictFromRow(self, row): claimed = mine = False claimed_at = None claim_row = self.claims.get(row.id, None) if claim_row: claimed = True claimed_at = claim_row.claimed_at mine = claim_row.objectid == self.MASTER_ID submitted_at = epoch2datetime(row.submitted_at) complete_at = epoch2datetime(row.complete_at) return dict(brid=row.id, buildsetid=row.buildsetid, buildername=row.buildername, priority=row.priority, claimed=claimed, claimed_at=claimed_at, mine=mine, complete=bool(row.complete), results=row.results, submitted_at=submitted_at, complete_at=complete_at) # fake methods def fakeClaimBuildRequest(self, brid, claimed_at=None, objectid=None): if objectid is None: objectid = self.MASTER_ID self.claims[brid] = BuildRequestClaim(brid=brid, objectid=objectid, claimed_at=self._reactor.seconds()) def fakeUnclaimBuildRequest(self, brid): del self.claims[brid] # assertions def assertMyClaims(self, claimed_brids): self.t.assertEqual( [ id for (id, brc) in self.claims.iteritems() if brc.objectid == self.MASTER_ID ], claimed_brids) class FakeBuildsComponent(FakeDBComponent): def setUp(self): self.builds = {} def insertTestData(self, rows): for row in rows: if isinstance(row, Build): self.builds[row.id] = row # component methods def _newId(self): id = 100 while id in self.builds: id += 1 return id def getBuild(self, bid): row = self.builds.get(bid) if not row: return defer.succeed(None) return defer.succeed(dict( bid=row.id, brid=row.brid, number=row.number, start_time=epoch2datetime(row.start_time), finish_time=epoch2datetime(row.finish_time))) def getBuildsForRequest(self, brid): ret = [] for (id, row) in self.builds.items(): if row.brid == brid: ret.append(dict(bid = row.id, brid=row.brid, number=row.number, start_time=epoch2datetime(row.start_time), finish_time=epoch2datetime(row.finish_time))) return defer.succeed(ret) def addBuild(self, brid, number, _reactor=reactor): bid = self._newId() self.builds[bid] = Build(id=bid, number=number, brid=brid, start_time=_reactor.seconds, finish_time=None) return bid def finishBuilds(self, bids, _reactor=reactor): now = _reactor.seconds() for bid in bids: b = self.builds.get(bid) if b: b.finish_time = now class FakeUsersComponent(FakeDBComponent): def setUp(self): self.users = {} self.users_info = {} self.id_num = 0 def insertTestData(self, rows): for row in rows: if isinstance(row, User): self.users[row.uid] = dict(identifier=row.identifier, bb_username=row.bb_username, bb_password=row.bb_password) if isinstance(row, UserInfo): assert row.uid in self.users if row.uid not in self.users_info: self.users_info[row.uid] = [dict(attr_type=row.attr_type, attr_data=row.attr_data)] else: self.users_info[row.uid].append( dict(attr_type=row.attr_type, attr_data=row.attr_data)) def _user2dict(self, uid): usdict = None if uid in self.users: usdict = self.users[uid] if uid in self.users_info: infos = self.users_info[uid] for attr in infos: usdict[attr['attr_type']] = attr['attr_data'] usdict['uid'] = uid return usdict def nextId(self): self.id_num += 1 return self.id_num # component methods def findUserByAttr(self, identifier, attr_type, attr_data): for uid in self.users_info: attrs = self.users_info[uid] for attr in attrs: if (attr_type == attr['attr_type'] and attr_data == attr['attr_data']): return defer.succeed(uid) uid = self.nextId() self.db.insertTestData([User(uid=uid, identifier=identifier)]) self.db.insertTestData([UserInfo(uid=uid, attr_type=attr_type, attr_data=attr_data)]) return defer.succeed(uid) def getUser(self, uid): usdict = None if uid in self.users: usdict = self._user2dict(uid) return defer.succeed(usdict) def getUserByUsername(self, username): usdict = None for uid in self.users: user = self.users[uid] if user['bb_username'] == username: usdict = self._user2dict(uid) return defer.succeed(usdict) def updateUser(self, uid=None, identifier=None, bb_username=None, bb_password=None, attr_type=None, attr_data=None): assert uid is not None if identifier is not None: self.users[uid]['identifier'] = identifier if bb_username is not None: assert bb_password is not None try: user = self.users[uid] user['bb_username'] = bb_username user['bb_password'] = bb_password except KeyError: pass if attr_type is not None: assert attr_data is not None try: infos = self.users_info[uid] for attr in infos: if attr_type == attr['attr_type']: attr['attr_data'] = attr_data break else: infos.append(dict(attr_type=attr_type, attr_data=attr_data)) except KeyError: pass return defer.succeed(None) def removeUser(self, uid): if uid in self.users: self.users.pop(uid) self.users_info.pop(uid) return defer.succeed(None) def identifierToUid(self, identifier): for uid in self.users: if identifier == self.users[uid]['identifier']: return defer.succeed(uid) return defer.succeed(None) class FakeDBConnector(object): """ A stand-in for C{master.db} that operates without an actual database backend. This also implements a test-data interface similar to the L{buildbot.test.util.db.RealDatabaseMixin.insertTestData} method. The child classes implement various useful assertions and faking methods; see their documentation for more. """ def __init__(self, testcase): self._components = [] self.changes = comp = FakeChangesComponent(self, testcase) self._components.append(comp) self.schedulers = comp = FakeSchedulersComponent(self, testcase) self._components.append(comp) self.sourcestampsets = comp = FakeSourceStampSetsComponent(self,testcase) self._components.append(comp) self.sourcestamps = comp = FakeSourceStampsComponent(self, testcase) self._components.append(comp) self.buildsets = comp = FakeBuildsetsComponent(self, testcase) self._components.append(comp) self.state = comp = FakeStateComponent(self, testcase) self._components.append(comp) self.buildrequests = comp = FakeBuildRequestsComponent(self, testcase) self._components.append(comp) self.builds = comp = FakeBuildsComponent(self, testcase) self._components.append(comp) self.users = comp = FakeUsersComponent(self, testcase) self._components.append(comp) def setup(self): self.is_setup = True return defer.succeed(None) def insertTestData(self, rows): """Insert a list of Row instances into the database; this method can be called synchronously or asynchronously (it completes immediately) """ for comp in self._components: comp.insertTestData(rows) return defer.succeed(None) buildbot-0.8.8/buildbot/test/fake/fakemaster.py000066400000000000000000000063751222546025000215310ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import weakref from twisted.internet import defer from buildbot.test.fake import fakedb from buildbot.test.fake import pbmanager from buildbot.test.fake.botmaster import FakeBotMaster from buildbot import config import mock class FakeCache(object): """Emulate an L{AsyncLRUCache}, but without any real caching. This I{does} do the weakref part, to catch un-weakref-able objects.""" def __init__(self, name, miss_fn): self.name = name self.miss_fn = miss_fn def get(self, key, **kwargs): d = self.miss_fn(key, **kwargs) def mkref(x): if x is not None: weakref.ref(x) return x d.addCallback(mkref) return d class FakeCaches(object): def get_cache(self, name, miss_fn): return FakeCache(name, miss_fn) class FakeStatus(object): def builderAdded(self, name, basedir, category=None, description=None): return FakeBuilderStatus() class FakeBuilderStatus(object): def setDescription(self, description): self._description = description def getDescription(self): return self._description def setCategory(self, category): self._category = category def getCategory(self): return self._category def setSlavenames(self, names): pass def setCacheSize(self, size): pass def setBigState(self, state): pass class FakeMaster(object): """ Create a fake Master instance: a Mock with some convenience implementations: - Non-caching implementation for C{self.caches} """ def __init__(self, master_id=fakedb.FakeBuildRequestsComponent.MASTER_ID): self._master_id = master_id self.config = config.MasterConfig() self.caches = FakeCaches() self.pbmanager = pbmanager.FakePBManager() self.basedir = 'basedir' self.botmaster = FakeBotMaster(master=self) self.botmaster.parent = self self.status = FakeStatus() self.status.master = self def getObjectId(self): return defer.succeed(self._master_id) def subscribeToBuildRequests(self, callback): pass # work around http://code.google.com/p/mock/issues/detail?id=105 def _get_child_mock(self, **kw): return mock.Mock(**kw) # Leave this alias, in case we want to add more behavior later def make_master(wantDb=False, testcase=None, **kwargs): master = FakeMaster(**kwargs) if wantDb: assert testcase is not None, "need testcase for wantDb" master.db = fakedb.FakeDBConnector(testcase) return master buildbot-0.8.8/buildbot/test/fake/libvirt.py000066400000000000000000000033101222546025000210440ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members class Domain(object): def __init__(self, name, conn): self.conn = conn self._name = name self.running = False def name(self): return self._name def create(self): self.running = True def shutdown(self): self.running = False def destroy(self): self.running = False del self.conn[self._name] class Connection(object): def __init__(self, uri): self.uri = uri self.domains = {} def createXML(self, xml, flags): #FIXME: This should really parse the name out of the xml, i guess d = self.fake_add("instance") d.running = True return d def listDomainsID(self): return self.domains.keys() def lookupByName(self, name): return self.domains[name] def lookupByID(self, ID): return self.domains[ID] def fake_add(self, name): d = Domain(name, self) self.domains[name] = d return d def open(uri): return Connection(uri) buildbot-0.8.8/buildbot/test/fake/openstack.py000066400000000000000000000047111222546025000213660ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members # Portions Copyright 2013 Cray Inc. import uuid ACTIVE = 'ACTIVE' BUILD = 'BUILD' DELETED = 'DELETED' ERROR = 'ERROR' UNKNOWN = 'UNKNOWN' # Parts used from novaclient.v1_1. class Client(): def __init__(self, username, password, tenant_name, auth_url): self.images = Images() self.servers = Servers() class Images(): images = [] def list(self): return self.images class Servers(): fail_to_get = False fail_to_start = False gets_until_active = 2 def __init__(self): self.instances = {} def create(self, *boot_args, **boot_kwargs): instance_id = uuid.uuid4() instance = Instance(instance_id, self, boot_args, boot_kwargs) self.instances[instance_id] = instance return instance def get(self, instance_id): if not self.fail_to_get and instance_id in self.instances: inst = self.instances[instance_id] inst.gets += 1 if inst.gets >= self.gets_until_active: if not self.fail_to_start: inst.status = ACTIVE else: inst.status = ERROR return inst else: raise NotFound def delete(self, instance_id): if instance_id in self.instances: del self.instances[instance_id] # This is returned by Servers.create(). class Instance(): def __init__(self, id, servers, boot_args, boot_kwargs): self.id = id self.servers = servers self.boot_args = boot_args self.boot_kwargs = boot_kwargs self.gets = 0 self.status = BUILD self.name = 'name' def delete(self): self.servers.delete(self.id) # Parts used from novaclient.exceptions. class NotFound(): pass buildbot-0.8.8/buildbot/test/fake/pbmanager.py000066400000000000000000000034511222546025000213330ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.application import service from twisted.internet import defer class FakePBManager(service.MultiService): def __init__(self): service.MultiService.__init__(self) self.setName("fake-pbmanager") self._registrations = [] self._unregistrations = [] def register(self, portstr, username, password, pfactory): if (portstr, username) not in self._registrations: reg = FakeRegistration(self, portstr, username) self._registrations.append((portstr,username,password)) return reg else: raise KeyError, ("username '%s' is already registered on port %s" % (username, portstr)) def _unregister(self, portstr, username): self._unregistrations.append((portstr, username)) return defer.succeed(None) class FakeRegistration(object): def __init__(self, pbmanager, portstr, username): self._portstr = portstr self._username = username self._pbmanager = pbmanager def unregister(self): self._pbmanager._unregister(self._portstr, self._username) buildbot-0.8.8/buildbot/test/fake/remotecommand.py000066400000000000000000000246061222546025000222360ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.internet import defer from twisted.python import failure from buildbot.status.logfile import STDOUT, STDERR, HEADER from cStringIO import StringIO from buildbot.status.results import SUCCESS, FAILURE class FakeRemoteCommand(object): # callers should set this to the running TestCase instance testcase = None active = False def __init__(self, remote_command, args, ignore_updates=False, collectStdout=False, collectStderr=False, decodeRC={0:SUCCESS}): # copy the args and set a few defaults self.remote_command = remote_command self.args = args.copy() self.logs = {} self.delayedLogs = {} self.rc = -999 self.collectStdout = collectStdout self.collectStderr = collectStderr self.updates = {} self.decodeRC = decodeRC if collectStdout: self.stdout = '' if collectStderr: self.stderr = '' def run(self, step, remote): # delegate back to the test case return self.testcase._remotecommand_run(self, step, remote) def useLog(self, log, closeWhenFinished=False, logfileName=None): if not logfileName: logfileName = log.getName() self.logs[logfileName] = log def useLogDelayed(self, logfileName, activateCallBack, closeWhenFinished=False): self.delayedLogs[logfileName] = (activateCallBack, closeWhenFinished) def interrupt(self, why): raise NotImplementedError def results(self): if self.rc in self.decodeRC: return self.decodeRC[self.rc] return FAILURE def didFail(self): return self.results() == FAILURE def fakeLogData(self, step, log, header='', stdout='', stderr=''): # note that this should not be used in the same test as useLog(Delayed) self.logs[log] = l = FakeLogFile(log, step) l.fakeData(header=header, stdout=stdout, stderr=stderr) def __repr__(self): return "FakeRemoteCommand("+repr(self.remote_command)+","+repr(self.args)+")" class FakeRemoteShellCommand(FakeRemoteCommand): def __init__(self, workdir, command, env=None, want_stdout=1, want_stderr=1, timeout=20*60, maxTime=None, logfiles={}, usePTY="slave-config", logEnviron=True, collectStdout=False, collectStderr=False, interruptSignal=None, initialStdin=None, decodeRC={0:SUCCESS}): args = dict(workdir=workdir, command=command, env=env or {}, want_stdout=want_stdout, want_stderr=want_stderr, initial_stdin=initialStdin, timeout=timeout, maxTime=maxTime, logfiles=logfiles, usePTY=usePTY, logEnviron=logEnviron) FakeRemoteCommand.__init__(self, "shell", args, collectStdout=collectStdout, collectStderr=collectStderr, decodeRC=decodeRC) class FakeLogFile(object): def __init__(self, name, step): self.name = name self.header = '' self.stdout = '' self.stderr = '' self.chunks = [] self.step = step def getName(self): return self.name def addHeader(self, text): self.header += text self.chunks.append((HEADER, text)) def addStdout(self, text): self.stdout += text self.chunks.append((STDOUT, text)) if self.name in self.step.logobservers: for obs in self.step.logobservers[self.name]: obs.outReceived(text) def addStderr(self, text): self.stderr += text self.chunks.append((STDERR, text)) if self.name in self.step.logobservers: for obs in self.step.logobservers[self.name]: obs.errReceived(text) def readlines(self): io = StringIO(self.stdout) return io.readlines() def getText(self): return ''.join([ c for str,c in self.chunks if str in (STDOUT, STDERR)]) def getTextWithHeaders(self): return ''.join([ c for str,c in self.chunks]) def getChunks(self, channels=[], onlyText=False): if onlyText: return [ data for (ch, data) in self.chunks if not channels or ch in channels ] else: return [ (ch, data) for (ch, data) in self.chunks if not channels or ch in channels ] def finish(self): pass def fakeData(self, header='', stdout='', stderr=''): if header: self.header += header self.chunks.append((HEADER, header)) if stdout: self.stdout += stdout self.chunks.append((STDOUT, stdout)) if stderr: self.stderr += stderr self.chunks.append((STDERR, stderr)) class ExpectRemoteRef(object): """ Define an expected RemoteReference in the args to an L{Expect} class """ def __init__(self, rrclass): self.rrclass = rrclass def __eq__(self, other): return isinstance(other, self.rrclass) class Expect(object): """ Define an expected L{RemoteCommand}, with the same arguments Extra behaviors of the remote command can be added to the instance, using class methods. Use L{Expect.log} to add a logfile, L{Expect.update} to add an arbitrary update, or add an integer to specify the return code (rc), or add a Failure instance to raise an exception. Additionally, use L{Expect.behavior}, passing a callable that will be invoked with the real command and can do what it likes: def custom_behavior(command): ... Expect('somecommand', { args='foo' }) + Expect.behavior(custom_behavior), ... Expect('somecommand', { args='foo' }) + Expect.log('stdio', stdout='foo!') + Expect.log('config.log', stdout='some info') + Expect.update('status', 'running') + 0, # (specifies the rc) ... """ def __init__(self, remote_command, args, incomparable_args=[]): """ Expect a command named C{remote_command}, with args C{args}. Any args in C{incomparable_args} are not cmopared, but must exist. """ self.remote_command = remote_command self.incomparable_args = incomparable_args self.args = args self.result = None self.behaviors = [] @classmethod def behavior(cls, callable): """ Add an arbitrary behavior that is expected of this command. C{callable} will be invoked with the real command as an argument, and can do what it wishes. It will be invoked with maybeDeferred, in case the operation is asynchronous. """ return ('callable', callable) @classmethod def log(self, name, **streams): return ('log', name, streams) @classmethod def update(self, name, value): return ('update', name, value) def __add__(self, other): # special-case adding an integer (return code) or failure (error) if isinstance(other, int): self.behaviors.append(('rc', other)) elif isinstance(other, failure.Failure): self.behaviors.append(('err', other)) else: self.behaviors.append(other) return self def runBehavior(self, behavior, args, command): """ Implement the given behavior. Returns a Deferred. """ if behavior == 'rc': command.rc = args[0] elif behavior == 'err': return defer.fail(args[0]) elif behavior == 'update': command.updates.setdefault(args[0], []).append(args[1]) elif behavior == 'log': name, streams = args if 'header' in streams: command.logs[name].addHeader(streams['header']) if 'stdout' in streams: command.logs[name].addStdout(streams['stdout']) if command.collectStdout: command.stdout += streams['stdout'] if 'stderr' in streams: command.logs[name].addStderr(streams['stderr']) if command.collectStderr: command.stderr += streams['stderr'] elif behavior == 'callable': return defer.maybeDeferred(lambda : args[0](command)) else: return defer.fail(failure.Failure( AssertionError('invalid behavior %s' % behavior))) return defer.succeed(None) @defer.inlineCallbacks def runBehaviors(self, command): """ Run all expected behaviors for this command """ for behavior in self.behaviors: yield self.runBehavior(behavior[0], behavior[1:], command) def __repr__(self): return "Expect("+repr(self.remote_command)+")" class ExpectShell(Expect): """ Define an expected L{RemoteShellCommand}, with the same arguments Any non-default arguments must be specified explicitly (e.g., usePTY). """ def __init__(self, workdir, command, env={}, want_stdout=1, want_stderr=1, initialStdin=None, timeout=20*60, maxTime=None, logfiles={}, usePTY="slave-config", logEnviron=True): args = dict(workdir=workdir, command=command, env=env, want_stdout=want_stdout, want_stderr=want_stderr, initial_stdin=initialStdin, timeout=timeout, maxTime=maxTime, logfiles=logfiles, usePTY=usePTY, logEnviron=logEnviron) Expect.__init__(self, "shell", args) def __repr__(self): return "ExpectShell("+repr(self.remote_command)+repr(self.args['command'])+")" buildbot-0.8.8/buildbot/test/fake/slave.py000066400000000000000000000013661222546025000205140ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members class FakeSlave(object): slave_system = 'posix' buildbot-0.8.8/buildbot/test/fake/state.py000066400000000000000000000020031222546025000205070ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members class State(object): """ A simple class you can use to keep track of state throughout a test. Just assign whatever you want to its attributes. Its constructor provides a shortcut to setting initial values for attributes """ def __init__(self, **kwargs): self.__dict__.update(kwargs) buildbot-0.8.8/buildbot/test/fake/web.py000066400000000000000000000045521222546025000201570ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from mock import Mock from twisted.internet import defer from twisted.web import server class FakeRequest(Mock): """ A fake Twisted Web Request object, including some pointers to the buildmaster and an addChange method on that master which will append its arguments to self.addedChanges. """ written = '' finished = False redirected_to = None failure = None def __init__(self, args={}): Mock.__init__(self) self.args = args self.site = Mock() self.site.buildbot_service = Mock() master = self.site.buildbot_service.master = Mock() self.addedChanges = [] def addChange(**kwargs): self.addedChanges.append(kwargs) return defer.succeed(Mock()) master.addChange = addChange self.deferred = defer.Deferred() def write(self, data): self.written = self.written + data def redirect(self, url): self.redirected_to = url def finish(self): self.finished = True self.deferred.callback(None) def processingFailed(self, f): self.deferred.errback(f) # work around http://code.google.com/p/mock/issues/detail?id=105 def _get_child_mock(self, **kw): return Mock(**kw) # cribed from twisted.web.test._util._render def test_render(self, resource): result = resource.render(self) if isinstance(result, str): self.write(result) self.finish() return self.deferred elif result is server.NOT_DONE_YET: return self.deferred else: raise ValueError("Unexpected return value: %r" % (result,)) buildbot-0.8.8/buildbot/test/regressions/000077500000000000000000000000001222546025000204575ustar00rootroot00000000000000buildbot-0.8.8/buildbot/test/regressions/__init__.py000066400000000000000000000000001222546025000225560ustar00rootroot00000000000000buildbot-0.8.8/buildbot/test/regressions/test_bad_change_properties_rows.py000066400000000000000000000051111222546025000274470ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.test.util import connector_component from buildbot.db import changes from buildbot.test.fake import fakedb class TestBadRows(connector_component.ConnectorComponentMixin, unittest.TestCase): # See bug #1952 for details. This checks that users who used a development # version between 0.8.3 and 0.8.4 get reasonable behavior even though some # rows in the change_properties database do not contain a proper [value, # source] tuple. def setUp(self): d = self.setUpConnectorComponent( table_names=['changes', 'change_properties', 'change_files']) def finish_setup(_): self.db.changes = changes.ChangesConnectorComponent(self.db) d.addCallback(finish_setup) return d def tearDown(self): return self.tearDownConnectorComponent() def test_bogus_row_no_source(self): d = self.insertTestData([ fakedb.ChangeProperty(changeid=13, property_name='devel', property_value='"no source"'), fakedb.Change(changeid=13), ]) def get13(_): return self.db.changes.getChange(13) d.addCallback(get13) def check13(c): self.assertEqual(c['properties'], dict(devel=('no source', 'Change'))) d.addCallback(check13) return d def test_bogus_row_jsoned_list(self): d = self.insertTestData([ fakedb.ChangeProperty(changeid=13, property_name='devel', property_value='[1, 2]'), fakedb.Change(changeid=13), ]) def get13(_): return self.db.changes.getChange(13) d.addCallback(get13) def check13(c): self.assertEqual(c['properties'], dict(devel=([1,2], 'Change'))) d.addCallback(check13) return d buildbot-0.8.8/buildbot/test/regressions/test_import_unicode_changes.py000066400000000000000000000100441222546025000265770ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.db.connector import DBConnector from buildbot.test.util import change_import from buildbot.test.fake import fakemaster class TestUnicodeChanges(change_import.ChangeImportMixin, unittest.TestCase): def setUp(self): d = self.setUpChangeImport() def make_dbc(_): master = fakemaster.make_master() master.config.db['db_url'] = self.db_url self.db = DBConnector(master, self.basedir) return self.db.setup(check_version=False) d.addCallback(make_dbc) # note the connector isn't started, as we're testing upgrades return d def tearDown(self): return self.tearDownChangeImport() # tests def testUnicodeChange(self): self.make_pickle( self.make_change( who=u"Frosty the \N{SNOWMAN}".encode("utf8"), files=["foo"], comments=u"Frosty the \N{SNOWMAN}".encode("utf8"), branch="b1", revision=12345)) d = self.db.model.upgrade() d.addCallback(lambda _ : self.db.changes.getChange(1)) def check(c): self.failIf(c is None) self.assertEquals(c['author'], u"Frosty the \N{SNOWMAN}") self.assertEquals(c['comments'], u"Frosty the \N{SNOWMAN}") d.addCallback(check) return d def testNonUnicodeChange(self): self.make_pickle( self.make_change( who="\xff\xff\x00", files=["foo"], comments="\xff\xff\x00", branch="b1", revision=12345)) d = self.db.model.upgrade() return self.assertFailure(d, UnicodeError) def testAsciiChange(self): self.make_pickle( self.make_change( who="Frosty the Snowman", files=["foo"], comments="Frosty the Snowman", branch="b1", revision=12345)) d = self.db.model.upgrade() d.addCallback(lambda _ : self.db.changes.getChange(1)) def check(c): self.failIf(c is None) self.assertEquals(c['author'], "Frosty the Snowman") self.assertEquals(c['comments'], "Frosty the Snowman") d.addCallback(check) return d def testUTF16Change(self): self.make_pickle( self.make_change( who=u"Frosty the \N{SNOWMAN}".encode("utf16"), files=[u"foo".encode('utf16')], comments=u"Frosty the \N{SNOWMAN}".encode("utf16"), branch="b1", revision=12345), # instead of running contrib/fix_changes_pickle_encoding.py, we # just call the changemanager's recode_changes directly - it's # the function at the heart of the script anyway. recode_fn=lambda cm : cm.recode_changes('utf16', quiet=True)) d = self.db.model.upgrade() d.addCallback(lambda _ : self.db.changes.getChange(1)) def check(c): self.failIf(c is None) self.assertEquals(c['author'], u"Frosty the \N{SNOWMAN}") self.assertEquals(c['comments'], u"Frosty the \N{SNOWMAN}") d.addCallback(check) return d buildbot-0.8.8/buildbot/test/regressions/test_oldpaths.py000066400000000000000000000143001222546025000237040ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest class OldImportPaths(unittest.TestCase): """ Test that old, deprecated import paths still work. """ def test_scheduler_Scheduler(self): from buildbot.scheduler import Scheduler assert Scheduler def test_schedulers_basic_Scheduler(self): # renamed to basic.SingleBranchScheduler from buildbot.schedulers.basic import Scheduler assert Scheduler def test_scheduler_AnyBranchScheduler(self): from buildbot.scheduler import AnyBranchScheduler assert AnyBranchScheduler def test_scheduler_basic_Dependent(self): from buildbot.schedulers.basic import Dependent assert Dependent def test_scheduler_Dependent(self): from buildbot.scheduler import Dependent assert Dependent def test_scheduler_Periodic(self): from buildbot.scheduler import Periodic assert Periodic def test_scheduler_Nightly(self): from buildbot.scheduler import Nightly assert Nightly def test_scheduler_Triggerable(self): from buildbot.scheduler import Triggerable assert Triggerable def test_scheduler_Try_Jobdir(self): from buildbot.scheduler import Try_Jobdir assert Try_Jobdir def test_scheduler_Try_Userpass(self): from buildbot.scheduler import Try_Userpass assert Try_Userpass def test_changes_changes_ChangeMaster(self): # this must exist to open old changes pickles from buildbot.changes.changes import ChangeMaster assert ChangeMaster def test_changes_changes_Change(self): # this must exist to open old changes pickles from buildbot.changes.changes import Change assert Change def test_status_html_Webstatus(self): from buildbot.status.html import WebStatus assert WebStatus def test_schedulers_filter_ChangeFilter(self): # this was the location of ChangeFilter until 0.8.4 from buildbot.schedulers.filter import ChangeFilter assert ChangeFilter def test_process_base_Build(self): from buildbot.process.base import Build assert Build def test_buildrequest_BuildRequest(self): from buildbot.buildrequest import BuildRequest assert BuildRequest def test_sourcestamp_SourceStamp(self): # this must exist, and the class must be defined at this package path, # in order for old build pickles to be loaded. from buildbot.sourcestamp import SourceStamp assert SourceStamp def test_process_subunitlogobserver_SubunitShellCommand(self): from buildbot.process.subunitlogobserver import SubunitShellCommand assert SubunitShellCommand def test_status_builder_results(self): # these symbols are now in buildbot.status.results, but lots of user # code references them here: from buildbot.status.builder import SUCCESS, WARNINGS, FAILURE, SKIPPED from buildbot.status.builder import EXCEPTION, RETRY, Results from buildbot.status.builder import worst_status # reference the symbols to avoid failure from pyflakes (SUCCESS, WARNINGS, FAILURE, SKIPPED,EXCEPTION, RETRY, Results, worst_status) def test_status_builder_BuildStepStatus(self): from buildbot.status.builder import BuildStepStatus assert BuildStepStatus def test_status_builder_BuildSetStatus(self): from buildbot.status.builder import BuildSetStatus assert BuildSetStatus def test_status_builder_TestResult(self): from buildbot.status.builder import TestResult assert TestResult def test_status_builder_LogFile(self): from buildbot.status.builder import LogFile assert LogFile def test_status_builder_HTMLLogFile(self): from buildbot.status.builder import HTMLLogFile assert HTMLLogFile def test_status_builder_SlaveStatus(self): from buildbot.status.builder import SlaveStatus assert SlaveStatus def test_status_builder_Status(self): from buildbot.status.builder import Status assert Status def test_status_builder_Event(self): from buildbot.status.builder import Event assert Event def test_status_builder_BuildStatus(self): from buildbot.status.builder import BuildStatus assert BuildStatus def test_steps_source_Source(self): from buildbot.steps.source import Source assert Source def test_steps_source_CVS(self): from buildbot.steps.source import CVS assert CVS def test_steps_source_SVN(self): from buildbot.steps.source import SVN assert SVN def test_steps_source_Git(self): from buildbot.steps.source import Git assert Git def test_steps_source_Darcs(self): from buildbot.steps.source import Darcs assert Darcs def test_steps_source_Repo(self): from buildbot.steps.source import Repo assert Repo def test_steps_source_Bzr(self): from buildbot.steps.source import Bzr assert Bzr def test_steps_source_Mercurial(self): from buildbot.steps.source import Mercurial assert Mercurial def test_steps_source_P4(self): from buildbot.steps.source import P4 assert P4 def test_steps_source_Monotone(self): from buildbot.steps.source import Monotone assert Monotone def test_steps_source_BK(self): from buildbot.steps.source import BK assert BK buildbot-0.8.8/buildbot/test/regressions/test_shell_command_properties.py000066400000000000000000000066261222546025000271630ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.steps.shell import ShellCommand, SetPropertyFromCommand from buildbot.process.properties import WithProperties, Properties from buildbot.process.factory import BuildFactory from buildbot.sourcestamp import SourceStamp from buildbot import config class FakeSlaveBuilder: slave = None class FakeBuildStatus: def __init__(self): self.names = [] def addStepWithName(self, name): self.names.append(name) return FakeStepStatus() def getProperties(self): return Properties() def setSourceStamps(self, ss_list): self.ss_list = ss_list def setReason(self, reason): self.reason = reason def setBlamelist(self, bl): self.bl = bl def setProgress(self, p): self.progress = p class FakeStepStatus: txt = None def setText(self, txt): self.txt = txt def setProgress(self, sp): pass class FakeBuildRequest: def __init__(self, reason, sources, buildername): self.reason = reason self.sources = sources self.buildername = buildername self.changes = [] self.properties = Properties() def mergeSourceStampsWith(self, others): return [source for source in self.sources.itervalues()] def mergeReasons(self, others): return self.reason class TestShellCommandProperties(unittest.TestCase): def testCommand(self): f = BuildFactory() f.addStep(SetPropertyFromCommand(command=["echo", "value"], property="propname")) f.addStep(ShellCommand(command=["echo", WithProperties("%(propname)s")])) ss = SourceStamp() req = FakeBuildRequest("Testing", {ss.repository:ss}, None) b = f.newBuild([req]) b.build_status = FakeBuildStatus() b.slavebuilder = FakeSlaveBuilder() # This shouldn't raise an exception b.setupBuild(None) class TestSetProperty(unittest.TestCase): def testGoodStep(self): f = BuildFactory() f.addStep(SetPropertyFromCommand(command=["echo", "value"], property="propname")) ss = SourceStamp() req = FakeBuildRequest("Testing", {ss.repository:ss}, None) b = f.newBuild([req]) b.build_status = FakeBuildStatus() b.slavebuilder = FakeSlaveBuilder() # This shouldn't raise an exception b.setupBuild(None) def testErrorBothSet(self): self.assertRaises(config.ConfigErrors, SetPropertyFromCommand, command=["echo", "value"], property="propname", extract_fn=lambda x:{"propname": "hello"}) def testErrorNoneSet(self): self.assertRaises(config.ConfigErrors, SetPropertyFromCommand, command=["echo", "value"]) buildbot-0.8.8/buildbot/test/regressions/test_sourcestamp_revision.py000066400000000000000000000020751222546025000263570ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.sourcestamp import SourceStamp from buildbot.changes.changes import Change class TestSourceStampRevision(unittest.TestCase): def testNoRevision(self): c = Change(who="catlee", files=["foo"], comments="", branch="b1", revision=None) ss = SourceStamp(changes=[c]) self.assertEquals(ss.revision, None) buildbot-0.8.8/buildbot/test/regressions/test_steps_shell_WarningCountingShellCommand.py000066400000000000000000000035531222546025000321060ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest import re from buildbot.steps.shell import WarningCountingShellCommand class TestWarningCountingShellCommand(unittest.TestCase): # Makes sure that it is possible to supress warnings even if the # warning extractor does not provie line information def testSuppressingLinelessWarningsPossible(self): # Use a warningExtractor that does not provide line # information w = WarningCountingShellCommand( warningExtractor=WarningCountingShellCommand.warnExtractWholeLine) # Add suppression manually instead of using suppressionFile fileRe = None warnRe = ".*SUPPRESS.*" start = None end = None suppression = (fileRe, warnRe, start, end) w.addSuppression([suppression]) # Now call maybeAddWarning warnings = [] line = "this warning should be SUPPRESSed" match = re.match(".*warning.*", line) w.maybeAddWarning(warnings, line, match) # Finally make the suppressed warning was *not* added to the # list of warnings expectedWarnings = 0 self.assertEquals(len(warnings), expectedWarnings) buildbot-0.8.8/buildbot/test/regressions/test_unpickling.py000066400000000000000000000056271222546025000242450ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import base64 import cPickle from twisted.trial import unittest from twisted.persisted import styles from buildbot.status.buildstep import BuildStepStatus from buildbot.status.build import BuildStatus from buildbot.status.builder import BuilderStatus class StatusPickles(unittest.TestCase): # This pickle was created with Buildbot tag v0.8.1: # >>> bs = BuildStatus(BuilderStatus('test'), 1) # >>> bss = BuildStepStatus(bs) # >>> pkl = pickle.dumps(dict(buildstatus=bs, buildstepstatus=bss)) pickle_b64 = """ KGRwMQpTJ2J1aWxkc3RlcHN0YXR1cycKcDIKKGlidWlsZGJvdC5zdGF0dXMuYnVpbGRlcgp CdWlsZFN0ZXBTdGF0dXMKcDMKKGRwNApTJ2xvZ3MnCnA1CihscDYKc1MndXJscycKcDcKKG RwOApzUydzdGF0aXN0aWNzJwpwOQooZHAxMApzUydidWlsZGJvdC5zdGF0dXMuYnVpbGRlc i5CdWlsZFN0ZXBTdGF0dXMucGVyc2lzdGVuY2VWZXJzaW9uJwpwMTEKSTIKc2JzUydidWls ZHN0YXR1cycKcDEyCihpYnVpbGRib3Quc3RhdHVzLmJ1aWxkZXIKQnVpbGRTdGF0dXMKcDE zCihkcDE0ClMnbnVtYmVyJwpwMTUKSTEKc1MnYnVpbGRib3Quc3RhdHVzLmJ1aWxkZXIuQn VpbGRTdGF0dXMucGVyc2lzdGVuY2VWZXJzaW9uJwpwMTYKSTMKc1MnZmluaXNoZWQnCnAxN wpJMDEKc1Mnc3RlcHMnCnAxOAoobHAxOQpzUydwcm9wZXJ0aWVzJwpwMjAKKGlidWlsZGJv dC5wcm9jZXNzLnByb3BlcnRpZXMKUHJvcGVydGllcwpwMjEKKGRwMjIKZzIwCihkcDIzCnN ic1MndGVzdFJlc3VsdHMnCnAyNAooZHAyNQpzYnMu""" pickle_data = base64.b64decode(pickle_b64) # In 0.8.1, the following persistence versions were in effect: # # BuildStepStatus: 2 # BuildStatus: 3 # BuilderStatus: 1 # # the regression that can occur here is that if the classes are renamed, # then older upgradeToVersionX may be run in cases where it should not; # this error can be "silent" since the upgrade will not fail. def test_upgrade(self): self.patch(BuildStepStatus, 'upgradeToVersion1', lambda _ : self.fail("BuildStepStatus.upgradeToVersion1 called")) self.patch(BuildStatus, 'upgradeToVersion1', lambda _ : self.fail("BuildStatus.upgradeToVersion1 called")) self.patch(BuilderStatus, 'upgradeToVersion1', lambda _ : self.fail("BuilderStatus.upgradeToVersion1 called")) pkl_result = cPickle.loads(self.pickle_data) styles.doUpgrade() del pkl_result buildbot-0.8.8/buildbot/test/test_extra_coverage.py000066400000000000000000000052531222546025000225300ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # this file imports a number of source files that are not # included in the coverage because none of the tests import # them; this results in a more accurate total coverage percent. modules = [] # for the benefit of pyflakes from buildbot import buildslave modules.extend([buildslave]) from buildbot.changes import p4poller, svnpoller modules.extend([p4poller, svnpoller]) from buildbot.clients import base, sendchange, tryclient modules.extend([base, sendchange, tryclient]) from buildbot.process import mtrlogobserver, subunitlogobserver modules.extend([mtrlogobserver, subunitlogobserver]) from buildbot.scripts import checkconfig, logwatcher, reconfig, runner modules.extend([checkconfig, logwatcher, reconfig, runner]) from buildbot.status import client, html, status_gerrit, status_push modules.extend([client, html, status_gerrit, status_push]) from buildbot.status import tinderbox, words modules.extend([tinderbox, words]) from buildbot.status.web import baseweb, build, builder, buildstatus, changes modules.extend([baseweb, build, builder, buildstatus, changes]) from buildbot.status.web import console, feeds, grid, logs, olpb, root, slaves modules.extend([console, feeds, grid, logs, olpb, root, slaves]) from buildbot.status.web import status_json, step, tests, waterfall modules.extend([status_json, step, tests, waterfall]) from buildbot.steps import master, maxq, python, python_twisted, subunit modules.extend([master, maxq, python, python_twisted, subunit]) from buildbot.steps import trigger, vstudio modules.extend([trigger, vstudio]) from buildbot.steps.package.rpm import rpmbuild, rpmlint, rpmspec modules.extend([rpmbuild, rpmlint, rpmspec]) from buildbot.util import eventual modules.extend([eventual]) # require gobject #import buildbot.clients.gtkPanes #import buildbot.clients.debug # requires mercurial #import buildbot.changes.hgbuildbot # requires libboto #import buildbot.ec2buildslave # requires libvirt #import buildbot.libvirtbuildslave # requires pycrypto #import buildbot.manhole buildbot-0.8.8/buildbot/test/unit/000077500000000000000000000000001222546025000170735ustar00rootroot00000000000000buildbot-0.8.8/buildbot/test/unit/__init__.py000066400000000000000000000000001222546025000211720ustar00rootroot00000000000000buildbot-0.8.8/buildbot/test/unit/test_buildslave_base.py000066400000000000000000000220011222546025000236230ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from twisted.internet import defer from buildbot import config, locks from buildbot.buildslave import base from buildbot.test.fake import fakemaster, pbmanager from buildbot.test.fake.botmaster import FakeBotMaster class TestAbstractBuildSlave(unittest.TestCase): class ConcreteBuildSlave(base.AbstractBuildSlave): pass def test_constructor_minimal(self): bs = self.ConcreteBuildSlave('bot', 'pass') self.assertEqual(bs.slavename, 'bot') self.assertEqual(bs.password, 'pass') self.assertEqual(bs.max_builds, None) self.assertEqual(bs.notify_on_missing, []) self.assertEqual(bs.missing_timeout, 3600) self.assertEqual(bs.properties.getProperty('slavename'), 'bot') self.assertEqual(bs.access, []) self.assertEqual(bs.keepalive_interval, 3600) def test_constructor_full(self): lock1, lock2 = mock.Mock(name='lock1'), mock.Mock(name='lock2') bs = self.ConcreteBuildSlave('bot', 'pass', max_builds=2, notify_on_missing=['me@me.com'], missing_timeout=120, properties={'a':'b'}, locks=[lock1, lock2], keepalive_interval=60) self.assertEqual(bs.max_builds, 2) self.assertEqual(bs.notify_on_missing, ['me@me.com']) self.assertEqual(bs.missing_timeout, 120) self.assertEqual(bs.properties.getProperty('a'), 'b') self.assertEqual(bs.access, [lock1, lock2]) self.assertEqual(bs.keepalive_interval, 60) def test_constructor_notify_on_missing_not_list(self): bs = self.ConcreteBuildSlave('bot', 'pass', notify_on_missing='foo@foo.com') # turned into a list: self.assertEqual(bs.notify_on_missing, ['foo@foo.com']) def test_constructor_notify_on_missing_not_string(self): self.assertRaises(config.ConfigErrors, lambda : self.ConcreteBuildSlave('bot', 'pass', notify_on_missing=['a@b.com', 13])) @defer.inlineCallbacks def do_test_reconfigService(self, old, old_port, new, new_port): master = self.master = fakemaster.make_master() old.master = master if old_port: self.old_registration = old.registration = \ pbmanager.FakeRegistration(master.pbmanager, old_port, old.slavename) old.registered_port = old_port old.missing_timer = mock.Mock(name='missing_timer') old.startService() new_config = mock.Mock() new_config.slavePortnum = new_port new_config.slaves = [ new ] yield old.reconfigService(new_config) @defer.inlineCallbacks def test_reconfigService_attrs(self): old = self.ConcreteBuildSlave('bot', 'pass', max_builds=2, notify_on_missing=['me@me.com'], missing_timeout=120, properties={'a':'b'}, keepalive_interval=60) new = self.ConcreteBuildSlave('bot', 'pass', max_builds=3, notify_on_missing=['her@me.com'], missing_timeout=121, properties={'a':'c'}, keepalive_interval=61) old.updateSlave = mock.Mock(side_effect=lambda : defer.succeed(None)) yield self.do_test_reconfigService(old, 'tcp:1234', new, 'tcp:1234') self.assertEqual(old.max_builds, 3) self.assertEqual(old.notify_on_missing, ['her@me.com']) self.assertEqual(old.missing_timeout, 121) self.assertEqual(old.properties.getProperty('a'), 'c') self.assertEqual(old.keepalive_interval, 61) self.assertEqual(self.master.pbmanager._registrations, []) self.assertTrue(old.updateSlave.called) @defer.inlineCallbacks def test_reconfigService_has_properties(self): old = self.ConcreteBuildSlave('bot', 'pass') yield self.do_test_reconfigService(old, 'tcp:1234', old, 'tcp:1234') self.assertTrue(old.properties.getProperty('slavename'), 'bot') @defer.inlineCallbacks def test_reconfigService_initial_registration(self): old = self.ConcreteBuildSlave('bot', 'pass') yield self.do_test_reconfigService(old, None, old, 'tcp:1234') self.assertEqual(self.master.pbmanager._registrations, [('tcp:1234', 'bot', 'pass')]) @defer.inlineCallbacks def test_reconfigService_reregister_password(self): old = self.ConcreteBuildSlave('bot', 'pass') new = self.ConcreteBuildSlave('bot', 'newpass') yield self.do_test_reconfigService(old, 'tcp:1234', new, 'tcp:1234') self.assertEqual(old.password, 'newpass') self.assertEqual(self.master.pbmanager._unregistrations, [('tcp:1234', 'bot')]) self.assertEqual(self.master.pbmanager._registrations, [('tcp:1234', 'bot', 'newpass')]) @defer.inlineCallbacks def test_reconfigService_reregister_port(self): old = self.ConcreteBuildSlave('bot', 'pass') new = self.ConcreteBuildSlave('bot', 'pass') yield self.do_test_reconfigService(old, 'tcp:1234', new, 'tcp:5678') self.assertEqual(self.master.pbmanager._unregistrations, [('tcp:1234', 'bot')]) self.assertEqual(self.master.pbmanager._registrations, [('tcp:5678', 'bot', 'pass')]) @defer.inlineCallbacks def test_stopService(self): master = self.master = fakemaster.make_master() slave = self.ConcreteBuildSlave('bot', 'pass') slave.master = master slave.startService() config = mock.Mock() config.slavePortnum = "tcp:1234" config.slaves = [ slave ] yield slave.reconfigService(config) yield slave.stopService() self.assertEqual(self.master.pbmanager._unregistrations, [('tcp:1234', 'bot')]) self.assertEqual(self.master.pbmanager._registrations, [('tcp:1234', 'bot', 'pass')]) # FIXME: Test that reconfig properly deals with # 1) locks # 2) telling slave about builder # 3) missing timer # in both the initial config and a reconfiguration. def test_startMissingTimer_no_parent(self): bs = self.ConcreteBuildSlave('bot', 'pass', notify_on_missing=['abc'], missing_timeout=10) bs.startMissingTimer() self.assertEqual(bs.missing_timer, None) def test_startMissingTimer_no_timeout(self): bs = self.ConcreteBuildSlave('bot', 'pass', notify_on_missing=['abc'], missing_timeout=0) bs.parent = mock.Mock() bs.startMissingTimer() self.assertEqual(bs.missing_timer, None) def test_startMissingTimer_no_notify(self): bs = self.ConcreteBuildSlave('bot', 'pass', missing_timeout=3600) bs.parent = mock.Mock() bs.startMissingTimer() self.assertEqual(bs.missing_timer, None) def test_missing_timer(self): bs = self.ConcreteBuildSlave('bot', 'pass', notify_on_missing=['abc'], missing_timeout=100) bs.parent = mock.Mock() bs.startMissingTimer() self.assertNotEqual(bs.missing_timer, None) bs.stopMissingTimer() self.assertEqual(bs.missing_timer, None) def test_setServiceParent_started(self): master = self.master = fakemaster.make_master() botmaster = FakeBotMaster(master) botmaster.startService() bs = self.ConcreteBuildSlave('bot', 'pass') bs.setServiceParent(botmaster) self.assertEqual(bs.botmaster, botmaster) self.assertEqual(bs.master, master) def test_setServiceParent_masterLocks(self): """ http://trac.buildbot.net/ticket/2278 """ master = self.master = fakemaster.make_master() botmaster = FakeBotMaster(master) botmaster.startService() lock = locks.MasterLock('masterlock') bs = self.ConcreteBuildSlave('bot', 'pass', locks = [lock.access("counting")]) bs.setServiceParent(botmaster) def test_setServiceParent_slaveLocks(self): """ http://trac.buildbot.net/ticket/2278 """ master = self.master = fakemaster.make_master() botmaster = FakeBotMaster(master) botmaster.startService() lock = locks.SlaveLock('lock') bs = self.ConcreteBuildSlave('bot', 'pass', locks = [lock.access("counting")]) bs.setServiceParent(botmaster) buildbot-0.8.8/buildbot/test/unit/test_buildslave_libvirt.py000066400000000000000000000233451222546025000244000ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from twisted.internet import defer, reactor, utils from twisted.python import failure from buildbot import config from buildbot.test.fake import libvirt from buildbot.test.util import compat from buildbot.buildslave import libvirt as libvirtbuildslave class TestLibVirtSlave(unittest.TestCase): class ConcreteBuildSlave(libvirtbuildslave.LibVirtSlave): pass def setUp(self): self.patch(libvirtbuildslave, "libvirt", libvirt) self.conn = libvirtbuildslave.Connection("test://") self.lvconn = self.conn.connection def test_constructor_nolibvirt(self): self.patch(libvirtbuildslave, "libvirt", None) self.assertRaises(config.ConfigErrors, self.ConcreteBuildSlave, 'bot', 'pass', None, 'path', 'path') def test_constructor_minimal(self): bs = self.ConcreteBuildSlave('bot', 'pass', self.conn, 'path', 'otherpath') yield bs._find_existing_deferred self.assertEqual(bs.slavename, 'bot') self.assertEqual(bs.password, 'pass') self.assertEqual(bs.connection, self.conn) self.assertEqual(bs.image, 'path') self.assertEqual(bs.base_image, 'otherpath') self.assertEqual(bs.keepalive_interval, 3600) @defer.inlineCallbacks def test_find_existing(self): d = self.lvconn.fake_add("bot") bs = self.ConcreteBuildSlave('bot', 'pass', self.conn, 'p', 'o') yield bs._find_existing_deferred self.assertEqual(bs.domain.domain, d) self.assertEqual(bs.substantiated, True) @defer.inlineCallbacks def test_prepare_base_image_none(self): self.patch(utils, "getProcessValue", mock.Mock()) utils.getProcessValue.side_effect = lambda x,y: defer.succeed(0) bs = self.ConcreteBuildSlave('bot', 'pass', self.conn, 'p', None) yield bs._find_existing_deferred yield bs._prepare_base_image() self.assertEqual(utils.getProcessValue.call_count, 0) @defer.inlineCallbacks def test_prepare_base_image_cheap(self): self.patch(utils, "getProcessValue", mock.Mock()) utils.getProcessValue.side_effect = lambda x,y: defer.succeed(0) bs = self.ConcreteBuildSlave('bot', 'pass', self.conn, 'p', 'o') yield bs._find_existing_deferred yield bs._prepare_base_image() utils.getProcessValue.assert_called_with( "qemu-img", ["create", "-b", "o", "-f", "qcow2", "p"]) @defer.inlineCallbacks def test_prepare_base_image_full(self): pass self.patch(utils, "getProcessValue", mock.Mock()) utils.getProcessValue.side_effect = lambda x,y: defer.succeed(0) bs = self.ConcreteBuildSlave('bot', 'pass', self.conn, 'p', 'o') yield bs._find_existing_deferred bs.cheap_copy = False yield bs._prepare_base_image() utils.getProcessValue.assert_called_with( "cp", ["o", "p"]) @defer.inlineCallbacks def test_start_instance(self): bs = self.ConcreteBuildSlave('b', 'p', self.conn, 'p', 'o', xml='') prep = mock.Mock() prep.side_effect = lambda: defer.succeed(0) self.patch(bs, "_prepare_base_image", prep) yield bs._find_existing_deferred started = yield bs.start_instance(mock.Mock()) self.assertEqual(started, True) @compat.usesFlushLoggedErrors @defer.inlineCallbacks def test_start_instance_create_fails(self): bs = self.ConcreteBuildSlave('b', 'p', self.conn, 'p', 'o', xml='') prep = mock.Mock() prep.side_effect = lambda: defer.succeed(0) self.patch(bs, "_prepare_base_image", prep) create = mock.Mock() create.side_effect = lambda self : defer.fail( failure.Failure(RuntimeError('oh noes'))) self.patch(libvirtbuildslave.Connection, 'create', create) yield bs._find_existing_deferred started = yield bs.start_instance(mock.Mock()) self.assertEqual(bs.domain, None) self.assertEqual(started, False) self.assertEqual(len(self.flushLoggedErrors(RuntimeError)), 1) @defer.inlineCallbacks def setup_canStartBuild(self): bs = self.ConcreteBuildSlave('b', 'p', self.conn, 'p', 'o') yield bs._find_existing_deferred bs.updateLocks() defer.returnValue(bs) @defer.inlineCallbacks def test_canStartBuild(self): bs = yield self.setup_canStartBuild() self.assertEqual(bs.canStartBuild(), True) @defer.inlineCallbacks def test_canStartBuild_notready(self): """ If a LibVirtSlave hasnt finished scanning for existing VMs then we shouldn't start builds on it as it might create a 2nd VM when we want to reuse the existing one. """ bs = yield self.setup_canStartBuild() bs.ready = False self.assertEqual(bs.canStartBuild(), False) @defer.inlineCallbacks def test_canStartBuild_domain_and_not_connected(self): """ If we've found that the VM this slave would instance already exists but hasnt connected then we shouldn't start builds or we'll end up with a dupe. """ bs = yield self.setup_canStartBuild() bs.domain = mock.Mock() self.assertEqual(bs.canStartBuild(), False) @defer.inlineCallbacks def test_canStartBuild_domain_and_connected(self): """ If we've found an existing VM and it is connected then we should start builds """ bs = yield self.setup_canStartBuild() bs.domain = mock.Mock() isconnected = mock.Mock() isconnected.return_value = True self.patch(bs, "isConnected", isconnected) self.assertEqual(bs.canStartBuild(), True) class TestWorkQueue(unittest.TestCase): def setUp(self): self.queue = libvirtbuildslave.WorkQueue() def delayed_success(self): def work(): d = defer.Deferred() reactor.callLater(0, d.callback, True) return d return work def delayed_errback(self): def work(): d = defer.Deferred() reactor.callLater(0, d.errback, failure.Failure(RuntimeError("Test failure"))) return d return work def expect_errback(self, d): def shouldnt_get_called(f): self.failUnlessEqual(True, False) d.addCallback(shouldnt_get_called) def errback(f): #log.msg("errback called?") pass d.addErrback(errback) return d def test_handle_exceptions(self): def work(): raise ValueError return self.expect_errback(self.queue.execute(work)) def test_handle_immediate_errback(self): def work(): return defer.fail(RuntimeError("Sad times")) return self.expect_errback(self.queue.execute(work)) def test_handle_delayed_errback(self): work = self.delayed_errback() return self.expect_errback(self.queue.execute(work)) def test_handle_immediate_success(self): def work(): return defer.succeed(True) return self.queue.execute(work) def test_handle_delayed_success(self): work = self.delayed_success() return self.queue.execute(work) def test_single_pow_fires(self): return self.queue.execute(self.delayed_success()) def test_single_pow_errors_gracefully(self): d = self.queue.execute(self.delayed_errback()) return self.expect_errback(d) def test_fail_doesnt_break_further_work(self): self.expect_errback(self.queue.execute(self.delayed_errback())) return self.queue.execute(self.delayed_success()) def test_second_pow_fires(self): self.queue.execute(self.delayed_success()) return self.queue.execute(self.delayed_success()) def test_work(self): # We want these deferreds to fire in order flags = {1: False, 2: False, 3: False } # When first deferred fires, flags[2] and flags[3] should still be false # flags[1] shouldnt already be set, either d1 = self.queue.execute(self.delayed_success()) def cb1(res): self.failUnlessEqual(flags[1], False) flags[1] = True self.failUnlessEqual(flags[2], False) self.failUnlessEqual(flags[3], False) d1.addCallback(cb1) # When second deferred fires, only flags[3] should be set # flags[2] should definitely be False d2 = self.queue.execute(self.delayed_success()) def cb2(res): assert flags[2] == False flags[2] = True assert flags[1] == True assert flags[3] == False d2.addCallback(cb2) # When third deferred fires, only flags[3] should be unset d3 = self.queue.execute(self.delayed_success()) def cb3(res): assert flags[3] == False flags[3] = True assert flags[1] == True assert flags[2] == True d3.addCallback(cb3) return defer.DeferredList([d1, d2, d3], fireOnOneErrback=True) buildbot-0.8.8/buildbot/test/unit/test_buildslave_openstack.py000066400000000000000000000121531222546025000247070ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Portions Copyright Buildbot Team Members # Portions Copyright 2013 Cray Inc. import mock from twisted.trial import unittest from buildbot import config, interfaces from buildbot.buildslave import openstack import buildbot.test.fake.openstack as novaclient class TestOpenStackBuildSlave(unittest.TestCase): def setUp(self): self.patch(openstack, "nce", novaclient) self.patch(openstack, "client", novaclient) def test_constructor_nonova(self): self.patch(openstack, "nce", None) self.patch(openstack, "client", None) self.assertRaises(config.ConfigErrors, openstack.OpenStackLatentBuildSlave, 'bot', 'pass', flavor=1, image='image', os_username='user', os_password='pass', os_tenant_name='tenant', os_auth_url='auth') def test_constructor_minimal(self): bs = openstack.OpenStackLatentBuildSlave('bot', 'pass', flavor=1, image='image', os_username='user', os_password='pass', os_tenant_name='tenant', os_auth_url='auth') self.assertEqual(bs.slavename, 'bot') self.assertEqual(bs.password, 'pass') self.assertEqual(bs.flavor, 1) self.assertEqual(bs.image, 'image') self.assertEqual(bs.os_username, 'user') self.assertEqual(bs.os_password, 'pass') self.assertEqual(bs.os_tenant_name, 'tenant') self.assertEqual(bs.os_auth_url, 'auth') def test_getImage_string(self): bs = openstack.OpenStackLatentBuildSlave('bot', 'pass', flavor=1, image='image-uuid', os_username='user', os_password='pass', os_tenant_name='tenant', os_auth_url='auth') self.assertEqual('image-uuid', bs._getImage(None)) def test_getImage_callable(self): def image_callable(images): return images[0] bs = openstack.OpenStackLatentBuildSlave('bot', 'pass', flavor=1, image=image_callable, os_username='user', os_password='pass', os_tenant_name='tenant', os_auth_url='auth') os_client = novaclient.Client('user', 'pass', 'tenant', 'auth') os_client.images.images = ['uuid1', 'uuid2', 'uuid2'] self.assertEqual('uuid1', bs._getImage(os_client)) def test_start_instance_already_exists(self): bs = openstack.OpenStackLatentBuildSlave('bot', 'pass', flavor=1, image='image-uuid', os_username='user', os_password='pass', os_tenant_name='tenant', os_auth_url='auth') bs.instance = mock.Mock() self.assertRaises(ValueError, bs.start_instance, None) def test_start_instance_fail_to_find(self): bs = openstack.OpenStackLatentBuildSlave('bot', 'pass', flavor=1, image='image-uuid', os_username='user', os_password='pass', os_tenant_name='tenant', os_auth_url='auth') bs._poll_resolution = 0 self.patch(novaclient.Servers, 'fail_to_get', True) self.assertRaises(interfaces.LatentBuildSlaveFailedToSubstantiate, bs._start_instance) def test_start_instance_fail_to_start(self): bs = openstack.OpenStackLatentBuildSlave('bot', 'pass', flavor=1, image='image-uuid', os_username='user', os_password='pass', os_tenant_name='tenant', os_auth_url='auth') bs._poll_resolution = 0 self.patch(novaclient.Servers, 'fail_to_start', True) self.assertRaises(interfaces.LatentBuildSlaveFailedToSubstantiate, bs._start_instance) def test_start_instance_success(self): bs = openstack.OpenStackLatentBuildSlave('bot', 'pass', flavor=1, image='image-uuid', os_username='user', os_password='pass', os_tenant_name='tenant', os_auth_url='auth') bs._poll_resolution = 0 uuid, image_uuid, time_waiting = bs._start_instance() self.assertTrue(uuid) self.assertEqual(image_uuid, 'image-uuid') self.assertTrue(time_waiting) def test_start_instance_check_meta(self): meta_arg = {'some_key': 'some-value'} bs = openstack.OpenStackLatentBuildSlave('bot', 'pass', flavor=1, image='image-uuid', os_username='user', os_password='pass', os_tenant_name='tenant', os_auth_url='auth', meta=meta_arg) bs._poll_resolution = 0 uuid, image_uuid, time_waiting = bs._start_instance() self.assertIn('meta', bs.instance.boot_kwargs) self.assertIdentical(bs.instance.boot_kwargs['meta'], meta_arg) buildbot-0.8.8/buildbot/test/unit/test_changes_base.py000066400000000000000000000054711222546025000231150ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.internet import defer, reactor, task from buildbot.test.util import changesource, compat from buildbot.changes import base class TestPollingChangeSource(changesource.ChangeSourceMixin, unittest.TestCase): class Subclass(base.PollingChangeSource): pass def setUp(self): # patch in a Clock so we can manipulate the reactor's time self.clock = task.Clock() self.patch(reactor, 'callLater', self.clock.callLater) self.patch(reactor, 'seconds', self.clock.seconds) d = self.setUpChangeSource() def create_changesource(_): self.attachChangeSource(self.Subclass()) d.addCallback(create_changesource) return d def tearDown(self): return self.tearDownChangeSource() def runClockFor(self, _, secs): self.clock.pump([1.0] * secs) def test_loop_loops(self): # track when poll() gets called loops = [] self.changesource.poll = \ lambda : loops.append(self.clock.seconds()) self.changesource.pollInterval = 5 self.startChangeSource() d = defer.Deferred() d.addCallback(self.runClockFor, 12) def check(_): # note that it does *not* poll at time 0 self.assertEqual(loops, [5.0, 10.0]) d.addCallback(check) reactor.callWhenRunning(d.callback, None) return d @compat.usesFlushLoggedErrors def test_loop_exception(self): # track when poll() gets called loops = [] def poll(): loops.append(self.clock.seconds()) raise RuntimeError("oh noes") self.changesource.poll = poll self.changesource.pollInterval = 5 self.startChangeSource() d = defer.Deferred() d.addCallback(self.runClockFor, 12) def check(_): # note that it keeps looping after error self.assertEqual(loops, [5.0, 10.0]) self.assertEqual(len(self.flushLoggedErrors(RuntimeError)), 2) d.addCallback(check) reactor.callWhenRunning(d.callback, None) return d buildbot-0.8.8/buildbot/test/unit/test_changes_bonsaipoller.py000066400000000000000000000234121222546025000246670ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from copy import deepcopy import re from twisted.trial import unittest from twisted.internet import defer from twisted.web import client from buildbot.test.util import changesource from buildbot.util import epoch2datetime from buildbot.changes.bonsaipoller import FileNode, CiNode, BonsaiResult, \ BonsaiParser, BonsaiPoller, InvalidResultError, EmptyResult log1 = "Add Bug 338541a" who1 = "sar@gmail.com" date1 = 1161908700 log2 = "bug 357427 add static ctor/dtor methods" who2 = "aarrg@ooacm.org" date2 = 1161910620 log3 = "Testing log #3 lbah blah" who3 = "huoents@hueont.net" date3 = 1089822728 rev1 = "1.8" file1 = "mozilla/testing/mochitest/tests/index.html" rev2 = "1.1" file2 = "mozilla/testing/mochitest/tests/test_bug338541.xhtml" rev3 = "1.1812" file3 = "mozilla/xpcom/threads/nsAutoLock.cpp" rev4 = "1.3" file4 = "mozilla/xpcom/threads/nsAutoLock.h" rev5 = "2.4" file5 = "mozilla/xpcom/threads/test.cpp" nodes = [] files = [] files.append(FileNode(rev1,file1)) nodes.append(CiNode(log1, who1, date1, files)) files = [] files.append(FileNode(rev2, file2)) files.append(FileNode(rev3, file3)) nodes.append(CiNode(log2, who2, date2, files)) nodes.append(CiNode(log3, who3, date3, [])) goodParsedResult = BonsaiResult(nodes) goodUnparsedResult = """\ %s %s %s %s %s %s """ % (who1, date1, log1, rev1, file1, who2, date2, log2, rev2, file2, rev3, file3, who3, date3, log3) badUnparsedResult = deepcopy(goodUnparsedResult) badUnparsedResult = badUnparsedResult.replace("", "") invalidDateResult = deepcopy(goodUnparsedResult) invalidDateResult = invalidDateResult.replace(str(date1), "foobar") missingFilenameResult = deepcopy(goodUnparsedResult) missingFilenameResult = missingFilenameResult.replace(file2, "") duplicateLogResult = deepcopy(goodUnparsedResult) duplicateLogResult = re.sub(""+log1+"", "blahblah", duplicateLogResult) duplicateFilesResult = deepcopy(goodUnparsedResult) duplicateFilesResult = re.sub("\s*", "", duplicateFilesResult) missingCiResult = deepcopy(goodUnparsedResult) r = re.compile("", re.DOTALL | re.MULTILINE) missingCiResult = re.sub(r, "", missingCiResult) badResultMsgs = { 'badUnparsedResult': "BonsaiParser did not raise an exception when given a bad query", 'invalidDateResult': "BonsaiParser did not raise an exception when given an invalid date", 'missingRevisionResult': "BonsaiParser did not raise an exception when a revision was missing", 'missingFilenameResult': "BonsaiParser did not raise an exception when a filename was missing", 'duplicateLogResult': "BonsaiParser did not raise an exception when there was two tags", 'duplicateFilesResult': "BonsaiParser did not raise an exception when there was two tags", 'missingCiResult': "BonsaiParser did not raise an exception when there was no tags" } noCheckinMsgResult = """\ first/file.ext second/file.ext third/file.ext """ noCheckinMsgRef = [dict(filename="first/file.ext", revision="1.1"), dict(filename="second/file.ext", revision="1.2"), dict(filename="third/file.ext", revision="1.3")] class TestBonsaiParser(unittest.TestCase): def testFullyFormedResult(self): br = BonsaiParser(goodUnparsedResult) result = br.getData() # make sure the result is a BonsaiResult self.failUnless(isinstance(result, BonsaiResult)) # test for successful parsing self.failUnlessEqual(goodParsedResult, result, "BonsaiParser did not return the expected BonsaiResult") def testBadUnparsedResult(self): try: BonsaiParser(badUnparsedResult) self.fail(badResultMsgs["badUnparsedResult"]) except InvalidResultError: pass def testInvalidDateResult(self): try: BonsaiParser(invalidDateResult) self.fail(badResultMsgs["invalidDateResult"]) except InvalidResultError: pass def testMissingFilenameResult(self): try: BonsaiParser(missingFilenameResult) self.fail(badResultMsgs["missingFilenameResult"]) except InvalidResultError: pass def testDuplicateLogResult(self): try: BonsaiParser(duplicateLogResult) self.fail(badResultMsgs["duplicateLogResult"]) except InvalidResultError: pass def testDuplicateFilesResult(self): try: BonsaiParser(duplicateFilesResult) self.fail(badResultMsgs["duplicateFilesResult"]) except InvalidResultError: pass def testMissingCiResult(self): try: BonsaiParser(missingCiResult) self.fail(badResultMsgs["missingCiResult"]) except EmptyResult: pass def testMergeEmptyLogMsg(self): """Ensure that BonsaiPoller works around the bonsai xml output issue when the check-in comment is empty""" bp = BonsaiParser(noCheckinMsgResult) result = bp.getData() self.failUnlessEqual(len(result.nodes), 1) self.failUnlessEqual(result.nodes[0].who, "johndoe@domain.tld") self.failUnlessEqual(result.nodes[0].date, 12345678) self.failUnlessEqual(result.nodes[0].log, "") for file, ref in zip(result.nodes[0].files, noCheckinMsgRef): self.failUnlessEqual(file.filename, ref['filename']) self.failUnlessEqual(file.revision, ref['revision']) class TestBonsaiPoller(changesource.ChangeSourceMixin, unittest.TestCase): def setUp(self): d = self.setUpChangeSource() def create_poller(_): self.attachChangeSource(BonsaiPoller('http://bonsai.mozilla.org', 'all', 'seamonkey')) d.addCallback(create_poller) return d def tearDown(self): return self.tearDownChangeSource() def fakeGetPage(self, result): """Install a fake getPage that puts the requested URL in C{self.getPage_got_url} and return C{result}""" self.getPage_got_url = None def fake(url, timeout=None): self.getPage_got_url = url return defer.succeed(result) self.patch(client, "getPage", fake) # tests def test_describe(self): assert re.search(r'bonsai\.mozilla\.org', self.changesource.describe()) def test_poll_bad(self): # Make sure a change is not submitted if the BonsaiParser fails, and # that the poll operation catches the exception correctly self.fakeGetPage(badUnparsedResult) d = self.changesource.poll() def check(_): self.assertEqual(len(self.changes_added), 0) d.addCallback(check) return d def test_poll_good(self): self.fakeGetPage(goodUnparsedResult) d = self.changesource.poll() def check(_): self.assertEqual(len(self.changes_added), 3) self.assertEqual(self.changes_added[0]['author'], who1) self.assertEqual(self.changes_added[0]['when_timestamp'], epoch2datetime(date1)) self.assertEqual(self.changes_added[0]['comments'], log1) self.assertEqual(self.changes_added[0]['branch'], 'seamonkey') self.assertEqual(self.changes_added[0]['files'], [ '%s (revision %s)' % (file1, rev1) ]) self.assertEqual(self.changes_added[1]['author'], who2) self.assertEqual(self.changes_added[1]['when_timestamp'], epoch2datetime(date2)) self.assertEqual(self.changes_added[1]['comments'], log2) self.assertEqual(self.changes_added[1]['files'], [ '%s (revision %s)' % (file2, rev2), '%s (revision %s)' % (file3, rev3) ]) self.assertEqual(self.changes_added[2]['author'], who3) self.assertEqual(self.changes_added[2]['comments'], log3) self.assertEqual(self.changes_added[2]['when_timestamp'], epoch2datetime(date3)) self.assertEqual(self.changes_added[2]['files'], []) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_changes_changes.py000066400000000000000000000077361222546025000236210ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import textwrap import re from twisted.trial import unittest from buildbot.test.fake import fakedb from buildbot.changes import changes class Change(unittest.TestCase): change23_rows = [ fakedb.Change(changeid=23, author="dustin", comments="fix whitespace", is_dir=0, branch="warnerdb", revision="deadbeef", when_timestamp=266738404, revlink='http://warner/0e92a098b', category='devel', repository='git://warner', codebase='mainapp', project='Buildbot'), fakedb.ChangeFile(changeid=23, filename='master/README.txt'), fakedb.ChangeFile(changeid=23, filename='slave/README.txt'), fakedb.ChangeProperty(changeid=23, property_name='notest', property_value='["no","Change"]'), fakedb.ChangeUser(changeid=23, uid=27), ] def setUp(self): self.change23 = changes.Change(**dict( # using **dict(..) forces kwargs category='devel', isdir=0, repository=u'git://warner', codebase=u'mainapp', who=u'dustin', when=266738404, comments=u'fix whitespace', project=u'Buildbot', branch=u'warnerdb', revlink=u'http://warner/0e92a098b', properties={'notest':"no"}, files=[u'master/README.txt', u'slave/README.txt'], revision=u'deadbeef')) self.change23.number = 23 def test_str(self): string = str(self.change23) self.assertTrue(re.match(r"Change\(.*\)", string), string) def test_asText(self): text = self.change23.asText() self.assertTrue(re.match(textwrap.dedent(u'''\ Files: master/README.txt slave/README.txt On: git://warner For: Buildbot At: .* Changed By: dustin Comments: fix whitespaceProperties: notest: no '''), text), text) def test_asDict(self): dict = self.change23.asDict() self.assertIn('1978', dict['at']) # timezone-sensitive del dict['at'] self.assertEqual(dict, { 'branch': u'warnerdb', 'category': u'devel', 'codebase': u'mainapp', 'comments': u'fix whitespace', 'files': [{'name': u'master/README.txt'}, {'name': u'slave/README.txt'}], 'number': 23, 'project': u'Buildbot', 'properties': [('notest', 'no', 'Change')], 'repository': u'git://warner', 'rev': u'deadbeef', 'revision': u'deadbeef', 'revlink': u'http://warner/0e92a098b', 'when': 266738404, 'who': u'dustin'}) def test_getShortAuthor(self): self.assertEqual(self.change23.getShortAuthor(), 'dustin') def test_getTime(self): # careful, or timezones will hurt here self.assertIn('Jun 1978', self.change23.getTime()) def test_getTimes(self): self.assertEqual(self.change23.getTimes(), (266738404, None)) def test_getText(self): self.change23.who = 'nasty < nasty' # test the html escaping (ugh!) self.assertEqual(self.change23.getText(), ['nasty < nasty']) def test_getLogs(self): self.assertEqual(self.change23.getLogs(), {}) buildbot-0.8.8/buildbot/test/unit/test_changes_filter.py000066400000000000000000000126421222546025000234660ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import re from twisted.trial import unittest from buildbot.changes import filter from buildbot.test.fake.state import State class Change(State): project = '' repository = '' branch = '' category = '' codebase = '' class ChangeFilter(unittest.TestCase): def setUp(self): self.results = [] # (got, expected, msg) self.filt = None def tearDown(self): if self.results: raise RuntimeError("test forgot to call check()") def setfilter(self, **kwargs): self.filt = filter.ChangeFilter(**kwargs) def yes(self, change, msg): self.results.append((self.filt.filter_change(change), True, msg)) def no(self, change, msg): self.results.append((self.filt.filter_change(change), False, msg)) def check(self): errs = [] for r in self.results: if (r[0] or r[1]) and not (r[0] and r[1]): errs.append(r[2]) self.results = [] if errs: self.fail("; ".join(errs)) def test_filter_change_filter_fn(self): self.setfilter(filter_fn = lambda ch : ch.x > 3) self.no(Change(x=2), "filter_fn returns False") self.yes(Change(x=4), "filter_fn returns True") self.check() def test_filter_change_filt_str(self): self.setfilter(project = "myproj") self.no(Change(project="yourproj"), "non-matching PROJECT returns False") self.yes(Change(project="myproj"), "matching PROJECT returns True") self.check() def test_filter_change_filt_list(self): self.setfilter(repository = ["vc://a", "vc://b"]) self.yes(Change(repository="vc://a"), "matching REPOSITORY vc://a returns True") self.yes(Change(repository="vc://b"), "matching REPOSITORY vc://b returns True") self.no(Change(repository="vc://c"), "non-matching REPOSITORY returns False") self.no(Change(repository=None), "None for REPOSITORY returns False") self.check() def test_filter_change_filt_list_None(self): self.setfilter(branch = ["mybr", None]) self.yes(Change(branch="mybr"), "matching BRANCH mybr returns True") self.yes(Change(branch=None), "matching BRANCH None returns True") self.no(Change(branch="misc"), "non-matching BRANCH returns False") self.check() def test_filter_change_filt_re(self): self.setfilter(category_re = "^a.*") self.yes(Change(category="albert"), "matching CATEGORY returns True") self.no(Change(category="boris"), "non-matching CATEGORY returns False") self.check() def test_filter_change_branch_re(self): # regression - see #927 self.setfilter(branch_re = "^t.*") self.yes(Change(branch="trunk"), "matching BRANCH returns True") self.no(Change(branch="development"), "non-matching BRANCH returns False") self.no(Change(branch=None), "branch=None returns False") self.check() def test_filter_change_filt_re_compiled(self): self.setfilter(category_re = re.compile("^b.*", re.I)) self.no(Change(category="albert"), "non-matching CATEGORY returns False") self.yes(Change(category="boris"), "matching CATEGORY returns True") self.yes(Change(category="Bruce"), "matching CATEGORY returns True, using re.I") self.check() def test_filter_change_combination(self): self.setfilter(project='p', repository='r', branch='b', category='c', codebase='cb') self.no(Change(project='x', repository='x', branch='x', category='x'), "none match -> False") self.no(Change(project='p', repository='r', branch='b', category='x'), "three match -> False") self.no(Change(project='p', repository='r', branch='b', category='c', codebase='x'), "four match -> False") self.yes(Change(project='p', repository='r', branch='b', category='c', codebase='cb'), "all match -> True") self.check() def test_filter_change_combination_filter_fn(self): self.setfilter(project='p', repository='r', branch='b', category='c', filter_fn = lambda c : c.ff) self.no(Change(project='x', repository='x', branch='x', category='x', ff=False), "none match and fn returns False -> False") self.no(Change(project='p', repository='r', branch='b', category='c', ff=False), "all match and fn returns False -> False") self.no(Change(project='x', repository='x', branch='x', category='x', ff=True), "none match and fn returns True -> False") self.yes(Change(project='p', repository='r', branch='b', category='c', ff=True), "all match and fn returns True -> False") self.check() buildbot-0.8.8/buildbot/test/unit/test_changes_gerritchangesource.py000066400000000000000000000071201222546025000260570ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc[''], 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.util import json from buildbot.test.util import changesource from buildbot.changes import gerritchangesource class TestGerritChangeSource(changesource.ChangeSourceMixin, unittest.TestCase): def setUp(self): return self.setUpChangeSource() def tearDown(self): return self.tearDownChangeSource() def newChangeSource(self, host, user): s = gerritchangesource.GerritChangeSource(host, user) self.attachChangeSource(s) return s # tests def test_describe(self): s = self.newChangeSource('somehost', 'someuser') self.assertSubstring("GerritChangeSource", s.describe()) # TODO: test the backoff algorithm # this variable is reused in test_steps_source_repo # to ensure correct integration between change source and repo step expected_change = {'category': u'patchset-created', 'files': ['unknown'], 'repository': u'ssh://someuser@somehost:29418/pr', 'author': u'Dustin ', 'comments': u'fix 1234', 'project': u'pr', 'branch': u'br/4321', 'revlink': u'http://buildbot.net', 'properties': {u'event.change.owner.email': u'dustin@mozilla.com', u'event.change.subject': u'fix 1234', u'event.change.project': u'pr', u'event.change.owner.name': u'Dustin', u'event.change.number': u'4321', u'event.change.url': u'http://buildbot.net', u'event.change.branch': u'br', u'event.type': u'patchset-created', u'event.patchSet.revision': u'abcdef', u'event.patchSet.number': u'12'}, u'revision': u'abcdef'} def test_lineReceived_patchset_created(self): s = self.newChangeSource('somehost', 'someuser') d = s.lineReceived(json.dumps(dict( type="patchset-created", change=dict( branch="br", project="pr", number="4321", owner=dict(name="Dustin", email="dustin@mozilla.com"), url="http://buildbot.net", subject="fix 1234" ), patchSet=dict(revision="abcdef", number="12") ))) def check(_): self.failUnlessEqual(len(self.changes_added), 1) c = self.changes_added[0] for k, v in c.items(): self.assertEqual(self.expected_change[k], v) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_changes_gitpoller.py000066400000000000000000000603241222546025000242020ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os import mock from twisted.trial import unittest from twisted.internet import defer from buildbot.changes import base, gitpoller from buildbot.test.util import changesource, config, gpo from buildbot.util import epoch2datetime # Test that environment variables get propagated to subprocesses (See #2116) os.environ['TEST_THAT_ENVIRONMENT_GETS_PASSED_TO_SUBPROCESSES'] = 'TRUE' class GitOutputParsing(gpo.GetProcessOutputMixin, unittest.TestCase): """Test GitPoller methods for parsing git output""" def setUp(self): self.poller = gitpoller.GitPoller('git@example.com:foo/baz.git') self.setUpGetProcessOutput() dummyRevStr = '12345abcde' def _perform_git_output_test(self, methodToTest, args, desiredGoodOutput, desiredGoodResult, emptyRaisesException=True): # make this call to self.patch here so that we raise a SkipTest if it # is not supported self.expectCommands( gpo.Expect('git', *args) .path('gitpoller-work'), ) d = defer.succeed(None) def call_empty(_): # we should get an Exception with empty output from git return methodToTest(self.dummyRevStr) d.addCallback(call_empty) def cb_empty(_): if emptyRaisesException: self.fail("getProcessOutput should have failed on empty output") def eb_empty(f): if not emptyRaisesException: self.fail("getProcessOutput should NOT have failed on empty output") d.addCallbacks(cb_empty, eb_empty) d.addCallback(lambda _: self.assertAllCommandsRan()) # and the method shouldn't supress any exceptions self.expectCommands( gpo.Expect('git', *args) .path('gitpoller-work') .exit(1), ) def call_exception(_): return methodToTest(self.dummyRevStr) d.addCallback(call_exception) def cb_exception(_): self.fail("getProcessOutput should have failed on stderr output") def eb_exception(f): pass d.addCallbacks(cb_exception, eb_exception) d.addCallback(lambda _: self.assertAllCommandsRan()) # finally we should get what's expected from good output self.expectCommands( gpo.Expect('git', *args) .path('gitpoller-work') .stdout(desiredGoodOutput) ) def call_desired(_): return methodToTest(self.dummyRevStr) d.addCallback(call_desired) def cb_desired(r): self.assertEquals(r, desiredGoodResult) d.addCallback(cb_desired) d.addCallback(lambda _: self.assertAllCommandsRan()) def test_get_commit_author(self): authorStr = 'Sammy Jankis ' return self._perform_git_output_test(self.poller._get_commit_author, ['log', '--no-walk', '--format=%aN <%aE>', self.dummyRevStr, '--'], authorStr, authorStr) def test_get_commit_comments(self): commentStr = 'this is a commit message\n\nthat is multiline' return self._perform_git_output_test(self.poller._get_commit_comments, ['log', '--no-walk', '--format=%s%n%b', self.dummyRevStr, '--'], commentStr, commentStr) def test_get_commit_files(self): filesStr = 'file1\nfile2' return self._perform_git_output_test(self.poller._get_commit_files, ['log', '--name-only', '--no-walk', '--format=%n', self.dummyRevStr, '--'], filesStr, filesStr.split(), emptyRaisesException=False) def test_get_commit_timestamp(self): stampStr = '1273258009' return self._perform_git_output_test(self.poller._get_commit_timestamp, ['log', '--no-walk', '--format=%ct', self.dummyRevStr, '--'], stampStr, float(stampStr)) # _get_changes is tested in TestGitPoller, below class TestGitPoller(gpo.GetProcessOutputMixin, changesource.ChangeSourceMixin, unittest.TestCase): REPOURL = 'git@example.com:foo/baz.git' REPOURL_QUOTED = 'git%40example.com%3Afoo%2Fbaz.git' def setUp(self): self.setUpGetProcessOutput() d = self.setUpChangeSource() def create_poller(_): self.poller = gitpoller.GitPoller(self.REPOURL) self.poller.master = self.master d.addCallback(create_poller) return d def tearDown(self): return self.tearDownChangeSource() def test_describe(self): self.assertSubstring("GitPoller", self.poller.describe()) def test_poll_initial(self): self.expectCommands( gpo.Expect('git', 'init', '--bare', 'gitpoller-work'), gpo.Expect('git', 'fetch', self.REPOURL, '+master:refs/buildbot/%s/master' % self.REPOURL_QUOTED) .path('gitpoller-work'), gpo.Expect('git', 'rev-parse', 'refs/buildbot/%s/master' % self.REPOURL_QUOTED) .path('gitpoller-work') .stdout('bf0b01df6d00ae8d1ffa0b2e2acbe642a6cd35d5\n'), ) d = self.poller.poll() @d.addCallback def cb(_): self.assertAllCommandsRan() self.assertEqual(self.poller.lastRev, { 'master': 'bf0b01df6d00ae8d1ffa0b2e2acbe642a6cd35d5' }) self.master.db.state.assertStateByClass( name=self.REPOURL, class_name='GitPoller', lastRev={ 'master': 'bf0b01df6d00ae8d1ffa0b2e2acbe642a6cd35d5' }) return d def test_poll_failInit(self): self.expectCommands( gpo.Expect('git', 'init', '--bare', 'gitpoller-work') .exit(1), ) d = self.assertFailure(self.poller.poll(), EnvironmentError) d.addCallback(lambda _: self.assertAllCommandsRan) return d def test_poll_failFetch(self): self.expectCommands( gpo.Expect('git', 'init', '--bare', 'gitpoller-work'), gpo.Expect('git', 'fetch', self.REPOURL, '+master:refs/buildbot/%s/master' % self.REPOURL_QUOTED) .path('gitpoller-work') .exit(1), ) d = self.assertFailure(self.poller.poll(), EnvironmentError) d.addCallback(lambda _: self.assertAllCommandsRan) return d def test_poll_failRevParse(self): self.expectCommands( gpo.Expect('git', 'init', '--bare', 'gitpoller-work'), gpo.Expect('git', 'fetch', self.REPOURL, '+master:refs/buildbot/%s/master' % self.REPOURL_QUOTED) .path('gitpoller-work'), gpo.Expect('git', 'rev-parse', 'refs/buildbot/%s/master' % self.REPOURL_QUOTED) .path('gitpoller-work') .exit(1), ) d = self.poller.poll() @d.addCallback def cb(_): self.assertAllCommandsRan() self.assertEqual(len(self.flushLoggedErrors()), 1) self.assertEqual(self.poller.lastRev, {}) def test_poll_failLog(self): self.expectCommands( gpo.Expect('git', 'init', '--bare', 'gitpoller-work'), gpo.Expect('git', 'fetch', self.REPOURL, '+master:refs/buildbot/%s/master' % self.REPOURL_QUOTED) .path('gitpoller-work'), gpo.Expect('git', 'rev-parse', 'refs/buildbot/%s/master' % self.REPOURL_QUOTED) .path('gitpoller-work') .stdout('4423cdbcbb89c14e50dd5f4152415afd686c5241\n'), gpo.Expect('git', 'log', '--format=%H', 'fa3ae8ed68e664d4db24798611b352e3c6509930..4423cdbcbb89c14e50dd5f4152415afd686c5241', '--') .path('gitpoller-work') .exit(1), ) # do the poll self.poller.lastRev = { 'master': 'fa3ae8ed68e664d4db24798611b352e3c6509930' } d = self.poller.poll() @d.addCallback def cb(_): self.assertAllCommandsRan() self.assertEqual(len(self.flushLoggedErrors()), 1) self.assertEqual(self.poller.lastRev, { 'master': '4423cdbcbb89c14e50dd5f4152415afd686c5241' }) def test_poll_nothingNew(self): # Test that environment variables get propagated to subprocesses # (See #2116) self.patch(os, 'environ', {'ENVVAR': 'TRUE'}) self.addGetProcessOutputExpectEnv({'ENVVAR': 'TRUE'}) self.expectCommands( gpo.Expect('git', 'init', '--bare', 'gitpoller-work'), gpo.Expect('git', 'fetch', self.REPOURL, '+master:refs/buildbot/%s/master' % self.REPOURL_QUOTED) .path('gitpoller-work') .stdout('no interesting output'), gpo.Expect('git', 'rev-parse', 'refs/buildbot/%s/master' % self.REPOURL_QUOTED) .path('gitpoller-work') .stdout('4423cdbcbb89c14e50dd5f4152415afd686c5241\n'), gpo.Expect('git', 'log', '--format=%H', '4423cdbcbb89c14e50dd5f4152415afd686c5241..4423cdbcbb89c14e50dd5f4152415afd686c5241', '--') .path('gitpoller-work') .stdout(''), ) self.poller.lastRev = { 'master': '4423cdbcbb89c14e50dd5f4152415afd686c5241' } d = self.poller.poll() @d.addCallback def cb(_): self.assertAllCommandsRan() self.master.db.state.assertStateByClass( name=self.REPOURL, class_name='GitPoller', lastRev={ 'master': '4423cdbcbb89c14e50dd5f4152415afd686c5241' }) return d def test_poll_multipleBranches_initial(self): self.expectCommands( gpo.Expect('git', 'init', '--bare', 'gitpoller-work'), gpo.Expect('git', 'fetch', self.REPOURL, '+master:refs/buildbot/%s/master' % self.REPOURL_QUOTED, '+release:refs/buildbot/%s/release' % self.REPOURL_QUOTED) .path('gitpoller-work'), gpo.Expect('git', 'rev-parse', 'refs/buildbot/%s/master' % self.REPOURL_QUOTED) .path('gitpoller-work') .stdout('4423cdbcbb89c14e50dd5f4152415afd686c5241\n'), gpo.Expect('git', 'rev-parse', 'refs/buildbot/%s/release' % self.REPOURL_QUOTED) .path('gitpoller-work') .stdout('9118f4ab71963d23d02d4bdc54876ac8bf05acf2'), ) # do the poll self.poller.branches = ['master', 'release'] d = self.poller.poll() @d.addCallback def cb(_): self.assertAllCommandsRan() self.assertEqual(self.poller.lastRev, { 'master': '4423cdbcbb89c14e50dd5f4152415afd686c5241', 'release': '9118f4ab71963d23d02d4bdc54876ac8bf05acf2' }) return d def test_poll_multipleBranches(self): self.expectCommands( gpo.Expect('git', 'init', '--bare', 'gitpoller-work'), gpo.Expect('git', 'fetch', self.REPOURL, '+master:refs/buildbot/%s/master' % self.REPOURL_QUOTED, '+release:refs/buildbot/%s/release' % self.REPOURL_QUOTED) .path('gitpoller-work'), gpo.Expect('git', 'rev-parse', 'refs/buildbot/%s/master' % self.REPOURL_QUOTED) .path('gitpoller-work') .stdout('4423cdbcbb89c14e50dd5f4152415afd686c5241\n'), gpo.Expect('git', 'log', '--format=%H', 'fa3ae8ed68e664d4db24798611b352e3c6509930..4423cdbcbb89c14e50dd5f4152415afd686c5241', '--') .path('gitpoller-work') .stdout('\n'.join([ '64a5dc2a4bd4f558b5dd193d47c83c7d7abc9a1a', '4423cdbcbb89c14e50dd5f4152415afd686c5241'])), gpo.Expect('git', 'rev-parse', 'refs/buildbot/%s/release' % self.REPOURL_QUOTED) .path('gitpoller-work') .stdout('9118f4ab71963d23d02d4bdc54876ac8bf05acf2'), gpo.Expect('git', 'log', '--format=%H', 'bf0b01df6d00ae8d1ffa0b2e2acbe642a6cd35d5..9118f4ab71963d23d02d4bdc54876ac8bf05acf2', '--') .path('gitpoller-work') .stdout( '\n'.join([ '9118f4ab71963d23d02d4bdc54876ac8bf05acf2' ])), ) # and patch out the _get_commit_foo methods which were already tested # above def timestamp(rev): return defer.succeed(1273258009.0) self.patch(self.poller, '_get_commit_timestamp', timestamp) def author(rev): return defer.succeed('by:' + rev[:8]) self.patch(self.poller, '_get_commit_author', author) def files(rev): return defer.succeed(['/etc/' + rev[:3]]) self.patch(self.poller, '_get_commit_files', files) def comments(rev): return defer.succeed('hello!') self.patch(self.poller, '_get_commit_comments', comments) # do the poll self.poller.branches = ['master', 'release'] self.poller.lastRev = { 'master': 'fa3ae8ed68e664d4db24798611b352e3c6509930', 'release': 'bf0b01df6d00ae8d1ffa0b2e2acbe642a6cd35d5' } d = self.poller.poll() @d.addCallback def cb(_): self.assertAllCommandsRan() self.assertEqual(self.poller.lastRev, { 'master': '4423cdbcbb89c14e50dd5f4152415afd686c5241', 'release': '9118f4ab71963d23d02d4bdc54876ac8bf05acf2' }) self.assertEqual(len(self.changes_added), 3) self.assertEqual(self.changes_added[0]['author'], 'by:4423cdbc') self.assertEqual(self.changes_added[0]['when_timestamp'], epoch2datetime(1273258009)) self.assertEqual(self.changes_added[0]['comments'], 'hello!') self.assertEqual(self.changes_added[0]['branch'], 'master') self.assertEqual(self.changes_added[0]['files'], [ '/etc/442' ]) self.assertEqual(self.changes_added[0]['src'], 'git') self.assertEqual(self.changes_added[1]['author'], 'by:64a5dc2a') self.assertEqual(self.changes_added[1]['when_timestamp'], epoch2datetime(1273258009)) self.assertEqual(self.changes_added[1]['comments'], 'hello!') self.assertEqual(self.changes_added[1]['files'], [ '/etc/64a' ]) self.assertEqual(self.changes_added[1]['src'], 'git') self.assertEqual(self.changes_added[2]['author'], 'by:9118f4ab') self.assertEqual(self.changes_added[2]['when_timestamp'], epoch2datetime(1273258009)) self.assertEqual(self.changes_added[2]['comments'], 'hello!') self.assertEqual(self.changes_added[2]['files'], [ '/etc/911' ]) self.assertEqual(self.changes_added[2]['src'], 'git') return d def test_poll_noChanges(self): # Test that environment variables get propagated to subprocesses # (See #2116) self.patch(os, 'environ', {'ENVVAR': 'TRUE'}) self.addGetProcessOutputExpectEnv({'ENVVAR': 'TRUE'}) self.expectCommands( gpo.Expect('git', 'init', '--bare', 'gitpoller-work'), gpo.Expect('git', 'fetch', self.REPOURL, '+master:refs/buildbot/%s/master' % self.REPOURL_QUOTED) .path('gitpoller-work') .stdout('no interesting output'), gpo.Expect('git', 'rev-parse', 'refs/buildbot/%s/master' % self.REPOURL_QUOTED) .path('gitpoller-work') .stdout('4423cdbcbb89c14e50dd5f4152415afd686c5241\n'), gpo.Expect('git', 'log', '--format=%H', '4423cdbcbb89c14e50dd5f4152415afd686c5241..4423cdbcbb89c14e50dd5f4152415afd686c5241', '--') .path('gitpoller-work') .stdout(''), ) self.poller.lastRev = { 'master': '4423cdbcbb89c14e50dd5f4152415afd686c5241' } d = self.poller.poll() @d.addCallback def cb(_): self.assertAllCommandsRan() self.assertEqual(self.poller.lastRev, { 'master': '4423cdbcbb89c14e50dd5f4152415afd686c5241' }) return d def test_poll_old(self): # Test that environment variables get propagated to subprocesses # (See #2116) self.patch(os, 'environ', {'ENVVAR': 'TRUE'}) self.addGetProcessOutputExpectEnv({'ENVVAR': 'TRUE'}) # patch out getProcessOutput and getProcessOutputAndValue for the # benefit of the _get_changes method self.expectCommands( gpo.Expect('git', 'init', '--bare', 'gitpoller-work'), gpo.Expect('git', 'fetch', self.REPOURL, '+master:refs/buildbot/%s/master' % self.REPOURL_QUOTED) .path('gitpoller-work') .stdout('no interesting output'), gpo.Expect('git', 'rev-parse', 'refs/buildbot/%s/master' % self.REPOURL_QUOTED) .path('gitpoller-work') .stdout('4423cdbcbb89c14e50dd5f4152415afd686c5241\n'), gpo.Expect('git', 'log', '--format=%H', 'fa3ae8ed68e664d4db24798611b352e3c6509930..4423cdbcbb89c14e50dd5f4152415afd686c5241', '--') .path('gitpoller-work') .stdout('\n'.join([ '64a5dc2a4bd4f558b5dd193d47c83c7d7abc9a1a', '4423cdbcbb89c14e50dd5f4152415afd686c5241' ])), ) # and patch out the _get_commit_foo methods which were already tested # above def timestamp(rev): return defer.succeed(1273258009.0) self.patch(self.poller, '_get_commit_timestamp', timestamp) def author(rev): return defer.succeed('by:' + rev[:8]) self.patch(self.poller, '_get_commit_author', author) def files(rev): return defer.succeed(['/etc/' + rev[:3]]) self.patch(self.poller, '_get_commit_files', files) def comments(rev): return defer.succeed('hello!') self.patch(self.poller, '_get_commit_comments', comments) # do the poll self.poller.lastRev = { 'master': 'fa3ae8ed68e664d4db24798611b352e3c6509930' } d = self.poller.poll() # check the results def check_changes(_): self.assertEqual(self.poller.lastRev, { 'master': '4423cdbcbb89c14e50dd5f4152415afd686c5241' }) self.assertEqual(len(self.changes_added), 2) self.assertEqual(self.changes_added[0]['author'], 'by:4423cdbc') self.assertEqual(self.changes_added[0]['when_timestamp'], epoch2datetime(1273258009)) self.assertEqual(self.changes_added[0]['comments'], 'hello!') self.assertEqual(self.changes_added[0]['branch'], 'master') self.assertEqual(self.changes_added[0]['files'], [ '/etc/442' ]) self.assertEqual(self.changes_added[0]['src'], 'git') self.assertEqual(self.changes_added[1]['author'], 'by:64a5dc2a') self.assertEqual(self.changes_added[1]['when_timestamp'], epoch2datetime(1273258009)) self.assertEqual(self.changes_added[1]['comments'], 'hello!') self.assertEqual(self.changes_added[1]['files'], [ '/etc/64a' ]) self.assertEqual(self.changes_added[1]['src'], 'git') self.assertAllCommandsRan() self.master.db.state.assertStateByClass( name=self.REPOURL, class_name='GitPoller', lastRev={ 'master': '4423cdbcbb89c14e50dd5f4152415afd686c5241' }) d.addCallback(check_changes) return d # We mock out base.PollingChangeSource.startService, since it calls # reactor.callWhenRunning, which leaves a dirty reactor if a synchronous # deferred is returned from a test method. def test_startService(self): startService = mock.Mock() self.patch(base.PollingChangeSource, "startService", startService) d = self.poller.startService() def check(_): self.assertEqual(self.poller.workdir, os.path.join('basedir', 'gitpoller-work')) self.assertEqual(self.poller.lastRev, {}) startService.assert_called_once_with(self.poller) d.addCallback(check) return d def test_startService_loadLastRev(self): startService = mock.Mock() self.patch(base.PollingChangeSource, "startService", startService) self.master.db.state.fakeState( name=self.REPOURL, class_name='GitPoller', lastRev={"master": "fa3ae8ed68e664d4db24798611b352e3c6509930"}, ) d = self.poller.startService() def check(_): self.assertEqual(self.poller.lastRev, { "master": "fa3ae8ed68e664d4db24798611b352e3c6509930" }) startService.assert_called_once_with(self.poller) d.addCallback(check) return d class TestGitPollerConstructor(unittest.TestCase, config.ConfigErrorsMixin): def test_deprecatedFetchRefspec(self): self.assertRaisesConfigError("fetch_refspec is no longer supported", lambda: gitpoller.GitPoller("/tmp/git.git", fetch_refspec='not-supported')) def test_oldPollInterval(self): poller = gitpoller.GitPoller("/tmp/git.git", pollinterval=10) self.assertEqual(poller.pollInterval, 10) def test_branches_default(self): poller = gitpoller.GitPoller("/tmp/git.git") self.assertEqual(poller.branches, ["master"]) def test_branches_oldBranch(self): poller = gitpoller.GitPoller("/tmp/git.git", branch='magic') self.assertEqual(poller.branches, ["magic"]) def test_branches(self): poller = gitpoller.GitPoller("/tmp/git.git", branches=['magic', 'marker']) self.assertEqual(poller.branches, ["magic", "marker"]) def test_branches_andBranch(self): self.assertRaisesConfigError("can't specify both branch and branches", lambda: gitpoller.GitPoller("/tmp/git.git", branch='bad', branches=['listy'])) def test_gitbin_default(self): poller = gitpoller.GitPoller("/tmp/git.git") self.assertEqual(poller.gitbin, "git") buildbot-0.8.8/buildbot/test/unit/test_changes_hgpoller.py000066400000000000000000000165501222546025000240170ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os from twisted.trial import unittest from twisted.internet import defer from buildbot.changes import hgpoller from buildbot.test.util import changesource, gpo from buildbot.test.fake.fakedb import FakeDBConnector from buildbot.util import epoch2datetime ENVIRON_2116_KEY = 'TEST_THAT_ENVIRONMENT_GETS_PASSED_TO_SUBPROCESSES' class TestHgPoller(gpo.GetProcessOutputMixin, changesource.ChangeSourceMixin, unittest.TestCase): def setUp(self): # To test that environment variables get propagated to subprocesses # (See #2116) os.environ[ENVIRON_2116_KEY] = 'TRUE' self.setUpGetProcessOutput() d = self.setUpChangeSource() self.remote_repo = 'ssh://example.com/foo/baz' self.repo_ready = True def _isRepositoryReady(): return self.repo_ready def create_poller(_): self.poller = hgpoller.HgPoller(self.remote_repo, workdir='/some/dir') self.poller.master = self.master self.poller._isRepositoryReady = _isRepositoryReady def create_db(_): db = self.master.db = FakeDBConnector(self) return db.setup() d.addCallback(create_poller) d.addCallback(create_db) return d def tearDown(self): del os.environ[ENVIRON_2116_KEY] return self.tearDownChangeSource() def gpoFullcommandPattern(self, commandName, *expected_args): """Match if the command is commandName and arg list start as expected. This allows to test a bit more if expected GPO are issued, be it by obscure failures due to the result not being given. """ def matchesSubcommand(bin, given_args, **kwargs): return bin == commandName and tuple( given_args[:len(expected_args)]) == expected_args return matchesSubcommand def test_describe(self): self.assertSubstring("HgPoller", self.poller.describe()) def test_hgbin_default(self): self.assertEqual(self.poller.hgbin, "hg") def test_poll_initial(self): self.repo_ready = False # Test that environment variables get propagated to subprocesses # (See #2116) expected_env = {ENVIRON_2116_KEY: 'TRUE'} self.addGetProcessOutputExpectEnv(expected_env) self.expectCommands( gpo.Expect('hg', 'init', '/some/dir'), gpo.Expect('hg', 'pull', '-b', 'default', 'ssh://example.com/foo/baz') .path('/some/dir'), gpo.Expect('hg', 'heads', 'default', '--template={rev}' + os.linesep) .path('/some/dir').stdout("73591"), gpo.Expect('hg', 'log', '-b', 'default', '-r', '73591:73591', # only fetches that head '--template={rev}:{node}\\n') .path('/some/dir').stdout(os.linesep.join(['73591:4423cdb'])), gpo.Expect('hg', 'log', '-r', '4423cdb', '--template={date|hgdate}' + os.linesep + '{author}' + os.linesep + '{files}' + os.linesep + '{desc|strip}') .path('/some/dir').stdout(os.linesep.join([ '1273258100.0 -7200', 'Bob Test ', 'file1 dir/file2', 'This is rev 73591', ''])), ) # do the poll d = self.poller.poll() # check the results def check_changes(_): self.assertEqual(len(self.changes_added), 1) change = self.changes_added[0] self.assertEqual(change['revision'], '4423cdb') self.assertEqual(change['author'], 'Bob Test ') self.assertEqual(change['when_timestamp'], epoch2datetime(1273258100)), self.assertEqual(change['files'], ['file1', 'dir/file2']) self.assertEqual(change['src'], 'hg') self.assertEqual(change['branch'], 'default') self.assertEqual(change['comments'], 'This is rev 73591') d.addCallback(check_changes) d.addCallback(self.check_current_rev(73591)) return d def check_current_rev(self, wished): def check_on_rev(_): d = self.poller._getCurrentRev() d.addCallback(lambda oid_rev: self.assertEqual(oid_rev[1], wished)) return check_on_rev @defer.inlineCallbacks def test_poll_several_heads(self): # If there are several heads on the named branch, the poller musn't # climb (good enough for now, ideally it should even go to the common # ancestor) self.expectCommands( gpo.Expect('hg', 'pull', '-b', 'default', 'ssh://example.com/foo/baz') .path('/some/dir'), gpo.Expect('hg', 'heads', 'default', '--template={rev}' + os.linesep) .path('/some/dir').stdout('5' + os.linesep + '6' + os.linesep), ) yield self.poller._setCurrentRev(3) # do the poll: we must stay at rev 3 d = self.poller.poll() d.addCallback(self.check_current_rev(3)) @defer.inlineCallbacks def test_poll_regular(self): # normal operation. There's a previous revision, we get a new one. self.expectCommands( gpo.Expect('hg', 'pull', '-b', 'default', 'ssh://example.com/foo/baz') .path('/some/dir'), gpo.Expect('hg', 'heads', 'default', '--template={rev}' + os.linesep) .path('/some/dir').stdout('5' + os.linesep), gpo.Expect('hg', 'log', '-b', 'default', '-r', '5:5', '--template={rev}:{node}\\n') .path('/some/dir').stdout('5:784bd' + os.linesep), gpo.Expect('hg', 'log', '-r', '784bd', '--template={date|hgdate}' + os.linesep + '{author}' + os.linesep + '{files}' + os.linesep + '{desc|strip}') .path('/some/dir').stdout(os.linesep.join([ '1273258009.0 -7200', 'Joe Test ', 'file1 file2', 'Comment for rev 5', ''])), ) yield self.poller._setCurrentRev(4) d = self.poller.poll() d.addCallback(self.check_current_rev(5)) def check_changes(_): self.assertEquals(len(self.changes_added), 1) change = self.changes_added[0] self.assertEqual(change['revision'], '784bd') self.assertEqual(change['comments'], 'Comment for rev 5') d.addCallback(check_changes) buildbot-0.8.8/buildbot/test/unit/test_changes_mail.py000066400000000000000000000067261222546025000231310ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os from twisted.trial import unittest from buildbot.test.util import changesource, dirs from buildbot.changes import mail class TestMaildirSource(changesource.ChangeSourceMixin, dirs.DirsMixin, unittest.TestCase): def setUp(self): self.maildir = os.path.abspath("maildir") d = self.setUpChangeSource() d.addCallback(lambda _ : self.setUpDirs(self.maildir)) return d def populateMaildir(self): "create a fake maildir with a fake new message ('newmsg') in it" newdir = os.path.join(self.maildir, "new") os.makedirs(newdir) curdir = os.path.join(self.maildir, "cur") os.makedirs(curdir) fake_message = "Subject: test\n\nthis is a test" mailfile = os.path.join(newdir, "newmsg") with open(mailfile, "w") as f: f.write(fake_message) def assertMailProcessed(self): self.assertFalse(os.path.exists(os.path.join(self.maildir, "new", "newmsg"))) self.assertTrue(os.path.exists(os.path.join(self.maildir, "cur", "newmsg"))) def tearDown(self): d = self.tearDownDirs() d.addCallback(lambda _ : self.tearDownChangeSource()) return d # tests def test_describe(self): mds = mail.MaildirSource(self.maildir) self.assertSubstring(self.maildir, mds.describe()) def test_messageReceived_svn(self): self.populateMaildir() mds = mail.MaildirSource(self.maildir) self.attachChangeSource(mds) # monkey-patch in a parse method def parse(message, prefix): assert 'this is a test' in message.get_payload() return ('svn', dict(fake_chdict=1)) mds.parse = parse d = mds.messageReceived('newmsg') def check(_): self.assertMailProcessed() self.assertEqual(len(self.changes_added), 1) self.assertEqual(self.changes_added[0]['fake_chdict'], 1) self.assertEqual(self.changes_added[0]['src'], 'svn') d.addCallback(check) return d def test_messageReceived_bzr(self): self.populateMaildir() mds = mail.MaildirSource(self.maildir) self.attachChangeSource(mds) # monkey-patch in a parse method def parse(message, prefix): assert 'this is a test' in message.get_payload() return ('bzr', dict(fake_chdict=1)) mds.parse = parse d = mds.messageReceived('newmsg') def check(_): self.assertMailProcessed() self.assertEqual(len(self.changes_added), 1) self.assertEqual(self.changes_added[0]['fake_chdict'], 1) self.assertEqual(self.changes_added[0]['src'], 'bzr') d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_changes_mail_CVSMaildirSource.py000066400000000000000000000201051222546025000263120ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from email import message_from_string from email.Utils import parsedate_tz, mktime_tz from buildbot.changes.mail import CVSMaildirSource # # Sample message from CVS version 1.11 # cvs1_11_msg = """From: Andy Howell To: buildbot@example.com Subject: cvs module MyModuleName Date: Sat, 07 Aug 2010 11:11:49 +0000 X-Mailer: Python buildbot-cvs-mail $Revision: 1.3 $ Cvsmode: 1.11 Category: None CVSROOT: :ext:cvshost.example.com:/cvsroot Files: base/module/src/make GNUmakefile,1.362,1.363 Project: MyModuleName Update of /cvsroot/base/moduel/src/make In directory cvshost:/tmp/cvs-serv10922 Modified Files: GNUmakefile Log Message: Commented out some stuff. """ # # Sample message from CVS version 1.12 # # Paths are handled differently by the two versions # cvs1_12_msg="""Date: Wed, 11 Aug 2010 04:56:44 +0000 From: andy@example.com To: buildbot@example.com Subject: cvs update for project RaiCore X-Mailer: Python buildbot-cvs-mail $Revision: 1.3 $ Cvsmode: 1.12 Category: None CVSROOT: :ext:cvshost.example.com:/cvsroot Files: file1.cpp 1.77 1.78 file2.cpp 1.75 1.76 Path: base/module/src Project: MyModuleName Update of /cvsroot/base/module/src In directory example.com:/tmp/cvs-serv26648/InsightMonAgent Modified Files: file1.cpp file2.cpp Log Message: Changes for changes sake """ class TestCVSMaildirSource(unittest.TestCase): def test_CVSMaildirSource_create_change_from_cvs1_11msg(self): m = message_from_string(cvs1_11_msg) src = CVSMaildirSource('/dev/null') try: src, chdict = src.parse( m ) except: self.fail('Failed to get change from email message.') self.assert_(chdict != None) self.assert_(chdict['author'] == 'andy') self.assert_(len(chdict['files']) == 1) self.assert_(chdict['files'][0] == 'base/module/src/make/GNUmakefile') self.assert_(chdict['comments'] == 'Commented out some stuff.\n') self.assert_(chdict['isdir'] == False) self.assert_(chdict['revision'] == '2010-08-07 11:11:49') dateTuple = parsedate_tz('Sat, 07 Aug 2010 11:11:49 +0000') self.assert_(chdict['when'] == mktime_tz(dateTuple)) self.assert_(chdict['branch'] == None) self.assert_(chdict['repository'] == ':ext:cvshost.example.com:/cvsroot') self.assert_(chdict['project'] == 'MyModuleName') self.assert_(len(chdict['properties']) == 0) self.assert_(src == 'cvs') def test_CVSMaildirSource_create_change_from_cvs1_12msg(self): m = message_from_string(cvs1_12_msg) src = CVSMaildirSource('/dev/null') try: src, chdict = src.parse( m ) except: self.fail('Failed to get change from email message.') self.assert_(chdict != None) self.assert_(chdict['author'] == 'andy') self.assert_(len(chdict['files']) == 2) self.assert_(chdict['files'][0] == 'base/module/src/file1.cpp') self.assert_(chdict['files'][1] == 'base/module/src/file2.cpp') self.assert_(chdict['comments'] == 'Changes for changes sake\n') self.assert_(chdict['isdir'] == False) self.assert_(chdict['revision'] == '2010-08-11 04:56:44') dateTuple = parsedate_tz('Wed, 11 Aug 2010 04:56:44 +0000') self.assert_(chdict['when'] == mktime_tz(dateTuple)) self.assert_(chdict['branch'] == None) self.assert_(chdict['repository'] == ':ext:cvshost.example.com:/cvsroot') self.assert_(chdict['project'] == 'MyModuleName') self.assert_(len(chdict['properties']) == 0) self.assert_(src == 'cvs') def test_CVSMaildirSource_create_change_from_cvs1_12_with_no_path(self): msg = cvs1_12_msg.replace('Path: base/module/src', '') m = message_from_string(msg) src = CVSMaildirSource('/dev/null') try: assert src.parse( m )[1] except ValueError: pass else: self.fail('Expect ValueError.') def test_CVSMaildirSource_create_change_with_bad_cvsmode(self): # Branch is indicated afer 'Tag:' in modified file list msg = cvs1_11_msg.replace('Cvsmode: 1.11', 'Cvsmode: 9.99') m = message_from_string(msg) src = CVSMaildirSource('/dev/null') try: assert src.parse( m )[1] except ValueError: pass else: self.fail('Expected ValueError') def test_CVSMaildirSource_create_change_with_branch(self): # Branch is indicated afer 'Tag:' in modified file list msg = cvs1_11_msg.replace(' GNUmakefile', ' Tag: Test_Branch\n GNUmakefile') m = message_from_string(msg) src = CVSMaildirSource('/dev/null') try: chdict = src.parse( m )[1] except: self.fail('Failed to get change from email message.') self.assert_(chdict['branch'] == 'Test_Branch') def test_CVSMaildirSource_create_change_with_category(self): msg = cvs1_11_msg.replace('Category: None', 'Category: Test category') m = message_from_string(msg) src = CVSMaildirSource('/dev/null') try: chdict = src.parse( m )[1] except: self.fail('Failed to get change from email message.') self.assert_(chdict['category'] == 'Test category') def test_CVSMaildirSource_create_change_with_no_comment(self): # Strip off comments msg = cvs1_11_msg[:cvs1_11_msg.find('Commented out some stuff')] m = message_from_string(msg) src = CVSMaildirSource('/dev/null') try: chdict = src.parse( m )[1] except: self.fail('Failed to get change from email message.') self.assert_(chdict['comments'] == None ) def test_CVSMaildirSource_create_change_with_no_files(self): # A message with no files is likely not for us msg = cvs1_11_msg.replace('Files: base/module/src/make GNUmakefile,1.362,1.363','') m = message_from_string(msg) src = CVSMaildirSource('/dev/null') try: chdict = src.parse( m ) except: self.fail('Failed to get change from email message.') self.assert_(chdict == None ) def test_CVSMaildirSource_create_change_with_no_project(self): msg = cvs1_11_msg.replace('Project: MyModuleName', '') m = message_from_string(msg) src = CVSMaildirSource('/dev/null') try: chdict = src.parse( m )[1] except: self.fail('Failed to get change from email message.') self.assert_(chdict['project'] == None ) def test_CVSMaildirSource_create_change_with_no_repository(self): msg = cvs1_11_msg.replace('CVSROOT: :ext:cvshost.example.com:/cvsroot', '') m = message_from_string(msg) src = CVSMaildirSource('/dev/null') try: chdict = src.parse( m )[1] except: self.fail('Failed to get change from email message.') self.assert_(chdict['repository'] == None ) def test_CVSMaildirSource_create_change_with_property(self): m = message_from_string(cvs1_11_msg) propDict = { 'foo' : 'bar' } src = CVSMaildirSource('/dev/null', properties=propDict) try: chdict = src.parse( m )[1] except: self.fail('Failed to get change from email message.') self.assert_(chdict['properties']['foo'] == 'bar') buildbot-0.8.8/buildbot/test/unit/test_changes_manager.py000066400000000000000000000037561222546025000236210ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from twisted.application import service from buildbot.changes import manager class FakeChangeSource(service.Service): pass class TestChangeManager(unittest.TestCase): def setUp(self): self.master = mock.Mock() self.cm = manager.ChangeManager(self.master) self.new_config = mock.Mock() def make_sources(self, n): for i in range(n): src = FakeChangeSource() src.setName('ChangeSource %d' % i) yield src def test_reconfigService_add(self): src1, src2 = self.make_sources(2) src1.setServiceParent(self.cm) self.new_config.change_sources = [ src1, src2 ] d = self.cm.reconfigService(self.new_config) @d.addCallback def check(_): self.assertIdentical(src2.parent, self.cm) self.assertIdentical(src2.master, self.master) return d def test_reconfigService_remove(self): src1, = self.make_sources(1) src1.setServiceParent(self.cm) self.new_config.change_sources = [ ] d = self.cm.reconfigService(self.new_config) @d.addCallback def check(_): self.assertIdentical(src1.parent, None) self.assertIdentical(src1.master, None) return d buildbot-0.8.8/buildbot/test/unit/test_changes_p4poller.py000066400000000000000000000216071222546025000237430ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import time from twisted.trial import unittest from buildbot.changes.p4poller import P4Source, get_simple_split, P4PollerError from buildbot.test.util import changesource, gpo from buildbot.util import epoch2datetime first_p4changes = \ """Change 1 on 2006/04/13 by slamb@testclient 'first rev' """ second_p4changes = \ """Change 3 on 2006/04/13 by bob@testclient 'short desc truncated' Change 2 on 2006/04/13 by slamb@testclient 'bar' """ third_p4changes = \ """Change 5 on 2006/04/13 by mpatel@testclient 'first rev' """ change_4_log = \ """Change 4 by mpatel@testclient on 2006/04/13 21:55:39 short desc truncated because this is a long description. """ change_3_log = \ u"""Change 3 by bob@testclient on 2006/04/13 21:51:39 short desc truncated because this is a long description. ASDF-GUI-P3-\u2018Upgrade Icon\u2019 disappears sometimes. """ change_2_log = \ """Change 2 by slamb@testclient on 2006/04/13 21:46:23 creation """ p4change = { 3: change_3_log + """Affected files ... ... //depot/myproject/branch_b/branch_b_file#1 add ... //depot/myproject/branch_b/whatbranch#1 branch ... //depot/myproject/branch_c/whatbranch#1 branch """, 2: change_2_log + """Affected files ... ... //depot/myproject/trunk/whatbranch#1 add ... //depot/otherproject/trunk/something#1 add """, 5: change_4_log + """Affected files ... ... //depot/myproject/branch_b/branch_b_file#1 add ... //depot/myproject/branch_b#75 edit ... //depot/myproject/branch_c/branch_c_file#1 add """, } class TestP4Poller(changesource.ChangeSourceMixin, gpo.GetProcessOutputMixin, unittest.TestCase): def setUp(self): self.setUpGetProcessOutput() return self.setUpChangeSource() def tearDown(self): return self.tearDownChangeSource() def add_p4_describe_result(self, number, result): self.expectCommands( gpo.Expect('p4', 'describe', '-s', str(number)).stdout(result)) def makeTime(self, timestring): datefmt = '%Y/%m/%d %H:%M:%S' when = time.mktime(time.strptime(timestring, datefmt)) return epoch2datetime(when) # tests def test_describe(self): self.attachChangeSource( P4Source(p4port=None, p4user=None, p4base='//depot/myproject/', split_file=lambda x: x.split('/', 1))) self.assertSubstring("p4source", self.changesource.describe()) def do_test_poll_successful(self, **kwargs): encoding = kwargs.get('encoding', 'utf8') self.attachChangeSource( P4Source(p4port=None, p4user=None, p4base='//depot/myproject/', split_file=lambda x: x.split('/', 1), **kwargs)) self.expectCommands( gpo.Expect('p4', 'changes', '-m', '1', '//depot/myproject/...').stdout(first_p4changes), gpo.Expect('p4', 'changes', '//depot/myproject/...@2,now').stdout(second_p4changes), ) encoded_p4change = p4change.copy() encoded_p4change[3] = encoded_p4change[3].encode(encoding) self.add_p4_describe_result(2, encoded_p4change[2]) self.add_p4_describe_result(3, encoded_p4change[3]) # The first time, it just learns the change to start at. self.assert_(self.changesource.last_change is None) d = self.changesource.poll() def check_first_check(_): self.assertEquals(self.changes_added, []) self.assertEquals(self.changesource.last_change, 1) d.addCallback(check_first_check) # Subsequent times, it returns Change objects for new changes. d.addCallback(lambda _ : self.changesource.poll()) def check_second_check(res): self.assertEquals(len(self.changes_added), 3) self.assertEquals(self.changesource.last_change, 3) # They're supposed to go oldest to newest, so this one must be first. self.assertEquals(self.changes_added[0], dict(author='slamb', files=['whatbranch'], project='', comments=change_2_log, revision='2', when_timestamp=self.makeTime("2006/04/13 21:46:23"), branch='trunk')) # These two can happen in either order, since they're from the same # Perforce change. if self.changes_added[1]['branch'] == 'branch_c': self.changes_added[1:] = reversed(self.changes_added[1:]) self.assertEquals(self.changes_added[1], dict(author='bob', files=['branch_b_file', 'whatbranch'], project='', comments=change_3_log, # converted to unicode correctly revision='3', when_timestamp=self.makeTime("2006/04/13 21:51:39"), branch='branch_b')) self.assertEquals(self.changes_added[2], dict(author='bob', files=['whatbranch'], project='', comments=change_3_log, # converted to unicode correctly revision='3', when_timestamp=self.makeTime("2006/04/13 21:51:39"), branch='branch_c')) self.assertAllCommandsRan() d.addCallback(check_second_check) return d def test_poll_successful_default_encoding(self): return self.do_test_poll_successful() def test_poll_successful_macroman_encoding(self): return self.do_test_poll_successful(encoding='macroman') def test_poll_failed_changes(self): self.attachChangeSource( P4Source(p4port=None, p4user=None, p4base='//depot/myproject/', split_file=lambda x: x.split('/', 1))) self.expectCommands( gpo.Expect('p4', 'changes', '-m', '1', '//depot/myproject/...').stdout('Perforce client error:\n...')) # call _poll, so we can catch the failure d = self.changesource._poll() return self.assertFailure(d, P4PollerError) def test_poll_failed_describe(self): self.attachChangeSource( P4Source(p4port=None, p4user=None, p4base='//depot/myproject/', split_file=lambda x: x.split('/', 1))) self.expectCommands( gpo.Expect('p4', 'changes', '//depot/myproject/...@3,now').stdout(second_p4changes), ) self.add_p4_describe_result(2, p4change[2]) self.add_p4_describe_result(3, 'Perforce client error:\n...') self.changesource.last_change = 2 # tell poll() that it's already been called once # call _poll, so we can catch the failure d = self.changesource._poll() self.assertFailure(d, P4PollerError) @d.addCallback def check(_): # check that 2 was processed OK self.assertEquals(self.changesource.last_change, 2) self.assertAllCommandsRan() return d def test_poll_split_file(self): """Make sure split file works on branch only changes""" self.attachChangeSource( P4Source(p4port=None, p4user=None, p4base='//depot/myproject/', split_file=get_simple_split)) self.expectCommands( gpo.Expect('p4', 'changes', '//depot/myproject/...@51,now').stdout(third_p4changes), ) self.add_p4_describe_result(5, p4change[5]) self.changesource.last_change = 50 d = self.changesource.poll() def check(res): self.assertEquals(len(self.changes_added), 2) self.assertEquals(self.changesource.last_change, 5) self.assertAllCommandsRan() d.addCallback(check) return d class TestSplit(unittest.TestCase): def test_get_simple_split(self): self.assertEqual(get_simple_split('foo/bar'), ('foo', 'bar')) self.assertEqual(get_simple_split('foo-bar'), (None, None)) self.assertEqual(get_simple_split('/bar'), ('', 'bar')) self.assertEqual(get_simple_split('foo/'), ('foo', '')) buildbot-0.8.8/buildbot/test/unit/test_changes_pb.py000066400000000000000000000225441222546025000226040ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members """ Test the PB change source. """ import mock from twisted.trial import unittest from twisted.internet import defer from buildbot.changes import pb from buildbot.test.util import changesource, pbmanager from buildbot.util import epoch2datetime class TestPBChangeSource( changesource.ChangeSourceMixin, pbmanager.PBManagerMixin, unittest.TestCase): def setUp(self): self.setUpPBChangeSource() d = self.setUpChangeSource() @d.addCallback def setup(_): self.master.pbmanager = self.pbmanager return d def test_registration_no_slaveport(self): return self._test_registration(None, user='alice', passwd='sekrit') def test_registration_global_slaveport(self): return self._test_registration(('9999', 'alice', 'sekrit'), slavePort='9999', user='alice', passwd='sekrit') def test_registration_custom_port(self): return self._test_registration(('8888', 'alice', 'sekrit'), user='alice', passwd='sekrit', port='8888') def test_registration_no_userpass(self): return self._test_registration(('9939', 'change', 'changepw'), slavePort='9939') def test_registration_no_userpass_no_global(self): return self._test_registration(None) @defer.inlineCallbacks def _test_registration(self, exp_registration, slavePort=None, **constr_kwargs): config = mock.Mock() config.slavePortnum = slavePort self.attachChangeSource(pb.PBChangeSource(**constr_kwargs)) self.startChangeSource() yield self.changesource.reconfigService(config) if exp_registration: self.assertRegistered(*exp_registration) else: self.assertNotRegistered() yield self.stopChangeSource() if exp_registration: self.assertUnregistered(*exp_registration) self.assertEqual(self.changesource.registration, None) def test_perspective(self): self.attachChangeSource(pb.PBChangeSource('alice', 'sekrit', port='8888')) persp = self.changesource.getPerspective(mock.Mock(), 'alice') self.assertIsInstance(persp, pb.ChangePerspective) def test_describe(self): cs = pb.PBChangeSource() self.assertSubstring("PBChangeSource", cs.describe()) def test_describe_prefix(self): cs = pb.PBChangeSource(prefix="xyz") self.assertSubstring("PBChangeSource", cs.describe()) self.assertSubstring("xyz", cs.describe()) def test_describe_int(self): cs = pb.PBChangeSource(port=9989) self.assertSubstring("PBChangeSource", cs.describe()) @defer.inlineCallbacks def test_reconfigService_no_change(self): config = mock.Mock() self.attachChangeSource(pb.PBChangeSource(port='9876')) self.startChangeSource() yield self.changesource.reconfigService(config) self.assertRegistered('9876', 'change', 'changepw') yield self.stopChangeSource() self.assertUnregistered('9876', 'change', 'changepw') @defer.inlineCallbacks def test_reconfigService_default_changed(self): config = mock.Mock() config.slavePortnum = '9876' self.attachChangeSource(pb.PBChangeSource()) self.startChangeSource() yield self.changesource.reconfigService(config) self.assertRegistered('9876', 'change', 'changepw') config.slavePortnum = '1234' yield self.changesource.reconfigService(config) self.assertUnregistered('9876', 'change', 'changepw') self.assertRegistered('1234', 'change', 'changepw') yield self.stopChangeSource() self.assertUnregistered('1234', 'change', 'changepw') class TestChangePerspective(unittest.TestCase): def setUp(self): self.added_changes = [] self.master = mock.Mock() def addChange(**chdict): self.added_changes.append(chdict) return defer.succeed(mock.Mock()) self.master.addChange = addChange def test_addChange_noprefix(self): cp = pb.ChangePerspective(self.master, None) d = cp.perspective_addChange(dict(who="bar", files=['a'])) def check(_): self.assertEqual(self.added_changes, [ dict(author="bar", files=['a']) ]) d.addCallback(check) return d def test_addChange_codebase(self): cp = pb.ChangePerspective(self.master, None) d = cp.perspective_addChange(dict(who="bar", files=[], codebase='cb')) def check(_): self.assertEqual(self.added_changes, [ dict(author="bar", files=[], codebase='cb') ]) d.addCallback(check) return d def test_addChange_prefix(self): cp = pb.ChangePerspective(self.master, 'xx/') d = cp.perspective_addChange( dict(who="bar", files=['xx/a', 'yy/b'])) def check(_): self.assertEqual(self.added_changes, [ dict(author="bar", files=['a']) ]) d.addCallback(check) return d def test_addChange_sanitize_None(self): cp = pb.ChangePerspective(self.master, None) d = cp.perspective_addChange( dict(project=None, revlink=None, repository=None) ) def check(_): self.assertEqual(self.added_changes, [ dict(project="", revlink="", repository="", files=[]) ]) d.addCallback(check) return d def test_addChange_when_None(self): cp = pb.ChangePerspective(self.master, None) d = cp.perspective_addChange( dict(when=None) ) def check(_): self.assertEqual(self.added_changes, [ dict(when_timestamp=None, files=[]) ]) d.addCallback(check) return d def test_addChange_files_tuple(self): cp = pb.ChangePerspective(self.master, None) d = cp.perspective_addChange( dict(files=('a', 'b')) ) def check(_): self.assertEqual(self.added_changes, [ dict(files=['a', 'b']) ]) d.addCallback(check) return d def test_addChange_unicode(self): cp = pb.ChangePerspective(self.master, None) d = cp.perspective_addChange(dict(author=u"\N{SNOWMAN}", comments=u"\N{SNOWMAN}", files=[u'\N{VERY MUCH GREATER-THAN}'])) def check(_): self.assertEqual(self.added_changes, [ dict(author=u"\N{SNOWMAN}", comments=u"\N{SNOWMAN}", files=[u'\N{VERY MUCH GREATER-THAN}']) ]) d.addCallback(check) return d def test_addChange_unicode_as_bytestring(self): cp = pb.ChangePerspective(self.master, None) d = cp.perspective_addChange(dict(author=u"\N{SNOWMAN}".encode('utf8'), comments=u"\N{SNOWMAN}".encode('utf8'), files=[u'\N{VERY MUCH GREATER-THAN}'.encode('utf8')])) def check(_): self.assertEqual(self.added_changes, [ dict(author=u"\N{SNOWMAN}", comments=u"\N{SNOWMAN}", files=[u'\N{VERY MUCH GREATER-THAN}']) ]) d.addCallback(check) return d def test_addChange_non_utf8_bytestring(self): cp = pb.ChangePerspective(self.master, None) bogus_utf8 = '\xff\xff\xff\xff' replacement = bogus_utf8.decode('utf8', 'replace') d = cp.perspective_addChange(dict(author=bogus_utf8, files=['a'])) def check(_): self.assertEqual(self.added_changes, [ dict(author=replacement, files=['a']) ]) d.addCallback(check) return d def test_addChange_old_param_names(self): cp = pb.ChangePerspective(self.master, None) d = cp.perspective_addChange(dict(isdir=1, who='me', when=1234, files=[])) def check(_): self.assertEqual(self.added_changes, [ dict(is_dir=1, author='me', files=[], when_timestamp=epoch2datetime(1234)) ]) d.addCallback(check) return d def test_createUserObject_git_src(self): cp = pb.ChangePerspective(self.master, None) d = cp.perspective_addChange(dict(who="c ", src='git')) def check_change(_): self.assertEqual(self.added_changes, [ dict(author="c ", files=[], src='git') ]) d.addCallback(check_change) return d buildbot-0.8.8/buildbot/test/unit/test_changes_svnpoller.py000066400000000000000000000526551222546025000242350ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import xml.dom.minidom from twisted.internet import defer from twisted.trial import unittest from buildbot.test.util import changesource, gpo, compat from buildbot.changes import svnpoller # this is the output of "svn info --xml # svn+ssh://svn.twistedmatrix.com/svn/Twisted/trunk" prefix_output = """\ svn+ssh://svn.twistedmatrix.com/svn/Twisted/trunk svn+ssh://svn.twistedmatrix.com/svn/Twisted bbbe8e31-12d6-0310-92fd-ac37d47ddeeb jml 2006-10-01T02:37:34.063255Z """ # and this is "svn info --xml svn://svn.twistedmatrix.com/svn/Twisted". I # think this is kind of a degenerate case.. it might even be a form of error. prefix_output_2 = """\ """ # this is the svn info output for a local repository, svn info --xml # file:///home/warner/stuff/Projects/BuildBot/trees/svnpoller/_trial_temp/test_vc/repositories/SVN-Repository prefix_output_3 = """\ file:///home/warner/stuff/Projects/BuildBot/trees/svnpoller/_trial_temp/test_vc/repositories/SVN-Repository file:///home/warner/stuff/Projects/BuildBot/trees/svnpoller/_trial_temp/test_vc/repositories/SVN-Repository c0f47ff4-ba1e-0410-96b5-d44cc5c79e7f warner 2006-10-01T07:37:04.182499Z """ # % svn info --xml file:///home/warner/stuff/Projects/BuildBot/trees/svnpoller/_trial_temp/test_vc/repositories/SVN-Repository/sample/trunk prefix_output_4 = """\ file:///home/warner/stuff/Projects/BuildBot/trees/svnpoller/_trial_temp/test_vc/repositories/SVN-Repository/sample/trunk file:///home/warner/stuff/Projects/BuildBot/trees/svnpoller/_trial_temp/test_vc/repositories/SVN-Repository c0f47ff4-ba1e-0410-96b5-d44cc5c79e7f warner 2006-10-01T07:37:02.286440Z """ # output from svn log on .../SVN-Repository/sample # (so it includes trunk and branches) sample_base = ("file:///usr/home/warner/stuff/Projects/BuildBot/trees/misc/" + "_trial_temp/test_vc/repositories/SVN-Repository/sample") sample_logentries = [None] * 6 sample_logentries[5] = """\ warner 2006-10-01T19:35:16.165664Z /sample/branch/version.c revised_to_2 """ sample_logentries[4] = """\ warner 2006-10-01T19:35:16.165664Z /sample/branch revised_to_2 """ sample_logentries[3] = """\ warner 2006-10-01T19:35:16.165664Z /sample/trunk/version.c revised_to_2 """ sample_logentries[2] = """\ warner 2006-10-01T19:35:10.215692Z /sample/branch/main.c commit_on_branch """ sample_logentries[1] = """\ warner 2006-10-01T19:35:09.154973Z /sample/branch make_branch """ sample_logentries[0] = """\ warner 2006-10-01T19:35:08.642045Z /sample /sample/trunk /sample/trunk/subdir/subdir.c /sample/trunk/main.c /sample/trunk/version.c /sample/trunk/subdir sample_project_files """ sample_info_output = """\ file:///usr/home/warner/stuff/Projects/BuildBot/trees/misc/_trial_temp/test_vc/repositories/SVN-Repository/sample file:///usr/home/warner/stuff/Projects/BuildBot/trees/misc/_trial_temp/test_vc/repositories/SVN-Repository 4f94adfc-c41e-0410-92d5-fbf86b7c7689 warner 2006-10-01T19:35:16.165664Z """ changes_output_template = """\ %s """ def make_changes_output(maxrevision): # return what 'svn log' would have just after the given revision was # committed logs = sample_logentries[0:maxrevision] assert len(logs) == maxrevision logs.reverse() output = changes_output_template % ("".join(logs)) return output def make_logentry_elements(maxrevision): "return the corresponding logentry elements for the given revisions" doc = xml.dom.minidom.parseString(make_changes_output(maxrevision)) return doc.getElementsByTagName("logentry") def split_file(path): pieces = path.split("/") if pieces[0] == "branch": return dict(branch="branch", path="/".join(pieces[1:])) if pieces[0] == "trunk": return dict(path="/".join(pieces[1:])) raise RuntimeError("there shouldn't be any files like %r" % path) class TestSVNPoller(gpo.GetProcessOutputMixin, changesource.ChangeSourceMixin, unittest.TestCase): def setUp(self): self.setUpGetProcessOutput() return self.setUpChangeSource() def tearDown(self): return self.tearDownChangeSource() def attachSVNPoller(self, *args, **kwargs): s = svnpoller.SVNPoller(*args, **kwargs) self.attachChangeSource(s) return s def add_svn_command_result(self, command, result): self.expectCommands( gpo.Expect('svn', command).stdout(result)) # tests def test_describe(self): s = self.attachSVNPoller('file://') self.assertSubstring("SVNPoller", s.describe()) def test_strip_svnurl(self): base = "svn+ssh://svn.twistedmatrix.com/svn/Twisted/trunk" s = self.attachSVNPoller(base + "/") self.failUnlessEqual(s.svnurl, base) def do_test_get_prefix(self, base, output, expected): s = self.attachSVNPoller(base) self.expectCommands(gpo.Expect('svn', 'info', '--xml', '--non-interactive', base).stdout(output)) d = s.get_prefix() def check(prefix): self.failUnlessEqual(prefix, expected) self.assertAllCommandsRan() d.addCallback(check) return d def test_get_prefix_1(self): base = "svn+ssh://svn.twistedmatrix.com/svn/Twisted/trunk" return self.do_test_get_prefix(base, prefix_output, 'trunk') def test_get_prefix_2(self): base = "svn+ssh://svn.twistedmatrix.com/svn/Twisted" return self.do_test_get_prefix(base, prefix_output_2, '') def test_get_prefix_3(self): base = ("file:///home/warner/stuff/Projects/BuildBot/trees/" + "svnpoller/_trial_temp/test_vc/repositories/SVN-Repository") return self.do_test_get_prefix(base, prefix_output_3, '') def test_get_prefix_4(self): base = ("file:///home/warner/stuff/Projects/BuildBot/trees/" + "svnpoller/_trial_temp/test_vc/repositories/SVN-Repository/sample/trunk") return self.do_test_get_prefix(base, prefix_output_3, 'sample/trunk') def test_log_parsing(self): s = self.attachSVNPoller('file:///foo') output = make_changes_output(4) entries = s.parse_logs(output) # no need for elaborate assertions here; this is minidom's logic self.assertEqual(len(entries), 4) def test_get_new_logentries(self): s = self.attachSVNPoller('file:///foo') entries = make_logentry_elements(4) s.last_change = 4 new = s.get_new_logentries(entries) self.assertEqual(s.last_change, 4) self.assertEqual(len(new), 0) s.last_change = 3 new = s.get_new_logentries(entries) self.assertEqual(s.last_change, 4) self.assertEqual(len(new), 1) s.last_change = 1 new = s.get_new_logentries(entries) self.assertEqual(s.last_change, 4) self.assertEqual(len(new), 3) # special case: if last_change is None, then no new changes are queued s.last_change = None new = s.get_new_logentries(entries) self.assertEqual(s.last_change, 4) self.assertEqual(len(new), 0) def test_create_changes(self): base = ("file:///home/warner/stuff/Projects/BuildBot/trees/" + "svnpoller/_trial_temp/test_vc/repositories/SVN-Repository/sample") s = self.attachSVNPoller(base, split_file=split_file) s._prefix = "sample" logentries = dict(zip(xrange(1, 7), reversed(make_logentry_elements(6)))) changes = s.create_changes(reversed([ logentries[3], logentries[2] ])) self.failUnlessEqual(len(changes), 2) # note that parsing occurs in reverse self.failUnlessEqual(changes[0]['branch'], "branch") self.failUnlessEqual(changes[0]['revision'], '2') self.failUnlessEqual(changes[0]['project'], '') self.failUnlessEqual(changes[0]['repository'], base) self.failUnlessEqual(changes[1]['branch'], "branch") self.failUnlessEqual(changes[1]['files'], ["main.c"]) self.failUnlessEqual(changes[1]['revision'], '3') self.failUnlessEqual(changes[1]['project'], '') self.failUnlessEqual(changes[1]['repository'], base) changes = s.create_changes([ logentries[4] ]) self.failUnlessEqual(len(changes), 1) self.failUnlessEqual(changes[0]['branch'], None) self.failUnlessEqual(changes[0]['revision'], '4') self.failUnlessEqual(changes[0]['files'], ["version.c"]) # r5 should *not* create a change as it's a branch deletion changes = s.create_changes([ logentries[5] ]) self.failUnlessEqual(len(changes), 0) # r6 should create a change as it's not deleting an entire branch changes = s.create_changes([ logentries[6] ]) self.failUnlessEqual(len(changes), 1) self.failUnlessEqual(changes[0]['branch'], 'branch') self.failUnlessEqual(changes[0]['revision'], '6') self.failUnlessEqual(changes[0]['files'], ["version.c"]) def makeInfoExpect(self): return gpo.Expect('svn', 'info', '--xml', '--non-interactive', sample_base, '--username=dustin', '--password=bbrocks') def makeLogExpect(self): return gpo.Expect('svn', 'log', '--xml', '--verbose', '--non-interactive', '--username=dustin', '--password=bbrocks', '--limit=100', sample_base) def test_create_changes_overriden_project(self): def custom_split_file(path): f = split_file(path) if f: f["project"] = "overriden-project" f["repository"] = "overriden-repository" f["codebase"] = "overriden-codebase" return f base = ("file:///home/warner/stuff/Projects/BuildBot/trees/" + "svnpoller/_trial_temp/test_vc/repositories/SVN-Repository/sample") s = self.attachSVNPoller(base, split_file=custom_split_file) s._prefix = "sample" logentries = dict(zip(xrange(1, 7), reversed(make_logentry_elements(6)))) changes = s.create_changes(reversed([ logentries[3], logentries[2] ])) self.failUnlessEqual(len(changes), 2) # note that parsing occurs in reverse self.failUnlessEqual(changes[0]['branch'], "branch") self.failUnlessEqual(changes[0]['revision'], '2') self.failUnlessEqual(changes[0]['project'], "overriden-project") self.failUnlessEqual(changes[0]['repository'], "overriden-repository") self.failUnlessEqual(changes[0]['codebase'], "overriden-codebase") self.failUnlessEqual(changes[1]['branch'], "branch") self.failUnlessEqual(changes[1]['files'], ["main.c"]) self.failUnlessEqual(changes[1]['revision'], '3') self.failUnlessEqual(changes[1]['project'], "overriden-project") self.failUnlessEqual(changes[1]['repository'], "overriden-repository") self.failUnlessEqual(changes[1]['codebase'], "overriden-codebase") def test_poll(self): s = self.attachSVNPoller(sample_base, split_file=split_file, svnuser='dustin', svnpasswd='bbrocks') d = defer.succeed(None) self.expectCommands( self.makeInfoExpect().stdout(sample_info_output), self.makeLogExpect().stdout(make_changes_output(1)), self.makeLogExpect().stdout(make_changes_output(1)), self.makeLogExpect().stdout(make_changes_output(2)), self.makeLogExpect().stdout(make_changes_output(4)), ) # fire it the first time; it should do nothing d.addCallback(lambda _ : s.poll()) def check_first(_): # no changes generated on the first iteration self.assertEqual(self.changes_added, []) self.failUnlessEqual(s.last_change, 1) d.addCallback(check_first) # now fire it again, nothing changing d.addCallback(lambda _ : s.poll()) def check_second(_): self.assertEqual(self.changes_added, []) self.failUnlessEqual(s.last_change, 1) d.addCallback(check_second) # and again, with r2 this time d.addCallback(lambda _ : s.poll()) def check_third(_): self.assertEqual(len(self.changes_added), 1) c = self.changes_added[0] self.failUnlessEqual(c['branch'], "branch") self.failUnlessEqual(c['revision'], '2') self.failUnlessEqual(c['files'], ['']) # signals a new branch self.failUnlessEqual(c['comments'], "make_branch") self.failUnlessEqual(c['src'], "svn") self.failUnlessEqual(s.last_change, 2) d.addCallback(check_third) # and again with both r3 and r4 appearing together def setup_fourth(_): self.changes_added = [] d.addCallback(setup_fourth) d.addCallback(lambda _ : s.poll()) def check_fourth(_): self.assertEqual(len(self.changes_added), 2) c = self.changes_added[0] self.failUnlessEqual(c['branch'], "branch") self.failUnlessEqual(c['revision'], '3') self.failUnlessEqual(c['files'], ["main.c"]) self.failUnlessEqual(c['comments'], "commit_on_branch") self.failUnlessEqual(c['src'], "svn") c = self.changes_added[1] self.failUnlessEqual(c['branch'], None) self.failUnlessEqual(c['revision'], '4') self.failUnlessEqual(c['files'], ["version.c"]) self.failUnlessEqual(c['comments'], "revised_to_2") self.failUnlessEqual(c['src'], "svn") self.failUnlessEqual(s.last_change, 4) self.assertAllCommandsRan() d.addCallback(check_fourth) return d @compat.usesFlushLoggedErrors def test_poll_get_prefix_exception(self): s = self.attachSVNPoller(sample_base, split_file=split_file, svnuser='dustin', svnpasswd='bbrocks') self.expectCommands( self.makeInfoExpect().stderr("error")) d = s.poll() @d.addCallback def check(_): # should have logged the RuntimeError, but not errback'd from poll self.assertEqual(len(self.flushLoggedErrors(IOError)), 1) self.assertAllCommandsRan() return d @compat.usesFlushLoggedErrors def test_poll_get_logs_exception(self): s = self.attachSVNPoller(sample_base, split_file=split_file, svnuser='dustin', svnpasswd='bbrocks') s._prefix = "abc" # skip the get_prefix stuff self.expectCommands( self.makeLogExpect().stderr("some error")) d = s.poll() @d.addCallback def check(_): # should have logged the RuntimeError, but not errback'd from poll self.assertEqual(len(self.flushLoggedErrors(IOError)), 1) self.assertAllCommandsRan() return d def test_cachepath_empty(self): cachepath = os.path.abspath('revcache') if os.path.exists(cachepath): os.unlink(cachepath) s = self.attachSVNPoller(sample_base, cachepath=cachepath) self.assertEqual(s.last_change, None) def test_cachepath_full(self): cachepath = os.path.abspath('revcache') with open(cachepath, "w") as f: f.write('33') s = self.attachSVNPoller(sample_base, cachepath=cachepath) self.assertEqual(s.last_change, 33) s.last_change = 44 s.finished_ok(None) with open(cachepath) as f: self.assertEqual(f.read().strip(), '44') @compat.usesFlushLoggedErrors def test_cachepath_bogus(self): cachepath = os.path.abspath('revcache') with open(cachepath, "w") as f: f.write('nine') s = self.attachSVNPoller(sample_base, cachepath=cachepath) self.assertEqual(s.last_change, None) self.assertEqual(s.cachepath, None) # it should have called log.err once with a ValueError self.assertEqual(len(self.flushLoggedErrors(ValueError)), 1) def test_constructor_pollinterval(self): self.attachSVNPoller(sample_base, pollinterval=100) # just don't fail! def test_extra_args(self): extra_args = ['--no-auth-cache',] base = "svn+ssh://svn.twistedmatrix.com/svn/Twisted/trunk" s = self.attachSVNPoller(svnurl=base, extra_args=extra_args) self.failUnlessEqual(s.extra_args, extra_args) class TestSplitFile(unittest.TestCase): def test_split_file_alwaystrunk(self): self.assertEqual(svnpoller.split_file_alwaystrunk('foo'), dict(path='foo')) def test_split_file_branches_trunk(self): self.assertEqual( svnpoller.split_file_branches('trunk/'), (None, '')) def test_split_file_branches_trunk_subdir(self): self.assertEqual( svnpoller.split_file_branches('trunk/subdir/'), (None, 'subdir/')) def test_split_file_branches_trunk_subfile(self): self.assertEqual( svnpoller.split_file_branches('trunk/subdir/file.c'), (None, 'subdir/file.c')) def test_split_file_branches_trunk_invalid(self): # file named trunk (not a directory): self.assertEqual( svnpoller.split_file_branches('trunk'), None) def test_split_file_branches_branch(self): self.assertEqual( svnpoller.split_file_branches('branches/1.5.x/'), ('branches/1.5.x', '')) def test_split_file_branches_branch_subdir(self): self.assertEqual( svnpoller.split_file_branches('branches/1.5.x/subdir/'), ('branches/1.5.x', 'subdir/')) def test_split_file_branches_branch_subfile(self): self.assertEqual( svnpoller.split_file_branches('branches/1.5.x/subdir/file.c'), ('branches/1.5.x', 'subdir/file.c')) def test_split_file_branches_branch_invalid(self): # file named branches/1.5.x (not a directory): self.assertEqual( svnpoller.split_file_branches('branches/1.5.x'), None) def test_split_file_branches_otherdir(self): # other dirs are ignored: self.assertEqual( svnpoller.split_file_branches('tags/testthis/subdir/'), None) def test_split_file_branches_otherfile(self): # other files are ignored: self.assertEqual( svnpoller.split_file_branches('tags/testthis/subdir/file.c'), None) def test_split_file_projects_branches(self): self.assertEqual( svnpoller.split_file_projects_branches('buildbot/trunk/subdir/file.c'), dict(project='buildbot', path='subdir/file.c')) self.assertEqual( svnpoller.split_file_projects_branches('buildbot/branches/1.5.x/subdir/file.c'), dict(project='buildbot', branch='branches/1.5.x', path='subdir/file.c')) # tags are ignored: self.assertEqual( svnpoller.split_file_projects_branches('buildbot/tags/testthis/subdir/file.c'), None) buildbot-0.8.8/buildbot/test/unit/test_clients_sendchange.py000066400000000000000000000234441222546025000243330ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from twisted.spread import pb from twisted.internet import defer, reactor from buildbot.clients import sendchange class Sender(unittest.TestCase): def setUp(self): # patch out some PB components and make up some mocks self.patch(pb, 'PBClientFactory', self._fake_PBClientFactory) self.patch(reactor, 'connectTCP', self._fake_connectTCP) self.factory = mock.Mock(name='PBClientFactory') self.factory.login = self._fake_login self.factory.login_d = defer.Deferred() self.remote = mock.Mock(name='PB Remote') self.remote.callRemote = self._fake_callRemote self.remote.broker.transport.loseConnection = self._fake_loseConnection # results self.creds = None self.conn_host = self.conn_port = None self.lostConnection = False self.added_changes = [] self.vc_used = None def _fake_PBClientFactory(self): return self.factory def _fake_login(self, creds): self.creds = creds return self.factory.login_d def _fake_connectTCP(self, host, port, factory): self.conn_host = host self.conn_port = port self.assertIdentical(factory, self.factory) self.factory.login_d.callback(self.remote) def _fake_callRemote(self, method, change): self.assertEqual(method, 'addChange') self.added_changes.append(change) return defer.succeed(None) def _fake_loseConnection(self): self.lostConnection = True def assertProcess(self, host, port, username, password, changes): self.assertEqual([host, port, username, password, changes], [ self.conn_host, self.conn_port, self.creds.username, self.creds.password, self.added_changes]) def test_send_minimal(self): s = sendchange.Sender('localhost:1234') d = s.send('branch', 'rev', 'comm', ['a']) def check(_): self.assertProcess('localhost', 1234, 'change', 'changepw', [ dict(project='', repository='', who=None, files=['a'], comments='comm', branch='branch', revision='rev', category=None, when=None, properties={}, revlink='', src=None)]) d.addCallback(check) return d def test_send_auth(self): s = sendchange.Sender('localhost:1234', auth=('me','sekrit')) d = s.send('branch', 'rev', 'comm', ['a']) def check(_): self.assertProcess('localhost', 1234, 'me', 'sekrit', [ dict(project='', repository='', who=None, files=['a'], comments='comm', branch='branch', revision='rev', category=None, when=None, properties={}, revlink='', src=None)]) d.addCallback(check) return d def test_send_full(self): s = sendchange.Sender('localhost:1234') d = s.send('branch', 'rev', 'comm', ['a'], who='me', category='cats', when=1234, properties={'a':'b'}, repository='r', vc='git', project='p', revlink='rl') def check(_): self.assertProcess('localhost', 1234, 'change', 'changepw', [ dict(project='p', repository='r', who='me', files=['a'], comments='comm', branch='branch', revision='rev', category='cats', when=1234, properties={'a':'b'}, revlink='rl', src='git')]) d.addCallback(check) return d def test_send_files_tuple(self): # 'buildbot sendchange' sends files as a tuple, rather than a list.. s = sendchange.Sender('localhost:1234') d = s.send('branch', 'rev', 'comm', ('a', 'b')) def check(_): self.assertProcess('localhost', 1234, 'change', 'changepw', [ dict(project='', repository='', who=None, files=['a', 'b'], comments='comm', branch='branch', revision='rev', category=None, when=None, properties={}, revlink='', src=None)]) d.addCallback(check) return d def test_send_codebase(self): s = sendchange.Sender('localhost:1234') d = s.send('branch', 'rev', 'comm', ['a'], codebase='mycb') def check(_): self.assertProcess('localhost', 1234, 'change', 'changepw', [ dict(project='', repository='', who=None, files=['a'], comments='comm', branch='branch', revision='rev', category=None, when=None, properties={}, revlink='', src=None, codebase='mycb')]) d.addCallback(check) return d def test_send_unicode(self): s = sendchange.Sender('localhost:1234') d = s.send(u'\N{DEGREE SIGN}', u'\U0001f49e', u'\N{POSTAL MARK FACE}', [u'\U0001F4C1'], project=u'\N{SKULL AND CROSSBONES}', repository=u'\N{SNOWMAN}', who=u'\N{THAI CHARACTER KHOMUT}', category=u'\U0001F640', when=1234, properties={u'\N{LATIN SMALL LETTER A WITH MACRON}':'b'}, revlink=u'\U0001F517') def check(_): self.assertProcess('localhost', 1234, 'change', 'changepw', [ dict(project=u'\N{SKULL AND CROSSBONES}', repository=u'\N{SNOWMAN}', who=u'\N{THAI CHARACTER KHOMUT}', files=[u'\U0001F4C1'], # FILE FOLDER comments=u'\N{POSTAL MARK FACE}', branch=u'\N{DEGREE SIGN}', revision=u'\U0001f49e', # REVOLVING HEARTS category=u'\U0001F640', # WEARY CAT FACE when=1234, properties={u'\N{LATIN SMALL LETTER A WITH MACRON}':'b'}, revlink=u'\U0001F517', # LINK SYMBOL src=None)]) d.addCallback(check) return d def test_send_unicode_utf8(self): s = sendchange.Sender('localhost:1234') d = s.send(u'\N{DEGREE SIGN}'.encode('utf8'), u'\U0001f49e'.encode('utf8'), u'\N{POSTAL MARK FACE}'.encode('utf8'), [u'\U0001F4C1'.encode('utf8')], project=u'\N{SKULL AND CROSSBONES}'.encode('utf8'), repository=u'\N{SNOWMAN}'.encode('utf8'), who=u'\N{THAI CHARACTER KHOMUT}'.encode('utf8'), category=u'\U0001F640'.encode('utf8'), when=1234, properties={ u'\N{LATIN SMALL LETTER A WITH MACRON}'.encode('utf8') : 'b'}, revlink=u'\U0001F517'.encode('utf8')) def check(_): self.assertProcess('localhost', 1234, 'change', 'changepw', [ dict(project=u'\N{SKULL AND CROSSBONES}', repository=u'\N{SNOWMAN}', who=u'\N{THAI CHARACTER KHOMUT}', files=[u'\U0001F4C1'], # FILE FOLDER comments=u'\N{POSTAL MARK FACE}', branch=u'\N{DEGREE SIGN}', revision=u'\U0001f49e', # REVOLVING HEARTS category=u'\U0001F640', # WEARY CAT FACE when=1234, ## NOTE: not decoded! properties={'\xc4\x81':'b'}, revlink=u'\U0001F517', # LINK SYMBOL src=None)]) d.addCallback(check) return d def test_send_unicode_latin1(self): # hand send() a bunch of latin1 strings, and expect them recoded # to unicode s = sendchange.Sender('localhost:1234', encoding='latin1') d = s.send(u'\N{YEN SIGN}'.encode('latin1'), u'\N{POUND SIGN}'.encode('latin1'), u'\N{BROKEN BAR}'.encode('latin1'), [u'\N{NOT SIGN}'.encode('latin1')], project=u'\N{DEGREE SIGN}'.encode('latin1'), repository=u'\N{SECTION SIGN}'.encode('latin1'), who=u'\N{MACRON}'.encode('latin1'), category=u'\N{PILCROW SIGN}'.encode('latin1'), when=1234, properties={ u'\N{SUPERSCRIPT ONE}'.encode('latin1') : 'b'}, revlink=u'\N{INVERTED QUESTION MARK}'.encode('latin1')) def check(_): self.assertProcess('localhost', 1234, 'change', 'changepw', [ dict(project=u'\N{DEGREE SIGN}', repository=u'\N{SECTION SIGN}', who=u'\N{MACRON}', files=[u'\N{NOT SIGN}'], comments=u'\N{BROKEN BAR}', branch=u'\N{YEN SIGN}', revision=u'\N{POUND SIGN}', category=u'\N{PILCROW SIGN}', when=1234, ## NOTE: not decoded! properties={'\xb9':'b'}, revlink=u'\N{INVERTED QUESTION MARK}', src=None)]) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_clients_tryclient.py000066400000000000000000000115211222546025000242420ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement from twisted.trial import unittest from buildbot.clients import tryclient from buildbot.util import json class createJobfile(unittest.TestCase): def makeNetstring(self, *strings): return ''.join(['%d:%s,' % (len(s), s) for s in strings]) # version 1 is deprecated and not produced by the try client def test_createJobfile_v2_one_builder(self): jobid = '123-456' branch = 'branch' baserev = 'baserev' patch_level = 0 patch_body = 'diff...' repository = 'repo' project = 'proj' who = None comment = None builderNames = ['runtests'] properties = {} job = tryclient.createJobfile( jobid, branch, baserev, patch_level, patch_body, repository, project, who, comment, builderNames, properties) jobstr = self.makeNetstring( '2', jobid, branch, baserev, str(patch_level), patch_body, repository, project, builderNames[0]) self.assertEqual(job, jobstr) def test_createJobfile_v2_two_builders(self): jobid = '123-456' branch = 'branch' baserev = 'baserev' patch_level = 0 patch_body = 'diff...' repository = 'repo' project = 'proj' who = None comment = None builderNames = ['runtests', 'moretests'] properties = {} job = tryclient.createJobfile( jobid, branch, baserev, patch_level, patch_body, repository, project, who, comment, builderNames, properties) jobstr = self.makeNetstring( '2', jobid, branch, baserev, str(patch_level), patch_body, repository, project, builderNames[0], builderNames[1]) self.assertEqual(job, jobstr) def test_createJobfile_v3(self): jobid = '123-456' branch = 'branch' baserev = 'baserev' patch_level = 0 patch_body = 'diff...' repository = 'repo' project = 'proj' who = 'someuser' comment = None builderNames = ['runtests'] properties = {} job = tryclient.createJobfile( jobid, branch, baserev, patch_level, patch_body, repository, project, who, comment, builderNames, properties) jobstr = self.makeNetstring( '3', jobid, branch, baserev, str(patch_level), patch_body, repository, project, who, builderNames[0]) self.assertEqual(job, jobstr) def test_createJobfile_v4(self): jobid = '123-456' branch = 'branch' baserev = 'baserev' patch_level = 0 patch_body = 'diff...' repository = 'repo' project = 'proj' who = 'someuser' comment = 'insightful comment' builderNames = ['runtests'] properties = {} job = tryclient.createJobfile( jobid, branch, baserev, patch_level, patch_body, repository, project, who, comment, builderNames, properties) jobstr = self.makeNetstring( '4', jobid, branch, baserev, str(patch_level), patch_body, repository, project, who, comment, builderNames[0]) self.assertEqual(job, jobstr) def test_createJobfile_v5(self): jobid = '123-456' branch = 'branch' baserev = 'baserev' patch_level = 0 patch_body = 'diff...' repository = 'repo' project = 'proj' who = 'someuser' comment = 'insightful comment' builderNames = ['runtests'] properties = {'foo': 'bar'} job = tryclient.createJobfile( jobid, branch, baserev, patch_level, patch_body, repository, project, who, comment, builderNames, properties) jobstr = self.makeNetstring( '5', json.dumps({ 'jobid': jobid, 'branch': branch, 'baserev': baserev, 'patch_level': patch_level, 'patch_body': patch_body, 'repository': repository, 'project': project, 'who': who, 'comment': comment, 'builderNames': builderNames, 'properties': properties, })) self.assertEqual(job, jobstr) buildbot-0.8.8/buildbot/test/unit/test_clients_usersclient.py000066400000000000000000000066351222546025000245770ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from twisted.spread import pb from twisted.internet import defer, reactor from buildbot.clients import usersclient class TestUsersClient(unittest.TestCase): def setUp(self): # patch out some PB components and make up some mocks self.patch(pb, 'PBClientFactory', self._fake_PBClientFactory) self.patch(reactor, 'connectTCP', self._fake_connectTCP) self.factory = mock.Mock(name='PBClientFactory') self.factory.login = self._fake_login self.factory.login_d = defer.Deferred() self.remote = mock.Mock(name='PB Remote') self.remote.callRemote = self._fake_callRemote self.remote.broker.transport.loseConnection = self._fake_loseConnection # results self.conn_host = self.conn_port = None self.lostConnection = False def _fake_PBClientFactory(self): return self.factory def _fake_login(self, creds): return self.factory.login_d def _fake_connectTCP(self, host, port, factory): self.conn_host = host self.conn_port = port self.assertIdentical(factory, self.factory) self.factory.login_d.callback(self.remote) def _fake_callRemote(self, method, op, bb_username, bb_password, ids, info): self.assertEqual(method, 'commandline') self.called_with = dict(op=op, bb_username=bb_username, bb_password=bb_password, ids=ids, info=info) return defer.succeed(None) def _fake_loseConnection(self): self.lostConnection = True def assertProcess(self, host, port, called_with): self.assertEqual([host, port, called_with], [self.conn_host, self.conn_port, self.called_with]) def test_usersclient_info(self): uc = usersclient.UsersClient('localhost', "user", "userpw", 1234) d = uc.send('update', 'bb_user', 'hashed_bb_pass', None, [{'identifier':'x', 'svn':'x'}]) def check(_): self.assertProcess('localhost', 1234, dict(op='update', bb_username='bb_user', bb_password='hashed_bb_pass', ids=None, info=[dict(identifier='x', svn='x')])) d.addCallback(check) return d def test_usersclient_ids(self): uc = usersclient.UsersClient('localhost', "user", "userpw", 1234) d = uc.send('remove', None, None, ['x'], None) def check(_): self.assertProcess('localhost', 1234, dict(op='remove', bb_username=None, bb_password=None, ids=['x'], info=None)) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_config.py000066400000000000000000001241521222546025000217560ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import re import os import textwrap import mock import __builtin__ from zope.interface import implements from twisted.trial import unittest from twisted.application import service from twisted.internet import defer from buildbot import config, buildslave, interfaces, revlinks, locks from buildbot.process import properties, factory from buildbot.test.util import dirs, compat from buildbot.test.util.config import ConfigErrorsMixin from buildbot.changes import base as changes_base from buildbot.schedulers import base as schedulers_base from buildbot.status import base as status_base global_defaults = dict( title='Buildbot', titleURL='http://buildbot.net', buildbotURL='http://localhost:8080/', changeHorizon=None, eventHorizon=50, logHorizon=None, buildHorizon=None, logCompressionLimit=4096, logCompressionMethod='bz2', logMaxTailSize=None, logMaxSize=None, properties=properties.Properties(), mergeRequests=None, prioritizeBuilders=None, slavePortnum=None, multiMaster=False, debugPassword=None, manhole=None, ) class FakeChangeSource(changes_base.ChangeSource): pass class FakeStatusReceiver(status_base.StatusReceiver): pass class FakeScheduler(object): implements(interfaces.IScheduler) def __init__(self, name): self.name = name class FakeBuilder(object): def __init__(self, **kwargs): self.__dict__.update(kwargs) class ConfigErrors(unittest.TestCase): def test_constr(self): ex = config.ConfigErrors(['a', 'b']) self.assertEqual(ex.errors, ['a', 'b']) def test_addError(self): ex = config.ConfigErrors(['a']) ex.addError('c') self.assertEqual(ex.errors, ['a', 'c']) def test_nonempty(self): empty = config.ConfigErrors() full = config.ConfigErrors(['a']) self.failUnless(not empty) self.failIf(not full) def test_error_raises(self): e = self.assertRaises(config.ConfigErrors, config.error, "message") self.assertEqual(e.errors, ["message"]) def test_error_no_raise(self): e = config.ConfigErrors() self.patch(config, "_errors", e) config.error("message") self.assertEqual(e.errors, ["message"]) def test_str(self): ex = config.ConfigErrors() self.assertEqual(str(ex), "") ex = config.ConfigErrors(["a"]) self.assertEqual(str(ex), "a") ex = config.ConfigErrors(["a", "b"]) self.assertEqual(str(ex), "a\nb") ex = config.ConfigErrors(["a"]) ex.addError('c') self.assertEqual(str(ex), "a\nc") class MasterConfig(ConfigErrorsMixin, dirs.DirsMixin, unittest.TestCase): def setUp(self): self.basedir = os.path.abspath('basedir') self.filename = os.path.join(self.basedir, 'test.cfg') return self.setUpDirs('basedir') def tearDown(self): return self.tearDownDirs() # utils def patch_load_helpers(self): # patch out all of the "helpers" for laodConfig with null functions for n in dir(config.MasterConfig): if n.startswith('load_'): typ = 'loader' elif n.startswith('check_'): typ = 'checker' else: continue v = getattr(config.MasterConfig, n) if callable(v): if typ == 'loader': self.patch(config.MasterConfig, n, mock.Mock(side_effect= lambda filename, config_dict: None)) else: self.patch(config.MasterConfig, n, mock.Mock(side_effect= lambda: None)) def install_config_file(self, config_file, other_files={}): config_file = textwrap.dedent(config_file) with open(os.path.join(self.basedir, self.filename), "w") as f: f.write(config_file) for file, contents in other_files.items(): with open(file, "w") as f: f.write(contents) # tests def test_defaults(self): cfg = config.MasterConfig() expected = dict( #validation, db=dict( db_url='sqlite:///state.sqlite', db_poll_interval=None), metrics = None, caches = dict(Changes=10, Builds=15), schedulers = {}, builders = [], slaves = [], change_sources = [], status = [], user_managers = [], revlink = revlinks.default_revlink_matcher ) expected.update(global_defaults) got = dict([ (attr, getattr(cfg, attr)) for attr, exp in expected.iteritems() ]) self.assertEqual(got, expected) def test_defaults_validation(self): # re's aren't comparable, but we can make sure the keys match cfg = config.MasterConfig() self.assertEqual(sorted(cfg.validation.keys()), sorted([ 'branch', 'revision', 'property_name', 'property_value', ])) def test_loadConfig_missing_file(self): self.assertRaisesConfigError( re.compile("configuration file .* does not exist"), lambda : config.MasterConfig.loadConfig( self.basedir, self.filename)) def test_loadConfig_missing_basedir(self): self.assertRaisesConfigError( re.compile("basedir .* does not exist"), lambda : config.MasterConfig.loadConfig( os.path.join(self.basedir, 'NO'), 'test.cfg')) def test_loadConfig_open_error(self): """ Check that loadConfig() raises correct ConfigError exception in cases when configure file is found, but we fail to open it. """ def raise_IOError(*args): raise IOError("error_msg") self.install_config_file('#dummy') # override build-in open() function to always rise IOError self.patch(__builtin__, "open", raise_IOError) # check that we got the expected ConfigError exception self.assertRaisesConfigError( re.compile("unable to open configuration file .*: error_msg"), lambda : config.MasterConfig.loadConfig( self.basedir, self.filename)) @compat.usesFlushLoggedErrors def test_loadConfig_parse_error(self): self.install_config_file('def x:\nbar') self.assertRaisesConfigError( re.compile("error while parsing.*traceback in logfile"), lambda : config.MasterConfig.loadConfig( self.basedir, self.filename)) self.assertEqual(len(self.flushLoggedErrors(SyntaxError)), 1) def test_loadConfig_eval_ConfigError(self): self.install_config_file("""\ from buildbot import config BuildmasterConfig = { 'multiMaster': True } config.error('oh noes!')""") self.assertRaisesConfigError("oh noes", lambda : config.MasterConfig.loadConfig( self.basedir, self.filename)) def test_loadConfig_eval_ConfigErrors(self): # We test a config that has embedded errors, as well # as semantic errors that get added later. If an exception is raised # prematurely, then the semantic errors wouldn't get reported. self.install_config_file("""\ from buildbot import config BuildmasterConfig = {} config.error('oh noes!') config.error('noes too!')""") e = self.assertRaises(config.ConfigErrors, lambda : config.MasterConfig.loadConfig( self.basedir, self.filename)) self.assertEqual(e.errors, ['oh noes!', 'noes too!', 'no slaves are configured', 'no builders are configured']) def test_loadConfig_no_BuildmasterConfig(self): self.install_config_file('x=10') self.assertRaisesConfigError("does not define 'BuildmasterConfig'", lambda : config.MasterConfig.loadConfig( self.basedir, self.filename)) def test_loadConfig_unknown_key(self): self.patch_load_helpers() self.install_config_file("""\ BuildmasterConfig = dict(foo=10) """) self.assertRaisesConfigError("Unknown BuildmasterConfig key foo", lambda : config.MasterConfig.loadConfig( self.basedir, self.filename)) def test_loadConfig_unknown_keys(self): self.patch_load_helpers() self.install_config_file("""\ BuildmasterConfig = dict(foo=10, bar=20) """) self.assertRaisesConfigError("Unknown BuildmasterConfig keys bar, foo", lambda : config.MasterConfig.loadConfig( self.basedir, self.filename)) def test_loadConfig_success(self): self.patch_load_helpers() self.install_config_file("""\ BuildmasterConfig = dict() """) rv = config.MasterConfig.loadConfig( self.basedir, self.filename) self.assertIsInstance(rv, config.MasterConfig) # make sure all of the loaders and checkers are called self.failUnless(rv.load_global.called) self.failUnless(rv.load_validation.called) self.failUnless(rv.load_db.called) self.failUnless(rv.load_metrics.called) self.failUnless(rv.load_caches.called) self.failUnless(rv.load_schedulers.called) self.failUnless(rv.load_builders.called) self.failUnless(rv.load_slaves.called) self.failUnless(rv.load_change_sources.called) self.failUnless(rv.load_status.called) self.failUnless(rv.load_user_managers.called) self.failUnless(rv.check_single_master.called) self.failUnless(rv.check_schedulers.called) self.failUnless(rv.check_locks.called) self.failUnless(rv.check_builders.called) self.failUnless(rv.check_status.called) self.failUnless(rv.check_horizons.called) self.failUnless(rv.check_slavePortnum.called) def test_loadConfig_with_local_import(self): self.patch_load_helpers() self.install_config_file("""\ from subsidiary_module import x BuildmasterConfig = dict() """, {'basedir/subsidiary_module.py' : "x = 10"}) rv = config.MasterConfig.loadConfig( self.basedir, self.filename) self.assertIsInstance(rv, config.MasterConfig) class MasterConfig_loaders(ConfigErrorsMixin, unittest.TestCase): filename = 'test.cfg' def setUp(self): self.cfg = config.MasterConfig() self.errors = config.ConfigErrors() self.patch(config, '_errors', self.errors) # utils def assertResults(self, **expected): self.failIf(self.errors, self.errors.errors) got = dict([ (attr, getattr(self.cfg, attr)) for attr, exp in expected.iteritems() ]) self.assertEqual(got, expected) # tests def test_load_global_defaults(self): self.cfg.load_global(self.filename, {}) self.assertResults(**global_defaults) def test_load_global_string_param_not_string(self): self.cfg.load_global(self.filename, dict(title=10)) self.assertConfigError(self.errors, 'must be a string') def test_load_global_int_param_not_int(self): self.cfg.load_global(self.filename, dict(changeHorizon='yes')) self.assertConfigError(self.errors, 'must be an int') def do_test_load_global(self, config_dict, **expected): self.cfg.load_global(self.filename, config_dict) self.assertResults(**expected) def test_load_global_title(self): self.do_test_load_global(dict(title='hi'), title='hi') def test_load_global_projectURL(self): self.do_test_load_global(dict(projectName='hey'), title='hey') def test_load_global_titleURL(self): self.do_test_load_global(dict(titleURL='hi'), titleURL='hi') def test_load_global_buildbotURL(self): self.do_test_load_global(dict(buildbotURL='hey'), buildbotURL='hey') def test_load_global_changeHorizon(self): self.do_test_load_global(dict(changeHorizon=10), changeHorizon=10) def test_load_global_changeHorizon_none(self): self.do_test_load_global(dict(changeHorizon=None), changeHorizon=None) def test_load_global_eventHorizon(self): self.do_test_load_global(dict(eventHorizon=10), eventHorizon=10) def test_load_global_logHorizon(self): self.do_test_load_global(dict(logHorizon=10), logHorizon=10) def test_load_global_buildHorizon(self): self.do_test_load_global(dict(buildHorizon=10), buildHorizon=10) def test_load_global_logCompressionLimit(self): self.do_test_load_global(dict(logCompressionLimit=10), logCompressionLimit=10) def test_load_global_logCompressionMethod(self): self.do_test_load_global(dict(logCompressionMethod='gz'), logCompressionMethod='gz') def test_load_global_logCompressionMethod_invalid(self): self.cfg.load_global(self.filename, dict(logCompressionMethod='foo')) self.assertConfigError(self.errors, "must be 'bz2' or 'gz'") def test_load_global_codebaseGenerator(self): func = lambda _: "dummy" self.do_test_load_global(dict(codebaseGenerator=func), codebaseGenerator=func) def test_load_global_codebaseGenerator_invalid(self): self.cfg.load_global(self.filename, dict(codebaseGenerator='dummy')) self.assertConfigError(self.errors, "codebaseGenerator must be a callable " "accepting a dict and returning a str") def test_load_global_logMaxSize(self): self.do_test_load_global(dict(logMaxSize=123), logMaxSize=123) def test_load_global_logMaxTailSize(self): self.do_test_load_global(dict(logMaxTailSize=123), logMaxTailSize=123) def test_load_global_properties(self): exp = properties.Properties() exp.setProperty('x', 10, self.filename) self.do_test_load_global(dict(properties=dict(x=10)), properties=exp) def test_load_global_properties_invalid(self): self.cfg.load_global(self.filename, dict(properties='yes')) self.assertConfigError(self.errors, "must be a dictionary") def test_load_global_mergeRequests_bool(self): self.do_test_load_global(dict(mergeRequests=False), mergeRequests=False) def test_load_global_mergeRequests_callable(self): callable = lambda : None self.do_test_load_global(dict(mergeRequests=callable), mergeRequests=callable) def test_load_global_mergeRequests_invalid(self): self.cfg.load_global(self.filename, dict(mergeRequests='yes')) self.assertConfigError(self.errors, "must be a callable, True, or False") def test_load_global_prioritizeBuilders_callable(self): callable = lambda : None self.do_test_load_global(dict(prioritizeBuilders=callable), prioritizeBuilders=callable) def test_load_global_prioritizeBuilders_invalid(self): self.cfg.load_global(self.filename, dict(prioritizeBuilders='yes')) self.assertConfigError(self.errors, "must be a callable") def test_load_global_slavePortnum_int(self): self.do_test_load_global(dict(slavePortnum=123), slavePortnum='tcp:123') def test_load_global_slavePortnum_str(self): self.do_test_load_global(dict(slavePortnum='udp:123'), slavePortnum='udp:123') def test_load_global_multiMaster(self): self.do_test_load_global(dict(multiMaster=1), multiMaster=1) def test_load_global_debugPassword(self): self.do_test_load_global(dict(debugPassword='xyz'), debugPassword='xyz') def test_load_global_manhole(self): mh = mock.Mock(name='manhole') self.do_test_load_global(dict(manhole=mh), manhole=mh) def test_load_global_revlink_callable(self): callable = lambda : None self.do_test_load_global(dict(revlink=callable), revlink=callable) def test_load_global_revlink_invalid(self): self.cfg.load_global(self.filename, dict(revlink='')) self.assertConfigError(self.errors, "must be a callable") def test_load_validation_defaults(self): self.cfg.load_validation(self.filename, {}) self.assertEqual(sorted(self.cfg.validation.keys()), sorted([ 'branch', 'revision', 'property_name', 'property_value', ])) def test_load_validation_invalid(self): self.cfg.load_validation(self.filename, dict(validation='plz')) self.assertConfigError(self.errors, "must be a dictionary") def test_load_validation_unk_keys(self): self.cfg.load_validation(self.filename, dict(validation=dict(users='.*'))) self.assertConfigError(self.errors, "unrecognized validation key(s)") def test_load_validation(self): r = re.compile('.*') self.cfg.load_validation(self.filename, dict(validation=dict(branch=r))) self.assertEqual(self.cfg.validation['branch'], r) # check that defaults are still around self.assertIn('revision', self.cfg.validation) def test_load_db_defaults(self): self.cfg.load_db(self.filename, {}) self.assertResults( db=dict(db_url='sqlite:///state.sqlite', db_poll_interval=None)) def test_load_db_db_url(self): self.cfg.load_db(self.filename, dict(db_url='abcd')) self.assertResults(db=dict(db_url='abcd', db_poll_interval=None)) def test_load_db_db_poll_interval(self): self.cfg.load_db(self.filename, dict(db_poll_interval=2)) self.assertResults( db=dict(db_url='sqlite:///state.sqlite', db_poll_interval=2)) def test_load_db_dict(self): self.cfg.load_db(self.filename, dict(db=dict(db_url='abcd', db_poll_interval=10))) self.assertResults(db=dict(db_url='abcd', db_poll_interval=10)) def test_load_db_unk_keys(self): self.cfg.load_db(self.filename, dict(db=dict(db_url='abcd', db_poll_interval=10, bar='bar'))) self.assertConfigError(self.errors, "unrecognized keys in") def test_load_db_not_int(self): self.cfg.load_db(self.filename, dict(db=dict(db_url='abcd', db_poll_interval='ten'))) self.assertConfigError(self.errors, "must be an int") def test_load_metrics_defaults(self): self.cfg.load_metrics(self.filename, {}) self.assertResults(metrics=None) def test_load_metrics_invalid(self): self.cfg.load_metrics(self.filename, dict(metrics=13)) self.assertConfigError(self.errors, "must be a dictionary") def test_load_metrics(self): self.cfg.load_metrics(self.filename, dict(metrics=dict(foo=1))) self.assertResults(metrics=dict(foo=1)) def test_load_caches_defaults(self): self.cfg.load_caches(self.filename, {}) self.assertResults(caches=dict(Changes=10, Builds=15)) def test_load_caches_invalid(self): self.cfg.load_caches(self.filename, dict(caches=13)) self.assertConfigError(self.errors, "must be a dictionary") def test_load_caches_buildCacheSize(self): self.cfg.load_caches(self.filename, dict(buildCacheSize=13)) self.assertResults(caches=dict(Builds=13, Changes=10)) def test_load_caches_buildCacheSize_and_caches(self): self.cfg.load_caches(self.filename, dict(buildCacheSize=13, caches=dict(builds=11))) self.assertConfigError(self.errors, "cannot specify") def test_load_caches_changeCacheSize(self): self.cfg.load_caches(self.filename, dict(changeCacheSize=13)) self.assertResults(caches=dict(Changes=13, Builds=15)) def test_load_caches_changeCacheSize_and_caches(self): self.cfg.load_caches(self.filename, dict(changeCacheSize=13, caches=dict(changes=11))) self.assertConfigError(self.errors, "cannot specify") def test_load_caches(self): self.cfg.load_caches(self.filename, dict(caches=dict(foo=1))) self.assertResults(caches=dict(Changes=10, Builds=15, foo=1)) def test_load_caches_entries_test(self): self.cfg.load_caches(self.filename, dict(caches=dict(foo="1"))) self.assertConfigError(self.errors, "value for cache size 'foo' must be an integer") def test_load_schedulers_defaults(self): self.cfg.load_schedulers(self.filename, {}) self.assertResults(schedulers={}) def test_load_schedulers_not_list(self): self.cfg.load_schedulers(self.filename, dict(schedulers=dict())) self.assertConfigError(self.errors, "must be a list of") def test_load_schedulers_not_instance(self): self.cfg.load_schedulers(self.filename, dict(schedulers=[mock.Mock()])) self.assertConfigError(self.errors, "must be a list of") def test_load_schedulers_dupe(self): sch1 = FakeScheduler(name='sch') sch2 = FakeScheduler(name='sch') self.cfg.load_schedulers(self.filename, dict(schedulers=[ sch1, sch2 ])) self.assertConfigError(self.errors, "scheduler name 'sch' used multiple times") def test_load_schedulers(self): class Sch(schedulers_base.BaseScheduler): def __init__(self, name): self.name = name sch = Sch('sch') self.cfg.load_schedulers(self.filename, dict(schedulers=[sch])) self.assertResults(schedulers=dict(sch=sch)) def test_load_builders_defaults(self): self.cfg.load_builders(self.filename, {}) self.assertResults(builders=[]) def test_load_builders_not_list(self): self.cfg.load_builders(self.filename, dict(builders=dict())) self.assertConfigError(self.errors, "must be a list") def test_load_builders_not_instance(self): self.cfg.load_builders(self.filename, dict(builders=[mock.Mock()])) self.assertConfigError(self.errors, "is not a builder config (in c['builders']") def test_load_builders(self): bldr = config.BuilderConfig(name='x', factory=factory.BuildFactory(), slavename='x') self.cfg.load_builders(self.filename, dict(builders=[bldr])) self.assertResults(builders=[bldr]) def test_load_builders_dict(self): bldr = dict(name='x', factory=factory.BuildFactory(), slavename='x') self.cfg.load_builders(self.filename, dict(builders=[bldr])) self.assertIsInstance(self.cfg.builders[0], config.BuilderConfig) self.assertEqual(self.cfg.builders[0].name, 'x') @compat.usesFlushWarnings def test_load_builders_abs_builddir(self): bldr = dict(name='x', factory=factory.BuildFactory(), slavename='x', builddir=os.path.abspath('.')) self.cfg.load_builders(self.filename, dict(builders=[bldr])) self.assertEqual( len(self.flushWarnings([self.cfg.load_builders])), 1) def test_load_slaves_defaults(self): self.cfg.load_slaves(self.filename, {}) self.assertResults(slaves=[]) def test_load_slaves_not_list(self): self.cfg.load_slaves(self.filename, dict(slaves=dict())) self.assertConfigError(self.errors, "must be a list") def test_load_slaves_not_instance(self): self.cfg.load_slaves(self.filename, dict(slaves=[mock.Mock()])) self.assertConfigError(self.errors, "must be a list of") def test_load_slaves_reserved_names(self): for name in 'debug', 'change', 'status': self.cfg.load_slaves(self.filename, dict(slaves=[buildslave.BuildSlave(name, 'x')])) self.assertConfigError(self.errors, "is reserved") self.errors.errors[:] = [] # clear out the errors def test_load_slaves(self): sl = buildslave.BuildSlave('foo', 'x') self.cfg.load_slaves(self.filename, dict(slaves=[sl])) self.assertResults(slaves=[sl]) def test_load_change_sources_defaults(self): self.cfg.load_change_sources(self.filename, {}) self.assertResults(change_sources=[]) def test_load_change_sources_not_instance(self): self.cfg.load_change_sources(self.filename, dict(change_source=[mock.Mock()])) self.assertConfigError(self.errors, "must be a list of") def test_load_change_sources_single(self): chsrc = FakeChangeSource() self.cfg.load_change_sources(self.filename, dict(change_source=chsrc)) self.assertResults(change_sources=[chsrc]) def test_load_change_sources_list(self): chsrc = FakeChangeSource() self.cfg.load_change_sources(self.filename, dict(change_source=[chsrc])) self.assertResults(change_sources=[chsrc]) def test_load_status_not_list(self): self.cfg.load_status(self.filename, dict(status="not-list")) self.assertConfigError(self.errors, "must be a list of") def test_load_status_not_status_rec(self): self.cfg.load_status(self.filename, dict(status=['fo'])) self.assertConfigError(self.errors, "must be a list of") def test_load_user_managers_defaults(self): self.cfg.load_user_managers(self.filename, {}) self.assertResults(user_managers=[]) def test_load_user_managers_not_list(self): self.cfg.load_user_managers(self.filename, dict(user_managers='foo')) self.assertConfigError(self.errors, "must be a list") def test_load_user_managers(self): um = mock.Mock() self.cfg.load_user_managers(self.filename, dict(user_managers=[um])) self.assertResults(user_managers=[um]) class MasterConfig_checkers(ConfigErrorsMixin, unittest.TestCase): def setUp(self): self.cfg = config.MasterConfig() self.errors = config.ConfigErrors() self.patch(config, '_errors', self.errors) # utils def setup_basic_attrs(self): # set up a basic config for checking; this will be modified below sch = mock.Mock() sch.name = 'sch' sch.listBuilderNames = lambda : [ 'b1', 'b2' ] b1 = mock.Mock() b1.name = 'b1' b2 = mock.Mock() b2.name = 'b2' self.cfg.schedulers = dict(sch=sch) self.cfg.slaves = [ mock.Mock() ] self.cfg.builders = [ b1, b2 ] def setup_builder_locks(self, builder_lock=None, dup_builder_lock=False, bare_builder_lock=False): """Set-up two mocked builders with specified locks. @type builder_lock: string or None @param builder_lock: Name of the lock to add to first builder. If None, no lock is added. @type dup_builder_lock: boolean @param dup_builder_lock: if True, add a lock with duplicate name to the second builder @type dup_builder_lock: boolean @param bare_builder_lock: if True, add bare lock objects, don't wrap them into locks.LockAccess object """ def bldr(name): b = mock.Mock() b.name = name b.locks = [] b.factory.steps = [ ('cls', (), dict(locks=[])) ] return b def lock(name): l = mock.Mock(spec=locks.MasterLock) l.name = name if bare_builder_lock: return l return locks.LockAccess(l, "counting", _skipChecks=True) b1, b2 = bldr('b1'), bldr('b2') self.cfg.builders = [ b1, b2 ] if builder_lock: b1.locks.append(lock(builder_lock)) if dup_builder_lock: b2.locks.append(lock(builder_lock)) # tests def test_check_single_master_multimaster(self): self.cfg.multiMaster = True self.cfg.check_single_master() self.assertNoConfigErrors(self.errors) def test_check_single_master_no_builders(self): self.setup_basic_attrs() self.cfg.builders = [ ] self.cfg.check_single_master() self.assertConfigError(self.errors, "no builders are configured") def test_check_single_master_no_slaves(self): self.setup_basic_attrs() self.cfg.slaves = [ ] self.cfg.check_single_master() self.assertConfigError(self.errors, "no slaves are configured") def test_check_single_master_unsch_builder(self): self.setup_basic_attrs() b3 = mock.Mock() b3.name = 'b3' self.cfg.builders.append(b3) self.cfg.check_single_master() self.assertConfigError(self.errors, "have no schedulers to drive them") def test_check_schedulers_unknown_builder(self): self.setup_basic_attrs() del self.cfg.builders[1] # remove b2, leaving b1 self.cfg.check_schedulers() self.assertConfigError(self.errors, "Unknown builder 'b2'") def test_check_schedulers_ignored_in_multiMaster(self): self.setup_basic_attrs() del self.cfg.builders[1] # remove b2, leaving b1 self.cfg.multiMaster = True self.cfg.check_schedulers() self.assertNoConfigErrors(self.errors) def test_check_schedulers(self): self.setup_basic_attrs() self.cfg.check_schedulers() self.assertNoConfigErrors(self.errors) def test_check_locks_dup_builder_lock(self): self.setup_builder_locks(builder_lock='l', dup_builder_lock=True) self.cfg.check_locks() self.assertConfigError(self.errors, "Two locks share") def test_check_locks(self): self.setup_builder_locks(builder_lock='bl') self.cfg.check_locks() self.assertNoConfigErrors(self.errors) def test_check_locks_none(self): # no locks in the whole config, should be fine self.setup_builder_locks() self.cfg.check_locks() self.assertNoConfigErrors(self.errors) def test_check_locks_bare(self): # check_locks() should be able to handle bare lock object, # lock objects that are not wrapped into LockAccess() object self.setup_builder_locks(builder_lock='oldlock', bare_builder_lock=True) self.cfg.check_locks() self.assertNoConfigErrors(self.errors) def test_check_builders_unknown_slave(self): sl = mock.Mock() sl.slavename = 'xyz' self.cfg.slaves = [ sl ] b1 = FakeBuilder(slavenames=[ 'xyz', 'abc' ], builddir='x', name='b1') self.cfg.builders = [ b1 ] self.cfg.check_builders() self.assertConfigError(self.errors, "builder 'b1' uses unknown slaves 'abc'") def test_check_builders_duplicate_name(self): b1 = FakeBuilder(slavenames=[], name='b1', builddir='1') b2 = FakeBuilder(slavenames=[], name='b1', builddir='2') self.cfg.builders = [ b1, b2 ] self.cfg.check_builders() self.assertConfigError(self.errors, "duplicate builder name 'b1'") def test_check_builders_duplicate_builddir(self): b1 = FakeBuilder(slavenames=[], name='b1', builddir='dir') b2 = FakeBuilder(slavenames=[], name='b2', builddir='dir') self.cfg.builders = [ b1, b2 ] self.cfg.check_builders() self.assertConfigError(self.errors, "duplicate builder builddir 'dir'") def test_check_builders(self): sl = mock.Mock() sl.slavename = 'a' self.cfg.slaves = [ sl ] b1 = FakeBuilder(slavenames=[ 'a' ], name='b1', builddir='dir1') b2 = FakeBuilder(slavenames=[ 'a' ], name='b2', builddir='dir2') self.cfg.builders = [ b1, b2 ] self.cfg.check_builders() self.assertNoConfigErrors(self.errors) def test_check_status_fails(self): st = FakeStatusReceiver() st.checkConfig = lambda status: config.error("oh noes") self.cfg.status = [ st ] self.cfg.check_status() self.assertConfigError(self.errors, "oh noes") def test_check_status(self): st = FakeStatusReceiver() st.checkConfig = mock.Mock() self.cfg.status = [ st ] self.cfg.check_status() self.assertNoConfigErrors(self.errors) st.checkConfig.assert_called_once_with(self.cfg.status) def test_check_horizons(self): self.cfg.logHorizon = 100 self.cfg.buildHorizon = 50 self.cfg.check_horizons() self.assertConfigError(self.errors, "logHorizon must be less") def test_check_slavePortnum_set(self): self.cfg.slavePortnum = 10 self.cfg.check_slavePortnum() self.assertNoConfigErrors(self.errors) def test_check_slavePortnum_not_set_slaves(self): self.cfg.slaves = [ mock.Mock() ] self.cfg.check_slavePortnum() self.assertConfigError(self.errors, "slaves are configured, but no slavePortnum is set") def test_check_slavePortnum_not_set_debug(self): self.cfg.debugPassword = 'ssh' self.cfg.check_slavePortnum() self.assertConfigError(self.errors, "debug client is configured, but no slavePortnum is set") class BuilderConfig(ConfigErrorsMixin, unittest.TestCase): factory = factory.BuildFactory() # utils def assertAttributes(self, cfg, **expected): got = dict([ (attr, getattr(cfg, attr)) for attr, exp in expected.iteritems() ]) self.assertEqual(got, expected) # tests def test_no_name(self): self.assertRaisesConfigError( "builder's name is required", lambda : config.BuilderConfig( factory=self.factory, slavenames=['a'])) def test_reserved_name(self): self.assertRaisesConfigError( "builder names must not start with an underscore: '_a'", lambda : config.BuilderConfig(name='_a', factory=self.factory, slavenames=['a'])) def test_no_factory(self): self.assertRaisesConfigError( "builder 'a' has no factory", lambda : config.BuilderConfig( name='a', slavenames=['a'])) def test_wrong_type_factory(self): self.assertRaisesConfigError( "builder 'a's factory is not", lambda : config.BuilderConfig( factory=[], name='a', slavenames=['a'])) def test_no_slavenames(self): self.assertRaisesConfigError( "builder 'a': at least one slavename is required", lambda : config.BuilderConfig( name='a', factory=self.factory)) def test_bogus_slavenames(self): self.assertRaisesConfigError( "slavenames must be a list or a string", lambda : config.BuilderConfig( name='a', slavenames={1:2}, factory=self.factory)) def test_bogus_slavename(self): self.assertRaisesConfigError( "slavename must be a string", lambda : config.BuilderConfig( name='a', slavename=1, factory=self.factory)) def test_bogus_category(self): self.assertRaisesConfigError( "category must be a string", lambda : config.BuilderConfig(category=13, name='a', slavenames=['a'], factory=self.factory)) def test_inv_nextSlave(self): self.assertRaisesConfigError( "nextSlave must be a callable", lambda : config.BuilderConfig(nextSlave="foo", name="a", slavenames=['a'], factory=self.factory)) def test_inv_nextBuild(self): self.assertRaisesConfigError( "nextBuild must be a callable", lambda : config.BuilderConfig(nextBuild="foo", name="a", slavenames=['a'], factory=self.factory)) def test_inv_canStartBuild(self): self.assertRaisesConfigError( "canStartBuild must be a callable", lambda : config.BuilderConfig(canStartBuild="foo", name="a", slavenames=['a'], factory=self.factory)) def test_inv_env(self): self.assertRaisesConfigError( "builder's env must be a dictionary", lambda : config.BuilderConfig(env="foo", name="a", slavenames=['a'], factory=self.factory)) def test_defaults(self): cfg = config.BuilderConfig( name='a b c', slavename='a', factory=self.factory) self.assertIdentical(cfg.factory, self.factory) self.assertAttributes(cfg, name='a b c', slavenames=['a'], builddir='a_b_c', slavebuilddir='a_b_c', category='', nextSlave=None, locks=[], env={}, properties={}, mergeRequests=None, description=None) def test_args(self): cfg = config.BuilderConfig( name='b', slavename='s1', slavenames='s2', builddir='bd', slavebuilddir='sbd', factory=self.factory, category='c', nextSlave=lambda : 'ns', nextBuild=lambda : 'nb', locks=['l'], env=dict(x=10), properties=dict(y=20), mergeRequests='mr', description='buzz') self.assertIdentical(cfg.factory, self.factory) self.assertAttributes(cfg, name='b', slavenames=['s2', 's1'], builddir='bd', slavebuilddir='sbd', category='c', locks=['l'], env={'x':10}, properties={'y':20}, mergeRequests='mr', description='buzz') def test_getConfigDict(self): ns = lambda : 'ns' nb = lambda : 'nb' cfg = config.BuilderConfig( name='b', slavename='s1', slavenames='s2', builddir='bd', slavebuilddir='sbd', factory=self.factory, category='c', nextSlave=ns, nextBuild=nb, locks=['l'], env=dict(x=10), properties=dict(y=20), mergeRequests='mr', description='buzz') self.assertEqual(cfg.getConfigDict(), {'builddir': 'bd', 'category': 'c', 'description': 'buzz', 'env': {'x': 10}, 'factory': self.factory, 'locks': ['l'], 'mergeRequests': 'mr', 'name': 'b', 'nextBuild': nb, 'nextSlave': ns, 'properties': {'y': 20}, 'slavebuilddir': 'sbd', 'slavenames': ['s2', 's1'], }) class FakeService(config.ReconfigurableServiceMixin, service.Service): succeed = True call_index = 1 def reconfigService(self, new_config): self.called = FakeService.call_index FakeService.call_index += 1 d = config.ReconfigurableServiceMixin.reconfigService(self, new_config) if not self.succeed: @d.addCallback def fail(_): raise ValueError("oh noes") return d class FakeMultiService(config.ReconfigurableServiceMixin, service.MultiService): def reconfigService(self, new_config): self.called = True d = config.ReconfigurableServiceMixin.reconfigService(self, new_config) return d class ReconfigurableServiceMixin(unittest.TestCase): def test_service(self): svc = FakeService() d = svc.reconfigService(mock.Mock()) @d.addCallback def check(_): self.assertTrue(svc.called) return d @defer.inlineCallbacks def test_service_failure(self): svc = FakeService() svc.succeed = False try: yield svc.reconfigService(mock.Mock()) except ValueError: pass else: self.fail("should have raised ValueError") def test_multiservice(self): svc = FakeMultiService() ch1 = FakeService() ch1.setServiceParent(svc) ch2 = FakeMultiService() ch2.setServiceParent(svc) ch3 = FakeService() ch3.setServiceParent(ch2) d = svc.reconfigService(mock.Mock()) @d.addCallback def check(_): self.assertTrue(svc.called) self.assertTrue(ch1.called) self.assertTrue(ch2.called) self.assertTrue(ch3.called) return d def test_multiservice_priority(self): parent = FakeMultiService() svc128 = FakeService() svc128.setServiceParent(parent) services = [ svc128 ] for i in range(20, 1, -1): svc = FakeService() svc.reconfig_priority = i svc.setServiceParent(parent) services.append(svc) d = parent.reconfigService(mock.Mock()) @d.addCallback def check(_): prio_order = [ svc.called for svc in services ] called_order = sorted(prio_order) self.assertEqual(prio_order, called_order) return d @compat.usesFlushLoggedErrors @defer.inlineCallbacks def test_multiservice_nested_failure(self): svc = FakeMultiService() ch1 = FakeService() ch1.setServiceParent(svc) ch1.succeed = False try: yield svc.reconfigService(mock.Mock()) except ValueError: pass else: self.fail("should have raised ValueError") buildbot-0.8.8/buildbot/test/unit/test_contrib_buildbot_cvs_mail.py000066400000000000000000000161671222546025000257200ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sys import re import os from twisted.python import log from twisted.trial import unittest from twisted.internet import protocol, defer, utils, reactor test = ''' Update of /cvsroot/test In directory example:/tmp/cvs-serv21085 Modified Files: README hello.c Log Message: two files checkin ''' golden_1_11_regex=[ '^From:', '^To: buildbot@example.com$', '^Reply-To: noreply@example.com$', '^Subject: cvs update for project test$', '^Date:', '^X-Mailer: Python buildbot-cvs-mail', '^$', '^Cvsmode: 1.11$', '^Category: None', '^CVSROOT: \"ext:example:/cvsroot\"', '^Files: test README 1.1,1.2 hello.c 2.2,2.3$', '^Project: test$', '^$', '^Update of /cvsroot/test$', '^In directory example:/tmp/cvs-serv21085$', '^$', '^Modified Files:$', 'README hello.c$', 'Log Message:$', '^two files checkin', '^$', '^$'] golden_1_12_regex=[ '^From: ', '^To: buildbot@example.com$', '^Reply-To: noreply@example.com$', '^Subject: cvs update for project test$', '^Date: ', '^X-Mailer: Python buildbot-cvs-mail', '^$', '^Cvsmode: 1.12$', '^Category: None$', '^CVSROOT: \"ext:example.com:/cvsroot\"$', '^Files: README 1.1 1.2 hello.c 2.2 2.3$', '^Path: test$', '^Project: test$', '^$', '^Update of /cvsroot/test$', '^In directory example:/tmp/cvs-serv21085$', '^$', '^Modified Files:$', 'README hello.c$', '^Log Message:$', 'two files checkin', '^$', '^$' ] class _SubprocessProtocol(protocol.ProcessProtocol): def __init__(self, input, deferred): self.input = input self.deferred = deferred self.output = '' def outReceived(self, s): self.output += s errReceived = outReceived def connectionMade(self): # push the input and send EOF self.transport.write(self.input) self.transport.closeStdin() def processEnded(self, reason): self.deferred.callback((self.output, reason.value.exitCode)) def getProcessOutputAndValueWithInput(executable, args, input): "similar to getProcessOutputAndValue, but also allows injection of input on stdin" d = defer.Deferred() p = _SubprocessProtocol(input, d) reactor.spawnProcess(p, executable, (executable,) + tuple(args)) return d class TestBuildbotCvsMail(unittest.TestCase): buildbot_cvs_mail_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../contrib/buildbot_cvs_mail.py')) if not os.path.exists(buildbot_cvs_mail_path): skip = ("'%s' does not exist (normal unless run from git)" % buildbot_cvs_mail_path) def assertOutputOk(self, (output, code), regexList): "assert that the output from getProcessOutputAndValueWithInput matches expectations" try: self.failUnlessEqual(code, 0, "subprocess exited uncleanly") lines = output.splitlines() self.failUnlessEqual(len(lines), len(regexList), "got wrong number of lines of output") misses = [] for line, regex in zip(lines, regexList): m = re.search(regex, line) if not m: misses.append((regex,line)) self.assertEqual(misses, [], "got non-matching lines") except: log.msg("got output:\n" + output) raise def test_buildbot_cvs_mail_from_cvs1_11(self): # Simulate CVS 1.11 d = getProcessOutputAndValueWithInput(sys.executable, [ self.buildbot_cvs_mail_path, '--cvsroot=\"ext:example:/cvsroot\"', '--email=buildbot@example.com', '-P', 'test', '-R', 'noreply@example.com', '-t', 'test', 'README', '1.1,1.2', 'hello.c', '2.2,2.3' ], input=test) d.addCallback(self.assertOutputOk, golden_1_11_regex) return d def test_buildbot_cvs_mail_from_cvs1_12(self): # Simulate CVS 1.12, with --path option d = getProcessOutputAndValueWithInput(sys.executable, [ self.buildbot_cvs_mail_path, '--cvsroot=\"ext:example.com:/cvsroot\"', '--email=buildbot@example.com', '-P', 'test', '--path', 'test', '-R', 'noreply@example.com', '-t', 'README', '1.1', '1.2', 'hello.c', '2.2', '2.3' ], input=test) d.addCallback(self.assertOutputOk, golden_1_12_regex) return d def test_buildbot_cvs_mail_no_args_exits_with_error(self): d = utils.getProcessOutputAndValue(sys.executable, [ self.buildbot_cvs_mail_path ]) def check((stdout, stderr, code)): self.assertEqual(code, 2) d.addCallback(check) return d def test_buildbot_cvs_mail_without_email_opt_exits_with_error(self): d = utils.getProcessOutputAndValue(sys.executable, [ self.buildbot_cvs_mail_path, '--cvsroot=\"ext:example.com:/cvsroot\"', '-P', 'test', '--path', 'test', '-R', 'noreply@example.com', '-t', 'README', '1.1', '1.2', 'hello.c', '2.2', '2.3']) def check((stdout, stderr, code)): self.assertEqual(code, 2) d.addCallback(check) return d def test_buildbot_cvs_mail_without_cvsroot_opt_exits_with_error(self): d = utils.getProcessOutputAndValue(sys.executable, [ self.buildbot_cvs_mail_path, '--complete-garbage-opt=gomi', '--cvsroot=\"ext:example.com:/cvsroot\"', '--email=buildbot@example.com','-P', 'test', '--path', 'test', '-R', 'noreply@example.com', '-t', 'README', '1.1', '1.2', 'hello.c', '2.2', '2.3']) def check((stdout, stderr, code)): self.assertEqual(code, 2) d.addCallback(check) return d def test_buildbot_cvs_mail_with_unknown_opt_exits_with_error(self): d = utils.getProcessOutputAndValue(sys.executable, [ self.buildbot_cvs_mail_path, '--email=buildbot@example.com','-P', 'test', '--path', 'test', '-R', 'noreply@example.com', '-t', 'README', '1.1', '1.2', 'hello.c', '2.2', '2.3']) def check((stdout, stderr, code)): self.assertEqual(code, 2) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_db_base.py000066400000000000000000000072601222546025000220700ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa import mock from buildbot.db import base from twisted.trial import unittest from twisted.internet import defer class TestBase(unittest.TestCase): def setUp(self): meta = sa.MetaData() self.tbl = sa.Table('tbl', meta, sa.Column('str32', sa.String(length=32)), sa.Column('txt', sa.Text)) self.db = mock.Mock() self.db.pool.engine.dialect.name = 'mysql' self.comp = base.DBConnectorComponent(self.db) def test_check_length_ok(self): self.comp.check_length(self.tbl.c.str32, "short string") def test_check_length_long(self): self.assertRaises(RuntimeError, lambda : self.comp.check_length(self.tbl.c.str32, "long string" * 5)) def test_check_length_text(self): self.assertRaises(AssertionError, lambda : self.comp.check_length(self.tbl.c.txt, "long string" * 5)) def test_check_length_long_not_mysql(self): self.db.pool.engine.dialect.name = 'sqlite' self.comp.check_length(self.tbl.c.str32, "long string" * 5) # run that again since the method gets stubbed out self.comp.check_length(self.tbl.c.str32, "long string" * 5) class TestCachedDecorator(unittest.TestCase): def setUp(self): # set this to True to check that cache.get isn't called (for # no_cache=1) self.cache_get_raises_exception = False class TestConnectorComponent(base.DBConnectorComponent): invocations = None @base.cached("mycache") def getThing(self, key): if self.invocations is None: self.invocations = [] self.invocations.append(key) return defer.succeed(key * 2) def get_cache(self, cache_name, miss_fn): self.assertEqual(cache_name, "mycache") cache = mock.Mock(name="mycache") if self.cache_get_raises_exception: def ex(key): raise RuntimeError("cache.get called unexpectedly") cache.get = ex else: cache.get = miss_fn return cache # tests @defer.inlineCallbacks def test_cached(self): # attach it to the connector connector = mock.Mock(name="connector") connector.master.caches.get_cache = self.get_cache # build an instance comp = self.TestConnectorComponent(connector) # test it twice (to test an implementation detail) res1 = yield comp.getThing("foo") res2 = yield comp.getThing("bar") self.assertEqual((res1, res2, comp.invocations), ('foofoo', 'barbar', ['foo', 'bar'])) @defer.inlineCallbacks def test_cached_no_cache(self): # attach it to the connector connector = mock.Mock(name="connector") connector.master.caches.get_cache = self.get_cache self.cache_get_raises_exception = True # build an instance comp = self.TestConnectorComponent(connector) yield comp.getThing("foo", no_cache=1) buildbot-0.8.8/buildbot/test/unit/test_db_buildrequests.py000066400000000000000000000747051222546025000240610ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import datetime import sqlalchemy as sa from twisted.trial import unittest from twisted.internet import task, defer from buildbot.db import buildrequests from buildbot.test.util import connector_component, db from buildbot.test.fake import fakedb from buildbot.util import UTC, epoch2datetime class TestBuildsetsConnectorComponent( connector_component.ConnectorComponentMixin, unittest.TestCase): # test that the datetime translations are done correctly by specifying # the epoch timestamp and datetime objects explicitly. These should # pass regardless of the local timezone used while running tests! CLAIMED_AT = datetime.datetime(1978, 6, 15, 12, 31, 15, tzinfo=UTC) CLAIMED_AT_EPOCH = 266761875 SUBMITTED_AT = datetime.datetime(1979, 6, 15, 12, 31, 15, tzinfo=UTC) SUBMITTED_AT_EPOCH = 298297875 COMPLETE_AT = datetime.datetime(1980, 6, 15, 12, 31, 15, tzinfo=UTC) COMPLETE_AT_EPOCH = 329920275 BSID = 567 BSID2 = 5670 MASTER_ID = "set in setUp" OTHER_MASTER_ID = "set in setUp" MASTER_NAME = "testmaster" MASTER_INCARN = "pid123-boot456789" def setUp(self): self.MASTER_ID = fakedb.FakeBuildRequestsComponent.MASTER_ID self.OTHER_MASTER_ID = self.MASTER_ID + 1111 d = self.setUpConnectorComponent( table_names=[ 'patches', 'changes', 'sourcestamp_changes', 'buildsets', 'buildset_properties', 'buildrequests', 'objects', 'buildrequest_claims', 'sourcestamps', 'sourcestampsets' ]) def finish_setup(_): self.db.buildrequests = \ buildrequests.BuildRequestsConnectorComponent(self.db) self.db.master.getObjectId = lambda : defer.succeed(self.MASTER_ID) d.addCallback(finish_setup) # set up a sourcestamp and buildset for use below d.addCallback(lambda _ : self.insertTestData([ fakedb.SourceStampSet(id=234), fakedb.SourceStamp(id=234, sourcestampsetid=234), fakedb.Object(id=self.MASTER_ID, name="fake master", class_name="BuildMaster"), fakedb.Object(id=self.OTHER_MASTER_ID, name="other master", class_name="BuildMaster"), fakedb.Buildset(id=self.BSID, sourcestampsetid=234), ])) return d def tearDown(self): return self.tearDownConnectorComponent() # tests def test_getBuildRequest(self): # ned fakedb.BuildRequestClaim d = self.insertTestData([ fakedb.BuildRequest(id=44, buildsetid=self.BSID, buildername="bbb", complete=1, results=75, priority=7, submitted_at=self.SUBMITTED_AT_EPOCH, complete_at=self.COMPLETE_AT_EPOCH), fakedb.BuildRequestClaim( brid=44, objectid=self.MASTER_ID, claimed_at=self.CLAIMED_AT_EPOCH), ]) d.addCallback(lambda _ : self.db.buildrequests.getBuildRequest(44)) def check(brdict): self.assertEqual(brdict, dict(brid=44, buildsetid=self.BSID, buildername="bbb", priority=7, claimed=True, mine=True, complete=True, results=75, claimed_at=self.CLAIMED_AT, submitted_at=self.SUBMITTED_AT, complete_at=self.COMPLETE_AT)) d.addCallback(check) return d def test_getBuildRequest_missing(self): d = self.db.buildrequests.getBuildRequest(44) def check(brdict): self.assertEqual(brdict, None) d.addCallback(check) return d def do_test_getBuildRequests_claim_args(self, **kwargs): expected = kwargs.pop('expected') d = self.insertTestData([ # 50: claimed by this master fakedb.BuildRequest(id=50, buildsetid=self.BSID), fakedb.BuildRequestClaim(brid=50, objectid=self.MASTER_ID, claimed_at=self.CLAIMED_AT_EPOCH), # 51: claimed by another master fakedb.BuildRequest(id=51, buildsetid=self.BSID), fakedb.BuildRequestClaim(brid=51, objectid=self.OTHER_MASTER_ID, claimed_at=self.CLAIMED_AT_EPOCH), # 52: unclaimed fakedb.BuildRequest(id=52, buildsetid=self.BSID), # 53: unclaimed but complete (should not appear for claimed=False) fakedb.BuildRequest(id=53, buildsetid=self.BSID, complete=1), ]) d.addCallback(lambda _ : self.db.buildrequests.getBuildRequests(**kwargs)) def check(brlist): self.assertEqual(sorted([ br['brid'] for br in brlist ]), sorted(expected)) d.addCallback(check) return d def test_getBuildRequests_no_claimed_arg(self): return self.do_test_getBuildRequests_claim_args( expected=[50, 51, 52, 53]) def test_getBuildRequests_claimed_mine(self): return self.do_test_getBuildRequests_claim_args( claimed="mine", expected=[50]) def test_getBuildRequests_claimed_true(self): return self.do_test_getBuildRequests_claim_args( claimed=True, expected=[50, 51]) def test_getBuildRequests_unclaimed(self): return self.do_test_getBuildRequests_claim_args( claimed=False, expected=[52]) def do_test_getBuildRequests_buildername_arg(self, **kwargs): expected = kwargs.pop('expected') d = self.insertTestData([ # 8: 'bb' fakedb.BuildRequest(id=8, buildsetid=self.BSID, buildername='bb'), # 9: 'cc' fakedb.BuildRequest(id=9, buildsetid=self.BSID, buildername='cc'), # 10: 'cc' fakedb.BuildRequest(id=10, buildsetid=self.BSID, buildername='cc'), ]) d.addCallback(lambda _ : self.db.buildrequests.getBuildRequests(**kwargs)) def check(brlist): self.assertEqual(sorted([ br['brid'] for br in brlist ]), sorted(expected)) d.addCallback(check) return d def test_getBuildRequests_buildername_single(self): return self.do_test_getBuildRequests_buildername_arg( buildername='bb', expected=[8]) def test_getBuildRequests_buildername_multiple(self): return self.do_test_getBuildRequests_buildername_arg( buildername='cc', expected=[9,10]) def test_getBuildRequests_buildername_none(self): return self.do_test_getBuildRequests_buildername_arg( buildername='dd', expected=[]) def do_test_getBuildRequests_complete_arg(self, **kwargs): expected = kwargs.pop('expected') d = self.insertTestData([ # 70: incomplete fakedb.BuildRequest(id=70, buildsetid=self.BSID, complete=0, complete_at=None), # 80: complete fakedb.BuildRequest(id=80, buildsetid=self.BSID, complete=1, complete_at=self.COMPLETE_AT_EPOCH), # 81: complete but no complete_at fakedb.BuildRequest(id=81, buildsetid=self.BSID, complete=1, complete_at=0), # 82: complete_at set but complete is false, so not complete fakedb.BuildRequest(id=82, buildsetid=self.BSID, complete=0, complete_at=self.COMPLETE_AT_EPOCH), ]) d.addCallback(lambda _ : self.db.buildrequests.getBuildRequests(**kwargs)) def check(brlist): self.assertEqual(sorted([ br['brid'] for br in brlist ]), sorted(expected)) d.addCallback(check) return d def test_getBuildRequests_complete_none(self): return self.do_test_getBuildRequests_complete_arg( expected=[ 70, 80, 81, 82]) def test_getBuildRequests_complete_true(self): return self.do_test_getBuildRequests_complete_arg( complete=True, expected=[ 80, 81 ]) def test_getBuildRequests_complete_false(self): return self.do_test_getBuildRequests_complete_arg( complete=False, expected=[ 70, 82 ]) def test_getBuildRequests_bsid_arg(self): d = self.insertTestData([ # the buildset that we are *not* looking for fakedb.Buildset(id=self.BSID+1, sourcestampsetid=234), fakedb.BuildRequest(id=70, buildsetid=self.BSID, complete=0, complete_at=None), fakedb.BuildRequest(id=71, buildsetid=self.BSID+1, complete=0, complete_at=None), fakedb.BuildRequest(id=72, buildsetid=self.BSID, complete=0, complete_at=None), ]) d.addCallback(lambda _ : self.db.buildrequests.getBuildRequests(bsid=self.BSID)) def check(brlist): self.assertEqual(sorted([ br['brid'] for br in brlist ]), sorted([70, 72])) d.addCallback(check) return d def test_getBuildRequests_combo(self): d = self.insertTestData([ # 44: everything we want fakedb.BuildRequest(id=44, buildsetid=self.BSID, buildername="bbb", complete=1, results=92, complete_at=self.COMPLETE_AT_EPOCH), fakedb.BuildRequestClaim(brid=44, objectid=self.MASTER_ID, claimed_at=self.CLAIMED_AT_EPOCH), # 45: different buildername fakedb.BuildRequest(id=45, buildsetid=self.BSID, buildername="ccc", complete=1, complete_at=self.COMPLETE_AT_EPOCH), fakedb.BuildRequestClaim(brid=45, objectid=self.MASTER_ID, claimed_at=self.CLAIMED_AT_EPOCH), # 46: incomplete fakedb.BuildRequest(id=46, buildsetid=self.BSID, buildername="bbb", complete=0, results=92, complete_at=0), fakedb.BuildRequestClaim(brid=46, objectid=self.MASTER_ID, claimed_at=self.CLAIMED_AT_EPOCH), # 47: unclaimed fakedb.BuildRequest(id=47, buildsetid=self.BSID, buildername="bbb", complete=1, results=92, complete_at=self.COMPLETE_AT_EPOCH), # 48: claimed by other fakedb.BuildRequest(id=48, buildsetid=self.BSID, buildername="bbb", complete=1, results=92, complete_at=self.COMPLETE_AT_EPOCH), fakedb.BuildRequestClaim(brid=48, objectid=self.OTHER_MASTER_ID, claimed_at=self.CLAIMED_AT_EPOCH), # 49: different bsid fakedb.Buildset(id=self.BSID+1, sourcestampsetid=234), fakedb.BuildRequest(id=49, buildsetid=self.BSID+1, buildername="bbb", complete=1, results=92, complete_at=self.COMPLETE_AT_EPOCH), fakedb.BuildRequestClaim(brid=49, objectid=self.MASTER_ID, claimed_at=self.CLAIMED_AT_EPOCH), ]) d.addCallback(lambda _ : self.db.buildrequests.getBuildRequests(buildername="bbb", claimed="mine", complete=True, bsid=self.BSID)) def check(brlist): self.assertEqual([ br['brid'] for br in brlist ], [ 44 ]) d.addCallback(check) return d def do_test_getBuildRequests_branch_arg(self, **kwargs): expected = kwargs.pop('expected') d = self.insertTestData([ fakedb.BuildRequest(id=70, buildsetid=self.BSID+1), fakedb.Buildset(id=self.BSID+1, sourcestampsetid=self.BSID+1), fakedb.SourceStampSet(id=self.BSID+1), fakedb.SourceStamp(sourcestampsetid=self.BSID+1, branch='branch_A'), fakedb.BuildRequest(id=80, buildsetid=self.BSID+2), fakedb.Buildset(id=self.BSID+2, sourcestampsetid=self.BSID+2), fakedb.SourceStampSet(id=self.BSID+2), fakedb.SourceStamp(sourcestampsetid=self.BSID+2, repository='repository_A'), fakedb.BuildRequest(id=90, buildsetid=self.BSID+3), fakedb.Buildset(id=self.BSID+3, sourcestampsetid=self.BSID+3), fakedb.SourceStampSet(id=self.BSID+3), fakedb.SourceStamp(sourcestampsetid=self.BSID+3, branch='branch_A', repository='repository_A'), ]) d.addCallback(lambda _ : self.db.buildrequests.getBuildRequests(**kwargs)) def check(brlist): self.assertEqual(sorted([ br['brid'] for br in brlist ]), sorted(expected)) d.addCallback(check) return d def test_getBuildRequests_branch(self): return self.do_test_getBuildRequests_branch_arg(branch='branch_A', expected=[70, 90]) def test_getBuildRequests_branch_empty(self): return self.do_test_getBuildRequests_branch_arg(branch='absent_branch', expected=[]) def test_getBuildRequests_repository(self): return self.do_test_getBuildRequests_branch_arg( repository='repository_A', expected=[80, 90]) def test_getBuildRequests_repository_empty(self): return self.do_test_getBuildRequests_branch_arg( repository='absent_repository', expected=[]) def test_getBuildRequests_repository_and_branch(self): return self.do_test_getBuildRequests_branch_arg( repository='repository_A', branch='branch_A', expected=[90]) def test_getBuildRequests_no_repository_nor_branch(self): return self.do_test_getBuildRequests_branch_arg(expected=[70, 80, 90]) def do_test_claimBuildRequests(self, rows, now, brids, expected=None, expfailure=None, claimed_at=None): clock = task.Clock() clock.advance(now) d = self.insertTestData(rows) d.addCallback(lambda _ : self.db.buildrequests.claimBuildRequests(brids=brids, claimed_at=claimed_at, _reactor=clock)) def check(brlist): self.assertNotEqual(expected, None, "unexpected success from claimBuildRequests") def thd(conn): reqs_tbl = self.db.model.buildrequests claims_tbl = self.db.model.buildrequest_claims q = sa.select([ reqs_tbl.outerjoin(claims_tbl, reqs_tbl.c.id == claims_tbl.c.brid) ]) results = conn.execute(q).fetchall() self.assertEqual( sorted([ (r.id, r.claimed_at, r.objectid) for r in results ]), sorted(expected)) return self.db.pool.do(thd) d.addCallback(check) def fail(f): if not expfailure: raise f f.trap(expfailure) d.addErrback(fail) return d def test_claimBuildRequests_single(self): return self.do_test_claimBuildRequests([ fakedb.BuildRequest(id=44, buildsetid=self.BSID), ], 1300305712, [ 44 ], [ (44, 1300305712, self.MASTER_ID) ]) def test_claimBuildRequests_single_explicit_claimed_at(self): return self.do_test_claimBuildRequests([ fakedb.BuildRequest(id=44, buildsetid=self.BSID), ], 1300305712, [ 44 ], [ (44, 14000000, self.MASTER_ID) ], claimed_at=epoch2datetime(14000000)) def test_claimBuildRequests_multiple(self): return self.do_test_claimBuildRequests([ fakedb.BuildRequest(id=44, buildsetid=self.BSID), fakedb.BuildRequest(id=45, buildsetid=self.BSID), fakedb.BuildRequest(id=46, buildsetid=self.BSID), ], 1300305712, [ 44, 46 ], [ (44, 1300305712, self.MASTER_ID), (45, None, None), (46, 1300305712, self.MASTER_ID), ]) def test_claimBuildRequests_stress(self): return self.do_test_claimBuildRequests([ fakedb.BuildRequest(id=id, buildsetid=self.BSID) for id in xrange(1, 1000) ], 1300305713, range(1, 1000), [ (id, 1300305713, self.MASTER_ID) for id in xrange(1, 1000) ]) def test_claimBuildRequests_other_master_claim(self): return self.do_test_claimBuildRequests([ fakedb.BuildRequest(id=44, buildsetid=self.BSID), fakedb.BuildRequestClaim(brid=44, objectid=self.OTHER_MASTER_ID, claimed_at=1300103810), ], 1300305712, [ 44 ], expfailure=buildrequests.AlreadyClaimedError) @db.skip_for_dialect('mysql') def test_claimBuildRequests_other_master_claim_stress(self): d = self.do_test_claimBuildRequests( [ fakedb.BuildRequest(id=id, buildsetid=self.BSID) for id in range(1, 1000) ] + [ fakedb.BuildRequest(id=1000, buildsetid=self.BSID), # the fly in the ointment.. fakedb.BuildRequestClaim(brid=1000, objectid=self.OTHER_MASTER_ID, claimed_at=1300103810), ], 1300305712, range(1, 1001), expfailure=buildrequests.AlreadyClaimedError) def check(_): # check that [1,1000) were not claimed, and 1000 is still claimed def thd(conn): tbl = self.db.model.buildrequest_claims q = tbl.select() results = conn.execute(q).fetchall() self.assertEqual([ (r.brid, r.objectid, r.claimed_at) for r in results ][:10], [ (1000, self.OTHER_MASTER_ID, 1300103810) ]) return self.db.pool.do(thd) d.addCallback(check) return d def test_claimBuildRequests_sequential(self): now = 120350934 clock = task.Clock() clock.advance(now) d = self.insertTestData([ fakedb.BuildRequest(id=44, buildsetid=self.BSID), fakedb.BuildRequest(id=45, buildsetid=self.BSID), ]) d.addCallback(lambda _ : self.db.buildrequests.claimBuildRequests(brids=[44], _reactor=clock)) d.addCallback(lambda _ : self.db.buildrequests.claimBuildRequests(brids=[45], _reactor=clock)) def check(brlist): def thd(conn): reqs_tbl = self.db.model.buildrequests claims_tbl = self.db.model.buildrequest_claims join = reqs_tbl.outerjoin(claims_tbl, reqs_tbl.c.id == claims_tbl.c.brid) q = join.select(claims_tbl.c.claimed_at == None) results = conn.execute(q).fetchall() self.assertEqual(results, []) return self.db.pool.do(thd) d.addCallback(check) return d def do_test_reclaimBuildRequests(self, rows, now, brids, expected=None, expfailure=None): clock = task.Clock() clock.advance(now) d = self.insertTestData(rows) d.addCallback(lambda _ : self.db.buildrequests.reclaimBuildRequests(brids=brids, _reactor=clock)) def check(brlist): self.assertNotEqual(expected, None, "unexpected success from claimBuildRequests") def thd(conn): reqs_tbl = self.db.model.buildrequests claims_tbl = self.db.model.buildrequest_claims q = sa.select([ reqs_tbl.outerjoin(claims_tbl, reqs_tbl.c.id == claims_tbl.c.brid) ]) results = conn.execute(q).fetchall() self.assertEqual( sorted([ (r.id, r.claimed_at, r.objectid) for r in results ]), sorted(expected)) return self.db.pool.do(thd) d.addCallback(check) def fail(f): if not expfailure: raise f f.trap(expfailure) d.addErrback(fail) return d def test_reclaimBuildRequests(self): return self.do_test_reclaimBuildRequests([ fakedb.BuildRequest(id=44, buildsetid=self.BSID), fakedb.BuildRequestClaim(brid=44, objectid=self.MASTER_ID, claimed_at=1300103810), ], 1300305712, [ 44 ], # note that the time is updated [ (44, 1300305712, self.MASTER_ID) ]) def test_reclaimBuildRequests_fail(self): d = self.do_test_reclaimBuildRequests([ fakedb.BuildRequest(id=44, buildsetid=self.BSID), fakedb.BuildRequestClaim(brid=44, objectid=self.MASTER_ID, claimed_at=1300103810), fakedb.BuildRequest(id=45, buildsetid=self.BSID), fakedb.BuildRequestClaim(brid=45, objectid=self.OTHER_MASTER_ID, claimed_at=1300103810), ], 1300305712, [ 44, 45 ], expfailure=buildrequests.AlreadyClaimedError) def check(_): # check that the time wasn't updated on 44, noting that MySQL does # not support this. if self.db_engine.dialect.name == 'mysql': return def thd(conn): tbl = self.db.model.buildrequest_claims q = tbl.select(order_by=tbl.c.brid) results = conn.execute(q).fetchall() self.assertEqual([ (r.brid, r.claimed_at, r.objectid) for r in results ], [ (44, 1300103810, self.MASTER_ID), (45, 1300103810, self.OTHER_MASTER_ID), ]) return self.db.pool.do(thd) d.addCallback(check) return d def do_test_completeBuildRequests(self, rows, now, expected=None, expfailure=None, brids=[44], complete_at=None): clock = task.Clock() clock.advance(now) d = self.insertTestData(rows) d.addCallback(lambda _ : self.db.buildrequests.completeBuildRequests(brids=brids, results=7, complete_at=complete_at, _reactor=clock)) def check(brlist): self.assertNotEqual(expected, None, "unexpected success from completeBuildRequests") def thd(conn): tbl = self.db.model.buildrequests q = sa.select([ tbl.c.id, tbl.c.complete, tbl.c.results, tbl.c.complete_at ]) results = conn.execute(q).fetchall() self.assertEqual(sorted(map(tuple, results)), sorted(expected)) return self.db.pool.do(thd) d.addCallback(check) def fail(f): if not expfailure: raise f f.trap(expfailure) d.addErrback(fail) return d def test_completeBuildRequests(self): return self.do_test_completeBuildRequests([ fakedb.BuildRequest(id=44, buildsetid=self.BSID), fakedb.BuildRequestClaim(brid=44, objectid=self.MASTER_ID, claimed_at=1300103810), ], 1300305712, [ (44, 1, 7, 1300305712) ]) def test_completeBuildRequests_explicit_time(self): return self.do_test_completeBuildRequests([ fakedb.BuildRequest(id=44, buildsetid=self.BSID), fakedb.BuildRequestClaim(brid=44, objectid=self.MASTER_ID, claimed_at=1300103810), ], 1300305712, [ (44, 1, 7, 999999) ], complete_at=epoch2datetime(999999)) def test_completeBuildRequests_multiple(self): return self.do_test_completeBuildRequests([ fakedb.BuildRequest(id=44, buildsetid=self.BSID), fakedb.BuildRequestClaim(brid=44, objectid=self.MASTER_ID, claimed_at=1300103810), fakedb.BuildRequest(id=45, buildsetid=self.BSID), fakedb.BuildRequestClaim(brid=45, objectid=self.OTHER_MASTER_ID, claimed_at=1300103811), fakedb.BuildRequest(id=46, buildsetid=self.BSID), fakedb.BuildRequestClaim(brid=46, objectid=self.MASTER_ID, claimed_at=1300103812), ], 1300305712, [ (44, 1, 7, 1300305712), (45, 0, -1, 0), (46, 1, 7, 1300305712), ], brids=[44, 46]) def test_completeBuildRequests_stress(self): return self.do_test_completeBuildRequests([ fakedb.BuildRequest(id=id, buildsetid=self.BSID) for id in range(1, 280) ] + [ fakedb.BuildRequestClaim(brid=id, objectid=self.MASTER_ID, claimed_at=1300103810) for id in range(1, 280) ], 1300305712, [ (id, 1, 7, 1300305712) for id in range(1, 280) ], brids=range(1, 280)) def test_completeBuildRequests_multiple_notmine(self): # note that the requests are completed even though they are not mine! return self.do_test_completeBuildRequests([ # two unclaimed requests fakedb.BuildRequest(id=44, buildsetid=self.BSID), fakedb.BuildRequest(id=45, buildsetid=self.BSID), # and one claimed by another master fakedb.BuildRequest(id=46, buildsetid=self.BSID), fakedb.BuildRequestClaim(brid=46, objectid=self.OTHER_MASTER_ID, claimed_at=1300103812), ], 1300305712, [ (44, 1, 7, 1300305712), (45, 1, 7, 1300305712), (46, 1, 7, 1300305712), ], brids=[44, 45, 46]) def test_completeBuildRequests_already_completed(self): return self.do_test_completeBuildRequests([ fakedb.BuildRequest(id=44, buildsetid=self.BSID, complete=1, complete_at=1300104190), ], 1300305712, expfailure=buildrequests.NotClaimedError) def test_completeBuildRequests_no_such(self): return self.do_test_completeBuildRequests([ fakedb.BuildRequest(id=45, buildsetid=self.BSID), ], 1300305712, expfailure=buildrequests.NotClaimedError) def do_test_unclaimMethod(self, method, expected): d = self.insertTestData([ # 44: a complete build (should not be unclaimed) fakedb.BuildRequest(id=44, buildsetid=self.BSID, complete=1, results=92, complete_at=self.COMPLETE_AT_EPOCH), fakedb.BuildRequestClaim(brid=44, objectid=self.MASTER_ID, claimed_at=self.CLAIMED_AT_EPOCH), # 45: incomplete build belonging to this incarnation fakedb.BuildRequest(id=45, buildsetid=self.BSID, complete=0, complete_at=0), fakedb.BuildRequestClaim(brid=45, objectid=self.MASTER_ID, claimed_at=self.CLAIMED_AT_EPOCH), # 46: incomplete build belonging to another master fakedb.BuildRequest(id=46, buildsetid=self.BSID, complete=0, complete_at=0), fakedb.BuildRequestClaim(brid=46, objectid=self.OTHER_MASTER_ID, claimed_at=self.CLAIMED_AT_EPOCH), # 47: unclaimed fakedb.BuildRequest(id=47, buildsetid=self.BSID, complete=0, complete_at=0), # 48: claimed by this master, but recently fakedb.BuildRequest(id=48, buildsetid=self.BSID, complete=0, complete_at=0), fakedb.BuildRequestClaim(brid=48, objectid=self.MASTER_ID, claimed_at=self.CLAIMED_AT_EPOCH-50), # 49: incomplete old build belonging to another master fakedb.BuildRequest(id=49, buildsetid=self.BSID, complete=0, complete_at=0), fakedb.BuildRequestClaim(brid=49, objectid=self.OTHER_MASTER_ID, claimed_at=self.CLAIMED_AT_EPOCH - 1000), ]) d.addCallback(lambda _ : method()) def check(brlist): def thd(conn): # just select the unclaimed requests reqs_tbl = self.db.model.buildrequests claims_tbl = self.db.model.buildrequest_claims join = reqs_tbl.outerjoin(claims_tbl, reqs_tbl.c.id == claims_tbl.c.brid) q = sa.select([ reqs_tbl.c.id ], from_obj=[ join ], whereclause=claims_tbl.c.claimed_at == None) results = conn.execute(q).fetchall() self.assertEqual(sorted([ r.id for r in results ]), sorted(expected)) return self.db.pool.do(thd) d.addCallback(check) return d def test_unclaimExpiredRequests(self): clock = task.Clock() clock.advance(self.CLAIMED_AT_EPOCH) meth = self.db.buildrequests.unclaimExpiredRequests return self.do_test_unclaimMethod( lambda : meth(100, _reactor=clock), [47, 49]) def test_unclaimBuildRequests(self): to_unclaim = [ 44, # completed -> unclaimed anyway 45, # incomplete -> unclaimed 46, # from another master -> not unclaimed 47, # unclaimed -> still unclaimed 48, # claimed -> unclaimed 49, # another master -> not unclaimed 50 # no such buildrequest -> no error ] return self.do_test_unclaimMethod( lambda : self.db.buildrequests.unclaimBuildRequests(to_unclaim), [44, 45, 47, 48]) buildbot-0.8.8/buildbot/test/unit/test_db_builds.py000066400000000000000000000141231222546025000224340ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.internet import defer, task from buildbot.db import builds from buildbot.test.util import connector_component from buildbot.test.fake import fakedb from buildbot.util import epoch2datetime class TestBuildsConnectorComponent( connector_component.ConnectorComponentMixin, unittest.TestCase): def setUp(self): d = self.setUpConnectorComponent( table_names=['builds', 'buildrequests', 'buildsets', 'sourcestamps', 'sourcestampsets', 'patches' ]) def finish_setup(_): self.db.builds = builds.BuildsConnectorComponent(self.db) d.addCallback(finish_setup) return d def tearDown(self): return self.tearDownConnectorComponent() # common sample data background_data = [ fakedb.SourceStampSet(id=27), fakedb.SourceStamp(id=27, sourcestampsetid=27, revision='abcd'), fakedb.Buildset(id=20, sourcestampsetid=27), fakedb.Buildset(id=30, sourcestampsetid=27), fakedb.BuildRequest(id=41, buildsetid=20, buildername='b1'), fakedb.BuildRequest(id=42, buildsetid=30, buildername='b1'), ] # tests def test_getBuild(self): d = self.insertTestData(self.background_data + [ fakedb.Build(id=50, brid=42, number=5, start_time=1304262222), ]) d.addCallback(lambda _ : self.db.builds.getBuild(50)) def check(bdict): self.assertEqual(bdict, dict(bid=50, number=5, brid=42, start_time=epoch2datetime(1304262222), finish_time=None)) d.addCallback(check) return d def test_getBuild_missing(self): d = defer.succeed(None) d.addCallback(lambda _ : self.db.builds.getBuild(50)) def check(bdict): self.assertEqual(bdict, None) d.addCallback(check) return d def test_getBuildsForRequest(self): d = self.insertTestData(self.background_data + [ fakedb.Build(id=50, brid=42, number=5, start_time=1304262222), fakedb.Build(id=51, brid=41, number=6, start_time=1304262223), fakedb.Build(id=52, brid=42, number=7, start_time=1304262224, finish_time=1304262235), ]) d.addCallback(lambda _ : self.db.builds.getBuildsForRequest(42)) def check(bdicts): self.assertEqual(sorted(bdicts), sorted([ dict(bid=50, number=5, brid=42, start_time=epoch2datetime(1304262222), finish_time=None), dict(bid=52, number=7, brid=42, start_time=epoch2datetime(1304262224), finish_time=epoch2datetime(1304262235)), ])) d.addCallback(check) return d def test_addBuild(self): clock = task.Clock() clock.advance(1302222222) d = self.insertTestData(self.background_data) d.addCallback(lambda _ : self.db.builds.addBuild(brid=41, number=119, _reactor=clock)) def check(_): def thd(conn): r = conn.execute(self.db.model.builds.select()) rows = [ (row.brid, row.number, row.start_time, row.finish_time) for row in r.fetchall() ] self.assertEqual(rows, [ (41, 119, 1302222222, None) ]) return self.db.pool.do(thd) d.addCallback(check) return d def test_finishBuilds(self): clock = task.Clock() clock.advance(1305555555) d = self.insertTestData(self.background_data + [ fakedb.Build(id=50, brid=41, number=5, start_time=1304262222), fakedb.Build(id=51, brid=42, number=5, start_time=1304262222), fakedb.Build(id=52, brid=42, number=6, start_time=1304262222), ]) d.addCallback(lambda _ : self.db.builds.finishBuilds([50,51], _reactor=clock)) def check(_): def thd(conn): r = conn.execute(self.db.model.builds.select()) rows = [ (row.id, row.brid, row.number, row.start_time, row.finish_time) for row in r.fetchall() ] self.assertEqual(sorted(rows), [ (50, 41, 5, 1304262222, 1305555555), (51, 42, 5, 1304262222, 1305555555), (52, 42, 6, 1304262222, None), ]) return self.db.pool.do(thd) d.addCallback(check) return d def test_finishBuilds_big(self): clock = task.Clock() clock.advance(1305555555) d = self.insertTestData(self.background_data + [ fakedb.Build(id=nn, brid=41, number=nn, start_time=1304262222) for nn in xrange(50,200) ]) d.addCallback(lambda _ : self.db.builds.finishBuilds(range(50,200), _reactor=clock)) def check(_): def thd(conn): r = conn.execute(self.db.model.builds.select()) rows = [ (row.id, row.brid, row.number, row.start_time, row.finish_time) for row in r.fetchall() ] self.assertEqual(sorted(rows), [ (nn, 41, nn, 1304262222, 1305555555) for nn in xrange(50,200) ]) return self.db.pool.do(thd) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_db_buildsets.py000066400000000000000000000453071222546025000231600ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import datetime from twisted.trial import unittest from twisted.internet import defer, task from buildbot.db import buildsets from buildbot.util import json, UTC, epoch2datetime from buildbot.test.util import connector_component from buildbot.test.fake import fakedb class TestBuildsetsConnectorComponent( connector_component.ConnectorComponentMixin, unittest.TestCase): def setUp(self): self.now = 9272359 self.clock = task.Clock() self.clock.advance(self.now) d = self.setUpConnectorComponent( table_names=[ 'patches', 'changes', 'sourcestamp_changes', 'buildsets', 'buildset_properties', 'objects', 'buildrequests', 'sourcestamps', 'sourcestampsets' ]) def finish_setup(_): self.db.buildsets = buildsets.BuildsetsConnectorComponent(self.db) d.addCallback(finish_setup) # set up a sourcestamp with id 234 for use below d.addCallback(lambda _ : self.insertTestData([ fakedb.SourceStampSet(id=234), fakedb.SourceStamp(id=234, sourcestampsetid=234), ])) return d def tearDown(self): return self.tearDownConnectorComponent() # tests def test_addBuildset_simple(self): d = defer.succeed(None) d.addCallback(lambda _ : self.db.buildsets.addBuildset(sourcestampsetid=234, reason='because', properties={}, builderNames=['bldr'], external_idstring='extid', _reactor=self.clock)) def check((bsid, brids)): def thd(conn): # we should only have one brid self.assertEqual(len(brids), 1) # should see one buildset row r = conn.execute(self.db.model.buildsets.select()) rows = [ (row.id, row.external_idstring, row.reason, row.sourcestampsetid, row.complete, row.complete_at, row.submitted_at, row.results) for row in r.fetchall() ] self.assertEqual(rows, [ ( bsid, 'extid', 'because', 234, 0, None, self.now, -1) ]) # and one buildrequests row r = conn.execute(self.db.model.buildrequests.select()) rows = [ (row.buildsetid, row.id, row.buildername, row.priority, row.complete, row.results, row.submitted_at, row.complete_at) for row in r.fetchall() ] self.assertEqual(rows, [ ( bsid, brids['bldr'], 'bldr', 0, 0, -1, self.now, None) ]) return self.db.pool.do(thd) d.addCallback(check) return d def test_addBuildset_bigger(self): props = dict(prop=(['list'], 'test')) d = defer.succeed(None) d.addCallback(lambda _ : self.db.buildsets.addBuildset(sourcestampsetid=234, reason='because', properties=props, builderNames=['a', 'b'])) def check((bsid, brids)): def thd(conn): self.assertEqual(len(brids), 2) # should see one buildset row r = conn.execute(self.db.model.buildsets.select()) rows = [ (row.id, row.external_idstring, row.reason, row.sourcestampsetid, row.complete, row.complete_at, row.results) for row in r.fetchall() ] self.assertEqual(rows, [ ( bsid, None, u'because', 234, 0, None, -1) ]) # one property row r = conn.execute(self.db.model.buildset_properties.select()) rows = [ (row.buildsetid, row.property_name, row.property_value) for row in r.fetchall() ] self.assertEqual(rows, [ ( bsid, 'prop', json.dumps([ ['list'], 'test' ]) ) ]) # and two buildrequests rows (and don't re-check the default columns) r = conn.execute(self.db.model.buildrequests.select()) rows = [ (row.buildsetid, row.id, row.buildername) for row in r.fetchall() ] # we don't know which of the brids is assigned to which # buildername, but either one will do self.assertEqual(sorted(rows), [ ( bsid, brids['a'], 'a'), (bsid, brids['b'], 'b') ]) return self.db.pool.do(thd) d.addCallback(check) return d def do_test_getBuildsetProperties(self, buildsetid, rows, expected): d = self.insertTestData(rows) d.addCallback(lambda _ : self.db.buildsets.getBuildsetProperties(buildsetid)) def check(props): self.assertEqual(props, expected) d.addCallback(check) return d def test_getBuildsetProperties_multiple(self): return self.do_test_getBuildsetProperties(91, [ fakedb.Buildset(id=91, sourcestampsetid=234, complete=0, results=-1, submitted_at=0), fakedb.BuildsetProperty(buildsetid=91, property_name='prop1', property_value='["one", "fake1"]'), fakedb.BuildsetProperty(buildsetid=91, property_name='prop2', property_value='["two", "fake2"]'), ], dict(prop1=("one", "fake1"), prop2=("two", "fake2"))) def test_getBuildsetProperties_empty(self): return self.do_test_getBuildsetProperties(91, [ fakedb.Buildset(id=91, sourcestampsetid=234, complete=0, results=-1, submitted_at=0), ], dict()) def test_getBuildsetProperties_nosuch(self): "returns an empty dict even if no such buildset exists" return self.do_test_getBuildsetProperties(91, [], dict()) def test_getBuildset_incomplete_None(self): d = self.insertTestData([ fakedb.Buildset(id=91, sourcestampsetid=234, complete=0, complete_at=None, results=-1, submitted_at=266761875, external_idstring='extid', reason='rsn'), ]) d.addCallback(lambda _ : self.db.buildsets.getBuildset(91)) def check(bsdict): self.assertEqual(bsdict, dict(external_idstring='extid', reason='rsn', sourcestampsetid=234, submitted_at=datetime.datetime(1978, 6, 15, 12, 31, 15, tzinfo=UTC), complete=False, complete_at=None, results=-1, bsid=91)) d.addCallback(check) return d def test_getBuildset_incomplete_zero(self): d = self.insertTestData([ fakedb.Buildset(id=91, sourcestampsetid=234, complete=0, complete_at=0, results=-1, submitted_at=266761875, external_idstring='extid', reason='rsn'), ]) d.addCallback(lambda _ : self.db.buildsets.getBuildset(91)) def check(bsdict): self.assertEqual(bsdict, dict(external_idstring='extid', reason='rsn', sourcestampsetid=234, submitted_at=datetime.datetime(1978, 6, 15, 12, 31, 15, tzinfo=UTC), complete=False, complete_at=None, results=-1, bsid=91)) d.addCallback(check) return d def test_getBuildset_complete(self): d = self.insertTestData([ fakedb.Buildset(id=91, sourcestampsetid=234, complete=1, complete_at=298297875, results=-1, submitted_at=266761875, external_idstring='extid', reason='rsn'), ]) d.addCallback(lambda _ : self.db.buildsets.getBuildset(91)) def check(bsdict): self.assertEqual(bsdict, dict(external_idstring='extid', reason='rsn', sourcestampsetid=234, submitted_at=datetime.datetime(1978, 6, 15, 12, 31, 15, tzinfo=UTC), complete=True, complete_at=datetime.datetime(1979, 6, 15, 12, 31, 15, tzinfo=UTC), results=-1, bsid=91)) d.addCallback(check) return d def test_getBuildset_nosuch(self): d = self.db.buildsets.getBuildset(91) def check(bsdict): self.assertEqual(bsdict, None) d.addCallback(check) return d def insert_test_getBuildsets_data(self): return self.insertTestData([ fakedb.Buildset(id=91, sourcestampsetid=234, complete=0, complete_at=298297875, results=-1, submitted_at=266761875, external_idstring='extid', reason='rsn1'), fakedb.Buildset(id=92, sourcestampsetid=234, complete=1, complete_at=298297876, results=7, submitted_at=266761876, external_idstring='extid', reason='rsn2'), ]) def test_getBuildsets_empty(self): d = self.db.buildsets.getBuildsets() def check(bsdictlist): self.assertEqual(bsdictlist, []) d.addCallback(check) return d def test_getBuildsets_all(self): d = self.insert_test_getBuildsets_data() d.addCallback(lambda _ : self.db.buildsets.getBuildsets()) def check(bsdictlist): self.assertEqual(sorted(bsdictlist), sorted([ dict(external_idstring='extid', reason='rsn1', sourcestampsetid=234, submitted_at=datetime.datetime(1978, 6, 15, 12, 31, 15, tzinfo=UTC), complete_at=datetime.datetime(1979, 6, 15, 12, 31, 15, tzinfo=UTC), complete=False, results=-1, bsid=91), dict(external_idstring='extid', reason='rsn2', sourcestampsetid=234, submitted_at=datetime.datetime(1978, 6, 15, 12, 31, 16, tzinfo=UTC), complete_at=datetime.datetime(1979, 6, 15, 12, 31, 16, tzinfo=UTC), complete=True, results=7, bsid=92), ])) d.addCallback(check) return d def test_getBuildsets_complete(self): d = self.insert_test_getBuildsets_data() d.addCallback(lambda _ : self.db.buildsets.getBuildsets(complete=True)) def check(bsdictlist): self.assertEqual(bsdictlist, [ dict(external_idstring='extid', reason='rsn2', sourcestampsetid=234, submitted_at=datetime.datetime(1978, 6, 15, 12, 31, 16, tzinfo=UTC), complete_at=datetime.datetime(1979, 6, 15, 12, 31, 16, tzinfo=UTC), complete=True, results=7, bsid=92), ]) d.addCallback(check) return d def test_getBuildsets_incomplete(self): d = self.insert_test_getBuildsets_data() d.addCallback(lambda _ : self.db.buildsets.getBuildsets(complete=False)) def check(bsdictlist): self.assertEqual(bsdictlist, [ dict(external_idstring='extid', reason='rsn1', sourcestampsetid=234, submitted_at=datetime.datetime(1978, 6, 15, 12, 31, 15, tzinfo=UTC), complete_at=datetime.datetime(1979, 6, 15, 12, 31, 15, tzinfo=UTC), complete=False, results=-1, bsid=91), ]) d.addCallback(check) return d def test_completeBuildset(self): d = self.insert_test_getBuildsets_data() d.addCallback(lambda _ : self.db.buildsets.completeBuildset(bsid=91, results=6, _reactor=self.clock)) def check(_): def thd(conn): # should see one buildset row r = conn.execute(self.db.model.buildsets.select()) rows = [ (row.id, row.complete, row.complete_at, row.results) for row in r.fetchall() ] self.assertEqual(sorted(rows), sorted([ ( 91, 1, self.now, 6), ( 92, 1, 298297876, 7) ])) return self.db.pool.do(thd) d.addCallback(check) return d def test_completeBuildset_explicit_complete_at(self): d = self.insert_test_getBuildsets_data() d.addCallback(lambda _ : self.db.buildsets.completeBuildset(bsid=91, results=6, complete_at=epoch2datetime(72759))) def check(_): def thd(conn): # should see one buildset row r = conn.execute(self.db.model.buildsets.select()) rows = [ (row.id, row.complete, row.complete_at, row.results) for row in r.fetchall() ] self.assertEqual(sorted(rows), sorted([ ( 91, 1, 72759, 6), ( 92, 1, 298297876, 7) ])) return self.db.pool.do(thd) d.addCallback(check) return d def test_completeBuildset_already_completed(self): d = self.insert_test_getBuildsets_data() d.addCallback(lambda _ : self.db.buildsets.completeBuildset(bsid=92, results=6, _reactor=self.clock)) return self.assertFailure(d, KeyError) def test_completeBuildset_missing(self): d = self.insert_test_getBuildsets_data() d.addCallback(lambda _ : self.db.buildsets.completeBuildset(bsid=93, results=6, _reactor=self.clock)) return self.assertFailure(d, KeyError) def insert_test_getRecentBuildsets_data(self): return self.insertTestData([ fakedb.SourceStamp(id=91, branch='branch_a', repository='repo_a', sourcestampsetid=91), fakedb.SourceStampSet(id=91), fakedb.Buildset(id=91, sourcestampsetid=91, complete=0, complete_at=298297875, results=-1, submitted_at=266761875, external_idstring='extid', reason='rsn1'), fakedb.Buildset(id=92, sourcestampsetid=91, complete=1, complete_at=298297876, results=7, submitted_at=266761876, external_idstring='extid', reason='rsn2'), # buildset unrelated to the change fakedb.SourceStampSet(id=1), fakedb.Buildset(id=93, sourcestampsetid=1, complete=1, complete_at=298297877, results=7, submitted_at=266761877, external_idstring='extid', reason='rsn2'), ]) def test_getRecentBuildsets_all(self): d = self.insert_test_getRecentBuildsets_data() d.addCallback(lambda _ : self.db.buildsets.getRecentBuildsets(2, branch='branch_a', repository='repo_a')) def check(bsdictlist): self.assertEqual(bsdictlist, [ dict(external_idstring='extid', reason='rsn1', sourcestampsetid=91, submitted_at=datetime.datetime(1978, 6, 15, 12, 31, 15, tzinfo=UTC), complete_at=datetime.datetime(1979, 6, 15, 12, 31, 15, tzinfo=UTC), complete=False, results=-1, bsid=91), dict(external_idstring='extid', reason='rsn2', sourcestampsetid=91, submitted_at=datetime.datetime(1978, 6, 15, 12, 31, 16, tzinfo=UTC), complete_at=datetime.datetime(1979, 6, 15, 12, 31, 16, tzinfo=UTC), complete=True, results=7, bsid=92), ]) d.addCallback(check) return d def test_getRecentBuildsets_one(self): d = self.insert_test_getRecentBuildsets_data() d.addCallback(lambda _ : self.db.buildsets.getRecentBuildsets(1, branch='branch_a', repository='repo_a')) def check(bsdictlist): self.assertEqual(bsdictlist, [ dict(external_idstring='extid', reason='rsn2', sourcestampsetid=91, submitted_at=datetime.datetime(1978, 6, 15, 12, 31, 16, tzinfo=UTC), complete_at=datetime.datetime(1979, 6, 15, 12, 31, 16, tzinfo=UTC), complete=True, results=7, bsid=92), ]) d.addCallback(check) return d def test_getRecentBuildsets_zero(self): d = self.insert_test_getRecentBuildsets_data() d.addCallback(lambda _ : self.db.buildsets.getRecentBuildsets(0, branch='branch_a', repository='repo_a')) def check(bsdictlist): self.assertEqual(bsdictlist, []) d.addCallback(check) return d def test_getRecentBuildsets_noBranchMatch(self): d = self.insert_test_getRecentBuildsets_data() d.addCallback(lambda _ : self.db.buildsets.getRecentBuildsets(2, branch='bad_branch', repository='repo_a')) def check(bsdictlist): self.assertEqual(bsdictlist, []) d.addCallback(check) return d def test_getRecentBuildsets_noRepoMatch(self): d = self.insert_test_getRecentBuildsets_data() d.addCallback(lambda _ : self.db.buildsets.getRecentBuildsets(2, branch='branch_a', repository='bad_repo')) def check(bsdictlist): self.assertEqual(bsdictlist, []) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_db_changes.py000066400000000000000000000467561222546025000226030ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock import pprint import sqlalchemy as sa from twisted.trial import unittest from twisted.internet import defer, task from buildbot.changes.changes import Change from buildbot.db import changes from buildbot.test.util import connector_component from buildbot.test.fake import fakedb from buildbot.util import epoch2datetime class TestChangesConnectorComponent( connector_component.ConnectorComponentMixin, unittest.TestCase): def setUp(self): d = self.setUpConnectorComponent( table_names=['changes', 'change_files', 'change_properties', 'scheduler_changes', 'objects', 'sourcestampsets', 'sourcestamps', 'sourcestamp_changes', 'patches', 'change_users', 'users']) def finish_setup(_): self.db.changes = changes.ChangesConnectorComponent(self.db) d.addCallback(finish_setup) return d def tearDown(self): return self.tearDownConnectorComponent() # common sample data change13_rows = [ fakedb.Change(changeid=13, author="dustin", comments="fix spelling", is_dir=0, branch="master", revision="deadbeef", when_timestamp=266738400, revlink=None, category=None, repository='', codebase='', project=''), fakedb.ChangeFile(changeid=13, filename='master/README.txt'), fakedb.ChangeFile(changeid=13, filename='slave/README.txt'), fakedb.ChangeProperty(changeid=13, property_name='notest', property_value='["no","Change"]'), ] change14_rows = [ fakedb.Change(changeid=14, author="warner", comments="fix whitespace", is_dir=0, branch="warnerdb", revision="0e92a098b", when_timestamp=266738404, revlink='http://warner/0e92a098b', category='devel', repository='git://warner', codebase='mainapp', project='Buildbot'), fakedb.ChangeFile(changeid=14, filename='master/buildbot/__init__.py'), ] change14_dict = { 'changeid': 14, 'author': u'warner', 'branch': u'warnerdb', 'category': u'devel', 'comments': u'fix whitespace', 'files': [u'master/buildbot/__init__.py'], 'is_dir': 0, 'project': u'Buildbot', 'properties': {}, 'repository': u'git://warner', 'codebase': u'mainapp', 'revision': u'0e92a098b', 'revlink': u'http://warner/0e92a098b', 'when_timestamp': epoch2datetime(266738404), } def change14(self): c = Change(**dict( category='devel', isdir=0, repository=u'git://warner', codebase=u'mainapp', who=u'warner', when=266738404, comments=u'fix whitespace', project=u'Buildbot', branch=u'warnerdb', revlink=u'http://warner/0e92a098b', properties={}, files=[u'master/buildbot/__init__.py'], revision=u'0e92a098b')) c.number = 14 return c # assertions def assertChangesEqual(self, ca, cb): ok = True ok = ok and ca.number == cb.number ok = ok and ca.who == cb.who ok = ok and sorted(ca.files) == sorted(cb.files) ok = ok and ca.comments == cb.comments ok = ok and bool(ca.isdir) == bool(cb.isdir) ok = ok and ca.revision == cb.revision ok = ok and ca.when == cb.when ok = ok and ca.branch == cb.branch ok = ok and ca.category == cb.category ok = ok and ca.revlink == cb.revlink ok = ok and ca.properties == cb.properties ok = ok and ca.repository == cb.repository ok = ok and ca.codebase == cb.codebase ok = ok and ca.project == cb.project if not ok: def printable(c): return pprint.pformat(c.__dict__) self.fail("changes do not match; expected\n%s\ngot\n%s" % (printable(ca), printable(cb))) # tests def test_getChange(self): d = self.insertTestData(self.change14_rows) def get14(_): return self.db.changes.getChange(14) d.addCallback(get14) def check14(chdict): self.assertEqual(chdict, self.change14_dict) d.addCallback(check14) return d def test_Change_fromChdict_with_chdict(self): # test that the chdict getChange returns works with Change.fromChdict d = Change.fromChdict(mock.Mock(), self.change14_dict) def check(c): self.assertChangesEqual(c, self.change14()) d.addCallback(check) return d def test_getChange_missing(self): d = defer.succeed(None) def get14(_): return self.db.changes.getChange(14) d.addCallback(get14) def check14(chdict): self.failUnless(chdict is None) d.addCallback(check14) return d def test_getLatestChangeid(self): d = self.insertTestData(self.change13_rows) def get(_): return self.db.changes.getLatestChangeid() d.addCallback(get) def check(changeid): self.assertEqual(changeid, 13) d.addCallback(check) return d def test_getLatestChangeid_empty(self): d = defer.succeed(None) def get(_): return self.db.changes.getLatestChangeid() d.addCallback(get) def check(changeid): self.assertEqual(changeid, None) d.addCallback(check) return d def test_addChange(self): d = self.db.changes.addChange( author=u'dustin', files=[u'master/LICENSING.txt', u'slave/LICENSING.txt'], comments=u'fix spelling', is_dir=0, revision=u'2d6caa52', when_timestamp=epoch2datetime(266738400), branch=u'master', category=None, revlink=None, properties={u'platform': (u'linux', 'Change')}, repository=u'', codebase=u'', project=u'') # check all of the columns of the four relevant tables def check_change(changeid): def thd(conn): self.assertEqual(changeid, 1) r = conn.execute(self.db.model.changes.select()) r = r.fetchall() self.assertEqual(len(r), 1) self.assertEqual(r[0].changeid, changeid) self.assertEqual(r[0].author, 'dustin') self.assertEqual(r[0].comments, 'fix spelling') self.assertFalse(r[0].is_dir) self.assertEqual(r[0].branch, 'master') self.assertEqual(r[0].revision, '2d6caa52') self.assertEqual(r[0].when_timestamp, 266738400) self.assertEqual(r[0].category, None) self.assertEqual(r[0].repository, '') self.assertEqual(r[0].codebase, '') self.assertEqual(r[0].project, '') return self.db.pool.do(thd) d.addCallback(check_change) def check_change_files(_): def thd(conn): query = self.db.model.change_files.select() query.where(self.db.model.change_files.c.changeid == 1) query.order_by(self.db.model.change_files.c.filename) r = conn.execute(query) r = r.fetchall() self.assertEqual(len(r), 2) self.assertEqual(r[0].filename, 'master/LICENSING.txt') self.assertEqual(r[1].filename, 'slave/LICENSING.txt') return self.db.pool.do(thd) d.addCallback(check_change_files) def check_change_properties(_): def thd(conn): query = self.db.model.change_properties.select() query.where(self.db.model.change_properties.c.changeid == 1) query.order_by(self.db.model.change_properties.c.property_name) r = conn.execute(query) r = r.fetchall() self.assertEqual(len(r), 1) self.assertEqual(r[0].property_name, 'platform') self.assertEqual(r[0].property_value, '["linux", "Change"]') return self.db.pool.do(thd) d.addCallback(check_change_properties) def check_change_users(_): def thd(conn): query = self.db.model.change_users.select() r = conn.execute(query) r = r.fetchall() self.assertEqual(len(r), 0) return self.db.pool.do(thd) d.addCallback(check_change_users) return d def test_addChange_when_timestamp_None(self): clock = task.Clock() clock.advance(1239898353) d = self.db.changes.addChange( author=u'dustin', files=[], comments=u'fix spelling', is_dir=0, revision=u'2d6caa52', when_timestamp=None, branch=u'master', category=None, revlink=None, properties={}, repository=u'', codebase=u'', project=u'', _reactor=clock) # check all of the columns of the four relevant tables def check_change(changeid): def thd(conn): r = conn.execute(self.db.model.changes.select()) r = r.fetchall() self.assertEqual(len(r), 1) self.assertEqual(r[0].changeid, changeid) self.assertEqual(r[0].when_timestamp, 1239898353) return self.db.pool.do(thd) d.addCallback(check_change) def check_change_files(_): def thd(conn): query = self.db.model.change_files.select() r = conn.execute(query) r = r.fetchall() self.assertEqual(len(r), 0) return self.db.pool.do(thd) d.addCallback(check_change_files) def check_change_properties(_): def thd(conn): query = self.db.model.change_properties.select() r = conn.execute(query) r = r.fetchall() self.assertEqual(len(r), 0) return self.db.pool.do(thd) d.addCallback(check_change_properties) def check_change_users(_): def thd(conn): query = self.db.model.change_users.select() r = conn.execute(query) r = r.fetchall() self.assertEqual(len(r), 0) return self.db.pool.do(thd) d.addCallback(check_change_users) return d def test_addChange_with_uid(self): d = self.insertTestData([ fakedb.User(uid=1, identifier="one"), ]) d.addCallback(lambda _ : self.db.changes.addChange( author=u'dustin', files=[], comments=u'fix spelling', is_dir=0, revision=u'2d6caa52', when_timestamp=epoch2datetime(1239898353), branch=u'master', category=None, revlink=None, properties={}, repository=u'', codebase=u'', project=u'', uid=1)) # check all of the columns of the five relevant tables def check_change(changeid): def thd(conn): r = conn.execute(self.db.model.changes.select()) r = r.fetchall() self.assertEqual(len(r), 1) self.assertEqual(r[0].changeid, changeid) self.assertEqual(r[0].when_timestamp, 1239898353) return self.db.pool.do(thd) d.addCallback(check_change) def check_change_files(_): def thd(conn): query = self.db.model.change_files.select() r = conn.execute(query) r = r.fetchall() self.assertEqual(len(r), 0) return self.db.pool.do(thd) d.addCallback(check_change_files) def check_change_properties(_): def thd(conn): query = self.db.model.change_properties.select() r = conn.execute(query) r = r.fetchall() self.assertEqual(len(r), 0) return self.db.pool.do(thd) d.addCallback(check_change_properties) def check_change_users(_): def thd(conn): query = self.db.model.change_users.select() r = conn.execute(query) r = r.fetchall() self.assertEqual(len(r), 1) self.assertEqual(r[0].changeid, 1) self.assertEqual(r[0].uid, 1) return self.db.pool.do(thd) d.addCallback(check_change_users) return d def test_getChangeUids_missing(self): d = self.db.changes.getChangeUids(1) def check(res): self.assertEqual(res, []) d.addCallback(check) return d def test_getChangeUids_found(self): d = self.insertTestData(self.change14_rows + [ fakedb.User(uid=1), fakedb.ChangeUser(changeid=14, uid=1), ]) d.addCallback(lambda _ : self.db.changes.getChangeUids(14)) def check(res): self.assertEqual(res, [1]) d.addCallback(check) return d def test_getChangeUids_multi(self): d = self.insertTestData(self.change14_rows + self.change13_rows + [ fakedb.User(uid=1, identifier="one"), fakedb.User(uid=2, identifier="two"), fakedb.User(uid=99, identifier="nooo"), fakedb.ChangeUser(changeid=14, uid=1), fakedb.ChangeUser(changeid=14, uid=2), fakedb.ChangeUser(changeid=13, uid=99), # not selected ]) d.addCallback(lambda _ : self.db.changes.getChangeUids(14)) def check(res): self.assertEqual(sorted(res), [1, 2]) d.addCallback(check) return d def test_pruneChanges(self): d = self.insertTestData([ fakedb.Object(id=29), fakedb.SourceStamp(id=234), fakedb.Change(changeid=11), fakedb.Change(changeid=12), fakedb.SchedulerChange(objectid=29, changeid=12), fakedb.SourceStampChange(sourcestampid=234, changeid=12), ] + self.change13_rows + [ fakedb.SchedulerChange(objectid=29, changeid=13), ] + self.change14_rows + [ fakedb.SchedulerChange(objectid=29, changeid=14), fakedb.Change(changeid=15), fakedb.SourceStampChange(sourcestampid=234, changeid=15), ] ) # pruning with a horizon of 2 should delete changes 11, 12 and 13 d.addCallback(lambda _ : self.db.changes.pruneChanges(2)) def check(_): def thd(conn): results = {} for tbl_name in ('scheduler_changes', 'sourcestamp_changes', 'change_files', 'change_properties', 'changes'): tbl = self.db.model.metadata.tables[tbl_name] r = conn.execute(sa.select([tbl.c.changeid])) results[tbl_name] = sorted([ r[0] for r in r.fetchall() ]) self.assertEqual(results, { 'scheduler_changes': [14], 'sourcestamp_changes': [15], 'change_files': [14], 'change_properties': [], 'changes': [14, 15], }) return self.db.pool.do(thd) d.addCallback(check) return d def test_pruneChanges_lots(self): d = self.insertTestData([ fakedb.Change(changeid=n) for n in xrange(1, 151) ]) d.addCallback(lambda _ : self.db.changes.pruneChanges(1)) def check(_): def thd(conn): results = {} for tbl_name in ('scheduler_changes', 'sourcestamp_changes', 'change_files', 'change_properties', 'changes'): tbl = self.db.model.metadata.tables[tbl_name] r = conn.execute(sa.select([tbl.c.changeid])) results[tbl_name] = len([ r for r in r.fetchall() ]) self.assertEqual(results, { 'scheduler_changes': 0, 'sourcestamp_changes': 0, 'change_files': 0, 'change_properties': 0, 'changes': 1, }) return self.db.pool.do(thd) d.addCallback(check) return d def test_pruneChanges_None(self): d = self.insertTestData(self.change13_rows) d.addCallback(lambda _ : self.db.changes.pruneChanges(None)) def check(_): def thd(conn): tbl = self.db.model.changes r = conn.execute(tbl.select()) self.assertEqual([ row.changeid for row in r.fetchall() ], [ 13 ]) return self.db.pool.do(thd) d.addCallback(check) return d def test_getRecentChanges_subset(self): d = self.insertTestData([ fakedb.Change(changeid=8), fakedb.Change(changeid=9), fakedb.Change(changeid=10), fakedb.Change(changeid=11), fakedb.Change(changeid=12), ] + self.change13_rows + self.change14_rows) d.addCallback(lambda _ : self.db.changes.getRecentChanges(5)) def check(changes): changeids = [ c['changeid'] for c in changes ] self.assertEqual(changeids, [10, 11, 12, 13, 14]) d.addCallback(check) return d def test_getRecentChanges_empty(self): d = defer.succeed(None) d.addCallback(lambda _ : self.db.changes.getRecentChanges(5)) def check(changes): changeids = [ c['changeid'] for c in changes ] self.assertEqual(changeids, []) d.addCallback(check) return d def test_getRecentChanges_missing(self): d = self.insertTestData(self.change13_rows + self.change14_rows) d.addCallback(lambda _ : self.db.changes.getRecentChanges(5)) def check(changes): # requested 5, but only got 2 changeids = [ c['changeid'] for c in changes ] self.assertEqual(changeids, [13, 14]) # double-check that they have .files, etc. self.assertEqual(sorted(changes[0]['files']), sorted(['master/README.txt', 'slave/README.txt'])) self.assertEqual(changes[0]['properties'], { 'notest' : ('no', 'Change') }) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_db_connector.py000066400000000000000000000060711222546025000231470ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os import mock from twisted.internet import defer from twisted.trial import unittest from buildbot.db import connector from buildbot import config from buildbot.test.util import db from buildbot.test.fake import fakemaster class DBConnector(db.RealDatabaseMixin, unittest.TestCase): """ Basic tests of the DBConnector class - all start with an empty DB """ @defer.inlineCallbacks def setUp(self): yield self.setUpRealDatabase(table_names=[ 'changes', 'change_properties', 'change_files', 'patches', 'sourcestamps', 'buildset_properties', 'buildsets', 'sourcestampsets' ]) self.master = fakemaster.make_master() self.master.config = config.MasterConfig() self.db = connector.DBConnector(self.master, os.path.abspath('basedir')) @defer.inlineCallbacks def tearDown(self): if self.db.running: yield self.db.stopService() yield self.tearDownRealDatabase() @defer.inlineCallbacks def startService(self, check_version=False): self.master.config.db['db_url'] = self.db_url yield self.db.setup(check_version=check_version) self.db.startService() yield self.db.reconfigService(self.master.config) # tests def test_doCleanup_service(self): d = self.startService() @d.addCallback def check(_): self.assertTrue(self.db.cleanup_timer.running) def test_doCleanup_unconfigured(self): self.db.changes.pruneChanges = mock.Mock( return_value=defer.succeed(None)) self.db._doCleanup() self.assertFalse(self.db.changes.pruneChanges.called) def test_doCleanup_configured(self): self.db.changes.pruneChanges = mock.Mock( return_value=defer.succeed(None)) d = self.startService() @d.addCallback def check(_): self.db._doCleanup() self.assertTrue(self.db.changes.pruneChanges.called) return d def test_setup_check_version_bad(self): d = self.startService(check_version=True) return self.assertFailure(d, connector.DatabaseNotReadyError) def test_setup_check_version_good(self): self.db.model.is_current = lambda : defer.succeed(True) return self.startService(check_version=True) buildbot-0.8.8/buildbot/test/unit/test_db_enginestrategy.py000066400000000000000000000172411222546025000242060ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.python import runtime from sqlalchemy.engine import url from sqlalchemy.pool import NullPool from buildbot.db import enginestrategy class BuildbotEngineStrategy_special_cases(unittest.TestCase): "Test the special case methods, without actually creating a db" # used several times below mysql_kwargs = dict(basedir='my-base-dir', connect_args=dict(init_command='SET storage_engine=MyISAM'), pool_recycle=3600) sqlite_kwargs = dict(basedir='/my-base-dir', poolclass=NullPool) def setUp(self): self.strat = enginestrategy.BuildbotEngineStrategy() # utility def filter_kwargs(self, kwargs): # filter out the listeners list to just include the class name if 'listeners' in kwargs: kwargs['listeners'] = [ lstnr.__class__.__name__ for lstnr in kwargs['listeners'] ] return kwargs # tests def test_sqlite_pct_sub(self): u = url.make_url("sqlite:///%(basedir)s/x/state.sqlite") kwargs = dict(basedir='/my-base-dir') u, kwargs, max_conns = self.strat.special_case_sqlite(u, kwargs) self.assertEqual([ str(u), max_conns, self.filter_kwargs(kwargs) ], [ "sqlite:////my-base-dir/x/state.sqlite", None, self.sqlite_kwargs ]) def test_sqlite_relpath(self): url_src = "sqlite:///x/state.sqlite" basedir = "/my-base-dir" expected_url = "sqlite:////my-base-dir/x/state.sqlite" # this looks a whole lot different on windows if runtime.platformType == 'win32': url_src = r'sqlite:///X\STATE.SQLITE' basedir = r'C:\MYBASE~1' expected_url = r'sqlite:///C:\MYBASE~1\X\STATE.SQLITE' exp_kwargs = self.sqlite_kwargs.copy() exp_kwargs['basedir'] = basedir u = url.make_url(url_src) kwargs = dict(basedir=basedir) u, kwargs, max_conns = self.strat.special_case_sqlite(u, kwargs) self.assertEqual([ str(u), max_conns, self.filter_kwargs(kwargs) ], [ expected_url, None, exp_kwargs ]) def test_sqlite_abspath(self): u = url.make_url("sqlite:////x/state.sqlite") kwargs = dict(basedir='/my-base-dir') u, kwargs, max_conns = self.strat.special_case_sqlite(u, kwargs) self.assertEqual([ str(u), max_conns, self.filter_kwargs(kwargs) ], [ "sqlite:////x/state.sqlite", None, self.sqlite_kwargs ]) def test_sqlite_memory(self): u = url.make_url("sqlite://") kwargs = dict(basedir='my-base-dir') u, kwargs, max_conns = self.strat.special_case_sqlite(u, kwargs) self.assertEqual([ str(u), max_conns, self.filter_kwargs(kwargs) ], [ "sqlite://", 1, # only one conn at a time dict(basedir='my-base-dir', # note: no poolclass= argument pool_size=1) ]) # extra in-memory args def test_mysql_simple(self): u = url.make_url("mysql://host/dbname") kwargs = dict(basedir='my-base-dir') u, kwargs, max_conns = self.strat.special_case_mysql(u, kwargs) self.assertEqual([ str(u), max_conns, self.filter_kwargs(kwargs) ], [ "mysql://host/dbname?charset=utf8&use_unicode=True", None, self.mysql_kwargs ]) def test_mysql_userport(self): u = url.make_url("mysql://user:pass@host:1234/dbname") kwargs = dict(basedir='my-base-dir') u, kwargs, max_conns = self.strat.special_case_mysql(u, kwargs) self.assertEqual([ str(u), max_conns, self.filter_kwargs(kwargs) ], [ "mysql://user:pass@host:1234/dbname?" "charset=utf8&use_unicode=True", None, self.mysql_kwargs ]) def test_mysql_local(self): u = url.make_url("mysql:///dbname") kwargs = dict(basedir='my-base-dir') u, kwargs, max_conns = self.strat.special_case_mysql(u, kwargs) self.assertEqual([ str(u), max_conns, self.filter_kwargs(kwargs) ], [ "mysql:///dbname?charset=utf8&use_unicode=True", None, self.mysql_kwargs ]) def test_mysql_args(self): u = url.make_url("mysql:///dbname?foo=bar") kwargs = dict(basedir='my-base-dir') u, kwargs, max_conns = self.strat.special_case_mysql(u, kwargs) self.assertEqual([ str(u), max_conns, self.filter_kwargs(kwargs) ], [ "mysql:///dbname?charset=utf8&foo=bar&use_unicode=True", None, self.mysql_kwargs ]) def test_mysql_max_idle(self): u = url.make_url("mysql:///dbname?max_idle=1234") kwargs = dict(basedir='my-base-dir') u, kwargs, max_conns = self.strat.special_case_mysql(u, kwargs) exp = self.mysql_kwargs.copy() exp['pool_recycle'] = 1234 self.assertEqual([ str(u), max_conns, self.filter_kwargs(kwargs) ], [ "mysql:///dbname?charset=utf8&use_unicode=True", None, exp ]) def test_mysql_good_charset(self): u = url.make_url("mysql:///dbname?charset=utf8") kwargs = dict(basedir='my-base-dir') u, kwargs, max_conns = self.strat.special_case_mysql(u, kwargs) self.assertEqual([ str(u), max_conns, self.filter_kwargs(kwargs) ], [ "mysql:///dbname?charset=utf8&use_unicode=True", None, self.mysql_kwargs ]) def test_mysql_bad_charset(self): u = url.make_url("mysql:///dbname?charset=ebcdic") kwargs = dict(basedir='my-base-dir') self.assertRaises(TypeError, lambda : self.strat.special_case_mysql(u, kwargs)) def test_mysql_good_use_unicode(self): u = url.make_url("mysql:///dbname?use_unicode=True") kwargs = dict(basedir='my-base-dir') u, kwargs, max_conns = self.strat.special_case_mysql(u, kwargs) self.assertEqual([ str(u), max_conns, self.filter_kwargs(kwargs) ], [ "mysql:///dbname?charset=utf8&use_unicode=True", None, self.mysql_kwargs ]) def test_mysql_bad_use_unicode(self): u = url.make_url("mysql:///dbname?use_unicode=maybe") kwargs = dict(basedir='my-base-dir') self.assertRaises(TypeError, lambda : self.strat.special_case_mysql(u, kwargs)) def test_mysql_storage_engine(self): u = url.make_url("mysql:///dbname?storage_engine=foo") kwargs = dict(basedir='my-base-dir') u, kwargs, max_conns = self.strat.special_case_mysql(u, kwargs) exp = self.mysql_kwargs.copy() exp['connect_args'] = dict(init_command='SET storage_engine=foo') self.assertEqual([ str(u), max_conns, self.filter_kwargs(kwargs) ], [ "mysql:///dbname?charset=utf8&use_unicode=True", None, exp ]) class BuildbotEngineStrategy(unittest.TestCase): "Test create_engine by creating a sqlite in-memory db" def test_create_engine(self): engine = enginestrategy.create_engine('sqlite://', basedir="/base") self.assertEqual(engine.scalar("SELECT 13 + 14"), 27) buildbot-0.8.8/buildbot/test/unit/test_db_migrate_versions_011_add_buildrequest_claims.py000066400000000000000000000105011222546025000320370ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.test.util import migration import sqlalchemy as sa from sqlalchemy.engine import reflection class Migration(migration.MigrateTestMixin, unittest.TestCase): def setUp(self): return self.setUpMigrateTest() def tearDown(self): return self.tearDownMigrateTest() def create_tables_thd(self, conn): metadata = sa.MetaData() metadata.bind = conn self.buildsets = sa.Table('buildsets', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('external_idstring', sa.String(256)), sa.Column('reason', sa.String(256)), sa.Column('sourcestampid', sa.Integer, nullable=False), # NOTE: foreign key omitted sa.Column('submitted_at', sa.Integer, nullable=False), sa.Column('complete', sa.SmallInteger, nullable=False, server_default=sa.DefaultClause("0")), sa.Column('complete_at', sa.Integer), sa.Column('results', sa.SmallInteger), ) self.buildsets.create(bind=conn) self.buildrequests = sa.Table('buildrequests', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('buildsetid', sa.Integer, sa.ForeignKey("buildsets.id"), nullable=False), sa.Column('buildername', sa.String(length=256), nullable=False), sa.Column('priority', sa.Integer, nullable=False, server_default=sa.DefaultClause("0")), sa.Column('claimed_at', sa.Integer, server_default=sa.DefaultClause("0")), sa.Column('claimed_by_name', sa.String(length=256)), sa.Column('claimed_by_incarnation', sa.String(length=256)), sa.Column('complete', sa.Integer, server_default=sa.DefaultClause("0")), sa.Column('results', sa.SmallInteger), sa.Column('submitted_at', sa.Integer, nullable=False), sa.Column('complete_at', sa.Integer), ) self.buildrequests.create(bind=conn) idx = sa.Index('buildrequests_buildsetid', self.buildrequests.c.buildsetid) idx.create() idx = sa.Index('buildrequests_buildername', self.buildrequests.c.buildername) idx.create() idx = sa.Index('buildrequests_complete', self.buildrequests.c.complete) idx.create() self.objects = sa.Table("objects", metadata, sa.Column("id", sa.Integer, primary_key=True), sa.Column('name', sa.String(128), nullable=False), sa.Column('class_name', sa.String(128), nullable=False), sa.UniqueConstraint('name', 'class_name', name='object_identity'), ) self.objects.create(bind=conn) # tests def test_migrate(self): def setup_thd(conn): self.create_tables_thd(conn) def verify_thd(conn): # regression test for bug #2158; this is known to be broken on # sqlite (and fixed in db version 016) but expected to work on # other engines. if conn.dialect.name != 'sqlite': insp = reflection.Inspector.from_engine(conn) indexes = insp.get_indexes('buildrequests') self.assertEqual( sorted([ i['name'] for i in indexes ]), sorted([ 'buildrequests_buildername', 'buildrequests_buildsetid', 'buildrequests_complete', ])) return self.do_test_migration(10, 11, setup_thd, verify_thd) buildbot-0.8.8/buildbot/test/unit/test_db_migrate_versions_015_remove_bad_master_objectid.py000066400000000000000000000120251222546025000325170ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.test.util import migration import sqlalchemy as sa class Migration(migration.MigrateTestMixin, unittest.TestCase): def setUp(self): return self.setUpMigrateTest() def tearDown(self): return self.tearDownMigrateTest() def create_tables_thd(self, conn): metadata = sa.MetaData() self.objects = sa.Table("objects", metadata, sa.Column("id", sa.Integer, primary_key=True), sa.Column('name', sa.String(128), nullable=False), sa.Column('class_name', sa.String(128), nullable=False), sa.UniqueConstraint('name', 'class_name', name='object_identity'), ) self.object_state = sa.Table("object_state", metadata, sa.Column("objectid", sa.Integer, sa.ForeignKey('objects.id'), nullable=False), sa.Column("name", sa.String(length=256), nullable=False), sa.Column("value_json", sa.Text, nullable=False), sa.UniqueConstraint('objectid', 'name', name='name_per_object'), ) self.objects.create(bind=conn) self.object_state.create(bind=conn) def insert_old_obj(self, conn): conn.execute(self.objects.insert(), id=21, name='master', class_name='buildbot.master.BuildMaster') conn.execute(self.object_state.insert(), objectid=21, name='last_processed_change', value_json='938') def insert_new_objs(self, conn, count): for id in range(50, 50+count): conn.execute(self.objects.insert(), id=id, name='some_hostname:/base/dir/%d' % id, class_name='BuildMaster') # (this id would be referenced from buildrequests, but that table # doesn't change) def assertObjectState_thd(self, conn, exp_objects=[], exp_object_state=[]): tbl = self.objects res = conn.execute(tbl.select(order_by=tbl.c.id)) got_objects = res.fetchall() tbl = self.object_state res = conn.execute(tbl.select( order_by=[tbl.c.objectid, tbl.c.name])) got_object_state = res.fetchall() self.assertEqual( dict(objects=exp_objects, object_state=exp_object_state), dict(objects=got_objects, object_state=got_object_state)) # tests def test_no_old_id(self): def setup_thd(conn): self.create_tables_thd(conn) self.insert_new_objs(conn, 2) def verify_thd(conn): self.assertObjectState_thd(conn, [ (50, 'some_hostname:/base/dir/50', 'buildbot.master.BuildMaster'), (51, 'some_hostname:/base/dir/51', 'buildbot.master.BuildMaster'), ], []) return self.do_test_migration(14, 15, setup_thd, verify_thd) def test_no_new_id(self): def setup_thd(conn): self.create_tables_thd(conn) self.insert_old_obj(conn) def verify_thd(conn): self.assertObjectState_thd(conn, [], []) return self.do_test_migration(14, 15, setup_thd, verify_thd) def test_one_new_id(self): def setup_thd(conn): self.create_tables_thd(conn) self.insert_old_obj(conn) self.insert_new_objs(conn, 1) def verify_thd(conn): self.assertObjectState_thd(conn, [ (50, 'some_hostname:/base/dir/50', 'buildbot.master.BuildMaster'), ], [ (50, 'last_processed_change', '938'), ]) return self.do_test_migration(14, 15, setup_thd, verify_thd) def test_two_new_ids(self): def setup_thd(conn): self.create_tables_thd(conn) self.insert_old_obj(conn) self.insert_new_objs(conn, 2) def verify_thd(conn): self.assertObjectState_thd(conn, [ (50, 'some_hostname:/base/dir/50', 'buildbot.master.BuildMaster'), (51, 'some_hostname:/base/dir/51', 'buildbot.master.BuildMaster'), ], [ # last_processed_change is just deleted ]) return self.do_test_migration(14, 15, setup_thd, verify_thd) buildbot-0.8.8/buildbot/test/unit/test_db_migrate_versions_016_restore_buildrequest_indices.py000066400000000000000000000056741222546025000331640ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.test.util import migration import sqlalchemy as sa from sqlalchemy.engine import reflection class Migration(migration.MigrateTestMixin, unittest.TestCase): def setUp(self): return self.setUpMigrateTest() def tearDown(self): return self.tearDownMigrateTest() def create_tables_thd(self, conn): metadata = sa.MetaData() metadata.bind = conn self.buildrequests = sa.Table('buildrequests', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('buildsetid', sa.Integer, # foreign key removed nullable=False), sa.Column('buildername', sa.String(length=256), nullable=False), sa.Column('priority', sa.Integer, nullable=False, server_default=sa.DefaultClause("0")), sa.Column('complete', sa.Integer, server_default=sa.DefaultClause("0")), sa.Column('results', sa.SmallInteger), sa.Column('submitted_at', sa.Integer, nullable=False), sa.Column('complete_at', sa.Integer), ) self.buildrequests.create(bind=conn) # these indices should already exist everywhere but on sqlite if conn.dialect.name != 'sqlite': idx = sa.Index('buildrequests_buildsetid', self.buildrequests.c.buildsetid) idx.create() idx = sa.Index('buildrequests_buildername', self.buildrequests.c.buildername) idx.create() idx = sa.Index('buildrequests_complete', self.buildrequests.c.complete) idx.create() # tests def test_migrate(self): def setup_thd(conn): self.create_tables_thd(conn) def verify_thd(conn): insp = reflection.Inspector.from_engine(conn) indexes = insp.get_indexes('buildrequests') self.assertEqual( sorted([ i['name'] for i in indexes ]), sorted([ 'buildrequests_buildername', 'buildrequests_buildsetid', 'buildrequests_complete', ])) return self.do_test_migration(15, 16, setup_thd, verify_thd) buildbot-0.8.8/buildbot/test/unit/test_db_migrate_versions_017_restore_other_indices.py000066400000000000000000000117231222546025000315660ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.test.util import migration import sqlalchemy as sa from sqlalchemy.engine import reflection class Migration(migration.MigrateTestMixin, unittest.TestCase): def setUp(self): return self.setUpMigrateTest() def tearDown(self): return self.tearDownMigrateTest() def create_tables_thd(self, conn): metadata = sa.MetaData() metadata.bind = conn self.changes = sa.Table('changes', metadata, sa.Column('changeid', sa.Integer, primary_key=True), sa.Column('author', sa.String(256), nullable=False), sa.Column('comments', sa.String(1024), nullable=False), sa.Column('is_dir', sa.SmallInteger, nullable=False), sa.Column('branch', sa.String(256)), sa.Column('revision', sa.String(256)), sa.Column('revlink', sa.String(256)), sa.Column('when_timestamp', sa.Integer, nullable=False), sa.Column('category', sa.String(256)), sa.Column('repository', sa.String(length=512), nullable=False, server_default=''), sa.Column('project', sa.String(length=512), nullable=False, server_default=''), ) self.changes.create(bind=conn) self.schedulers = sa.Table("schedulers", metadata, sa.Column('schedulerid', sa.Integer, primary_key=True), sa.Column('name', sa.String(128), nullable=False), sa.Column('class_name', sa.String(128), nullable=False), ) self.schedulers.create(bind=conn) self.users = sa.Table("users", metadata, sa.Column("uid", sa.Integer, primary_key=True), sa.Column("identifier", sa.String(256), nullable=False), sa.Column("bb_username", sa.String(128)), sa.Column("bb_password", sa.String(128)), ) self.users.create(bind=conn) self.objects = sa.Table("objects", metadata, sa.Column("id", sa.Integer, primary_key=True), sa.Column('name', sa.String(128), nullable=False), sa.Column('class_name', sa.String(128), nullable=False), ) self.objects.create() self.object_state = sa.Table("object_state", metadata, sa.Column("objectid", sa.Integer, sa.ForeignKey('objects.id'), nullable=False), sa.Column("name", sa.String(length=256), nullable=False), sa.Column("value_json", sa.Text, nullable=False), ) self.object_state.create() # these indices should already exist everywhere but on sqlite if conn.dialect.name != 'sqlite': sa.Index('name_and_class', self.schedulers.c.name, self.schedulers.c.class_name).create() sa.Index('changes_branch', self.changes.c.branch).create() sa.Index('changes_revision', self.changes.c.revision).create() sa.Index('changes_author', self.changes.c.author).create() sa.Index('changes_category', self.changes.c.category).create() sa.Index('changes_when_timestamp', self.changes.c.when_timestamp).create() # create this index without the unique attribute sa.Index('users_identifier', self.users.c.identifier).create() # tests def test_migrate(self): def setup_thd(conn): self.create_tables_thd(conn) def verify_thd(conn): insp = reflection.Inspector.from_engine(conn) indexes = (insp.get_indexes('changes') + insp.get_indexes('schedulers')) self.assertEqual( sorted([ i['name'] for i in indexes ]), sorted([ 'changes_author', 'changes_branch', 'changes_category', 'changes_revision', 'changes_when_timestamp', 'name_and_class', ])) indexes = insp.get_indexes('users') for idx in indexes: if idx['name'] == 'users_identifier': self.assertTrue(idx['unique']) break else: self.fail("no users_identifier index") return self.do_test_migration(16, 17, setup_thd, verify_thd) buildbot-0.8.8/buildbot/test/unit/test_db_migrate_versions_018_add_sourcestampset.py000066400000000000000000000234451222546025000311020ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa from sqlalchemy.engine import reflection from twisted.python import log from twisted.trial import unittest from buildbot.test.util import migration class Migration(migration.MigrateTestMixin, unittest.TestCase): def setUp(self): return self.setUpMigrateTest() def tearDown(self): return self.tearDownMigrateTest() def create_tables_thd(self, conn): metadata = sa.MetaData() metadata.bind = conn self.buildsets = sa.Table('buildsets', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('external_idstring', sa.String(256)), sa.Column('reason', sa.String(256)), sa.Column('sourcestampid', sa.Integer, nullable=False), # NOTE: foreign key omitted sa.Column('submitted_at', sa.Integer, nullable=False), sa.Column('complete', sa.SmallInteger, nullable=False, server_default=sa.DefaultClause("0")), sa.Column('complete_at', sa.Integer), sa.Column('results', sa.SmallInteger), ) self.buildsets.create(bind=conn) sa.Index('buildsets_complete', self.buildsets.c.complete).create() sa.Index('buildsets_submitted_at', self.buildsets.c.submitted_at).create() self.patches = sa.Table('patches', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('patchlevel', sa.Integer, nullable=False), sa.Column('patch_base64', sa.Text, nullable=False), sa.Column('patch_author', sa.Text, nullable=False), sa.Column('patch_comment', sa.Text, nullable=False), sa.Column('subdir', sa.Text), ) self.patches.create(bind=conn) self.sourcestamps = sa.Table('sourcestamps', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('branch', sa.String(256)), sa.Column('revision', sa.String(256)), sa.Column('patchid', sa.Integer, sa.ForeignKey('patches.id')), sa.Column('repository', sa.String(length=512), nullable=False, server_default=''), sa.Column('project', sa.String(length=512), nullable=False, server_default=''), sa.Column('sourcestampid', sa.Integer, sa.ForeignKey('sourcestamps.id')), ) self.sourcestamps.create(bind=conn) def fill_tables_with_testdata(self, conn, testdata): for bsid, ssid in testdata: self.insert_buildset_sourcestamp(conn, bsid, ssid) def insert_buildset_sourcestamp(self, conn, bsid, sourcestampid): conn.execute(self.buildsets.insert(), id=bsid, externalid_string='', reason = 'just', sourcestampid=sourcestampid, submitted_at=22417200, complete = 0, complete_at=22417200, results=0) conn.execute(self.sourcestamps.insert(), id=sourcestampid, branch='this_branch', revision='this_revision', patchid = None, repository='repo_a', project='') def assertBuildsetSourceStamp_thd(self, conn, exp_buildsets=[], exp_sourcestamps=[]): metadata = sa.MetaData() metadata.bind = conn tbl = sa.Table('buildsets', metadata, autoload=True) res = conn.execute(sa.select([tbl.c.id, tbl.c.sourcestampsetid], order_by=tbl.c.id)) got_buildsets = res.fetchall() tbl = sa.Table('sourcestamps', metadata, autoload=True) res = conn.execute(sa.select([tbl.c.id, tbl.c.sourcestampsetid], order_by=[tbl.c.sourcestampsetid, tbl.c.id])) got_sourcestamps = res.fetchall() self.assertEqual( dict(buildsets=exp_buildsets, sourcestamps=exp_sourcestamps), dict(buildsets=got_buildsets, sourcestamps=got_sourcestamps)) # tests def thd_assertForeignKeys(self, conn, exp, with_constrained_columns=[]): # MySQL does not reflect or use foreign keys, so we can't check.. if conn.dialect.name == 'mysql': return insp = reflection.Inspector.from_engine(conn) fks = orig_fks = insp.get_foreign_keys('buildsets') # filter out constraints including all of the given columns with_constrained_columns = set(with_constrained_columns) fks = sorted([ fk for fk in fks if not with_constrained_columns - set(fk['constrained_columns']) ]) # clean up for fk in fks: del fk['name'] # schema dependent del fk['referred_schema'] # idem # finally, assert if fks != exp: log.msg("got: %r" % (orig_fks,)) self.assertEqual(fks, exp) def test_1_buildsets(self): buildsetdata = [(10, 100),(20, 200),(30, 300)] def setup_thd(conn): self.create_tables_thd(conn) self.fill_tables_with_testdata(conn, buildsetdata) def verify_thd(conn): metadata = sa.MetaData() metadata.bind = conn tbl = sa.Table('buildsets', metadata, autoload=True) self.assertTrue(hasattr(tbl.c, 'sourcestampsetid')) self.thd_assertForeignKeys(conn, [ { 'constrained_columns':['sourcestampsetid'], 'referred_table':'sourcestampsets', 'referred_columns':['id']}, ], with_constrained_columns=['sourcestampsetid']) res = conn.execute(sa.select([tbl.c.id, tbl.c.sourcestampsetid], order_by=tbl.c.id)) got_buildsets = res.fetchall() self.assertEqual(got_buildsets, buildsetdata) return self.do_test_migration(17, 18, setup_thd, verify_thd) def test_2_sourcestamp(self): buildsetdata = [(10, 100),(20, 200),(30, 300)] sourcestampdata = [ (ssid, ssid) for bsid, ssid in buildsetdata ] def setup_thd(conn): self.create_tables_thd(conn) self.fill_tables_with_testdata(conn, buildsetdata) def verify_thd(conn): metadata = sa.MetaData() metadata.bind = conn tbl = sa.Table('sourcestamps', metadata, autoload=True) self.assertTrue(hasattr(tbl.c, 'sourcestampsetid')) self.thd_assertForeignKeys(conn, [ { 'constrained_columns':['sourcestampsetid'], 'referred_table':'sourcestampsets', 'referred_columns':['id']}, ], with_constrained_columns=['sourcestampsetid']) res = conn.execute(sa.select([tbl.c.id, tbl.c.sourcestampsetid], order_by=[tbl.c.sourcestampsetid, tbl.c.id])) got_sourcestamps = res.fetchall() self.assertEqual(got_sourcestamps, sourcestampdata) return self.do_test_migration(17, 18, setup_thd, verify_thd) def test_3_sourcestampset(self): buildsetdata = [(10, 100),(20, 200),(30, 300)] sourcestampsetdata = [ (ssid,) for bsid, ssid in buildsetdata ] def setup_thd(conn): self.create_tables_thd(conn) self.fill_tables_with_testdata(conn, buildsetdata) def verify_thd(conn): metadata = sa.MetaData() metadata.bind = conn tbl = sa.Table('sourcestampsets', metadata, autoload=True) self.assertTrue(hasattr(tbl.c, 'id')) res = conn.execute(sa.select([tbl.c.id],order_by=[tbl.c.id])) got_sourcestampsets = res.fetchall() self.assertEqual(got_sourcestampsets, sourcestampsetdata) return self.do_test_migration(17, 18, setup_thd, verify_thd) def test_4_integrated_migration(self): buildsetdata = [(10, 100),(20, 200),(30, 300)] sourcestampdata = [ (ssid, ssid) for bsid, ssid in buildsetdata ] sourcestampsetdata = [ (ssid,) for bsid, ssid in buildsetdata ] def setup_thd(conn): self.create_tables_thd(conn) self.fill_tables_with_testdata(conn, buildsetdata) def verify_thd(conn): metadata = sa.MetaData() metadata.bind = conn # Test for the buildsets tbl = sa.Table('buildsets', metadata, autoload=True) res = conn.execute(sa.select([tbl.c.id, tbl.c.sourcestampsetid], order_by=tbl.c.id)) got_buildsets = res.fetchall() self.assertEqual(got_buildsets, buildsetdata) # Test for the sourcestamps tbl = sa.Table('sourcestamps', metadata, autoload=True) res = conn.execute(sa.select([tbl.c.id, tbl.c.sourcestampsetid], order_by=[tbl.c.sourcestampsetid, tbl.c.id])) got_sourcestamps = res.fetchall() self.assertEqual(got_sourcestamps, sourcestampdata) tbl = sa.Table('sourcestampsets', metadata, autoload=True) res = conn.execute(sa.select([tbl.c.id],order_by=[tbl.c.id])) got_sourcestampsets = res.fetchall() self.assertEqual(got_sourcestampsets, sourcestampsetdata) return self.do_test_migration(17, 18, setup_thd, verify_thd) buildbot-0.8.8/buildbot/test/unit/test_db_migrate_versions_019_merge_schedulers_to_objects.py000066400000000000000000000112071222546025000327360ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa from twisted.trial import unittest from buildbot.test.util import migration class Migration(migration.MigrateTestMixin, unittest.TestCase): def setUp(self): return self.setUpMigrateTest() def tearDown(self): return self.tearDownMigrateTest() def create_tables_thd(self, conn): metadata = sa.MetaData() metadata.bind = conn changes = sa.Table('changes', metadata, sa.Column('changeid', sa.Integer, primary_key=True), # the rest is unimportant ) changes.create() buildsets = sa.Table('buildsets', metadata, sa.Column('id', sa.Integer, primary_key=True), # the rest is unimportant ) buildsets.create() self.schedulers = sa.Table("schedulers", metadata, sa.Column('schedulerid', sa.Integer, primary_key=True), sa.Column('name', sa.String(128), nullable=False), sa.Column('class_name', sa.String(128), nullable=False), ) self.schedulers.create(bind=conn) sa.Index('name_and_class', self.schedulers.c.name, self.schedulers.c.class_name).create() self.scheduler_changes = sa.Table('scheduler_changes', metadata, sa.Column('schedulerid', sa.Integer, sa.ForeignKey('schedulers.schedulerid')), sa.Column('changeid', sa.Integer, sa.ForeignKey('changes.changeid')), sa.Column('important', sa.SmallInteger), ) self.scheduler_changes.create() sa.Index('scheduler_changes_schedulerid', self.scheduler_changes.c.schedulerid).create() sa.Index('scheduler_changes_changeid', self.scheduler_changes.c.changeid).create() sa.Index('scheduler_changes_unique', self.scheduler_changes.c.schedulerid, self.scheduler_changes.c.changeid, unique=True).create() self.scheduler_upstream_buildsets = sa.Table( 'scheduler_upstream_buildsets', metadata, sa.Column('buildsetid', sa.Integer, sa.ForeignKey('buildsets.id')), sa.Column('schedulerid', sa.Integer, sa.ForeignKey('schedulers.schedulerid')), ) self.scheduler_upstream_buildsets.create() sa.Index('scheduler_upstream_buildsets_buildsetid', self.scheduler_upstream_buildsets.c.buildsetid).create() sa.Index('scheduler_upstream_buildsets_schedulerid', self.scheduler_upstream_buildsets.c.schedulerid).create() self.objects = sa.Table("objects", metadata, sa.Column("id", sa.Integer, primary_key=True), sa.Column('name', sa.String(128), nullable=False), sa.Column('class_name', sa.String(128), nullable=False), ) self.objects.create(bind=conn) sa.Index('object_identity', self.objects.c.name, self.objects.c.class_name, unique=True).create() # tests def test_update(self): # this upgrade script really just drops a bunch of tables, so # there's not much to test! def setup_thd(conn): self.create_tables_thd(conn) def verify_thd(conn): metadata = sa.MetaData() metadata.bind = conn # these tables are gone for tbl in 'schedulers', 'scheduler_upstream_buildsets': try: conn.execute("select * from %s" % tbl) except: pass else: self.fail("%s table still exists" % tbl) # but scheduler_changes is not s_c_tbl = sa.Table("scheduler_changes", metadata, autoload=True) q = sa.select( [ s_c_tbl.c.objectid, s_c_tbl.c.changeid, s_c_tbl.c.important ]) self.assertEqual(conn.execute(q).fetchall(), []) return self.do_test_migration(18, 19, setup_thd, verify_thd) buildbot-0.8.8/buildbot/test/unit/test_db_migrate_versions_020_remove_change_links.py000066400000000000000000000041501222546025000311740ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa from twisted.trial import unittest from buildbot.test.util import migration class Migration(migration.MigrateTestMixin, unittest.TestCase): def setUp(self): return self.setUpMigrateTest() def tearDown(self): return self.tearDownMigrateTest() def create_tables_thd(self, conn): metadata = sa.MetaData() metadata.bind = conn changes = sa.Table('changes', metadata, sa.Column('changeid', sa.Integer, primary_key=True), # the rest is unimportant ) changes.create() # Links (URLs) for changes change_links = sa.Table('change_links', metadata, sa.Column('changeid', sa.Integer, sa.ForeignKey('changes.changeid'), nullable=False), sa.Column('link', sa.String(1024), nullable=False), ) change_links.create() sa.Index('change_links_changeid', change_links.c.changeid).create() # tests def test_update(self): def setup_thd(conn): self.create_tables_thd(conn) def verify_thd(conn): metadata = sa.MetaData() metadata.bind = conn try: conn.execute("select * from change_links") except: pass else: self.fail("change_links still exists") return self.do_test_migration(19, 20, setup_thd, verify_thd) buildbot-0.8.8/buildbot/test/unit/test_db_migrate_versions_021_fix_postgres_sequences.py000066400000000000000000000051451222546025000317670ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa from twisted.trial import unittest from buildbot.test.util import migration class Migration(migration.MigrateTestMixin, unittest.TestCase): def setUp(self): return self.setUpMigrateTest() def tearDown(self): return self.tearDownMigrateTest() cols = [ 'buildrequests.id', 'builds.id', 'buildsets.id', 'changes.changeid', 'patches.id', 'sourcestampsets.id', 'sourcestamps.id', 'objects.id', 'users.uid', ] # tests def test_update(self): def setup_thd(conn): metadata = sa.MetaData() metadata.bind = conn # insert a row into each table, giving an explicit id column so # that the sequence is not advanced correctly, but leave no rows in # one table to test that corner case for i, col in enumerate(self.cols): tbl_name, col_name = col.split('.') tbl = sa.Table(tbl_name, metadata, sa.Column(col_name, sa.Integer, primary_key=True)) tbl.create() if i > 1: conn.execute(tbl.insert(), { col_name : i }) def verify_thd(conn): metadata = sa.MetaData() metadata.bind = conn # try inserting *without* an ID, and verify that the resulting ID # is as expected for i, col in enumerate(self.cols): tbl_name, col_name = col.split('.') tbl = sa.Table(tbl_name, metadata, sa.Column(col_name, sa.Integer, primary_key=True)) r = conn.execute(tbl.insert(), {}) if i > 1: exp = i+1 else: exp = 1 self.assertEqual(r.inserted_primary_key[0], exp) return self.do_test_migration(20, 21, setup_thd, verify_thd) buildbot-0.8.8/buildbot/test/unit/test_db_migrate_versions_022_add_codebase.py000066400000000000000000000132361222546025000275560ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import datetime import sqlalchemy as sa from twisted.trial import unittest from buildbot.test.util import migration from buildbot.util import UTC, datetime2epoch class Migration(migration.MigrateTestMixin, unittest.TestCase): def setUp(self): return self.setUpMigrateTest() def tearDown(self): return self.tearDownMigrateTest() # create tables as they are before migrating to version 019 def create_tables_thd(self, conn): metadata = sa.MetaData() metadata.bind = conn self.sourcestamps = sa.Table('sourcestamps', metadata, sa.Column('id', sa.Integer, primary_key=True), sa.Column('branch', sa.String(256)), sa.Column('revision', sa.String(256)), sa.Column('patchid', sa.Integer), sa.Column('repository', sa.String(length=512), nullable=False, server_default=''), sa.Column('project', sa.String(length=512), nullable=False, server_default=''), sa.Column('sourcestampsetid', sa.Integer), ) self.sourcestamps.create(bind=conn) self.changes = sa.Table('changes', metadata, sa.Column('changeid', sa.Integer, primary_key=True), sa.Column('author', sa.String(256), nullable=False), sa.Column('comments', sa.String(1024), nullable=False), sa.Column('is_dir', sa.SmallInteger, nullable=False), sa.Column('branch', sa.String(256)), sa.Column('revision', sa.String(256)), sa.Column('revlink', sa.String(256)), sa.Column('when_timestamp', sa.Integer, nullable=False), sa.Column('category', sa.String(256)), sa.Column('repository', sa.String(length=512), nullable=False, server_default=''), sa.Column('project', sa.String(length=512), nullable=False, server_default=''), ) self.changes.create(bind=conn) def reload_tables_after_migration(self, conn): metadata = sa.MetaData() metadata.bind = conn self.sourcestamps = sa.Table('sourcestamps', metadata, autoload=True) self.changes = sa.Table('changes', metadata, autoload=True) def fill_tables_with_testdata(self, conn, testdata): for ssid, repo, codebase, cid in testdata: self.insert_sourcestamps_changes(conn, ssid, repo, codebase, cid) def insert_sourcestamps_changes(self, conn, sourcestampid, repository, codebase, changeid): conn.execute(self.sourcestamps.insert(), id=sourcestampid, sourcestampsetid=sourcestampid, branch='this_branch', revision='this_revision', patchid = None, repository=repository, project='', codebase=codebase) dt_when = datetime.datetime(1978, 6, 15, 12, 31, 15, tzinfo=UTC) conn.execute(self.changes.insert(), changeid = changeid, author = 'develop', comments = 'no comment', is_dir = 0, branch = 'default', revision = 'FD56A89', revling = None, when_timestamp = datetime2epoch(dt_when), category = None, repository = repository, codebase = codebase, project = '') def test_changes_has_codebase(self): changesdata = [(1000, 'https://svn.com/repo_a', 'repo_a', 1)] def setup_thd(conn): self.create_tables_thd(conn) def verify_thd(conn): self.reload_tables_after_migration(conn) tbl = self.changes self.assertTrue(hasattr(tbl.c, 'codebase'), 'Column codebase not found') # insert data in the table and new column self.fill_tables_with_testdata(conn, changesdata) res = conn.execute(sa.select([tbl.c.changeid, tbl.c.repository, tbl.c.codebase, ])) got_changes = res.fetchall() self.assertEqual(got_changes, [(1, 'https://svn.com/repo_a', 'repo_a')]) return self.do_test_migration(21, 22, setup_thd, verify_thd) def test_sourcestamps_has_codebase(self): changesdata = [(1000, 'https://svn.com/repo_a', 'repo_a', 1)] def setup_thd(conn): self.create_tables_thd(conn) def verify_thd(conn): self.reload_tables_after_migration(conn) tbl = self.sourcestamps self.assertTrue(hasattr(tbl.c, 'codebase'), 'Column codebase not found') # insert data in the table and new column self.fill_tables_with_testdata(conn, changesdata) res = conn.execute(sa.select([tbl.c.id, tbl.c.repository, tbl.c.codebase,])) got_sourcestamps = res.fetchall() self.assertEqual(got_sourcestamps, [(1000, 'https://svn.com/repo_a', 'repo_a')]) return self.do_test_migration(21, 22, setup_thd, verify_thd) buildbot-0.8.8/buildbot/test/unit/test_db_model.py000066400000000000000000000041051222546025000222510ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os import mock from twisted.trial import unittest from twisted.internet import defer from buildbot.db import model, enginestrategy from buildbot.test.util import db class DBConnector_Basic(db.RealDatabaseMixin, unittest.TestCase): """ Basic tests of the DBConnector class - all start with an empty DB """ def setUp(self): d = self.setUpRealDatabase() def make_fake_pool(_): engine = enginestrategy.create_engine(self.db_url, basedir=os.path.abspath('basedir')) # mock out the pool, and set up the model self.db = mock.Mock() self.db.pool.do_with_engine = lambda thd : defer.maybeDeferred(thd,engine) self.db.model = model.Model(self.db) self.db.start() d.addCallback(make_fake_pool) return d def tearDown(self): self.db.stop() return self.tearDownRealDatabase() def test_is_current_empty(self): d = self.db.model.is_current() d.addCallback(lambda r : self.assertFalse(r)) return d def test_is_current_full(self): d = self.db.model.upgrade() d.addCallback(lambda _ : self.db.model.is_current()) d.addCallback(lambda r : self.assertTrue(r)) return d # the upgrade method is very well-tested by the integration tests; the # remainder of the object is just tables. buildbot-0.8.8/buildbot/test/unit/test_db_pool.py000066400000000000000000000141121222546025000221210ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os import time import sqlalchemy as sa from twisted.trial import unittest from twisted.internet import defer, reactor from buildbot.db import pool from buildbot.test.util import db class Basic(unittest.TestCase): # basic tests, just using an in-memory SQL db and one thread def setUp(self): self.engine = sa.create_engine('sqlite://') self.engine.optimal_thread_pool_size = 1 self.pool = pool.DBThreadPool(self.engine) def tearDown(self): self.pool.shutdown() def test_do(self): def add(conn, addend1, addend2): rp = conn.execute("SELECT %d + %d" % (addend1, addend2)) return rp.scalar() d = self.pool.do(add, 10, 11) def check(res): self.assertEqual(res, 21) d.addCallback(check) return d def test_do_error(self): def fail(conn): rp = conn.execute("EAT COOKIES") return rp.scalar() d = self.pool.do(fail) return self.assertFailure(d, sa.exc.OperationalError) def test_do_exception(self): def raise_something(conn): raise RuntimeError("oh noes") d = self.pool.do(raise_something) return self.assertFailure(d, RuntimeError) def test_do_with_engine(self): def add(engine, addend1, addend2): rp = engine.execute("SELECT %d + %d" % (addend1, addend2)) return rp.scalar() d = self.pool.do_with_engine(add, 10, 11) def check(res): self.assertEqual(res, 21) d.addCallback(check) return d def test_do_with_engine_exception(self): def fail(engine): rp = engine.execute("EAT COOKIES") return rp.scalar() d = self.pool.do_with_engine(fail) return self.assertFailure(d, sa.exc.OperationalError) def test_persistence_across_invocations(self): # NOTE: this assumes that both methods are called with the same # connection; if they run in parallel threads then it is not valid to # assume that the database engine will have finalized the first # transaction (and thus created the table) by the time the second # transaction runs. This is why we set optimal_thread_pool_size in # setUp. d = defer.succeed(None) def create_table(engine): engine.execute("CREATE TABLE tmp ( a integer )") d.addCallback( lambda r : self.pool.do_with_engine(create_table)) def insert_into_table(engine): engine.execute("INSERT INTO tmp values ( 1 )") d.addCallback( lambda r : self.pool.do_with_engine(insert_into_table)) return d class Stress(unittest.TestCase): def setUp(self): setup_engine = sa.create_engine('sqlite:///test.sqlite') setup_engine.execute("pragma journal_mode = wal") setup_engine.execute("CREATE TABLE test (a integer, b integer)") self.engine = sa.create_engine('sqlite:///test.sqlite') self.engine.optimal_thread_pool_size = 2 self.pool = pool.DBThreadPool(self.engine) def tearDown(self): self.pool.shutdown() os.unlink("test.sqlite") @defer.inlineCallbacks def test_inserts(self): def write(conn): trans = conn.begin() conn.execute("INSERT INTO test VALUES (1, 1)") time.sleep(31) trans.commit() d1 = self.pool.do(write) def write2(conn): trans = conn.begin() conn.execute("INSERT INTO test VALUES (1, 1)") trans.commit() d2 = defer.Deferred() d2.addCallback(lambda _ : self.pool.do(write2)) reactor.callLater(0.1, d2.callback, None) yield defer.DeferredList([ d1, d2 ]) # don't run this test, since it takes 30s del test_inserts class BasicWithDebug(Basic): # same thing, but with debug=True def setUp(self): pool.debug = True return Basic.setUp(self) def tearDown(self): pool.debug = False return Basic.tearDown(self) class Native(unittest.TestCase, db.RealDatabaseMixin): # similar tests, but using the BUILDBOT_TEST_DB_URL def setUp(self): d = self.setUpRealDatabase(want_pool=False) def make_pool(_): self.pool = pool.DBThreadPool(self.db_engine) d.addCallback(make_pool) return d def tearDown(self): # try to delete the 'native_tests' table meta = sa.MetaData() native_tests = sa.Table("native_tests", meta) def thd(conn): native_tests.drop(bind=self.db_engine, checkfirst=True) d = self.pool.do(thd) d.addCallback(lambda _ : self.pool.shutdown()) d.addCallback(lambda _ : self.tearDownRealDatabase()) return d def test_ddl_and_queries(self): meta = sa.MetaData() native_tests = sa.Table("native_tests", meta, sa.Column('name', sa.String(length=200))) # perform a DDL operation and immediately try to access that table; # this has caused problems in the past, so this is basically a # regression test. def ddl(conn): t = conn.begin() native_tests.create(bind=conn) t.commit() d = self.pool.do(ddl) def access(conn): native_tests.insert(bind=conn).execute([ {'name':'foo'} ]) d.addCallback(lambda _ : self.pool.do(access)) return d buildbot-0.8.8/buildbot/test/unit/test_db_schedulers.py000066400000000000000000000146031222546025000233160ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.db import schedulers from buildbot.test.util import connector_component from buildbot.test.fake import fakedb class TestSchedulersConnectorComponent( connector_component.ConnectorComponentMixin, unittest.TestCase): def setUp(self): d = self.setUpConnectorComponent( table_names=['changes', 'objects', 'scheduler_changes' ]) def finish_setup(_): self.db.schedulers = \ schedulers.SchedulersConnectorComponent(self.db) d.addCallback(finish_setup) return d def tearDown(self): return self.tearDownConnectorComponent() def checkScheduler(self, objectid, name, class_name): def thd(conn): q = self.db.model.schedulers.select( whereclause=(self.db.model.schedulers.c.objectid == objectid)) for row in conn.execute(q): self.assertEqual([ row.objectid, row.name, row.class_name], [ objectid, name, class_name]) return self.db.pool.do(thd) # test data change3 = fakedb.Change(changeid=3) change4 = fakedb.Change(changeid=4) change5 = fakedb.Change(changeid=5) change6 = fakedb.Change(changeid=6, branch='sql') scheduler24 = fakedb.Object(id=24) def addClassifications(self, _, objectid, *classifications): def thd(conn): q = self.db.model.scheduler_changes.insert() conn.execute(q, [ dict(changeid=c[0], objectid=objectid, important=c[1]) for c in classifications ]) return self.db.pool.do(thd) # tests def test_classifyChanges(self): d = self.insertTestData([ self.change3, self.change4, self.scheduler24 ]) d.addCallback(lambda _ : self.db.schedulers.classifyChanges(24, { 3 : False, 4: True })) def check(_): def thd(conn): sch_chgs_tbl = self.db.model.scheduler_changes q = sch_chgs_tbl.select(order_by=sch_chgs_tbl.c.changeid) r = conn.execute(q) rows = [ (row.objectid, row.changeid, row.important) for row in r.fetchall() ] self.assertEqual(rows, [ (24, 3, 0), (24, 4, 1) ]) return self.db.pool.do(thd) d.addCallback(check) return d def test_classifyChanges_again(self): # test reclassifying changes, which may happen during some timing # conditions d = self.insertTestData([ self.change3, self.scheduler24, fakedb.SchedulerChange(objectid=24, changeid=3, important=0), ]) d.addCallback(lambda _ : self.db.schedulers.classifyChanges(24, { 3 : True })) def check(_): def thd(conn): sch_chgs_tbl = self.db.model.scheduler_changes q = sch_chgs_tbl.select(order_by=sch_chgs_tbl.c.changeid) r = conn.execute(q) rows = [ (row.objectid, row.changeid, row.important) for row in r.fetchall() ] self.assertEqual(rows, [ (24, 3, 1) ]) return self.db.pool.do(thd) d.addCallback(check) return d def test_flushChangeClassifications(self): d = self.insertTestData([ self.change3, self.change4, self.change5, self.scheduler24 ]) d.addCallback(self.addClassifications, 24, (3, 1), (4, 0), (5, 1)) d.addCallback(lambda _ : self.db.schedulers.flushChangeClassifications(24)) def check(_): def thd(conn): q = self.db.model.scheduler_changes.select() rows = conn.execute(q).fetchall() self.assertEqual(rows, []) return self.db.pool.do(thd) d.addCallback(check) return d def test_flushChangeClassifications_less_than(self): d = self.insertTestData([ self.change3, self.change4, self.change5, self.scheduler24 ]) d.addCallback(self.addClassifications, 24, (3, 1), (4, 0), (5, 1)) d.addCallback(lambda _ : self.db.schedulers.flushChangeClassifications(24, less_than=5)) def check(_): def thd(conn): q = self.db.model.scheduler_changes.select() rows = conn.execute(q).fetchall() self.assertEqual([ (r.changeid, r.important) for r in rows], [ (5, 1) ]) return self.db.pool.do(thd) d.addCallback(check) return d def test_getChangeClassifications(self): d = self.insertTestData([ self.change3, self.change4, self.change5, self.change6, self.scheduler24 ]) d.addCallback(self.addClassifications, 24, (3, 1), (4, 0), (5, 1), (6, 1)) d.addCallback(lambda _ : self.db.schedulers.getChangeClassifications(24)) def check(cls): self.assertEqual(cls, { 3 : True, 4 : False, 5 : True, 6: True }) d.addCallback(check) return d def test_getChangeClassifications_branch(self): d = self.insertTestData([ self.change3, self.change4, self.change5, self.change6, self.scheduler24 ]) d.addCallback(self.addClassifications, 24, (3, 1), (4, 0), (5, 1), (6, 1)) d.addCallback(lambda _ : self.db.schedulers.getChangeClassifications(24, branch='sql')) def check(cls): self.assertEqual(cls, { 6 : True }) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_db_sourcestamps.py000066400000000000000000000220721222546025000237040ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.db import sourcestamps from buildbot.test.util import connector_component from buildbot.test.fake import fakedb class TestSourceStampsConnectorComponent( connector_component.ConnectorComponentMixin, unittest.TestCase): def setUp(self): d = self.setUpConnectorComponent( table_names=['changes', 'change_files', 'patches', 'sourcestamp_changes', 'sourcestamps', 'sourcestampsets' ]) def finish_setup(_): self.db.sourcestamps = \ sourcestamps.SourceStampsConnectorComponent(self.db) d.addCallback(finish_setup) return d def tearDown(self): return self.tearDownConnectorComponent() # tests def test_addSourceStamp_simple(self): # add a sourcestampset for referential integrity d = self.insertTestData([ fakedb.SourceStampSet(id=1), ]) d.addCallback(lambda _ : self.db.sourcestamps.addSourceStamp(branch = 'production', revision='abdef', repository='test://repo', codebase='cb', project='stamper', sourcestampsetid=1)) def check(ssid): def thd(conn): # should see one sourcestamp row ss_tbl = self.db.model.sourcestamps r = conn.execute(ss_tbl.select()) rows = [ (row.id, row.branch, row.revision, row.patchid, row.repository, row.codebase, row.project, row.sourcestampsetid) for row in r.fetchall() ] self.assertEqual(rows, [ ( ssid, 'production', 'abdef', None, 'test://repo', 'cb', 'stamper', 1) ]) # .. and no sourcestamp_changes ssc_tbl = self.db.model.sourcestamp_changes r = conn.execute(ssc_tbl.select()) rows = [ 1 for row in r.fetchall() ] self.assertEqual(rows, []) return self.db.pool.do(thd) d.addCallback(check) return d def test_addSourceStamp_changes(self): # add some sample changes and a sourcestampset for referential integrity d = self.insertTestData([ fakedb.SourceStampSet(id=1), fakedb.Change(changeid=3), fakedb.Change(changeid=4), ]) d.addCallback(lambda _ : self.db.sourcestamps.addSourceStamp(branch = 'production', revision='abdef', repository='test://repo', codebase='cb', project='stamper', sourcestampsetid=1, changeids=[3,4])) def check(ssid): def thd(conn): # should see one sourcestamp row ss_tbl = self.db.model.sourcestamps r = conn.execute(ss_tbl.select()) rows = [ (row.id, row.branch, row.revision, row.patchid, row.repository, row.codebase, row.project, row.sourcestampsetid) for row in r.fetchall() ] self.assertEqual(rows, [ ( ssid, 'production', 'abdef', None, 'test://repo', 'cb', 'stamper', 1) ]) # .. and two sourcestamp_changes ssc_tbl = self.db.model.sourcestamp_changes r = conn.execute(ssc_tbl.select()) rows = [ (row.sourcestampid, row.changeid) for row in r.fetchall() ] self.assertEqual(sorted(rows), [ (ssid, 3), (ssid, 4) ]) return self.db.pool.do(thd) d.addCallback(check) return d def test_addSourceStamp_patch(self): # add a sourcestampset for referential integrity d = self.insertTestData([ fakedb.SourceStampSet(id=1), ]) d.addCallback(lambda _ : self.db.sourcestamps.addSourceStamp(branch = 'production', revision='abdef', repository='test://repo', codebase='cb', project='stamper', sourcestampsetid=1, patch_body='my patch', patch_level=3, patch_subdir='master/', patch_author='me', patch_comment="comment")) def check(ssid): def thd(conn): # should see one sourcestamp row ss_tbl = self.db.model.sourcestamps r = conn.execute(ss_tbl.select()) rows = [ (row.id, row.branch, row.revision, row.patchid, row.repository, row.codebase, row.project, row.sourcestampsetid) for row in r.fetchall() ] patchid = row.patchid self.assertNotEqual(patchid, None) self.assertEqual(rows, [ ( ssid, 'production', 'abdef', patchid, 'test://repo', 'cb', 'stamper', 1) ]) # .. and a single patch patches_tbl = self.db.model.patches r = conn.execute(patches_tbl.select()) rows = [ (row.id, row.patchlevel, row.patch_base64, row.subdir, row.patch_author, row.patch_comment) for row in r.fetchall() ] self.assertEqual(rows, [(patchid, 3, 'bXkgcGF0Y2g=', 'master/', 'me', 'comment')]) return self.db.pool.do(thd) d.addCallback(check) return d def test_getSourceStamp_simple(self): d = self.insertTestData([ fakedb.SourceStampSet(id=234), fakedb.SourceStamp(id=234, sourcestampsetid=234, branch='br', revision='rv', repository='rep', codebase='cb', project='prj'), ]) d.addCallback(lambda _ : self.db.sourcestamps.getSourceStamp(234)) def check(ssdict): self.assertEqual(ssdict, dict(ssid=234, branch='br', revision='rv', sourcestampsetid=234, repository='rep', codebase = 'cb', project='prj', patch_body=None, patch_level=None, patch_subdir=None, patch_author=None, patch_comment=None, changeids=set([]))) d.addCallback(check) return d def test_getSourceStamp_simple_None(self): "check that NULL branch and revision are handled correctly" d = self.insertTestData([ fakedb.SourceStampSet(id=234), fakedb.SourceStamp(id=234, sourcestampsetid=234, branch=None, revision=None, repository='rep', codebase='cb', project='prj'), ]) d.addCallback(lambda _ : self.db.sourcestamps.getSourceStamp(234)) def check(ssdict): self.assertEqual((ssdict['branch'], ssdict['revision']), (None, None)) d.addCallback(check) return d def test_getSourceStamp_changes(self): d = self.insertTestData([ fakedb.Change(changeid=16), fakedb.Change(changeid=19), fakedb.Change(changeid=20), fakedb.SourceStampSet(id=234), fakedb.SourceStamp(id=234, sourcestampsetid=234), fakedb.SourceStampChange(sourcestampid=234, changeid=16), fakedb.SourceStampChange(sourcestampid=234, changeid=20), ]) d.addCallback(lambda _ : self.db.sourcestamps.getSourceStamp(234)) def check(ssdict): self.assertEqual(ssdict['changeids'], set([16,20])) d.addCallback(check) return d def test_getSourceStamp_patch(self): d = self.insertTestData([ fakedb.Patch(id=99, patch_base64='aGVsbG8sIHdvcmxk', patch_author='bar', patch_comment='foo', subdir='/foo', patchlevel=3), fakedb.SourceStampSet(id=234), fakedb.SourceStamp(id=234, sourcestampsetid=234, patchid=99), ]) d.addCallback(lambda _ : self.db.sourcestamps.getSourceStamp(234)) def check(ssdict): self.assertEqual(dict((k,v) for k,v in ssdict.iteritems() if k.startswith('patch_')), dict(patch_body='hello, world', patch_level=3, patch_author='bar', patch_comment='foo', patch_subdir='/foo')) d.addCallback(check) return d def test_getSourceStamp_nosuch(self): d = self.db.sourcestamps.getSourceStamp(234) def check(ssdict): self.assertEqual(ssdict, None) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_db_sourcestampsets.py000066400000000000000000000043371222546025000244240ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.internet import defer from buildbot.db import sourcestampsets from buildbot.test.util import connector_component class TestSourceStampSetsConnectorComponent( connector_component.ConnectorComponentMixin, unittest.TestCase): def setUp(self): d = self.setUpConnectorComponent( table_names=[ 'patches', 'buildsets', 'sourcestamps', 'sourcestampsets' ]) def finish_setup(_): self.db.sourcestampsets = \ sourcestampsets.SourceStampSetsConnectorComponent(self.db) d.addCallback(finish_setup) return d def tearDown(self): return self.tearDownConnectorComponent() # tests def test_addSourceStampSet_simple(self): d = defer.succeed(None) d.addCallback(lambda _ : self.db.sourcestampsets.addSourceStampSet()) def check(sourcestampsetid): def thd(conn): # should see one sourcestamp row ssset_tbl = self.db.model.sourcestampsets r = conn.execute(ssset_tbl.select()) rows = [ (row.id) for row in r.fetchall() ] # Test if returned setid is in database self.assertEqual(rows, [ ( sourcestampsetid) ]) # Test if returned set id starts with self.assertEqual(sourcestampsetid, 1) return self.db.pool.do(thd) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_db_state.py000066400000000000000000000147571222546025000223070ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.db import state from buildbot.test.util import connector_component from buildbot.test.fake import fakedb class TestStateConnectorComponent( connector_component.ConnectorComponentMixin, unittest.TestCase): def setUp(self): d = self.setUpConnectorComponent( table_names=['objects', 'object_state' ]) def finish_setup(_): self.db.state = \ state.StateConnectorComponent(self.db) d.addCallback(finish_setup) return d def tearDown(self): return self.tearDownConnectorComponent() def test_getObjectId_new(self): d = self.db.state.getObjectId('someobj', 'someclass') def check(objectid): self.assertNotEqual(objectid, None) def thd(conn): q = self.db.model.objects.select() rows = conn.execute(q).fetchall() self.assertEqual( [ (r.id, r.name, r.class_name) for r in rows ], [ (objectid, 'someobj', 'someclass') ]) return self.db.pool.do(thd) d.addCallback(check) return d def test_getObjectId_existing(self): d = self.insertTestData([ fakedb.Object(id=19, name='someobj', class_name='someclass') ]) d.addCallback(lambda _ : self.db.state.getObjectId('someobj', 'someclass')) def check(objectid): self.assertEqual(objectid, 19) d.addCallback(check) return d def test_getObjectId_conflict(self): # set up to insert a row between looking for an existing object # and adding a new one, triggering the fallback to re-running # the select. def hook(conn): conn.execute(self.db.model.objects.insert(), id=27, name='someobj', class_name='someclass') self.db.state._test_timing_hook = hook d = self.db.state.getObjectId('someobj', 'someclass') def check(objectid): self.assertEqual(objectid, 27) d.addCallback(check) return d def test_getState_missing(self): d = self.db.state.getState(10, 'nosuch') return self.assertFailure(d, KeyError) def test_getState_missing_default(self): d = self.db.state.getState(10, 'nosuch', 'abc') def check(val): self.assertEqual(val, 'abc') d.addCallback(check) return d def test_getState_missing_default_None(self): d = self.db.state.getState(10, 'nosuch', None) def check(val): self.assertEqual(val, None) d.addCallback(check) return d def test_getState_present(self): d = self.insertTestData([ fakedb.Object(id=10, name='x', class_name='y'), fakedb.ObjectState(objectid=10, name='x', value_json='[1,2]'), ]) d.addCallback(lambda _ : self.db.state.getState(10, 'x')) def check(val): self.assertEqual(val, [1,2]) d.addCallback(check) return d def test_getState_badjson(self): d = self.insertTestData([ fakedb.Object(id=10, name='x', class_name='y'), fakedb.ObjectState(objectid=10, name='x', value_json='ff[1'), ]) d.addCallback(lambda _ : self.db.state.getState(10, 'x')) return self.assertFailure(d, TypeError) def test_setState(self): d = self.insertTestData([ fakedb.Object(id=10, name='-', class_name='-'), ]) d.addCallback(lambda _ : self.db.state.setState(10, 'x', [1,2])) def check(_): def thd(conn): q = self.db.model.object_state.select() rows = conn.execute(q).fetchall() self.assertEqual( [ (r.objectid, r.name, r.value_json) for r in rows ], [ (10, 'x', '[1, 2]') ]) return self.db.pool.do(thd) d.addCallback(check) return d def test_setState_badjson(self): d = self.insertTestData([ fakedb.Object(id=10, name='x', class_name='y'), ]) d.addCallback(lambda _ : self.db.state.setState(10, 'x', self)) # self is not JSON-able.. return self.assertFailure(d, TypeError) def test_setState_existing(self): d = self.insertTestData([ fakedb.Object(id=10, name='-', class_name='-'), fakedb.ObjectState(objectid=10, name='x', value_json='99'), ]) d.addCallback(lambda _ : self.db.state.setState(10, 'x', [1,2])) def check(_): def thd(conn): q = self.db.model.object_state.select() rows = conn.execute(q).fetchall() self.assertEqual( [ (r.objectid, r.name, r.value_json) for r in rows ], [ (10, 'x', '[1, 2]') ]) return self.db.pool.do(thd) d.addCallback(check) return d def test_setState_conflict(self): d = self.insertTestData([ fakedb.Object(id=10, name='-', class_name='-'), ]) def hook(conn): conn.execute(self.db.model.object_state.insert(), objectid=10, name='x', value_json='22') self.db.state._test_timing_hook = hook d.addCallback(lambda _ : self.db.state.setState(10, 'x', [1,2])) def check(_): def thd(conn): q = self.db.model.object_state.select() rows = conn.execute(q).fetchall() self.assertEqual( [ (r.objectid, r.name, r.value_json) for r in rows ], [ (10, 'x', '22') ]) return self.db.pool.do(thd) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_db_users.py000066400000000000000000000430611222546025000223160ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa from twisted.trial import unittest from buildbot.db import users from buildbot.test.util import connector_component from buildbot.test.fake import fakedb class TestUsersConnectorComponent(connector_component.ConnectorComponentMixin, unittest.TestCase): def setUp(self): d = self.setUpConnectorComponent( table_names=['users', 'users_info', 'changes', 'change_users']) def finish_setup(_): self.db.users = users.UsersConnectorComponent(self.db) d.addCallback(finish_setup) return d def tearDown(self): return self.tearDownConnectorComponent() # sample user data user1_rows = [ fakedb.User(uid=1, identifier='soap'), fakedb.UserInfo(uid=1, attr_type='IPv9', attr_data='0578cc6.8db024'), ] user2_rows = [ fakedb.User(uid=2, identifier='lye'), fakedb.UserInfo(uid=2, attr_type='git', attr_data='Tyler Durden '), fakedb.UserInfo(uid=2, attr_type='irc', attr_data='durden') ] user3_rows = [ fakedb.User(uid=3, identifier='marla', bb_username='marla', bb_password='cancer') ] user1_dict = { 'uid': 1, 'identifier': u'soap', 'bb_username': None, 'bb_password': None, 'IPv9': u'0578cc6.8db024', } user2_dict = { 'uid': 2, 'identifier': u'lye', 'bb_username': None, 'bb_password': None, 'irc': u'durden', 'git': u'Tyler Durden ' } user3_dict = { 'uid': 3, 'identifier': u'marla', 'bb_username': u'marla', 'bb_password': u'cancer', } # tests def test_addUser_new(self): d = self.db.users.findUserByAttr(identifier='soap', attr_type='subspace_net_handle', attr_data='Durden0924') def check_user(uid): def thd(conn): users_tbl = self.db.model.users users_info_tbl = self.db.model.users_info users = conn.execute(users_tbl.select()).fetchall() infos = conn.execute(users_info_tbl.select()).fetchall() self.assertEqual(len(users), 1) self.assertEqual(users[0].uid, uid) self.assertEqual(users[0].identifier, 'soap') self.assertEqual(len(infos), 1) self.assertEqual(infos[0].uid, uid) self.assertEqual(infos[0].attr_type, 'subspace_net_handle') self.assertEqual(infos[0].attr_data, 'Durden0924') return self.db.pool.do(thd) d.addCallback(check_user) return d def test_addUser_existing(self): d = self.insertTestData(self.user1_rows) d.addCallback(lambda _ : self.db.users.findUserByAttr( identifier='soapy', attr_type='IPv9', attr_data='0578cc6.8db024')) def check_user(uid): self.assertEqual(uid, 1) def thd(conn): users_tbl = self.db.model.users users_info_tbl = self.db.model.users_info users = conn.execute(users_tbl.select()).fetchall() infos = conn.execute(users_info_tbl.select()).fetchall() self.assertEqual(len(users), 1) self.assertEqual(users[0].uid, uid) self.assertEqual(users[0].identifier, 'soap') # not changed! self.assertEqual(len(infos), 1) self.assertEqual(infos[0].uid, uid) self.assertEqual(infos[0].attr_type, 'IPv9') self.assertEqual(infos[0].attr_data, '0578cc6.8db024') return self.db.pool.do(thd) d.addCallback(check_user) return d def test_findUser_existing(self): d = self.insertTestData( self.user1_rows + self.user2_rows + self.user3_rows) d.addCallback(lambda _ : self.db.users.findUserByAttr( identifier='lye', attr_type='git', attr_data='Tyler Durden ')) def check_user(uid): self.assertEqual(uid, 2) def thd(conn): users_tbl = self.db.model.users users_info_tbl = self.db.model.users_info users = conn.execute(users_tbl.select()).fetchall() infos = conn.execute(users_info_tbl.select()).fetchall() self.assertEqual(( sorted([ tuple(u) for u in users]), sorted([ tuple(i) for i in infos]) ), ( [ (1L, u'soap', None, None), (2L, u'lye', None, None), (3L, u'marla', u'marla', u'cancer'), ], [ (1L, u'IPv9', u'0578cc6.8db024'), (2L, u'git', u'Tyler Durden '), (2L, u'irc', u'durden') ])) return self.db.pool.do(thd) d.addCallback(check_user) return d def test_addUser_race(self): def race_thd(conn): # note that this assumes that both inserts can happen "at once". # This is the case for DB engines that support transactions, but # not for MySQL. so this test does not detect the potential MySQL # failure, which will generally result in a spurious failure. conn.execute(self.db.model.users.insert(), uid=99, identifier='soap') conn.execute(self.db.model.users_info.insert(), uid=99, attr_type='subspace_net_handle', attr_data='Durden0924') d = self.db.users.findUserByAttr(identifier='soap', attr_type='subspace_net_handle', attr_data='Durden0924', _race_hook=race_thd) def check_user(uid): self.assertEqual(uid, 99) def thd(conn): users_tbl = self.db.model.users users_info_tbl = self.db.model.users_info users = conn.execute(users_tbl.select()).fetchall() infos = conn.execute(users_info_tbl.select()).fetchall() self.assertEqual(len(users), 1) self.assertEqual(users[0].uid, uid) self.assertEqual(users[0].identifier, 'soap') self.assertEqual(len(infos), 1) self.assertEqual(infos[0].uid, uid) self.assertEqual(infos[0].attr_type, 'subspace_net_handle') self.assertEqual(infos[0].attr_data, 'Durden0924') return self.db.pool.do(thd) d.addCallback(check_user) return d def test_addUser_existing_identifier(self): d = self.insertTestData(self.user1_rows) d.addCallback(lambda _ : self.db.users.findUserByAttr( identifier='soap', attr_type='telepathIO(tm)', attr_data='hmm,lye')) return self.assertFailure(d, sa.exc.IntegrityError, sa.exc.ProgrammingError) def test_getUser(self): d = self.insertTestData(self.user1_rows) def get1(_): return self.db.users.getUser(1) d.addCallback(get1) def check1(usdict): self.assertEqual(usdict, self.user1_dict) d.addCallback(check1) return d def test_getUser_bb(self): d = self.insertTestData(self.user3_rows) def get3(_): return self.db.users.getUser(3) d.addCallback(get3) def check3(usdict): self.assertEqual(usdict, self.user3_dict) d.addCallback(check3) return d def test_getUser_multi_attr(self): d = self.insertTestData(self.user2_rows) def get1(_): return self.db.users.getUser(2) d.addCallback(get1) def check1(usdict): self.assertEqual(usdict, self.user2_dict) d.addCallback(check1) return d def test_getUser_no_match(self): d = self.insertTestData(self.user1_rows) def get3(_): return self.db.users.getUser(3) d.addCallback(get3) def check3(none): self.assertEqual(none, None) d.addCallback(check3) return d def test_getUsers_none(self): d = self.db.users.getUsers() def check(res): self.assertEqual(res, []) d.addCallback(check) return d def test_getUsers(self): d = self.insertTestData(self.user1_rows) def get(_): return self.db.users.getUsers() d.addCallback(get) def check(res): self.assertEqual(res, [dict(uid=1, identifier='soap')]) d.addCallback(check) return d def test_getUsers_multiple(self): d = self.insertTestData(self.user1_rows + self.user2_rows) def get(_): return self.db.users.getUsers() d.addCallback(get) def check(res): self.assertEqual(res, [dict(uid=1, identifier='soap'), dict(uid=2, identifier='lye')]) d.addCallback(check) return d def test_getUserByUsername(self): d = self.insertTestData(self.user3_rows) def get3(_): return self.db.users.getUserByUsername("marla") d.addCallback(get3) def check3(res): self.assertEqual(res, self.user3_dict) d.addCallback(check3) return d def test_getUserByUsername_no_match(self): d = self.insertTestData(self.user3_rows) def get3(_): return self.db.users.getUserByUsername("tyler") d.addCallback(get3) def check3(none): self.assertEqual(none, None) d.addCallback(check3) return d def test_updateUser_existing_type(self): d = self.insertTestData(self.user1_rows) def update1(_): return self.db.users.updateUser( uid=1, attr_type='IPv9', attr_data='abcd.1234') d.addCallback(update1) def get1(_): return self.db.users.getUser(1) d.addCallback(get1) def check1(usdict): self.assertEqual(usdict['IPv9'], 'abcd.1234') self.assertEqual(usdict['identifier'], 'soap') # no change d.addCallback(check1) return d def test_updateUser_new_type(self): d = self.insertTestData(self.user1_rows) def update1(_): return self.db.users.updateUser( uid=1, attr_type='IPv4', attr_data='123.134.156.167') d.addCallback(update1) def get1(_): return self.db.users.getUser(1) d.addCallback(get1) def check1(usdict): self.assertEqual(usdict['IPv4'], '123.134.156.167') self.assertEqual(usdict['IPv9'], '0578cc6.8db024') # no change self.assertEqual(usdict['identifier'], 'soap') # no change d.addCallback(check1) return d def test_updateUser_identifier(self): d = self.insertTestData(self.user1_rows) def update1(_): return self.db.users.updateUser( uid=1, identifier='lye') d.addCallback(update1) def get1(_): return self.db.users.getUser(1) d.addCallback(get1) def check1(usdict): self.assertEqual(usdict['identifier'], 'lye') self.assertEqual(usdict['IPv9'], '0578cc6.8db024') # no change d.addCallback(check1) return d def test_updateUser_bb(self): d = self.insertTestData(self.user3_rows) def update3(_): return self.db.users.updateUser( uid=3, bb_username='boss', bb_password='fired') d.addCallback(update3) def get3(_): return self.db.users.getUser(3) d.addCallback(get3) def check3(usdict): self.assertEqual(usdict['bb_username'], 'boss') self.assertEqual(usdict['bb_password'], 'fired') self.assertEqual(usdict['identifier'], 'marla') # no change d.addCallback(check3) return d def test_updateUser_all(self): d = self.insertTestData(self.user1_rows) def update1(_): return self.db.users.updateUser( uid=1, identifier='lye', bb_username='marla', bb_password='cancer', attr_type='IPv4', attr_data='123.134.156.167') d.addCallback(update1) def get1(_): return self.db.users.getUser(1) d.addCallback(get1) def check1(usdict): self.assertEqual(usdict['identifier'], 'lye') self.assertEqual(usdict['bb_username'], 'marla') self.assertEqual(usdict['bb_password'], 'cancer') self.assertEqual(usdict['IPv4'], '123.134.156.167') self.assertEqual(usdict['IPv9'], '0578cc6.8db024') # no change d.addCallback(check1) return d def test_updateUser_race(self): # called from the db thread, this opens a *new* connection (to avoid # the existing transaction) and executes a conflicting insert in that # connection. This will cause the insert in the db method to fail, and # the data in this insert (8.8.8.8) will appear below. def race_thd(conn): conn = self.db.pool.engine.connect() conn.execute(self.db.model.users_info.insert(), uid=1, attr_type='IPv4', attr_data='8.8.8.8') d = self.insertTestData(self.user1_rows) def update1(_): return self.db.users.updateUser( uid=1, attr_type='IPv4', attr_data='123.134.156.167', _race_hook=race_thd) d.addCallback(update1) def get1(_): return self.db.users.getUser(1) d.addCallback(get1) def check1(usdict): self.assertEqual(usdict['identifier'], 'soap') self.assertEqual(usdict['IPv4'], '8.8.8.8') self.assertEqual(usdict['IPv9'], '0578cc6.8db024') # no change d.addCallback(check1) return d def test_update_NoMatch_identifier(self): d = self.insertTestData(self.user1_rows) def update3(_): return self.db.users.updateUser( uid=3, identifier='abcd') d.addCallback(update3) def get1(_): return self.db.users.getUser(1) d.addCallback(get1) def check1(usdict): self.assertEqual(usdict['identifier'], 'soap') # no change d.addCallback(check1) return d def test_update_NoMatch_attribute(self): d = self.insertTestData(self.user1_rows) def update3(_): return self.db.users.updateUser( uid=3, attr_type='abcd', attr_data='efgh') d.addCallback(update3) def get1(_): return self.db.users.getUser(1) d.addCallback(get1) def check1(usdict): self.assertEqual(usdict['IPv9'], '0578cc6.8db024') # no change d.addCallback(check1) return d def test_update_NoMatch_bb(self): d = self.insertTestData(self.user1_rows) def update3(_): return self.db.users.updateUser( uid=3, attr_type='marla', attr_data='cancer') d.addCallback(update3) def get1(_): return self.db.users.getUser(1) d.addCallback(get1) def check1(usdict): self.assertEqual(usdict['IPv9'], '0578cc6.8db024') # no change d.addCallback(check1) return d def test_removeUser_uid(self): d = self.insertTestData(self.user1_rows) def remove1(_): return self.db.users.removeUser(1) d.addCallback(remove1) def check1(_): def thd(conn): r = conn.execute(self.db.model.users.select()) r = r.fetchall() self.assertEqual(len(r), 0) return self.db.pool.do(thd) d.addCallback(check1) return d def test_removeNoMatch(self): d = self.insertTestData(self.user1_rows) def check(_): return self.db.users.removeUser(uid=3) d.addCallback(check) return d def test_identifierToUid_NoMatch(self): d = self.db.users.identifierToUid(identifier="soap") def check(res): self.assertEqual(res, None) d.addCallback(check) return d def test_identifierToUid_match(self): d = self.insertTestData(self.user1_rows) def ident2uid(_): return self.db.users.identifierToUid(identifier="soap") d.addCallback(ident2uid) def check(res): self.assertEqual(res, 1) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_master.py000066400000000000000000000517771222546025000220200ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os import mock import signal from twisted.internet import defer, reactor, task from twisted.trial import unittest from twisted.python import log from buildbot import master, monkeypatches, config from buildbot.util import subscription from buildbot.db import connector from buildbot.test.util import dirs, compat, misc, logging from buildbot.test.fake import fakedb from buildbot.util import epoch2datetime from buildbot.changes import changes from buildbot.process.users import users class Subscriptions(dirs.DirsMixin, unittest.TestCase): def setUp(self): basedir = os.path.abspath('basedir') d = self.setUpDirs(basedir) def set_master(_): self.master = master.BuildMaster(basedir) self.master.config.db['db_poll_interval'] = None d.addCallback(set_master) return d def tearDown(self): return self.tearDownDirs() def test_change_subscription(self): changeid = 918 chdict = { 'changeid': 14, 'author': u'warner', 'branch': u'warnerdb', 'category': u'devel', 'comments': u'fix whitespace', 'files': [u'master/buildbot/__init__.py'], 'is_dir': 0, 'project': u'Buildbot', 'properties': {}, 'repository': u'git://warner', 'revision': u'0e92a098b', 'revlink': u'http://warner/0e92a098b', 'when_timestamp': epoch2datetime(266738404), } newchange = mock.Mock(name='newchange') # patch out everything we're about to call self.master.db = mock.Mock() self.master.db.changes.addChange.return_value = \ defer.succeed(changeid) self.master.db.changes.getChange.return_value = \ defer.succeed(chdict) self.patch(changes.Change, 'fromChdict', classmethod(lambda cls, master, chdict : defer.succeed(newchange))) cb = mock.Mock() sub = self.master.subscribeToChanges(cb) self.assertIsInstance(sub, subscription.Subscription) d = self.master.addChange() def check(change): # master called the right thing in the db component, including with # appropriate default values self.master.db.changes.addChange.assert_called_with(author=None, files=None, comments=None, is_dir=0, revision=None, when_timestamp=None, branch=None, codebase='', category=None, revlink='', properties={}, repository='', project='', uid=None) self.master.db.changes.getChange.assert_called_with(changeid) # addChange returned the right value self.failUnless(change is newchange) # fromChdict's return value # and the notification sub was called correctly cb.assert_called_with(newchange) d.addCallback(check) return d def do_test_addChange_args(self, args=(), kwargs={}, exp_db_kwargs={}): # add default arguments default_db_kwargs = dict(files=None, comments=None, author=None, is_dir=0, revision=None, when_timestamp=None, branch=None, category=None, revlink='', properties={}, repository='', codebase='', project='', uid=None) k = default_db_kwargs k.update(exp_db_kwargs) exp_db_kwargs = k self.master.db = mock.Mock() got = [] def db_addChange(*args, **kwargs): got[:] = args, kwargs # use an exception as a quick way to bail out of the remainder # of the addChange method return defer.fail(RuntimeError) self.master.db.changes.addChange = db_addChange d = self.master.addChange(*args, **kwargs) d.addCallback(lambda _ : self.fail("should not succeed")) def check(f): self.assertEqual(got, [(), exp_db_kwargs]) d.addErrback(check) return d def test_addChange_args_author(self): # who should come through as author return self.do_test_addChange_args( kwargs=dict(who='me'), exp_db_kwargs=dict(author='me')) def test_addChange_args_isdir(self): # isdir should come through as is_dir return self.do_test_addChange_args( kwargs=dict(isdir=1), exp_db_kwargs=dict(is_dir=1)) def test_addChange_args_when(self): # when should come through as when_timestamp, as a datetime return self.do_test_addChange_args( kwargs=dict(when=892293875), exp_db_kwargs=dict(when_timestamp=epoch2datetime(892293875))) def test_addChange_args_properties(self): # properties should be qualified with a source return self.do_test_addChange_args( kwargs=dict(properties={ 'a' : 'b' }), exp_db_kwargs=dict(properties={ 'a' : ('b', 'Change') })) def test_addChange_args_properties_tuple(self): # properties should be qualified with a source, even if they # already look like they have a source return self.do_test_addChange_args( kwargs=dict(properties={ 'a' : ('b', 'Change') }), exp_db_kwargs=dict(properties={ 'a' : (('b', 'Change'), 'Change') })) def test_addChange_args_positional(self): # master.addChange can take author, files, comments as positional # arguments return self.do_test_addChange_args( args=('me', ['a'], 'com'), exp_db_kwargs=dict(author='me', files=['a'], comments='com')) def do_test_createUserObjects_args(self, args=(), kwargs={}, exp_args=()): got = [] def fake_createUserObject(*args, **kwargs): got[:] = args, kwargs # use an exception as a quick way to bail out of the remainder # of the createUserObject method return defer.fail(RuntimeError) self.patch(users, 'createUserObject', fake_createUserObject) d = self.master.addChange(*args, **kwargs) d.addCallback(lambda _ : self.fail("should not succeed")) def check(f): self.assertEqual(got, [exp_args, {}]) d.addErrback(check) return d def test_addChange_createUserObject_args(self): # who should come through as author return self.do_test_createUserObjects_args( kwargs=dict(who='me', src='git'), exp_args=(self.master, 'me', 'git')) def test_buildset_subscription(self): self.master.db = mock.Mock() self.master.db.buildsets.addBuildset.return_value = \ defer.succeed((938593, dict(a=19,b=20))) cb = mock.Mock() sub = self.master.subscribeToBuildsets(cb) self.assertIsInstance(sub, subscription.Subscription) d = self.master.addBuildset(ssid=999) def check((bsid,brids)): # master called the right thing in the db component self.master.db.buildsets.addBuildset.assert_called_with(ssid=999) # addBuildset returned the right value self.assertEqual((bsid,brids), (938593, dict(a=19,b=20))) # and the notification sub was called correctly cb.assert_called_with(bsid=938593, ssid=999) d.addCallback(check) return d def test_buildset_completion_subscription(self): self.master.db = mock.Mock() cb = mock.Mock() sub = self.master.subscribeToBuildsetCompletions(cb) self.assertIsInstance(sub, subscription.Subscription) self.master._buildsetComplete(938593, 999) # assert the notification sub was called correctly cb.assert_called_with(938593, 999) class StartupAndReconfig(dirs.DirsMixin, logging.LoggingMixin, unittest.TestCase): def setUp(self): self.setUpLogging() self.basedir = os.path.abspath('basedir') d = self.setUpDirs(self.basedir) @d.addCallback def make_master(_): # don't create child services self.patch(master.BuildMaster, 'create_child_services', lambda self : None) # patch out a few other annoying things the master likes to do self.patch(monkeypatches, 'patch_all', lambda : None) self.patch(signal, 'signal', lambda sig, hdlr : None) self.patch(master, 'Status', lambda master : mock.Mock()) # XXX temporary self.patch(config.MasterConfig, 'loadConfig', classmethod(lambda cls, b, f : cls())) self.master = master.BuildMaster(self.basedir) self.db = self.master.db = fakedb.FakeDBConnector(self) return d def tearDown(self): return self.tearDownDirs() def make_reactor(self): r = mock.Mock() r.callWhenRunning = reactor.callWhenRunning return r def patch_loadConfig_fail(self): @classmethod def loadConfig(cls, b, f): config.error('oh noes') self.patch(config.MasterConfig, 'loadConfig', loadConfig) # tests def test_startup_bad_config(self): reactor = self.make_reactor() self.patch_loadConfig_fail() d = self.master.startService(_reactor=reactor) @d.addCallback def check(_): reactor.stop.assert_called() self.assertLogged("oh noes") return d def test_startup_db_not_ready(self): reactor = self.make_reactor() def db_setup(): log.msg("GOT HERE") raise connector.DatabaseNotReadyError() self.db.setup = db_setup d = self.master.startService(_reactor=reactor) @d.addCallback def check(_): reactor.stop.assert_called() self.assertLogged("GOT HERE") return d @compat.usesFlushLoggedErrors def test_startup_error(self): reactor = self.make_reactor() def db_setup(): raise RuntimeError("oh noes") self.db.setup = db_setup d = self.master.startService(_reactor=reactor) @d.addCallback def check(_): reactor.stop.assert_called() self.assertEqual(len(self.flushLoggedErrors(RuntimeError)), 1) return d def test_startup_ok(self): reactor = self.make_reactor() d = self.master.startService(_reactor=reactor) d.addCallback(lambda _ : self.master.stopService()) @d.addCallback def check(_): self.failIf(reactor.stop.called) self.assertLogged("BuildMaster is running") return d def test_reconfig(self): reactor = self.make_reactor() self.master.reconfigService = mock.Mock( side_effect=lambda n : defer.succeed(None)) d = self.master.startService(_reactor=reactor) d.addCallback(lambda _ : self.master.reconfig()) d.addCallback(lambda _ : self.master.stopService()) @d.addCallback def check(_): self.master.reconfigService.assert_called() return d @defer.inlineCallbacks def test_reconfig_bad_config(self): reactor = self.make_reactor() self.master.reconfigService = mock.Mock( side_effect=lambda n : defer.succeed(None)) yield self.master.startService(_reactor=reactor) # reset, since startService called reconfigService self.master.reconfigService.reset_mock() # reconfig, with a failure self.patch_loadConfig_fail() yield self.master.reconfig() self.master.stopService() self.assertLogged("reconfig aborted without") self.failIf(self.master.reconfigService.called) @defer.inlineCallbacks def test_reconfigService_db_url_changed(self): old = self.master.config = config.MasterConfig() old.db['db_url'] = 'aaaa' yield self.master.reconfigService(old) new = config.MasterConfig() new.db['db_url'] = 'bbbb' self.assertRaises(config.ConfigErrors, lambda : self.master.reconfigService(new)) def test_reconfigService_start_polling(self): loopingcall = mock.Mock() self.patch(task, 'LoopingCall', lambda fn : loopingcall) self.master.config = config.MasterConfig() new = config.MasterConfig() new.db['db_poll_interval'] = 120 d = self.master.reconfigService(new) @d.addCallback def check(_): loopingcall.start.assert_called_with(120, now=False) return d @defer.inlineCallbacks def test_reconfigService_stop_polling(self): db_loop = self.master.db_loop = mock.Mock() old = self.master.config = config.MasterConfig() old.db['db_poll_interval'] = 120 yield self.master.reconfigService(old) new = config.MasterConfig() new.db['db_poll_interval'] = None yield self.master.reconfigService(new) db_loop.stop.assert_called() self.assertEqual(self.master.db_loop, None) class Polling(dirs.DirsMixin, misc.PatcherMixin, unittest.TestCase): def setUp(self): self.gotten_changes = [] self.gotten_buildset_additions = [] self.gotten_buildset_completions = [] self.gotten_buildrequest_additions = [] basedir = os.path.abspath('basedir') # patch out os.uname so that we get a consistent hostname self.patch_os_uname(lambda : [ 0, 'testhost.localdomain' ]) self.master_name = "testhost.localdomain:%s" % (basedir,) d = self.setUpDirs(basedir) def set_master(_): self.master = master.BuildMaster(basedir) self.db = self.master.db = fakedb.FakeDBConnector(self) self.master.config.db['db_poll_interval'] = 10 # overridesubscription callbacks self.master._change_subs = sub = mock.Mock() sub.deliver = self.deliverChange self.master._new_buildset_subs = sub = mock.Mock() sub.deliver = self.deliverBuildsetAddition self.master._complete_buildset_subs = sub = mock.Mock() sub.deliver = self.deliverBuildsetCompletion self.master._new_buildrequest_subs = sub = mock.Mock() sub.deliver = self.deliverBuildRequestAddition d.addCallback(set_master) return d def tearDown(self): return self.tearDownDirs() def deliverChange(self, change): self.gotten_changes.append(change) def deliverBuildsetAddition(self, **kwargs): self.gotten_buildset_additions.append(kwargs) def deliverBuildsetCompletion(self, bsid, result): self.gotten_buildset_completions.append((bsid, result)) def deliverBuildRequestAddition(self, notif): self.gotten_buildrequest_additions.append(notif) # tests def test_pollDatabaseChanges_empty(self): self.db.insertTestData([ fakedb.Object(id=22, name=self.master_name, class_name='buildbot.master.BuildMaster'), ]) d = self.master.pollDatabaseChanges() def check(_): self.assertEqual(self.gotten_changes, []) self.assertEqual(self.gotten_buildset_additions, []) self.assertEqual(self.gotten_buildset_completions, []) self.db.state.assertState(22, last_processed_change=0) d.addCallback(check) return d def test_pollDatabaseChanges_catchup(self): # with no existing state, it should catch up to the most recent change, # but not process anything self.db.insertTestData([ fakedb.Object(id=22, name=self.master_name, class_name='buildbot.master.BuildMaster'), fakedb.Change(changeid=10), fakedb.Change(changeid=11), ]) d = self.master.pollDatabaseChanges() def check(_): self.assertEqual(self.gotten_changes, []) self.assertEqual(self.gotten_buildset_additions, []) self.assertEqual(self.gotten_buildset_completions, []) self.db.state.assertState(22, last_processed_change=11) d.addCallback(check) return d def test_pollDatabaseChanges_multiple(self): self.db.insertTestData([ fakedb.Object(id=53, name=self.master_name, class_name='buildbot.master.BuildMaster'), fakedb.ObjectState(objectid=53, name='last_processed_change', value_json='10'), fakedb.Change(changeid=10), fakedb.Change(changeid=11), fakedb.Change(changeid=12), ]) d = self.master.pollDatabaseChanges() def check(_): self.assertEqual([ ch.number for ch in self.gotten_changes], [ 11, 12 ]) # note 10 was already seen self.assertEqual(self.gotten_buildset_additions, []) self.assertEqual(self.gotten_buildset_completions, []) self.db.state.assertState(53, last_processed_change=12) d.addCallback(check) return d def test_pollDatabaseChanges_nothing_new(self): self.db.insertTestData([ fakedb.Object(id=53, name='master', class_name='buildbot.master.BuildMaster'), fakedb.ObjectState(objectid=53, name='last_processed_change', value_json='10'), fakedb.Change(changeid=10), ]) d = self.master.pollDatabaseChanges() def check(_): self.assertEqual(self.gotten_changes, []) self.assertEqual(self.gotten_buildset_additions, []) self.assertEqual(self.gotten_buildset_completions, []) self.db.state.assertState(53, last_processed_change=10) d.addCallback(check) return d def test_pollDatabaseBuildRequests_empty(self): d = self.master.pollDatabaseBuildRequests() def check(_): self.assertEqual(self.gotten_buildrequest_additions, []) d.addCallback(check) return d def test_pollDatabaseBuildRequests_new(self): self.db.insertTestData([ fakedb.SourceStampSet(id=127), fakedb.SourceStamp(id=127, sourcestampsetid=127), fakedb.Buildset(id=99, sourcestampsetid=127), fakedb.BuildRequest(id=19, buildsetid=99, buildername='9teen'), fakedb.BuildRequest(id=20, buildsetid=99, buildername='twenty') ]) d = self.master.pollDatabaseBuildRequests() def check(_): self.assertEqual(sorted(self.gotten_buildrequest_additions), sorted([dict(bsid=99, brid=19, buildername='9teen'), dict(bsid=99, brid=20, buildername='twenty')])) d.addCallback(check) return d def test_pollDatabaseBuildRequests_incremental(self): d = defer.succeed(None) def insert1(_): self.db.insertTestData([ fakedb.SourceStampSet(id=127), fakedb.SourceStamp(id=127, sourcestampsetid=127), fakedb.Buildset(id=99, sourcestampsetid=127), fakedb.BuildRequest(id=11, buildsetid=9, buildername='eleventy'), ]) d.addCallback(insert1) d.addCallback(lambda _ : self.master.pollDatabaseBuildRequests()) def insert2_and_claim(_): self.gotten_buildrequest_additions.append('MARK') self.db.insertTestData([ fakedb.BuildRequest(id=20, buildsetid=9, buildername='twenty'), ]) self.db.buildrequests.fakeClaimBuildRequest(11) d.addCallback(insert2_and_claim) d.addCallback(lambda _ : self.master.pollDatabaseBuildRequests()) def unclaim(_): self.gotten_buildrequest_additions.append('MARK') self.db.buildrequests.fakeUnclaimBuildRequest(11) # note that at this point brid 20 is still unclaimed, but we do # not get a new notification about it d.addCallback(unclaim) d.addCallback(lambda _ : self.master.pollDatabaseBuildRequests()) def check(_): self.assertEqual(self.gotten_buildrequest_additions, [ dict(bsid=9, brid=11, buildername='eleventy'), 'MARK', dict(bsid=9, brid=20, buildername='twenty'), 'MARK', dict(bsid=9, brid=11, buildername='eleventy'), ]) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_pbmanager.py000066400000000000000000000076521222546025000224520ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # Test clean shutdown functionality of the master import mock from twisted.trial import unittest from twisted.internet import defer from twisted.spread import pb from twisted.cred import credentials from buildbot import pbmanager class TestPBManager(unittest.TestCase): def setUp(self): self.pbm = pbmanager.PBManager() self.pbm.startService() self.connections = [] def tearDown(self): return self.pbm.stopService() def perspectiveFactory(self, mind, username): persp = mock.Mock() persp.is_my_persp = True persp.attached = lambda mind : defer.succeed(None) self.connections.append(username) return defer.succeed(persp) def test_repr(self): reg = self.pbm.register('tcp:0:interface=127.0.0.1', "x", "y", self.perspectiveFactory) self.assertEqual(`self.pbm.dispatchers['tcp:0:interface=127.0.0.1']`, '') self.assertEqual(`reg`, '') def test_register_unregister(self): portstr = "tcp:0:interface=127.0.0.1" reg = self.pbm.register(portstr, "boris", "pass", self.perspectiveFactory) # make sure things look right self.assertIn(portstr, self.pbm.dispatchers) disp = self.pbm.dispatchers[portstr] self.assertIn('boris', disp.users) # we can't actually connect to it, as that requires finding the # dynamically allocated port number which is buried out of reach; # however, we can try the requestAvatar and requestAvatarId methods. d = disp.requestAvatarId(credentials.UsernamePassword('boris', 'pass')) def check_avatarid(username): self.assertEqual(username, 'boris') d.addCallback(check_avatarid) d.addCallback(lambda _ : disp.requestAvatar('boris', mock.Mock(), pb.IPerspective)) def check_avatar((iface, persp, detach_fn)): self.failUnless(persp.is_my_persp) self.assertIn('boris', self.connections) d.addCallback(check_avatar) d.addCallback(lambda _ : reg.unregister()) return d def test_double_register_unregister(self): portstr = "tcp:0:interface=127.0.0.1" reg1 = self.pbm.register(portstr, "boris", "pass", None) reg2 = self.pbm.register(portstr, "ivona", "pass", None) # make sure things look right self.assertEqual(len(self.pbm.dispatchers), 1) self.assertIn(portstr, self.pbm.dispatchers) disp = self.pbm.dispatchers[portstr] self.assertIn('boris', disp.users) self.assertIn('ivona', disp.users) d = reg1.unregister() def check_boris_gone(_): self.assertEqual(len(self.pbm.dispatchers), 1) self.assertIn(portstr, self.pbm.dispatchers) disp = self.pbm.dispatchers[portstr] self.assertNotIn('boris', disp.users) self.assertIn('ivona', disp.users) d.addCallback(check_boris_gone) d.addCallback(lambda _ : reg2.unregister()) def check_dispatcher_gone(_): self.assertEqual(len(self.pbm.dispatchers), 0) d.addCallback(check_dispatcher_gone) return d buildbot-0.8.8/buildbot/test/unit/test_process_botmaster_BotMaster.py000066400000000000000000000213721222546025000262270ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from zope.interface import implements from twisted.trial import unittest from twisted.internet import defer from twisted.application import service from buildbot.process.botmaster import BotMaster from buildbot.process import factory from buildbot import config, interfaces from buildbot.test.fake import fakemaster class TestCleanShutdown(unittest.TestCase): def setUp(self): self.botmaster = BotMaster(mock.Mock()) self.reactor = mock.Mock() self.botmaster.startService() def assertReactorStopped(self, _=None): self.assertTrue(self.reactor.stop.called) def assertReactorNotStopped(self, _=None): self.assertFalse(self.reactor.stop.called) def makeFakeBuild(self): self.fake_builder = builder = mock.Mock() build = mock.Mock() builder.builder_status.getCurrentBuilds.return_value = [build] self.build_deferred = defer.Deferred() build.waitUntilFinished.return_value = self.build_deferred self.botmaster.builders = mock.Mock() self.botmaster.builders.values.return_value = [builder] def finishFakeBuild(self): self.fake_builder.builder_status.getCurrentBuilds.return_value = [] self.build_deferred.callback(None) # tests def test_shutdown_idle(self): """Test that the master shuts down when it's idle""" self.botmaster.cleanShutdown(_reactor=self.reactor) self.assertReactorStopped() def test_shutdown_busy(self): """Test that the master shuts down after builds finish""" self.makeFakeBuild() self.botmaster.cleanShutdown(_reactor=self.reactor) # check that we haven't stopped yet, since there's a running build self.assertReactorNotStopped() # try to shut it down again, just to check that this does not fail self.botmaster.cleanShutdown(_reactor=self.reactor) # Now we cause the build to finish self.finishFakeBuild() # And now we should be stopped self.assertReactorStopped() def test_shutdown_cancel_not_shutting_down(self): """Test that calling cancelCleanShutdown when none is in progress works""" # this just shouldn't fail.. self.botmaster.cancelCleanShutdown() def test_shutdown_cancel(self): """Test that we can cancel a shutdown""" self.makeFakeBuild() self.botmaster.cleanShutdown(_reactor=self.reactor) # Next we check that we haven't stopped yet, since there's a running # build. self.assertReactorNotStopped() # but the BuildRequestDistributor should not be running self.assertFalse(self.botmaster.brd.running) # Cancel the shutdown self.botmaster.cancelCleanShutdown() # Now we cause the build to finish self.finishFakeBuild() # We should still be running! self.assertReactorNotStopped() # and the BuildRequestDistributor should be, as well self.assertTrue(self.botmaster.brd.running) class FakeBuildSlave(config.ReconfigurableServiceMixin, service.Service): implements(interfaces.IBuildSlave) reconfig_count = 0 def __init__(self, slavename): self.slavename = slavename def reconfigService(self, new_config): self.reconfig_count += 1 return defer.succeed(None) class FakeBuildSlave2(FakeBuildSlave): pass class TestBotMaster(unittest.TestCase): def setUp(self): self.master = fakemaster.make_master() self.botmaster = BotMaster(self.master) self.new_config = mock.Mock() self.botmaster.startService() def tearDown(self): return self.botmaster.stopService() def test_reconfigService(self): # check that reconfigServiceSlaves and reconfigServiceBuilders are # both called; they will be tested invidually below self.patch(self.botmaster, 'reconfigServiceBuilders', mock.Mock(side_effect=lambda c : defer.succeed(None))) self.patch(self.botmaster, 'reconfigServiceSlaves', mock.Mock(side_effect=lambda c : defer.succeed(None))) self.patch(self.botmaster, 'maybeStartBuildsForAllBuilders', mock.Mock()) old_config, new_config = mock.Mock(), mock.Mock() d = self.botmaster.reconfigService(new_config) @d.addCallback def check(_): self.botmaster.reconfigServiceBuilders.assert_called_with( new_config) self.botmaster.reconfigServiceSlaves.assert_called_with( new_config) self.assertTrue( self.botmaster.maybeStartBuildsForAllBuilders.called) return d @defer.inlineCallbacks def test_reconfigServiceSlaves_add_remove(self): sl = FakeBuildSlave('sl1') self.new_config.slaves = [ sl ] yield self.botmaster.reconfigServiceSlaves(self.new_config) self.assertIdentical(sl.parent, self.botmaster) self.assertEqual(self.botmaster.slaves, { 'sl1' : sl }) self.new_config.slaves = [ ] yield self.botmaster.reconfigServiceSlaves(self.new_config) self.assertIdentical(sl.parent, None) self.assertIdentical(sl.master, None) @defer.inlineCallbacks def test_reconfigServiceSlaves_reconfig(self): sl = FakeBuildSlave('sl1') self.botmaster.slaves = dict(sl1=sl) sl.setServiceParent(self.botmaster) sl.master = self.master sl.botmaster = self.botmaster sl_new = FakeBuildSlave('sl1') self.new_config.slaves = [ sl_new ] yield self.botmaster.reconfigServiceSlaves(self.new_config) # sl was not replaced.. self.assertIdentical(self.botmaster.slaves['sl1'], sl) @defer.inlineCallbacks def test_reconfigServiceSlaves_class_changes(self): sl = FakeBuildSlave('sl1') self.botmaster.slaves = dict(sl1=sl) sl.setServiceParent(self.botmaster) sl.master = self.master sl.botmaster = self.botmaster sl_new = FakeBuildSlave2('sl1') self.new_config.slaves = [ sl_new ] yield self.botmaster.reconfigServiceSlaves(self.new_config) # sl *was* replaced (different class) self.assertIdentical(self.botmaster.slaves['sl1'], sl_new) @defer.inlineCallbacks def test_reconfigServiceBuilders_add_remove(self): bc = config.BuilderConfig(name='bldr', factory=factory.BuildFactory(), slavename='f') self.new_config.builders = [ bc ] yield self.botmaster.reconfigServiceBuilders(self.new_config) bldr = self.botmaster.builders['bldr'] self.assertIdentical(bldr.parent, self.botmaster) self.assertIdentical(bldr.master, self.master) self.assertEqual(self.botmaster.builderNames, [ 'bldr' ]) self.new_config.builders = [ ] yield self.botmaster.reconfigServiceBuilders(self.new_config) self.assertIdentical(bldr.parent, None) self.assertIdentical(bldr.master, None) self.assertEqual(self.botmaster.builders, {}) self.assertEqual(self.botmaster.builderNames, []) def test_maybeStartBuildsForBuilder(self): brd = self.botmaster.brd = mock.Mock() self.botmaster.maybeStartBuildsForBuilder('frank') brd.maybeStartBuildsOn.assert_called_once_with(['frank']) def test_maybeStartBuildsForSlave(self): brd = self.botmaster.brd = mock.Mock() b1 = mock.Mock(name='frank') b1.name = 'frank' b2 = mock.Mock(name='larry') b2.name = 'larry' self.botmaster.getBuildersForSlave = mock.Mock(return_value=[b1, b2]) self.botmaster.maybeStartBuildsForSlave('centos') self.botmaster.getBuildersForSlave.assert_called_once_with('centos') brd.maybeStartBuildsOn.assert_called_once_with(['frank', 'larry']) def test_maybeStartBuildsForAll(self): brd = self.botmaster.brd = mock.Mock() self.botmaster.builderNames = ['frank', 'larry'] self.botmaster.maybeStartBuildsForAllBuilders() brd.maybeStartBuildsOn.assert_called_once_with(['frank', 'larry']) buildbot-0.8.8/buildbot/test/unit/test_process_botmaster_DuplicateSlaveArbitrator.py000066400000000000000000000203401222546025000312600ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.python import failure, log from twisted.trial import unittest from twisted.internet import defer, reactor, task from twisted.spread import pb from buildbot.test.util import compat from buildbot.process import botmaster from buildbot.util.eventual import eventually # TODO: should do this with more of the Twisted machinery intact - maybe in a # separate integration test? class FakeAbstractBuildSlave(object): def __init__(self, slave, name, isConnected=True, reactor=reactor): self.slavename = name self.slave = slave self.isConnectedResult = isConnected self.reactor = reactor self.call_on_detach = lambda : None # set up for loseConnection to cause the slave to detach, but not # immediately def tport_loseConnection(): self.isConnectedResult = False self.call_on_detach() self.call_on_detach = None self.slave.broker.transport.loseConnection = (lambda : eventually(tport_loseConnection)) def subscribeToDetach(self, callback): self.call_on_detach = callback def isConnected(self): return self.isConnectedResult class FakeRemoteBuildSlave(object): def __init__(self, name, callRemoteFailure=False, callRemoteException=False, callRemoteHang=0, reactor=reactor): self.name = name self.callRemoteFailure = callRemoteFailure self.callRemoteException = callRemoteException self.callRemoteHang = callRemoteHang self.reactor = reactor self.broker = mock.Mock() self.broker.transport.getPeer = lambda : "" % name def _makePingResult(self): if self.callRemoteException: exc = self.callRemoteException() log.msg(" -> exception %r" % (exc,)) raise exc if self.callRemoteFailure: f = defer.fail(self.callRemoteFailure()) log.msg(" -> failure %r" % (f,)) return f return defer.succeed(None) def callRemote(self, meth, *args, **kwargs): assert meth == "print" log.msg("%r.callRemote('print', %r)" % (self, args[0],)) # if we're asked to hang, then set up to fire the deferred later if self.callRemoteHang: log.msg(" -> hang for %d s" % (self.callRemoteHang,)) d = defer.Deferred() self.reactor.callLater(self.callRemoteHang, d.callback, None) def hangdone(_): log.msg("%r.callRemote hang finished" % (self,)) return self._makePingResult() d.addCallback(hangdone) self.callRemote_d = d # otherwise, return a fired deferred else: self.callRemote_d = self._makePingResult() return self.callRemote_d def __repr__(self): return "" % (self.name,) class DuplicateSlaveArbitrator(unittest.TestCase): def makeDeadReferenceError(self): return pb.DeadReferenceError("Calling Stale Broker (fake exception)") def makeRuntimeError(self): return RuntimeError("oh noes!") def makePBConnectionLostFailure(self): return failure.Failure(pb.PBConnectionLost("gone")) def test_old_slave_present(self): old_remote = FakeRemoteBuildSlave("old") new_remote = FakeRemoteBuildSlave("new") buildslave = FakeAbstractBuildSlave(old_remote, name="testslave") arb = botmaster.DuplicateSlaveArbitrator(buildslave) d = arb.getPerspective(new_remote, "testslave") def got_persp(bs): self.fail("shouldn't get here") def failed(f): f.trap(RuntimeError) # expected error d.addCallbacks(got_persp, failed) return d def test_old_slave_absent_deadref_exc(self): old_remote = FakeRemoteBuildSlave("old", callRemoteException=self.makeDeadReferenceError) new_remote = FakeRemoteBuildSlave("new") buildslave = FakeAbstractBuildSlave(old_remote, name="testslave") arb = botmaster.DuplicateSlaveArbitrator(buildslave) d = arb.getPerspective(new_remote, "testslave") def got_persp(bs): self.assertIdentical(bs, buildslave) d.addCallback(got_persp) return d def test_old_slave_absent_connlost_failure(self): old_remote = FakeRemoteBuildSlave("old", callRemoteFailure=self.makePBConnectionLostFailure) new_remote = FakeRemoteBuildSlave("new") buildslave = FakeAbstractBuildSlave(old_remote, name="testslave") arb = botmaster.DuplicateSlaveArbitrator(buildslave) d = arb.getPerspective(new_remote, "testslave") def got_persp(bs): self.assertIdentical(bs, buildslave) d.addCallback(got_persp) return d @compat.usesFlushLoggedErrors def test_old_slave_absent_unexpected_exc(self): old_remote = FakeRemoteBuildSlave("old", callRemoteException=self.makeRuntimeError) new_remote = FakeRemoteBuildSlave("new") buildslave = FakeAbstractBuildSlave(old_remote, name="testslave") arb = botmaster.DuplicateSlaveArbitrator(buildslave) d = arb.getPerspective(new_remote, "testslave") def got_persp(bs): # getPerspective has returned successfully: self.assertIdentical(bs, buildslave) # and the unexpected RuntimeError was logged: self.assertEqual(len(self.flushLoggedErrors(RuntimeError)), 1) d.addCallback(got_persp) return d def do_test_old_slave_absent_timeout(self, callRemoteException=None): clock = task.Clock() PING_TIMEOUT = botmaster.DuplicateSlaveArbitrator.PING_TIMEOUT old_remote = FakeRemoteBuildSlave("old", reactor=clock, callRemoteHang=PING_TIMEOUT+1, callRemoteException=callRemoteException) new_remote = FakeRemoteBuildSlave("new") buildslave = FakeAbstractBuildSlave(old_remote, name="testslave", reactor=clock) arb = botmaster.DuplicateSlaveArbitrator(buildslave) arb._reactor = clock d = arb.getPerspective(new_remote, "testslave") def got_persp(bs): self.assertIdentical(bs, buildslave) d.addCallback(got_persp) # show the passage of time for 2s more than the PING_TIMEOUT, to allow # the old callRemote to return eventually clock.pump([.1] * 10 * (PING_TIMEOUT+4)) # check that the timed-out call eventually returned (and was ignored, # even if there was an exception) self.failUnless(old_remote.callRemote_d.called) return d def test_old_slave_absent_timeout(self): return self.do_test_old_slave_absent_timeout() def test_old_slave_absent_timeout_exc(self): return self.do_test_old_slave_absent_timeout( callRemoteException=self.makeRuntimeError) @compat.usesFlushLoggedErrors def test_new_slave_ping_error(self): old_remote = FakeRemoteBuildSlave("old") new_remote = FakeRemoteBuildSlave("new", callRemoteException=self.makeRuntimeError) buildslave = FakeAbstractBuildSlave(old_remote, name="testslave") arb = botmaster.DuplicateSlaveArbitrator(buildslave) d = arb.getPerspective(new_remote, "testslave") def got_persp(bs): self.fail("shouldn't get here") def failed(f): pass #f.trap(RuntimeError) # expected error d.addCallbacks(got_persp, failed) return d buildbot-0.8.8/buildbot/test/unit/test_process_build.py000066400000000000000000000666141222546025000233560ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from twisted.trial import unittest from twisted.internet import defer from buildbot import interfaces from buildbot.process.build import Build from buildbot.process.properties import Properties from buildbot.status.results import FAILURE, SUCCESS, WARNINGS, RETRY, EXCEPTION from buildbot.locks import SlaveLock from buildbot.process.buildstep import LoggingBuildStep from buildbot.test.fake.fakemaster import FakeBotMaster from buildbot import config from mock import Mock class FakeChange: properties = Properties() def __init__(self, number = None): self.number = number self.who = "me" class FakeSource: def __init__(self): self.sourcestampsetid = None self.changes = [] self.branch = None self.revision = None self.repository = '' self.codebase = '' self.project = '' self.patch_info = None self.patch = None def getRepository(self): return self.repository class FakeRequest: def __init__(self): self.sources = [] self.reason = "Because" self.properties = Properties() def mergeSourceStampsWith(self, others): return self.sources def mergeReasons(self, others): return self.reason class FakeBuildStep: def __init__(self): self.haltOnFailure = False self.flunkOnWarnings = False self.flunkOnFailure = True self.warnOnWarnings = True self.warnOnFailure = False self.alwaysRun = False self.name = 'fake' class FakeMaster: def __init__(self): self.locks = {} self.parent = Mock() self.config = config.MasterConfig() def getLockByID(self, lockid): if not lockid in self.locks: self.locks[lockid] = lockid.lockClass(lockid) return self.locks[lockid] class FakeBuildStatus(Mock): implements(interfaces.IProperties) class FakeBuilderStatus: implements(interfaces.IBuilderStatus) class FakeStepFactory(object): """Fake step factory that just returns a fixed step object.""" implements(interfaces.IBuildStepFactory) def __init__(self, step): self.step = step def buildStep(self): return self.step class TestBuild(unittest.TestCase): def setUp(self): r = FakeRequest() r.sources = [FakeSource()] r.sources[0].changes = [FakeChange()] r.sources[0].revision = "12345" self.request = r self.master = FakeMaster() self.master.botmaster = FakeBotMaster(master=self.master) self.builder = self.createBuilder() self.build = Build([r]) self.build.setBuilder(self.builder) def createBuilder(self): bldr = Mock() bldr.botmaster = self.master.botmaster return bldr def testRunSuccessfulBuild(self): b = self.build step = Mock() step.return_value = step step.startStep.return_value = SUCCESS b.setStepFactories([FakeStepFactory(step)]) slavebuilder = Mock() b.startBuild(FakeBuildStatus(), None, slavebuilder) self.assertEqual(b.result, SUCCESS) self.assert_( ('startStep', (slavebuilder.remote,), {}) in step.method_calls) def testStopBuild(self): b = self.build step = Mock() step.return_value = step b.setStepFactories([FakeStepFactory(step)]) slavebuilder = Mock() def startStep(*args, **kw): # Now interrupt the build b.stopBuild("stop it") return defer.Deferred() step.startStep = startStep b.startBuild(FakeBuildStatus(), None, slavebuilder) self.assertEqual(b.result, EXCEPTION) self.assert_( ('interrupt', ('stop it',), {}) in step.method_calls) def testAlwaysRunStepStopBuild(self): """Test that steps marked with alwaysRun=True still get run even if the build is stopped.""" # Create a build with 2 steps, the first one will get interrupted, and # the second one is marked with alwaysRun=True b = self.build step1 = Mock() step1.return_value = step1 step1.alwaysRun = False step2 = Mock() step2.return_value = step2 step2.alwaysRun = True b.setStepFactories([ FakeStepFactory(step1), FakeStepFactory(step2), ]) slavebuilder = Mock() def startStep1(*args, **kw): # Now interrupt the build b.stopBuild("stop it") return defer.succeed( SUCCESS ) step1.startStep = startStep1 step1.stepDone.return_value = False step2Started = [False] def startStep2(*args, **kw): step2Started[0] = True return defer.succeed( SUCCESS ) step2.startStep = startStep2 step1.stepDone.return_value = False d = b.startBuild(FakeBuildStatus(), None, slavebuilder) def check(ign): self.assertEqual(b.result, EXCEPTION) self.assert_( ('interrupt', ('stop it',), {}) in step1.method_calls) self.assert_(step2Started[0]) d.addCallback(check) return d def testBuildcanStartWithSlavebuilder(self): b = self.build slavebuilder1 = Mock() slavebuilder2 = Mock() l = SlaveLock('lock') counting_access = l.access('counting') real_lock = b.builder.botmaster.getLockByID(l) # no locks, so both these pass (call twice to verify there's no state/memory) lock_list = [(real_lock, counting_access)] self.assertTrue(Build.canStartWithSlavebuilder(lock_list, slavebuilder1)) self.assertTrue(Build.canStartWithSlavebuilder(lock_list, slavebuilder1)) self.assertTrue(Build.canStartWithSlavebuilder(lock_list, slavebuilder2)) self.assertTrue(Build.canStartWithSlavebuilder(lock_list, slavebuilder2)) slave_lock_1 = real_lock.getLock(slavebuilder1.slave) slave_lock_2 = real_lock.getLock(slavebuilder2.slave) # then have slavebuilder2 claim its lock: slave_lock_2.claim(slavebuilder2, counting_access) self.assertTrue(Build.canStartWithSlavebuilder(lock_list, slavebuilder1)) self.assertTrue(Build.canStartWithSlavebuilder(lock_list, slavebuilder1)) self.assertFalse(Build.canStartWithSlavebuilder(lock_list, slavebuilder2)) self.assertFalse(Build.canStartWithSlavebuilder(lock_list, slavebuilder2)) slave_lock_2.release(slavebuilder2, counting_access) # then have slavebuilder1 claim its lock: slave_lock_1.claim(slavebuilder1, counting_access) self.assertFalse(Build.canStartWithSlavebuilder(lock_list, slavebuilder1)) self.assertFalse(Build.canStartWithSlavebuilder(lock_list, slavebuilder1)) self.assertTrue(Build.canStartWithSlavebuilder(lock_list, slavebuilder2)) self.assertTrue(Build.canStartWithSlavebuilder(lock_list, slavebuilder2)) slave_lock_1.release(slavebuilder1, counting_access) def testBuildLocksAcquired(self): b = self.build slavebuilder = Mock() l = SlaveLock('lock') claimCount = [0] lock_access = l.access('counting') l.access = lambda mode: lock_access real_lock = b.builder.botmaster.getLockByID(l).getLock(slavebuilder.slave) def claim(owner, access): claimCount[0] += 1 return real_lock.old_claim(owner, access) real_lock.old_claim = real_lock.claim real_lock.claim = claim b.setLocks([lock_access]) step = Mock() step.return_value = step step.startStep.return_value = SUCCESS b.setStepFactories([FakeStepFactory(step)]) b.startBuild(FakeBuildStatus(), None, slavebuilder) self.assertEqual(b.result, SUCCESS) self.assert_( ('startStep', (slavebuilder.remote,), {}) in step.method_calls) self.assertEquals(claimCount[0], 1) def testBuildLocksOrder(self): """Test that locks are acquired in FIFO order; specifically that counting locks cannot jump ahead of exclusive locks""" eBuild = self.build cBuilder = self.createBuilder() cBuild = Build([self.request]) cBuild.setBuilder(cBuilder) eSlavebuilder = Mock() cSlavebuilder = Mock() slave = eSlavebuilder.slave cSlavebuilder.slave = slave l = SlaveLock('lock', 2) claimLog = [] realLock = self.master.botmaster.getLockByID(l).getLock(slave) def claim(owner, access): claimLog.append(owner) return realLock.oldClaim(owner, access) realLock.oldClaim = realLock.claim realLock.claim = claim eBuild.setLocks([l.access('exclusive')]) cBuild.setLocks([l.access('counting')]) fakeBuild = Mock() fakeBuildAccess = l.access('counting') realLock.claim(fakeBuild, fakeBuildAccess) step = Mock() step.return_value = step step.startStep.return_value = SUCCESS eBuild.setStepFactories([FakeStepFactory(step)]) cBuild.setStepFactories([FakeStepFactory(step)]) e = eBuild.startBuild(FakeBuildStatus(), None, eSlavebuilder) c = cBuild.startBuild(FakeBuildStatus(), None, cSlavebuilder) d = defer.DeferredList([e, c]) realLock.release(fakeBuild, fakeBuildAccess) def check(ign): self.assertEqual(eBuild.result, SUCCESS) self.assertEqual(cBuild.result, SUCCESS) self.assertEquals(claimLog, [fakeBuild, eBuild, cBuild]) d.addCallback(check) return d def testBuildWaitingForLocks(self): b = self.build slavebuilder = Mock() l = SlaveLock('lock') claimCount = [0] lock_access = l.access('counting') l.access = lambda mode: lock_access real_lock = b.builder.botmaster.getLockByID(l).getLock(slavebuilder.slave) def claim(owner, access): claimCount[0] += 1 return real_lock.old_claim(owner, access) real_lock.old_claim = real_lock.claim real_lock.claim = claim b.setLocks([lock_access]) step = Mock() step.return_value = step step.startStep.return_value = SUCCESS b.setStepFactories([FakeStepFactory(step)]) real_lock.claim(Mock(), l.access('counting')) b.startBuild(FakeBuildStatus(), None, slavebuilder) self.assert_( ('startStep', (slavebuilder.remote,), {}) not in step.method_calls) self.assertEquals(claimCount[0], 1) self.assert_(b.currentStep is None) self.assert_(b._acquiringLock is not None) def testStopBuildWaitingForLocks(self): b = self.build slavebuilder = Mock() l = SlaveLock('lock') lock_access = l.access('counting') l.access = lambda mode: lock_access real_lock = b.builder.botmaster.getLockByID(l).getLock(slavebuilder) b.setLocks([lock_access]) step = Mock() step.return_value = step step.startStep.return_value = SUCCESS step.alwaysRun = False b.setStepFactories([FakeStepFactory(step)]) real_lock.claim(Mock(), l.access('counting')) def acquireLocks(res=None): retval = Build.acquireLocks(b, res) b.stopBuild('stop it') return retval b.acquireLocks = acquireLocks b.startBuild(FakeBuildStatus(), None, slavebuilder) self.assert_( ('startStep', (slavebuilder.remote,), {}) not in step.method_calls) self.assert_(b.currentStep is None) self.assertEqual(b.result, EXCEPTION) self.assert_( ('interrupt', ('stop it',), {}) not in step.method_calls) def testStopBuildWaitingForLocks_lostRemote(self): b = self.build slavebuilder = Mock() l = SlaveLock('lock') lock_access = l.access('counting') l.access = lambda mode: lock_access real_lock = b.builder.botmaster.getLockByID(l).getLock(slavebuilder) b.setLocks([lock_access]) step = Mock() step.return_value = step step.startStep.return_value = SUCCESS step.alwaysRun = False b.setStepFactories([FakeStepFactory(step)]) real_lock.claim(Mock(), l.access('counting')) def acquireLocks(res=None): retval = Build.acquireLocks(b, res) b.lostRemote() return retval b.acquireLocks = acquireLocks b.startBuild(FakeBuildStatus(), None, slavebuilder) self.assert_( ('startStep', (slavebuilder.remote,), {}) not in step.method_calls) self.assert_(b.currentStep is None) self.assertEqual(b.result, RETRY) self.assert_( ('interrupt', ('stop it',), {}) not in step.method_calls) self.build.build_status.setText.assert_called_with(["retry", "lost", "remote"]) self.build.build_status.setResults.assert_called_with(RETRY) def testStopBuildWaitingForStepLocks(self): b = self.build slavebuilder = Mock() l = SlaveLock('lock') lock_access = l.access('counting') l.access = lambda mode: lock_access real_lock = b.builder.botmaster.getLockByID(l).getLock(slavebuilder) step = LoggingBuildStep(locks=[lock_access]) b.setStepFactories([FakeStepFactory(step)]) real_lock.claim(Mock(), l.access('counting')) gotLocks = [False] def acquireLocks(res=None): gotLocks[0] = True retval = LoggingBuildStep.acquireLocks(step, res) self.assert_(b.currentStep is step) b.stopBuild('stop it') return retval step.acquireLocks = acquireLocks step.setStepStatus = Mock() step.step_status = Mock() step.step_status.addLog().chunkSize = 10 step.step_status.getLogs.return_value = [] b.startBuild(FakeBuildStatus(), None, slavebuilder) self.assertEqual(gotLocks, [True]) self.assert_(('stepStarted', (), {}) in step.step_status.method_calls) self.assertEqual(b.result, EXCEPTION) def testStepDone(self): b = self.build b.results = [SUCCESS] b.result = SUCCESS b.remote = Mock() step = FakeBuildStep() terminate = b.stepDone(SUCCESS, step) self.assertEqual(terminate, False) self.assertEqual(b.result, SUCCESS) def testStepDoneHaltOnFailure(self): b = self.build b.results = [] b.result = SUCCESS b.remote = Mock() step = FakeBuildStep() step.haltOnFailure = True terminate = b.stepDone(FAILURE, step) self.assertEqual(terminate, True) self.assertEqual(b.result, FAILURE) def testStepDoneHaltOnFailureNoFlunkOnFailure(self): b = self.build b.results = [] b.result = SUCCESS b.remote = Mock() step = FakeBuildStep() step.flunkOnFailure = False step.haltOnFailure = True terminate = b.stepDone(FAILURE, step) self.assertEqual(terminate, True) self.assertEqual(b.result, SUCCESS) def testStepDoneFlunkOnWarningsFlunkOnFailure(self): b = self.build b.results = [] b.result = SUCCESS b.remote = Mock() step = FakeBuildStep() step.flunkOnFailure = True step.flunkOnWarnings = True b.stepDone(WARNINGS, step) terminate = b.stepDone(FAILURE, step) self.assertEqual(terminate, False) self.assertEqual(b.result, FAILURE) def testStepDoneNoWarnOnWarnings(self): b = self.build b.results = [SUCCESS] b.result = SUCCESS b.remote = Mock() step = FakeBuildStep() step.warnOnWarnings = False terminate = b.stepDone(WARNINGS, step) self.assertEqual(terminate, False) self.assertEqual(b.result, SUCCESS) def testStepDoneWarnings(self): b = self.build b.results = [SUCCESS] b.result = SUCCESS b.remote = Mock() step = FakeBuildStep() terminate = b.stepDone(WARNINGS, step) self.assertEqual(terminate, False) self.assertEqual(b.result, WARNINGS) def testStepDoneFail(self): b = self.build b.results = [SUCCESS] b.result = SUCCESS b.remote = Mock() step = FakeBuildStep() terminate = b.stepDone(FAILURE, step) self.assertEqual(terminate, False) self.assertEqual(b.result, FAILURE) def testStepDoneFailOverridesWarnings(self): b = self.build b.results = [SUCCESS, WARNINGS] b.result = WARNINGS b.remote = Mock() step = FakeBuildStep() terminate = b.stepDone(FAILURE, step) self.assertEqual(terminate, False) self.assertEqual(b.result, FAILURE) def testStepDoneWarnOnFailure(self): b = self.build b.results = [SUCCESS] b.result = SUCCESS b.remote = Mock() step = FakeBuildStep() step.warnOnFailure = True step.flunkOnFailure = False terminate = b.stepDone(FAILURE, step) self.assertEqual(terminate, False) self.assertEqual(b.result, WARNINGS) def testStepDoneFlunkOnWarnings(self): b = self.build b.results = [SUCCESS] b.result = SUCCESS b.remote = Mock() step = FakeBuildStep() step.flunkOnWarnings = True terminate = b.stepDone(WARNINGS, step) self.assertEqual(terminate, False) self.assertEqual(b.result, FAILURE) def testStepDoneHaltOnFailureFlunkOnWarnings(self): b = self.build b.results = [SUCCESS] b.result = SUCCESS b.remote = Mock() step = FakeBuildStep() step.flunkOnWarnings = True self.haltOnFailure = True terminate = b.stepDone(WARNINGS, step) self.assertEqual(terminate, False) self.assertEqual(b.result, FAILURE) def testStepDoneWarningsDontOverrideFailure(self): b = self.build b.results = [FAILURE] b.result = FAILURE b.remote = Mock() step = FakeBuildStep() terminate = b.stepDone(WARNINGS, step) self.assertEqual(terminate, False) self.assertEqual(b.result, FAILURE) def testStepDoneRetryOverridesAnythingElse(self): b = self.build b.results = [RETRY] b.result = RETRY b.remote = Mock() step = FakeBuildStep() step.alwaysRun = True b.stepDone(WARNINGS, step) b.stepDone(FAILURE, step) b.stepDone(SUCCESS, step) terminate = b.stepDone(EXCEPTION, step) self.assertEqual(terminate, True) self.assertEqual(b.result, RETRY) class TestMultipleSourceStamps(unittest.TestCase): def setUp(self): r = FakeRequest() s1 = FakeSource() s1.repository = "repoA" s1.codebase = "A" s1.changes = [FakeChange(10), FakeChange(11)] s1.revision = "12345" s2 = FakeSource() s2.repository = "repoB" s2.codebase = "B" s2.changes = [FakeChange(12),FakeChange(13)] s2.revision = "67890" s3 = FakeSource() s3.repository = "repoC" # no codebase defined s3.changes = [FakeChange(14),FakeChange(15)] s3.revision = "111213" r.sources.extend([s1,s2,s3]) self.build = Build([r]) def test_buildReturnSourceStamp(self): """ Test that a build returns the correct sourcestamp """ source1 = self.build.getSourceStamp("A") source2 = self.build.getSourceStamp("B") self.assertEqual( [source1.repository, source1.revision], ["repoA", "12345"]) self.assertEqual( [source2.repository, source2.revision], ["repoB", "67890"]) def test_buildReturnSourceStamp_empty_codebase(self): """ Test that a build returns the correct sourcestamp if codebase is empty """ codebase = '' source3 = self.build.getSourceStamp(codebase) self.assertTrue(source3 is not None) self.assertEqual( [source3.repository, source3.revision], ["repoC", "111213"]) class TestBuildBlameList(unittest.TestCase): def setUp(self): self.sourceByMe = FakeSource() self.sourceByMe.repository = "repoA" self.sourceByMe.codebase = "A" self.sourceByMe.changes = [FakeChange(10), FakeChange(11)] self.sourceByMe.changes[0].who = "me" self.sourceByMe.changes[1].who = "me" self.sourceByHim = FakeSource() self.sourceByHim.repository = "repoB" self.sourceByHim.codebase = "B" self.sourceByHim.changes = [FakeChange(12), FakeChange(13)] self.sourceByHim.changes[0].who = "him" self.sourceByHim.changes[1].who = "him" self.patchSource = FakeSource() self.patchSource.repository = "repoB" self.patchSource.codebase = "B" self.patchSource.changes = [] self.patchSource.revision = "67890" self.patchSource.patch_info = ("jeff", "jeff's new feature") def test_blamelist_for_changes(self): r = FakeRequest() r.sources.extend([self.sourceByMe, self.sourceByHim]) build = Build([r]) blamelist = build.blamelist() self.assertEqual(blamelist, ['him', 'me']) def test_blamelist_for_patch(self): r = FakeRequest() r.sources.extend([self.patchSource]) build = Build([r]) blamelist = build.blamelist() self.assertEqual(blamelist, ['jeff']) class TestSetupProperties_MultipleSources(unittest.TestCase): """ Test that the property values, based on the available requests, are initialized properly """ def setUp(self): self.props = {} r = FakeRequest() r.sources = [] r.sources.append(FakeSource()) r.sources[0].changes = [FakeChange()] r.sources[0].repository = "http://svn-repo-A" r.sources[0].codebase = "A" r.sources[0].branch = "develop" r.sources[0].revision = "12345" r.sources.append(FakeSource()) r.sources[1].changes = [FakeChange()] r.sources[1].repository = "http://svn-repo-B" r.sources[1].codebase = "B" r.sources[1].revision = "34567" self.build = Build([r]) self.build.setStepFactories([]) self.builder = Mock() self.build.setBuilder(self.builder) self.build.build_status = FakeBuildStatus() # record properties that will be set self.build.build_status.setProperty = self.setProperty def setProperty(self, n,v,s, runtime = False): if s not in self.props: self.props[s] = {} if not self.props[s]: self.props[s] = {} self.props[s][n] = v def test_sourcestamp_properties_not_set(self): self.build.setupProperties() self.assertTrue("codebase" not in self.props["Build"]) self.assertTrue("revision" not in self.props["Build"]) self.assertTrue("branch" not in self.props["Build"]) self.assertTrue("project" not in self.props["Build"]) self.assertTrue("repository" not in self.props["Build"]) class TestSetupProperties_SingleSource(unittest.TestCase): """ Test that the property values, based on the available requests, are initialized properly """ def setUp(self): self.props = {} r = FakeRequest() r.sources = [] r.sources.append(FakeSource()) r.sources[0].changes = [FakeChange()] r.sources[0].repository = "http://svn-repo-A" r.sources[0].codebase = "A" r.sources[0].branch = "develop" r.sources[0].revision = "12345" self.build = Build([r]) self.build.setStepFactories([]) self.builder = Mock() self.build.setBuilder(self.builder) self.build.build_status = FakeBuildStatus() # record properties that will be set self.build.build_status.setProperty = self.setProperty def setProperty(self, n,v,s, runtime = False): if s not in self.props: self.props[s] = {} if not self.props[s]: self.props[s] = {} self.props[s][n] = v def test_properties_codebase(self): self.build.setupProperties() codebase = self.props["Build"]["codebase"] self.assertEqual(codebase, "A") def test_properties_repository(self): self.build.setupProperties() repository = self.props["Build"]["repository"] self.assertEqual(repository, "http://svn-repo-A") def test_properties_revision(self): self.build.setupProperties() revision = self.props["Build"]["revision"] self.assertEqual(revision, "12345") def test_properties_branch(self): self.build.setupProperties() branch = self.props["Build"]["branch"] self.assertEqual(branch, "develop") def test_property_project(self): self.build.setupProperties() project = self.props["Build"]["project"] self.assertEqual(project, '') class TestBuildProperties(unittest.TestCase): """ Test that a Build has the necessary L{IProperties} methods, and that they properly delegate to the C{build_status} attribute - so really just a test of the L{IProperties} adapter. """ def setUp(self): r = FakeRequest() r.sources = [FakeSource()] r.sources[0].changes = [FakeChange()] r.sources[0].revision = "12345" self.build = Build([r]) self.build.setStepFactories([]) self.builder = Mock() self.build.setBuilder(self.builder) self.build_status = FakeBuildStatus() self.build.startBuild(self.build_status, None, Mock()) def test_getProperty(self): self.build.getProperty('x') self.build_status.getProperty.assert_called_with('x', None) def test_getProperty_default(self): self.build.getProperty('x', 'nox') self.build_status.getProperty.assert_called_with('x', 'nox') def test_setProperty(self): self.build.setProperty('n', 'v', 's') self.build_status.setProperty.assert_called_with('n', 'v', 's', runtime=True) def test_hasProperty(self): self.build_status.hasProperty.return_value = True self.assertTrue(self.build.hasProperty('p')) self.build_status.hasProperty.assert_called_with('p') def test_has_key(self): self.build_status.has_key.return_value = True self.assertTrue(self.build.has_key('p')) # has_key calls through to hasProperty self.build_status.hasProperty.assert_called_with('p') def test_render(self): self.build.render("xyz") self.build_status.render.assert_called_with("xyz") buildbot-0.8.8/buildbot/test/unit/test_process_builder.py000066400000000000000000000401611222546025000236720ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock import random from twisted.trial import unittest from twisted.internet import defer from buildbot import config from buildbot.test.fake import fakedb, fakemaster from buildbot.process import builder, factory from buildbot.util import epoch2datetime class BuilderMixin(object): def makeBuilder(self, name="bldr", patch_random=False, **config_kwargs): """Set up C{self.bldr}""" self.factory = factory.BuildFactory() self.master = fakemaster.make_master() # only include the necessary required config, plus user-requested config_args = dict(name=name, slavename="slv", builddir="bdir", slavebuilddir="sbdir", factory=self.factory) config_args.update(config_kwargs) self.builder_config = config.BuilderConfig(**config_args) self.bldr = builder.Builder(self.builder_config.name, _addServices=False) self.master.db = self.db = fakedb.FakeDBConnector(self) self.bldr.master = self.master self.bldr.botmaster = self.master.botmaster # patch into the _startBuildsFor method self.builds_started = [] def _startBuildFor(slavebuilder, buildrequests): self.builds_started.append((slavebuilder, buildrequests)) return defer.succeed(True) self.bldr._startBuildFor = _startBuildFor if patch_random: # patch 'random.choice' to always take the slave that sorts # last, based on its name self.patch(random, "choice", lambda lst : sorted(lst, key=lambda m : m.name)[-1]) self.bldr.startService() mastercfg = config.MasterConfig() mastercfg.builders = [ self.builder_config ] return self.bldr.reconfigService(mastercfg) class TestBuilderBuildCreation(BuilderMixin, unittest.TestCase): def setUp(self): # a collection of rows that would otherwise clutter up every test self.base_rows = [ fakedb.SourceStampSet(id=21), fakedb.SourceStamp(id=21, sourcestampsetid=21), fakedb.Buildset(id=11, reason='because', sourcestampsetid=21), ] def makeBuilder(self, patch_random=False, startBuildsForSucceeds=True, **config_kwargs): d = BuilderMixin.makeBuilder(self, patch_random=patch_random, **config_kwargs) @d.addCallback def patch_startBuildsFor(_): # patch into the _startBuildsFor method self.builds_started = [] def _startBuildFor(slavebuilder, buildrequests): self.builds_started.append((slavebuilder, buildrequests)) return defer.succeed(startBuildsForSucceeds) self.bldr._startBuildFor = _startBuildFor return d def assertBuildsStarted(self, exp): # munge builds_started into a list of (slave, [brids]) builds_started = [ (sl.name, [ br.id for br in buildreqs ]) for (sl, buildreqs) in self.builds_started ] self.assertEqual(sorted(builds_started), sorted(exp)) def setSlaveBuilders(self, slavebuilders): """C{slaves} maps name : available""" self.bldr.slaves = [] for name, avail in slavebuilders.iteritems(): sb = mock.Mock(spec=['isAvailable'], name=name) sb.name = name sb.isAvailable.return_value = avail self.bldr.slaves.append(sb) # services @defer.inlineCallbacks def test_maybeStartBuild_builder_stopped(self): yield self.makeBuilder() # this will cause an exception if maybeStartBuild tries to start self.bldr.slaves = None # so we just hope this does not fail yield self.bldr.stopService() started = yield self.bldr.maybeStartBuild(None, []) self.assertEquals(started, False) # maybeStartBuild def _makeMocks(self): slave = mock.Mock() slave.name = 'slave' buildrequest = mock.Mock() buildrequest.id = 10 buildrequests = [buildrequest] return slave, buildrequests @defer.inlineCallbacks def test_maybeStartBuild(self): yield self.makeBuilder() slave, buildrequests = self._makeMocks() started = yield self.bldr.maybeStartBuild(slave, buildrequests) self.assertEqual(started, True) self.assertBuildsStarted([('slave', [10])]) @defer.inlineCallbacks def test_maybeStartBuild_failsToStart(self): yield self.makeBuilder(startBuildsForSucceeds=False) slave, buildrequests = self._makeMocks() started = yield self.bldr.maybeStartBuild(slave, buildrequests) self.assertEqual(started, False) self.assertBuildsStarted([('slave', [10])]) @defer.inlineCallbacks def do_test_getMergeRequestsFn(self, builder_param=None, global_param=None, expected=0): cble = lambda : None builder_param = builder_param == 'callable' and cble or builder_param global_param = global_param == 'callable' and cble or global_param # omit the constructor parameter if None was given if builder_param is None: yield self.makeBuilder() else: yield self.makeBuilder(mergeRequests=builder_param) self.master.config.mergeRequests = global_param fn = self.bldr.getMergeRequestsFn() if fn == builder.Builder._defaultMergeRequestFn: fn = "default" elif fn is cble: fn = 'callable' self.assertEqual(fn, expected) def test_getMergeRequestsFn_defaults(self): self.do_test_getMergeRequestsFn(None, None, "default") def test_getMergeRequestsFn_global_True(self): self.do_test_getMergeRequestsFn(None, True, "default") def test_getMergeRequestsFn_global_False(self): self.do_test_getMergeRequestsFn(None, False, None) def test_getMergeRequestsFn_global_function(self): self.do_test_getMergeRequestsFn(None, 'callable', 'callable') def test_getMergeRequestsFn_builder_True(self): self.do_test_getMergeRequestsFn(True, False, "default") def test_getMergeRequestsFn_builder_False(self): self.do_test_getMergeRequestsFn(False, True, None) def test_getMergeRequestsFn_builder_function(self): self.do_test_getMergeRequestsFn('callable', None, 'callable') # other methods @defer.inlineCallbacks def test_reclaimAllBuilds_empty(self): yield self.makeBuilder() # just to be sure this doesn't crash yield self.bldr.reclaimAllBuilds() @defer.inlineCallbacks def test_reclaimAllBuilds(self): yield self.makeBuilder() claims = [] def fakeClaimBRs(*args): claims.append(args) return defer.succeed(None) self.bldr.master.db.buildrequests.claimBuildRequests = fakeClaimBRs self.bldr.master.db.buildrequests.reclaimBuildRequests = fakeClaimBRs def mkbld(brids): bld = mock.Mock(name='Build') bld.requests = [] for brid in brids: br = mock.Mock(name='BuildRequest %d' % brid) br.id = brid bld.requests.append(br) return bld old = mkbld([15]) # keep a reference to the "old" build self.bldr.old_building[old] = None self.bldr.building.append(mkbld([10,11,12])) yield self.bldr.reclaimAllBuilds() self.assertEqual(claims, [ (set([10,11,12,15]),) ]) @defer.inlineCallbacks def test_canStartBuild(self): yield self.makeBuilder() # by default, it returns True startable = yield self.bldr.canStartBuild('slave', 100) self.assertEqual(startable, True) startable = yield self.bldr.canStartBuild('slave', 101) self.assertEqual(startable, True) # set a configurable one record = [] def canStartBuild(bldr, slave, breq): record.append((bldr, slave, breq)) return (slave,breq)==('slave',100) self.bldr.config.canStartBuild = canStartBuild startable = yield self.bldr.canStartBuild('slave', 100) self.assertEqual(startable, True) self.assertEqual(record, [(self.bldr, 'slave', 100)]) startable = yield self.bldr.canStartBuild('slave', 101) self.assertEqual(startable, False) self.assertEqual(record, [(self.bldr, 'slave', 100), (self.bldr, 'slave', 101)]) # set a configurable one to return Deferred record = [] def canStartBuild_deferred(bldr, slave, breq): record.append((bldr, slave, breq)) return (slave,breq)==('slave',100) return defer.succeed((slave,breq)==('slave',100)) self.bldr.config.canStartBuild = canStartBuild_deferred startable = yield self.bldr.canStartBuild('slave', 100) self.assertEqual(startable, True) self.assertEqual(record, [(self.bldr, 'slave', 100)]) startable = yield self.bldr.canStartBuild('slave', 101) self.assertEqual(startable, False) self.assertEqual(record, [(self.bldr, 'slave', 100), (self.bldr, 'slave', 101)]) @defer.inlineCallbacks def test_enforceChosenSlave(self): """enforceChosenSlave rejects and accepts builds""" yield self.makeBuilder() self.bldr.config.canStartBuild = builder.enforceChosenSlave slave = mock.Mock() slave.slave.slavename = 'slave5' breq = mock.Mock() # no buildslave requested breq.properties = {} result = yield self.bldr.canStartBuild(slave, breq) self.assertIdentical(True, result) # buildslave requested as the right one breq.properties = { 'slavename': 'slave5' } result = yield self.bldr.canStartBuild(slave, breq) self.assertIdentical(True, result) # buildslave requested as the wrong one breq.properties = { 'slavename': 'slave4' } result = yield self.bldr.canStartBuild(slave, breq) self.assertIdentical(False, result) # buildslave set to non string value gets skipped breq.properties = { 'slavename': 0 } result = yield self.bldr.canStartBuild(slave, breq) self.assertIdentical(True, result) class TestGetOldestRequestTime(BuilderMixin, unittest.TestCase): def setUp(self): # a collection of rows that would otherwise clutter up every test master_id = fakedb.FakeBuildRequestsComponent.MASTER_ID self.base_rows = [ fakedb.SourceStampSet(id=21), fakedb.SourceStamp(id=21, sourcestampsetid=21), fakedb.Buildset(id=11, reason='because', sourcestampsetid=21), fakedb.BuildRequest(id=111, submitted_at=1000, buildername='bldr1', buildsetid=11), fakedb.BuildRequest(id=222, submitted_at=2000, buildername='bldr1', buildsetid=11), fakedb.BuildRequestClaim(brid=222, objectid=master_id, claimed_at=2001), fakedb.BuildRequest(id=333, submitted_at=3000, buildername='bldr1', buildsetid=11), fakedb.BuildRequest(id=444, submitted_at=2500, buildername='bldr2', buildsetid=11), fakedb.BuildRequestClaim(brid=444, objectid=master_id, claimed_at=2501), ] def test_gort_unclaimed(self): d = self.makeBuilder(name='bldr1') d.addCallback(lambda _ : self.db.insertTestData(self.base_rows)) d.addCallback(lambda _ : self.bldr.getOldestRequestTime()) def check(rqtime): self.assertEqual(rqtime, epoch2datetime(1000)) d.addCallback(check) return d def test_gort_all_claimed(self): d = self.makeBuilder(name='bldr2') d.addCallback(lambda _ : self.db.insertTestData(self.base_rows)) d.addCallback(lambda _ : self.bldr.getOldestRequestTime()) def check(rqtime): self.assertEqual(rqtime, None) d.addCallback(check) return d class TestRebuild(BuilderMixin, unittest.TestCase): def makeBuilder(self, name, sourcestamps): d = BuilderMixin.makeBuilder(self, name=name) @d.addCallback def setupBstatus(_): self.bstatus = mock.Mock() bstatus_properties = mock.Mock() bstatus_properties.properties = {} self.bstatus.getProperties.return_value = bstatus_properties self.bstatus.getSourceStamps.return_value = sourcestamps self.master.addBuildset = addBuildset = mock.Mock() addBuildset.return_value = (1, [100]) return d @defer.inlineCallbacks def do_test_rebuild(self, sourcestampsetid, nr_of_sourcestamps): # Store combinations of sourcestampId and sourcestampSetId self.sslist = {} self.ssseq = 1 def addSourceStampToDatabase(master, sourcestampsetid): self.sslist[self.ssseq] = sourcestampsetid self.ssseq += 1 return defer.succeed(sourcestampsetid) def getSourceStampSetId(master): return addSourceStampToDatabase(master, sourcestampsetid = sourcestampsetid) sslist = [] for x in range(nr_of_sourcestamps): ssx = mock.Mock() ssx.addSourceStampToDatabase = addSourceStampToDatabase ssx.getSourceStampSetId = getSourceStampSetId sslist.append(ssx) yield self.makeBuilder(name='bldr1', sourcestamps = sslist) control = mock.Mock(spec=['master']) control.master = self.master self.bldrctrl = builder.BuilderControl(self.bldr, control) yield self.bldrctrl.rebuildBuild(self.bstatus, reason = 'unit test', extraProperties = {}) @defer.inlineCallbacks def test_rebuild_with_no_sourcestamps(self): yield self.do_test_rebuild(101, 0) self.assertEqual(self.sslist, {}) @defer.inlineCallbacks def test_rebuild_with_single_sourcestamp(self): yield self.do_test_rebuild(101, 1) self.assertEqual(self.sslist, {1:101}) self.master.addBuildset.assert_called_with(builderNames=['bldr1'], sourcestampsetid=101, reason = 'unit test', properties = {}) @defer.inlineCallbacks def test_rebuild_with_multiple_sourcestamp(self): yield self.do_test_rebuild(101, 3) self.assertEqual(self.sslist, {1:101, 2:101, 3:101}) self.master.addBuildset.assert_called_with(builderNames=['bldr1'], sourcestampsetid=101, reason = 'unit test', properties = {}) class TestReconfig(BuilderMixin, unittest.TestCase): """Tests that a reconfig properly updates all attributes""" @defer.inlineCallbacks def test_reconfig(self): yield self.makeBuilder(description="Old", category="OldCat") self.builder_config.description = "New" self.builder_config.category = "NewCat" mastercfg = config.MasterConfig() mastercfg.builders = [ self.builder_config ] yield self.bldr.reconfigService(mastercfg) self.assertEqual( dict(description=self.bldr.builder_status.getDescription(), category=self.bldr.builder_status.getCategory()), dict(description="New", category="NewCat")) buildbot-0.8.8/buildbot/test/unit/test_process_buildrequest.py000066400000000000000000000403141222546025000247540ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.test.fake import fakedb, fakemaster from buildbot.process import buildrequest class FakeSource: def __init__(self, mergeable = True): self.codebase = '' self.mergeable = mergeable self.changes = [] def canBeMergedWith(self, other): return self.mergeable class TestBuildRequest(unittest.TestCase): def test_fromBrdict(self): master = fakemaster.make_master() master.db = fakedb.FakeDBConnector(self) master.db.insertTestData([ fakedb.Change(changeid=13, branch='trunk', revision='9283', repository='svn://...', project='world-domination'), fakedb.SourceStampSet(id=234), fakedb.SourceStamp(id=234, sourcestampsetid=234, branch='trunk', revision='9284', repository='svn://...', project='world-domination'), fakedb.SourceStampChange(sourcestampid=234, changeid=13), fakedb.Buildset(id=539, reason='triggered', sourcestampsetid=234), fakedb.BuildsetProperty(buildsetid=539, property_name='x', property_value='[1, "X"]'), fakedb.BuildsetProperty(buildsetid=539, property_name='y', property_value='[2, "Y"]'), fakedb.BuildRequest(id=288, buildsetid=539, buildername='bldr', priority=13, submitted_at=1200000000), ]) # use getBuildRequest to minimize the risk from changes to the format # of the brdict d = master.db.buildrequests.getBuildRequest(288) d.addCallback(lambda brdict : buildrequest.BuildRequest.fromBrdict(master, brdict)) def check(br): # check enough of the source stamp to verify it found the changes self.assertEqual(br.source.ssid, 234) self.assertEqual([ ch.number for ch in br.source.changes], [13]) self.assertEqual(br.reason, 'triggered') self.assertEqual(br.properties.getProperty('x'), 1) self.assertEqual(br.properties.getProperty('y'), 2) self.assertEqual(br.submittedAt, 1200000000) self.assertEqual(br.buildername, 'bldr') self.assertEqual(br.priority, 13) self.assertEqual(br.id, 288) self.assertEqual(br.bsid, 539) d.addCallback(check) return d def test_fromBrdict_submittedAt_NULL(self): master = fakemaster.make_master() master.db = fakedb.FakeDBConnector(self) master.db.insertTestData([ fakedb.SourceStampSet(id=234), fakedb.SourceStamp(id=234, sourcestampsetid=234, branch='trunk', revision='9284', repository='svn://...', project='world-domination'), fakedb.Buildset(id=539, reason='triggered', sourcestampsetid=234), fakedb.BuildRequest(id=288, buildsetid=539, buildername='bldr', priority=13, submitted_at=None), ]) # use getBuildRequest to minimize the risk from changes to the format # of the brdict d = master.db.buildrequests.getBuildRequest(288) d.addCallback(lambda brdict : buildrequest.BuildRequest.fromBrdict(master, brdict)) def check(br): # remaining fields assumed to be checked in test_fromBrdict self.assertEqual(br.submittedAt, None) d.addCallback(check) return d def test_fromBrdict_no_sourcestamps(self): master = fakemaster.make_master() master.db = fakedb.FakeDBConnector(self) master.db.insertTestData([ fakedb.SourceStampSet(id=234), # Sourcestampset has no sourcestamps fakedb.Buildset(id=539, reason='triggered', sourcestampsetid=234), fakedb.BuildRequest(id=288, buildsetid=539, buildername='not important', priority=0, submitted_at=None), ]) # use getBuildRequest to minimize the risk from changes to the format # of the brdict d = master.db.buildrequests.getBuildRequest(288) d.addCallback(lambda brdict: buildrequest.BuildRequest.fromBrdict(master, brdict)) return self.assertFailure(d, AssertionError) def test_fromBrdict_multiple_sourcestamps(self): master = fakemaster.make_master() master.db = fakedb.FakeDBConnector(self) master.db.insertTestData([ fakedb.SourceStampSet(id=234), fakedb.Change(changeid=13, branch='trunk', revision='9283', repository='svn://a..', codebase='A', project='world-domination'), fakedb.SourceStamp(id=234, sourcestampsetid=234, branch='trunk', revision='9283', repository='svn://a..', codebase='A', project='world-domination'), fakedb.SourceStampChange(sourcestampid=234, changeid=13), fakedb.Change(changeid=14, branch='trunk', revision='9284', repository='svn://b..', codebase='B', project='world-domination'), fakedb.SourceStamp(id=235, sourcestampsetid=234, branch='trunk', revision='9284', repository='svn://b..', codebase='B', project='world-domination'), fakedb.SourceStampChange(sourcestampid=235, changeid=14), fakedb.Buildset(id=539, reason='triggered', sourcestampsetid=234), fakedb.BuildsetProperty(buildsetid=539, property_name='x', property_value='[1, "X"]'), fakedb.BuildsetProperty(buildsetid=539, property_name='y', property_value='[2, "Y"]'), fakedb.BuildRequest(id=288, buildsetid=539, buildername='bldr', priority=13, submitted_at=1200000000), ]) # use getBuildRequest to minimize the risk from changes to the format # of the brdict d = master.db.buildrequests.getBuildRequest(288) d.addCallback(lambda brdict : buildrequest.BuildRequest.fromBrdict(master, brdict)) def check(br): # check enough of the source stamp to verify it found the changes # Test the single-sourcestamp interface self.assertEqual(br.source.ssid, 234) # Test the multiple sourcestamp interface self.assertEqual(br.sources['A'].ssid, 234) self.assertEqual(br.sources['B'].ssid, 235) self.assertEqual([ ch.number for ch in br.sources['A'].changes], [13]) self.assertEqual([ ch.number for ch in br.sources['B'].changes], [14]) self.assertEqual(br.reason, 'triggered') self.assertEqual(br.properties.getProperty('x'), 1) self.assertEqual(br.properties.getProperty('y'), 2) self.assertEqual(br.submittedAt, 1200000000) self.assertEqual(br.buildername, 'bldr') self.assertEqual(br.priority, 13) self.assertEqual(br.id, 288) self.assertEqual(br.bsid, 539) d.addCallback(check) return d def test_mergeSourceStampsWith_common_codebases(self): """ This testcase has two buildrequests Request Change Codebase Revision Comment ---------------------------------------------------------------------- 288 13 A 9283 289 15 A 9284 288 14 B 9200 289 16 B 9201 -------------------------------- After merged in Build: Source1 has rev 9284 and contains changes 13 and 15 from repository svn://a Source2 has rev 9201 and contains changes 14 and 16 from repository svn://b """ brs=[] # list of buildrequests master = fakemaster.make_master() master.db = fakedb.FakeDBConnector(self) master.db.insertTestData([ fakedb.SourceStampSet(id=2340), fakedb.Change(changeid=13, branch='trunk', revision='9283', repository='svn://a..', codebase='A', project='world-domination'), fakedb.SourceStamp(id=234, sourcestampsetid=2340, branch='trunk', revision='9283', repository='svn://a..', codebase='A', project='world-domination'), fakedb.SourceStampChange(sourcestampid=234, changeid=13), fakedb.Change(changeid=14, branch='trunk', revision='9200', repository='svn://b..', codebase='A', project='world-domination'), fakedb.SourceStamp(id=235, sourcestampsetid=2340, branch='trunk', revision='9200', repository='svn://b..', codebase='B', project='world-domination'), fakedb.SourceStampChange(sourcestampid=235, changeid=14), fakedb.SourceStampSet(id=2360), fakedb.Change(changeid=15, branch='trunk', revision='9284', repository='svn://a..', codebase='A', project='world-domination'), fakedb.SourceStamp(id=236, sourcestampsetid=2360, branch='trunk', revision='9284', repository='svn://a..', codebase='A', project='world-domination'), fakedb.SourceStampChange(sourcestampid=236, changeid=15), fakedb.Change(changeid=16, branch='trunk', revision='9201', repository='svn://b..', codebase='B', project='world-domination'), fakedb.SourceStamp(id=237, sourcestampsetid=2360, branch='trunk', revision='9201', repository='svn://b..', codebase='B', project='world-domination'), fakedb.SourceStampChange(sourcestampid=237, changeid=16), fakedb.Buildset(id=539, reason='triggered', sourcestampsetid=2340), fakedb.BuildRequest(id=288, buildsetid=539, buildername='bldr'), fakedb.Buildset(id=540, reason='triggered', sourcestampsetid=2360), fakedb.BuildRequest(id=289, buildsetid=540, buildername='bldr'), ]) # use getBuildRequest to minimize the risk from changes to the format # of the brdict d = master.db.buildrequests.getBuildRequest(288) d.addCallback(lambda brdict : buildrequest.BuildRequest.fromBrdict(master, brdict)) d.addCallback(lambda br : brs.append(br)) d.addCallback(lambda _ : master.db.buildrequests.getBuildRequest(289)) d.addCallback(lambda brdict : buildrequest.BuildRequest.fromBrdict(master, brdict)) d.addCallback(lambda br : brs.append(br)) def check(_): sources = brs[0].mergeSourceStampsWith(brs[1:]) source1 = source2 = None for source in sources: if source.codebase == 'A': source1 = source if source.codebase == 'B': source2 = source self.assertFalse(source1 == None) self.assertEqual(source1.revision,'9284') self.assertFalse(source2 == None) self.assertEqual(source2.revision,'9201') self.assertEqual([c.number for c in source1.changes], [13,15]) self.assertEqual([c.number for c in source2.changes], [14,16]) d.addCallback(check) return d def test_canBeMergedWith_different_codebases_raises_error(self): """ This testcase has two buildrequests Request Change Codebase Revision Comment ---------------------------------------------------------------------- 288 17 C 1800 request 1 has repo not in request 2 289 18 D 2100 request 2 has repo not in request 1 -------------------------------- Merge cannot be performd and raises error: Merging requests requires both requests to have the same codebases """ brs=[] # list of buildrequests master = fakemaster.make_master() master.db = fakedb.FakeDBConnector(self) master.db.insertTestData([ fakedb.SourceStampSet(id=2340), fakedb.Change(changeid=17, branch='trunk', revision='1800', repository='svn://c..', codebase='C', project='world-domination'), fakedb.SourceStamp(id=238, sourcestampsetid=2340, branch='trunk', revision='1800', repository='svn://c..', codebase='C', project='world-domination'), fakedb.SourceStampChange(sourcestampid=238, changeid=17), fakedb.SourceStampSet(id=2360), fakedb.Change(changeid=18, branch='trunk', revision='2100', repository='svn://d..', codebase='D', project='world-domination'), fakedb.SourceStamp(id=239, sourcestampsetid=2360, branch='trunk', revision='2100', repository='svn://d..', codebase='D', project='world-domination'), fakedb.SourceStampChange(sourcestampid=239, changeid=18), fakedb.Buildset(id=539, reason='triggered', sourcestampsetid=2340), fakedb.BuildRequest(id=288, buildsetid=539, buildername='bldr'), fakedb.Buildset(id=540, reason='triggered', sourcestampsetid=2360), fakedb.BuildRequest(id=289, buildsetid=540, buildername='bldr'), ]) # use getBuildRequest to minimize the risk from changes to the format # of the brdict d = master.db.buildrequests.getBuildRequest(288) d.addCallback(lambda brdict : buildrequest.BuildRequest.fromBrdict(master, brdict)) d.addCallback(lambda br : brs.append(br)) d.addCallback(lambda _ : master.db.buildrequests.getBuildRequest(289)) d.addCallback(lambda brdict : buildrequest.BuildRequest.fromBrdict(master, brdict)) d.addCallback(lambda br : brs.append(br)) def check(_): self.assertEqual(brs[0].canBeMergedWith(brs[1]), False) d.addCallback(check) return d def test_build_can_be_merged_with_mergables_same_codebases(self): r1 = buildrequest.BuildRequest() r1.sources = {"A": FakeSource()} r2 = buildrequest.BuildRequest() r2.sources = {"A": FakeSource()} mergeable = r1.canBeMergedWith(r2) self.assertTrue(mergeable, "Both request should be able to merge") def test_build_can_be_merged_with_non_mergable_same_codebases(self): r1 = buildrequest.BuildRequest() r1.sources = {"A": FakeSource(mergeable = False)} r2 = buildrequest.BuildRequest() r2.sources = {"A": FakeSource(mergeable = False)} mergeable = r1.canBeMergedWith(r2) self.assertFalse(mergeable, "Both request should not be able to merge") def test_build_can_be_merged_with_non_mergables_different_codebases(self): r1 = buildrequest.BuildRequest() r1.sources = {"A": FakeSource(mergeable = False)} r2 = buildrequest.BuildRequest() r2.sources = {"B": FakeSource(mergeable = False)} mergeable = r1.canBeMergedWith(r2) self.assertFalse(mergeable, "Request containing different codebases " + "should never be able to merge") buildbot-0.8.8/buildbot/test/unit/test_process_buildrequestdistributor_BuildRequestDistributor.py000066400000000000000000001220021222546025000341650ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from twisted.internet import defer, reactor from twisted.python import failure from buildbot.test.util import compat from buildbot.test.fake import fakedb, fakemaster from buildbot.process import buildrequestdistributor from buildbot.util import epoch2datetime from buildbot.util.eventual import fireEventually from buildbot.db import buildrequests def nth_slave(n): def pick_nth_by_name(lst): slaves = lst[:] slaves.sort(cmp=lambda a,b: cmp(a.name, b.name)) return slaves[n] return pick_nth_by_name class SkipSlavesThatCantGetLock(buildrequestdistributor.BasicBuildChooser): """This class disables the 'rejectedSlaves' feature""" def __init__(self, *args, **kwargs): buildrequestdistributor.BasicBuildChooser.__init__(self, *args, **kwargs) self.rejectedSlaves = None # disable this feature class Test(unittest.TestCase): def setUp(self): self.botmaster = mock.Mock(name='botmaster') self.botmaster.builders = {} def prioritizeBuilders(master, builders): # simple sort-by-name by default return sorted(builders, lambda b1,b2 : cmp(b1.name, b2.name)) self.master = self.botmaster.master = mock.Mock(name='master') self.master.config.prioritizeBuilders = prioritizeBuilders self.master.db = fakedb.FakeDBConnector(self) self.brd = buildrequestdistributor.BuildRequestDistributor(self.botmaster) self.brd.startService() # TODO: this is a terrible way to detect the "end" of the test - # it regularly completes too early after a simple modification of # a test. Is there a better way? self.quiet_deferred = defer.Deferred() def _quiet(): if self.quiet_deferred: d, self.quiet_deferred = self.quiet_deferred, None d.callback(None) else: self.fail("loop has already gone quiet once") self.brd._quiet = _quiet self.builders = {} def tearDown(self): if self.brd.running: return self.brd.stopService() def checkAllCleanedUp(self): # check that the BRD didnt end with a stuck lock or in the 'active' state (which would mean # it ended without unwinding correctly) self.assertEqual(self.brd.pending_builders_lock.locked, False) self.assertEqual(self.brd.activity_lock.locked, False) self.assertEqual(self.brd.active, False) def useMock_maybeStartBuildsOnBuilder(self): # sets up a mock "maybeStartBuildsOnBuilder" so we can track # how the method gets invoked # keep track of the calls to brd.maybeStartBuildsOnBuilder self.maybeStartBuildsOnBuilder_calls = [] def maybeStartBuildsOnBuilder(bldr): self.assertIdentical(self.builders[bldr.name], bldr) self.maybeStartBuildsOnBuilder_calls.append(bldr.name) return fireEventually() self.brd._maybeStartBuildsOnBuilder = maybeStartBuildsOnBuilder def addBuilders(self, names): self.startedBuilds = [] for name in names: bldr = mock.Mock(name=name) bldr.name = name self.botmaster.builders[name] = bldr self.builders[name] = bldr def maybeStartBuild(*args): self.startedBuilds.append((name, args)) d = defer.Deferred() reactor.callLater(0, d.callback, None) return d bldr.maybeStartBuild = maybeStartBuild bldr.canStartWithSlavebuilder = lambda _: True bldr.slaves = [] bldr.getAvailableSlaves = lambda : [ s for s in bldr.slaves if s.isAvailable ] def removeBuilder(self, name): del self.builders[name] del self.botmaster.builders[name] # tests def test_maybeStartBuildsOn_simple(self): self.useMock_maybeStartBuildsOnBuilder() self.addBuilders(['bldr1']) self.brd.maybeStartBuildsOn(['bldr1']) def check(_): self.assertEqual(self.maybeStartBuildsOnBuilder_calls, ['bldr1']) self.checkAllCleanedUp() self.quiet_deferred.addCallback(check) return self.quiet_deferred def test_maybeStartBuildsOn_parallel(self): # test 15 "parallel" invocations of maybeStartBuildsOn, with a # _sortBuilders that takes a while. This is a regression test for bug # #1979. builders = ['bldr%02d' % i for i in xrange(15) ] def slow_sorter(master, bldrs): bldrs.sort(lambda b1, b2 : cmp(b1.name, b2.name)) d = defer.Deferred() reactor.callLater(0, d.callback, bldrs) def done(_): return _ d.addCallback(done) return d self.master.config.prioritizeBuilders = slow_sorter self.useMock_maybeStartBuildsOnBuilder() self.addBuilders(builders) for bldr in builders: self.brd.maybeStartBuildsOn([bldr]) def check(_): self.assertEqual(self.maybeStartBuildsOnBuilder_calls, builders) self.checkAllCleanedUp() self.quiet_deferred.addCallback(check) return self.quiet_deferred @compat.usesFlushLoggedErrors def test_maybeStartBuildsOn_exception(self): self.addBuilders(['bldr1']) def _maybeStartBuildsOnBuilder(n): # fail slowly, so that the activity loop doesn't go quiet too soon d = defer.Deferred() reactor.callLater(0, d.errback, failure.Failure(RuntimeError("oh noes"))) return d self.brd._maybeStartBuildsOnBuilder = _maybeStartBuildsOnBuilder self.brd.maybeStartBuildsOn(['bldr1']) def check(_): self.assertEqual(len(self.flushLoggedErrors(RuntimeError)), 1) self.checkAllCleanedUp() self.quiet_deferred.addCallback(check) return self.quiet_deferred def test_maybeStartBuildsOn_collapsing(self): self.useMock_maybeStartBuildsOnBuilder() self.addBuilders(['bldr1', 'bldr2', 'bldr3']) self.brd.maybeStartBuildsOn(['bldr3']) self.brd.maybeStartBuildsOn(['bldr2', 'bldr1']) self.brd.maybeStartBuildsOn(['bldr4']) # should be ignored self.brd.maybeStartBuildsOn(['bldr2']) # already queued - ignored self.brd.maybeStartBuildsOn(['bldr3', 'bldr2']) def check(_): # bldr3 gets invoked twice, since it's considered to have started # already when the first call to maybeStartBuildsOn returns self.assertEqual(self.maybeStartBuildsOnBuilder_calls, ['bldr3', 'bldr1', 'bldr2', 'bldr3']) self.checkAllCleanedUp() self.quiet_deferred.addCallback(check) return self.quiet_deferred def test_maybeStartBuildsOn_builders_missing(self): self.useMock_maybeStartBuildsOnBuilder() self.addBuilders(['bldr1', 'bldr2', 'bldr3']) self.brd.maybeStartBuildsOn(['bldr1', 'bldr2', 'bldr3']) # bldr1 is already run, so surreptitiously remove the other # two - nothing should crash, but the builders should not run self.removeBuilder('bldr2') self.removeBuilder('bldr3') def check(_): self.assertEqual(self.maybeStartBuildsOnBuilder_calls, ['bldr1']) self.checkAllCleanedUp() self.quiet_deferred.addCallback(check) return self.quiet_deferred def do_test_sortBuilders(self, prioritizeBuilders, oldestRequestTimes, expected, returnDeferred=False): self.useMock_maybeStartBuildsOnBuilder() self.addBuilders(oldestRequestTimes.keys()) self.master.config.prioritizeBuilders = prioritizeBuilders def mklambda(t): # work around variable-binding issues if returnDeferred: return lambda : defer.succeed(t) else: return lambda : t for n, t in oldestRequestTimes.iteritems(): if t is not None: t = epoch2datetime(t) self.builders[n].getOldestRequestTime = mklambda(t) d = self.brd._sortBuilders(oldestRequestTimes.keys()) def check(result): self.assertEqual(result, expected) self.checkAllCleanedUp() d.addCallback(check) return d def test_sortBuilders_default_sync(self): return self.do_test_sortBuilders(None, # use the default sort dict(bldr1=777, bldr2=999, bldr3=888), ['bldr1', 'bldr3', 'bldr2']) def test_sortBuilders_default_asyn(self): return self.do_test_sortBuilders(None, # use the default sort dict(bldr1=777, bldr2=999, bldr3=888), ['bldr1', 'bldr3', 'bldr2'], returnDeferred=True) def test_sortBuilders_default_None(self): return self.do_test_sortBuilders(None, # use the default sort dict(bldr1=777, bldr2=None, bldr3=888), ['bldr1', 'bldr3', 'bldr2']) def test_sortBuilders_custom(self): def prioritizeBuilders(master, builders): self.assertIdentical(master, self.master) return sorted(builders, key=lambda b : b.name) return self.do_test_sortBuilders(prioritizeBuilders, dict(bldr1=1, bldr2=1, bldr3=1), ['bldr1', 'bldr2', 'bldr3']) def test_sortBuilders_custom_async(self): def prioritizeBuilders(master, builders): self.assertIdentical(master, self.master) return defer.succeed(sorted(builders, key=lambda b : b.name)) return self.do_test_sortBuilders(prioritizeBuilders, dict(bldr1=1, bldr2=1, bldr3=1), ['bldr1', 'bldr2', 'bldr3']) @compat.usesFlushLoggedErrors def test_sortBuilders_custom_exception(self): self.useMock_maybeStartBuildsOnBuilder() self.addBuilders(['x', 'y']) def fail(m, b): raise RuntimeError("oh noes") self.master.config.prioritizeBuilders = fail # expect to get the builders back in the same order in the event of an # exception d = self.brd._sortBuilders(['y', 'x']) def check(result): self.assertEqual(result, ['y', 'x']) # and expect the exception to be logged self.assertEqual(len(self.flushLoggedErrors(RuntimeError)), 1) d.addCallback(check) return d def test_stopService(self): # check that stopService waits for a builder run to complete, but does not # allow a subsequent run to start self.useMock_maybeStartBuildsOnBuilder() self.addBuilders(['A', 'B']) oldMSBOB = self.brd._maybeStartBuildsOnBuilder def maybeStartBuildsOnBuilder(bldr): d = oldMSBOB(bldr) stop_d = self.brd.stopService() stop_d.addCallback(lambda _ : self.maybeStartBuildsOnBuilder_calls.append('(stopped)')) d.addCallback(lambda _ : self.maybeStartBuildsOnBuilder_calls.append('finished')) return d self.brd._maybeStartBuildsOnBuilder = maybeStartBuildsOnBuilder # start both builds; A should start and complete *before* the service stops, # and B should not run. self.brd.maybeStartBuildsOn(['A', 'B']) def check(_): self.assertEqual(self.maybeStartBuildsOnBuilder_calls, ['A', 'finished', '(stopped)']) self.quiet_deferred.addCallback(check) return self.quiet_deferred class TestMaybeStartBuilds(unittest.TestCase): def setUp(self): self.botmaster = mock.Mock(name='botmaster') self.botmaster.builders = {} self.master = self.botmaster.master = mock.Mock(name='master') self.master.db = fakedb.FakeDBConnector(self) class getCache(object): def get_cache(self): return self def get(self, name): return self.master.caches = fakemaster.FakeCaches() self.brd = buildrequestdistributor.BuildRequestDistributor(self.botmaster) self.brd.startService() self.startedBuilds = [] # TODO: this is a terrible way to detect the "end" of the test - # it regularly completes too early after a simple modification of # a test. Is there a better way? self.quiet_deferred = defer.Deferred() def _quiet(): if self.quiet_deferred: d, self.quiet_deferred = self.quiet_deferred, None d.callback(None) else: self.fail("loop has already gone quiet once") self.brd._quiet = _quiet self.bldr = self.createBuilder('A') # a collection of rows that would otherwise clutter up every test self.base_rows = [ fakedb.SourceStampSet(id=21), fakedb.SourceStamp(id=21, sourcestampsetid=21), fakedb.Buildset(id=11, reason='because', sourcestampsetid=21), ] def tearDown(self): if self.brd.running: return self.brd.stopService() def createBuilder(self, name): bldr = mock.Mock(name=name) bldr.name = name self.botmaster.builders[name] = bldr def maybeStartBuild(slave, builds): self.startedBuilds.append((slave.name, builds)) return defer.succeed(True) bldr.maybeStartBuild = maybeStartBuild bldr.canStartWithSlavebuilder = lambda _: True bldr.getMergeRequestsFn = lambda : False bldr.slaves = [] bldr.getAvailableSlaves = lambda : [ s for s in bldr.slaves if s.isAvailable() ] bldr.config.nextSlave = None bldr.config.nextBuild = None def canStartBuild(*args): can = bldr.config.canStartBuild return not can or can(*args) bldr.canStartBuild = canStartBuild return bldr def addSlaves(self, slavebuilders): """C{slaves} maps name : available""" for name, avail in slavebuilders.iteritems(): sb = mock.Mock(spec=['isAvailable'], name=name) sb.name = name sb.isAvailable.return_value = avail self.bldr.slaves.append(sb) def assertBuildsStarted(self, exp): # munge builds_started into (slave, [brids]) builds_started = [ (slave, [br.id for br in breqs]) for (slave, breqs) in self.startedBuilds ] self.assertEqual(sorted(builds_started), sorted(exp)) # _maybeStartBuildsOnBuilder @defer.inlineCallbacks def do_test_maybeStartBuildsOnBuilder(self, rows=[], exp_claims=[], exp_builds=[]): yield self.master.db.insertTestData(rows) yield self.brd._maybeStartBuildsOnBuilder(self.bldr) self.master.db.buildrequests.assertMyClaims(exp_claims) self.assertBuildsStarted(exp_builds) @defer.inlineCallbacks def test_no_buildreqests(self): self.addSlaves({'test-slave11':1}) yield self.do_test_maybeStartBuildsOnBuilder(exp_claims=[], exp_builds=[]) @defer.inlineCallbacks def test_no_slavebuilders(self): rows = [ fakedb.BuildRequest(id=11, buildsetid=10, buildername="bldr"), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[], exp_builds=[]) @defer.inlineCallbacks def test_limited_by_slaves(self): self.master.config.mergeRequests = False self.addSlaves({'test-slave1':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=10, buildsetid=11, buildername="A", submitted_at=130000), fakedb.BuildRequest(id=11, buildsetid=11, buildername="A", submitted_at=135000), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[10], exp_builds=[('test-slave1', [10])]) @defer.inlineCallbacks def test_sorted_by_submit_time(self): self.master.config.mergeRequests = False # same as "limited_by_slaves" but with rows swapped self.addSlaves({'test-slave1':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=11, buildsetid=11, buildername="A", submitted_at=135000), fakedb.BuildRequest(id=10, buildsetid=11, buildername="A", submitted_at=130000), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[10], exp_builds=[('test-slave1', [10])]) @defer.inlineCallbacks def test_limited_by_available_slaves(self): self.master.config.mergeRequests = False self.addSlaves({'test-slave1':0, 'test-slave2':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=10, buildsetid=11, buildername="A", submitted_at=130000), fakedb.BuildRequest(id=11, buildsetid=11, buildername="A", submitted_at=135000), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[10], exp_builds=[('test-slave2', [10])]) @defer.inlineCallbacks def test_slow_db(self): # test what happens if the "getBuildRequests" fetch takes a "long time" self.master.config.mergeRequests = False self.addSlaves({'test-slave1':1}) # wrap to simulate a "long" db access old_getBuildRequests = self.master.db.buildrequests.getBuildRequests def longGetBuildRequests(*args, **kwargs): res_d = old_getBuildRequests(*args, **kwargs) long_d = defer.Deferred() long_d.addCallback(lambda _: res_d) reactor.callLater(0, long_d.callback, None) return long_d self.master.db.buildrequests.getBuildRequests = longGetBuildRequests rows = self.base_rows + [ fakedb.BuildRequest(id=10, buildsetid=11, buildername="A", submitted_at=130000), fakedb.BuildRequest(id=11, buildsetid=11, buildername="A", submitted_at=135000), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[10], exp_builds=[('test-slave1', [10])]) @mock.patch('random.choice', nth_slave(-1)) @defer.inlineCallbacks def test_limited_by_canStartBuild(self): """Set the 'canStartBuild' value in the config to something that limits the possible options.""" self.master.config.mergeRequests = False slaves_attempted = [] def _canStartWithSlavebuilder(slavebuilder): slaves_attempted.append(slavebuilder.name) return True self.bldr.canStartWithSlavebuilder = _canStartWithSlavebuilder pairs_tested = [] def _canStartBuild(slave, breq): result = (slave.name, breq.id) pairs_tested.append(result) allowed = [ ("test-slave1", 10), ("test-slave3", 11), ] return result in allowed self.bldr.config.canStartBuild = _canStartBuild self.addSlaves({'test-slave1':1, 'test-slave2':1, 'test-slave3':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=10, buildsetid=11, buildername="A", submitted_at=130000), fakedb.BuildRequest(id=11, buildsetid=11, buildername="A", submitted_at=135000), fakedb.BuildRequest(id=12, buildsetid=11, buildername="A", submitted_at=140000), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[10, 11], exp_builds=[('test-slave1', [10]), ('test-slave3', [11])]) self.assertEqual(slaves_attempted, ['test-slave3', 'test-slave2', 'test-slave1']) # we expect brids in order (10-11-12), # with each searched in reverse order of slaves (3-2-1) available (due to nth_slave(-1)) self.assertEqual(pairs_tested, [ ('test-slave3', 10), ('test-slave2', 10), ('test-slave1', 10), ('test-slave3', 11), ('test-slave2', 12)]) @mock.patch('random.choice', nth_slave(-1)) @mock.patch('buildbot.process.buildrequestdistributor.BuildRequestDistributor.BuildChooser', SkipSlavesThatCantGetLock) @defer.inlineCallbacks def test_limited_by_canStartBuild_deferreds(self): """Another variant that: * returns Defered types, * use 'canStartWithSlavebuilder' to reject one of the slaves * patch using SkipSlavesThatCantGetLock to disable the 'rejectedSlaves' feature""" self.master.config.mergeRequests = False slaves_attempted = [] def _canStartWithSlavebuilder(slavebuilder): slaves_attempted.append(slavebuilder.name) allowed = slavebuilder.name in ['test-slave2', 'test-slave1'] return defer.succeed(allowed) # a defered here! self.bldr.canStartWithSlavebuilder = _canStartWithSlavebuilder pairs_tested = [] def _canStartBuild(slave, breq): result = (slave.name, breq.id) pairs_tested.append(result) allowed = [ ("test-slave1", 10), ("test-slave3", 11), ] return defer.succeed(result in allowed) self.bldr.config.canStartBuild = _canStartBuild self.addSlaves({'test-slave1':1, 'test-slave2':1, 'test-slave3':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=10, buildsetid=11, buildername="A", submitted_at=130000), fakedb.BuildRequest(id=11, buildsetid=11, buildername="A", submitted_at=135000), fakedb.BuildRequest(id=12, buildsetid=11, buildername="A", submitted_at=140000), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[10], exp_builds=[('test-slave1', [10])]) self.assertEqual(slaves_attempted, ['test-slave3', 'test-slave2', 'test-slave1']) # we expect brids in order (10-11-12), # with slave3 skipped, and slave2 unable to pair self.assertEqual(pairs_tested, [ ('test-slave2', 10), ('test-slave1', 10), ('test-slave2', 11), ('test-slave2', 12)]) @mock.patch('random.choice', nth_slave(-1)) @defer.inlineCallbacks def test_limited_by_canStartWithSlavebuilder(self): self.master.config.mergeRequests = False slaves_attempted = [] def _canStartWithSlavebuilder(slavebuilder): slaves_attempted.append(slavebuilder.name) return (slavebuilder.name == 'test-slave3') self.bldr.canStartWithSlavebuilder = _canStartWithSlavebuilder self.addSlaves({'test-slave1':0, 'test-slave2':1, 'test-slave3':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=10, buildsetid=11, buildername="A", submitted_at=130000), fakedb.BuildRequest(id=11, buildsetid=11, buildername="A", submitted_at=135000), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[10, 11], exp_builds=[('test-slave3', [10]), ('test-slave2', [11])]) self.assertEqual(slaves_attempted, ['test-slave3', 'test-slave2']) @mock.patch('random.choice', nth_slave(-1)) @defer.inlineCallbacks def test_unlimited(self): self.master.config.mergeRequests = False self.addSlaves({'test-slave1':1, 'test-slave2':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=10, buildsetid=11, buildername="A", submitted_at=130000), fakedb.BuildRequest(id=11, buildsetid=11, buildername="A", submitted_at=135000), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[10, 11], exp_builds=[('test-slave2', [10]), ('test-slave1', [11])]) @mock.patch('random.choice', nth_slave(-1)) @defer.inlineCallbacks def test_bldr_maybeStartBuild_fails_always(self): # the builder fails to start the build; we'll see that the build # was requested, but the brids will get reclaimed def maybeStartBuild(slave, builds): self.startedBuilds.append((slave.name, builds)) return defer.succeed(False) self.bldr.maybeStartBuild = maybeStartBuild self.master.config.mergeRequests = False self.addSlaves({'test-slave1':1, 'test-slave2':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=10, buildsetid=11, buildername="A", submitted_at=130000), fakedb.BuildRequest(id=11, buildsetid=11, buildername="A", submitted_at=135000), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[], # reclaimed so none taken! exp_builds=[('test-slave2', [10]), ('test-slave1', [11])]) @mock.patch('random.choice', nth_slave(-1)) @defer.inlineCallbacks def test_bldr_maybeStartBuild_fails_once(self): # the builder fails to start the build; we'll see that the build # was requested, but the brids will get reclaimed def maybeStartBuild(slave, builds, _fail=[False]): self.startedBuilds.append((slave.name, builds)) ret = _fail[0] _fail[0] = True return defer.succeed(ret) self.bldr.maybeStartBuild = maybeStartBuild self.master.config.mergeRequests = False self.addSlaves({'test-slave1':1, 'test-slave2':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=10, buildsetid=11, buildername="A", submitted_at=130000), fakedb.BuildRequest(id=11, buildsetid=11, buildername="A", submitted_at=135000), ] yield self.master.db.insertTestData(rows) # first time around, only #11 stays claimed yield self.brd._maybeStartBuildsOnBuilder(self.bldr) self.master.db.buildrequests.assertMyClaims([11]) # reclaimed so none taken! self.assertBuildsStarted([('test-slave2', [10]), ('test-slave1', [11])]) # second time around the #10 will pass, adding another request and it is claimed yield self.brd._maybeStartBuildsOnBuilder(self.bldr) self.master.db.buildrequests.assertMyClaims([10, 11]) self.assertBuildsStarted([('test-slave2', [10]), ('test-slave1', [11]), ('test-slave2', [10])]) @mock.patch('random.choice', nth_slave(1)) @defer.inlineCallbacks def test_limited_by_requests(self): self.master.config.mergeRequests = False self.addSlaves({'test-slave1':1, 'test-slave2':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=11, buildsetid=11, buildername="A"), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[11], exp_builds=[('test-slave2', [11])]) @defer.inlineCallbacks def test_nextSlave_None(self): self.bldr.config.nextSlave = lambda _1,_2 : defer.succeed(None) self.addSlaves({'test-slave1':1, 'test-slave2':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=11, buildsetid=11, buildername="A"), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[], exp_builds=[]) @defer.inlineCallbacks def test_nextSlave_bogus(self): self.bldr.config.nextSlave = lambda _1,_2 : defer.succeed(mock.Mock()) self.addSlaves({'test-slave1':1, 'test-slave2':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=11, buildsetid=11, buildername="A"), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[], exp_builds=[]) @defer.inlineCallbacks def test_nextSlave_fails(self): def nextSlaveRaises(*args): raise RuntimeError("xx") self.bldr.config.nextSlave = nextSlaveRaises self.addSlaves({'test-slave1':1, 'test-slave2':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=11, buildsetid=11, buildername="A"), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[], exp_builds=[]) @defer.inlineCallbacks def test_nextBuild_None(self): self.bldr.config.nextBuild = lambda _1,_2 : defer.succeed(None) self.addSlaves({'test-slave1':1, 'test-slave2':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=11, buildsetid=11, buildername="A"), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[], exp_builds=[]) @defer.inlineCallbacks def test_nextBuild_bogus(self): self.bldr.config.nextBuild = lambda _1,_2 : mock.Mock() self.addSlaves({'test-slave1':1, 'test-slave2':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=11, buildsetid=11, buildername="A"), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[], exp_builds=[]) @defer.inlineCallbacks def test_nextBuild_fails(self): def nextBuildRaises(*args): raise RuntimeError("xx") self.bldr.config.nextBuild = nextBuildRaises self.addSlaves({'test-slave1':1, 'test-slave2':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=11, buildsetid=11, buildername="A"), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[], exp_builds=[]) # check concurrency edge cases @mock.patch('random.choice', nth_slave(0)) @defer.inlineCallbacks def test_claim_race(self): # fake a race condition on the buildrequests table old_claimBuildRequests = self.master.db.buildrequests.claimBuildRequests def claimBuildRequests(brids): # first, ensure this only happens the first time self.master.db.buildrequests.claimBuildRequests = old_claimBuildRequests # claim brid 10 for some other master assert 10 in brids self.master.db.buildrequests.fakeClaimBuildRequest(10, 136000, objectid=9999) # some other objectid # ..and fail return defer.fail(buildrequests.AlreadyClaimedError()) self.master.db.buildrequests.claimBuildRequests = claimBuildRequests self.addSlaves({'test-slave1':1, 'test-slave2':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=10, buildsetid=11, buildername="A", submitted_at=130000), # will turn out to be claimed! fakedb.BuildRequest(id=11, buildsetid=11, buildername="A", submitted_at=135000), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[11], exp_builds=[('test-slave1', [11])]) # nextSlave @defer.inlineCallbacks def do_test_nextSlave(self, nextSlave, exp_choice=None): for i in range(4): self.addSlaves({'sb%d'%i: 1}) self.bldr.config.nextSlave = nextSlave rows = self.base_rows + [ fakedb.BuildRequest(id=11, buildsetid=11, buildername="A"), ] if exp_choice is None: exp_claims = [] exp_builds = [] else: exp_claims = [11] exp_builds = [('sb%d'%exp_choice, [11])] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=exp_claims, exp_builds=exp_builds) @mock.patch('random.choice', nth_slave(2)) def test_nextSlave_default(self): return self.do_test_nextSlave(None, exp_choice=2) def test_nextSlave_simple(self): def nextSlave(bldr, lst): self.assertIdentical(bldr, self.bldr) return lst[1] return self.do_test_nextSlave(nextSlave, exp_choice=1) def test_nextSlave_deferred(self): def nextSlave(bldr, lst): self.assertIdentical(bldr, self.bldr) return defer.succeed(lst[1]) return self.do_test_nextSlave(nextSlave, exp_choice=1) def test_nextSlave_exception(self): def nextSlave(bldr, lst): raise RuntimeError("") return self.do_test_nextSlave(nextSlave) def test_nextSlave_failure(self): def nextSlave(bldr, lst): return defer.fail(failure.Failure(RuntimeError())) return self.do_test_nextSlave(nextSlave) # _nextBuild @mock.patch('random.choice', nth_slave(-1)) @defer.inlineCallbacks def do_test_nextBuild(self, nextBuild, exp_choice=None): self.bldr.config.nextBuild = nextBuild self.master.config.mergeRequests = False rows = self.base_rows[:] for i in range(4): rows.append(fakedb.Buildset(id=100+i, reason='because', sourcestampsetid=21)) rows.append(fakedb.BuildRequest(id=10+i, buildsetid=100+i, buildername="A")) self.addSlaves({'test-slave%d'%i:1}) exp_claims = [] exp_builds = [] if exp_choice is not None: slave = 3 for choice in exp_choice: exp_claims.append(choice) exp_builds.append(('test-slave%d'%slave, [choice])) slave = slave - 1 yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=sorted(exp_claims), exp_builds=exp_builds) def test_nextBuild_default(self): "default chooses the first in the list, which should be the earliest" return self.do_test_nextBuild(None, exp_choice=[10, 11, 12, 13]) def test_nextBuild_simple(self): def nextBuild(bldr, lst): self.assertIdentical(bldr, self.bldr) return lst[-1] return self.do_test_nextBuild(nextBuild, exp_choice=[13, 12, 11, 10]) def test_nextBuild_deferred(self): def nextBuild(bldr, lst): self.assertIdentical(bldr, self.bldr) return defer.succeed(lst[-1]) return self.do_test_nextBuild(nextBuild, exp_choice=[13, 12, 11, 10]) def test_nextBuild_exception(self): def nextBuild(bldr, lst): raise RuntimeError("") return self.do_test_nextBuild(nextBuild) def test_nextBuild_failure(self): def nextBuild(bldr, lst): return defer.fail(failure.Failure(RuntimeError())) return self.do_test_nextBuild(nextBuild) # merge tests @defer.inlineCallbacks def test_merge_ordering(self): # (patch_random=True) self.bldr.getMergeRequestsFn = lambda : lambda _, req1, req2: req1.canBeMergedWith(req2) self.addSlaves({'test-slave1':1}) # based on the build in bug #2249 rows = [ fakedb.SourceStampSet(id=1976), fakedb.SourceStamp(id=1976, sourcestampsetid=1976), fakedb.Buildset(id=1980, reason='scheduler', sourcestampsetid=1976, submitted_at=1332024020.67792), fakedb.BuildRequest(id=42880, buildsetid=1980, submitted_at=1332024020.67792, buildername="A"), fakedb.SourceStampSet(id=1977), fakedb.SourceStamp(id=1977, sourcestampsetid=1977), fakedb.Buildset(id=1981, reason='scheduler', sourcestampsetid=1977, submitted_at=1332025495.19141), fakedb.BuildRequest(id=42922, buildsetid=1981, buildername="A", submitted_at=1332025495.19141), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[42880, 42922], exp_builds=[('test-slave1', [42880, 42922])]) @mock.patch('random.choice', nth_slave(0)) @defer.inlineCallbacks def test_mergeRequests(self): # set up all of the data required for a BuildRequest object rows = [ fakedb.SourceStampSet(id=234), fakedb.SourceStamp(id=234, sourcestampsetid=234), fakedb.Buildset(id=30, sourcestampsetid=234, reason='foo', submitted_at=1300305712, results=-1), fakedb.BuildRequest(id=19, buildsetid=30, buildername='A', priority=13, submitted_at=1300305712, results=-1), fakedb.BuildRequest(id=20, buildsetid=30, buildername='A', priority=13, submitted_at=1300305712, results=-1), fakedb.BuildRequest(id=21, buildsetid=30, buildername='A', priority=13, submitted_at=1300305712, results=-1), ] self.addSlaves({'test-slave1':1, 'test-slave2': 1}) def mergeRequests_fn(builder, breq, other): # merge evens with evens, odds with odds self.assertIdentical(builder, self.bldr) return breq.id % 2 == other.id % 2 self.bldr.getMergeRequestsFn = lambda : mergeRequests_fn yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[19, 20, 21], exp_builds=[ ('test-slave1', [19, 21]), ('test-slave2', [20]) ]) @mock.patch('random.choice', nth_slave(0)) @defer.inlineCallbacks def test_mergeRequest_no_other_request(self): """ Test if builder test for codebases in requests """ # set up all of the data required for a BuildRequest object rows = [ fakedb.SourceStampSet(id=234), fakedb.SourceStamp(id=234, sourcestampsetid=234, codebase='A'), fakedb.Change(changeid=14, codebase='A'), fakedb.SourceStampChange(sourcestampid=234, changeid=14), fakedb.Buildset(id=30, sourcestampsetid=234, reason='foo', submitted_at=1300305712, results=-1), fakedb.BuildRequest(id=19, buildsetid=30, buildername='A', priority=13, submitted_at=1300305712, results=-1), ] self.addSlaves({'test-slave1':1, 'test-slave2': 1}) def mergeRequests_fn(builder, breq, other): # Allow all requests self.fail("Should never be called") return True self.bldr.getMergeRequestsFn = lambda : mergeRequests_fn # check if the request remains the same yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[19], exp_builds=[ ('test-slave1', [19]), ]) @mock.patch('random.choice', nth_slave(0)) @defer.inlineCallbacks def test_mergeRequests_no_merging(self): """ Test if builder test for codebases in requests """ # set up all of the data required for a BuildRequest object rows = [ fakedb.SourceStampSet(id=234), fakedb.SourceStamp(id=234, sourcestampsetid=234, codebase='C'), fakedb.Buildset(id=30, sourcestampsetid=234, reason='foo', submitted_at=1300305712, results=-1), fakedb.SourceStampSet(id=235), fakedb.SourceStamp(id=235, sourcestampsetid=235, codebase='C'), fakedb.Buildset(id=31, sourcestampsetid=235, reason='foo', submitted_at=1300305712, results=-1), fakedb.SourceStampSet(id=236), fakedb.SourceStamp(id=236, sourcestampsetid=236, codebase='C'), fakedb.Buildset(id=32, sourcestampsetid=236, reason='foo', submitted_at=1300305712, results=-1), fakedb.BuildRequest(id=19, buildsetid=30, buildername='A', priority=13, submitted_at=1300305712, results=-1), fakedb.BuildRequest(id=20, buildsetid=31, buildername='A', priority=13, submitted_at=1300305712, results=-1), fakedb.BuildRequest(id=21, buildsetid=32, buildername='A', priority=13, submitted_at=1300305712, results=-1), ] self.addSlaves({'test-slave1':1, 'test-slave2': 1}) def mergeRequests_fn(builder, breq, other): # Fail all merge attempts return False self.bldr.getMergeRequestsFn = lambda : mergeRequests_fn # check if all are merged yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[19, 20], exp_builds=[ ('test-slave1', [19]), ('test-slave2', [20]), ]) @defer.inlineCallbacks def test_mergeRequests_fails(self): def mergeRequests_fn(*args): raise RuntimeError("xx") self.bldr.getMergeRequestsFn = lambda : mergeRequests_fn self.addSlaves({'test-slave1':1, 'test-slave2':1}) rows = self.base_rows + [ fakedb.BuildRequest(id=11, buildsetid=11, buildername="bldr"), ] yield self.do_test_maybeStartBuildsOnBuilder(rows=rows, exp_claims=[], exp_builds=[]) buildbot-0.8.8/buildbot/test/unit/test_process_buildstep.py000066400000000000000000000233541222546025000242440ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import re import mock from twisted.trial import unittest from twisted.internet import defer from twisted.python import log from buildbot.process import buildstep from buildbot.process.buildstep import regex_log_evaluator from buildbot.status.results import FAILURE, SUCCESS, WARNINGS, EXCEPTION from buildbot.test.fake import fakebuild, remotecommand from buildbot.test.util import config, steps, compat from buildbot.util.eventual import eventually class FakeLogFile: def __init__(self, text): self.text = text def getText(self): return self.text class FakeStepStatus: pass class TestRegexLogEvaluator(unittest.TestCase): def makeRemoteCommand(self, rc, stdout, stderr=''): cmd = remotecommand.FakeRemoteCommand('cmd', {}) cmd.fakeLogData(self, 'stdio', stdout=stdout, stderr=stderr) cmd.rc = rc return cmd def test_find_worse_status(self): cmd = self.makeRemoteCommand(0, 'This is a big step') step_status = FakeStepStatus() r = [(re.compile("This is"), WARNINGS)] new_status = regex_log_evaluator(cmd, step_status, r) self.assertEqual(new_status, WARNINGS, "regex_log_evaluator returned %d, expected %d" % (new_status, WARNINGS)) def test_multiple_regexes(self): cmd = self.makeRemoteCommand(0, "Normal stdout text\nan error") step_status = FakeStepStatus() r = [(re.compile("Normal stdout"), SUCCESS), (re.compile("error"), FAILURE)] new_status = regex_log_evaluator(cmd, step_status, r) self.assertEqual(new_status, FAILURE, "regex_log_evaluator returned %d, expected %d" % (new_status, FAILURE)) def test_exception_not_in_stdout(self): cmd = self.makeRemoteCommand(0, "Completely normal output", "exception output") step_status = FakeStepStatus() r = [(re.compile("exception"), EXCEPTION)] new_status = regex_log_evaluator(cmd, step_status, r) self.assertEqual(new_status, EXCEPTION, "regex_log_evaluator returned %d, expected %d" % (new_status, EXCEPTION)) def test_pass_a_string(self): cmd = self.makeRemoteCommand(0, "Output", "Some weird stuff on stderr") step_status = FakeStepStatus() r = [("weird stuff", WARNINGS)] new_status = regex_log_evaluator(cmd, step_status, r) self.assertEqual(new_status, WARNINGS, "regex_log_evaluator returned %d, expected %d" % (new_status, WARNINGS)) class TestBuildStep(steps.BuildStepMixin, config.ConfigErrorsMixin, unittest.TestCase): class FakeBuildStep(buildstep.BuildStep): def start(self): eventually(self.finished, 0) def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() # support def _setupWaterfallTest(self, hideStepIf, expect, expectedResult=SUCCESS): self.setupStep(TestBuildStep.FakeBuildStep(hideStepIf=hideStepIf)) self.expectOutcome(result=expectedResult, status_text=["generic"]) self.expectHidden(expect) # tests def test_nameIsntString(self): """ When BuildStep is passed a name that isn't a string, it reports a config error. """ self.assertRaisesConfigError("BuildStep name must be a string", lambda: buildstep.BuildStep(name=5)) def test_unexpectedKeywordArgument(self): """ When BuildStep is passed an unknown keyword argument, it reports a config error. """ self.assertRaisesConfigError("__init__ got unexpected keyword argument(s) ['oogaBooga']", lambda: buildstep.BuildStep(oogaBooga=5)) def test_getProperty(self): bs = buildstep.BuildStep() bs.build = fakebuild.FakeBuild() props = bs.build.build_status.properties = mock.Mock() bs.getProperty("xyz", 'b') props.getProperty.assert_called_with("xyz", 'b') bs.getProperty("xyz") props.getProperty.assert_called_with("xyz", None) def test_setProperty(self): bs = buildstep.BuildStep() bs.build = fakebuild.FakeBuild() props = bs.build.build_status.properties = mock.Mock() bs.setProperty("x", "y", "t") props.setProperty.assert_called_with("x", "y", "t", runtime=True) bs.setProperty("x", "abc", "test", runtime=True) props.setProperty.assert_called_with("x", "abc", "test", runtime=True) def test_hideStepIf_False(self): self._setupWaterfallTest(False, False) return self.runStep() def test_hideStepIf_True(self): self._setupWaterfallTest(True, True) return self.runStep() def test_hideStepIf_Callable_False(self): called = [False] def shouldHide(result, step): called[0] = True self.assertTrue(step is self.step) self.assertEquals(result, SUCCESS) return False self._setupWaterfallTest(shouldHide, False) d = self.runStep() d.addCallback(lambda _ : self.assertTrue(called[0])) return d def test_hideStepIf_Callable_True(self): called = [False] def shouldHide(result, step): called[0] = True self.assertTrue(step is self.step) self.assertEquals(result, SUCCESS) return True self._setupWaterfallTest(shouldHide, True) d = self.runStep() d.addCallback(lambda _ : self.assertTrue(called[0])) return d def test_hideStepIf_fails(self): # 0/0 causes DivideByZeroError, which should be flagged as an exception self._setupWaterfallTest(lambda : 0/0, False, expectedResult=EXCEPTION) return self.runStep() @compat.usesFlushLoggedErrors def test_hideStepIf_Callable_Exception(self): called = [False] def shouldHide(result, step): called[0] = True self.assertTrue(step is self.step) self.assertEquals(result, EXCEPTION) return True def createException(*args, **kwargs): raise RuntimeError() self.setupStep(self.FakeBuildStep(hideStepIf=shouldHide, doStepIf=createException)) self.expectOutcome(result=EXCEPTION, status_text=['generic', 'exception']) self.expectHidden(True) d = self.runStep() d.addErrback(log.err) d.addCallback(lambda _ : self.assertEqual(len(self.flushLoggedErrors(defer.FirstError)), 1)) d.addCallback(lambda _: self.assertEqual(len(self.flushLoggedErrors(RuntimeError)), 1)) d.addCallback(lambda _ : self.assertTrue(called[0])) return d class TestLoggingBuildStep(unittest.TestCase): def makeRemoteCommand(self, rc, stdout, stderr=''): cmd = remotecommand.FakeRemoteCommand('cmd', {}) cmd.fakeLogData(self, 'stdio', stdout=stdout, stderr=stderr) cmd.rc = rc return cmd def test_evaluateCommand_success(self): cmd = self.makeRemoteCommand(0, "Log text", "Log text") lbs = buildstep.LoggingBuildStep() status = lbs.evaluateCommand(cmd) self.assertEqual(status, SUCCESS, "evaluateCommand returned %d, should've returned %d" % (status, SUCCESS)) def test_evaluateCommand_failed(self): cmd = self.makeRemoteCommand(23, "Log text", "") lbs = buildstep.LoggingBuildStep() status = lbs.evaluateCommand(cmd) self.assertEqual(status, FAILURE, "evaluateCommand returned %d, should've returned %d" % (status, FAILURE)) def test_evaluateCommand_log_eval_func(self): cmd = self.makeRemoteCommand(0, "Log text") def eval(cmd, step_status): return WARNINGS lbs = buildstep.LoggingBuildStep(log_eval_func=eval) status = lbs.evaluateCommand(cmd) self.assertEqual(status, WARNINGS, "evaluateCommand didn't call log_eval_func or overrode its results") class FailingCustomStep(buildstep.LoggingBuildStep): def __init__(self, exception=buildstep.BuildStepFailed, *args, **kwargs): buildstep.LoggingBuildStep.__init__(self, *args, **kwargs) self.exception = exception @defer.inlineCallbacks def start(self): yield defer.succeed(None) raise self.exception() class TestCustomStepExecution(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_step_raining_buildstepfailed_in_start(self): self.setupStep(FailingCustomStep()) self.expectOutcome(result=FAILURE, status_text=["generic"]) return self.runStep() def test_step_raising_exception_in_start(self): self.setupStep(FailingCustomStep(exception=ValueError)) self.expectOutcome(result=EXCEPTION, status_text=["generic", "exception"]) d = self.runStep() @d.addCallback def cb(_): self.assertEqual(len(self.flushLoggedErrors(ValueError)), 1) return d buildbot-0.8.8/buildbot/test/unit/test_process_cache.py000066400000000000000000000040131222546025000233030ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from buildbot.process import cache class CacheManager(unittest.TestCase): def setUp(self): self.caches = cache.CacheManager() def make_config(self, **kwargs): cfg = mock.Mock() cfg.caches = kwargs return cfg def test_get_cache_idempotency(self): foo_cache = self.caches.get_cache("foo", None) bar_cache = self.caches.get_cache("bar", None) foo_cache2 = self.caches.get_cache("foo", None) self.assertIdentical(foo_cache, foo_cache2) self.assertNotIdentical(foo_cache, bar_cache) def test_reconfigService(self): # load config with one cache loaded and the other not foo_cache = self.caches.get_cache("foo", None) d = self.caches.reconfigService( self.make_config(foo=5, bar=6, bing=11)) @d.addCallback def check(_): bar_cache = self.caches.get_cache("bar", None) self.assertEqual((foo_cache.max_size, bar_cache.max_size), (5, 6)) def test_get_metrics(self): self.caches.get_cache("foo", None) self.assertIn('foo', self.caches.get_metrics()) metric = self.caches.get_metrics()['foo'] for k in 'hits', 'refhits', 'misses', 'max_size': self.assertIn(k, metric) buildbot-0.8.8/buildbot/test/unit/test_process_debug.py000066400000000000000000000117171222546025000233370ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from twisted.internet import defer from twisted.application import service from buildbot.process import debug from buildbot import config class FakeManhole(service.Service): pass class TestDebugServices(unittest.TestCase): def setUp(self): self.master = mock.Mock(name='master') self.config = config.MasterConfig() @defer.inlineCallbacks def test_reconfigService_debug(self): # mock out PBManager self.master.pbmanager = pbmanager = mock.Mock() registration = mock.Mock(name='registration') registration.unregister = mock.Mock(name='unregister', side_effect=lambda : defer.succeed(None)) pbmanager.register.return_value = registration ds = debug.DebugServices(self.master) ds.startService() # start off with no debug password self.config.slavePortnum = '9824' self.config.debugPassword = None yield ds.reconfigService(self.config) self.assertFalse(pbmanager.register.called) # set the password, and see it register self.config.debugPassword = 'seeeekrit' yield ds.reconfigService(self.config) self.assertTrue(pbmanager.register.called) self.assertEqual(pbmanager.register.call_args[0][:3], ('9824', 'debug', 'seeeekrit')) factory = pbmanager.register.call_args[0][3] self.assertIsInstance(factory(mock.Mock(), mock.Mock()), debug.DebugPerspective) # change the password, and see it re-register self.config.debugPassword = 'lies' pbmanager.register.reset_mock() yield ds.reconfigService(self.config) self.assertTrue(registration.unregister.called) self.assertTrue(pbmanager.register.called) self.assertEqual(pbmanager.register.call_args[0][:3], ('9824', 'debug', 'lies')) # remove the password, and see it unregister self.config.debugPassword = None pbmanager.register.reset_mock() registration.unregister.reset_mock() yield ds.reconfigService(self.config) self.assertTrue(registration.unregister.called) self.assertFalse(pbmanager.register.called) # re-register to test stopService self.config.debugPassword = 'confusion' pbmanager.register.reset_mock() yield ds.reconfigService(self.config) # stop the service, and see that it unregisters pbmanager.register.reset_mock() registration.unregister.reset_mock() yield ds.stopService() self.assertTrue(registration.unregister.called) @defer.inlineCallbacks def test_reconfigService_manhole(self): master = mock.Mock(name='master') ds = debug.DebugServices(master) ds.startService() # start off with no manhole yield ds.reconfigService(self.config) # set a manhole, fire it up self.config.manhole = manhole = FakeManhole() yield ds.reconfigService(self.config) self.assertTrue(manhole.running) self.assertIdentical(manhole.master, master) # unset it, see it stop self.config.manhole = None yield ds.reconfigService(self.config) self.assertFalse(manhole.running) self.assertIdentical(manhole.master, None) # re-start to test stopService self.config.manhole = manhole yield ds.reconfigService(self.config) # stop the service, and see that it unregisters yield ds.stopService() self.assertFalse(manhole.running) self.assertIdentical(manhole.master, None) class TestDebugPerspective(unittest.TestCase): def setUp(self): self.master = mock.Mock() self.persp = debug.DebugPerspective(self.master) def test_attached(self): self.assertIdentical(self.persp.attached(mock.Mock()), self.persp) def test_detached(self): self.persp.detached(mock.Mock()) # just shouldn't crash def test_perspective_reload(self): d = defer.maybeDeferred(lambda : self.persp.perspective_reload()) def check(_): self.master.reconfig.assert_called_with() d.addCallback(check) return d # remaining methods require IControl adapters or other weird stuff.. TODO buildbot-0.8.8/buildbot/test/unit/test_process_factory.py000066400000000000000000000065521222546025000237210ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.process.factory import BuildFactory, s from buildbot.process.buildstep import BuildStep, _BuildStepFactory class TestBuildFactory(unittest.TestCase): def test_init(self): step = BuildStep() factory = BuildFactory([step]) self.assertEqual(factory.steps, [_BuildStepFactory(BuildStep)]) def test_addStep(self): step = BuildStep() factory = BuildFactory() factory.addStep(step) self.assertEqual(factory.steps, [_BuildStepFactory(BuildStep)]) def test_addStep_deprecated_withArguments(self): """ Passing keyword arguments to L{BuildFactory.addStep} is deprecated, but pass the arguments to the first argument, to construct a step. """ factory = BuildFactory() factory.addStep(BuildStep, name='test') self.assertEqual(factory.steps, [_BuildStepFactory(BuildStep, name='test')]) warnings = self.flushWarnings([self.test_addStep_deprecated_withArguments]) self.assertEqual(len(warnings), 1) self.assertEqual(warnings[0]['category'], DeprecationWarning) def test_addStep_deprecated(self): """ Passing keyword arguments to L{BuildFactory.addStep} is deprecated, but pass the arguments to the first argument, to construct a step. """ factory = BuildFactory() factory.addStep(BuildStep) self.assertEqual(factory.steps, [_BuildStepFactory(BuildStep)]) warnings = self.flushWarnings([self.test_addStep_deprecated]) self.assertEqual(len(warnings), 1) self.assertEqual(warnings[0]['category'], DeprecationWarning) def test_s(self): """ L{s} is deprecated, but pass keyword arguments to the first argument, to construct a step. """ stepFactory = s(BuildStep, name='test') self.assertEqual(stepFactory, _BuildStepFactory(BuildStep, name='test')) warnings = self.flushWarnings([self.test_s]) self.assertEqual(len(warnings), 1) self.assertEqual(warnings[0]['category'], DeprecationWarning) def test_addStep_notAStep(self): factory = BuildFactory() # This fails because object isn't adaptable to IBuildStepFactory self.assertRaises(TypeError, factory.addStep, object()) def test_addStep_ArgumentsInTheWrongPlace(self): factory = BuildFactory() self.assertRaises(TypeError, factory.addStep, BuildStep(), name="name") def test_addSteps(self): factory = BuildFactory() factory.addSteps([BuildStep(), BuildStep()]) self.assertEqual(factory.steps, [_BuildStepFactory(BuildStep), _BuildStepFactory(BuildStep)]) buildbot-0.8.8/buildbot/test/unit/test_process_metrics.py000066400000000000000000000202261222546025000237120ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import gc, sys from twisted.trial import unittest from twisted.internet import task from buildbot.process import metrics from buildbot.test.fake import fakemaster class TestMetricBase(unittest.TestCase): def setUp(self): self.clock = task.Clock() self.observer = metrics.MetricLogObserver() self.observer.parent = self.master = fakemaster.make_master() self.master.config.db['db_poll_interval'] = 60 self.master.config.metrics = dict(log_interval=0, periodic_interval=0) self.observer._reactor = self.clock self.observer.startService() self.observer.reconfigService(self.master.config) def tearDown(self): if self.observer.running: self.observer.stopService() class TestMetricCountEvent(TestMetricBase): def testIncrement(self): metrics.MetricCountEvent.log('num_widgets', 1) report = self.observer.asDict() self.assertEquals(report['counters']['num_widgets'], 1) metrics.MetricCountEvent.log('num_widgets', 1) report = self.observer.asDict() self.assertEquals(report['counters']['num_widgets'], 2) def testDecrement(self): metrics.MetricCountEvent.log('num_widgets', 1) report = self.observer.asDict() self.assertEquals(report['counters']['num_widgets'], 1) metrics.MetricCountEvent.log('num_widgets', -1) report = self.observer.asDict() self.assertEquals(report['counters']['num_widgets'], 0) def testAbsolute(self): metrics.MetricCountEvent.log('num_widgets', 10, absolute=True) report = self.observer.asDict() self.assertEquals(report['counters']['num_widgets'], 10) def testCountMethod(self): @metrics.countMethod('foo_called') def foo(): return "foo!" for i in range(10): foo() report = self.observer.asDict() self.assertEquals(report['counters']['foo_called'], 10) class TestMetricTimeEvent(TestMetricBase): def testManualEvent(self): metrics.MetricTimeEvent.log('foo_time', 0.001) report = self.observer.asDict() self.assertEquals(report['timers']['foo_time'], 0.001) def testTimer(self): clock = task.Clock() t = metrics.Timer('foo_time') t._reactor = clock t.start() clock.advance(5) t.stop() report = self.observer.asDict() self.assertEquals(report['timers']['foo_time'], 5) def testStartStopDecorators(self): clock = task.Clock() t = metrics.Timer('foo_time') t._reactor = clock @t.startTimer def foo(): clock.advance(5) return "foo!" @t.stopTimer def bar(): clock.advance(5) return "bar!" foo() bar() report = self.observer.asDict() self.assertEquals(report['timers']['foo_time'], 10) def testTimeMethod(self): clock = task.Clock() @metrics.timeMethod('foo_time', _reactor=clock) def foo(): clock.advance(5) return "foo!" foo() report = self.observer.asDict() self.assertEquals(report['timers']['foo_time'], 5) def testAverages(self): data = range(10) for i in data: metrics.MetricTimeEvent.log('foo_time', i) report = self.observer.asDict() self.assertEquals(report['timers']['foo_time'], sum(data)/float(len(data))) class TestPeriodicChecks(TestMetricBase): def testPeriodicCheck(self): # fake out that there's no garbage (since we can't rely on Python # not having any garbage while running tests) self.patch(gc, 'garbage', []) clock = task.Clock() metrics.periodicCheck(_reactor=clock) clock.pump([0.1, 0.1, 0.1]) # We should have 0 reactor delay since we're using a fake clock report = self.observer.asDict() self.assertEquals(report['timers']['reactorDelay'], 0) self.assertEquals(report['counters']['gc.garbage'], 0) self.assertEquals(report['alarms']['gc.garbage'][0], 'OK') def testUncollectable(self): # make some fake garbage self.patch(gc, 'garbage', [ 1, 2 ]) clock = task.Clock() metrics.periodicCheck(_reactor=clock) clock.pump([0.1, 0.1, 0.1]) # We should have 0 reactor delay since we're using a fake clock report = self.observer.asDict() self.assertEquals(report['timers']['reactorDelay'], 0) self.assertEquals(report['counters']['gc.garbage'], 2) self.assertEquals(report['alarms']['gc.garbage'][0], 'WARN') def testGetRSS(self): self.assert_(metrics._get_rss() > 0) if sys.platform != 'linux2': testGetRSS.skip = "only available on linux2 platforms" class TestReconfig(TestMetricBase): def testReconfig(self): observer = self.observer new_config = self.master.config # starts up without running tasks self.assertEquals(observer.log_task, None) self.assertEquals(observer.periodic_task, None) # enable log_interval new_config.metrics = dict(log_interval=10, periodic_interval=0) observer.reconfigService(new_config) self.assert_(observer.log_task) self.assertEquals(observer.periodic_task, None) # disable that and enable periodic_interval new_config.metrics = dict(periodic_interval=10, log_interval=0) observer.reconfigService(new_config) self.assert_(observer.periodic_task) self.assertEquals(observer.log_task, None) # Make the periodic check run self.clock.pump([0.1]) # disable the whole listener new_config.metrics = None observer.reconfigService(new_config) self.assertFalse(observer.enabled) self.assertEquals(observer.log_task, None) self.assertEquals(observer.periodic_task, None) # disable both new_config.metrics = dict(periodic_interval=0, log_interval=0) observer.reconfigService(new_config) self.assertEquals(observer.log_task, None) self.assertEquals(observer.periodic_task, None) # enable both new_config.metrics = dict(periodic_interval=10, log_interval=10) observer.reconfigService(new_config) self.assert_(observer.log_task) self.assert_(observer.periodic_task) # (service will be stopped by tearDown) class _LogObserver: def __init__(self): self.events = [] def gotEvent(self, event): self.events.append(event) class TestReports(unittest.TestCase): def testMetricCountReport(self): handler = metrics.MetricCountHandler(None) handler.handle({}, metrics.MetricCountEvent('num_foo', 1)) self.assertEquals("Counter num_foo: 1", handler.report()) self.assertEquals({"counters": {"num_foo": 1}}, handler.asDict()) def testMetricTimeReport(self): handler = metrics.MetricTimeHandler(None) handler.handle({}, metrics.MetricTimeEvent('time_foo', 1)) self.assertEquals("Timer time_foo: 1", handler.report()) self.assertEquals({"timers": {"time_foo": 1}}, handler.asDict()) def testMetricAlarmReport(self): handler = metrics.MetricAlarmHandler(None) handler.handle({}, metrics.MetricAlarmEvent('alarm_foo', msg='Uh oh', level=metrics.ALARM_WARN)) self.assertEquals("WARN alarm_foo: Uh oh", handler.report()) self.assertEquals({"alarms": {"alarm_foo": ("WARN", "Uh oh")}}, handler.asDict()) buildbot-0.8.8/buildbot/test/unit/test_process_properties.py000066400000000000000000001510061222546025000244410ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from zope.interface import implements from twisted.internet import defer from twisted.trial import unittest from twisted.python import components from buildbot.process.properties import Properties, WithProperties from buildbot.process.properties import Interpolate from buildbot.process.properties import _Lazy, _SourceStampDict, _Lookup from buildbot.process.properties import Property, PropertiesMixin, renderer from buildbot.interfaces import IRenderable, IProperties from buildbot.test.fake.fakebuild import FakeBuild from buildbot.test.util.config import ConfigErrorsMixin from buildbot.test.util.properties import ConstantRenderable from buildbot.test.util import compat class FakeSource: def __init__(self): self.branch = None self.codebase = '' self.project = '' self.repository = '' self.revision = None def asDict(self): ds = {} ds['branch'] = self.branch ds['codebase'] = self.codebase ds['project'] = self.project ds['repository'] = self.repository ds['revision'] = self.revision return ds class DeferredRenderable: implements (IRenderable) def __init__(self): self.d = defer.Deferred() def getRenderingFor(self, build): return self.d def callback(self, value): self.d.callback(value) class TestPropertyMap(unittest.TestCase): """ Test the behavior of PropertyMap, using the external interace provided by WithProperties. """ def setUp(self): self.props = Properties( prop_str='a-string', prop_none=None, prop_list=['a', 'b'], prop_zero=0, prop_one=1, prop_false=False, prop_true=True, prop_empty='', ) self.build = FakeBuild(self.props) def doTestSimpleWithProperties(self, fmtstring, expect, **kwargs): d = self.build.render(WithProperties(fmtstring, **kwargs)) d.addCallback(self.failUnlessEqual, "%s" % expect) return d def testSimpleStr(self): return self.doTestSimpleWithProperties('%(prop_str)s', 'a-string') def testSimpleNone(self): # None is special-cased to become an empty string return self.doTestSimpleWithProperties('%(prop_none)s', '') def testSimpleList(self): return self.doTestSimpleWithProperties('%(prop_list)s', ['a', 'b']) def testSimpleZero(self): return self.doTestSimpleWithProperties('%(prop_zero)s', 0) def testSimpleOne(self): return self.doTestSimpleWithProperties('%(prop_one)s', 1) def testSimpleFalse(self): return self.doTestSimpleWithProperties('%(prop_false)s', False) def testSimpleTrue(self): return self.doTestSimpleWithProperties('%(prop_true)s', True) def testSimpleEmpty(self): return self.doTestSimpleWithProperties('%(prop_empty)s', '') def testSimpleUnset(self): d = self.build.render(WithProperties('%(prop_nosuch)s')) return self.assertFailure(d, KeyError) def testColonMinusSet(self): return self.doTestSimpleWithProperties('%(prop_str:-missing)s', 'a-string') def testColonMinusNone(self): # None is special-cased here, too return self.doTestSimpleWithProperties('%(prop_none:-missing)s', '') def testColonMinusZero(self): return self.doTestSimpleWithProperties('%(prop_zero:-missing)s', 0) def testColonMinusOne(self): return self.doTestSimpleWithProperties('%(prop_one:-missing)s', 1) def testColonMinusFalse(self): return self.doTestSimpleWithProperties('%(prop_false:-missing)s', False) def testColonMinusTrue(self): return self.doTestSimpleWithProperties('%(prop_true:-missing)s', True) def testColonMinusEmpty(self): return self.doTestSimpleWithProperties('%(prop_empty:-missing)s', '') def testColonMinusUnset(self): return self.doTestSimpleWithProperties('%(prop_nosuch:-missing)s', 'missing') def testColonTildeSet(self): return self.doTestSimpleWithProperties('%(prop_str:~missing)s', 'a-string') def testColonTildeNone(self): # None is special-cased *differently* for ~: return self.doTestSimpleWithProperties('%(prop_none:~missing)s', 'missing') def testColonTildeZero(self): return self.doTestSimpleWithProperties('%(prop_zero:~missing)s', 'missing') def testColonTildeOne(self): return self.doTestSimpleWithProperties('%(prop_one:~missing)s', 1) def testColonTildeFalse(self): return self.doTestSimpleWithProperties('%(prop_false:~missing)s', 'missing') def testColonTildeTrue(self): return self.doTestSimpleWithProperties('%(prop_true:~missing)s', True) def testColonTildeEmpty(self): return self.doTestSimpleWithProperties('%(prop_empty:~missing)s', 'missing') def testColonTildeUnset(self): return self.doTestSimpleWithProperties('%(prop_nosuch:~missing)s', 'missing') def testColonPlusSet(self): return self.doTestSimpleWithProperties('%(prop_str:+present)s', 'present') def testColonPlusNone(self): return self.doTestSimpleWithProperties('%(prop_none:+present)s', 'present') def testColonPlusZero(self): return self.doTestSimpleWithProperties('%(prop_zero:+present)s', 'present') def testColonPlusOne(self): return self.doTestSimpleWithProperties('%(prop_one:+present)s', 'present') def testColonPlusFalse(self): return self.doTestSimpleWithProperties('%(prop_false:+present)s', 'present') def testColonPlusTrue(self): return self.doTestSimpleWithProperties('%(prop_true:+present)s', 'present') def testColonPlusEmpty(self): return self.doTestSimpleWithProperties('%(prop_empty:+present)s', 'present') def testColonPlusUnset(self): return self.doTestSimpleWithProperties('%(prop_nosuch:+present)s', '') def testClearTempValues(self): d = self.doTestSimpleWithProperties('', '', prop_temp=lambda b: 'present') d.addCallback(lambda _: self.doTestSimpleWithProperties('%(prop_temp:+present)s', '')) return d def testTempValue(self): self.doTestSimpleWithProperties('%(prop_temp)s', 'present', prop_temp=lambda b: 'present') def testTempValueOverrides(self): return self.doTestSimpleWithProperties('%(prop_one)s', 2, prop_one=lambda b: 2) def testTempValueColonMinusSet(self): return self.doTestSimpleWithProperties('%(prop_one:-missing)s', 2, prop_one=lambda b: 2) def testTempValueColonMinusUnset(self): return self.doTestSimpleWithProperties('%(prop_nosuch:-missing)s', 'temp', prop_nosuch=lambda b: 'temp') def testTempValueColonTildeTrueSet(self): return self.doTestSimpleWithProperties('%(prop_false:~nontrue)s', 'temp', prop_false=lambda b: 'temp') def testTempValueColonTildeTrueUnset(self): return self.doTestSimpleWithProperties('%(prop_nosuch:~nontrue)s', 'temp', prop_nosuch=lambda b: 'temp') def testTempValueColonTildeFalseFalse(self): return self.doTestSimpleWithProperties('%(prop_false:~nontrue)s', 'nontrue', prop_false=lambda b: False) def testTempValueColonTildeTrueFalse(self): return self.doTestSimpleWithProperties('%(prop_true:~nontrue)s', True, prop_true=lambda b: False) def testTempValueColonTildeNoneFalse(self): return self.doTestSimpleWithProperties('%(prop_nosuch:~nontrue)s', 'nontrue', prop_nosuch=lambda b: False) def testTempValueColonTildeFalseZero(self): return self.doTestSimpleWithProperties('%(prop_false:~nontrue)s', 'nontrue', prop_false=lambda b: 0) def testTempValueColonTildeTrueZero(self): return self.doTestSimpleWithProperties('%(prop_true:~nontrue)s', True, prop_true=lambda b: 0) def testTempValueColonTildeNoneZero(self): return self.doTestSimpleWithProperties('%(prop_nosuch:~nontrue)s', 'nontrue', prop_nosuch=lambda b: 0) def testTempValueColonTildeFalseBlank(self): return self.doTestSimpleWithProperties('%(prop_false:~nontrue)s', 'nontrue', prop_false=lambda b: '') def testTempValueColonTildeTrueBlank(self): return self.doTestSimpleWithProperties('%(prop_true:~nontrue)s', True, prop_true=lambda b: '') def testTempValueColonTildeNoneBlank(self): return self.doTestSimpleWithProperties('%(prop_nosuch:~nontrue)s', 'nontrue', prop_nosuch=lambda b: '') def testTempValuePlusSetSet(self): return self.doTestSimpleWithProperties('%(prop_one:+set)s', 'set', prop_one=lambda b: 2) def testTempValuePlusUnsetSet(self): return self.doTestSimpleWithProperties('%(prop_nosuch:+set)s', 'set', prop_nosuch=lambda b: 1) class TestInterpolateConfigure(unittest.TestCase, ConfigErrorsMixin): """ Test that Interpolate reports erros in the interpolation string at configure time. """ def test_invalid_args_and_kwargs(self): self.assertRaisesConfigError("Interpolate takes either positional", lambda : Interpolate("%s %(foo)s", 1, foo=2)) def test_invalid_selector(self): self.assertRaisesConfigError("invalid Interpolate selector 'garbage'", lambda: Interpolate("%(garbage:test)s")) def test_no_selector(self): self.assertRaisesConfigError("invalid Interpolate substitution without selector 'garbage'", lambda: Interpolate("%(garbage)s")) def test_invalid_default_type(self): self.assertRaisesConfigError("invalid Interpolate default type '@'", lambda: Interpolate("%(prop:some_prop:@wacky)s")) def test_nested_invalid_selector(self): self.assertRaisesConfigError("invalid Interpolate selector 'garbage'", lambda: Interpolate("%(prop:some_prop:~%(garbage:test)s)s")) def test_colon_ternary_missing_delimeter(self): self.assertRaisesConfigError("invalid Interpolate ternary expression 'one' with delimiter ':'", lambda: Interpolate("echo '%(prop:P:?:one)s'")) def test_colon_ternary_paren_delimiter(self): self.assertRaisesConfigError("invalid Interpolate ternary expression 'one(:)' with delimiter ':'", lambda: Interpolate("echo '%(prop:P:?:one(:))s'")) def test_colon_ternary_hash_bad_delimeter(self): self.assertRaisesConfigError("invalid Interpolate ternary expression 'one' with delimiter '|'", lambda: Interpolate("echo '%(prop:P:#?|one)s'")) def test_prop_invalid_character(self): self.assertRaisesConfigError("Property name must be alphanumeric for prop Interpolation 'a+a'", lambda: Interpolate("echo '%(prop:a+a)s'")) def test_kw_invalid_character(self): self.assertRaisesConfigError("Keyword must be alphanumeric for kw Interpolation 'a+a'", lambda: Interpolate("echo '%(kw:a+a)s'")) def test_src_codebase_invalid_character(self): self.assertRaisesConfigError("Codebase must be alphanumeric for src Interpolation 'a+a:a'", lambda: Interpolate("echo '%(src:a+a:a)s'")) def test_src_attr_invalid_character(self): self.assertRaisesConfigError("Attribute must be alphanumeric for src Interpolation 'a:a+a'", lambda: Interpolate("echo '%(src:a:a+a)s'")) def test_src_missing_attr(self): self.assertRaisesConfigError("Must specify both codebase and attr", lambda: Interpolate("echo '%(src:a)s'")) class TestInterpolatePositional(unittest.TestCase): def setUp(self): self.props = Properties() self.build = FakeBuild(self.props) def test_string(self): command = Interpolate("test %s", "one fish") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "test one fish") def test_twoString(self): command = Interpolate("test %s, %s", "one fish", "two fish") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "test one fish, two fish") def test_deferred(self): renderable = DeferredRenderable() command = Interpolate("echo '%s'", renderable) d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'red fish'") renderable.callback("red fish") return d def test_renderable(self): self.props.setProperty("buildername", "blue fish", "test") command = Interpolate("echo '%s'", Property("buildername")) d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'blue fish'") return d class TestInterpolateProperties(unittest.TestCase): def setUp(self): self.props = Properties() self.build = FakeBuild(self.props) def test_properties(self): self.props.setProperty("buildername", "winbld", "test") command = Interpolate("echo buildby-%(prop:buildername)s") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo buildby-winbld") return d def test_properties_newline(self): self.props.setProperty("buildername", "winbld", "test") command = Interpolate("aa\n%(prop:buildername)s\nbb") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "aa\nwinbld\nbb") return d def test_property_not_set(self): command = Interpolate("echo buildby-%(prop:buildername)s") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo buildby-") return d def test_property_colon_minus(self): command = Interpolate("echo buildby-%(prop:buildername:-blddef)s") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo buildby-blddef") return d def test_property_colon_tilde_true(self): self.props.setProperty("buildername", "winbld", "test") command = Interpolate("echo buildby-%(prop:buildername:~blddef)s") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo buildby-winbld") return d def test_property_colon_tilde_false(self): self.props.setProperty("buildername", "", "test") command = Interpolate("echo buildby-%(prop:buildername:~blddef)s") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo buildby-blddef") return d def test_property_colon_plus(self): self.props.setProperty("project", "proj1", "test") command = Interpolate("echo %(prop:project:+projectdefined)s") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo projectdefined") return d def test_nested_property(self): self.props.setProperty("project", "so long!", "test") command = Interpolate("echo '%(prop:missing:~%(prop:project)s)s'") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'so long!'") return d def test_property_substitute_recursively(self): self.props.setProperty("project", "proj1", "test") command = Interpolate("echo '%(prop:no_such:-%(prop:project)s)s'") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'proj1'") return d def test_property_colon_ternary_present(self): self.props.setProperty("project", "proj1", "test") command = Interpolate("echo %(prop:project:?:defined:missing)s") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo defined") return d def test_property_colon_ternary_missing(self): command = Interpolate("echo %(prop:project:?|defined|missing)s") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo missing") return d def test_property_colon_ternary_hash_true(self): self.props.setProperty("project", "winbld", "test") command = Interpolate("echo buildby-%(prop:project:#?:T:F)s") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo buildby-T") return d def test_property_colon_ternary_hash_false(self): self.props.setProperty("project", "", "test") command = Interpolate("echo buildby-%(prop:project:#?|T|F)s") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo buildby-F") return d def test_property_colon_ternary_substitute_recursively_true(self): self.props.setProperty("P", "present", "test") self.props.setProperty("one", "proj1", "test") self.props.setProperty("two", "proj2", "test") command = Interpolate("echo '%(prop:P:?|%(prop:one)s|%(prop:two)s)s'") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'proj1'") return d def test_property_colon_ternary_substitute_recursively_false(self): self.props.setProperty("one", "proj1", "test") self.props.setProperty("two", "proj2", "test") command = Interpolate("echo '%(prop:P:?|%(prop:one)s|%(prop:two)s)s'") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'proj2'") return d def test_property_colon_ternary_substitute_recursively_delimited_true(self): self.props.setProperty("P", "present", "test") self.props.setProperty("one", "proj1", "test") self.props.setProperty("two", "proj2", "test") command = Interpolate("echo '%(prop:P:?|%(prop:one:?|true|false)s|%(prop:two:?|false|true)s)s'") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'true'") return d def test_property_colon_ternary_substitute_recursively_delimited_false(self): self.props.setProperty("one", "proj1", "test") self.props.setProperty("two", "proj2", "test") command = Interpolate("echo '%(prop:P:?|%(prop:one:?|true|false)s|%(prop:two:?|false|true)s)s'") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'false'") return d class TestInterpolateSrc(unittest.TestCase): def setUp(self): self.props = Properties() self.build = FakeBuild(self.props) sa = FakeSource() sb = FakeSource() sc = FakeSource() sa.repository = 'cvs://A..' sa.codebase = 'cbA' sa.project = "Project" self.build.sources['cbA'] = sa sb.repository = 'cvs://B..' sb.codebase = 'cbB' sb.project = "Project" self.build.sources['cbB'] = sb sc.repository = 'cvs://C..' sc.codebase = 'cbC' sc.project = None self.build.sources['cbC'] = sc def test_src(self): command = Interpolate("echo %(src:cbB:repository)s") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo cvs://B..") return d def test_src_src(self): command = Interpolate("echo %(src:cbB:repository)s %(src:cbB:project)s") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo cvs://B.. Project") return d def test_src_attr_empty(self): command = Interpolate("echo %(src:cbC:project)s") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo ") return d def test_src_attr_codebase_notfound(self): command = Interpolate("echo %(src:unknown_codebase:project)s") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo ") return d def test_src_colon_plus_false(self): command = Interpolate("echo '%(src:cbD:project:+defaultrepo)s'") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo ''") return d def test_src_colon_plus_true(self): command = Interpolate("echo '%(src:cbB:project:+defaultrepo)s'") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'defaultrepo'") return d def test_src_colon_minus(self): command = Interpolate("echo %(src:cbB:nonattr:-defaultrepo)s") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo defaultrepo") return d def test_src_colon_minus_false(self): command = Interpolate("echo '%(src:cbC:project:-noproject)s'") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo ''") return d def test_src_colon_minus_true(self): command = Interpolate("echo '%(src:cbB:project:-noproject)s'") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'Project'") return d def test_src_colon_minus_codebase_notfound(self): command = Interpolate("echo '%(src:unknown_codebase:project:-noproject)s'") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'noproject'") return d def test_src_colon_tilde_true(self): command = Interpolate("echo '%(src:cbB:project:~noproject)s'") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'Project'") return d def test_src_colon_tilde_false(self): command = Interpolate("echo '%(src:cbC:project:~noproject)s'") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'noproject'") return d def test_src_colon_tilde_false_src_as_replacement(self): command = Interpolate("echo '%(src:cbC:project:~%(src:cbA:project)s)s'") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'Project'") return d def test_src_colon_tilde_codebase_notfound(self): command = Interpolate("echo '%(src:unknown_codebase:project:~noproject)s'") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'noproject'") return d class TestInterpolateKwargs(unittest.TestCase): def setUp(self): self.props = Properties() self.build = FakeBuild(self.props) sa = FakeSource() sa.repository = 'cvs://A..' sa.codebase = 'cbA' sa.project = None sa.branch = "default" self.build.sources['cbA'] = sa def test_kwarg(self): command = Interpolate("echo %(kw:repository)s", repository = "cvs://A..") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo cvs://A..") return d def test_kwarg_kwarg(self): command = Interpolate("echo %(kw:repository)s %(kw:branch)s", repository = "cvs://A..", branch = "default") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo cvs://A.. default") return d def test_kwarg_not_mapped(self): command = Interpolate("echo %(kw:repository)s", project = "projectA") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo ") return d def test_kwarg_colon_minus_not_available(self): command = Interpolate("echo %(kw:repository)s", project = "projectA") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo ") return d def test_kwarg_colon_minus_not_available_default(self): command = Interpolate("echo %(kw:repository:-cvs://A..)s", project = "projectA") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo cvs://A..") return d def test_kwarg_colon_minus_available(self): command = Interpolate("echo %(kw:repository:-cvs://A..)s", repository = "cvs://B..") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo cvs://B..") return d def test_kwarg_colon_tilde_true(self): command = Interpolate("echo %(kw:repository:~cvs://B..)s", repository = "cvs://A..") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo cvs://A..") return d def test_kwarg_colon_tilde_false(self): command = Interpolate("echo %(kw:repository:~cvs://B..)s", repository = "") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo cvs://B..") return d def test_kwarg_colon_tilde_none(self): command = Interpolate("echo %(kw:repository:~cvs://B..)s", repository = None) d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo cvs://B..") return d def test_kwarg_colon_plus_false(self): command = Interpolate("echo %(kw:repository:+cvs://B..)s", project = "project") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo ") return d def test_kwarg_colon_plus_true(self): command = Interpolate("echo %(kw:repository:+cvs://B..)s", repository = None) d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo cvs://B..") return d def test_kwargs_colon_minus_false_src_as_replacement(self): command = Interpolate("echo '%(kw:text:-%(src:cbA:branch)s)s'", notext='ddd') d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'default'") return d def test_kwargs_renderable(self): command = Interpolate("echo '%(kw:test)s'", test = ConstantRenderable('testing')) d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'testing'") return d def test_kwargs_deferred(self): renderable = DeferredRenderable() command = Interpolate("echo '%(kw:test)s'", test = renderable) d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'testing'") renderable.callback('testing') return d def test_kwarg_deferred(self): renderable = DeferredRenderable() command = Interpolate("echo '%(kw:project)s'", project=renderable) d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'testing'") renderable.callback('testing') return d def test_nested_kwarg_deferred(self): renderable = DeferredRenderable() command = Interpolate("echo '%(kw:missing:~%(kw:fishy)s)s'", missing=renderable, fishy="so long!") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "echo 'so long!'") renderable.callback(False) return d class TestWithProperties(unittest.TestCase): def setUp(self): self.props = Properties() self.build = FakeBuild(self.props) def testInvalidParams(self): self.assertRaises(ValueError, lambda : WithProperties("%s %(foo)s", 1, foo=2)) def testBasic(self): # test basic substitution with WithProperties self.props.setProperty("revision", "47", "test") command = WithProperties("build-%s.tar.gz", "revision") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "build-47.tar.gz") return d def testDict(self): # test dict-style substitution with WithProperties self.props.setProperty("other", "foo", "test") command = WithProperties("build-%(other)s.tar.gz") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "build-foo.tar.gz") return d def testDictColonMinus(self): # test dict-style substitution with WithProperties self.props.setProperty("prop1", "foo", "test") command = WithProperties("build-%(prop1:-empty)s-%(prop2:-empty)s.tar.gz") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "build-foo-empty.tar.gz") return d def testDictColonPlus(self): # test dict-style substitution with WithProperties self.props.setProperty("prop1", "foo", "test") command = WithProperties("build-%(prop1:+exists)s-%(prop2:+exists)s.tar.gz") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "build-exists-.tar.gz") return d def testEmpty(self): # None should render as '' self.props.setProperty("empty", None, "test") command = WithProperties("build-%(empty)s.tar.gz") d = self.build.render(command) d.addCallback(self.failUnlessEqual, "build-.tar.gz") return d def testRecursiveList(self): self.props.setProperty("x", 10, "test") self.props.setProperty("y", 20, "test") command = [ WithProperties("%(x)s %(y)s"), "and", WithProperties("%(y)s %(x)s") ] d = self.build.render(command) d.addCallback(self.failUnlessEqual, ["10 20", "and", "20 10"]) return d def testRecursiveTuple(self): self.props.setProperty("x", 10, "test") self.props.setProperty("y", 20, "test") command = ( WithProperties("%(x)s %(y)s"), "and", WithProperties("%(y)s %(x)s") ) d = self.build.render(command) d.addCallback(self.failUnlessEqual, ("10 20", "and", "20 10")) return d def testRecursiveDict(self): self.props.setProperty("x", 10, "test") self.props.setProperty("y", 20, "test") command = { WithProperties("%(x)s %(y)s") : WithProperties("%(y)s %(x)s") } d = self.build.render(command) d.addCallback(self.failUnlessEqual, {"10 20" : "20 10"}) return d def testLambdaSubst(self): command = WithProperties('%(foo)s', foo=lambda _: 'bar') d = self.build.render(command) d.addCallback(self.failUnlessEqual, 'bar') return d def testLambdaHasattr(self): command = WithProperties('%(foo)s', foo=lambda b : b.hasProperty('x') and 'x' or 'y') d = self.build.render(command) d.addCallback(self.failUnlessEqual, 'y') return d def testLambdaOverride(self): self.props.setProperty('x', 10, 'test') command = WithProperties('%(x)s', x=lambda _: 20) d = self.build.render(command) d.addCallback(self.failUnlessEqual, '20') return d def testLambdaCallable(self): self.assertRaises(ValueError, lambda: WithProperties('%(foo)s', foo='bar')) def testLambdaUseExisting(self): self.props.setProperty('x', 10, 'test') self.props.setProperty('y', 20, 'test') command = WithProperties('%(z)s', z=lambda props: props.getProperty('x') + props.getProperty('y')) d = self.build.render(command) d.addCallback(self.failUnlessEqual, '30') return d def testColon(self): self.props.setProperty('some:property', 10, 'test') command = WithProperties('%(some:property:-with-default)s') d = self.build.render(command) d.addCallback(self.failUnlessEqual, '10') return d def testColon_default(self): command = WithProperties('%(some:property:-with-default)s') d = self.build.render(command) d.addCallback(self.failUnlessEqual, 'with-default') return d def testColon_colon(self): command = WithProperties('%(some:property:-with:default)s') d = self.build.render(command) d.addCallback(self.failUnlessEqual, 'with:default') return d class TestProperties(unittest.TestCase): def setUp(self): self.props = Properties() def testDictBehavior(self): # note that dictionary-like behavior is deprecated and not exposed to # users! self.props.setProperty("do-tests", 1, "scheduler") self.props.setProperty("do-install", 2, "scheduler") self.assert_(self.props.has_key('do-tests')) self.failUnlessEqual(self.props['do-tests'], 1) self.failUnlessEqual(self.props['do-install'], 2) self.assertRaises(KeyError, lambda : self.props['do-nothing']) self.failUnlessEqual(self.props.getProperty('do-install'), 2) self.assertIn('do-tests', self.props) self.assertNotIn('missing-do-tests', self.props) def testAsList(self): self.props.setProperty("happiness", 7, "builder") self.props.setProperty("flames", True, "tester") self.assertEqual(sorted(self.props.asList()), [ ('flames', True, 'tester'), ('happiness', 7, 'builder') ]) def testAsDict(self): self.props.setProperty("msi_filename", "product.msi", 'packager') self.props.setProperty("dmg_filename", "product.dmg", 'packager') self.assertEqual(self.props.asDict(), dict(msi_filename=('product.msi', 'packager'), dmg_filename=('product.dmg', 'packager'))) def testUpdate(self): self.props.setProperty("x", 24, "old") newprops = { 'a' : 1, 'b' : 2 } self.props.update(newprops, "new") self.failUnlessEqual(self.props.getProperty('x'), 24) self.failUnlessEqual(self.props.getPropertySource('x'), 'old') self.failUnlessEqual(self.props.getProperty('a'), 1) self.failUnlessEqual(self.props.getPropertySource('a'), 'new') def testUpdateRuntime(self): self.props.setProperty("x", 24, "old") newprops = { 'a' : 1, 'b' : 2 } self.props.update(newprops, "new", runtime=True) self.failUnlessEqual(self.props.getProperty('x'), 24) self.failUnlessEqual(self.props.getPropertySource('x'), 'old') self.failUnlessEqual(self.props.getProperty('a'), 1) self.failUnlessEqual(self.props.getPropertySource('a'), 'new') self.assertEqual(self.props.runtime, set(['a', 'b'])) def testUpdateFromProperties(self): self.props.setProperty("a", 94, "old") self.props.setProperty("x", 24, "old") newprops = Properties() newprops.setProperty('a', 1, "new") newprops.setProperty('b', 2, "new") self.props.updateFromProperties(newprops) self.failUnlessEqual(self.props.getProperty('x'), 24) self.failUnlessEqual(self.props.getPropertySource('x'), 'old') self.failUnlessEqual(self.props.getProperty('a'), 1) self.failUnlessEqual(self.props.getPropertySource('a'), 'new') def testUpdateFromPropertiesNoRuntime(self): self.props.setProperty("a", 94, "old") self.props.setProperty("b", 84, "old") self.props.setProperty("x", 24, "old") newprops = Properties() newprops.setProperty('a', 1, "new", runtime=True) newprops.setProperty('b', 2, "new", runtime=False) newprops.setProperty('c', 3, "new", runtime=True) newprops.setProperty('d', 3, "new", runtime=False) self.props.updateFromPropertiesNoRuntime(newprops) self.failUnlessEqual(self.props.getProperty('a'), 94) self.failUnlessEqual(self.props.getPropertySource('a'), 'old') self.failUnlessEqual(self.props.getProperty('b'), 2) self.failUnlessEqual(self.props.getPropertySource('b'), 'new') self.failUnlessEqual(self.props.getProperty('c'), None) # not updated self.failUnlessEqual(self.props.getProperty('d'), 3) self.failUnlessEqual(self.props.getPropertySource('d'), 'new') self.failUnlessEqual(self.props.getProperty('x'), 24) self.failUnlessEqual(self.props.getPropertySource('x'), 'old') @compat.usesFlushWarnings def test_setProperty_notJsonable(self): self.props.setProperty("project", ConstantRenderable('testing'), "test") self.props.setProperty("project", object, "test") self.assertEqual(len(self.flushWarnings([self.test_setProperty_notJsonable])), 2) # IProperties methods def test_getProperty(self): self.props.properties['p1'] = (['p', 1], 'test') self.assertEqual(self.props.getProperty('p1'), ['p', 1]) def test_getProperty_default_None(self): self.assertEqual(self.props.getProperty('p1'), None) def test_getProperty_default(self): self.assertEqual(self.props.getProperty('p1', 2), 2) def test_hasProperty_false(self): self.assertFalse(self.props.hasProperty('x')) def test_hasProperty_true(self): self.props.properties['x'] = (False, 'test') self.assertTrue(self.props.hasProperty('x')) def test_has_key_false(self): self.assertFalse(self.props.has_key('x')) def test_setProperty(self): self.props.setProperty('x', 'y', 'test') self.assertEqual(self.props.properties['x'], ('y', 'test')) self.assertNotIn('x', self.props.runtime) def test_setProperty_runtime(self): self.props.setProperty('x', 'y', 'test', runtime=True) self.assertEqual(self.props.properties['x'], ('y', 'test')) self.assertIn('x', self.props.runtime) def test_setProperty_no_source(self): self.assertRaises(TypeError, lambda : self.props.setProperty('x', 'y')) def test_getProperties(self): self.assertIdentical(self.props.getProperties(), self.props) def test_getBuild(self): self.assertIdentical(self.props.getBuild(), self.props.build) def test_render(self): class Renderable(object): implements(IRenderable) def getRenderingFor(self, props): return props.getProperty('x') + 'z' self.props.setProperty('x', 'y', 'test') d = self.props.render(Renderable()) d.addCallback(self.assertEqual, 'yz') return d class MyPropertiesThing(PropertiesMixin): set_runtime_properties = True def adaptMyProperties(mp): return mp.properties components.registerAdapter(adaptMyProperties, MyPropertiesThing, IProperties) class TestPropertiesMixin(unittest.TestCase): def setUp(self): self.mp = MyPropertiesThing() self.mp.properties = mock.Mock() def test_getProperty(self): self.mp.getProperty('abc') self.mp.properties.getProperty.assert_called_with('abc', None) def xtest_getProperty_default(self): self.mp.getProperty('abc', 'def') self.mp.properties.getProperty.assert_called_with('abc', 'def') def test_hasProperty(self): self.mp.properties.hasProperty.return_value = True self.assertTrue(self.mp.hasProperty('abc')) self.mp.properties.hasProperty.assert_called_with('abc') def test_has_key(self): self.mp.properties.hasProperty.return_value = True self.assertTrue(self.mp.has_key('abc')) self.mp.properties.hasProperty.assert_called_with('abc') def test_setProperty(self): self.mp.setProperty('abc', 'def', 'src') self.mp.properties.setProperty.assert_called_with('abc', 'def', 'src', runtime=True) def test_setProperty_no_source(self): # this compatibility is maintained for old code self.mp.setProperty('abc', 'def') self.mp.properties.setProperty.assert_called_with('abc', 'def', 'Unknown', runtime=True) def test_render(self): self.mp.render([1,2]) self.mp.properties.render.assert_called_with([1,2]) class TestProperty(unittest.TestCase): def setUp(self): self.props = Properties() self.build = FakeBuild(self.props) def testIntProperty(self): self.props.setProperty("do-tests", 1, "scheduler") value = Property("do-tests") d = self.build.render(value) d.addCallback(self.failUnlessEqual, 1) return d def testStringProperty(self): self.props.setProperty("do-tests", "string", "scheduler") value = Property("do-tests") d = self.build.render(value) d.addCallback(self.failUnlessEqual, "string") return d def testMissingProperty(self): value = Property("do-tests") d = self.build.render(value) d.addCallback(self.failUnlessEqual, None) return d def testDefaultValue(self): value = Property("do-tests", default="Hello!") d = self.build.render(value) d.addCallback(self.failUnlessEqual, "Hello!") return d def testDefaultValueNested(self): self.props.setProperty("xxx", 'yyy', "scheduler") value = Property("do-tests", default=WithProperties("a-%(xxx)s-b")) d = self.build.render(value) d.addCallback(self.failUnlessEqual, "a-yyy-b") return d def testIgnoreDefaultValue(self): self.props.setProperty("do-tests", "string", "scheduler") value = Property("do-tests", default="Hello!") d = self.build.render(value) d.addCallback(self.failUnlessEqual, "string") return d def testIgnoreFalseValue(self): self.props.setProperty("do-tests-string", "", "scheduler") self.props.setProperty("do-tests-int", 0, "scheduler") self.props.setProperty("do-tests-list", [], "scheduler") self.props.setProperty("do-tests-None", None, "scheduler") value = [ Property("do-tests-string", default="Hello!"), Property("do-tests-int", default="Hello!"), Property("do-tests-list", default="Hello!"), Property("do-tests-None", default="Hello!") ] d = self.build.render(value) d.addCallback(self.failUnlessEqual, ["Hello!"] * 4) return d def testDefaultWhenFalse(self): self.props.setProperty("do-tests-string", "", "scheduler") self.props.setProperty("do-tests-int", 0, "scheduler") self.props.setProperty("do-tests-list", [], "scheduler") self.props.setProperty("do-tests-None", None, "scheduler") value = [ Property("do-tests-string", default="Hello!", defaultWhenFalse=False), Property("do-tests-int", default="Hello!", defaultWhenFalse=False), Property("do-tests-list", default="Hello!", defaultWhenFalse=False), Property("do-tests-None", default="Hello!", defaultWhenFalse=False) ] d = self.build.render(value) d.addCallback(self.failUnlessEqual, ["", 0, [], None]) return d def testDeferredDefault(self): default = DeferredRenderable() value = Property("no-such-property", default) d = self.build.render(value) d.addCallback(self.failUnlessEqual, "default-value") default.callback("default-value") return d class TestRenderalbeAdapters(unittest.TestCase): """ Tests for list, tuple and dict renderers. """ def setUp(self): self.props = Properties() self.build = FakeBuild(self.props) def test_list_deferred(self): r1 = DeferredRenderable() r2 = DeferredRenderable() d = self.build.render([r1, r2]) d.addCallback(self.failUnlessEqual, ["lispy", "lists"]) r2.callback("lists") r1.callback("lispy") return d def test_tuple_deferred(self): r1 = DeferredRenderable() r2 = DeferredRenderable() d = self.build.render((r1, r2)) d.addCallback(self.failUnlessEqual, ("totally", "tupled")) r2.callback("tupled") r1.callback("totally") return d def test_dict(self): r1 = DeferredRenderable() r2 = DeferredRenderable() k1 = DeferredRenderable() k2 = DeferredRenderable() d = self.build.render({k1: r1, k2: r2}) d.addCallback(self.failUnlessEqual, {"lock": "load", "dict": "lookup"}) k1.callback("lock") r1.callback("load") k2.callback("dict") r2.callback("lookup") return d class Renderer(unittest.TestCase): def setUp(self): self.props = Properties() self.build = FakeBuild(self.props) def test_renderer(self): self.props.setProperty("x", "X", "test") d = self.build.render( renderer(lambda p : 'x%sx' % p.getProperty('x'))) d.addCallback(self.failUnlessEqual, 'xXx') return d def test_renderer_called(self): # it's tempting to try to call the decorated function. Don't do that. # It's not a function anymore. d = defer.maybeDeferred(lambda : self.build.render(renderer(lambda p : 'x')('y'))) self.failUnlessFailure(d, TypeError) return d def test_renderer_decorator(self): self.props.setProperty("x", "X", "test") @renderer def rend(p): return 'x%sx' % p.getProperty('x') d = self.build.render(rend) d.addCallback(self.failUnlessEqual, 'xXx') return d def test_renderer_deferred(self): self.props.setProperty("x", "X", "test") d = self.build.render( renderer(lambda p : defer.succeed('y%sy' % p.getProperty('x')))) d.addCallback(self.failUnlessEqual, 'yXy') return d def test_renderer_fails(self): d = self.build.render( renderer(lambda p : defer.fail(RuntimeError("oops")))) self.failUnlessFailure(d, RuntimeError) return d class Compare(unittest.TestCase): def test_WithProperties_lambda(self): self.failIfEqual(WithProperties("%(key)s", key=lambda p:'val'), WithProperties("%(key)s", key=lambda p:'val')) def rend(p): return "val" self.failUnlessEqual( WithProperties("%(key)s", key=rend), WithProperties("%(key)s", key=rend)) self.failIfEqual( WithProperties("%(key)s", key=rend), WithProperties("%(key)s", otherkey=rend)) def test_WithProperties_positional(self): self.failIfEqual( WithProperties("%s", 'key'), WithProperties("%s", 'otherkey')) self.failUnlessEqual( WithProperties("%s", 'key'), WithProperties("%s", 'key')) self.failIfEqual( WithProperties("%s", 'key'), WithProperties("k%s", 'key')) def test_Interpolate_constant(self): self.failIfEqual( Interpolate('some text here'), Interpolate('and other text there')) self.failUnlessEqual( Interpolate('some text here'), Interpolate('some text here')) def test_Interpolate_positional(self): self.failIfEqual( Interpolate('%s %s', "test", "text"), Interpolate('%s %s', "other", "text")) self.failUnlessEqual( Interpolate('%s %s', "test", "text"), Interpolate('%s %s', "test", "text")) def test_Interpolate_kwarg(self): self.failIfEqual( Interpolate("%(kw:test)s", test=object(), other=2), Interpolate("%(kw:test)s", test=object(), other=2)) self.failUnlessEqual( Interpolate('testing: %(kw:test)s', test="test", other=3), Interpolate('testing: %(kw:test)s', test="test", other=3)) def test_renderer(self): self.failIfEqual( renderer(lambda p:'val'), renderer(lambda p:'val')) def rend(p): return "val" self.failUnlessEqual( renderer(rend), renderer(rend)) def test_Lookup_simple(self): self.failIfEqual( _Lookup({'test': 5, 'other': 6}, 'other'), _Lookup({'test': 5, 'other': 6}, 'test')) self.failUnlessEqual( _Lookup({'test': 5, 'other': 6}, 'test'), _Lookup({'test': 5, 'other': 6}, 'test')) def test_Lookup_default(self): self.failIfEqual( _Lookup({'test': 5, 'other': 6}, 'test', default='default'), _Lookup({'test': 5, 'other': 6}, 'test')) self.failUnlessEqual( _Lookup({'test': 5, 'other': 6}, 'test', default='default'), _Lookup({'test': 5, 'other': 6}, 'test', default='default')) def test_Lookup_defaultWhenFalse(self): self.failIfEqual( _Lookup({'test': 5, 'other': 6}, 'test', defaultWhenFalse=False), _Lookup({'test': 5, 'other': 6}, 'test')) self.failIfEqual( _Lookup({'test': 5, 'other': 6}, 'test', defaultWhenFalse=False), _Lookup({'test': 5, 'other': 6}, 'test', defaultWhenFalse=True)) self.failUnlessEqual( _Lookup({'test': 5, 'other': 6}, 'test', defaultWhenFalse=True), _Lookup({'test': 5, 'other': 6}, 'test', defaultWhenFalse=True)) self.failUnlessEqual( _Lookup({'test': 5, 'other': 6}, 'test'), _Lookup({'test': 5, 'other': 6}, 'test', defaultWhenFalse=True)) def test_Lookup_hasKey(self): self.failIfEqual( _Lookup({'test': 5, 'other': 6}, 'test', hasKey=None), _Lookup({'test': 5, 'other': 6}, 'test')) self.failIfEqual( _Lookup({'test': 5, 'other': 6}, 'test', hasKey='has-key'), _Lookup({'test': 5, 'other': 6}, 'test')) self.failIfEqual( _Lookup({'test': 5, 'other': 6}, 'test', hasKey='has-key'), _Lookup({'test': 5, 'other': 6}, 'test', hasKey='other-key')) self.failUnlessEqual( _Lookup({'test': 5, 'other': 6}, 'test', hasKey='has-key'), _Lookup({'test': 5, 'other': 6}, 'test', hasKey='has-key')) def test_Lookup_elideNoneAs(self): self.failUnlessEqual( _Lookup({'test': 5, 'other': 6}, 'test', elideNoneAs=None), _Lookup({'test': 5, 'other': 6}, 'test')) self.failIfEqual( _Lookup({'test': 5, 'other': 6}, 'test', elideNoneAs=''), _Lookup({'test': 5, 'other': 6}, 'test')) self.failIfEqual( _Lookup({'test': 5, 'other': 6}, 'test', elideNoneAs='got None'), _Lookup({'test': 5, 'other': 6}, 'test', elideNoneAs='')) self.failUnlessEqual( _Lookup({'test': 5, 'other': 6}, 'test', elideNoneAs='got None'), _Lookup({'test': 5, 'other': 6}, 'test', elideNoneAs='got None')) def test_Lazy(self): self.failIfEqual( _Lazy(5), _Lazy(6)) self.failUnlessEqual( _Lazy(5), _Lazy(5)) def test_SourceStampDict(self): self.failIfEqual( _SourceStampDict('binary'), _SourceStampDict('library')) self.failUnlessEqual( _SourceStampDict('binary'), _SourceStampDict('binary')) buildbot-0.8.8/buildbot/test/unit/test_process_users_manager.py000066400000000000000000000032351222546025000251000ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from twisted.internet import defer from buildbot.process.users import manager, manual from buildbot import config class FakeUserManager(manual.UsersBase): pass class TestUserManager(unittest.TestCase): def setUp(self): self.master = mock.Mock() self.umm = manager.UserManagerManager(self.master) self.umm.startService() self.config = config.MasterConfig() def tearDown(self): self.umm.stopService() @defer.inlineCallbacks def test_reconfigService(self): # add a user manager um1 = FakeUserManager() self.config.user_managers = [ um1 ] yield self.umm.reconfigService(self.config) self.assertTrue(um1.running) self.assertIdentical(um1.master, self.master) # and back to nothing self.config.user_managers = [ ] yield self.umm.reconfigService(self.config) self.assertIdentical(um1.master, None) buildbot-0.8.8/buildbot/test/unit/test_process_users_manual.py000066400000000000000000000322101222546025000247360ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # this class is known to contain cruft and will be looked at later, so # no current implementation utilizes it aside from scripts.runner. import mock from twisted.trial import unittest from twisted.internet import defer from buildbot.test.fake import fakedb from buildbot.process.users import manual class ManualUsersMixin(object): """ This class fakes out the master/db components to test the manual user managers located in process.users.manual. """ class FakeMaster(object): def __init__(self): self.db = fakedb.FakeDBConnector(self) self.slavePortnum = "tcp:9989" self.caches = mock.Mock(name="caches") self.caches.get_cache = self.get_cache def get_cache(self, cache_name, miss_fn): c = mock.Mock(name=cache_name) c.get = miss_fn return c def setUpManualUsers(self): self.master = self.FakeMaster() class TestUsersBase(unittest.TestCase): """ Not really sure what there is to test, aside from _setUpManualUsers getting self.master set. """ pass class TestCommandlineUserManagerPerspective(unittest.TestCase, ManualUsersMixin): def setUp(self): self.setUpManualUsers() def call_perspective_commandline(self, *args): persp = manual.CommandlineUserManagerPerspective(self.master) return persp.perspective_commandline(*args) def test_perspective_commandline_add(self): d = self.call_perspective_commandline('add', None, None, None, [{'identifier':'x', 'git': 'x'}]) def check_get(_): d = self.master.db.users.getUser(1) def real_check(usdict): self.assertEqual(usdict, dict(uid=1, identifier='x', bb_username=None, bb_password=None, git='x')) d.addCallback(real_check) return d d.addCallback(check_get) return d def test_perspective_commandline_update(self): d = self.call_perspective_commandline('add', None, None, None, [{'identifier':'x', 'svn':'x'}]) d.addCallback(lambda _ : self.call_perspective_commandline( 'update', None, None, None, [{'identifier':'x', 'svn':'y'}])) def check(_): d = self.master.db.users.getUser(1) def real_check(usdict): self.assertEqual(usdict, dict(uid=1, identifier='x', bb_username=None, bb_password=None, svn='y')) d.addCallback(real_check) return d d.addCallback(check) return d def test_perspective_commandline_update_bb(self): d = self.call_perspective_commandline('add', None, None, None, [{'identifier':'x', 'svn':'x'}]) d.addCallback(lambda _ : self.call_perspective_commandline( 'update', 'bb_user', 'hashed_bb_pass', None, [{'identifier':'x'}])) def check(_): d = self.master.db.users.getUser(1) def real_check(usdict): self.assertEqual(usdict, dict(uid=1, identifier='x', bb_username='bb_user', bb_password='hashed_bb_pass', svn='x')) d.addCallback(real_check) return d d.addCallback(check) return d def test_perspective_commandline_update_both(self): d = self.call_perspective_commandline('add', None, None, None, [{'identifier':'x', 'svn':'x'}]) d.addCallback(lambda _ : self.call_perspective_commandline( 'update', 'bb_user', 'hashed_bb_pass', None, [{'identifier':'x', 'svn':'y'}])) def check(_): d = self.master.db.users.getUser(1) def real_check(usdict): self.assertEqual(usdict, dict(uid=1, identifier='x', bb_username='bb_user', bb_password='hashed_bb_pass', svn='y')) d.addCallback(real_check) return d d.addCallback(check) return d def test_perspective_commandline_remove(self): d = self.call_perspective_commandline('add', None, None, None, [{'identifier':'h@c', 'git': 'hi '}]) d.addCallback(lambda _ : self.call_perspective_commandline('remove', None, None, ['x'], None)) def check(_): d = self.master.db.users.getUser('x') def real_check(res): self.assertEqual(res, None) d.addCallback(real_check) return d d.addCallback(check) return d def test_perspective_commandline_get(self): d = self.call_perspective_commandline('add', None, None, None, [{'identifier':'x', 'svn':'x'}]) d.addCallback(lambda _ : self.call_perspective_commandline('get', None, None, ['x'], None)) def check(_): d = self.master.db.users.getUser(1) def real_check(res): self.assertEqual(res, dict(uid=1, identifier='x', bb_username=None, bb_password=None, svn='x')) d.addCallback(real_check) return d d.addCallback(check) return d def test_perspective_commandline_get_multiple_attrs(self): d = self.call_perspective_commandline('add', None, None, None, [{'identifier': 'x', 'svn': 'x', 'git': 'x@c'}]) d.addCallback(lambda _ : self.call_perspective_commandline('get', None, None, ['x'], None)) def check(_): d = self.master.db.users.getUser(1) def real_check(res): self.assertEqual(res, dict(uid=1, identifier='x', bb_username=None, bb_password=None, svn='x', git='x@c')) d.addCallback(real_check) return d d.addCallback(check) return d def test_perspective_commandline_add_format(self): d = self.call_perspective_commandline('add', None, None, None, [{'identifier':'x', 'svn':'x'}]) def check(result): exp_format = "user(s) added:\nidentifier: x\nuid: 1\n\n" self.assertEqual(result, exp_format) d.addCallback(check) return d def test_perspective_commandline_update_format(self): d = self.call_perspective_commandline('add', None, None, None, [{'identifier':'x', 'svn':'x'}]) d.addCallback(lambda _ : self.call_perspective_commandline('update', None, None, None, [{'identifier':'x', 'svn':'y'}])) def check(result): exp_format = 'user(s) updated:\nidentifier: x\n' self.assertEqual(result, exp_format) d.addCallback(check) return d def test_perspective_commandline_remove_format(self): d = self.call_perspective_commandline('add', None, None, None, [{'identifier':'h@c', 'git': 'hi '}]) d.addCallback(lambda _ : self.call_perspective_commandline('remove', None, None, ['h@c'], None)) def check(result): exp_format = "user(s) removed:\nidentifier: h@c\n" self.assertEqual(result, exp_format) d.addCallback(check) return d def test_perspective_commandline_get_format(self): d = self.call_perspective_commandline('add', None, None, None, [{'identifier':'x@y', 'git': 'x '}]) d.addCallback(lambda _ : self.call_perspective_commandline('get', None, None, ['x@y'], None)) def check(result): exp_format = 'user(s) found:\ngit: x \nidentifier: x@y\n' \ 'bb_username: None\nuid: 1\n\n' self.assertEqual(result, exp_format) d.addCallback(check) return d def test_perspective_commandline_remove_no_match_format(self): d = self.call_perspective_commandline('remove', None, None, ['x'], None) def check(result): exp_format = "user(s) removed:\n" self.assertEqual(result, exp_format) d.addCallback(check) return d def test_perspective_commandline_get_no_match_format(self): d = self.call_perspective_commandline('get', None, None, ['x'], None) def check(result): exp_format = "user(s) found:\nno match found\n" self.assertEqual(result, exp_format) d.addCallback(check) return d class TestCommandlineUserManager(unittest.TestCase, ManualUsersMixin): def setUp(self): self.setUpManualUsers() self.manual_component = manual.CommandlineUserManager(username="user", passwd="userpw", port="9990") self.manual_component.master = self.master def test_no_userpass(self): d = defer.maybeDeferred(lambda : manual.CommandlineUserManager()) return self.assertFailure(d, AssertionError) def test_no_port(self): d = defer.maybeDeferred(lambda : manual.CommandlineUserManager(username="x", passwd="y")) return self.assertFailure(d, AssertionError) def test_service(self): # patch out the pbmanager's 'register' command both to be sure # the registration is correct and to get a copy of the factory registration = mock.Mock() registration.unregister = lambda : defer.succeed(None) self.master.pbmanager = mock.Mock() def register(portstr, user, passwd, factory): self.assertEqual([portstr, user, passwd], ['9990', 'user', 'userpw']) self.got_factory = factory return registration self.master.pbmanager.register = register self.manual_component.startService() persp = self.got_factory(mock.Mock(), 'user') self.failUnless(isinstance(persp, manual.CommandlineUserManagerPerspective)) return self.manual_component.stopService() buildbot-0.8.8/buildbot/test/unit/test_process_users_users.py000066400000000000000000000147511222546025000246340ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from buildbot.process.users import users from buildbot.test.fake import fakedb class UsersTests(unittest.TestCase): def setUp(self): self.master = mock.Mock() self.master.db = self.db = fakedb.FakeDBConnector(self) self.test_sha = users.encrypt("cancer") def test_createUserObject_no_src(self): d = users.createUserObject(self.master, "Tyler Durden", None) def check(_): self.assertEqual(self.db.users.users, {}) self.assertEqual(self.db.users.users_info, {}) d.addCallback(check) return d def test_createUserObject_unrecognized_src(self): d = users.createUserObject(self.master, "Tyler Durden", 'blah') def check(_): self.assertEqual(self.db.users.users, {}) self.assertEqual(self.db.users.users_info, {}) d.addCallback(check) return d def test_createUserObject_git(self): d = users.createUserObject(self.master, "Tyler Durden ", 'git') def check(_): self.assertEqual(self.db.users.users, { 1: dict(identifier='Tyler Durden ', bb_username=None, bb_password=None) }) self.assertEqual(self.db.users.users_info, { 1: [dict(attr_type="git", attr_data="Tyler Durden ")]}) d.addCallback(check) return d def test_createUserObject_svn(self): d = users.createUserObject(self.master, "tdurden", 'svn') def check(_): self.assertEqual(self.db.users.users, { 1: dict(identifier='tdurden', bb_username=None, bb_password=None) }) self.assertEqual(self.db.users.users_info, { 1: [dict(attr_type="svn", attr_data="tdurden")]}) d.addCallback(check) return d def test_createUserObject_hg(self): d = users.createUserObject(self.master, "Tyler Durden ", 'hg') def check(_): self.assertEqual(self.db.users.users, { 1: dict(identifier='Tyler Durden ', bb_username=None, bb_password=None) }) self.assertEqual(self.db.users.users_info, { 1: [dict(attr_type="hg", attr_data="Tyler Durden ")]}) d.addCallback(check) return d def test_createUserObject_cvs(self): d = users.createUserObject(self.master, "tdurden", 'cvs') def check(_): self.assertEqual(self.db.users.users, { 1: dict(identifier='tdurden', bb_username=None, bb_password=None) }) self.assertEqual(self.db.users.users_info, { 1: [dict(attr_type="cvs", attr_data="tdurden")]}) d.addCallback(check) return d def test_createUserObject_darcs(self): d = users.createUserObject(self.master, "tyler@mayhem.net", 'darcs') def check(_): self.assertEqual(self.db.users.users, { 1: dict(identifier='tyler@mayhem.net', bb_username=None, bb_password=None) }) self.assertEqual(self.db.users.users_info, { 1: [dict(attr_type="darcs", attr_data="tyler@mayhem.net")]}) d.addCallback(check) return d def test_createUserObject_bzr(self): d = users.createUserObject(self.master, "Tyler Durden", 'bzr') def check(_): self.assertEqual(self.db.users.users, { 1: dict(identifier='Tyler Durden', bb_username=None, bb_password=None) }) self.assertEqual(self.db.users.users_info, { 1: [dict(attr_type="bzr", attr_data="Tyler Durden")]}) d.addCallback(check) return d def test_getUserContact_found(self): self.db.insertTestData([fakedb.User(uid=1, identifier='tdurden'), fakedb.UserInfo(uid=1, attr_type='svn', attr_data='tdurden'), fakedb.UserInfo(uid=1, attr_type='email', attr_data='tyler@mayhem.net')]) d = users.getUserContact(self.master, contact_types=['email'], uid=1) def check(contact): self.assertEqual(contact, 'tyler@mayhem.net') d.addCallback(check) return d def test_getUserContact_key_not_found(self): self.db.insertTestData([fakedb.User(uid=1, identifier='tdurden'), fakedb.UserInfo(uid=1, attr_type='svn', attr_data='tdurden'), fakedb.UserInfo(uid=1, attr_type='email', attr_data='tyler@mayhem.net')]) d = users.getUserContact(self.master, contact_types=['blargh'], uid=1) def check(contact): self.assertEqual(contact, None) d.addCallback(check) return d def test_getUserContact_uid_not_found(self): d = users.getUserContact(self.master, contact_types=['email'], uid=1) def check(contact): self.assertEqual(contact, None) d.addCallback(check) return d def test_check_passwd(self): res = users.check_passwd("cancer", self.test_sha) self.assertEqual(res, True) buildbot-0.8.8/buildbot/test/unit/test_revlinks.py000066400000000000000000000073071222546025000223500ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.revlinks import RevlinkMatch, GithubRevlink, SourceforgeGitRevlink, GitwebMatch class TestGithubRevlink(unittest.TestCase): revision = 'b6874701b54e0043a78882b020afc86033133f91' url = 'https://github.com/buildbot/buildbot/commit/b6874701b54e0043a78882b020afc86033133f91' def testHTTPS(self): self.assertEqual(GithubRevlink(self.revision, 'https://github.com/buildbot/buildbot.git'), self.url) def testGIT(self): self.assertEqual(GithubRevlink(self.revision, 'git://github.com/buildbot/buildbot.git'), self.url) def testSSH(self): self.assertEqual(GithubRevlink(self.revision, 'git@github.com:buildbot/buildbot.git'), self.url) def testSSHuri(self): self.assertEqual(GithubRevlink(self.revision, 'ssh://git@github.com/buildbot/buildbot.git'), self.url) class TestSourceforgeGitRevlink(unittest.TestCase): revision = 'b99c89a2842d386accea8072ae5bb6e24aa7cf29' url = 'http://gemrb.git.sourceforge.net/git/gitweb.cgi?p=gemrb/gemrb;a=commit;h=b99c89a2842d386accea8072ae5bb6e24aa7cf29' def testGIT(self): self.assertEqual(SourceforgeGitRevlink(self.revision, 'git://gemrb.git.sourceforge.net/gitroot/gemrb/gemrb'), self.url) def testSSH(self): self.assertEqual(SourceforgeGitRevlink(self.revision, 'somebody@gemrb.git.sourceforge.net:gitroot/gemrb/gemrb'), self.url) def testSSHuri(self): self.assertEqual(SourceforgeGitRevlink(self.revision, 'ssh://somebody@gemrb.git.sourceforge.net/gitroot/gemrb/gemrb'), self.url) class TestRevlinkMatch(unittest.TestCase): def testNotmuch(self): revision = 'f717d2ece1836c863f9cc02abd1ff2539307cd1d' matcher = RevlinkMatch(['git://notmuchmail.org/git/(.*)'], r'http://git.notmuchmail.org/git/\1/commit/%s') self.assertEquals(matcher(revision, 'git://notmuchmail.org/git/notmuch'), 'http://git.notmuchmail.org/git/notmuch/commit/f717d2ece1836c863f9cc02abd1ff2539307cd1d') def testSingleString(self): revision = 'rev' matcher = RevlinkMatch('test', 'out%s') self.assertEquals(matcher(revision, 'test'), 'outrev') def testSingleUnicode(self): revision = 'rev' matcher = RevlinkMatch(u'test', 'out%s') self.assertEquals(matcher(revision, 'test'), 'outrev') def testTwoCaptureGroups(self): revision = 'rev' matcher = RevlinkMatch('([A-Z]*)Z([0-9]*)', r'\2-\1-%s') self.assertEquals(matcher(revision, 'ABCZ43'), '43-ABC-rev') class TestGitwebMatch(unittest.TestCase): def testOrgmode(self): revision = '490d6ace10e0cfe74bab21c59e4b7bd6aa3c59b8' matcher = GitwebMatch('git://orgmode.org/(?P.*)', 'http://orgmode.org/w/') self.assertEquals(matcher(revision, 'git://orgmode.org/org-mode.git'), 'http://orgmode.org/w/?p=org-mode.git;a=commit;h=490d6ace10e0cfe74bab21c59e4b7bd6aa3c59b8') buildbot-0.8.8/buildbot/test/unit/test_schedulers_base.py000066400000000000000000000566251222546025000236550ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sys import mock import twisted from twisted.trial import unittest from twisted.internet import defer from buildbot import config from buildbot.schedulers import base from buildbot.process import properties from buildbot.test.util import scheduler from buildbot.test.fake import fakedb class BaseScheduler(scheduler.SchedulerMixin, unittest.TestCase): OBJECTID = 19 def setUp(self): self.setUpScheduler() def tearDown(self): self.tearDownScheduler() def makeScheduler(self, name='testsched', builderNames=['a', 'b'], properties={}, codebases = {'':{}}): sched = self.attachScheduler( base.BaseScheduler(name=name, builderNames=builderNames, properties=properties, codebases=codebases), self.OBJECTID) return sched # tests def test_constructor_builderNames(self): self.assertRaises(config.ConfigErrors, lambda : self.makeScheduler(builderNames='xxx')) def test_constructor_builderNames_unicode(self): self.makeScheduler(builderNames=[u'a']) def test_constructor_codebases_valid(self): codebases = {"codebase1": {"repository":"", "branch":"", "revision":""}} self.makeScheduler(codebases = codebases) def test_constructor_codebases_invalid(self): # scheduler only accepts codebases with at least repository set codebases = {"codebase1": {"dictionary":"", "that":"", "fails":""}} self.assertRaises(config.ConfigErrors, lambda : self.makeScheduler(codebases = codebases)) def test_listBuilderNames(self): sched = self.makeScheduler(builderNames=['x', 'y']) self.assertEqual(sched.listBuilderNames(), ['x', 'y']) def test_getPendingBuildTimes(self): sched = self.makeScheduler() self.assertEqual(sched.getPendingBuildTimes(), []) def test_addBuildsetForLatest_defaults(self): sched = self.makeScheduler(name='testy', builderNames=['x'], properties=dict(a='b')) d = sched.addBuildsetForLatest(reason='because') def check((bsid,brids)): self.db.buildsets.assertBuildset(bsid, dict(reason='because', brids=brids, external_idstring=None, properties=[ ('a', ('b', 'Scheduler')), ('scheduler', ('testy', 'Scheduler')), ], sourcestampsetid=100), {'': dict(branch=None, revision=None, repository='', codebase='', project='', sourcestampsetid=100) }) d.addCallback(check) return d def test_startConsumingChanges_fileIsImportant_check(self): sched = self.makeScheduler() self.assertRaises(AssertionError, lambda : sched.startConsumingChanges(fileIsImportant="maybe")) def do_test_change_consumption(self, kwargs, change, expected_result): # (expected_result should be True (important), False (unimportant), or # None (ignore the change)) sched = self.makeScheduler() sched.startService() change_received = [ None ] def gotChange(got_change, got_important): self.assertEqual(got_change, change) change_received[0] = got_important return defer.succeed(None) sched.gotChange = gotChange d = sched.startConsumingChanges(**kwargs) def test(_): # check that it registered a callback callbacks = self.master.getSubscriptionCallbacks() self.assertNotEqual(callbacks['changes'], None) # invoke the callback with the change, and check the result callbacks['changes'](change) self.assertEqual(change_received[0], expected_result) d.addCallback(test) d.addCallback(lambda _ : sched.stopService()) return d def test_change_consumption_defaults(self): # all changes are important by default return self.do_test_change_consumption( dict(), self.makeFakeChange(), True) def test_change_consumption_fileIsImportant_True(self): return self.do_test_change_consumption( dict(fileIsImportant=lambda c : True), self.makeFakeChange(), True) def test_change_consumption_fileIsImportant_False(self): return self.do_test_change_consumption( dict(fileIsImportant=lambda c : False), self.makeFakeChange(), False) def test_change_consumption_fileIsImportant_exception(self): d = self.do_test_change_consumption( dict(fileIsImportant=lambda c : 1/0), self.makeFakeChange(), None) def check_err(_): self.assertEqual(1, len(self.flushLoggedErrors(ZeroDivisionError))) d.addCallback(check_err) return d if twisted.version.major <= 9 and sys.version_info[:2] >= (2,7): test_change_consumption_fileIsImportant_exception.skip = \ "flushLoggedErrors does not work correctly on 9.0.0 and earlier with Python-2.7" def test_change_consumption_change_filter_True(self): cf = mock.Mock() cf.filter_change = lambda c : True return self.do_test_change_consumption( dict(change_filter=cf), self.makeFakeChange(), True) def test_change_consumption_change_filter_False(self): cf = mock.Mock() cf.filter_change = lambda c : False return self.do_test_change_consumption( dict(change_filter=cf), self.makeFakeChange(), None) def test_change_consumption_fileIsImportant_False_onlyImportant(self): return self.do_test_change_consumption( dict(fileIsImportant=lambda c : False, onlyImportant=True), self.makeFakeChange(), None) def test_change_consumption_fileIsImportant_True_onlyImportant(self): return self.do_test_change_consumption( dict(fileIsImportant=lambda c : True, onlyImportant=True), self.makeFakeChange(), True) def test_addBuilsetForLatest_args(self): sched = self.makeScheduler(name='xyz', builderNames=['y', 'z']) d = sched.addBuildsetForLatest(reason='cuz', branch='default', project='myp', repository='hgmo', external_idstring='try_1234') def check((bsid,brids)): self.db.buildsets.assertBuildset(bsid, dict(reason='cuz', brids=brids, external_idstring='try_1234', properties=[('scheduler', ('xyz', 'Scheduler'))], sourcestampsetid=100), {'': dict(branch='default', revision=None, repository='hgmo', codebase='', project='myp', sourcestampsetid=100) }) d.addCallback(check) return d def test_addBuildsetForLatest_properties(self): props = properties.Properties(xxx="yyy") sched = self.makeScheduler(name='xyz', builderNames=['y', 'z']) d = sched.addBuildsetForLatest(reason='cuz', branch='default', project='myp', repository='hgmo', external_idstring='try_1234', properties=props) def check((bsid,brids)): self.db.buildsets.assertBuildset(bsid, dict(reason='cuz', brids=brids, external_idstring='try_1234', properties=[ ('scheduler', ('xyz', 'Scheduler')), ('xxx', ('yyy', 'TEST')), ], sourcestampsetid=100), {'': dict(branch='default', revision=None, repository='hgmo', codebase='', project='myp', sourcestampsetid=100) }) d.addCallback(check) return d def test_addBuildsetForLatest_builderNames(self): sched = self.makeScheduler(name='xyz', builderNames=['y', 'z']) d = sched.addBuildsetForLatest(reason='cuz', branch='default', builderNames=['a', 'b']) def check((bsid,brids)): self.db.buildsets.assertBuildset(bsid, dict(reason='cuz', brids=brids, external_idstring=None, properties=[('scheduler', ('xyz', 'Scheduler'))], sourcestampsetid=100), {'': dict(branch='default', revision=None, repository='', codebase='', project='', sourcestampsetid=100) }) d.addCallback(check) return d def test_addBuildsetForChanges_one_change(self): sched = self.makeScheduler(name='n', builderNames=['b']) self.db.insertTestData([ fakedb.Change(changeid=13, branch='trunk', revision='9283', repository='svn://...', codebase='', project='world-domination'), ]) d = sched.addBuildsetForChanges(reason='power', changeids=[13]) def check((bsid,brids)): self.db.buildsets.assertBuildset(bsid, dict(reason='power', brids=brids, external_idstring=None, properties=[('scheduler', ('n', 'Scheduler'))], sourcestampsetid=100), {'': dict(branch='trunk', repository='svn://...', codebase='', changeids=set([13]), project='world-domination', revision='9283', sourcestampsetid=100) }) d.addCallback(check) return d def test_addBuildsetForChanges_properties(self): props = properties.Properties(xxx="yyy") sched = self.makeScheduler(name='n', builderNames=['c']) self.db.insertTestData([ fakedb.Change(changeid=14, branch='default', revision='123:abc', repository='', project='', codebase=''), ]) d = sched.addBuildsetForChanges(reason='downstream', changeids=[14], properties=props) def check((bsid,brids)): self.db.buildsets.assertBuildset(bsid, dict(reason='downstream', brids=brids, external_idstring=None, properties=[ ('scheduler', ('n', 'Scheduler')), ('xxx', ('yyy', 'TEST')), ], sourcestampsetid=100), {'': dict(branch='default', revision='123:abc', repository='', project='', changeids=set([14]), sourcestampsetid=100, codebase='') }) d.addCallback(check) return d def test_addBuildsetForChanges_one_change_builderNames(self): sched = self.makeScheduler(name='n', builderNames=['b']) self.db.insertTestData([ fakedb.Change(changeid=13, branch='trunk', revision='9283', codebase='', repository='svn://...', project='world-domination'), ]) d = sched.addBuildsetForChanges(reason='power', changeids=[13], builderNames=['p']) def check((bsid,brids)): self.db.buildsets.assertBuildset(bsid, dict(reason='power', brids=brids, external_idstring=None, properties=[('scheduler', ('n', 'Scheduler'))], sourcestampsetid=100), {'': dict(branch='trunk', repository='svn://...', codebase='', changeids=set([13]), project='world-domination', revision='9283', sourcestampsetid=100) }) d.addCallback(check) return d def test_addBuildsetForChanges_multiple_changes_no_codebaseGenerator(self): # This is a test for backwards compatibility # Changes from different repositories come together in one build sched = self.makeScheduler(name='n', builderNames=['b', 'c']) # No codebaseGenerator means all changes have codebase == '' self.db.insertTestData([ fakedb.Change(changeid=13, branch='trunk', revision='9283', repository='svn://A..', project='knitting', codebase=''), fakedb.Change(changeid=14, branch='devel', revision='9284', repository='svn://B..', project='making-tea', codebase=''), fakedb.Change(changeid=15, branch='trunk', revision='9285', repository='svn://C..', project='world-domination', codebase=''), ]) # note that the changeids are given out of order here; it should still # use the most recent d = sched.addBuildsetForChanges(reason='power', changeids=[14, 15, 13]) def check((bsid,brids)): self.db.buildsets.assertBuildset(bsid, dict(reason='power', brids=brids, external_idstring=None, properties=[('scheduler', ('n', 'Scheduler'))], sourcestampsetid=100), {'': dict(branch='trunk', repository='svn://C..', codebase='', changeids=set([13,14,15]), project='world-domination', revision='9285', sourcestampsetid=100) }) d.addCallback(check) return d def test_addBuildsetForChanges_multiple_changes_single_codebase(self): sched = self.makeScheduler(name='n', builderNames=['b', 'c']) self.db.insertTestData([ fakedb.Change(changeid=13, branch='trunk', revision='9283', repository='svn://...', project='knitting', codebase=''), fakedb.Change(changeid=14, branch='devel', revision='9284', repository='svn://...', project='making-tea', codebase=''), fakedb.Change(changeid=15, branch='trunk', revision='9285', repository='svn://...', project='world-domination', codebase=''), ]) # note that the changeids are given out of order here; it should still # use the most recent d = sched.addBuildsetForChanges(reason='power', changeids=[14, 15, 13]) def check((bsid,brids)): self.db.buildsets.assertBuildset(bsid, dict(reason='power', brids=brids, external_idstring=None, properties=[('scheduler', ('n', 'Scheduler'))], sourcestampsetid=100), {'': dict(branch='trunk', repository='svn://...', codebase='', changeids=set([13,14,15]), project='world-domination', revision='9285', sourcestampsetid=100) }) d.addCallback(check) return d def test_addBuildsetForChanges_codebases_set_multiple_changed_codebases(self): codebases = { 'cbA':dict( repository='svn://A..', branch='stable', revision='13579'), 'cbB':dict( repository='svn://B..', branch='stable', revision='24680'), 'cbC':dict( repository='svn://C..', branch='stable', revision='12345'), 'cbD':dict( repository='svn://D..')} # Scheduler gets codebases that can be used to create extra sourcestamps # for repositories that have no changes sched = self.makeScheduler(name='n', builderNames=['b', 'c'], codebases=codebases) self.db.insertTestData([ fakedb.Change(changeid=12, branch='trunk', revision='9282', repository='svn://A..', project='playing', codebase='cbA'), fakedb.Change(changeid=13, branch='trunk', revision='9283', repository='svn://A..', project='knitting', codebase='cbA'), fakedb.Change(changeid=14, branch='develop', revision='9284', repository='svn://A..', project='making-tea', codebase='cbA'), fakedb.Change(changeid=15, branch='trunk', revision='8085', repository='svn://B..', project='boxing', codebase='cbB'), fakedb.Change(changeid=16, branch='develop', revision='8086', repository='svn://B..', project='playing soccer', codebase='cbB'), fakedb.Change(changeid=17, branch='develop', revision='8087', repository='svn://B..', project='swimming', codebase='cbB'), ]) # note that the changeids are given out of order here; it should still # use the most recent for each codebase d = sched.addBuildsetForChanges(reason='power', changeids=[14, 12, 17, 16, 13, 15]) def check((bsid,brids)): self.db.buildsets.assertBuildset(bsid, dict(reason='power', brids=brids, external_idstring=None, properties=[('scheduler', ('n', 'Scheduler'))], sourcestampsetid=100), {'cbA': dict(branch='develop', repository='svn://A..', codebase='cbA', changeids=set([12,13,14]), project='making-tea', revision='9284', sourcestampsetid=100), 'cbB': dict(branch='develop', repository='svn://B..', codebase='cbB', changeids=set([15,16,17]), project='swimming', revision='8087', sourcestampsetid=100), 'cbC': dict(branch='stable', repository='svn://C..', codebase='cbC', project='', revision='12345', sourcestampsetid=100), 'cbD': dict(branch=None, repository='svn://D..', codebase='cbD', project='', revision=None, sourcestampsetid=100), }) d.addCallback(check) return d def test_addBuildsetForSourceStamp(self): sched = self.makeScheduler(name='n', builderNames=['b']) d = self.db.insertTestData([ fakedb.SourceStampSet(id=1091), fakedb.SourceStamp(id=91, sourcestampsetid=1091, branch='fixins', revision='abc', patchid=None, repository='r', project='p'), ]) d.addCallback(lambda _ : sched.addBuildsetForSourceStamp(reason='whynot', setid=1091)) def check((bsid,brids)): self.db.buildsets.assertBuildset(bsid, dict(reason='whynot', brids=brids, external_idstring=None, properties=[('scheduler', ('n', 'Scheduler'))], sourcestampsetid=1091), {'': dict(branch='fixins', revision='abc', repository='r', project='p', codebase='', sourcestampsetid=1091) }) d.addCallback(check) return d def test_addBuildsetForSourceStamp_properties(self): props = properties.Properties(xxx="yyy") sched = self.makeScheduler(name='n', builderNames=['b']) d = self.db.insertTestData([ fakedb.SourceStampSet(id=1091), fakedb.SourceStamp(id=91, sourcestampsetid=1091, branch='fixins', revision='abc', patchid=None, repository='r', codebase='cb', project='p'), ]) d.addCallback(lambda _ : sched.addBuildsetForSourceStamp(reason='whynot', setid=1091, properties=props)) def check((bsid,brids)): self.db.buildsets.assertBuildset(bsid, dict(reason='whynot', brids=brids, external_idstring=None, properties=[ ('scheduler', ('n', 'Scheduler')), ('xxx', ('yyy', 'TEST')), ], sourcestampsetid=1091), {'cb': dict(branch='fixins', revision='abc', repository='r', codebase='cb', project='p', sourcestampsetid=1091) }) d.addCallback(check) return d def test_addBuildsetForSourceStamp_builderNames(self): sched = self.makeScheduler(name='n', builderNames=['k']) d = self.db.insertTestData([ fakedb.SourceStampSet(id=1091), fakedb.SourceStamp(id=91, sourcestampsetid=1091, branch='fixins', revision='abc', patchid=None, repository='r', codebase='cb', project='p'), ]) d.addCallback(lambda _ : sched.addBuildsetForSourceStamp(reason='whynot', setid = 1091, builderNames=['a', 'b'])) def check((bsid,brids)): self.db.buildsets.assertBuildset(bsid, dict(reason='whynot', brids=brids, external_idstring=None, properties=[('scheduler', ('n', 'Scheduler'))], sourcestampsetid=1091), {'cb': dict(branch='fixins', revision='abc', repository='r', codebase='cb', project='p', sourcestampsetid=1091) }) d.addCallback(check) return d def test_findNewSchedulerInstance(self): sched = self.makeScheduler(name='n', builderNames=['k']) new_sched = self.makeScheduler(name='n', builderNames=['l']) distractor = self.makeScheduler(name='x', builderNames=['l']) config = mock.Mock() config.schedulers = dict(dist=distractor, n=new_sched) self.assertIdentical(sched.findNewSchedulerInstance(config), new_sched) buildbot-0.8.8/buildbot/test/unit/test_schedulers_basic.py000066400000000000000000000437701222546025000240210ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from twisted.internet import defer, task from buildbot import config from buildbot.test.fake import fakedb from buildbot.schedulers import basic from buildbot.test.util import scheduler class CommonStuffMixin(object): def makeScheduler(self, klass, **kwargs_override): kwargs = dict(name="tsched", treeStableTimer=60, builderNames=['tbuild']) kwargs.update(kwargs_override) sched = self.attachScheduler(klass(**kwargs), self.OBJECTID) # add a Clock to help checking timing issues self.clock = sched._reactor = task.Clock() # keep track of builds in self.events self.events = [] def addBuildsetForChanges(reason='', external_idstring=None, changeids=[]): self.assertEqual(external_idstring, None) self.assertEqual(reason, 'scheduler') # basic schedulers all use this self.events.append('B%s@%d' % (`changeids`.replace(' ',''), self.clock.seconds())) return defer.succeed(None) sched.addBuildsetForChanges = addBuildsetForChanges # see self.assertConsumingChanges self.consumingChanges = None def startConsumingChanges(**kwargs): self.consumingChanges = kwargs return defer.succeed(None) sched.startConsumingChanges = startConsumingChanges return sched def assertConsumingChanges(self, **kwargs): self.assertEqual(self.consumingChanges, kwargs) class BaseBasicScheduler(CommonStuffMixin, scheduler.SchedulerMixin, unittest.TestCase): OBJECTID = 244 # a custom subclass since we're testing the base class. This basically # re-implements SingleBranchScheduler, but with more asserts class Subclass(basic.BaseBasicScheduler): timer_started = False def getChangeFilter(self, *args, **kwargs): return kwargs.get('change_filter') def getTimerNameForChange(self, change): self.timer_started = True return "xxx" def getChangeClassificationsForTimer(self, objectid, timer_name): assert timer_name == "xxx" assert objectid == BaseBasicScheduler.OBJECTID return self.master.db.schedulers.getChangeClassifications(objectid) def setUp(self): self.setUpScheduler() def tearDown(self): self.tearDownScheduler() # tests def test_constructor_positional_exception(self): self.assertRaises(config.ConfigErrors, lambda : self.Subclass("tsched", "master", 60)) def test_startService_no_treeStableTimer(self): cf = mock.Mock('cf') fII = mock.Mock('fII') sched = self.makeScheduler(self.Subclass, treeStableTimer=None, change_filter=cf, fileIsImportant=fII) self.db.schedulers.fakeClassifications(self.OBJECTID, { 20 : True }) d = sched.startService(_returnDeferred=True) # check that the scheduler has started to consume changes, and the # classifications *have* been flushed, since they will not be used def check(_): self.assertConsumingChanges(fileIsImportant=fII, change_filter=cf, onlyImportant=False) self.db.schedulers.assertClassifications(self.OBJECTID, {}) d.addCallback(check) d.addCallback(lambda _ : sched.stopService()) return d def test_subclass_fileIsImportant(self): class Subclass(self.Subclass): def fileIsImportant(self, change): return False sched = self.makeScheduler(Subclass, onlyImportant=True) self.failUnlessEqual(Subclass.fileIsImportant.__get__(sched), sched.fileIsImportant) def test_startService_treeStableTimer(self): cf = mock.Mock() sched = self.makeScheduler(self.Subclass, treeStableTimer=10, change_filter=cf) self.db.schedulers.fakeClassifications(self.OBJECTID, { 20 : True }) self.master.db.insertTestData([ fakedb.Change(changeid=20), fakedb.SchedulerChange(objectid=self.OBJECTID, changeid=20, important=1) ]) d = sched.startService(_returnDeferred=True) # check that the scheduler has started to consume changes, and no # classifications have been flushed. Furthermore, the existing # classification should have been acted on, so the timer should be # running def check(_): self.assertConsumingChanges(fileIsImportant=None, change_filter=cf, onlyImportant=False) self.db.schedulers.assertClassifications(self.OBJECTID, { 20 : True }) self.assertTrue(sched.timer_started) self.assertEqual(sched.getPendingBuildTimes(), [ 10 ]) self.clock.advance(10) self.assertEqual(sched.getPendingBuildTimes(), []) d.addCallback(check) d.addCallback(lambda _ : sched.stopService()) return d def test_gotChange_no_treeStableTimer_unimportant(self): sched = self.makeScheduler(self.Subclass, treeStableTimer=None, branch='master') sched.startService() d = sched.gotChange(self.makeFakeChange(branch='master', number=13), False) def check(_): self.assertEqual(self.events, []) d.addCallback(check) d.addCallback(lambda _ : sched.stopService()) def test_gotChange_no_treeStableTimer_important(self): sched = self.makeScheduler(self.Subclass, treeStableTimer=None, branch='master') sched.startService() d = sched.gotChange(self.makeFakeChange(branch='master', number=13), True) def check(_): self.assertEqual(self.events, [ 'B[13]@0' ]) d.addCallback(check) d.addCallback(lambda _ : sched.stopService()) def test_gotChange_treeStableTimer_unimportant(self): sched = self.makeScheduler(self.Subclass, treeStableTimer=10, branch='master') sched.startService() d = sched.gotChange(self.makeFakeChange(branch='master', number=13), False) def check(_): self.assertEqual(self.events, []) d.addCallback(check) d.addCallback(lambda _ : self.clock.advance(10)) d.addCallback(check) # should still be empty d.addCallback(lambda _ : sched.stopService()) def test_gotChange_treeStableTimer_important(self): sched = self.makeScheduler(self.Subclass, treeStableTimer=10, branch='master') sched.startService() d = sched.gotChange(self.makeFakeChange(branch='master', number=13), True) d.addCallback(lambda _ : self.clock.advance(10)) def check(_): self.assertEqual(self.events, [ 'B[13]@10' ]) d.addCallback(check) d.addCallback(lambda _ : sched.stopService()) @defer.inlineCallbacks def test_gotChange_treeStableTimer_sequence(self): sched = self.makeScheduler(self.Subclass, treeStableTimer=9, branch='master') self.master.db.insertTestData([ fakedb.Change(changeid=1, branch='master', when_timestamp=1110), fakedb.ChangeFile(changeid=1, filename='readme.txt'), fakedb.Change(changeid=2, branch='master', when_timestamp=2220), fakedb.ChangeFile(changeid=2, filename='readme.txt'), fakedb.Change(changeid=3, branch='master', when_timestamp=3330), fakedb.ChangeFile(changeid=3, filename='readme.txt'), fakedb.Change(changeid=4, branch='master', when_timestamp=4440), fakedb.ChangeFile(changeid=4, filename='readme.txt'), ]) sched.startService() self.clock.advance(2220) # this important change arrives at 2220, so the stable timer will last # until 2229 yield sched.gotChange( self.makeFakeChange(branch='master', number=1, when=2220), True) self.assertEqual(self.events, []) self.assertEqual(sched.getPendingBuildTimes(), [2229]) self.db.schedulers.assertClassifications(self.OBJECTID, { 1 : True }) # but another (unimportant) change arrives before then self.clock.advance(6) # to 2226 self.assertEqual(self.events, []) yield sched.gotChange( self.makeFakeChange(branch='master', number=2, when=2226), False) self.assertEqual(self.events, []) self.assertEqual(sched.getPendingBuildTimes(), [2235]) self.db.schedulers.assertClassifications(self.OBJECTID, { 1 : True, 2 : False }) self.clock.advance(3) # to 2229 self.assertEqual(self.events, []) self.clock.advance(3) # to 2232 self.assertEqual(self.events, []) # another important change arrives at 2232 yield sched.gotChange( self.makeFakeChange(branch='master', number=3, when=2232), True) self.assertEqual(self.events, []) self.assertEqual(sched.getPendingBuildTimes(), [2241]) self.db.schedulers.assertClassifications(self.OBJECTID, { 1 : True, 2 : False, 3 : True }) self.clock.advance(3) # to 2235 self.assertEqual(self.events, []) # finally, time to start the build! self.clock.advance(6) # to 2241 self.assertEqual(self.events, [ 'B[1,2,3]@2241' ]) self.assertEqual(sched.getPendingBuildTimes(), []) self.db.schedulers.assertClassifications(self.OBJECTID, { }) yield sched.stopService() class SingleBranchScheduler(CommonStuffMixin, scheduler.SchedulerMixin, unittest.TestCase): OBJECTID = 245 def setUp(self): self.setUpScheduler() def tearDown(self): self.tearDownScheduler() def test_constructor_branch_mandatory(self): self.assertRaises(config.ConfigErrors, lambda : basic.SingleBranchScheduler(name="tsched", treeStableTimer=60)) def test_constructor_no_branch_but_filter(self): # this shouldn't fail basic.SingleBranchScheduler(name="tsched", treeStableTimer=60, builderNames=['a','b'], change_filter=mock.Mock()) def test_constructor_branches_forbidden(self): self.assertRaises(config.ConfigErrors, lambda : basic.SingleBranchScheduler(name="tsched", treeStableTimer=60, branches='x')) def test_gotChange_treeStableTimer_important(self): # this looks suspiciously like the same test above, because SingleBranchScheduler # is about the same as the test subclass used above sched = self.makeScheduler(basic.SingleBranchScheduler, treeStableTimer=10, branch='master') sched.startService() d = sched.gotChange(self.makeFakeChange(branch='master', number=13), True) d.addCallback(lambda _ : self.clock.advance(10)) def check(_): self.assertEqual(self.events, [ 'B[13]@10' ]) d.addCallback(check) d.addCallback(lambda _ : sched.stopService()) class AnyBranchScheduler(CommonStuffMixin, scheduler.SchedulerMixin, unittest.TestCase): OBJECTID = 246 def setUp(self): self.setUpScheduler() def tearDown(self): self.tearDownScheduler() def test_constructor_branch_forbidden(self): self.assertRaises(config.ConfigErrors, lambda : basic.SingleBranchScheduler(name="tsched", treeStableTimer=60, branch='x')) def test_gotChange_treeStableTimer_multiple_branches(self): """Two changes with different branches get different treeStableTimers""" sched = self.makeScheduler(basic.AnyBranchScheduler, treeStableTimer=10, branches=['master', 'devel', 'boring']) sched.startService() def mkch(**kwargs): ch = self.makeFakeChange(**kwargs) self.db.changes.fakeAddChangeInstance(ch) return ch d = defer.succeed(None) d.addCallback(lambda _ : sched.gotChange(mkch(branch='master', number=13), True)) d.addCallback(lambda _ : self.assertEqual(sched.getPendingBuildTimes(), [10])) d.addCallback(lambda _ : self.clock.advance(1)) # time is now 1 d.addCallback(lambda _ : sched.gotChange(mkch(branch='master', number=14), False)) d.addCallback(lambda _ : self.assertEqual(sched.getPendingBuildTimes(), [11])) d.addCallback(lambda _ : sched.gotChange(mkch(branch='boring', number=15), False)) d.addCallback(lambda _ : self.clock.pump([1]*4)) # time is now 5 d.addCallback(lambda _ : sched.gotChange(mkch(branch='devel', number=16), True)) d.addCallback(lambda _ : self.assertEqual(sorted(sched.getPendingBuildTimes()), [11,15])) d.addCallback(lambda _ : self.clock.pump([1]*10)) # time is now 15 def check(_): self.assertEqual(self.events, [ 'B[13,14]@11', 'B[16]@15' ]) d.addCallback(check) d.addCallback(lambda _ : sched.stopService()) def test_gotChange_treeStableTimer_multiple_repositories(self): """Two repositories, even with the same branch name, have different treeStableTimers""" sched = self.makeScheduler(basic.AnyBranchScheduler, treeStableTimer=10, branches=['master']) sched.startService() def mkch(**kwargs): ch = self.makeFakeChange(**kwargs) self.db.changes.fakeAddChangeInstance(ch) return ch d = defer.succeed(None) d.addCallback(lambda _ : sched.gotChange(mkch(branch='master', repository="repo", number=13), True)) d.addCallback(lambda _ : self.clock.advance(1)) # time is now 1 d.addCallback(lambda _ : sched.gotChange(mkch(branch='master', repository="repo", number=14), False)) d.addCallback(lambda _ : sched.gotChange(mkch(branch='master', repository="other_repo", number=15), False)) d.addCallback(lambda _ : self.clock.pump([1]*4)) # time is now 5 d.addCallback(lambda _ : sched.gotChange(mkch(branch='master', repository="other_repo", number=17), True)) d.addCallback(lambda _ : self.clock.pump([1]*10)) # time is now 15 def check(_): self.assertEqual(self.events, [ 'B[13,14]@11', 'B[15,17]@15' ]) d.addCallback(check) d.addCallback(lambda _ : sched.stopService()) def test_gotChange_treeStableTimer_multiple_projects(self): """Two projects, even with the same branch name, have different treeStableTimers""" sched = self.makeScheduler(basic.AnyBranchScheduler, treeStableTimer=10, branches=['master']) sched.startService() def mkch(**kwargs): ch = self.makeFakeChange(**kwargs) self.db.changes.fakeAddChangeInstance(ch) return ch d = defer.succeed(None) d.addCallback(lambda _ : sched.gotChange(mkch(branch='master', project="proj", number=13), True)) d.addCallback(lambda _ : self.clock.advance(1)) # time is now 1 d.addCallback(lambda _ : sched.gotChange(mkch(branch='master', project="proj", number=14), False)) d.addCallback(lambda _ : sched.gotChange(mkch(branch='master', project="other_proj", number=15), False)) d.addCallback(lambda _ : self.clock.pump([1]*4)) # time is now 5 d.addCallback(lambda _ : sched.gotChange(mkch(branch='master', project="other_proj", number=17), True)) d.addCallback(lambda _ : self.clock.pump([1]*10)) # time is now 15 def check(_): self.assertEqual(self.events, [ 'B[13,14]@11', 'B[15,17]@15' ]) d.addCallback(check) d.addCallback(lambda _ : sched.stopService()) def test_gotChange_treeStableTimer_multiple_codebases(self): """Two codebases, even with the same branch name, have different treeStableTimers""" sched = self.makeScheduler(basic.AnyBranchScheduler, treeStableTimer=10, branches=['master']) sched.startService() def mkch(**kwargs): ch = self.makeFakeChange(**kwargs) self.db.changes.fakeAddChangeInstance(ch) return ch d = defer.succeed(None) d.addCallback(lambda _ : sched.gotChange(mkch(branch='master', codebase="base", number=13), True)) d.addCallback(lambda _ : self.clock.advance(1)) # time is now 1 d.addCallback(lambda _ : sched.gotChange(mkch(branch='master', codebase="base", number=14), False)) d.addCallback(lambda _ : sched.gotChange(mkch(branch='master', codebase="other_base", number=15), False)) d.addCallback(lambda _ : self.clock.pump([1]*4)) # time is now 5 d.addCallback(lambda _ : sched.gotChange(mkch(branch='master', codebase="other_base", number=17), True)) d.addCallback(lambda _ : self.clock.pump([1]*10)) # time is now 15 def check(_): self.assertEqual(self.events, [ 'B[13,14]@11', 'B[15,17]@15' ]) d.addCallback(check) d.addCallback(lambda _ : sched.stopService()) buildbot-0.8.8/buildbot/test/unit/test_schedulers_dependent.py000066400000000000000000000135741222546025000247050ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.internet import defer from buildbot import config from buildbot.schedulers import dependent, base from buildbot.status.results import SUCCESS, WARNINGS, FAILURE from buildbot.test.util import scheduler from buildbot.test.fake import fakedb class Dependent(scheduler.SchedulerMixin, unittest.TestCase): OBJECTID = 33 UPSTREAM_NAME = 'uppy' def setUp(self): self.setUpScheduler() def tearDown(self): self.tearDownScheduler() def makeScheduler(self, upstream=None): # build a fake upstream scheduler class Upstream(base.BaseScheduler): def __init__(self, name): self.name = name if not upstream: upstream = Upstream(self.UPSTREAM_NAME) sched = dependent.Dependent(name='n', builderNames=['b'], upstream=upstream) self.attachScheduler(sched, self.OBJECTID) return sched def assertBuildsetSubscriptions(self, bsids=None): self.db.state.assertState(self.OBJECTID, upstream_bsids=bsids) # tests # NOTE: these tests take advantage of the fact that all of the fake # scheduler operations are synchronous, and thus do not return a Deferred. # The Deferred from trigger() is completely processed before this test # method returns. def test_constructor_string_arg(self): self.assertRaises(config.ConfigErrors, lambda : self.makeScheduler(upstream='foo')) def test_startService(self): sched = self.makeScheduler() sched.startService() callbacks = self.master.getSubscriptionCallbacks() self.assertNotEqual(callbacks['buildsets'], None) self.assertNotEqual(callbacks['buildset_completion'], None) d = sched.stopService() def check(_): callbacks = self.master.getSubscriptionCallbacks() self.assertEqual(callbacks['buildsets'], None) self.assertEqual(callbacks['buildset_completion'], None) d.addCallback(check) return d def do_test(self, scheduler_name, expect_subscription, result, expect_buildset): sched = self.makeScheduler() sched.startService() callbacks = self.master.getSubscriptionCallbacks() # pretend we saw a buildset with a matching name self.db.insertTestData([ fakedb.SourceStamp(id=93, sourcestampsetid=1093, revision='555', branch='master', project='proj', repository='repo', codebase = 'cb'), fakedb.Buildset(id=44, sourcestampsetid=1093), ]) callbacks['buildsets'](bsid=44, properties=dict(scheduler=(scheduler_name, 'Scheduler'))) # check whether scheduler is subscribed to that buildset if expect_subscription: self.assertBuildsetSubscriptions([44]) else: self.assertBuildsetSubscriptions([]) # pretend that the buildset is finished self.db.buildsets.fakeBuildsetCompletion(bsid=44, result=result) callbacks['buildset_completion'](44, result) # and check whether a buildset was added in response if expect_buildset: self.db.buildsets.assertBuildsets(2) bsids = self.db.buildsets.allBuildsetIds() bsids.remove(44) self.db.buildsets.assertBuildset(bsids[0], dict(external_idstring=None, properties=[('scheduler', ('n', 'Scheduler'))], reason='downstream', sourcestampsetid = 1093), {'cb': dict(revision='555', branch='master', project='proj', repository='repo', codebase='cb', sourcestampsetid = 1093) }) else: self.db.buildsets.assertBuildsets(1) # only the one we added above def test_related_buildset_SUCCESS(self): return self.do_test(self.UPSTREAM_NAME, True, SUCCESS, True) def test_related_buildset_WARNINGS(self): return self.do_test(self.UPSTREAM_NAME, True, WARNINGS, True) def test_related_buildset_FAILURE(self): return self.do_test(self.UPSTREAM_NAME, True, FAILURE, False) def test_unrelated_buildset(self): return self.do_test('unrelated', False, SUCCESS, False) @defer.inlineCallbacks def test_getUpstreamBuildsets_missing(self): sched = self.makeScheduler() # insert some state, with more bsids than exist self.db.insertTestData([ fakedb.SourceStampSet(id=99), fakedb.Buildset(id=11, sourcestampsetid=99), fakedb.Buildset(id=13, sourcestampsetid=99), fakedb.Object(id=self.OBJECTID), fakedb.ObjectState(objectid=self.OBJECTID, name='upstream_bsids', value_json='[11,12,13]'), ]) # check return value (missing 12) self.assertEqual((yield sched._getUpstreamBuildsets()), [(11, 99, False, -1), (13, 99, False, -1)]) # and check that it wrote the correct value back to the state self.db.state.assertState(self.OBJECTID, upstream_bsids=[11, 13]) buildbot-0.8.8/buildbot/test/unit/test_schedulers_forcesched.py000066400000000000000000000543111222546025000250360ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.internet import defer from buildbot import config from buildbot.schedulers.forcesched import ForceScheduler, StringParameter from buildbot.schedulers.forcesched import IntParameter, FixedParameter from buildbot.schedulers.forcesched import BooleanParameter, UserNameParameter from buildbot.schedulers.forcesched import ChoiceStringParameter, ValidationError from buildbot.schedulers.forcesched import NestedParameter, AnyPropertyParameter from buildbot.schedulers.forcesched import CodebaseParameter, BaseParameter from buildbot.test.util import scheduler from buildbot.test.util.config import ConfigErrorsMixin class TestForceScheduler(scheduler.SchedulerMixin, ConfigErrorsMixin, unittest.TestCase): OBJECTID = 19 def setUp(self): self.setUpScheduler() def tearDown(self): self.tearDownScheduler() def makeScheduler(self, name='testsched', builderNames=['a', 'b'], **kw): sched = self.attachScheduler( ForceScheduler(name=name, builderNames=builderNames,**kw), self.OBJECTID) sched.master.config = config.MasterConfig() self.assertEquals(sched.name, name) return sched # tests def test_compare_branch(self): self.assertNotEqual( ForceScheduler(name="testched", builderNames=[]), ForceScheduler(name="testched", builderNames=[], branch=FixedParameter("branch","fishing/pole"))) def test_compare_reason(self): self.assertNotEqual( ForceScheduler(name="testched", builderNames=[], reason=FixedParameter("reason","no fish for you!")), ForceScheduler(name="testched", builderNames=[], reason=FixedParameter("reason","thanks for the fish!"))) def test_compare_revision(self): self.assertNotEqual( ForceScheduler(name="testched", builderNames=[], revision=FixedParameter("revision","fish-v1")), ForceScheduler(name="testched", builderNames=[], revision=FixedParameter("revision","fish-v2"))) def test_compare_repository(self): self.assertNotEqual( ForceScheduler(name="testched", builderNames=[], repository=FixedParameter("repository","git://pond.org/fisher.git")), ForceScheduler(name="testched", builderNames=[], repository=FixedParameter("repository","svn://ocean.com/trawler/"))) def test_compare_project(self): self.assertNotEqual( ForceScheduler(name="testched", builderNames=[], project=FixedParameter("project","fisher")), ForceScheduler(name="testched", builderNames=[], project=FixedParameter("project","trawler"))) def test_compare_username(self): self.assertNotEqual( ForceScheduler(name="testched", builderNames=[]), ForceScheduler(name="testched", builderNames=[], project=FixedParameter("username","The Fisher King "))) def test_compare_properties(self): self.assertNotEqual( ForceScheduler(name="testched", builderNames=[], properties=[]), ForceScheduler(name="testched", builderNames=[], properties=[FixedParameter("prop","thanks for the fish!")])) def test_compare_codebases(self): self.assertNotEqual( ForceScheduler(name="testched", builderNames=[], codebases=['bar']), ForceScheduler(name="testched", builderNames=[], codebases=['foo'])) @defer.inlineCallbacks def test_basicForce(self): sched = self.makeScheduler() res = yield sched.force('user', builderNames=['a'], branch='a', reason='because',revision='c', repository='d', project='p', property1_name='p1',property1_value='e', property2_name='p2',property2_value='f', property3_name='p3',property3_value='g', property4_name='p4',property4_value='h' ) bsid,brids = res # only one builder forced, so there should only be one brid self.assertEqual(len(brids), 1) self.db.buildsets.assertBuildset\ (bsid, dict(reason="A build was forced by 'user': because", brids=brids, external_idstring=None, properties=[ ('owner', ('user', 'Force Build Form')), ('p1', ('e', 'Force Build Form')), ('p2', ('f', 'Force Build Form')), ('p3', ('g', 'Force Build Form')), ('p4', ('h', 'Force Build Form')), ('reason', ('because', 'Force Build Form')), ('scheduler', ('testsched', 'Scheduler')), ], sourcestampsetid=100), {'': dict(branch='a', revision='c', repository='d', codebase='', project='p', sourcestampsetid=100) }) @defer.inlineCallbacks def test_force_allBuilders(self): sched = self.makeScheduler() res = yield sched.force('user', branch='a', reason='because',revision='c', repository='d', project='p', ) bsid,brids = res self.assertEqual(len(brids), 2) self.db.buildsets.assertBuildset\ (bsid, dict(reason="A build was forced by 'user': because", brids=brids, builders = ['a', 'b'], external_idstring=None, properties=[ ('owner', ('user', 'Force Build Form')), ('reason', ('because', 'Force Build Form')), ('scheduler', ('testsched', 'Scheduler')), ], sourcestampsetid=100), {'': dict(branch='a', revision='c', repository='d', codebase='', project='p', sourcestampsetid=100) }) @defer.inlineCallbacks def test_force_someBuilders(self): sched = self.makeScheduler(builderNames=['a','b','c']) res = yield sched.force('user', builderNames=['a','b'], branch='a', reason='because',revision='c', repository='d', project='p', ) bsid,brids = res self.assertEqual(len(brids), 2) self.db.buildsets.assertBuildset\ (bsid, dict(reason="A build was forced by 'user': because", brids=brids, builders = ['a', 'b'], external_idstring=None, properties=[ ('owner', ('user', 'Force Build Form')), ('reason', ('because', 'Force Build Form')), ('scheduler', ('testsched', 'Scheduler')), ], sourcestampsetid=100), {'': dict(branch='a', revision='c', repository='d', codebase='', project='p', sourcestampsetid=100) }) def test_bad_codebases(self): # cant specify both codebases and branch/revision/project/repository: self.assertRaisesConfigError("ForceScheduler: Must either specify 'codebases' or the 'branch/revision/repository/project' parameters:", lambda: ForceScheduler(name='foo', builderNames=['bar'], codebases=['foo'], branch=StringParameter('name'))) self.assertRaisesConfigError("ForceScheduler: Must either specify 'codebases' or the 'branch/revision/repository/project' parameters:", lambda: ForceScheduler(name='foo', builderNames=['bar'], codebases=['foo'], revision=StringParameter('name'))) self.assertRaisesConfigError("ForceScheduler: Must either specify 'codebases' or the 'branch/revision/repository/project' parameters:", lambda: ForceScheduler(name='foo', builderNames=['bar'], codebases=['foo'], project=StringParameter('name'))) self.assertRaisesConfigError("ForceScheduler: Must either specify 'codebases' or the 'branch/revision/repository/project' parameters:", lambda: ForceScheduler(name='foo', builderNames=['bar'], codebases=['foo'], repository=StringParameter('name'))) # codebases must be a list of either string or BaseParameter types self.assertRaisesConfigError("ForceScheduler: 'codebases' must be a list of strings or CodebaseParameter objects:", lambda: ForceScheduler(name='foo', builderNames=['bar'], codebases=[123],)) self.assertRaisesConfigError("ForceScheduler: 'codebases' must be a list of strings or CodebaseParameter objects:", lambda: ForceScheduler(name='foo', builderNames=['bar'], codebases=[IntParameter('foo')],)) # codebases cannot be empty self.assertRaisesConfigError("ForceScheduler: 'codebases' cannot be empty; use CodebaseParameter(codebase='', hide=True) if needed:", lambda: ForceScheduler(name='foo', builderNames=['bar'], codebases=[])) @defer.inlineCallbacks def test_good_codebases(self): sched = self.makeScheduler(codebases=['foo', CodebaseParameter('bar')]) res = yield sched.force('user', builderNames=['a'], reason='because', foo_branch='a', foo_revision='c', foo_repository='d', foo_project='p', bar_branch='a2', bar_revision='c2', bar_repository='d2', bar_project='p2', property1_name='p1',property1_value='e', property2_name='p2',property2_value='f', property3_name='p3',property3_value='g', property4_name='p4',property4_value='h' ) bsid,brids = res self.db.buildsets.assertBuildset\ (bsid, dict(reason="A build was forced by 'user': because", brids=brids, external_idstring=None, properties=[ ('owner', ('user', 'Force Build Form')), ('p1', ('e', 'Force Build Form')), ('p2', ('f', 'Force Build Form')), ('p3', ('g', 'Force Build Form')), ('p4', ('h', 'Force Build Form')), ('reason', ('because', 'Force Build Form')), ('scheduler', ('testsched', 'Scheduler')), ], sourcestampsetid=100), {'foo': dict(codebase='foo', sourcestampsetid=100, branch='a', revision='c', repository='d', project='p', ), 'bar': dict(codebase='bar', sourcestampsetid=100, branch='a2', revision='c2', repository='d2', project='p2', ), }) # value = the value to be sent with the parameter (ignored if req is set) # expect = the expected result (can be an exception type) # klass = the parameter class type # req = use this request instead of the auto-generated one based on value @defer.inlineCallbacks def do_ParameterTest(self, expect, klass, expectKind=None, # None=one prop, Exception=exception, dict=many props owner='user', value=None, req=None, **kwargs): name = kwargs.setdefault('name', 'p1') # construct one if needed if isinstance(klass, type): prop = klass(**kwargs) else: prop = klass self.assertEqual(prop.name, name) self.assertEqual(prop.label, kwargs.get('label', prop.name)) sched = self.makeScheduler(properties=[prop]) if not req: req = {name:value, 'reason':'because'} try: bsid, brids = yield sched.force(owner, builderNames=['a'], **req) except Exception,e: if expectKind is not Exception: # an exception is not expected raise if not isinstance(e, expect): # the exception is the wrong kind raise defer.returnValue(None) # success expect_props = [ ('owner', ('user', 'Force Build Form')), ('reason', ('because', 'Force Build Form')), ('scheduler', ('testsched', 'Scheduler')), ] if expectKind is None: expect_props.append((name, (expect, 'Force Build Form'))) elif expectKind is dict: for k,v in expect.iteritems(): expect_props.append((k, (v, 'Force Build Form'))) else: self.fail("expectKind is wrong type!") self.db.buildsets.assertBuildset\ (bsid, dict(reason="A build was forced by 'user': because", brids=brids, external_idstring=None, properties=sorted(expect_props), sourcestampsetid=100), {"": dict(branch="", revision="", repository="", codebase='', project="", sourcestampsetid=100) }) def test_StringParameter(self): self.do_ParameterTest(value="testedvalue", expect="testedvalue", klass=StringParameter) def test_IntParameter(self): self.do_ParameterTest(value="123", expect=123, klass=IntParameter) def test_FixedParameter(self): self.do_ParameterTest(value="123", expect="321", klass=FixedParameter, default="321") def test_BooleanParameter_True(self): req = dict(p1=True,reason='because') self.do_ParameterTest(value="123", expect=True, klass=BooleanParameter, req=req) def test_BooleanParameter_False(self): req = dict(p2=True,reason='because') self.do_ParameterTest(value="123", expect=False, klass=BooleanParameter, req=req) def test_UserNameParameter(self): email = "test " self.do_ParameterTest(value=email, expect=email, klass=UserNameParameter(), name="username", label="Your name:") def test_UserNameParameterError(self): for value in ["test","test@buildbot.net",""]: self.do_ParameterTest(value=value, expect=ValidationError, expectKind=Exception, klass=UserNameParameter(debug=False), name="username", label="Your name:") def test_ChoiceParameter(self): self.do_ParameterTest(value='t1', expect='t1', klass=ChoiceStringParameter, choices=['t1','t2']) def test_ChoiceParameterError(self): self.do_ParameterTest(value='t3', expect=ValidationError, expectKind=Exception, klass=ChoiceStringParameter, choices=['t1','t2'], debug=False) def test_ChoiceParameterError_notStrict(self): self.do_ParameterTest(value='t1', expect='t1', strict=False, klass=ChoiceStringParameter, choices=['t1','t2']) def test_ChoiceParameterMultiple(self): self.do_ParameterTest(value=['t1','t2'], expect=['t1','t2'], klass=ChoiceStringParameter,choices=['t1','t2'], multiple=True) def test_ChoiceParameterMultipleError(self): self.do_ParameterTest(value=['t1','t3'], expect=ValidationError, expectKind=Exception, klass=ChoiceStringParameter, choices=['t1','t2'], multiple=True, debug=False) def test_NestedParameter(self): fields = [ IntParameter(name="foo") ] self.do_ParameterTest(req=dict(p1_foo='123', reason="because"), expect=dict(foo=123), klass=NestedParameter, fields=fields) def test_NestedNestedParameter(self): fields = [ NestedParameter(name="inner", fields=[ StringParameter(name='str'), AnyPropertyParameter(name='any') ]), IntParameter(name="foo") ] self.do_ParameterTest(req=dict(p1_foo='123', p1_inner_str="bar", p1_inner_any_name="hello", p1_inner_any_value="world", reason="because"), expect=dict(foo=123, inner=dict(str="bar", hello="world")), klass=NestedParameter, fields=fields) def test_NestedParameter_nullname(self): # same as above except "p1" and "any" are skipped fields = [ NestedParameter(name="inner", fields=[ StringParameter(name='str'), AnyPropertyParameter(name='') ]), IntParameter(name="foo"), NestedParameter(name='bar', fields=[ NestedParameter(name='', fields=[AnyPropertyParameter(name='a')]), NestedParameter(name='', fields=[AnyPropertyParameter(name='b')]) ]) ] self.do_ParameterTest(req=dict(foo='123', inner_str="bar", inner_name="hello", inner_value="world", reason="because", bar_a_name="a", bar_a_value="7", bar_b_name="b", bar_b_value="8"), expect=dict(foo=123, inner=dict(str="bar", hello="world"), bar={'a':'7', 'b':'8'}), expectKind=dict, klass=NestedParameter, fields=fields, name='') def test_bad_reason(self): self.assertRaisesConfigError("ForceScheduler reason must be a StringParameter", lambda: ForceScheduler(name='testsched', builderNames=[], codebases=['bar'], reason="foo")) def test_bad_username(self): self.assertRaisesConfigError("ForceScheduler username must be a StringParameter", lambda: ForceScheduler(name='testsched', builderNames=[], codebases=['bar'], username="foo")) def test_notstring_name(self): self.assertRaisesConfigError("ForceScheduler name must be a unicode string:", lambda: ForceScheduler(name=1234, builderNames=[], codebases=['bar'], username="foo")) def test_emptystring_name(self): self.assertRaisesConfigError("ForceScheduler name must not be empty:", lambda: ForceScheduler(name='', builderNames=[], codebases=['bar'], username="foo")) def test_integer_builderNames(self): self.assertRaisesConfigError("ForceScheduler builderNames must be a list of strings:", lambda: ForceScheduler(name='testsched', builderNames=1234, codebases=['bar'], username="foo")) def test_listofints_builderNames(self): self.assertRaisesConfigError("ForceScheduler builderNames must be a list of strings:", lambda: ForceScheduler(name='testsched', builderNames=[1234], codebases=['bar'], username="foo")) def test_listofmixed_builderNames(self): self.assertRaisesConfigError("ForceScheduler builderNames must be a list of strings:", lambda: ForceScheduler(name='testsched', builderNames=['test', 1234], codebases=['bar'], username="foo")) def test_integer_properties(self): self.assertRaisesConfigError("ForceScheduler properties must be a list of BaseParameters:", lambda: ForceScheduler(name='testsched', builderNames=[], codebases=['bar'], username="foo", properties=1234)) def test_listofints_properties(self): self.assertRaisesConfigError("ForceScheduler properties must be a list of BaseParameters:", lambda: ForceScheduler(name='testsched', builderNames=[], codebases=['bar'], username="foo", properties=[1234, 2345])) def test_listofmixed_properties(self): self.assertRaisesConfigError("ForceScheduler properties must be a list of BaseParameters:", lambda: ForceScheduler(name='testsched', builderNames=[], codebases=['bar'], username="foo", properties=[BaseParameter(name="test",), 4567])) buildbot-0.8.8/buildbot/test/unit/test_schedulers_manager.py000066400000000000000000000140701222546025000243410ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from twisted.internet import defer from buildbot.schedulers import manager, base from buildbot import config class SchedulerManager(unittest.TestCase): def setUp(self): self.next_objectid = 13 self.objectids = {} self.master = mock.Mock() def getObjectId(sched_name, class_name): k = (sched_name, class_name) try: rv = self.objectids[k] except: rv = self.objectids[k] = self.next_objectid self.next_objectid += 1 return defer.succeed(rv) self.master.db.state.getObjectId = getObjectId self.new_config = mock.Mock() self.sm = manager.SchedulerManager(self.master) self.sm.startService() def tearDown(self): if self.sm.running: return self.sm.stopService() class Sched(base.BaseScheduler): # changing sch.attr should make a scheduler look "updated" compare_attrs = base.BaseScheduler.compare_attrs + ( 'attr', ) already_started = False reconfig_count = 0 def startService(self): assert not self.already_started assert self.master is not None assert self.objectid is not None self.already_started = True base.BaseScheduler.startService(self) def stopService(self): d = base.BaseScheduler.stopService(self) def still_set(_): assert self.master is not None assert self.objectid is not None d.addCallback(still_set) return d class ReconfigSched(config.ReconfigurableServiceMixin, Sched): def reconfigService(self, new_config): self.reconfig_count += 1 new_sched = self.findNewSchedulerInstance(new_config) self.attr = new_sched.attr return config.ReconfigurableServiceMixin.reconfigService(self, new_config) class ReconfigSched2(ReconfigSched): pass def makeSched(self, cls, name, attr='alpha'): sch = cls(name=name, builderNames=['x'], properties={}) sch.attr = attr return sch # tests @defer.inlineCallbacks def test_reconfigService_add_and_change_and_remove(self): sch1 = self.makeSched(self.ReconfigSched, 'sch1', attr='alpha') self.new_config.schedulers = dict(sch1=sch1) yield self.sm.reconfigService(self.new_config) self.assertIdentical(sch1.parent, self.sm) self.assertIdentical(sch1.master, self.master) self.assertEqual(sch1.reconfig_count, 1) sch1_new = self.makeSched(self.ReconfigSched, 'sch1', attr='beta') sch2 = self.makeSched(self.ReconfigSched, 'sch2', attr='alpha') self.new_config.schedulers = dict(sch1=sch1_new, sch2=sch2) yield self.sm.reconfigService(self.new_config) # sch1 is still the active scheduler, and has been reconfig'd, # and has the correct attribute self.assertIdentical(sch1.parent, self.sm) self.assertIdentical(sch1.master, self.master) self.assertEqual(sch1.attr, 'beta') self.assertEqual(sch1.reconfig_count, 2) self.assertIdentical(sch1_new.parent, None) self.assertIdentical(sch1_new.master, None) self.assertIdentical(sch2.parent, self.sm) self.assertIdentical(sch2.master, self.master) self.new_config.schedulers = {} yield self.sm.reconfigService(self.new_config) self.assertIdentical(sch1.parent, None) self.assertIdentical(sch1.master, None) @defer.inlineCallbacks def test_reconfigService_class_name_change(self): sch1 = self.makeSched(self.ReconfigSched, 'sch1') self.new_config.schedulers = dict(sch1=sch1) yield self.sm.reconfigService(self.new_config) self.assertIdentical(sch1.parent, self.sm) self.assertIdentical(sch1.master, self.master) self.assertEqual(sch1.reconfig_count, 1) sch1_new = self.makeSched(self.ReconfigSched2, 'sch1') self.new_config.schedulers = dict(sch1=sch1_new) yield self.sm.reconfigService(self.new_config) # sch1 had its class name change, so sch1_new is now the active # instance self.assertIdentical(sch1_new.parent, self.sm) self.assertIdentical(sch1_new.master, self.master) @defer.inlineCallbacks def test_reconfigService_add_and_change_and_remove_no_reconfig(self): sch1 = self.makeSched(self.Sched, 'sch1', attr='alpha') self.new_config.schedulers = dict(sch1=sch1) yield self.sm.reconfigService(self.new_config) self.assertIdentical(sch1.parent, self.sm) self.assertIdentical(sch1.master, self.master) sch1_new = self.makeSched(self.Sched, 'sch1', attr='beta') sch2 = self.makeSched(self.Sched, 'sch2', attr='alpha') self.new_config.schedulers = dict(sch1=sch1_new, sch2=sch2) yield self.sm.reconfigService(self.new_config) # sch1 is not longer active, and sch1_new is self.assertIdentical(sch1.parent, None) self.assertIdentical(sch1.master, None) self.assertIdentical(sch1_new.parent, self.sm) self.assertIdentical(sch1_new.master, self.master) self.assertIdentical(sch2.parent, self.sm) self.assertIdentical(sch2.master, self.master) buildbot-0.8.8/buildbot/test/unit/test_schedulers_timed_Nightly.py000066400000000000000000000217271222546025000255360ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import time import mock from twisted.trial import unittest from twisted.internet import defer, task from twisted.python import log from buildbot.schedulers import timed from buildbot.test.util import scheduler from buildbot.changes import filter from buildbot import config class Nightly(scheduler.SchedulerMixin, unittest.TestCase): OBJECTID = 132 # not all timezones are even multiples of 1h from GMT. This variable # holds the number of seconds ahead of the hour for the current timezone. # This is then added to the clock before each test is run (to get to 0 # minutes past the hour) and subtracted before the time offset is reported. localtime_offset = time.timezone % 3600 def makeScheduler(self, firstBuildDuration=0, **kwargs): sched = self.attachScheduler(timed.Nightly(**kwargs), self.OBJECTID) # add a Clock to help checking timing issues self.clock = sched._reactor = task.Clock() self.clock.advance(self.localtime_offset) # get to 0 min past the hour # keep track of builds in self.events self.events = [] def addBuildsetForLatest(reason='', external_idstring='', branch=None, repository='', project=''): self.assertIn('scheduler named', reason) isFirst = (self.events == []) self.events.append('B(%s)@%d' % (branch, # show the offset as seconds past the GMT hour self.clock.seconds() - self.localtime_offset)) if isFirst and firstBuildDuration: d = defer.Deferred() self.clock.callLater(firstBuildDuration, d.callback, None) return d else: return defer.succeed(None) sched.addBuildsetForLatest = addBuildsetForLatest def addBuildsetForChanges(reason='', external_idstring='', changeids=[]): self.events.append('B%s@%d' % (`changeids`.replace(' ',''), # show the offset as seconds past the GMT hour self.clock.seconds() - self.localtime_offset)) return defer.succeed(None) sched.addBuildsetForChanges = addBuildsetForChanges # see self.assertConsumingChanges self.consumingChanges = None def startConsumingChanges(**kwargs): self.consumingChanges = kwargs return defer.succeed(None) sched.startConsumingChanges = startConsumingChanges return sched def setUp(self): self.setUpScheduler() def tearDown(self): self.tearDownScheduler() def assertConsumingChanges(self, **kwargs): self.assertEqual(self.consumingChanges, kwargs) ## Tests def test_constructor_change_filter(self): sched = self.makeScheduler(name='test', builderNames=['test'], branch=None, change_filter=filter.ChangeFilter(category_re="fo+o")) assert sched.change_filter def test_constructor_no_branch(self): self.assertRaises(config.ConfigErrors, lambda : self.makeScheduler(name='test', builderNames=['test'], change_filter=filter.ChangeFilter(category_re="fo+o"))) ## end-to-end tests: let's see the scheduler in action def test_iterations_simple(self): # note that Nightly works in local time, but the task.Clock() always # starts at midnight UTC, so be careful not to use times that are # timezone dependent -- stick to minutes-past-the-half-hour, as some # timezones are multiples of 30 minutes off from UTC sched = self.makeScheduler(name='test', builderNames=[ 'test' ], branch=None, minute=[10, 20, 21, 40, 50, 51]) # add a change classification self.db.schedulers.fakeClassifications(self.OBJECTID, { 19 : True }) sched.startService() # check that the classification has been flushed, since this # invocation has not requested onlyIfChanged self.db.schedulers.assertClassifications(self.OBJECTID, {}) self.clock.advance(0) # let it get set up while self.clock.seconds() < self.localtime_offset + 30*60: self.clock.advance(60) self.assertEqual(self.events, [ 'B(None)@600', 'B(None)@1200', 'B(None)@1260' ]) self.db.state.assertStateByClass('test', 'Nightly', last_build=1260 + self.localtime_offset) d = sched.stopService() return d def test_iterations_simple_with_branch(self): # see timezone warning above sched = self.makeScheduler(name='test', builderNames=[ 'test' ], branch='master', minute=[5, 35]) sched.startService() self.clock.advance(0) while self.clock.seconds() < self.localtime_offset + 10*60: self.clock.advance(60) self.assertEqual(self.events, [ 'B(master)@300' ]) self.db.state.assertStateByClass('test', 'Nightly', last_build=300 + self.localtime_offset) d = sched.stopService() return d def do_test_iterations_onlyIfChanged(self, *changes_at): fII = mock.Mock(name='fII') sched = self.makeScheduler(name='test', builderNames=[ 'test' ], branch=None, minute=[5, 25, 45], onlyIfChanged=True, fileIsImportant=fII) sched.startService() # check that the scheduler has started to consume changes self.assertConsumingChanges(fileIsImportant=fII, change_filter=None, onlyImportant=False) # manually run the clock forward through a half-hour, allowing any # excitement to take place changes_at = list(changes_at) self.clock.advance(0) # let it trigger the first build while self.clock.seconds() < self.localtime_offset + 30*60: # inject any new changes.. while (changes_at and self.clock.seconds() >= self.localtime_offset + changes_at[0][0]): when, newchange, important = changes_at.pop(0) self.sched.gotChange(newchange, important).addErrback(log.err) # and advance the clock by a minute self.clock.advance(60) def test_iterations_onlyIfChanged_no_changes(self): self.do_test_iterations_onlyIfChanged() self.assertEqual(self.events, []) self.db.state.assertStateByClass('test', 'Nightly', last_build=1500 + self.localtime_offset) return self.sched.stopService() def test_iterations_onlyIfChanged_unimp_changes(self): self.do_test_iterations_onlyIfChanged( (60, mock.Mock(), False), (600, mock.Mock(), False)) self.assertEqual(self.events, []) self.db.state.assertStateByClass('test', 'Nightly', last_build=1500 + self.localtime_offset) return self.sched.stopService() def test_iterations_onlyIfChanged_off_branch_changes(self): self.do_test_iterations_onlyIfChanged( (60, self.makeFakeChange(branch='testing'), True), (1700, self.makeFakeChange(branch='staging'), True)) self.assertEqual(self.events, []) self.db.state.assertStateByClass('test', 'Nightly', last_build=1500 + self.localtime_offset) return self.sched.stopService() def test_iterations_onlyIfChanged_mixed_changes(self): self.do_test_iterations_onlyIfChanged( (120, self.makeFakeChange(number=3, branch=None), False), (130, self.makeFakeChange(number=4, branch='offbranch'), True), (1200, self.makeFakeChange(number=5, branch=None), True), (1201, self.makeFakeChange(number=6, branch=None), False), (1202, self.makeFakeChange(number=7, branch='offbranch'), True)) # note that the changeid list includes the unimportant changes, but not the # off-branch changes, and note that no build took place at 300s, as no important # changes had yet arrived self.assertEqual(self.events, [ 'B[3,5,6]@1500' ]) self.db.state.assertStateByClass('test', 'Nightly', last_build=1500 + self.localtime_offset) return self.sched.stopService() buildbot-0.8.8/buildbot/test/unit/test_schedulers_timed_NightlyBase.py000066400000000000000000000221061222546025000263210ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sys import time from twisted.trial import unittest from twisted.internet import defer from buildbot.schedulers import timed from buildbot.test.util import scheduler from buildbot.test.util import config class NightlyBase(scheduler.SchedulerMixin, unittest.TestCase): """detailed getNextBuildTime tests""" OBJECTID = 133 def makeScheduler(self, firstBuildDuration=0, **kwargs): return self.attachScheduler(timed.NightlyBase(**kwargs), self.OBJECTID) @defer.inlineCallbacks def do_getNextBuildTime_test(self, sched, *expectations): for lastActuated, expected in expectations: # convert from tuples to epoch time (in local timezone) lastActuated_ep, expected_ep = [ time.mktime(t + (0,) * (8 - len(t)) + (-1,)) for t in (lastActuated, expected) ] got_ep = yield sched.getNextBuildTime(lastActuated_ep) self.assertEqual(got_ep, expected_ep, "%s -> %s != %s" % (lastActuated, time.localtime(got_ep), expected)) def test_getNextBuildTime_hourly(self): sched = self.makeScheduler(name='test', builderNames=['test']) return self.do_getNextBuildTime_test(sched, ((2011, 1, 1, 3, 0, 0), (2011, 1, 1, 4, 0, 0)), ((2011, 1, 1, 3, 15, 0), (2011, 1, 1, 4, 0, 0)), ((2011, 1, 1, 3, 15, 1), (2011, 1, 1, 4, 0, 0)), ((2011, 1, 1, 3, 59, 1), (2011, 1, 1, 4, 0, 0)), ((2011, 1, 1, 3, 59, 59), (2011, 1, 1, 4, 0, 0)), ((2011, 1, 1, 23, 22, 22), (2011, 1, 2, 0, 0, 0)), ((2011, 1, 1, 23, 59, 0), (2011, 1, 2, 0, 0, 0)), ) def test_getNextBuildTime_minutes_single(self): # basically the same as .._hourly sched = self.makeScheduler(name='test', builderNames=['test'], minute=4) return self.do_getNextBuildTime_test(sched, ((2011, 1, 1, 3, 0, 0), (2011, 1, 1, 3, 4, 0)), ((2011, 1, 1, 3, 15, 0), (2011, 1, 1, 4, 4, 0)), ) def test_getNextBuildTime_minutes_multiple(self): sched = self.makeScheduler(name='test', builderNames=['test'], minute=[4, 34]) return self.do_getNextBuildTime_test(sched, ((2011, 1, 1, 3, 0, 0), (2011, 1, 1, 3, 4, 0)), ((2011, 1, 1, 3, 15, 0), (2011, 1, 1, 3, 34, 0)), ((2011, 1, 1, 3, 34, 0), (2011, 1, 1, 4, 4, 0)), ((2011, 1, 1, 3, 59, 1), (2011, 1, 1, 4, 4, 0)), ) def test_getNextBuildTime_minutes_star(self): sched = self.makeScheduler(name='test', builderNames=['test'], minute='*') return self.do_getNextBuildTime_test(sched, ((2011, 1, 1, 3, 11, 30), (2011, 1, 1, 3, 12, 0)), ((2011, 1, 1, 3, 12, 0), (2011, 1, 1, 3, 13, 0)), ((2011, 1, 1, 3, 59, 0), (2011, 1, 1, 4, 0, 0)), ) def test_getNextBuildTime_hours_single(self): sched = self.makeScheduler(name='test', builderNames=['test'], hour=4) return self.do_getNextBuildTime_test(sched, ((2011, 1, 1, 3, 0), (2011, 1, 1, 4, 0)), ((2011, 1, 1, 13, 0), (2011, 1, 2, 4, 0)), ) def test_getNextBuildTime_hours_multiple(self): sched = self.makeScheduler(name='test', builderNames=['test'], hour=[7, 19]) return self.do_getNextBuildTime_test(sched, ((2011, 1, 1, 3, 0), (2011, 1, 1, 7, 0)), ((2011, 1, 1, 7, 1), (2011, 1, 1, 19, 0)), ((2011, 1, 1, 18, 59), (2011, 1, 1, 19, 0)), ((2011, 1, 1, 19, 59), (2011, 1, 2, 7, 0)), ) def test_getNextBuildTime_hours_minutes(self): sched = self.makeScheduler(name='test', builderNames=['test'], hour=13, minute=19) return self.do_getNextBuildTime_test(sched, ((2011, 1, 1, 3, 11), (2011, 1, 1, 13, 19)), ((2011, 1, 1, 13, 19), (2011, 1, 2, 13, 19)), ((2011, 1, 1, 23, 59), (2011, 1, 2, 13, 19)), ) def test_getNextBuildTime_month_single(self): sched = self.makeScheduler(name='test', builderNames=['test'], month=3) return self.do_getNextBuildTime_test(sched, ((2011, 2, 27, 3, 11), (2011, 3, 1, 0, 0)), ((2011, 3, 1, 1, 11), (2011, 3, 1, 2, 0)), # still hourly! ) def test_getNextBuildTime_month_multiple(self): sched = self.makeScheduler(name='test', builderNames=['test'], month=[4, 6]) return self.do_getNextBuildTime_test(sched, ((2011, 3, 30, 3, 11), (2011, 4, 1, 0, 0)), ((2011, 4, 1, 1, 11), (2011, 4, 1, 2, 0)), # still hourly! ((2011, 5, 29, 3, 11), (2011, 6, 1, 0, 0)), ) def test_getNextBuildTime_month_dayOfMonth(self): sched = self.makeScheduler(name='test', builderNames=['test'], month=[3, 6], dayOfMonth=[15]) return self.do_getNextBuildTime_test(sched, ((2011, 2, 12, 3, 11), (2011, 3, 15, 0, 0)), ((2011, 3, 12, 3, 11), (2011, 3, 15, 0, 0)), ) def test_getNextBuildTime_dayOfMonth_single(self): sched = self.makeScheduler(name='test', builderNames=['test'], dayOfMonth=10) return self.do_getNextBuildTime_test(sched, ((2011, 1, 9, 3, 0), (2011, 1, 10, 0, 0)), ((2011, 1, 10, 3, 0), (2011, 1, 10, 4, 0)), # still hourly! ((2011, 1, 30, 3, 0), (2011, 2, 10, 0, 0)), ((2011, 12, 30, 11, 0), (2012, 1, 10, 0, 0)), ) def test_getNextBuildTime_dayOfMonth_multiple(self): sched = self.makeScheduler(name='test', builderNames=['test'], dayOfMonth=[10, 20, 30]) return self.do_getNextBuildTime_test(sched, ((2011, 1, 9, 22, 0), (2011, 1, 10, 0, 0)), ((2011, 1, 19, 22, 0), (2011, 1, 20, 0, 0)), ((2011, 1, 29, 22, 0), (2011, 1, 30, 0, 0)), ((2011, 2, 29, 22, 0), (2011, 3, 10, 0, 0)), # no Feb 30! ) def test_getNextBuildTime_dayOfMonth_hours_minutes(self): sched = self.makeScheduler(name='test', builderNames=['test'], dayOfMonth=15, hour=20, minute=30) return self.do_getNextBuildTime_test(sched, ((2011, 1, 13, 22, 19), (2011, 1, 15, 20, 30)), ((2011, 1, 15, 19, 19), (2011, 1, 15, 20, 30)), ((2011, 1, 15, 20, 29), (2011, 1, 15, 20, 30)), ) def test_getNextBuildTime_dayOfWeek_single(self): sched = self.makeScheduler(name='test', builderNames=['test'], dayOfWeek=1) # Tuesday (2011-1-1 was a Saturday) return self.do_getNextBuildTime_test(sched, ((2011, 1, 3, 22, 19), (2011, 1, 4, 0, 0)), ((2011, 1, 4, 19, 19), (2011, 1, 4, 20, 0)), # still hourly! ) def test_getNextBuildTime_dayOfWeek_multiple_hours(self): sched = self.makeScheduler(name='test', builderNames=['test'], dayOfWeek=[1,3], hour=1) # Tuesday, Thursday (2011-1-1 was a Saturday) return self.do_getNextBuildTime_test(sched, ((2011, 1, 3, 22, 19), (2011, 1, 4, 1, 0)), ((2011, 1, 4, 22, 19), (2011, 1, 6, 1, 0)), ) def test_getNextBuildTime_dayOfWeek_dayOfMonth(self): sched = self.makeScheduler(name='test', builderNames=['test'], dayOfWeek=[1,4], dayOfMonth=5, hour=1) return self.do_getNextBuildTime_test(sched, ((2011, 1, 3, 22, 19), (2011, 1, 4, 1, 0)), # Tues ((2011, 1, 4, 22, 19), (2011, 1, 5, 1, 0)), # 5th ((2011, 1, 5, 22, 19), (2011, 1, 7, 1, 0)), # Thurs ) class NightlyCroniterImport(config.ConfigErrorsMixin, unittest.TestCase): def setUp(self): self.savedModules = sys.modules.copy() def tearDown(self): sys.modules.clear() sys.modules.update(self.savedModules) def test_error_without_dateutil(self): del sys.modules['buildbot.schedulers.timed'] del sys.modules['buildbot.util'] sys.modules["dateutil.relativedelta"] = None from buildbot.schedulers.timed import NightlyBase self.assertRaisesConfigError("python-dateutil", lambda: NightlyBase(name='name', builderNames=[])) buildbot-0.8.8/buildbot/test/unit/test_schedulers_timed_NightlyTriggerable.py000066400000000000000000000267211222546025000277050ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.internet import task from buildbot.process import properties from buildbot.schedulers import timed from buildbot.test.fake import fakedb from buildbot.test.util import scheduler class NightlyTriggerable(scheduler.SchedulerMixin, unittest.TestCase): SCHEDULERID = 1327 def makeScheduler(self, firstBuildDuration=0, **kwargs): sched = self.attachScheduler(timed.NightlyTriggerable(**kwargs), self.SCHEDULERID) # add a Clock to help checking timing issues self.clock = sched._reactor = task.Clock() return sched def setUp(self): self.setUpScheduler() def tearDown(self): self.tearDownScheduler() def test_timer_noBuilds(self): sched = self.makeScheduler(name='test', builderNames=['test'], minute=[5]) sched.startService() self.clock.advance(60*60) # Run for 1h self.db.buildsets.assertBuildsets(0) def test_timer_oneTrigger(self): sched = self.makeScheduler(name='test', builderNames=['test'], minute=[5], codebases={'cb':{'repository':'annoying'}}) sched.startService() sched.trigger({'cb': dict(revision='myrev', branch='br', project='p', repository='r'), }, set_props=None) self.clock.advance(60*60) # Run for 1h self.db.buildsets.assertBuildset('?', dict(external_idstring=None, properties=[ ('scheduler', ('test', 'Scheduler')), ], reason="The NightlyTriggerable scheduler named 'test' triggered this build", sourcestampsetid=100), {'cb': dict(branch='br', project='p', repository='r', codebase='cb', revision='myrev', sourcestampsetid=100) }) def test_timer_twoTriggers(self): sched = self.makeScheduler(name='test', builderNames=['test'], minute=[5], codebases={'cb':{'repository':'annoying'}}) sched.startService() sched.trigger({ 'cb': dict(revision='myrev1', branch='br', project='p', repository='r') } , set_props=None) sched.trigger({ 'cb': dict(revision='myrev2', branch='br', project='p', repository='r') } , set_props=None) self.clock.advance(60*60) # Run for 1h self.db.buildsets.assertBuildset('?', dict(external_idstring=None, properties=[ ('scheduler', ('test', 'Scheduler')), ], reason="The NightlyTriggerable scheduler named 'test' triggered this build", sourcestampsetid=100), {'cb': dict(branch='br', project='p', repository='r', codebase='cb', revision='myrev2', sourcestampsetid=100) }) def test_timer_oneTrigger_then_noBuild(self): sched = self.makeScheduler(name='test', builderNames=['test'], minute=[5], codebases={'cb':{'repository':'annoying'}}) sched.startService() sched.trigger({ 'cb': dict(revision='myrev', branch='br', project='p', repository='r') } , set_props=None) self.clock.advance(60*60) # Run for 1h self.db.buildsets.assertBuildset('?', dict(external_idstring=None, properties=[ ('scheduler', ('test', 'Scheduler')), ], reason="The NightlyTriggerable scheduler named 'test' triggered this build", sourcestampsetid=100), {'cb': dict(branch='br', project='p', repository='r', codebase='cb', revision='myrev', sourcestampsetid=100) }) self.db.buildsets.flushBuildsets() self.clock.advance(60*60) # Run for 1h self.db.buildsets.assertBuildsets(0) def test_timer_oneTriggers_then_oneTrigger(self): sched = self.makeScheduler(name='test', builderNames=['test'], minute=[5], codebases={'cb':{'repository':'annoying'}}) sched.startService() sched.trigger({ 'cb': dict(revision='myrev1', branch='br', project='p', repository='r') } , set_props=None) self.clock.advance(60*60) # Run for 1h self.db.buildsets.assertBuildset('?', dict(external_idstring=None, properties=[ ('scheduler', ('test', 'Scheduler')), ], reason="The NightlyTriggerable scheduler named 'test' triggered this build", sourcestampsetid=100), {'cb': dict(branch='br', project='p', repository='r', codebase='cb', revision='myrev1', sourcestampsetid=100) }) self.db.buildsets.flushBuildsets() sched.trigger({ 'cb': dict(revision='myrev2', branch='br', project='p', repository='r') } , set_props=None) self.clock.advance(60*60) # Run for 1h self.db.buildsets.assertBuildset('?', dict(external_idstring=None, properties=[ ('scheduler', ('test', 'Scheduler')), ], reason="The NightlyTriggerable scheduler named 'test' triggered this build", sourcestampsetid=101), {'cb': dict(branch='br', project='p', repository='r', codebase='cb', revision='myrev2', sourcestampsetid=101) }) def test_savedTrigger(self): sched = self.makeScheduler(name='test', builderNames=['test'], minute=[5], codebases={'cb':{'repository':'annoying'}}) self.db.insertTestData([ fakedb.Object(id=self.SCHEDULERID, name='test', class_name='NightlyTriggerable'), fakedb.ObjectState(objectid=self.SCHEDULERID, name='lastTrigger', value_json='[ {"cb": {"project": "p", "repository": "r", "branch": "br", "revision": "myrev"}} , {} ]'), ]) sched.startService() self.clock.advance(60*60) # Run for 1h self.db.buildsets.assertBuildset('?', dict(external_idstring=None, properties=[ ('scheduler', ('test', 'Scheduler')), ], reason="The NightlyTriggerable scheduler named 'test' triggered this build", sourcestampsetid=100), {'cb': dict(branch='br', project='p', repository='r', codebase='cb', revision='myrev', sourcestampsetid=100) }) def test_saveTrigger(self): sched = self.makeScheduler(name='test', builderNames=['test'], minute=[5], codebases={'cb':{'repository':'annoying'}}) self.db.insertTestData([ fakedb.Object(id=self.SCHEDULERID, name='test', class_name='NightlyTriggerable'), ]) sched.startService() d = sched.trigger({'cb': dict(revision='myrev', branch='br', project='p', repository='r'), }, set_props=None) @d.addCallback def cb(_): self.db.state.assertState(self.SCHEDULERID, lastTrigger=[{'cb': dict(revision='myrev', branch='br', project='p', repository='r'), }, {}]) return d def test_saveTrigger_noTrigger(self): sched = self.makeScheduler(name='test', builderNames=['test'], minute=[5], codebases={'cb':{'repository':'annoying'}}) self.db.insertTestData([ fakedb.Object(id=self.SCHEDULERID, name='test', class_name='NightlyTriggerable'), ]) sched.startService() d = sched.trigger({'cb': dict(revision='myrev', branch='br', project='p', repository='r'), }, set_props=None) self.clock.advance(60*60) # Run for 1h @d.addCallback def cb(_): self.db.state.assertState(self.SCHEDULERID, lastTrigger=None) return d def test_triggerProperties(self): sched = self.makeScheduler(name='test', builderNames=['test'], minute=[5], codebases={'cb':{'repository':'annoying'}}) self.db.insertTestData([ fakedb.Object(id=self.SCHEDULERID, name='test', class_name='NightlyTriggerable'), ]) sched.startService() sched.trigger({'cb': dict(revision='myrev', branch='br', project='p', repository='r'), }, properties.Properties(testprop='test')) self.db.state.assertState(self.SCHEDULERID, lastTrigger=[{'cb': dict(revision='myrev', branch='br', project='p', repository='r'), }, {'testprop': ['test', 'TEST']}]) self.clock.advance(60*60) # Run for 1h self.db.buildsets.assertBuildset('?', dict(external_idstring=None, properties=[ ('scheduler', ('test', 'Scheduler')), ('testprop', ('test', 'TEST')), ], reason="The NightlyTriggerable scheduler named 'test' triggered this build", sourcestampsetid=100), {'cb': dict(branch='br', project='p', repository='r', codebase='cb', revision='myrev', sourcestampsetid=100) }) def test_savedProperties(self): sched = self.makeScheduler(name='test', builderNames=['test'], minute=[5], codebases={'cb':{'repository':'annoying'}}) self.db.insertTestData([ fakedb.Object(id=self.SCHEDULERID, name='test', class_name='NightlyTriggerable'), fakedb.ObjectState(objectid=self.SCHEDULERID, name='lastTrigger', value_json='[ {"cb": {"project": "p", "repository": "r", "branch": "br", "revision": "myrev"}} , {"testprop": ["test", "TEST"]} ]'), ]) sched.startService() self.clock.advance(60*60) # Run for 1h self.db.buildsets.assertBuildset('?', dict(external_idstring=None, properties=[ ('scheduler', ('test', 'Scheduler')), ('testprop', ('test', 'TEST')), ], reason="The NightlyTriggerable scheduler named 'test' triggered this build", sourcestampsetid=100), {'cb': dict(branch='br', project='p', repository='r', codebase='cb', revision='myrev', sourcestampsetid=100) }) buildbot-0.8.8/buildbot/test/unit/test_schedulers_timed_Periodic.py000066400000000000000000000150041222546025000256450ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.internet import task, defer from buildbot.schedulers import timed from buildbot import config class Periodic(unittest.TestCase): def makeScheduler(self, firstBuildDuration=0, exp_branch=None, **kwargs): self.sched = sched = timed.Periodic(**kwargs) # add a Clock to help checking timing issues self.clock = sched._reactor = task.Clock() # keep track of builds in self.events self.events = [] def addBuildsetForLatest(reason=None, branch=None): self.assertIn('Periodic scheduler named', reason) self.assertEqual(branch, exp_branch) isFirst = (self.events == []) self.events.append('B@%d' % self.clock.seconds()) if isFirst and firstBuildDuration: d = defer.Deferred() self.clock.callLater(firstBuildDuration, d.callback, None) return d else: return defer.succeed(None) sched.addBuildsetForLatest = addBuildsetForLatest # handle state locally self.state = {} def getState(k, default): return defer.succeed(self.state.get(k, default)) sched.getState = getState def setState(k, v): self.state[k] = v return defer.succeed(None) sched.setState = setState return sched # tests def test_constructor_invalid(self): self.assertRaises(config.ConfigErrors, lambda : timed.Periodic(name='test', builderNames=[ 'test' ], periodicBuildTimer=-2)) def test_iterations_simple(self): sched = self.makeScheduler(name='test', builderNames=[ 'test' ], periodicBuildTimer=13) sched.startService() self.clock.advance(0) # let it trigger the first build while self.clock.seconds() < 30: self.clock.advance(1) self.assertEqual(self.events, [ 'B@0', 'B@13', 'B@26' ]) self.assertEqual(self.state.get('last_build'), 26) d = sched.stopService() return d def test_iterations_simple_branch(self): sched = self.makeScheduler(exp_branch='newfeature', name='test', builderNames=[ 'test' ], periodicBuildTimer=13, branch='newfeature') sched.startService() self.clock.advance(0) # let it trigger the first build while self.clock.seconds() < 30: self.clock.advance(1) self.assertEqual(self.events, [ 'B@0', 'B@13', 'B@26' ]) self.assertEqual(self.state.get('last_build'), 26) d = sched.stopService() return d def test_iterations_long(self): sched = self.makeScheduler(name='test', builderNames=[ 'test' ], periodicBuildTimer=10, firstBuildDuration=15) # takes a while to start a build sched.startService() self.clock.advance(0) # let it trigger the first (longer) build while self.clock.seconds() < 40: self.clock.advance(1) self.assertEqual(self.events, [ 'B@0', 'B@15', 'B@25', 'B@35' ]) self.assertEqual(self.state.get('last_build'), 35) d = sched.stopService() return d def test_iterations_stop_while_starting_build(self): sched = self.makeScheduler(name='test', builderNames=[ 'test' ], periodicBuildTimer=13, firstBuildDuration=6) # takes a while to start a build sched.startService() self.clock.advance(0) # let it trigger the first (longer) build self.clock.advance(3) # get partway into that build d = sched.stopService() # begin stopping the service d.addCallback(lambda _ : self.events.append('STOP@%d' % self.clock.seconds())) # run the clock out while self.clock.seconds() < 40: self.clock.advance(1) # note that the stopService completes after the first build completes, and no # subsequent builds occur self.assertEqual(self.events, [ 'B@0', 'STOP@6' ]) self.assertEqual(self.state.get('last_build'), 0) return d def test_iterations_with_initial_state(self): sched = self.makeScheduler(name='test', builderNames=[ 'test' ], periodicBuildTimer=13) self.state['last_build'] = self.clock.seconds() - 7 # so next build should start in 6s sched.startService() self.clock.advance(0) # let it trigger the first build while self.clock.seconds() < 30: self.clock.advance(1) self.assertEqual(self.events, [ 'B@6', 'B@19' ]) self.assertEqual(self.state.get('last_build'), 19) d = sched.stopService() return d def test_getNextBuildTime_None(self): sched = self.makeScheduler(name='test', builderNames=[ 'test' ], periodicBuildTimer=13) # given None, build right away d = sched.getNextBuildTime(None) d.addCallback(lambda t : self.assertEqual(t, 0)) return d def test_getNextBuildTime_given(self): sched = self.makeScheduler(name='test', builderNames=[ 'test' ], periodicBuildTimer=13) # given a time, add the periodicBuildTimer to it d = sched.getNextBuildTime(20) d.addCallback(lambda t : self.assertEqual(t, 33)) return d def test_getPendingBuildTimes(self): sched = self.makeScheduler(name='test', builderNames=[ 'test' ], periodicBuildTimer=13) self.state['last_build'] = self.clock.seconds() - 10 # so next build should start in 3s sched.startService() self.clock.advance(0) # let it schedule the first build self.assertEqual(sched.getPendingBuildTimes(), [ 3.0 ]) d = sched.stopService() return d buildbot-0.8.8/buildbot/test/unit/test_schedulers_timed_Timed.py000066400000000000000000000042161222546025000251540ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.internet import task, defer from buildbot.schedulers import timed from buildbot.test.util import scheduler class Timed(scheduler.SchedulerMixin, unittest.TestCase): OBJECTID = 928754 def setUp(self): self.setUpScheduler() def tearDown(self): self.tearDownScheduler() class Subclass(timed.Timed): def getNextBuildTime(self, lastActuation): self.got_lastActuation = lastActuation return defer.succeed((lastActuation or 1000) + 60) def startBuild(self): self.started_build = True return defer.succeed(None) def makeScheduler(self, firstBuildDuration=0, **kwargs): sched = self.attachScheduler(self.Subclass(**kwargs), self.OBJECTID) self.clock = sched._reactor = task.Clock() return sched # tests # note that most of the heavy-lifting for testing this class is handled by # the subclasses' tests, as that's the more natural place for it def test_getPendingBuildTimes(self): sched = self.makeScheduler(name='test', builderNames=['foo']) sched.startService() self.assertEqual(sched.got_lastActuation, None) self.assertEqual(sched.getPendingBuildTimes(), [ 1060 ]) self.clock.advance(1065) self.assertTrue(sched.started_build) self.assertEqual(sched.getPendingBuildTimes(), [ 1120 ]) d = sched.stopService() return d buildbot-0.8.8/buildbot/test/unit/test_schedulers_triggerable.py000066400000000000000000000217231222546025000252210ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.schedulers import triggerable from buildbot.process import properties from buildbot.test.util import scheduler class Triggerable(scheduler.SchedulerMixin, unittest.TestCase): OBJECTID = 33 def setUp(self): self.setUpScheduler() self.subscription = None def tearDown(self): self.tearDownScheduler() def makeScheduler(self, **kwargs): sched = self.attachScheduler( triggerable.Triggerable(name='n', builderNames=['b'], **kwargs), self.OBJECTID) return sched # tests # NOTE: these tests take advantage of the fact that all of the fake # scheduler operations are synchronous, and thus do not return a Deferred. # The Deferred from trigger() is completely processed before this test # method returns. def test_trigger(self): sched = self.makeScheduler(codebases = {'cb':{'repository':'r'}}) # no subscription should be in place yet callbacks = self.master.getSubscriptionCallbacks() self.assertEqual(callbacks['buildset_completion'], None) # trigger the scheduler, exercising properties while we're at it set_props = properties.Properties() set_props.setProperty('pr', 'op', 'test') ss = {'revision':'myrev', 'branch':'br', 'project':'p', 'repository':'r', 'codebase':'cb' } d = sched.trigger({'cb': ss}, set_props=set_props) bsid = self.db.buildsets.assertBuildset('?', dict(external_idstring=None, properties=[ ('pr', ('op', 'test')), ('scheduler', ('n', 'Scheduler')), ], reason='Triggerable(n)', sourcestampsetid=100), {'cb': dict(branch='br', project='p', repository='r', codebase='cb', revision='myrev', sourcestampsetid=100) }) # set up a boolean so that we can know when the deferred fires self.fired = False def fired((result, brids)): self.assertEqual(result, 13) # 13 comes from the result below self.assertEqual(brids, self.db.buildsets.allBuildRequests(bsid)) self.fired = True d.addCallback(fired) # check that the scheduler has subscribed to buildset changes, but # not fired yet callbacks = self.master.getSubscriptionCallbacks() self.assertNotEqual(callbacks['buildset_completion'], None) self.assertFalse(self.fired) # pretend a non-matching buildset is complete callbacks['buildset_completion'](bsid+27, 3) # scheduler should not have reacted callbacks = self.master.getSubscriptionCallbacks() self.assertNotEqual(callbacks['buildset_completion'], None) self.assertFalse(self.fired) # pretend the matching buildset is complete callbacks['buildset_completion'](bsid, 13) # scheduler should have reacted callbacks = self.master.getSubscriptionCallbacks() self.assertEqual(callbacks['buildset_completion'], None) self.assertTrue(self.fired) def test_trigger_overlapping(self): sched = self.makeScheduler(codebases = {'cb':{'repository':'r'}}) # no subscription should be in place yet callbacks = self.master.getSubscriptionCallbacks() self.assertEqual(callbacks['buildset_completion'], None) # define sourcestamp ss = {'revision':'myrev1', 'branch':'br', 'project':'p', 'repository':'r', 'codebase':'cb' } # trigger the scheduler the first time d = sched.trigger({'cb':ss}) bsid1 = self.db.buildsets.assertBuildset('?', dict(external_idstring=None, properties=[('scheduler', ('n', 'Scheduler'))], reason='Triggerable(n)', sourcestampsetid=100), {'cb': dict(branch='br', project='p', repository='r', codebase='cb', revision='myrev1', sourcestampsetid=100)}) d.addCallback(lambda (res, brids) : self.assertEqual(res, 11) and self.assertEqual(brids, self.db.buildsets.allBuildRequests(bsid1))) # define sourcestamp ss = {'revision':'myrev2', 'branch':'br', 'project':'p', 'repository':'r', 'codebase':'cb' } # and the second time d = sched.trigger({'cb':ss}) bsid2 = self.db.buildsets.assertBuildset(bsid1+1, # assumes bsid's are sequential dict(external_idstring=None, properties=[('scheduler', ('n', 'Scheduler'))], reason='Triggerable(n)', sourcestampsetid=101), {'cb': dict(branch='br', project='p', repository='r', codebase='cb', revision='myrev2', sourcestampsetid=101)}) d.addCallback(lambda (res, brids) : self.assertEqual(res, 22) and self.assertEqual(brids, self.db.buildsets.allBuildRequests(bsid2))) # check that the scheduler has subscribed to buildset changes callbacks = self.master.getSubscriptionCallbacks() self.assertNotEqual(callbacks['buildset_completion'], None) # let a few buildsets complete callbacks['buildset_completion'](bsid2+27, 3) callbacks['buildset_completion'](bsid2, 22) callbacks['buildset_completion'](bsid2+7, 3) callbacks['buildset_completion'](bsid1, 11) # both should have triggered with appropriate results, and the # subscription should be cancelled callbacks = self.master.getSubscriptionCallbacks() self.assertEqual(callbacks['buildset_completion'], None) def test_trigger_with_unknown_sourcestamp(self): # Test a scheduler with 2 repositories. # Trigger the scheduler with a sourcestamp that is unknown to the scheduler # Expected Result: # sourcestamp 1 for repository 1 based on configured sourcestamp # sourcestamp 2 for repository 2 based on configured sourcestamp sched = self.makeScheduler( codebases = {'cb':{'repository':'r', 'branch': 'branchX'}, 'cb2':{'repository':'r2', 'branch': 'branchX'},}) ss = {'repository': 'r3', 'codebase': 'cb3', 'revision': 'fixrev3', 'branch': 'default', 'project': 'p' } sched.trigger(sourcestamps = {'cb3': ss}) self.db.buildsets.assertBuildset('?', dict(external_idstring=None, properties=[('scheduler', ('n', 'Scheduler'))], reason='Triggerable(n)', sourcestampsetid=100), {'cb': dict(branch='branchX', project='', repository='r', codebase='cb', revision=None, sourcestampsetid=100), 'cb2': dict(branch='branchX', project='', repository='r2', codebase='cb2', revision=None, sourcestampsetid=100),}) def test_trigger_without_sourcestamps(self): # Test a scheduler with 2 repositories. # Trigger the scheduler without a sourcestamp # Expected Result: # sourcestamp 1 for repository 1 based on configured sourcestamp # sourcestamp 2 for repository 2 based on configured sourcestamp sched = self.makeScheduler( codebases = {'cb':{'repository':'r', 'branch': 'branchX'}, 'cb2':{'repository':'r2', 'branch': 'branchX'},}) sched.trigger(sourcestamps = None) self.db.buildsets.assertBuildset('?', dict(external_idstring=None, properties=[('scheduler', ('n', 'Scheduler'))], reason='Triggerable(n)', sourcestampsetid=100), {'cb': dict(branch='branchX', project='', repository='r', codebase='cb', revision=None, sourcestampsetid=100), 'cb2': dict(branch='branchX', project='', repository='r2', codebase='cb2', revision=None, sourcestampsetid=100),})buildbot-0.8.8/buildbot/test/unit/test_schedulers_trysched.py000066400000000000000000000672221222546025000245630ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import mock import os import shutil import cStringIO as StringIO import sys import twisted from twisted.internet import defer from twisted.trial import unittest from twisted.protocols import basic from buildbot.schedulers import trysched from buildbot.test.util import dirs from buildbot.test.util import scheduler from buildbot.util import json class TryBase(unittest.TestCase): def test_filterBuilderList_ok(self): sched = trysched.TryBase( name='tsched', builderNames=['a', 'b', 'c'], properties={}) self.assertEqual(sched.filterBuilderList(['b', 'c']), ['b', 'c']) def test_filterBuilderList_bad(self): sched = trysched.TryBase( name='tsched', builderNames=['a', 'b'], properties={}) self.assertEqual(sched.filterBuilderList(['b', 'c']), []) def test_filterBuilderList_empty(self): sched = trysched.TryBase( name='tsched', builderNames=['a', 'b'], properties={}) self.assertEqual(sched.filterBuilderList([]), ['a', 'b']) class JobdirService(dirs.DirsMixin, unittest.TestCase): def setUp(self): self.jobdir = 'jobdir' self.newdir = os.path.join(self.jobdir, 'new') self.curdir = os.path.join(self.jobdir, 'cur') self.tmpdir = os.path.join(self.jobdir, 'tmp') self.setUpDirs(self.jobdir, self.newdir, self.curdir, self.tmpdir) def tearDown(self): self.tearDownDirs() def test_messageReceived(self): svc = trysched.JobdirService(self.jobdir) # creat some new data to process jobdata = os.path.join(self.newdir, 'jobdata') with open(jobdata, "w") as f: f.write('JOBDATA') # stub out svc.parent.handleJobFile and .jobdir def handleJobFile(filename, f): self.assertEqual(filename, 'jobdata') self.assertEqual(f.read(), 'JOBDATA') svc.parent = mock.Mock() svc.parent.handleJobFile = handleJobFile svc.parent.jobdir = self.jobdir # run it svc.messageReceived('jobdata') class Try_Jobdir(scheduler.SchedulerMixin, unittest.TestCase): OBJECTID = 23 def setUp(self): self.setUpScheduler() self.jobdir = None def tearDown(self): self.tearDownScheduler() if self.jobdir: shutil.rmtree(self.jobdir) # tests def do_test_startService(self, jobdir, exp_jobdir): # set up jobdir self.jobdir = os.path.abspath('jobdir') if os.path.exists(self.jobdir): shutil.rmtree(self.jobdir) os.mkdir(self.jobdir) # build scheduler kwargs = dict(name="tsched", builderNames=['a'], jobdir=self.jobdir) sched = self.attachScheduler( trysched.Try_Jobdir(**kwargs), self.OBJECTID) # start it sched.startService() # check that it has set the basedir correctly self.assertEqual(sched.watcher.basedir, self.jobdir) return sched.stopService() def test_startService_reldir(self): return self.do_test_startService( 'jobdir', os.path.abspath('basedir/jobdir')) def test_startService_reldir_subdir(self): return self.do_test_startService( 'jobdir', os.path.abspath('basedir/jobdir/cur')) def test_startService_absdir(self): return self.do_test_startService( os.path.abspath('jobdir'), os.path.abspath('jobdir')) # parseJob def test_parseJob_empty(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['a'], jobdir='foo') self.assertRaises( trysched.BadJobfile, sched.parseJob, StringIO.StringIO('')) def test_parseJob_longer_than_netstring_MAXLENGTH(self): self.patch(basic.NetstringReceiver, 'MAX_LENGTH', 100) sched = trysched.Try_Jobdir(name='tsched', builderNames=['a'], jobdir='foo') jobstr = self.makeNetstring( '1', 'extid', 'trunk', '1234', '1', 'this is my diff, -- ++, etc.', 'buildera', 'builderc' ) jobstr += 'x' * 200 test_temp_file = StringIO.StringIO(jobstr) self.assertRaises(trysched.BadJobfile, lambda : sched.parseJob(test_temp_file)) def test_parseJob_invalid(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['a'], jobdir='foo') self.assertRaises( trysched.BadJobfile, sched.parseJob, StringIO.StringIO('this is not a netstring')) def test_parseJob_invalid_version(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['a'], jobdir='foo') self.assertRaises( trysched.BadJobfile, sched.parseJob, StringIO.StringIO('1:9,')) def makeNetstring(self, *strings): return ''.join(['%d:%s,' % (len(s), s) for s in strings]) def test_parseJob_v1(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( '1', 'extid', 'trunk', '1234', '1', 'this is my diff, -- ++, etc.', 'buildera', 'builderc' ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob, { 'baserev': '1234', 'branch': 'trunk', 'builderNames': ['buildera', 'builderc'], 'jobid': 'extid', 'patch_body': 'this is my diff, -- ++, etc.', 'patch_level': 1, 'project': '', 'who': '', 'comment': '', 'repository': '', 'properties': {}, }) def test_parseJob_v1_empty_branch_rev(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( # blank branch, rev are turned to None '1', 'extid', '', '', '1', 'this is my diff, -- ++, etc.', 'buildera', 'builderc' ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob['branch'], None) self.assertEqual(parsedjob['baserev'], None) def test_parseJob_v1_no_builders(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( '1', 'extid', '', '', '1', 'this is my diff, -- ++, etc.' ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob['builderNames'], []) def test_parseJob_v1_no_properties(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( '1', 'extid', '', '', '1', 'this is my diff, -- ++, etc.' ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob['properties'], {}) def test_parseJob_v2(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( '2', 'extid', 'trunk', '1234', '1', 'this is my diff, -- ++, etc.', 'repo', 'proj', 'buildera', 'builderc' ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob, { 'baserev': '1234', 'branch': 'trunk', 'builderNames': ['buildera', 'builderc'], 'jobid': 'extid', 'patch_body': 'this is my diff, -- ++, etc.', 'patch_level': 1, 'project': 'proj', 'who': '', 'comment': '', 'repository': 'repo', 'properties': {}, }) def test_parseJob_v2_empty_branch_rev(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( # blank branch, rev are turned to None '2', 'extid', '', '', '1', 'this is my diff, -- ++, etc.', 'repo', 'proj', 'buildera', 'builderc' ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob['branch'], None) self.assertEqual(parsedjob['baserev'], None) def test_parseJob_v2_no_builders(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( '2', 'extid', 'trunk', '1234', '1', 'this is my diff, -- ++, etc.', 'repo', 'proj', ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob['builderNames'], []) def test_parseJob_v2_no_properties(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( '2', 'extid', 'trunk', '1234', '1', 'this is my diff, -- ++, etc.', 'repo', 'proj', ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob['properties'], {}) def test_parseJob_v3(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( '3', 'extid', 'trunk', '1234', '1', 'this is my diff, -- ++, etc.', 'repo', 'proj', 'who', 'buildera', 'builderc' ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob, { 'baserev': '1234', 'branch': 'trunk', 'builderNames': ['buildera', 'builderc'], 'jobid': 'extid', 'patch_body': 'this is my diff, -- ++, etc.', 'patch_level': 1, 'project': 'proj', 'who': 'who', 'comment': '', 'repository': 'repo', 'properties': {}, }) def test_parseJob_v3_empty_branch_rev(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( # blank branch, rev are turned to None '3', 'extid', '', '', '1', 'this is my diff, -- ++, etc.', 'repo', 'proj', 'who', 'buildera', 'builderc' ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob['branch'], None) self.assertEqual(parsedjob['baserev'], None) def test_parseJob_v3_no_builders(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( '3', 'extid', 'trunk', '1234', '1', 'this is my diff, -- ++, etc.', 'repo', 'proj', 'who' ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob['builderNames'], []) def test_parseJob_v3_no_properties(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( '3', 'extid', 'trunk', '1234', '1', 'this is my diff, -- ++, etc.', 'repo', 'proj', 'who' ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob['properties'], {}) def test_parseJob_v4(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( '4', 'extid', 'trunk', '1234', '1', 'this is my diff, -- ++, etc.', 'repo', 'proj', 'who', 'comment', 'buildera', 'builderc' ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob, { 'baserev': '1234', 'branch': 'trunk', 'builderNames': ['buildera', 'builderc'], 'jobid': 'extid', 'patch_body': 'this is my diff, -- ++, etc.', 'patch_level': 1, 'project': 'proj', 'who': 'who', 'comment': 'comment', 'repository': 'repo', 'properties': {}, }) def test_parseJob_v4_empty_branch_rev(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( # blank branch, rev are turned to None '4', 'extid', '', '', '1', 'this is my diff, -- ++, etc.', 'repo', 'proj', 'who', 'comment', 'buildera', 'builderc' ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob['branch'], None) self.assertEqual(parsedjob['baserev'], None) def test_parseJob_v4_no_builders(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( '4', 'extid', 'trunk', '1234', '1', 'this is my diff, -- ++, etc.', 'repo', 'proj', 'who', 'comment' ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob['builderNames'], []) def test_parseJob_v4_no_properties(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( '4', 'extid', 'trunk', '1234', '1', 'this is my diff, -- ++, etc.', 'repo', 'proj', 'who', 'comment' ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob['properties'], {}) def test_parseJob_v5(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( '5', json.dumps({ 'jobid': 'extid', 'branch': 'trunk', 'baserev': '1234', 'patch_level': 1, 'patch_body': 'this is my diff, -- ++, etc.', 'repository': 'repo', 'project': 'proj', 'who': 'who', 'comment': 'comment', 'builderNames': ['buildera', 'builderc'], 'properties': {'foo': 'bar'}, })) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob, { 'baserev': '1234', 'branch': 'trunk', 'builderNames': ['buildera', 'builderc'], 'jobid': 'extid', 'patch_body': 'this is my diff, -- ++, etc.', 'patch_level': 1, 'project': 'proj', 'who': 'who', 'comment': 'comment', 'repository': 'repo', 'properties': {'foo': 'bar'}, }) def test_parseJob_v5_empty_branch_rev(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( # blank branch, rev are turned to None '4', 'extid', '', '', '1', 'this is my diff, -- ++, etc.', 'repo', 'proj', 'who', 'comment', 'buildera', 'builderc' ) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob['branch'], None) self.assertEqual(parsedjob['baserev'], None) def test_parseJob_v5_no_builders(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( '5', json.dumps({ 'jobid': 'extid', 'branch': 'trunk', 'baserev': '1234', 'patch_level': '1', 'diff': 'this is my diff, -- ++, etc.', 'repository': 'repo', 'project': 'proj', 'who': 'who', 'comment': 'comment', 'builderNames': [], 'properties': {'foo': 'bar'}, })) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob['builderNames'], []) def test_parseJob_v5_no_properties(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring( '5', json.dumps({ 'jobid': 'extid', 'branch': 'trunk', 'baserev': '1234', 'patch_level': '1', 'diff': 'this is my diff, -- ++, etc.', 'repository': 'repo', 'project': 'proj', 'who': 'who', 'comment': 'comment', 'builderNames': ['buildera', 'builderb'], 'properties': {}, })) parsedjob = sched.parseJob(StringIO.StringIO(jobstr)) self.assertEqual(parsedjob['properties'], {}) def test_parseJob_v5_invalid_json(self): sched = trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo') jobstr = self.makeNetstring('5', '{"comment": "com}') self.assertRaises( trysched.BadJobfile, sched.parseJob, StringIO.StringIO(jobstr)) # handleJobFile def call_handleJobFile(self, parseJob): sched = self.attachScheduler( trysched.Try_Jobdir( name='tsched', builderNames=['buildera', 'builderb'], jobdir='foo'), self.OBJECTID) fakefile = mock.Mock() def parseJob_(f): assert f is fakefile return parseJob(f) sched.parseJob = parseJob_ return sched.handleJobFile('fakefile', fakefile) def makeSampleParsedJob(self, **overrides): pj = dict(baserev='1234', branch='trunk', builderNames=['buildera', 'builderb'], jobid='extid', patch_body='this is my diff, -- ++, etc.', patch_level=1, project='proj', repository='repo', who='who', comment='comment', properties={}) pj.update(overrides) return pj def test_handleJobFile(self): d = self.call_handleJobFile(lambda f: self.makeSampleParsedJob()) def check(_): self.db.buildsets.assertBuildset('?', dict(reason="'try' job by user who", external_idstring='extid', properties=[('scheduler', ('tsched', 'Scheduler'))], sourcestampsetid=100), {'': dict(branch='trunk', repository='repo', codebase='', project='proj', revision='1234', patch_body='this is my diff, -- ++, etc.', patch_level=1, patch_subdir='', patch_author='who', patch_comment='comment', sourcestampsetid=100) }) d.addCallback(check) return d def test_handleJobFile_exception(self): def parseJob(f): raise trysched.BadJobfile d = self.call_handleJobFile(parseJob) def check(bsid): self.db.buildsets.assertBuildsets(0) self.assertEqual( 1, len(self.flushLoggedErrors(trysched.BadJobfile))) d.addCallback(check) return d if twisted.version.major <= 9 and sys.version_info[:2] >= (2, 7): test_handleJobFile_exception.skip = ( "flushLoggedErrors does not work correctly on 9.0.0 " "and earlier with Python-2.7") def test_handleJobFile_bad_builders(self): d = self.call_handleJobFile( lambda f: self.makeSampleParsedJob(builderNames=['xxx'])) def check(_): self.db.buildsets.assertBuildsets(0) d.addCallback(check) return d def test_handleJobFile_subset_builders(self): d = self.call_handleJobFile( lambda f: self.makeSampleParsedJob(builderNames=['buildera'])) def check(_): self.db.buildsets.assertBuildset('?', dict(reason="'try' job by user who", external_idstring='extid', properties=[('scheduler', ('tsched', 'Scheduler'))], sourcestampsetid=100), {'': dict(branch='trunk', repository='repo', codebase='', project='proj', revision='1234', patch_body='this is my diff, -- ++, etc.', patch_level=1, patch_subdir='', patch_author='who', patch_comment='comment', sourcestampsetid=100) }) d.addCallback(check) return d def test_handleJobFile_with_try_properties(self): d = self.call_handleJobFile( lambda f: self.makeSampleParsedJob(properties={'foo': 'bar'})) def check(_): self.db.buildsets.assertBuildset('?', dict(reason="'try' job by user who", external_idstring='extid', properties=[ ('foo', ('bar', 'try build')), ('scheduler', ('tsched', 'Scheduler')), ], sourcestampsetid=100), {'': dict(branch='trunk', repository='repo', codebase='', project='proj', revision='1234', patch_body='this is my diff, -- ++, etc.', patch_level=1, patch_subdir='', patch_author='who', patch_comment='comment', sourcestampsetid=100) }) d.addCallback(check) return d def test_handleJobFile_with_invalid_try_properties(self): d = self.call_handleJobFile( lambda f: self.makeSampleParsedJob(properties=['foo', 'bar'])) return self.assertFailure(d, AttributeError) class Try_Userpass_Perspective(scheduler.SchedulerMixin, unittest.TestCase): OBJECTID = 26 def setUp(self): self.setUpScheduler() def tearDown(self): self.tearDownScheduler() def makeScheduler(self, **kwargs): sched = self.attachScheduler(trysched.Try_Userpass(**kwargs), self.OBJECTID) # Try will return a remote version of master.status, so give it # something to return sched.master.status = mock.Mock() return sched def call_perspective_try(self, *args, **kwargs): sched = self.makeScheduler(name='tsched', builderNames=['a', 'b'], port='xxx', userpass=[('a', 'b')], properties=dict(frm='schd')) persp = trysched.Try_Userpass_Perspective(sched, 'a') return persp.perspective_try(*args, **kwargs) def test_perspective_try(self): d = self.call_perspective_try( 'default', 'abcdef', (1, '-- ++'), 'repo', 'proj', ['a'], properties={'pr': 'op'}) def check(_): self.db.buildsets.assertBuildset('?', dict(reason="'try' job", external_idstring=None, properties=[ ('frm', ('schd', 'Scheduler')), ('pr', ('op', 'try build')), ('scheduler', ('tsched', 'Scheduler')), ], sourcestampsetid=100, ), {'': dict(branch='default', repository='repo', codebase='', project='proj', revision='abcdef', sourcestampsetid=100, patch_body='-- ++', patch_level=1, patch_subdir='', patch_author="", patch_comment="") }) d.addCallback(check) return d def test_perspective_try_who(self): d = self.call_perspective_try( 'default', 'abcdef', (1, '-- ++'), 'repo', 'proj', ['a'], who='who', comment='comment', properties={'pr': 'op'}) def check(_): self.db.buildsets.assertBuildset('?', dict(reason="'try' job by user who (comment)", external_idstring=None, properties=[ ('frm', ('schd', 'Scheduler')), ('pr', ('op', 'try build')), ('scheduler', ('tsched', 'Scheduler')), ], sourcestampsetid=100, ), {'': dict(branch='default', repository='repo', codebase='', project='proj', revision='abcdef', sourcestampsetid=100, patch_body='-- ++', patch_level=1, patch_subdir='', patch_author='who', patch_comment="comment") }) d.addCallback(check) return d def test_perspective_try_bad_builders(self): d = self.call_perspective_try( 'default', 'abcdef', (1, '-- ++'), 'repo', 'proj', ['xxx'], properties={'pr': 'op'}) def check(_): self.db.buildsets.assertBuildsets(0) d.addCallback(check) return d def test_getAvailableBuilderNames(self): sched = self.makeScheduler(name='tsched', builderNames=['a', 'b'], port='xxx', userpass=[('a', 'b')]) persp = trysched.Try_Userpass_Perspective(sched, 'a') d = defer.maybeDeferred( lambda: persp.perspective_getAvailableBuilderNames()) def check(buildernames): self.assertEqual(buildernames, ['a', 'b']) d.addCallback(check) return d class Try_Userpass(scheduler.SchedulerMixin, unittest.TestCase): OBJECTID = 25 def setUp(self): self.setUpScheduler() def tearDown(self): self.tearDownScheduler() def makeScheduler(self, **kwargs): sched = self.attachScheduler(trysched.Try_Userpass(**kwargs), self.OBJECTID) return sched def test_service(self): sched = self.makeScheduler(name='tsched', builderNames=['a'], port='tcp:9999', userpass=[('fred', 'derf')]) # patch out the pbmanager's 'register' command both to be sure # the registration is correct and to get a copy of the factory registration = mock.Mock() registration.unregister = lambda: defer.succeed(None) sched.master.pbmanager = mock.Mock() def register(portstr, user, passwd, factory): self.assertEqual([portstr, user, passwd], ['tcp:9999', 'fred', 'derf']) self.got_factory = factory return registration sched.master.pbmanager.register = register # start it sched.startService() # make a fake connection by invoking the factory, and check that we # get the correct perspective persp = self.got_factory(mock.Mock(), 'fred') self.failUnless(isinstance(persp, trysched.Try_Userpass_Perspective)) return sched.stopService() buildbot-0.8.8/buildbot/test/unit/test_scripts_base.py000066400000000000000000000234001222546025000231640ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import string import cStringIO import textwrap from twisted.trial import unittest from buildbot.scripts import base from buildbot.test.util import dirs, misc from twisted.python import usage, runtime class TestIBD(dirs.DirsMixin, misc.StdoutAssertionsMixin, unittest.TestCase): def setUp(self): self.setUpDirs('test') self.stdout = cStringIO.StringIO() self.setUpStdoutAssertions() def test_isBuildmasterDir_no_dir(self): self.assertFalse(base.isBuildmasterDir(os.path.abspath('test/nosuch'))) self.assertInStdout('error reading') self.assertInStdout('invalid buildmaster directory') def test_isBuildmasterDir_no_file(self): self.assertFalse(base.isBuildmasterDir(os.path.abspath('test'))) self.assertInStdout('error reading') self.assertInStdout('invalid buildmaster directory') def test_isBuildmasterDir_no_Application(self): with open(os.path.join('test', 'buildbot.tac'), 'w') as f: f.write("foo\nx = Application('buildslave')\nbar") self.assertFalse(base.isBuildmasterDir(os.path.abspath('test'))) self.assertInStdout('unexpected content') self.assertInStdout('invalid buildmaster directory') def test_isBuildmasterDir_matches(self): with open(os.path.join('test', 'buildbot.tac'), 'w') as f: f.write("foo\nx = Application('buildmaster')\nbar") self.assertTrue(base.isBuildmasterDir(os.path.abspath('test'))) self.assertWasQuiet() class TestTacFallback(dirs.DirsMixin, unittest.TestCase): """ Tests for L{base.getConfigFileFromTac}. """ def setUp(self): """ Create a base directory. """ self.basedir = os.path.abspath('basedir') return self.setUpDirs('basedir') def _createBuildbotTac(self, contents=None): """ Create a C{buildbot.tac} that points to a given C{configfile} and create that file. @param configfile: Config file to point at and create. @type configfile: L{str} """ if contents is None: contents = '#dummy' tacfile = os.path.join(self.basedir, "buildbot.tac") with open(tacfile, "wt") as f: f.write(contents) return tacfile def test_getConfigFileFromTac(self): """ When L{getConfigFileFromTac} is passed a C{basedir} containing a C{buildbot.tac}, it reads the location of the config file from there. """ self._createBuildbotTac("configfile='other.cfg'") foundConfigFile = base.getConfigFileFromTac( basedir=self.basedir) self.assertEqual(foundConfigFile, "other.cfg") def test_getConfigFileFromTac_fallback(self): """ When L{getConfigFileFromTac} is passed a C{basedir} which doesn't contain a C{buildbot.tac}, it returns C{master.cfg} """ foundConfigFile = base.getConfigFileFromTac( basedir=self.basedir) self.assertEqual(foundConfigFile, 'master.cfg') def test_getConfigFileFromTac_tacWithoutConfigFile(self): """ When L{getConfigFileFromTac} is passed a C{basedir} containing a C{buildbot.tac}, but C{buildbot.tac} doesn't define C{configfile}, L{getConfigFileFromTac} returns C{master.cfg} """ self._createBuildbotTac() foundConfigFile = base.getConfigFileFromTac( basedir=self.basedir) self.assertEqual(foundConfigFile, 'master.cfg') def test_getConfigFileFromTac_usingFile(self): """ Wehn L{getConfigFileFromTac} is passed a C{basedir} containing a C{buildbot.tac} which references C{__file__}, that reference points to C{buildbot.tac}. """ self._createBuildbotTac(textwrap.dedent(""" from twisted.python.util import sibpath configfile = sibpath(__file__, "relative.cfg") """)) foundConfigFile = base.getConfigFileFromTac(basedir=self.basedir) self.assertEqual(foundConfigFile, os.path.join(self.basedir, "relative.cfg")) class TestSubcommandOptions(unittest.TestCase): def fakeOptionsFile(self, **kwargs): self.patch(base.SubcommandOptions, 'loadOptionsFile', lambda self : kwargs.copy()) def parse(self, cls, *args): self.opts = cls() self.opts.parseOptions(args) return self.opts class Bare(base.SubcommandOptions): optFlags = [ [ 'foo', 'f', 'Foo!' ] ] def test_bare_subclass(self): self.fakeOptionsFile() opts = self.parse(self.Bare, '-f') self.assertTrue(opts['foo']) class ParamsAndOptions(base.SubcommandOptions): optParameters = [ [ 'volume', 'v', '5', 'How Loud?' ] ] buildbotOptions = [ [ 'volcfg', 'volume' ] ] def test_buildbotOptions(self): self.fakeOptionsFile() opts = self.parse(self.ParamsAndOptions) self.assertEqual(opts['volume'], '5') def test_buildbotOptions_options(self): self.fakeOptionsFile(volcfg='3') opts = self.parse(self.ParamsAndOptions) self.assertEqual(opts['volume'], '3') def test_buildbotOptions_override(self): self.fakeOptionsFile(volcfg='3') opts = self.parse(self.ParamsAndOptions, '--volume', '7') self.assertEqual(opts['volume'], '7') class RequiredOptions(base.SubcommandOptions): optParameters = [ [ 'volume', 'v', None, 'How Loud?' ] ] requiredOptions = [ 'volume' ] def test_requiredOptions(self): self.fakeOptionsFile() self.assertRaises(usage.UsageError, lambda : self.parse(self.RequiredOptions)) class TestLoadOptionsFile(dirs.DirsMixin, misc.StdoutAssertionsMixin, unittest.TestCase): def setUp(self): self.setUpDirs('test', 'home') self.opts = base.SubcommandOptions() self.dir = os.path.abspath('test') self.home = os.path.abspath('home') self.setUpStdoutAssertions() def tearDown(self): self.tearDownDirs() def do_loadOptionsFile(self, _here, exp): # only patch these os.path functions briefly, to # avoid breaking other parts of the test system patches = [] if runtime.platformType == 'win32': from win32com.shell import shell patches.append(self.patch(shell, 'SHGetFolderPath', lambda *args : self.home)) else: def expanduser(p): return p.replace('~/', self.home + '/') patches.append(self.patch(os.path, 'expanduser', expanduser)) old_dirname = os.path.dirname def dirname(p): # bottom out at self.dir, rather than / if p == self.dir: return p return old_dirname(p) patches.append(self.patch(os.path, 'dirname', dirname)) try: self.assertEqual(self.opts.loadOptionsFile(_here=_here), exp) finally: for p in patches: p.restore() def writeOptionsFile(self, dir, content, bbdir='.buildbot'): os.makedirs(os.path.join(dir, bbdir)) with open(os.path.join(dir, bbdir, 'options'), 'w') as f: f.write(content) def test_loadOptionsFile_subdirs_not_found(self): subdir = os.path.join(self.dir, 'a', 'b') os.makedirs(subdir) self.do_loadOptionsFile(_here=subdir, exp={}) def test_loadOptionsFile_subdirs_at_root(self): subdir = os.path.join(self.dir, 'a', 'b') os.makedirs(subdir) self.writeOptionsFile(self.dir, 'abc="def"') self.writeOptionsFile(self.home, 'abc=123') # not seen self.do_loadOptionsFile(_here=subdir, exp={'abc':'def'}) def test_loadOptionsFile_subdirs_at_tip(self): subdir = os.path.join(self.dir, 'a', 'b') os.makedirs(subdir) self.writeOptionsFile(os.path.join(self.dir, 'a', 'b'), 'abc="def"') self.writeOptionsFile(self.dir, 'abc=123') # not seen self.do_loadOptionsFile(_here=subdir, exp={'abc':'def'}) def test_loadOptionsFile_subdirs_at_homedir(self): subdir = os.path.join(self.dir, 'a', 'b') os.makedirs(subdir) # on windows, the subdir of the home (well, appdata) dir # is 'buildbot', not '.buildbot' self.writeOptionsFile(self.home, 'abc=123', 'buildbot' if runtime.platformType == 'win32' else '.buildbot') self.do_loadOptionsFile(_here=subdir, exp={'abc':123}) def test_loadOptionsFile_syntax_error(self): self.writeOptionsFile(self.dir, 'abc=abc') self.assertRaises(NameError, lambda : self.do_loadOptionsFile(_here=self.dir, exp={})) self.assertInStdout('error while reading') def test_loadOptionsFile_toomany(self): subdir = os.path.join(self.dir, *tuple(string.lowercase)) os.makedirs(subdir) self.do_loadOptionsFile(_here=subdir, exp={}) self.assertInStdout('infinite glories') # NOTE: testing the ownership check requires patching os.stat, which causes # other problems since it is so heavily used. buildbot-0.8.8/buildbot/test/unit/test_scripts_checkconfig.py000066400000000000000000000145461222546025000245300ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import mock import re import sys import os import textwrap import cStringIO from twisted.trial import unittest from buildbot.test.util import dirs, compat from buildbot.scripts import base, checkconfig class TestConfigLoader(dirs.DirsMixin, unittest.TestCase): def setUp(self): return self.setUpDirs('configdir') def tearDown(self): return self.tearDownDirs() # tests def do_test_load(self, config='', other_files={}, stdout_re=None, stderr_re=None): configFile = os.path.join('configdir', 'master.cfg') with open(configFile, "w") as f: f.write(config) for filename, contents in other_files.iteritems(): if type(filename) == type(()): fn = os.path.join('configdir', *filename) dn = os.path.dirname(fn) if not os.path.isdir(dn): os.makedirs(dn) else: fn = os.path.join('configdir', filename) with open(fn, "w") as f: f.write(contents) old_stdout, old_stderr = sys.stdout, sys.stderr stdout = sys.stdout = cStringIO.StringIO() stderr = sys.stderr = cStringIO.StringIO() try: checkconfig._loadConfig( basedir='configdir', configFile="master.cfg", quiet=False) finally: sys.stdout, sys.stderr = old_stdout, old_stderr if stdout_re: stdout = stdout.getvalue() self.failUnless(stdout_re.search(stdout), stdout) if stderr_re: stderr = stderr.getvalue() self.failUnless(stderr_re.search(stderr), stderr) def test_success(self): len_sys_path = len(sys.path) config = textwrap.dedent("""\ c = BuildmasterConfig = {} c['multiMaster'] = True c['schedulers'] = [] from buildbot.config import BuilderConfig from buildbot.process.factory import BuildFactory c['builders'] = [ BuilderConfig('testbuilder', factory=BuildFactory(), slavename='sl'), ] from buildbot.buildslave import BuildSlave c['slaves'] = [ BuildSlave('sl', 'pass'), ] c['slavePortnum'] = 9989 """) self.do_test_load(config=config, stdout_re=re.compile('Config file is good!')) # (regression) check that sys.path hasn't changed self.assertEqual(len(sys.path), len_sys_path) @compat.usesFlushLoggedErrors def test_failure_ImportError(self): config = textwrap.dedent("""\ import test_scripts_checkconfig_does_not_exist """) self.do_test_load(config=config, stderr_re=re.compile( 'No module named test_scripts_checkconfig_does_not_exist')) self.flushLoggedErrors() @compat.usesFlushLoggedErrors def test_failure_no_slaves(self): config = textwrap.dedent("""\ BuildmasterConfig={} """) self.do_test_load(config=config, stderr_re=re.compile('no slaves')) self.flushLoggedErrors() def test_success_imports(self): config = textwrap.dedent("""\ from othermodule import port c = BuildmasterConfig = {} c['schedulers'] = [] c['builders'] = [] c['slaves'] = [] c['slavePortnum'] = port """) other_files = { 'othermodule.py' : 'port = 9989' } self.do_test_load(config=config, other_files=other_files) def test_success_import_package(self): config = textwrap.dedent("""\ from otherpackage.othermodule import port c = BuildmasterConfig = {} c['schedulers'] = [] c['builders'] = [] c['slaves'] = [] c['slavePortnum'] = port """) other_files = { ('otherpackage', '__init__.py') : '', ('otherpackage', 'othermodule.py') : 'port = 9989', } self.do_test_load(config=config, other_files=other_files) class TestCheckconfig(unittest.TestCase): def setUp(self): self.loadConfig = mock.Mock(spec=checkconfig._loadConfig, return_value=3) self.patch(checkconfig, '_loadConfig', self.loadConfig) def test_checkconfig_given_dir(self): self.assertEqual(checkconfig.checkconfig(dict(configFile='.')), 3) self.loadConfig.assert_called_with(basedir='.', configFile='master.cfg', quiet=None) def test_checkconfig_given_file(self): config = dict(configFile='master.cfg') self.assertEqual(checkconfig.checkconfig(config), 3) self.loadConfig.assert_called_with(basedir=os.getcwd(), configFile='master.cfg', quiet=None) def test_checkconfig_quiet(self): config = dict(configFile='master.cfg', quiet=True) self.assertEqual(checkconfig.checkconfig(config), 3) self.loadConfig.assert_called_with(basedir=os.getcwd(), configFile='master.cfg', quiet=True) def test_checkconfig_syntaxError_quiet(self): """ When C{base.getConfigFileFromTac} raises L{SyntaxError}, C{checkconfig.checkconfig} return an error. """ mockGetConfig = mock.Mock(spec=base.getConfigFileFromTac, side_effect=SyntaxError) self.patch(checkconfig, 'getConfigFileFromTac', mockGetConfig) config = dict(configFile='.', quiet=True) self.assertEqual(checkconfig.checkconfig(config), 1) buildbot-0.8.8/buildbot/test/unit/test_scripts_create_master.py000066400000000000000000000204741222546025000251000ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import mock from twisted.trial import unittest from twisted.internet import defer from buildbot.scripts import create_master from buildbot.db import connector, model from buildbot.test.util import dirs, misc def mkconfig(**kwargs): config = dict(force=False, relocatable=False, config='master.cfg', db='sqlite:///state.sqlite', basedir=os.path.abspath('basedir'), quiet=False, **{'no-logrotate':False, 'log-size':'10000000', 'log-count':'10'}) config.update(kwargs) return config class TestCreateMaster(misc.StdoutAssertionsMixin, unittest.TestCase): def setUp(self): # createMaster is decorated with @in_reactor, so strip that decoration # since the master is already running self.patch(create_master, 'createMaster', create_master.createMaster._orig) self.setUpStdoutAssertions() # tests def do_test_createMaster(self, config): # mock out everything that createMaster calls, then check that # they are called, in order functions = [ 'makeBasedir', 'makeTAC', 'makeSampleConfig', 'makePublicHtml', 'makeTemplatesDir', 'createDB' ] repls = {} calls = [] for fn in functions: repl = repls[fn] = mock.Mock(name=fn) repl.side_effect = lambda config, fn=fn : calls.append(fn) self.patch(create_master, fn, repl) repls['createDB'].side_effect = (lambda config : calls.append(fn) or defer.succeed(None)) d = create_master.createMaster(config) @d.addCallback def check(rc): self.assertEqual(rc, 0) self.assertEqual(calls, functions) for repl in repls.values(): repl.assert_called_with(config) return d def test_createMaster_quiet(self): d = self.do_test_createMaster(mkconfig(quiet=True)) @d.addCallback def check(_): self.assertWasQuiet() return d def test_createMaster_loud(self): d = self.do_test_createMaster(mkconfig(quiet=False)) @d.addCallback def check(_): self.assertInStdout('buildmaster configured in') return d class TestCreateMasterFunctions(dirs.DirsMixin, misc.StdoutAssertionsMixin, unittest.TestCase): def setUp(self): self.setUpDirs('test') self.basedir = os.path.abspath(os.path.join('test', 'basedir')) self.setUpStdoutAssertions() def tearDown(self): self.tearDownDirs() def assertInTacFile(self, str): self.assertIn(str, open(os.path.join('test', 'buildbot.tac'), 'rt').read()) def assertNotInTacFile(self, str): self.assertNotIn(str, open(os.path.join('test', 'buildbot.tac'), 'rt').read()) def assertDBSetup(self, basedir=None, db_url='sqlite:///state.sqlite', verbose=True): # mock out the database setup self.db = mock.Mock() self.db.setup.side_effect = lambda *a, **k : defer.succeed(None) self.DBConnector = mock.Mock() self.DBConnector.return_value = self.db self.patch(connector, 'DBConnector', self.DBConnector) basedir = basedir or self.basedir self.assertEqual( dict(basedir=self.DBConnector.call_args[0][1], db_url=self.DBConnector.call_args[0][0].mkconfig.db['db_url'], verbose=self.db.setup.call_args[1]['verbose'], check_version=self.db.setup.call_args[1]['check_version'], ), dict(basedir=self.basedir, db_url=db_url, verbose=True, check_version=False)) # tests def test_makeBasedir(self): self.assertFalse(os.path.exists(self.basedir)) create_master.makeBasedir(mkconfig(basedir=self.basedir)) self.assertTrue(os.path.exists(self.basedir)) self.assertInStdout('mkdir %s' % (self.basedir,)) def test_makeBasedir_quiet(self): self.assertFalse(os.path.exists(self.basedir)) create_master.makeBasedir(mkconfig(basedir=self.basedir, quiet=True)) self.assertTrue(os.path.exists(self.basedir)) self.assertWasQuiet() def test_makeBasedir_existing(self): os.mkdir(self.basedir) create_master.makeBasedir(mkconfig(basedir=self.basedir)) self.assertInStdout('updating existing installation') def test_makeTAC(self): create_master.makeTAC(mkconfig(basedir='test')) self.assertInTacFile("Application('buildmaster')") self.assertWasQuiet() def test_makeTAC_relocatable(self): create_master.makeTAC(mkconfig(basedir='test', relocatable=True)) self.assertInTacFile("basedir = '.'") # repr() prefers '' self.assertWasQuiet() def test_makeTAC_no_logrotate(self): create_master.makeTAC(mkconfig(basedir='test', **{'no-logrotate':True})) self.assertNotInTacFile("import Log") self.assertWasQuiet() def test_makeTAC_existing_incorrect(self): with open(os.path.join('test', 'buildbot.tac'), 'wt') as f: f.write('WRONG') create_master.makeTAC(mkconfig(basedir='test')) self.assertInTacFile("WRONG") self.assertTrue(os.path.exists( os.path.join('test', 'buildbot.tac.new'))) self.assertInStdout('not touching existing buildbot.tac') def test_makeTAC_existing_incorrect_quiet(self): with open(os.path.join('test', 'buildbot.tac'), 'wt') as f: f.write('WRONG') create_master.makeTAC(mkconfig(basedir='test', quiet=True)) self.assertInTacFile("WRONG") self.assertWasQuiet() def test_makeTAC_existing_correct(self): create_master.makeTAC(mkconfig(basedir='test', quiet=True)) create_master.makeTAC(mkconfig(basedir='test')) self.assertFalse(os.path.exists( os.path.join('test', 'buildbot.tac.new'))) self.assertInStdout('and is correct') def test_makeSampleConfig(self): create_master.makeSampleConfig(mkconfig(basedir='test')) self.assertTrue(os.path.exists( os.path.join('test', 'master.cfg.sample'))) self.assertInStdout('creating ') def test_makeSampleConfig_db(self): create_master.makeSampleConfig(mkconfig(basedir='test', db='XXYYZZ', quiet=True)) with open(os.path.join('test', 'master.cfg.sample'), 'rt') as f: self.assertIn("XXYYZZ", f.read()) self.assertWasQuiet() def test_makePublicHtml(self): create_master.makePublicHtml(mkconfig(basedir='test', quiet=True)) self.assertTrue(os.path.exists( os.path.join('test', 'public_html', 'robots.txt'))) self.assertWasQuiet() def test_makeTemplatesDir(self): create_master.makeTemplatesDir(mkconfig(basedir='test', quiet=True)) self.assertTrue(os.path.exists( os.path.join('test', 'templates', 'README.txt'))) self.assertWasQuiet() @defer.inlineCallbacks def test_createDB(self): setup = mock.Mock(side_effect=lambda **kwargs : defer.succeed(None)) self.patch(connector.DBConnector, 'setup', setup) upgrade = mock.Mock(side_effect=lambda **kwargs : defer.succeed(None)) self.patch(model.Model, 'upgrade', upgrade) yield create_master.createDB( mkconfig(basedir='test', quiet=True), _noMonkey=True) setup.asset_called_with(check_version=False, verbose=False) upgrade.assert_called() self.assertWasQuiet() buildbot-0.8.8/buildbot/test/unit/test_scripts_restart.py000066400000000000000000000052131222546025000237400ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os from twisted.trial import unittest from buildbot.scripts import restart, stop, start from buildbot.test.util import dirs, misc def mkconfig(**kwargs): config = dict(quiet=False, basedir=os.path.abspath('basedir')) config.update(kwargs) return config class TestStop(misc.StdoutAssertionsMixin, dirs.DirsMixin, unittest.TestCase): def setUp(self): self.setUpDirs('basedir') with open(os.path.join('basedir', 'buildbot.tac'), 'wt') as f: f.write("Application('buildmaster')") self.setUpStdoutAssertions() def tearDown(self): self.tearDownDirs() # tests def test_restart_not_basedir(self): self.assertEqual(restart.restart(mkconfig(basedir='doesntexist')), 1) self.assertInStdout('invalid buildmaster directory') def test_restart_stop_fails(self): self.patch(stop, 'stop', lambda config, wait : 1) self.assertEqual(restart.restart(mkconfig()), 1) def test_restart_stop_succeeds_start_fails(self): self.patch(stop, 'stop', lambda config, wait : 0) self.patch(start, 'start', lambda config : 1) self.assertEqual(restart.restart(mkconfig()), 1) def test_restart_succeeds(self): self.patch(stop, 'stop', lambda config, wait : 0) self.patch(start, 'start', lambda config : 0) self.assertEqual(restart.restart(mkconfig()), 0) self.assertInStdout('now restarting') def test_restart_succeeds_quiet(self): self.patch(stop, 'stop', lambda config, wait : 0) self.patch(start, 'start', lambda config : 0) self.assertEqual(restart.restart(mkconfig(quiet=True)), 0) self.assertWasQuiet() def test_restart_clean(self): self.patch(stop, 'stop', lambda config, wait : 0) self.patch(start, 'start', lambda config : 0) self.assertEqual(restart.restart(mkconfig(quiet=True, clean=True)), 0) self.assertWasQuiet() buildbot-0.8.8/buildbot/test/unit/test_scripts_runner.py000066400000000000000000001034011222546025000235630ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import sys import getpass import mock import cStringIO from twisted.trial import unittest from twisted.python import usage, runtime, log from buildbot.scripts import base, runner from buildbot.test.util import misc class OptionsMixin(object): def setUpOptions(self): self.options_file = {} self.patch(base.SubcommandOptions, 'loadOptionsFile', lambda other_self : self.options_file) def assertOptions(self, opts, exp): got = dict([(k, opts[k]) for k in exp]) if got != exp: msg = [] for k in exp: if opts[k] != exp[k]: msg.append(" %s: expected %r, got %r" % (k, exp[k], opts[k])) self.fail("did not get expected options\n" + ("\n".join(msg))) class TestUpgradeMasterOptions(OptionsMixin, unittest.TestCase): def setUp(self): self.setUpOptions() def parse(self, *args): self.opts = runner.UpgradeMasterOptions() self.opts.parseOptions(args) return self.opts def test_synopsis(self): opts = runner.UpgradeMasterOptions() self.assertIn('buildbot upgrade-master', opts.getSynopsis()) def test_defaults(self): opts = self.parse() exp = dict(quiet=False, replace=False) self.assertOptions(opts, exp) def test_short(self): opts = self.parse('-q', '-r') exp = dict(quiet=True, replace=True) self.assertOptions(opts, exp) def test_long(self): opts = self.parse('--quiet', '--replace') exp = dict(quiet=True, replace=True) self.assertOptions(opts, exp) class TestCreateMasterOptions(OptionsMixin, unittest.TestCase): def setUp(self): self.setUpOptions() def parse(self, *args): self.opts = runner.CreateMasterOptions() self.opts.parseOptions(args) return self.opts def defaults_and(self, **kwargs): defaults = dict(force=False, relocatable=False, config='master.cfg', db='sqlite:///state.sqlite', basedir=os.getcwd(), quiet=False, **{'no-logrotate':False, 'log-size':'10000000', 'log-count':'10'}) unk_keys = set(kwargs.keys()) - set(defaults.keys()) assert not unk_keys, "invalid keys %s" % (unk_keys,) opts = defaults.copy() opts.update(kwargs) return opts def test_synopsis(self): opts = runner.CreateMasterOptions() self.assertIn('buildbot create-master', opts.getSynopsis()) def test_defaults(self): opts = self.parse() exp = self.defaults_and() self.assertOptions(opts, exp) def test_db_quiet(self): opts = self.parse('-q') exp = self.defaults_and(quiet=True) self.assertOptions(opts, exp) def test_db_quiet_long(self): opts = self.parse('--quiet') exp = self.defaults_and(quiet=True) self.assertOptions(opts, exp) def test_force(self): opts = self.parse('-f') exp = self.defaults_and(force=True) self.assertOptions(opts, exp) def test_force_long(self): opts = self.parse('--force') exp = self.defaults_and(force=True) self.assertOptions(opts, exp) def test_relocatable(self): opts = self.parse('-r') exp = self.defaults_and(relocatable=True) self.assertOptions(opts, exp) def test_relocatable_long(self): opts = self.parse('--relocatable') exp = self.defaults_and(relocatable=True) self.assertOptions(opts, exp) def test_no_logrotate(self): opts = self.parse('-n') exp = self.defaults_and(**{'no-logrotate' : True}) self.assertOptions(opts, exp) def test_no_logrotate_long(self): opts = self.parse('--no-logrotate') exp = self.defaults_and(**{'no-logrotate' : True}) self.assertOptions(opts, exp) def test_config(self): opts = self.parse('-cxyz') exp = self.defaults_and(config='xyz') self.assertOptions(opts, exp) def test_config_long(self): opts = self.parse('--config=xyz') exp = self.defaults_and(config='xyz') self.assertOptions(opts, exp) def test_log_size(self): opts = self.parse('-s124') exp = self.defaults_and(**{'log-size':'124'}) self.assertOptions(opts, exp) def test_log_size_long(self): opts = self.parse('--log-size=124') exp = self.defaults_and(**{'log-size':'124'}) self.assertOptions(opts, exp) def test_log_size_noninteger(self): self.assertRaises(usage.UsageError, lambda :self.parse('--log-size=1M')) def test_log_count(self): opts = self.parse('-l124') exp = self.defaults_and(**{'log-count':'124'}) self.assertOptions(opts, exp) def test_log_count_long(self): opts = self.parse('--log-count=124') exp = self.defaults_and(**{'log-count':'124'}) self.assertOptions(opts, exp) def test_log_count_noninteger(self): self.assertRaises(usage.UsageError, lambda :self.parse('--log-count=M')) def test_db_long(self): opts = self.parse('--db=foo://bar') exp = self.defaults_and(db='foo://bar') self.assertOptions(opts, exp) def test_db_basedir(self): path = r'c:\foo\bar' if runtime.platformType == "win32" else '/foo/bar' opts = self.parse('-f', path) exp = self.defaults_and(force=True, basedir=path) self.assertOptions(opts, exp) class BaseTestSimpleOptions(OptionsMixin): # tests for options with just --quiet and a usage message commandName = None optionsClass = None def setUp(self): self.setUpOptions() def parse(self, *args): self.opts = self.optionsClass() self.opts.parseOptions(args) return self.opts def test_synopsis(self): opts = self.optionsClass() self.assertIn('buildbot %s' % self.commandName, opts.getSynopsis()) def test_defaults(self): opts = self.parse() exp = dict(quiet=False) self.assertOptions(opts, exp) def test_quiet(self): opts = self.parse('--quiet') exp = dict(quiet=True) self.assertOptions(opts, exp) class TestStopOptions(BaseTestSimpleOptions, unittest.TestCase): commandName = 'stop' optionsClass = runner.StopOptions class TestResetartOptions(BaseTestSimpleOptions, unittest.TestCase): commandName = 'restart' optionsClass = runner.RestartOptions def test_nodaemon(self): opts = self.parse('--nodaemon') exp = dict(nodaemon=True) self.assertOptions(opts, exp) class TestStartOptions(BaseTestSimpleOptions, unittest.TestCase): commandName = 'start' optionsClass = runner.StartOptions def test_nodaemon(self): opts = self.parse('--nodaemon') exp = dict(nodaemon=True) self.assertOptions(opts, exp) class TestReconfigOptions(BaseTestSimpleOptions, unittest.TestCase): commandName = 'reconfig' optionsClass = runner.ReconfigOptions class TestDebugClientOptions(OptionsMixin, unittest.TestCase): def setUp(self): self.setUpOptions() def parse(self, *args): self.opts = runner.DebugClientOptions() self.opts.parseOptions(args) return self.opts def test_synopsis(self): opts = runner.DebugClientOptions() self.assertIn('buildbot debugclient', opts.getSynopsis()) def test_defaults(self): self.assertRaises(usage.UsageError, lambda : self.parse()) def test_args_missing_passwd(self): self.assertRaises(usage.UsageError, lambda : self.parse('-m', 'mm')) def test_options_long(self): opts = self.parse('--master', 'mm:9989', '--passwd', 'pp') exp = dict(master='mm:9989', passwd='pp') self.assertOptions(opts, exp) def test_positional_master_passwd(self): opts = self.parse('foo:9989', 'pass') exp = dict(master='foo:9989', passwd='pass') self.assertOptions(opts, exp) def test_positional_master(self): opts = self.parse('-p', 'pass', 'foo:9989') exp = dict(master='foo:9989', passwd='pass') self.assertOptions(opts, exp) def test_args_master_passwd(self): opts = self.parse('foo:9989', 'pass') exp = dict(master='foo:9989', passwd='pass') self.assertOptions(opts, exp) def test_missing_both(self): self.assertRaises(usage.UsageError, lambda :self.parse()) def test_missing_passwd(self): self.assertRaises(usage.UsageError, lambda :self.parse('master')) def test_missing_master(self): self.assertRaises(usage.UsageError, lambda :self.parse('-p', 'pass')) def test_invalid_master(self): self.assertRaises(usage.UsageError, self.parse, "-m", "foo", "-p", "pass") def test_options_extra_positional(self): self.assertRaises(usage.UsageError, lambda : self.parse('mm', 'pp', '??')) def test_options_master(self): self.options_file['master'] = 'opt:9989' opts = self.parse('-p', 'pass') exp = dict(master='opt:9989', passwd='pass') self.assertOptions(opts, exp) def test_options_debugMaster(self): self.options_file['master'] = 'not seen' self.options_file['debugMaster'] = 'opt:9989' opts = self.parse('-p', 'pass') exp = dict(master='opt:9989', passwd='pass') self.assertOptions(opts, exp) class TestBaseStatusClientOptions(OptionsMixin, unittest.TestCase): def setUp(self): self.setUpOptions() def parse(self, *args): self.opts = runner.BaseStatusClientOptions() self.opts.parseOptions(args) return self.opts def test_defaults(self): opts = self.parse('--master', 'm:20') exp = dict(master='m:20', username='statusClient', passwd='clientpw') self.assertOptions(opts, exp) def test_short(self): opts = self.parse('-m', 'm:20', '-u', 'u', '-p', 'p') exp = dict(master='m:20', username='u', passwd='p') self.assertOptions(opts, exp) def test_long(self): opts = self.parse('--master', 'm:20', '--username', 'u', '--passwd', 'p') exp = dict(master='m:20', username='u', passwd='p') self.assertOptions(opts, exp) def test_positional_master(self): opts = self.parse('--username', 'u', '--passwd', 'p', 'm:20') exp = dict(master='m:20', username='u', passwd='p') self.assertOptions(opts, exp) def test_positional_extra(self): self.assertRaises(usage.UsageError, lambda : self.parse('--username', 'u', '--passwd', 'p', 'm', '2')) def test_missing_master(self): self.assertRaises(usage.UsageError, lambda : self.parse('--username', 'u', '--passwd', 'p')) def test_invalid_master(self): self.assertRaises(usage.UsageError, self.parse, "-m foo") def test_options_masterstatus(self): self.options_file['master'] = 'not_seen:2' self.options_file['masterstatus'] = 'opt:3' opts = self.parse('-p', 'pass', '-u', 'user') exp = dict(master='opt:3', username='user', passwd='pass') self.assertOptions(opts, exp) class TestStatusLogOptions(unittest.TestCase): def test_synopsis(self): opts = runner.StatusLogOptions() self.assertIn('buildbot statuslog', opts.getSynopsis()) class TestStatusGuiOptions(unittest.TestCase): def test_synopsis(self): opts = runner.StatusGuiOptions() self.assertIn('buildbot statusgui', opts.getSynopsis()) class TestTryOptions(OptionsMixin, unittest.TestCase): def setUp(self): self.setUpOptions() def parse(self, *args): self.opts = runner.TryOptions() self.opts.parseOptions(args) return self.opts def defaults_and(self, **kwargs): defaults = dict(connect=None, host=None, jobdir=None, username=None, master=None, passwd=None, who=None, comment=None, diff=None, patchlevel=0, baserev=None, vc=None, branch=None, repository=None, topfile=None, topdir=None, wait=False, dryrun=False, quiet=False, builders=[], properties={}, buildbotbin='buildbot') # dashes make python syntax hard.. defaults['get-builder-names'] = False if 'get_builder_names' in kwargs: kwargs['get-builder-names'] = kwargs['get_builder_names'] del kwargs['get_builder_names'] assert set(kwargs.keys()) <= set(defaults.keys()), "invalid keys" opts = defaults.copy() opts.update(kwargs) return opts def test_synopsis(self): opts = runner.TryOptions() self.assertIn('buildbot try', opts.getSynopsis()) def test_defaults(self): opts = self.parse() exp = self.defaults_and() self.assertOptions(opts, exp) def test_properties(self): opts = self.parse('--properties=a=b') exp = self.defaults_and(properties=dict(a='b')) self.assertOptions(opts, exp) def test_properties_multiple_opts(self): opts = self.parse('--properties=X=1', '--properties=Y=2') exp = self.defaults_and(properties=dict(X='1', Y='2')) self.assertOptions(opts, exp) def test_properties_equals(self): opts = self.parse('--properties=X=2+2=4') exp = self.defaults_and(properties=dict(X='2+2=4')) self.assertOptions(opts, exp) def test_properties_commas(self): opts = self.parse('--properties=a=b,c=d') exp = self.defaults_and(properties=dict(a='b', c='d')) self.assertOptions(opts, exp) def test_property(self): opts = self.parse('--property=a=b') exp = self.defaults_and(properties=dict(a='b')) self.assertOptions(opts, exp) def test_property_multiple_opts(self): opts = self.parse('--property=X=1', '--property=Y=2') exp = self.defaults_and(properties=dict(X='1', Y='2')) self.assertOptions(opts, exp) def test_property_equals(self): opts = self.parse('--property=X=2+2=4') exp = self.defaults_and(properties=dict(X='2+2=4')) self.assertOptions(opts, exp) def test_property_commas(self): opts = self.parse('--property=a=b,c=d') exp = self.defaults_and(properties=dict(a='b,c=d')) self.assertOptions(opts, exp) def test_property_and_properties(self): opts = self.parse('--property=X=1', '--properties=Y=2') exp = self.defaults_and(properties=dict(X='1', Y='2')) self.assertOptions(opts, exp) def test_properties_builders_multiple(self): opts = self.parse('--builder=aa', '--builder=bb') exp = self.defaults_and(builders=['aa', 'bb']) self.assertOptions(opts, exp) def test_options_short(self): opts = self.parse( *'-n -q -c pb -u me -m mr:7 -w you -C comm -p 2 -b bb'.split()) exp = self.defaults_and(dryrun=True, quiet=True, connect='pb', username='me', master='mr:7', who='you', comment='comm', patchlevel=2, builders=['bb']) self.assertOptions(opts, exp) def test_options_long(self): opts = self.parse( *"""--wait --dryrun --get-builder-names --quiet --connect=pb --host=h --jobdir=j --username=u --master=m:1234 --passwd=p --who=w --comment=comm --diff=d --patchlevel=7 --baserev=br --vc=cvs --branch=br --repository=rep --builder=bl --properties=a=b --topfile=Makefile --topdir=. --buildbotbin=.virtualenvs/buildbot/bin/buildbot""".split()) exp = self.defaults_and(wait=True, dryrun=True, get_builder_names=True, quiet=True, connect='pb', host='h', jobdir='j', username='u', master='m:1234', passwd='p', who='w', comment='comm', diff='d', patchlevel=7, baserev='br', vc='cvs', branch='br', repository='rep', builders=['bl'], properties=dict(a='b'), topfile='Makefile', topdir='.', buildbotbin='.virtualenvs/buildbot/bin/buildbot') self.assertOptions(opts, exp) def test_patchlevel_inval(self): self.assertRaises(ValueError, lambda: self.parse('-p', 'a')) def test_config_builders(self): self.options_file['try_builders'] = ['a', 'b'] opts = self.parse() self.assertOptions(opts, dict(builders=['a', 'b'])) def test_config_builders_override(self): self.options_file['try_builders'] = ['a', 'b'] opts = self.parse('-b', 'd') # overrides a, b self.assertOptions(opts, dict(builders=['d'])) def test_config_old_names(self): self.options_file['try_masterstatus'] = 'ms' self.options_file['try_dir'] = 'td' self.options_file['try_password'] = 'pw' opts = self.parse() self.assertOptions(opts, dict(master='ms', jobdir='td', passwd='pw')) def test_config_masterstatus(self): self.options_file['masterstatus'] = 'ms' opts = self.parse() self.assertOptions(opts, dict(master='ms')) def test_config_masterstatus_override(self): self.options_file['masterstatus'] = 'ms' opts = self.parse('-m', 'mm') self.assertOptions(opts, dict(master='mm')) def test_config_options(self): self.options_file.update(dict(try_connect='pb', try_vc='cvs', try_branch='br', try_repository='rep', try_topdir='.', try_topfile='Makefile', try_host='h', try_username='u', try_jobdir='j', try_password='p', try_master='m:8', try_who='w', try_comment='comm', try_quiet='y', try_wait='y', try_buildbotbin='.virtualenvs/buildbot/bin/buildbot')) opts = self.parse() exp = self.defaults_and(wait=True, quiet=True, connect='pb', host='h', jobdir='j', username='u', master='m:8', passwd='p', who='w', comment='comm', vc='cvs', branch='br', repository='rep', topfile='Makefile', topdir='.', buildbotbin='.virtualenvs/buildbot/bin/buildbot') self.assertOptions(opts, exp) def test_pb_withNoMaster(self): """ When 'builbot try' is asked to connect via pb, but no master is specified, a usage error is raised. """ self.assertRaises(usage.UsageError, self.parse, '--connect=pb') def test_pb_withInvalidMaster(self): """ When 'buildbot try' is asked to conncect via pb, but an invalid master is specified, a usage error is raised. """ self.assertRaises(usage.UsageError, self.parse, '--connect=pb', '--master=foo') class TestSendChangeOptions(OptionsMixin, unittest.TestCase): master_and_who = ['-m', 'm:1', '-W', 'w'] def setUp(self): self.setUpOptions() self.getpass_response = 'typed-password' self.patch(getpass, 'getpass', lambda prompt : self.getpass_response) def parse(self, *args): self.opts = runner.SendChangeOptions() self.opts.parseOptions(args) return self.opts def test_synopsis(self): opts = runner.SendChangeOptions() self.assertIn('buildbot sendchange', opts.getSynopsis()) def test_defaults(self): opts = self.parse('-m', 'm:1', '-W', 'me') exp = dict(master='m:1', auth=('change', 'changepw'), who='me', vc=None, repository='', project='', branch=None, category=None, revision=None, revision_file=None, property=None, comments='', logfile=None, when=None, revlink='', encoding='utf8', files=()) self.assertOptions(opts, exp) def test_files(self): opts = self.parse(*self.master_and_who + ['a', 'b', 'c']) self.assertEqual(opts['files'], ('a', 'b', 'c')) def test_properties(self): opts = self.parse('--property', 'x:y', '--property', 'a:b', *self.master_and_who) self.assertEqual(opts['properties'], dict(x="y", a="b")) def test_properties_with_colon(self): opts = self.parse('--property', 'x:http://foo', *self.master_and_who) self.assertEquals(opts['properties'], dict(x='http://foo')) def test_config_file(self): self.options_file['master'] = 'MMM:123' self.options_file['who'] = 'WWW' self.options_file['branch'] = 'BBB' self.options_file['category'] = 'CCC' self.options_file['vc'] = 'svn' opts = self.parse() exp = dict(master='MMM:123', who='WWW', branch='BBB', category='CCC', vc='svn') self.assertOptions(opts, exp) def test_short_args(self): opts = self.parse(*('-m m:1 -a a:b -W W -R r -P p -b b -s git ' + '-C c -r r -p pn:pv -c c -F f -w 123 -l l -e e').split()) exp = dict(master='m:1', auth=('a','b'), who='W', repository='r', project='p', branch='b', category='c', revision='r', vc='git', properties=dict(pn='pv'), comments='c', logfile='f', when=123.0, revlink='l', encoding='e') self.assertOptions(opts, exp) def test_long_args(self): opts = self.parse(*('--master m:1 --auth a:b --who w --repository r ' + '--project p --branch b --category c --revision r --vc git ' + '--property pn:pv --comments c --logfile f ' + '--when 123 --revlink l --encoding e').split()) exp = dict(master='m:1', auth=('a', 'b'), who='w', repository='r', project='p', branch='b', category='c', revision='r', vc='git', properties=dict(pn='pv'), comments='c', logfile='f', when=123.0, revlink='l', encoding='e') self.assertOptions(opts, exp) def test_revision_file(self): with open('revfile', 'wt') as f: f.write('my-rev') self.addCleanup(lambda : os.unlink('revfile')) opts = self.parse('--revision_file', 'revfile', *self.master_and_who) self.assertOptions(opts, dict(revision='my-rev')) def test_invalid_when(self): self.assertRaises(usage.UsageError, lambda : self.parse('--when=foo', *self.master_and_who)) def test_comments_overrides_logfile(self): opts = self.parse('--logfile', 'logs', '--comments', 'foo', *self.master_and_who) self.assertOptions(opts, dict(comments='foo')) def test_logfile(self): with open('comments', 'wt') as f: f.write('hi') self.addCleanup(lambda : os.unlink('comments')) opts = self.parse('--logfile', 'comments', *self.master_and_who) self.assertOptions(opts, dict(comments='hi')) def test_logfile_stdin(self): stdin = mock.Mock() stdin.read = lambda : 'hi' self.patch(sys, 'stdin', stdin) opts = self.parse('--logfile', '-', *self.master_and_who) self.assertOptions(opts, dict(comments='hi')) def test_auth_getpass(self): opts = self.parse('--auth=dustin', *self.master_and_who) self.assertOptions(opts, dict(auth=('dustin', 'typed-password'))) def test_invalid_vcs(self): self.assertRaises(usage.UsageError, lambda : self.parse('--vc=foo', *self.master_and_who)) def test_invalid_master(self): self.assertRaises(usage.UsageError, self.parse, "--who=test", "-m foo") class TestTryServerOptions(OptionsMixin, unittest.TestCase): def setUp(self): self.setUpOptions() def parse(self, *args): self.opts = runner.TryServerOptions() self.opts.parseOptions(args) return self.opts def test_synopsis(self): opts = runner.TryServerOptions() self.assertIn('buildbot tryserver', opts.getSynopsis()) def test_defaults(self): self.assertRaises(usage.UsageError, lambda : self.parse()) def test_with_jobdir(self): opts = self.parse('--jobdir', 'xyz') exp = dict(jobdir='xyz') self.assertOptions(opts, exp) class TestCheckConfigOptions(OptionsMixin, unittest.TestCase): def setUp(self): self.setUpOptions() def parse(self, *args): self.opts = runner.CheckConfigOptions() self.opts.parseOptions(args) return self.opts def test_synopsis(self): opts = runner.CheckConfigOptions() self.assertIn('buildbot checkconfig', opts.getSynopsis()) def test_defaults(self): opts = self.parse() exp = dict(quiet=False, configFile='master.cfg') self.assertOptions(opts, exp) def test_configfile(self): opts = self.parse('foo.cfg') exp = dict(quiet=False, configFile='foo.cfg') self.assertOptions(opts, exp) def test_quiet(self): opts = self.parse('-q') exp = dict(quiet=True, configFile='master.cfg') self.assertOptions(opts, exp) class TestUserOptions(OptionsMixin, unittest.TestCase): # mandatory arguments extra_args = [ '--master', 'a:1', '--username', 'u', '--passwd', 'p' ] def setUp(self): self.setUpOptions() def parse(self, *args): self.opts = runner.UserOptions() self.opts.parseOptions(args) return self.opts def test_defaults(self): self.assertRaises(usage.UsageError, lambda : self.parse()) def test_synopsis(self): opts = runner.UserOptions() self.assertIn('buildbot user', opts.getSynopsis()) def test_master(self): opts = self.parse("--master", "abcd:1234", '--op=get', '--ids=x', '--username=u', '--passwd=p') self.assertOptions(opts, dict(master="abcd:1234")) def test_ids(self): opts = self.parse("--ids", "id1,id2,id3", '--op', 'get', *self.extra_args) self.assertEqual(opts['ids'], ['id1', 'id2', 'id3']) def test_info(self): opts = self.parse("--info", "git=Tyler Durden ", '--op', 'add', *self.extra_args) self.assertEqual(opts['info'], [dict(git='Tyler Durden ')]) def test_info_only_id(self): opts = self.parse("--info", "tdurden", '--op', 'update', *self.extra_args) self.assertEqual(opts['info'], [dict(identifier='tdurden')]) def test_info_with_id(self): opts = self.parse("--info", "tdurden:svn=marla", '--op', 'update', *self.extra_args) self.assertEqual(opts['info'], [dict(identifier='tdurden', svn='marla')]) def test_info_multiple(self): opts = self.parse("--info", "git=Tyler Durden ", "--info", "git=Narrator ", '--op', 'add', *self.extra_args) self.assertEqual(opts['info'], [dict(git='Tyler Durden '), dict(git='Narrator ')]) def test_config_user_params(self): self.options_file['user_master'] = 'mm:99' self.options_file['user_username'] = 'un' self.options_file['user_passwd'] = 'pw' opts = self.parse('--op', 'get', '--ids', 'x') self.assertOptions(opts, dict(master='mm:99', username='un', passwd='pw')) def test_config_master(self): self.options_file['master'] = 'mm:99' opts = self.parse('--op', 'get', '--ids', 'x', '--username=u', '--passwd=p') self.assertOptions(opts, dict(master='mm:99')) def test_config_master_override(self): self.options_file['master'] = 'not seen' self.options_file['user_master'] = 'mm:99' opts = self.parse('--op', 'get', '--ids', 'x', '--username=u', '--passwd=p') self.assertOptions(opts, dict(master='mm:99')) def test_invalid_info(self): self.assertRaises(usage.UsageError, lambda : self.parse("--info", "foo=bar", '--op', 'add', *self.extra_args)) def test_no_master(self): self.assertRaises(usage.UsageError, lambda : self.parse('-op=foo')) def test_invalid_master(self): self.assertRaises(usage.UsageError, self.parse,'-m', 'foo') def test_no_operation(self): self.assertRaises(usage.UsageError, self.parse, '-m', 'a:1') def test_bad_operation(self): self.assertRaises(usage.UsageError, self.parse, '-m', 'a:1', '--op=mayhem') def test_no_username(self): self.assertRaises(usage.UsageError, self.parse, '-m', 'a:1', '--op=add') def test_no_password(self): self.assertRaises(usage.UsageError, self.parse, '--op=add', '-m', 'a:1', '-u', 'tdurden') def test_invalid_bb_username(self): self.assertRaises(usage.UsageError, self.parse, '--op=add', '--bb_username=tdurden', *self.extra_args) def test_invalid_bb_password(self): self.assertRaises(usage.UsageError, self.parse, '--op=add', '--bb_password=marla', *self.extra_args) def test_update_no_bb_username(self): self.assertRaises(usage.UsageError, self.parse, '--op=update', '--bb_password=marla', *self.extra_args) def test_update_no_bb_password(self): self.assertRaises(usage.UsageError, self.parse, '--op=update', '--bb_username=tdurden', *self.extra_args) def test_no_ids_info(self): self.assertRaises(usage.UsageError, self.parse, '--op=add', *self.extra_args) def test_ids_with_add(self): self.assertRaises(usage.UsageError, self.parse, '--op=add', '--ids=id1', *self.extra_args) def test_ids_with_update(self): self.assertRaises(usage.UsageError, self.parse, '--op=update', '--ids=id1', *self.extra_args) def test_no_ids_found_update(self): self.assertRaises(usage.UsageError, self.parse, "--op=update", "--info=svn=x", *self.extra_args) def test_id_with_add(self): self.assertRaises(usage.UsageError, self.parse, "--op=add", "--info=id:x", *self.extra_args) def test_info_with_remove(self): self.assertRaises(usage.UsageError, self.parse, '--op=remove', '--info=x=v', *self.extra_args) def test_info_with_get(self): self.assertRaises(usage.UsageError, self.parse, '--op=get', '--info=x=v', *self.extra_args) class TestOptions(OptionsMixin, misc.StdoutAssertionsMixin, unittest.TestCase): def setUp(self): self.setUpOptions() self.setUpStdoutAssertions() def parse(self, *args): self.opts = runner.Options() self.opts.parseOptions(args) return self.opts def test_defaults(self): self.assertRaises(usage.UsageError, lambda : self.parse()) def test_version(self): try: self.parse('--version') except SystemExit, e: self.assertEqual(e.args[0], 0) self.assertInStdout('Buildbot version:') def test_verbose(self): self.patch(log, 'startLogging', mock.Mock()) self.assertRaises(usage.UsageError, self.parse, "--verbose") log.startLogging.assert_called_once_with(sys.stderr) class TestRun(unittest.TestCase): class MySubCommand(usage.Options): subcommandFunction = 'buildbot.test.unit.test_scripts_runner.subcommandFunction' optFlags = [ [ 'loud', 'l', 'be noisy' ] ] def postOptions(self): if self['loud']: raise usage.UsageError('THIS IS ME BEING LOUD') def setUp(self): # patch our subcommand in self.patch(runner.Options, 'subCommands', [ [ 'my', None, self.MySubCommand, 'my, my' ] ]) # and patch in the callback for it global subcommandFunction subcommandFunction = mock.Mock(name='subcommandFunction', return_value=3) def test_run_good(self): self.patch(sys, 'argv', [ 'buildbot', 'my' ]) try: runner.run() except SystemExit, e: self.assertEqual(e.args[0], 3) else: self.fail("didn't exit") def test_run_bad(self): self.patch(sys, 'argv', [ 'buildbot', 'my', '-l' ]) stdout = cStringIO.StringIO() self.patch(sys, 'stdout', stdout) try: runner.run() except SystemExit, e: self.assertEqual(e.args[0], 1) else: self.fail("didn't exit") self.assertIn('THIS IS ME', stdout.getvalue()) buildbot-0.8.8/buildbot/test/unit/test_scripts_sendchange.py000066400000000000000000000115701222546025000243560ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.internet import reactor, defer from buildbot.scripts import sendchange from buildbot.clients import sendchange as sendchange_client from buildbot.test.util import misc class TestSendChange(misc.StdoutAssertionsMixin, unittest.TestCase): class FakeSender: def __init__(self, testcase, master, auth, encoding=None): self.master = master self.auth = auth self.encoding = encoding self.testcase = testcase def send(self, branch, revision, comments, files, **kwargs): kwargs['branch'] = branch kwargs['revision'] = revision kwargs['comments'] = comments kwargs['files'] = files self.send_kwargs = kwargs d = defer.Deferred() if self.testcase.fail: reactor.callLater(0, d.errback, RuntimeError("oh noes")) else: reactor.callLater(0, d.callback, None) return d def setUp(self): self.fail = False # set to true to get Sender.send to fail def Sender_constr(*args, **kwargs): self.sender = self.FakeSender(self, *args, **kwargs) return self.sender self.patch(sendchange_client, 'Sender', Sender_constr) # undo the effects of @in_reactor self.patch(sendchange, 'sendchange', sendchange.sendchange._orig) self.setUpStdoutAssertions() def test_sendchange_config(self): d = sendchange.sendchange(dict(encoding='utf16', who='me', auth=['a', 'b'], master='m', branch='br', category='cat', revision='rr', properties={'a':'b'}, repository='rep', project='prj', vc='git', revlink='rl', when=1234.0, comments='comm', files=('a', 'b'), codebase='cb')) def check(rc): self.assertEqual((self.sender.master, self.sender.auth, self.sender.encoding, self.sender.send_kwargs, self.getStdout(), rc), ('m', ['a','b'], 'utf16', { 'branch': 'br', 'category': 'cat', 'codebase': 'cb', 'comments': 'comm', 'files': ('a', 'b'), 'project': 'prj', 'properties': {'a':'b'}, 'repository': 'rep', 'revision': 'rr', 'revlink': 'rl', 'when': 1234.0, 'who': 'me', 'vc': 'git'}, 'change sent successfully', 0)) d.addCallback(check) return d def test_sendchange_config_no_codebase(self): d = sendchange.sendchange(dict(encoding='utf16', who='me', auth=['a', 'b'], master='m', branch='br', category='cat', revision='rr', properties={'a':'b'}, repository='rep', project='prj', vc='git', revlink='rl', when=1234.0, comments='comm', files=('a', 'b'))) def check(rc): self.assertEqual((self.sender.master, self.sender.auth, self.sender.encoding, self.sender.send_kwargs, self.getStdout(), rc), ('m', ['a','b'], 'utf16', { 'branch': 'br', 'category': 'cat', 'codebase': None, 'comments': 'comm', 'files': ('a', 'b'), 'project': 'prj', 'properties': {'a':'b'}, 'repository': 'rep', 'revision': 'rr', 'revlink': 'rl', 'when': 1234.0, 'who': 'me', 'vc': 'git'}, 'change sent successfully', 0)) d.addCallback(check) return d def test_sendchange_fail(self): self.fail = True d = sendchange.sendchange({}) def check(rc): self.assertEqual((self.getStdout().split('\n')[0], rc), ('change not sent:', 1)) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_scripts_start.py000066400000000000000000000070461222546025000234170ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os, sys import twisted from twisted.python import versions from twisted.internet.utils import getProcessOutputAndValue from twisted.trial import unittest from buildbot.scripts import start from buildbot.test.util import dirs, misc, compat def mkconfig(**kwargs): config = { 'quiet': False, 'basedir': os.path.abspath('basedir'), 'nodaemon': False, } config.update(kwargs) return config fake_master_tac = """\ from twisted.application import service from twisted.python import log from twisted.internet import reactor application = service.Application('highscore') class App(service.Service): def startService(self): service.Service.startService(self) log.msg("BuildMaster is running") # heh heh heh reactor.callLater(0, reactor.stop) app = App() app.setServiceParent(application) # isBuildmasterDir wants to see this -> Application('buildmaster') """ class TestStart(misc.StdoutAssertionsMixin, dirs.DirsMixin, unittest.TestCase): def setUp(self): self.setUpDirs('basedir') with open(os.path.join('basedir', 'buildbot.tac'), 'wt') as f: f.write(fake_master_tac) self.setUpStdoutAssertions() def tearDown(self): self.tearDownDirs() # tests def test_start_not_basedir(self): self.assertEqual(start.start(mkconfig(basedir='doesntexist')), 1) self.assertInStdout('invalid buildmaster directory') def runStart(self, **config): args=[ '-c', 'from buildbot.scripts.start import start; start(%r)' % (mkconfig(**config),), ] env = os.environ.copy() env['PYTHONPATH'] = os.pathsep.join(sys.path) return getProcessOutputAndValue(sys.executable, args=args, env=env) def test_start_no_daemon(self): d = self.runStart(nodaemon=True) @d.addCallback def cb(res): self.assertEquals(res, ('', '', 0)) print res return d def test_start_quiet(self): d = self.runStart(quiet=True) @d.addCallback def cb(res): self.assertEquals(res, ('', '', 0)) print res return d @compat.skipUnlessPlatformIs('posix') def test_start(self): d = self.runStart() @d.addCallback def cb((out, err, rc)): self.assertEqual((rc, err), (0, '')) self.assertSubstring('BuildMaster is running', out) return d if twisted.version <= versions.Version('twisted', 9, 0, 0): test_start.skip = test_start_quiet.skip = "Skipping due to suprious PotentialZombieWarning." # the remainder of this script does obscene things: # - forks # - shells out to tail # - starts and stops the reactor # so testing it will be *far* more pain than is worthwhile buildbot-0.8.8/buildbot/test/unit/test_scripts_statuslog.py000066400000000000000000000024051222546025000243010ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from buildbot.clients import text from buildbot.scripts import statuslog class TestStatusLog(unittest.TestCase): def test_statuslog(self): TextClient = mock.Mock() self.patch(text, 'TextClient', TextClient) inst = TextClient.return_value = mock.Mock(name='TextClient-instance') rc = statuslog.statuslog(dict(master='mm', passwd='pp', username='uu')) TextClient.assert_called_with('mm', passwd='pp', username='uu') inst.run.assert_called_with() self.assertEqual(rc, 0) buildbot-0.8.8/buildbot/test/unit/test_scripts_stop.py000066400000000000000000000111031222546025000232340ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import time import signal from twisted.trial import unittest from buildbot.scripts import stop from buildbot.test.util import dirs, misc, compat def mkconfig(**kwargs): config = dict(quiet=False, clean=False, basedir=os.path.abspath('basedir')) config.update(kwargs) return config class TestStop(misc.StdoutAssertionsMixin, dirs.DirsMixin, unittest.TestCase): def setUp(self): self.setUpDirs('basedir') self.setUpStdoutAssertions() def tearDown(self): self.tearDownDirs() # tests def do_test_stop(self, config, kill_sequence, is_running=True, **kwargs): with open(os.path.join('basedir', 'buildbot.tac'), 'wt') as f: f.write("Application('buildmaster')") if is_running: with open("basedir/twistd.pid", 'wt') as f: f.write('1234') def sleep(t): what, exp_t = kill_sequence.pop(0) self.assertEqual((what, exp_t), ('sleep', t)) self.patch(time, 'sleep', sleep) def kill(pid, signal): exp_sig, result = kill_sequence.pop(0) self.assertEqual((pid,signal), (1234,exp_sig)) if isinstance(result, Exception): raise result else: return result self.patch(os, 'kill', kill) rv = stop.stop(config, **kwargs) self.assertEqual(kill_sequence, []) return rv @compat.skipUnlessPlatformIs('posix') def test_stop_not_running(self): rv = self.do_test_stop(mkconfig(), [], is_running=False) self.assertInStdout('not running') self.assertEqual(rv, 0) @compat.skipUnlessPlatformIs('posix') def test_stop_dead_but_pidfile_remains(self): rv = self.do_test_stop(mkconfig(), [ (signal.SIGTERM, OSError(3, 'No such process')) ]) self.assertEqual(rv, 0) self.assertFalse(os.path.exists(os.path.join('basedir', 'twistd.pid'))) self.assertInStdout('not running') @compat.skipUnlessPlatformIs('posix') def test_stop_dead_but_pidfile_remains_quiet(self): rv = self.do_test_stop(mkconfig(quiet=True), [ (signal.SIGTERM, OSError(3, 'No such process')) ],) self.assertEqual(rv, 0) self.assertFalse(os.path.exists(os.path.join('basedir', 'twistd.pid'))) self.assertWasQuiet() @compat.skipUnlessPlatformIs('posix') def test_stop_dead_but_pidfile_remains_wait(self): rv = self.do_test_stop(mkconfig(), [ (signal.SIGTERM, OSError(3, 'No such process')) ], wait=True) self.assertEqual(rv, 0) self.assertFalse(os.path.exists(os.path.join('basedir', 'twistd.pid'))) @compat.skipUnlessPlatformIs('posix') def test_stop_slow_death_wait(self): rv = self.do_test_stop(mkconfig(), [ (signal.SIGTERM, None), ('sleep', 0.1), (0, None), # polling.. ('sleep', 1), (0, None), ('sleep', 1), (0, None), ('sleep', 1), (0, OSError(3, 'No such process')), ], wait=True) self.assertInStdout('is dead') self.assertEqual(rv, 0) @compat.skipUnlessPlatformIs('posix') def test_stop_slow_death_wait_timeout(self): rv = self.do_test_stop(mkconfig(), [ (signal.SIGTERM, None), ('sleep', 0.1), ] + [ (0, None), ('sleep', 1), ] * 10, wait=True) self.assertInStdout('never saw process') self.assertEqual(rv, 1) @compat.skipUnlessPlatformIs('posix') def test_stop_clean(self): rv = self.do_test_stop(mkconfig(clean=True), [ (signal.SIGUSR1, None), ], wait=False) self.assertInStdout('sent SIGUSR1 to process') self.assertEqual(rv, 0) buildbot-0.8.8/buildbot/test/unit/test_scripts_trycmd.py000066400000000000000000000022431222546025000235560ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from buildbot.clients import tryclient from buildbot.scripts import trycmd class TestStatusLog(unittest.TestCase): def test_trycmd(self): Try = mock.Mock() self.patch(tryclient, 'Try', Try) inst = Try.return_value = mock.Mock(name='Try-instance') rc = trycmd.trycmd(dict(cfg=1)) Try.assert_called_with(dict(cfg=1)) inst.run.assert_called_with() self.assertEqual(rc, 0) buildbot-0.8.8/buildbot/test/unit/test_scripts_tryserver.py000066400000000000000000000032361222546025000243240ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import sys from cStringIO import StringIO from twisted.trial import unittest from buildbot.scripts import tryserver from buildbot.test.util import dirs class TestStatusLog(dirs.DirsMixin, unittest.TestCase): def setUp(self): self.newdir = os.path.join('jobdir', 'new') self.tmpdir = os.path.join('jobdir', 'tmp') self.setUpDirs("jobdir", self.newdir, self.tmpdir) def test_trycmd(self): config = dict(jobdir='jobdir') inputfile = StringIO('this is my try job') self.patch(sys, 'stdin', inputfile) rc = tryserver.tryserver(config) self.assertEqual(rc, 0) newfiles = os.listdir(self.newdir) tmpfiles = os.listdir(self.tmpdir) self.assertEqual((len(newfiles), len(tmpfiles)), (1, 0)) with open(os.path.join(self.newdir, newfiles[0]), 'rt') as f: self.assertEqual(f.read(), 'this is my try job') buildbot-0.8.8/buildbot/test/unit/test_scripts_upgrade_master.py000066400000000000000000000237121222546025000252620ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import mock from twisted.trial import unittest from twisted.internet import defer from buildbot.scripts import upgrade_master from buildbot import config as config_module from buildbot.db import connector, model from buildbot.test.util import dirs, misc, compat def mkconfig(**kwargs): config = dict(quiet=False, replace=False, basedir='test') config.update(kwargs) return config class TestUpgradeMaster(dirs.DirsMixin, misc.StdoutAssertionsMixin, unittest.TestCase): def setUp(self): # createMaster is decorated with @in_reactor, so strip that decoration # since the master is already running self.patch(upgrade_master, 'upgradeMaster', upgrade_master.upgradeMaster._orig) self.setUpDirs('test') self.setUpStdoutAssertions() def patchFunctions(self, basedirOk=True, configOk=True): self.calls = [] def checkBasedir(config): self.calls.append('checkBasedir') return basedirOk self.patch(upgrade_master, 'checkBasedir', checkBasedir) def loadConfig(config, configFileName='master.cfg'): self.calls.append('loadConfig') return config_module.MasterConfig() if configOk else False self.patch(upgrade_master, 'loadConfig', loadConfig) def upgradeFiles(config): self.calls.append('upgradeFiles') self.patch(upgrade_master, 'upgradeFiles', upgradeFiles) def upgradeDatabase(config, master_cfg): self.assertIsInstance(master_cfg, config_module.MasterConfig) self.calls.append('upgradeDatabase') self.patch(upgrade_master, 'upgradeDatabase', upgradeDatabase) # tests def test_upgradeMaster_success(self): self.patchFunctions() d = upgrade_master.upgradeMaster(mkconfig(), _noMonkey=True) @d.addCallback def check(rv): self.assertEqual(rv, 0) self.assertInStdout('upgrade complete') return d def test_upgradeMaster_quiet(self): self.patchFunctions() d = upgrade_master.upgradeMaster(mkconfig(quiet=True), _noMonkey=True) @d.addCallback def check(rv): self.assertEqual(rv, 0) self.assertWasQuiet() return d def test_upgradeMaster_bad_basedir(self): self.patchFunctions(basedirOk=False) d = upgrade_master.upgradeMaster(mkconfig(), _noMonkey=True) @d.addCallback def check(rv): self.assertEqual(rv, 1) return d def test_upgradeMaster_bad_config(self): self.patchFunctions(configOk=False) d = upgrade_master.upgradeMaster(mkconfig(), _noMonkey=True) @d.addCallback def check(rv): self.assertEqual(rv, 1) return d class TestUpgradeMasterFunctions(dirs.DirsMixin, misc.StdoutAssertionsMixin, unittest.TestCase): def setUp(self): self.setUpDirs('test') self.basedir = os.path.abspath(os.path.join('test', 'basedir')) self.setUpStdoutAssertions() def tearDown(self): self.tearDownDirs() def activeBasedir(self): with open(os.path.join('test', 'buildbot.tac'), 'wt') as f: f.write("Application('buildmaster')") def writeFile(self, path, contents): with open(path, 'wt') as f: f.write(contents) def readFile(self, path): with open(path, 'rt') as f: return f.read() # tests def test_checkBasedir(self): self.activeBasedir() rv = upgrade_master.checkBasedir(mkconfig()) self.assertTrue(rv) self.assertInStdout('checking basedir') def test_checkBasedir_quiet(self): self.activeBasedir() rv = upgrade_master.checkBasedir(mkconfig(quiet=True)) self.assertTrue(rv) self.assertWasQuiet() def test_checkBasedir_no_dir(self): rv = upgrade_master.checkBasedir(mkconfig(basedir='doesntexist')) self.assertFalse(rv) self.assertInStdout('invalid buildmaster directory') @compat.skipUnlessPlatformIs('posix') def test_checkBasedir_active_pidfile(self): self.activeBasedir() open(os.path.join('test', 'twistd.pid'), 'w').close() rv = upgrade_master.checkBasedir(mkconfig()) self.assertFalse(rv) self.assertInStdout('still running') def test_loadConfig(self): @classmethod def loadConfig(cls, basedir, filename): return config_module.MasterConfig() self.patch(config_module.MasterConfig, 'loadConfig', loadConfig) cfg = upgrade_master.loadConfig(mkconfig()) self.assertIsInstance(cfg, config_module.MasterConfig) self.assertInStdout('checking') def test_loadConfig_ConfigErrors(self): @classmethod def loadConfig(cls, basedir, filename): raise config_module.ConfigErrors(['oh noes']) self.patch(config_module.MasterConfig, 'loadConfig', loadConfig) cfg = upgrade_master.loadConfig(mkconfig()) self.assertIdentical(cfg, None) self.assertInStdout('oh noes') def test_loadConfig_exception(self): @classmethod def loadConfig(cls, basedir, filename): raise RuntimeError() self.patch(config_module.MasterConfig, 'loadConfig', loadConfig) cfg = upgrade_master.loadConfig(mkconfig()) self.assertIdentical(cfg, None) self.assertInStdout('RuntimeError') def test_installFile(self): self.writeFile('test/srcfile', 'source data') upgrade_master.installFile(mkconfig(), 'test/destfile', 'test/srcfile') self.assertEqual(self.readFile('test/destfile'), 'source data') self.assertInStdout('creating test/destfile') def test_installFile_existing_differing(self): self.writeFile('test/srcfile', 'source data') self.writeFile('test/destfile', 'dest data') upgrade_master.installFile(mkconfig(), 'test/destfile', 'test/srcfile') self.assertEqual(self.readFile('test/destfile'), 'dest data') self.assertEqual(self.readFile('test/destfile.new'), 'source data') self.assertInStdout('writing new contents to') def test_installFile_existing_differing_overwrite(self): self.writeFile('test/srcfile', 'source data') self.writeFile('test/destfile', 'dest data') upgrade_master.installFile(mkconfig(), 'test/destfile', 'test/srcfile', overwrite=True) self.assertEqual(self.readFile('test/destfile'), 'source data') self.assertFalse(os.path.exists('test/destfile.new')) self.assertInStdout('overwriting') def test_installFile_existing_same(self): self.writeFile('test/srcfile', 'source data') self.writeFile('test/destfile', 'source data') upgrade_master.installFile(mkconfig(), 'test/destfile', 'test/srcfile') self.assertEqual(self.readFile('test/destfile'), 'source data') self.assertFalse(os.path.exists('test/destfile.new')) self.assertWasQuiet() def test_installFile_quiet(self): self.writeFile('test/srcfile', 'source data') upgrade_master.installFile(mkconfig(quiet=True), 'test/destfile', 'test/srcfile') self.assertWasQuiet() def test_upgradeFiles(self): upgrade_master.upgradeFiles(mkconfig()) for f in [ 'test/public_html', 'test/public_html/bg_gradient.jpg', 'test/public_html/default.css', 'test/public_html/robots.txt', 'test/public_html/favicon.ico', 'test/templates', 'test/master.cfg.sample', ]: self.assertTrue(os.path.exists(f), "%s not found" % f) self.assertInStdout('upgrading basedir') def test_upgradeFiles_rename_index_html(self): os.mkdir('test/public_html') self.writeFile('test/public_html/index.html', 'INDEX') upgrade_master.upgradeFiles(mkconfig()) self.assertFalse(os.path.exists("test/public_html/index.html")) self.assertEqual(self.readFile("test/templates/root.html"), 'INDEX') self.assertInStdout('Moving ') def test_upgradeFiles_index_html_collision(self): os.mkdir('test/public_html') self.writeFile('test/public_html/index.html', 'INDEX') os.mkdir('test/templates') self.writeFile('test/templates/root.html', 'ROOT') upgrade_master.upgradeFiles(mkconfig()) self.assertTrue(os.path.exists("test/public_html/index.html")) self.assertEqual(self.readFile("test/templates/root.html"), 'ROOT') self.assertInStdout('Decide') @defer.inlineCallbacks def test_upgradeDatabase(self): setup = mock.Mock(side_effect=lambda **kwargs : defer.succeed(None)) self.patch(connector.DBConnector, 'setup', setup) upgrade = mock.Mock(side_effect=lambda **kwargs : defer.succeed(None)) self.patch(model.Model, 'upgrade', upgrade) yield upgrade_master.upgradeDatabase( mkconfig(basedir='test', quiet=True), config_module.MasterConfig()) setup.asset_called_with(check_version=False, verbose=False) upgrade.assert_called() self.assertWasQuiet() buildbot-0.8.8/buildbot/test/unit/test_scripts_user.py000066400000000000000000000103011222546025000232240ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.internet import defer, reactor from buildbot.scripts import user from buildbot.clients import usersclient from buildbot.process.users import users class TestUsersClient(unittest.TestCase): class FakeUsersClient(object): def __init__(self, master, username="user", passwd="userpw", port=0): self.master = master self.port = port self.username = username self.passwd = passwd self.fail = False def send(self, op, bb_username, bb_password, ids, info): self.op = op self.bb_username = bb_username self.bb_password = bb_password self.ids = ids self.info = info d = defer.Deferred() if self.fail: reactor.callLater(0, d.errback, RuntimeError("oh noes")) else: reactor.callLater(0, d.callback, None) return d def setUp(self): def fake_UsersClient(*args): self.usersclient = self.FakeUsersClient(*args) return self.usersclient self.patch(usersclient, 'UsersClient', fake_UsersClient) # un-do the effects of @in_reactor self.patch(user, 'user', user.user._orig) def test_usersclient_send_ids(self): d = user.user(dict(master='a:9990', username="x", passwd="y", op='get', bb_username=None, bb_password=None, ids=['me', 'you'], info=None)) def check(_): c = self.usersclient self.assertEqual((c.master, c.port, c.username, c.passwd, c.op, c.ids, c.info), ('a', 9990, "x", "y", 'get', ['me', 'you'], None)) d.addCallback(check) return d def test_usersclient_send_update_info(self): def _fake_encrypt(passwd): assert passwd == 'day' return 'ENCRY' self.patch(users, 'encrypt', _fake_encrypt) d = user.user(dict(master='a:9990', username="x", passwd="y", op='update', bb_username='bud', bb_password='day', ids=None, info=[{'identifier':'x', 'svn':'x'}])) def check(_): c = self.usersclient self.assertEqual((c.master, c.port, c.username, c.passwd, c.op, c.bb_username, c.bb_password, c.ids, c.info), ('a', 9990, "x", "y", 'update', 'bud', 'ENCRY', None, [{'identifier':'x', 'svn':'x'}])) d.addCallback(check) return d def test_usersclient_send_add_info(self): d = user.user(dict(master='a:9990', username="x", passwd="y", op='add', bb_username=None, bb_password=None, ids=None, info=[{'git':'x ', 'irc':'aaa'}])) def check(_): c = self.usersclient self.assertEqual((c.master, c.port, c.username, c.passwd, c.op, c.bb_username, c.bb_password, c.ids, c.info), ('a', 9990, "x", "y", 'add', None, None, None, [{'identifier':'aaa', 'git': 'x ', 'irc': 'aaa'}])) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_sourcestamp.py000066400000000000000000000222111222546025000230470ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from buildbot.changes import changes from buildbot.test.fake import fakedb, fakemaster from buildbot import sourcestamp class TestBuilderBuildCreation(unittest.TestCase): def test_fromSsdict_changes(self): master = fakemaster.make_master() master.db = fakedb.FakeDBConnector(self) master.db.insertTestData([ fakedb.Change(changeid=13, branch='trunk', revision='9283', repository='svn://...', codebase='cb', project='world-domination'), fakedb.Change(changeid=14, branch='trunk', revision='9284', repository='svn://...', codebase='cb', project='world-domination'), fakedb.Change(changeid=15, branch='trunk', revision='9284', repository='svn://...', codebase='cb', project='world-domination'), fakedb.Change(changeid=16, branch='trunk', revision='9284', repository='svn://...', codebase='cb', project='world-domination'), fakedb.SourceStamp(id=234, branch='trunk', revision='9284', repository='svn://...', codebase='cb', project='world-domination'), fakedb.SourceStampChange(sourcestampid=234, changeid=14), fakedb.SourceStampChange(sourcestampid=234, changeid=13), fakedb.SourceStampChange(sourcestampid=234, changeid=15), fakedb.SourceStampChange(sourcestampid=234, changeid=16), ]) # use getSourceStamp to minimize the risk from changes to the format of # the ssdict d = master.db.sourcestamps.getSourceStamp(234) d.addCallback(lambda ssdict : sourcestamp.SourceStamp.fromSsdict(master, ssdict)) def check(ss): self.assertEqual(ss.ssid, 234) self.assertEqual(ss.branch, 'trunk') self.assertEqual(ss.revision, '9284') self.assertEqual(ss.patch, None) self.assertEqual(ss.patch_info, None) self.assertEqual([ ch.number for ch in ss.changes], [13, 14, 15, 16]) self.assertEqual(ss.project, 'world-domination') self.assertEqual(ss.repository, 'svn://...') self.assertEqual(ss.codebase, 'cb') d.addCallback(check) return d def test_fromSsdict_patch(self): master = fakemaster.make_master() master.db = fakedb.FakeDBConnector(self) master.db.insertTestData([ fakedb.Patch(id=99, subdir='/foo', patchlevel=3, patch_base64='LS0gKys=', patch_author='Professor Chaos', patch_comment='comment'), fakedb.SourceStamp(id=234, sourcestampsetid=234, branch='trunk', revision='9284', repository='svn://...', codebase='cb', project='world-domination', patchid=99), ]) # use getSourceStamp to minimize the risk from changes to the format of # the ssdict d = master.db.sourcestamps.getSourceStamp(234) d.addCallback(lambda ssdict : sourcestamp.SourceStamp.fromSsdict(master, ssdict)) def check(ss): self.assertEqual(ss.ssid, 234) self.assertEqual(ss.branch, 'trunk') self.assertEqual(ss.revision, '9284') self.assertEqual(ss.patch, (3, '-- ++', '/foo')) self.assertEqual(ss.patch_info, ('Professor Chaos', 'comment')) self.assertEqual(ss.changes, ()) self.assertEqual(ss.project, 'world-domination') self.assertEqual(ss.repository, 'svn://...') self.assertEqual(ss.codebase, 'cb') d.addCallback(check) return d def test_fromSsdict_simple(self): master = fakemaster.make_master() master.db = fakedb.FakeDBConnector(self) master.db.insertTestData([ fakedb.SourceStamp(id=234, sourcestampsetid=234, branch='trunk', revision='9284', repository='svn://...', codebase = 'cb', project='world-domination'), ]) # use getSourceStamp to minimize the risk from changes to the format of # the ssdict d = master.db.sourcestamps.getSourceStamp(234) d.addCallback(lambda ssdict : sourcestamp.SourceStamp.fromSsdict(master, ssdict)) def check(ss): self.assertEqual(ss.ssid, 234) self.assertEqual(ss.branch, 'trunk') self.assertEqual(ss.revision, '9284') self.assertEqual(ss.patch, None) self.assertEqual(ss.patch_info, None) self.assertEqual(ss.changes, ()) self.assertEqual(ss.project, 'world-domination') self.assertEqual(ss.repository, 'svn://...') self.assertEqual(ss.codebase, 'cb') d.addCallback(check) return d def test_getAbsoluteSourceStamp_from_relative(self): ss = sourcestamp.SourceStamp(branch='dev', revision=None, project='p', repository='r', codebase='cb') abs_ss = ss.getAbsoluteSourceStamp('abcdef') self.assertEqual(abs_ss.branch, 'dev') self.assertEqual(abs_ss.revision, 'abcdef') self.assertEqual(abs_ss.project, 'p') self.assertEqual(abs_ss.repository, 'r') self.assertEqual(abs_ss.codebase, 'cb') def test_getAbsoluteSourceStamp_from_absolute(self): ss = sourcestamp.SourceStamp(branch='dev', revision='xyz', project='p', repository='r', codebase='cb') abs_ss = ss.getAbsoluteSourceStamp('abcdef') self.assertEqual(abs_ss.branch, 'dev') # revision gets overridden self.assertEqual(abs_ss.revision, 'abcdef') self.assertEqual(abs_ss.project, 'p') self.assertEqual(abs_ss.repository, 'r') self.assertEqual(abs_ss.codebase, 'cb') def test_getAbsoluteSourceStamp_from_absolute_with_changes(self): c1 = mock.Mock() c1.branch = 'dev' c1.revision = 'xyz' c1.project = 'p' c1.repository = 'r' c1.codebase = 'cb' ss = sourcestamp.SourceStamp(branch='dev', revision='xyz', project='p', repository='r', codebase='cb', changes=[c1]) abs_ss = ss.getAbsoluteSourceStamp('abcdef') self.assertEqual(abs_ss.branch, 'dev') # revision changes, even though changes say different - this is # useful for CVS, for example self.assertEqual(abs_ss.revision, 'abcdef') self.assertEqual(abs_ss.project, 'p') self.assertEqual(abs_ss.repository, 'r') self.assertEqual(abs_ss.codebase, 'cb') def test_canBeMergedWith_where_sourcestamp_do_not_both_have_changes(self): c1 = mock.Mock() c1.codebase = 'cb' ss1 = sourcestamp.SourceStamp(branch='dev', revision='xyz', project='p', repository='r', codebase='cb', changes=[c1]) ss2 = sourcestamp.SourceStamp(branch='dev', revision='xyz', project='p', repository='r', codebase='cb', changes=[]) self.assertFalse(ss1.canBeMergedWith(ss2)) def test_canBeMergedWith_where_sourcestamp_have_different_codebases(self): ss1 = sourcestamp.SourceStamp(branch='dev', revision='xyz', project='p', repository='r', codebase='cbA', changes=[]) ss2 = sourcestamp.SourceStamp(branch='dev', revision='xyz', project='p', repository='r', codebase='cbB', changes=[]) self.assertFalse(ss1.canBeMergedWith(ss2)) def test_canBeMergedWith_with_self_patched_sourcestamps(self): ss = sourcestamp.SourceStamp(branch='dev', revision='xyz', project='p', repository='r', codebase='cbA', changes=[], patch=(1, '')) self.assertTrue(ss.canBeMergedWith(ss)) def test_constructor_most_recent_change(self): chgs = [ changes.Change('author', [], 'comments', branch='branch', revision='2'), changes.Change('author', [], 'comments', branch='branch', revision='3'), changes.Change('author', [], 'comments', branch='branch', revision='1'), ] for ch in chgs: # mock the DB changeid (aka build number) to match rev ch.number = int(ch.revision) ss = sourcestamp.SourceStamp(changes=chgs) self.assertEquals(ss.revision, '3') buildbot-0.8.8/buildbot/test/unit/test_status_build.py000066400000000000000000000134651222546025000232170ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements import mock from twisted.trial import unittest from buildbot.status import build from buildbot import interfaces from buildbot.test.fake import fakemaster from buildbot import util class FakeBuilderStatus: implements(interfaces.IBuilderStatus) class FakeSource(util.ComparableMixin): compare_attrs = ('codebase', 'revision') def __init__(self, codebase, revision): self.codebase = codebase self.revision = revision def clone(self): return FakeSource(self.codebase, self.revision) def getAbsoluteSourceStamp(self, revision): return FakeSource(self.codebase, revision) def __repr__(self): # note: this won't work for VC systems with huge 'revision' strings text = [] if self.codebase: text.append("(%s)" % self.codebase) if self.revision is None: return text + [ "latest" ] text.append(str(self.revision)) return "FakeSource(%s)" % (', '.join(text),) class TestBuildProperties(unittest.TestCase): """ Test that a BuildStatus has the necessary L{IProperties} methods and that they delegate to its C{properties} attribute properly - so really just a test of the L{IProperties} adapter. """ BUILD_NUMBER = 33 def setUp(self): self.builder_status = FakeBuilderStatus() self.master = fakemaster.make_master() self.build_status = build.BuildStatus(self.builder_status, self.master, self.BUILD_NUMBER) self.build_status.properties = mock.Mock() def test_getProperty(self): self.build_status.getProperty('x') self.build_status.properties.getProperty.assert_called_with('x', None) def test_getProperty_default(self): self.build_status.getProperty('x', 'nox') self.build_status.properties.getProperty.assert_called_with('x', 'nox') def test_setProperty(self): self.build_status.setProperty('n', 'v', 's') self.build_status.properties.setProperty.assert_called_with('n', 'v', 's', runtime=True) def test_hasProperty(self): self.build_status.properties.hasProperty.return_value = True self.assertTrue(self.build_status.hasProperty('p')) self.build_status.properties.hasProperty.assert_called_with('p') def test_render(self): self.build_status.render("xyz") self.build_status.properties.render.assert_called_with("xyz") class TestBuildGetSourcestamps(unittest.TestCase): """ Test that a BuildStatus has the necessary L{IProperties} methods and that they delegate to its C{properties} attribute properly - so really just a test of the L{IProperties} adapter. """ BUILD_NUMBER = 33 def setUp(self): self.builder_status = FakeBuilderStatus() self.master = fakemaster.make_master() self.build_status = build.BuildStatus(self.builder_status, self.master, self.BUILD_NUMBER) def test_getSourceStamps_no_codebases(self): got_revisions = {'': '1111111'} self.build_status.sources = [FakeSource('', '0000000')] self.build_status.setProperty('got_revision', got_revisions) sourcestamps = [ss for ss in self.build_status.getSourceStamps(absolute=False)] self.assertEqual(sourcestamps, [FakeSource('', '0000000')]) def test_getSourceStamps_no_codebases_absolute(self): got_revisions = {'': '1111111'} self.build_status.sources = [FakeSource('', '0000000')] self.build_status.setProperty('got_revision', got_revisions) sourcestamps = [ss for ss in self.build_status.getSourceStamps(absolute=True)] self.assertEqual(sourcestamps, [FakeSource('', '1111111')]) def test_getSourceStamps_with_codebases_absolute(self): got_revisions = {'lib1': '1111111', 'lib2': 'aaaaaaa'} self.build_status.sources = [FakeSource('lib1', '0000000'), FakeSource('lib2', '0000000')] self.build_status.setProperty('got_revision', got_revisions) sourcestamps = [ss for ss in self.build_status.getSourceStamps(absolute=True)] expected_sourcestamps = [FakeSource('lib1', '1111111'), FakeSource('lib2', 'aaaaaaa')] self.assertEqual(sourcestamps, expected_sourcestamps) def test_getSourceStamps_with_codebases_less_gotrevisions_absolute(self): got_revisions = {'lib1': '1111111', 'lib2': 'aaaaaaa'} self.build_status.sources = [FakeSource('lib1', '0000000'), FakeSource('lib2', '0000000'), FakeSource('lib3', '0000000')] self.build_status.setProperty('got_revision', got_revisions) sourcestamps = [ss for ss in self.build_status.getSourceStamps(absolute=True)] expected_sourcestamps = [FakeSource('lib1', '1111111'), FakeSource('lib2', 'aaaaaaa'), FakeSource('lib3', '0000000')] self.assertEqual(sourcestamps, expected_sourcestamps) buildbot-0.8.8/buildbot/test/unit/test_status_builder_cache.py000066400000000000000000000055661222546025000246740ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os from mock import Mock from twisted.trial import unittest from buildbot.status import builder, master from buildbot.test.fake import fakemaster class TestBuildStatus(unittest.TestCase): # that buildstep.BuildStepStatus is never instantiated here should tell you # that these classes are not well isolated! def setupBuilder(self, buildername, category=None, description=None): m = fakemaster.make_master() b = builder.BuilderStatus(buildername=buildername, category=category, master=m, description=description) # Awkwardly, Status sets this member variable. b.basedir = os.path.abspath(self.mktemp()) os.mkdir(b.basedir) # Otherwise, builder.nextBuildNumber is not defined. b.determineNextBuildNumber() # Must initialize these fields before pickling. b.currentBigState = 'idle' b.status = 'idle' return b def setupStatus(self, b): m = Mock() m.buildbotURL = 'http://buildbot:8010/' m.basedir = '/basedir' s = master.Status(m) b.status = s return s def testBuildCache(self): b = self.setupBuilder('builder_1') builds = [] for i in xrange(5): build = b.newBuild() build.setProperty('propkey', 'propval%d' % i, 'test') builds.append(build) build.buildStarted(build) build.buildFinished() for build in builds: build2 = b.getBuild(build.number) self.assertTrue(build2) self.assertEqual(build2.number, build.number) self.assertEqual(build2.getProperty('propkey'), 'propval%d' % build.number) # Do another round, to make sure we're hitting the cache hits = b.buildCache.hits for build in builds: build2 = b.getBuild(build.number) self.assertTrue(build2) self.assertEqual(build2.number, build.number) self.assertEqual(build2.getProperty('propkey'), 'propval%d' % build.number) self.assertEqual(b.buildCache.hits, hits+1) hits = hits + 1 buildbot-0.8.8/buildbot/test/unit/test_status_buildstep.py000066400000000000000000000050431222546025000241040ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os from twisted.trial import unittest from buildbot.status import builder, master from buildbot.test.fake import fakemaster class TestBuildStepStatus(unittest.TestCase): # that buildstep.BuildStepStatus is never instantiated here should tell you # that these classes are not well isolated! def setupBuilder(self, buildername, category=None, description=None): self.master = fakemaster.make_master() self.master.basedir = '/basedir' b = builder.BuilderStatus(buildername, self.master, category, description) b.master = self.master # Ackwardly, Status sets this member variable. b.basedir = os.path.abspath(self.mktemp()) os.mkdir(b.basedir) # Otherwise, builder.nextBuildNumber is not defined. b.determineNextBuildNumber() return b def setupStatus(self, b): s = master.Status(self.master) b.status = s return s def testBuildStepNumbers(self): b = self.setupBuilder('builder_1') bs = b.newBuild() self.assertEquals(0, bs.getNumber()) bss1 = bs.addStepWithName('step_1') self.assertEquals('step_1', bss1.getName()) bss2 = bs.addStepWithName('step_2') self.assertEquals(0, bss1.asDict()['step_number']) self.assertEquals('step_2', bss2.getName()) self.assertEquals(1, bss2.asDict()['step_number']) self.assertEquals([bss1, bss2], bs.getSteps()) def testLogDict(self): b = self.setupBuilder('builder_1') self.setupStatus(b) bs = b.newBuild() bss1 = bs.addStepWithName('step_1') bss1.stepStarted() bss1.addLog('log_1') self.assertEquals( bss1.asDict()['logs'], [['log_1', ('http://localhost:8080/builders/builder_1/' 'builds/0/steps/step_1/logs/log_1')]] ) buildbot-0.8.8/buildbot/test/unit/test_status_client.py000066400000000000000000000034311222546025000233660ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from buildbot.status import master, client from buildbot.test.fake import fakedb class TestStatusClientPerspective(unittest.TestCase): def makeStatusClientPersp(self): m = mock.Mock(name='master') self.db = m.db = fakedb.FakeDBConnector(self) m.basedir = r'C:\BASEDIR' s = master.Status(m) persp = client.StatusClientPerspective(s) return persp def test_getBuildSets(self): persp = self.makeStatusClientPersp() self.db.insertTestData([ fakedb.SourceStampSet(id=234), fakedb.Buildset(id=91, sourcestampsetid=234, complete=0, complete_at=298297875, results=-1, submitted_at=266761875, external_idstring='extid', reason='rsn1'), ]) d = persp.perspective_getBuildSets() def check(bslist): self.assertEqual(len(bslist), 1) self.assertEqual(bslist[0][1], 91) self.failUnlessIsInstance(bslist[0][0], client.RemoteBuildSet) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_status_logfile.py000066400000000000000000000302451222546025000235340ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import cStringIO, cPickle import mock from twisted.trial import unittest from twisted.internet import defer from buildbot.status import logfile from buildbot.test.util import dirs from buildbot import config class TestLogFileProducer(unittest.TestCase): def make_static_logfile(self, contents): "make a fake logfile with the given contents" lf = mock.Mock() lf.getFile = lambda : cStringIO.StringIO(contents) lf.waitUntilFinished = lambda : defer.succeed(None) # already finished lf.runEntries = [] return lf def test_getChunks_static_helloworld(self): lf = self.make_static_logfile("13:0hello world!,") lfp = logfile.LogFileProducer(lf, mock.Mock()) chunks = list(lfp.getChunks()) self.assertEqual(chunks, [ (0, 'hello world!') ]) def test_getChunks_static_multichannel(self): lf = self.make_static_logfile("2:0a,3:1xx,2:0c,") lfp = logfile.LogFileProducer(lf, mock.Mock()) chunks = list(lfp.getChunks()) self.assertEqual(chunks, [ (0, 'a'), (1, 'xx'), (0, 'c') ]) # Remainder of LogFileProduer has a wacky interface that's not # well-defined, so it's not tested yet class TestLogFile(unittest.TestCase, dirs.DirsMixin): def setUp(self): step = self.build_step_status = mock.Mock(name='build_step_status') self.basedir = step.build.builder.basedir = os.path.abspath('basedir') self.setUpDirs(self.basedir) self.logfile = logfile.LogFile(step, 'testlf', '123-stdio') self.master = self.logfile.master = mock.Mock() self.config = self.logfile.master.config = config.MasterConfig() def tearDown(self): if self.logfile.openfile: try: self.logfile.openfile.close() except: pass # oh well, we tried self.tearDownDirs() def pickle_and_restore(self): pkl = cPickle.dumps(self.logfile) self.logfile = cPickle.loads(pkl) step = self.build_step_status self.logfile.step = step self.logfile.master = self.master step.build.builder.basedir = self.basedir def delete_logfile(self): if self.logfile.openfile: try: self.logfile.openfile.close() except: pass # oh well, we tried os.unlink(os.path.join('basedir', '123-stdio')) # tests def test_getFilename(self): self.assertEqual(self.logfile.getFilename(), os.path.abspath(os.path.join('basedir', '123-stdio'))) def test_hasContents_yes(self): self.assertTrue(self.logfile.hasContents()) def test_hasContents_no(self): self.delete_logfile() self.assertFalse(self.logfile.hasContents()) def test_hasContents_gz(self): self.delete_logfile() with open(os.path.join(self.basedir, '123-stdio.gz'), "w") as f: f.write("hi") self.assertTrue(self.logfile.hasContents()) def test_hasContents_gz_pickled(self): self.delete_logfile() with open(os.path.join(self.basedir, '123-stdio.gz'), "w") as f: f.write("hi") self.pickle_and_restore() self.assertTrue(self.logfile.hasContents()) def test_hasContents_bz2(self): self.delete_logfile() with open(os.path.join(self.basedir, '123-stdio.bz2'), "w") as f: f.write("hi") self.assertTrue(self.logfile.hasContents()) def test_getName(self): self.assertEqual(self.logfile.getName(), 'testlf') def test_getStep(self): self.assertEqual(self.logfile.getStep(), self.build_step_status) def test_isFinished_no(self): self.assertFalse(self.logfile.isFinished()) def test_isFinished_yes(self): self.logfile.finish() self.assertTrue(self.logfile.isFinished()) def test_waitUntilFinished(self): state = [] d = self.logfile.waitUntilFinished() d.addCallback(lambda _ : state.append('called')) self.assertEqual(state, []) # not called yet self.logfile.finish() self.assertEqual(state, ['called']) def test_getFile(self): # test getFile at a number of points in the life-cycle self.logfile.addEntry(0, 'hello, world') self.logfile._merge() # while still open for writing fp = self.logfile.getFile() fp.seek(0, 0) self.assertEqual(fp.read(), '13:0hello, world,') self.logfile.finish() # fp is still open after finish() fp.seek(0, 0) self.assertEqual(fp.read(), '13:0hello, world,') # but a fresh getFile call works, too fp = self.logfile.getFile() fp.seek(0, 0) self.assertEqual(fp.read(), '13:0hello, world,') self.pickle_and_restore() # even after it is pickled fp = self.logfile.getFile() fp.seek(0, 0) self.assertEqual(fp.read(), '13:0hello, world,') # ..and compressed self.config.logCompressionMethod = 'bz2' d = self.logfile.compressLog() def check(_): self.assertTrue( os.path.exists(os.path.join(self.basedir, '123-stdio.bz2'))) fp = self.logfile.getFile() fp.seek(0, 0) self.assertEqual(fp.read(), '13:0hello, world,') d.addCallback(check) return d def do_test_addEntry(self, entries, expected): for chan, txt in entries: self.logfile.addEntry(chan, txt) self.logfile.finish() fp = self.logfile.getFile() fp.seek(0, 0) self.assertEqual(fp.read(), expected) def test_addEntry_single(self): return self.do_test_addEntry([(0, 'hello, world')], '13:0hello, world,') def test_addEntry_run(self): # test that addEntry is calling merge() correctly return self.do_test_addEntry([ (0, c) for c in 'hello, world' ], '13:0hello, world,') def test_addEntry_multichan(self): return self.do_test_addEntry([(1, 'x'), (2, 'y'), (1, 'z')], '2:1x,2:2y,2:1z,') def test_addEntry_length(self): self.do_test_addEntry([(1, 'x'), (2, 'y')], '2:1x,2:2y,') self.assertEqual(self.logfile.length, 2) def test_addEntry_unicode(self): return self.do_test_addEntry([(1, u'\N{SNOWMAN}')], '4:1\xe2\x98\x83,') # utf-8 encoded def test_addEntry_logMaxSize(self): self.config.logMaxSize = 10 # not evenly divisible by chunk size return self.do_test_addEntry([(0, 'abcdef')] * 10 , '11:0abcdefabcd,' '64:2\nOutput exceeded 10 bytes, remaining output has been ' 'truncated\n,') def test_addEntry_logMaxSize_ignores_header(self): self.config.logMaxSize = 10 return self.do_test_addEntry([(logfile.HEADER, 'abcdef')] * 10 , '61:2' + 'abcdef'*10 + ',') def test_addEntry_logMaxSize_divisor(self): self.config.logMaxSize = 12 # evenly divisible by chunk size return self.do_test_addEntry([(0, 'abcdef')] * 10 , '13:0abcdefabcdef,' '64:2\nOutput exceeded 12 bytes, remaining output has been ' 'truncated\n,') def test_addEntry_logMaxTailSize(self): self.config.logMaxSize = 10 self.config.logMaxTailSize = 14 return self.do_test_addEntry([(0, 'abcdef')] * 10 , '11:0abcdefabcd,' '64:2\nOutput exceeded 10 bytes, remaining output has been ' 'truncated\n,' # NOTE: this gets too few bytes; this is OK for now, and # easier than subdividing chunks in the tail tracking '31:2\nFinal 12 bytes follow below:\n,' '13:0abcdefabcdef,') def test_addEntry_logMaxTailSize_divisor(self): self.config.logMaxSize = 10 self.config.logMaxTailSize = 12 return self.do_test_addEntry([(0, 'abcdef')] * 10 , '11:0abcdefabcd,' '64:2\nOutput exceeded 10 bytes, remaining output has been ' 'truncated\n,' '31:2\nFinal 12 bytes follow below:\n,' '13:0abcdefabcdef,') # TODO: test that head and tail don't discriminate between stderr and stdout def test_addEntry_chunkSize(self): self.logfile.chunkSize = 11 return self.do_test_addEntry([(0, 'abcdef')] * 10 , # note that this doesn't re-chunk everything; just shrinks # chunks that will exceed the maximum size '12:0abcdefabcde,2:0f,' * 5) def test_addEntry_big_channel(self): # channels larger than one digit are not allowed self.assertRaises(AssertionError, lambda : self.do_test_addEntry([(9999, 'x')], '')) def test_addEntry_finished(self): self.logfile.finish() self.assertRaises(AssertionError, lambda : self.do_test_addEntry([(0, 'x')], '')) def test_addEntry_merge_exception(self): def fail(): raise RuntimeError("FAIL") self.patch(self.logfile, '_merge', fail) self.assertRaises(RuntimeError, lambda : self.do_test_addEntry([(0, 'x')], '')) def test_addEntry_watchers(self): watcher = mock.Mock(name='watcher') self.logfile.watchers.append(watcher) self.do_test_addEntry([(0, 'x')], '2:0x,') watcher.logChunk.assert_called_with(self.build_step_status.build, self.build_step_status, self.logfile, 0, 'x') def test_addEntry_watchers_logMaxSize(self): watcher = mock.Mock(name='watcher') self.logfile.watchers.append(watcher) self.config.logMaxSize = 10 self.do_test_addEntry([(0, 'x')] * 15, '11:0xxxxxxxxxx,' '64:2\nOutput exceeded 10 bytes, remaining output has been ' 'truncated\n,') logChunk_chunks = [ tuple(args[0][3:]) for args in watcher.logChunk.call_args_list ] self.assertEqual(logChunk_chunks, [(0, 'x')] * 15) def test_addStdout(self): addEntry = mock.Mock() self.patch(self.logfile, 'addEntry', addEntry) self.logfile.addStdout('oot') addEntry.assert_called_with(0, 'oot') def test_addStderr(self): addEntry = mock.Mock() self.patch(self.logfile, 'addEntry', addEntry) self.logfile.addStderr('eer') addEntry.assert_called_with(1, 'eer') def test_addHeader(self): addEntry = mock.Mock() self.patch(self.logfile, 'addEntry', addEntry) self.logfile.addHeader('hed') addEntry.assert_called_with(2, 'hed') def do_test_compressLog(self, ext, expect_comp=True): self.logfile.openfile.write('xyz' * 1000) self.logfile.finish() d = self.logfile.compressLog() def check(_): st = os.stat(self.logfile.getFilename() + ext) if expect_comp: self.assertTrue(0 < st.st_size < 3000) else: self.assertTrue(st.st_size == 3000) d.addCallback(check) return d def test_compressLog_gz(self): self.config.logCompressionMethod = 'gz' return self.do_test_compressLog('.gz') def test_compressLog_bz2(self): self.config.logCompressionMethod = 'bz2' return self.do_test_compressLog('.bz2') def test_compressLog_none(self): self.config.logCompressionMethod = None return self.do_test_compressLog('', expect_comp=False) buildbot-0.8.8/buildbot/test/unit/test_status_mail.py000066400000000000000000001026111222546025000230320ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sys from mock import Mock from buildbot import config from twisted.trial import unittest from buildbot.status.results import SUCCESS, FAILURE, WARNINGS, EXCEPTION from buildbot.status.mail import MailNotifier from twisted.internet import defer from buildbot.test.fake import fakedb from buildbot.test.fake.fakebuild import FakeBuildStatus from buildbot.process import properties py_27 = sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 7) class FakeLog(object): def __init__(self, text): self.text = text def getName(self): return 'log-name' def getStep(self): class FakeStep(object): def getName(self): return 'step-name' return FakeStep() def getText(self): return self.text class FakeSource: def __init__(self, branch = None, revision = None, repository = None, codebase = None, project = None): self.changes = [] self.branch = branch self.revision = revision self.repository = repository self.codebase = codebase self.project = project self.patch_info = None self.patch = None class TestMailNotifier(unittest.TestCase): def do_test_createEmail_cte(self, funnyChars, expEncoding): builds = [ FakeBuildStatus(name='build') ] msgdict = create_msgdict(funnyChars) mn = MailNotifier('from@example.org') d = mn.createEmail(msgdict, u'builder-name', u'project-name', SUCCESS, builds) @d.addCallback def callback(m): cte_lines = [ l for l in m.as_string().split("\n") if l.startswith('Content-Transfer-Encoding:') ] self.assertEqual(cte_lines, [ 'Content-Transfer-Encoding: %s' % expEncoding ], `m.as_string()`) return d def test_createEmail_message_content_transfer_encoding_7bit(self): return self.do_test_createEmail_cte(u"old fashioned ascii", '7bit' if py_27 else 'base64') def test_createEmail_message_content_transfer_encoding_8bit(self): return self.do_test_createEmail_cte(u"\U0001F4A7", '8bit' if py_27 else 'base64') def test_createEmail_message_without_patch_and_log_contains_unicode(self): builds = [ FakeBuildStatus(name="build") ] msgdict = create_msgdict() mn = MailNotifier('from@example.org') d = mn.createEmail(msgdict, u'builder-n\u00E5me', u'project-n\u00E5me', SUCCESS, builds) @d.addCallback def callback(m): try: m.as_string() except UnicodeEncodeError: self.fail('Failed to call as_string() on email message.') return d def test_createEmail_extraHeaders_one_build(self): builds = [ FakeBuildStatus(name="build") ] builds[0].properties = properties.Properties() builds[0].setProperty('hhh','vvv') msgdict = create_msgdict() mn = MailNotifier('from@example.org', extraHeaders=dict(hhh='vvv')) # add some Unicode to detect encoding problems d = mn.createEmail(msgdict, u'builder-n\u00E5me', u'project-n\u00E5me', SUCCESS, builds) @d.addCallback def callback(m): txt = m.as_string() self.assertIn('hhh: vvv', txt) return d def test_createEmail_extraHeaders_two_builds(self): builds = [ FakeBuildStatus(name="build1"), FakeBuildStatus(name="build2") ] msgdict = create_msgdict() mn = MailNotifier('from@example.org', extraHeaders=dict(hhh='vvv')) d = mn.createEmail(msgdict, u'builder-n\u00E5me', u'project-n\u00E5me', SUCCESS, builds) @d.addCallback def callback(m): txt = m.as_string() # note that the headers are *not* rendered self.assertIn('hhh: vvv', txt) return d def test_createEmail_message_with_patch_and_log_containing_unicode(self): builds = [ FakeBuildStatus(name="build") ] msgdict = create_msgdict() patches = [ ['', u'\u00E5\u00E4\u00F6', ''] ] msg = u'Unicode log with non-ascii (\u00E5\u00E4\u00F6).' # add msg twice: as unicode and already encoded logs = [ FakeLog(msg), FakeLog(msg.encode('utf-8')) ] mn = MailNotifier('from@example.org', addLogs=True) d = mn.createEmail(msgdict, u'builder-n\u00E5me', u'project-n\u00E5me', SUCCESS, builds, patches, logs) @d.addCallback def callback(m): try: m.as_string() except UnicodeEncodeError: self.fail('Failed to call as_string() on email message.') return d def test_createEmail_message_with_nonascii_patch(self): builds = [ FakeBuildStatus(name="build") ] msgdict = create_msgdict() patches = [ ['', '\x99\xaa', ''] ] logs = [ FakeLog('simple log') ] mn = MailNotifier('from@example.org', addLogs=True) d = mn.createEmail(msgdict, u'builder', u'pr', SUCCESS, builds, patches, logs) @d.addCallback def callback(m): txt = m.as_string() self.assertIn('application/octet-stream', txt) return d def test_init_enforces_categories_and_builders_are_mutually_exclusive(self): self.assertRaises(config.ConfigErrors, MailNotifier, 'from@example.org', categories=['fast','slow'], builders=['a','b']) def test_builderAdded_ignores_unspecified_categories(self): mn = MailNotifier('from@example.org', categories=['fast']) builder = Mock() builder.category = 'slow' self.assertEqual(None, mn.builderAdded('dummyBuilder', builder)) self.assert_(builder not in mn.watched) def test_builderAdded_subscribes_to_all_builders_by_default(self): mn = MailNotifier('from@example.org') builder = Mock() builder.category = 'slow' builder2 = Mock() builder2.category = None self.assertEqual(mn, mn.builderAdded('dummyBuilder', builder)) self.assertEqual(mn, mn.builderAdded('dummyBuilder2', builder2)) self.assertTrue(builder in mn.watched) self.assertTrue(builder2 in mn.watched) def test_buildFinished_ignores_unspecified_builders(self): mn = MailNotifier('from@example.org', builders=['a','b']) build = FakeBuildStatus() build.builder = Mock() self.assertEqual(None, mn.buildFinished('dummyBuilder', build, SUCCESS)) def test_buildsetFinished_sends_email(self): fakeBuildMessage = Mock() mn = MailNotifier('from@example.org', buildSetSummary=True, mode=("failing", "passing", "warnings"), builders=["Builder1", "Builder2"]) mn.buildMessage = fakeBuildMessage builder1 = Mock() builder1.getBuild = lambda number: build1 builder1.name = "Builder1" build1 = FakeBuildStatus() build1.results = FAILURE build1.finished = True build1.reason = "testReason" build1.getBuilder.return_value = builder1 builder2 = Mock() builder2.getBuild = lambda number: build2 builder2.name = "Builder2" build2 = FakeBuildStatus() build2.results = FAILURE build2.finished = True build2.reason = "testReason" build2.getBuilder.return_value = builder1 def fakeGetBuilder(buildername): return {"Builder1": builder1, "Builder2": builder2}[buildername] self.db = fakedb.FakeDBConnector(self) self.db.insertTestData([fakedb.SourceStampSet(id=127), fakedb.Buildset(id=99, sourcestampsetid=127, results=SUCCESS, reason="testReason"), fakedb.BuildRequest(id=11, buildsetid=99, buildername='Builder1'), fakedb.Build(number=0, brid=11), fakedb.BuildRequest(id=12, buildsetid=99, buildername='Builder2'), fakedb.Build(number=0, brid=12), ]) mn.master = self # FIXME: Should be FakeMaster self.status = Mock() mn.master_status = Mock() mn.master_status.getBuilder = fakeGetBuilder mn.buildMessageDict = Mock() mn.buildMessageDict.return_value = {"body":"body", "type":"text", "subject":"subject"} mn.buildsetFinished(99, FAILURE) fakeBuildMessage.assert_called_with("Buildset Complete: testReason", [build1, build2], SUCCESS) def test_buildsetFinished_doesnt_send_email(self): fakeBuildMessage = Mock() mn = MailNotifier('from@example.org', buildSetSummary=True, mode=("failing", "warnings"), builders=["Builder"]) mn.buildMessage = fakeBuildMessage def fakeGetBuild(number): return build def fakeGetBuilder(buildername): if buildername == builder.name: return builder return None def fakeGetBuildRequests(self, bsid): return defer.succeed([{"buildername":"Builder", "brid":1}]) builder = Mock() builder.getBuild = fakeGetBuild builder.name = "Builder" build = FakeBuildStatus() build.results = SUCCESS build.finished = True build.reason = "testReason" build.getBuilder.return_value = builder self.db = fakedb.FakeDBConnector(self) self.db.insertTestData([fakedb.SourceStampSet(id=127), fakedb.Buildset(id=99, sourcestampsetid=127, results=SUCCESS, reason="testReason"), fakedb.BuildRequest(id=11, buildsetid=99, buildername='Builder'), fakedb.Build(number=0, brid=11), ]) mn.master = self self.status = Mock() mn.master_status = Mock() mn.master_status.getBuilder = fakeGetBuilder mn.buildMessageDict = Mock() mn.buildMessageDict.return_value = {"body":"body", "type":"text", "subject":"subject"} mn.buildsetFinished(99, FAILURE) self.assertFalse(fakeBuildMessage.called) def test_getCustomMesgData_multiple_sourcestamps(self): self.passedAttrs = {} def fakeCustomMessage(attrs): self.passedAttrs = attrs return ("", "") mn = MailNotifier('from@example.org', buildSetSummary=True, mode=("failing", "passing", "warnings"), builders=["Builder"]) def fakeBuildMessage(name, builds, results): for build in builds: mn.buildMessageDict(name=build.getBuilder().name, build=build, results=build.results) mn.buildMessage = fakeBuildMessage mn.customMesg = fakeCustomMessage def fakeGetBuild(number): return build def fakeGetBuilder(buildername): if buildername == builder.name: return builder return None def fakeGetBuildRequests(self, bsid): return defer.succeed([{"buildername":"Builder", "brid":1}]) self.db = fakedb.FakeDBConnector(self) self.db.insertTestData([fakedb.SourceStampSet(id=127), fakedb.Buildset(id=99, sourcestampsetid=127, results=SUCCESS, reason="testReason"), fakedb.BuildRequest(id=11, buildsetid=99, buildername='Builder'), fakedb.Build(number=0, brid=11), ]) mn.master = self builder = Mock() builder.getBuild = fakeGetBuild builder.name = "Builder" build = FakeBuildStatus() build.results = FAILURE build.finished = True build.reason = "testReason" build.getLogs.return_value = [] build.getBuilder.return_value = builder self.status = Mock() mn.master_status = Mock() mn.master_status.getBuilder = fakeGetBuilder ss1 = FakeSource(revision='111222', codebase='testlib1') ss2 = FakeSource(revision='222333', codebase='testlib2') build.getSourceStamps.return_value = [ss1, ss2] mn.buildsetFinished(99, FAILURE) self.assertTrue('revision' in self.passedAttrs, "No revision entry found in attrs") self.assertTrue(isinstance(self.passedAttrs['revision'], dict)) self.assertEqual(self.passedAttrs['revision']['testlib1'], '111222') self.assertEqual(self.passedAttrs['revision']['testlib2'], '222333') def test_getCustomMesgData_single_sourcestamp(self): self.passedAttrs = {} def fakeCustomMessage(attrs): self.passedAttrs = attrs return ("", "") mn = MailNotifier('from@example.org', buildSetSummary=True, mode=("failing", "passing", "warnings"), builders=["Builder"]) def fakeBuildMessage(name, builds, results): for build in builds: mn.buildMessageDict(name=build.getBuilder().name, build=build, results=build.results) mn.buildMessage = fakeBuildMessage mn.customMesg = fakeCustomMessage def fakeGetBuild(number): return build def fakeGetBuilder(buildername): if buildername == builder.name: return builder return None def fakeGetBuildRequests(self, bsid): return defer.succeed([{"buildername":"Builder", "brid":1}]) self.db = fakedb.FakeDBConnector(self) self.db.insertTestData([fakedb.SourceStampSet(id=127), fakedb.Buildset(id=99, sourcestampsetid=127, results=SUCCESS, reason="testReason"), fakedb.BuildRequest(id=11, buildsetid=99, buildername='Builder'), fakedb.Build(number=0, brid=11), ]) mn.master = self builder = Mock() builder.getBuild = fakeGetBuild builder.name = "Builder" build = FakeBuildStatus() build.results = FAILURE build.finished = True build.reason = "testReason" build.getLogs.return_value = [] build.getBuilder.return_value = builder self.status = Mock() mn.master_status = Mock() mn.master_status.getBuilder = fakeGetBuilder ss1 = FakeSource(revision='111222', codebase='testlib1') build.getSourceStamps.return_value = [ss1] mn.buildsetFinished(99, FAILURE) self.assertTrue('builderName' in self.passedAttrs, "No builderName entry found in attrs") self.assertEqual(self.passedAttrs['builderName'], 'Builder') self.assertTrue('revision' in self.passedAttrs, "No revision entry found in attrs") self.assertTrue(isinstance(self.passedAttrs['revision'], str)) self.assertEqual(self.passedAttrs['revision'], '111222') def test_buildFinished_ignores_unspecified_categories(self): mn = MailNotifier('from@example.org', categories=['fast']) build = FakeBuildStatus(name="build") build.builder = Mock() build.builder.category = 'slow' self.assertEqual(None, mn.buildFinished('dummyBuilder', build, SUCCESS)) def run_simple_test_sends_email_for_mode(self, mode, result): mock_method = Mock() self.patch(MailNotifier, "buildMessage", mock_method) mn = MailNotifier('from@example.org', mode=mode) build = FakeBuildStatus(name="build") mn.buildFinished('dummyBuilder', build, result) mock_method.assert_called_with('dummyBuilder', [build], result) def run_simple_test_ignores_email_for_mode(self, mode, result): mock_method = Mock() self.patch(MailNotifier, "buildMessage", mock_method) mn = MailNotifier('from@example.org', mode=mode) build = FakeBuildStatus(name="build") mn.buildFinished('dummyBuilder', build, result) self.assertFalse(mock_method.called) def test_buildFinished_mode_all_for_success(self): self.run_simple_test_sends_email_for_mode("all", SUCCESS) def test_buildFinished_mode_all_for_failure(self): self.run_simple_test_sends_email_for_mode("all", FAILURE) def test_buildFinished_mode_all_for_warnings(self): self.run_simple_test_sends_email_for_mode("all", WARNINGS) def test_buildFinished_mode_all_for_exception(self): self.run_simple_test_sends_email_for_mode("all", EXCEPTION) def test_buildFinished_mode_failing_for_success(self): self.run_simple_test_ignores_email_for_mode("failing", SUCCESS) def test_buildFinished_mode_failing_for_failure(self): self.run_simple_test_sends_email_for_mode("failing", FAILURE) def test_buildFinished_mode_failing_for_warnings(self): self.run_simple_test_ignores_email_for_mode("failing", WARNINGS) def test_buildFinished_mode_failing_for_exception(self): self.run_simple_test_ignores_email_for_mode("failing", EXCEPTION) def test_buildFinished_mode_exception_for_success(self): self.run_simple_test_ignores_email_for_mode("exception", SUCCESS) def test_buildFinished_mode_exception_for_failure(self): self.run_simple_test_ignores_email_for_mode("exception", FAILURE) def test_buildFinished_mode_exception_for_warnings(self): self.run_simple_test_ignores_email_for_mode("exception", WARNINGS) def test_buildFinished_mode_exception_for_exception(self): self.run_simple_test_sends_email_for_mode("exception", EXCEPTION) def test_buildFinished_mode_warnings_for_success(self): self.run_simple_test_ignores_email_for_mode("warnings", SUCCESS) def test_buildFinished_mode_warnings_for_failure(self): self.run_simple_test_sends_email_for_mode("warnings", FAILURE) def test_buildFinished_mode_warnings_for_warnings(self): self.run_simple_test_sends_email_for_mode("warnings", WARNINGS) def test_buildFinished_mode_warnings_for_exception(self): self.run_simple_test_ignores_email_for_mode("warnings", EXCEPTION) def test_buildFinished_mode_passing_for_success(self): self.run_simple_test_sends_email_for_mode("passing", SUCCESS) def test_buildFinished_mode_passing_for_failure(self): self.run_simple_test_ignores_email_for_mode("passing", FAILURE) def test_buildFinished_mode_passing_for_warnings(self): self.run_simple_test_ignores_email_for_mode("passing", WARNINGS) def test_buildFinished_mode_passing_for_exception(self): self.run_simple_test_ignores_email_for_mode("passing", EXCEPTION) def test_buildFinished_mode_failing_ignores_successful_build(self): mn = MailNotifier('from@example.org', mode=("failing",)) build = FakeBuildStatus(name="build") self.assertEqual(None, mn.buildFinished('dummyBuilder', build, SUCCESS)) def test_buildFinished_mode_passing_ignores_failed_build(self): mn = MailNotifier('from@example.org', mode=("passing",)) build = FakeBuildStatus(name="build") self.assertEqual(None, mn.buildFinished('dummyBuilder', build, FAILURE)) def test_buildFinished_mode_problem_ignores_successful_build(self): mn = MailNotifier('from@example.org', mode=("problem",)) build = FakeBuildStatus(name="build") self.assertEqual(None, mn.buildFinished('dummyBuilder', build, SUCCESS)) def test_buildFinished_mode_problem_ignores_two_failed_builds_in_sequence(self): mn = MailNotifier('from@example.org', mode=("problem",)) build = FakeBuildStatus(name="build") old_build = FakeBuildStatus(name="old_build") build.getPreviousBuild.return_value = old_build old_build.getResults.return_value = FAILURE self.assertEqual(None, mn.buildFinished('dummyBuilder', build, FAILURE)) def test_buildFinished_mode_change_ignores_first_build(self): mn = MailNotifier('from@example.org', mode=("change",)) build = FakeBuildStatus(name="build") build.getPreviousBuild.return_value = None self.assertEqual(None, mn.buildFinished('dummyBuilder', build, FAILURE)) self.assertEqual(None, mn.buildFinished('dummyBuilder', build, SUCCESS)) def test_buildFinished_mode_change_ignores_same_result_in_sequence(self): mn = MailNotifier('from@example.org', mode=("change",)) build = FakeBuildStatus(name="build") old_build = FakeBuildStatus(name="old_build") build.getPreviousBuild.return_value = old_build old_build.getResults.return_value = FAILURE build2 = FakeBuildStatus(name="build2") old_build2 = FakeBuildStatus(name="old_build2") build2.getPreviousBuild.return_value = old_build2 old_build2.getResults.return_value = SUCCESS self.assertEqual(None, mn.buildFinished('dummyBuilder', build, FAILURE)) self.assertEqual(None, mn.buildFinished('dummyBuilder', build2, SUCCESS)) def test_buildMessage_addLogs(self): mn = MailNotifier('from@example.org', mode=("change",), addLogs=True) mn.buildMessageDict = Mock() mn.buildMessageDict.return_value = {"body":"body", "type":"text", "subject":"subject"} mn.createEmail = Mock("createEmail") mn._gotRecipients = Mock("_gotReceipients") mn._gotRecipients.return_value = None mn.master_status = Mock() mn.master_status.getTitle.return_value = 'TITLE' bldr = Mock(name="builder") builds = [ FakeBuildStatus(name='build1'), FakeBuildStatus(name='build2') ] logs = [ FakeLog('log1'), FakeLog('log2') ] for b, l in zip(builds, logs): b.builder = bldr b.results = 0 ss = Mock(name='ss') b.getSourceStamps.return_value = [ss] ss.patch = None ss.changes = [] b.getLogs.return_value = [ l ] d = mn.buildMessage("mybldr", builds, 0) def check(_): mn.createEmail.assert_called_with( dict(body='body\n\nbody\n\n', type='text', subject='subject'), 'mybldr', 'TITLE', 0, builds, [], logs) d.addCallback(check) return d def do_test_sendToInterestedUsers(self, lookup=None, extraRecipients=[], sendToInterestedUsers=True, exp_called_with=None, exp_TO=None, exp_CC=None): from email.Message import Message m = Message() mn = MailNotifier(fromaddr='from@example.org', lookup=lookup, sendToInterestedUsers=sendToInterestedUsers, extraRecipients=extraRecipients) mn.sendMessage = Mock() def fakeGetBuild(number): return build def fakeGetBuilder(buildername): if buildername == builder.name: return builder return None def fakeGetBuildRequests(self, bsid): return defer.succeed([{"buildername":"Builder", "brid":1}]) builder = Mock() builder.getBuild = fakeGetBuild builder.name = "Builder" build = FakeBuildStatus(name="build") build.result = FAILURE build.finished = True build.reason = "testReason" build.builder = builder def fakeCreateEmail(msgdict, builderName, title, results, builds=None, patches=None, logs=None): # only concerned with m['To'] and m['CC'], which are added in # _got_recipients later return defer.succeed(m) mn.createEmail = fakeCreateEmail self.db = fakedb.FakeDBConnector(self) self.db.insertTestData([fakedb.SourceStampSet(id=1099), fakedb.Buildset(id=99, sourcestampsetid=1099, results=SUCCESS, reason="testReason"), fakedb.BuildRequest(id=11, buildsetid=99, buildername='Builder'), fakedb.Build(number=0, brid=11), fakedb.Change(changeid=9123), fakedb.ChangeUser(changeid=9123, uid=1), fakedb.User(uid=1, identifier="tdurden"), fakedb.UserInfo(uid=1, attr_type='svn', attr_data="tdurden"), fakedb.UserInfo(uid=1, attr_type='email', attr_data="tyler@mayhem.net") ]) # fake sourcestamp with relevant user bits ss = Mock(name="sourcestamp") fake_change = Mock(name="change") fake_change.number = 9123 ss.changes = [fake_change] ss.patch, ss.addPatch = None, None def fakeGetSSlist(): return [ss] build.getSourceStamps = fakeGetSSlist def _getInterestedUsers(): # 'narrator' in this case is the owner, which tests the lookup return ["narrator"] build.getInterestedUsers = _getInterestedUsers def _getResponsibleUsers(): return ["Big Bob "] build.getResponsibleUsers = _getResponsibleUsers mn.master = self # FIXME: Should be FakeMaster self.status = mn.master_status = mn.buildMessageDict = Mock() mn.master_status.getBuilder = fakeGetBuilder mn.buildMessageDict.return_value = {"body": "body", "type": "text"} mn.buildMessage(builder.name, [build], build.result) mn.sendMessage.assert_called_with(m, exp_called_with) self.assertEqual(m['To'], exp_TO) self.assertEqual(m['CC'], exp_CC) def test_sendToInterestedUsers_lookup(self): self.do_test_sendToInterestedUsers( lookup="example.org", exp_called_with=['Big Bob ', 'narrator@example.org'], exp_TO="Big Bob , " \ "narrator@example.org") def test_buildMessage_sendToInterestedUsers_no_lookup(self): self.do_test_sendToInterestedUsers( exp_called_with=['tyler@mayhem.net'], exp_TO="tyler@mayhem.net") def test_buildMessage_sendToInterestedUsers_extraRecipients(self): self.do_test_sendToInterestedUsers( extraRecipients=["marla@mayhem.net"], exp_called_with=['tyler@mayhem.net', 'marla@mayhem.net'], exp_TO="tyler@mayhem.net", exp_CC="marla@mayhem.net") def test_sendToInterestedUsers_False(self): self.do_test_sendToInterestedUsers( extraRecipients=["marla@mayhem.net"], sendToInterestedUsers=False, exp_called_with=['marla@mayhem.net'], exp_TO="marla@mayhem.net") def test_sendToInterestedUsers_two_builds(self): from email.Message import Message m = Message() mn = MailNotifier(fromaddr="from@example.org", lookup=None) mn.sendMessage = Mock() def fakeGetBuilder(buildername): if buildername == builder.name: return builder return None def fakeGetBuildRequests(self, bsid): return defer.succeed([{"buildername":"Builder", "brid":1}]) builder = Mock() builder.name = "Builder" build1 = FakeBuildStatus(name="build") build1.result = FAILURE build1.finished = True build1.reason = "testReason" build1.builder = builder build2 = FakeBuildStatus(name="build") build2.result = FAILURE build2.finished = True build2.reason = "testReason" build2.builder = builder def fakeCreateEmail(msgdict, builderName, title, results, builds=None, patches=None, logs=None): # only concerned with m['To'] and m['CC'], which are added in # _got_recipients later return defer.succeed(m) mn.createEmail = fakeCreateEmail self.db = fakedb.FakeDBConnector(self) self.db.insertTestData([fakedb.SourceStampSet(id=1099), fakedb.Buildset(id=99, sourcestampsetid=1099, results=SUCCESS, reason="testReason"), fakedb.BuildRequest(id=11, buildsetid=99, buildername='Builder'), fakedb.Build(number=0, brid=11), fakedb.Build(number=1, brid=11), fakedb.Change(changeid=9123), fakedb.Change(changeid=9124), fakedb.ChangeUser(changeid=9123, uid=1), fakedb.ChangeUser(changeid=9124, uid=2), fakedb.User(uid=1, identifier="tdurden"), fakedb.User(uid=2, identifier="user2"), fakedb.UserInfo(uid=1, attr_type='email', attr_data="tyler@mayhem.net"), fakedb.UserInfo(uid=2, attr_type='email', attr_data="user2@example.net") ]) def _getInterestedUsers(): # 'narrator' in this case is the owner, which tests the lookup return ["narrator"] build1.getInterestedUsers = _getInterestedUsers build2.getInterestedUsers = _getInterestedUsers def _getResponsibleUsers(): return ["Big Bob "] build1.getResponsibleUsers = _getResponsibleUsers build2.getResponsibleUsers = _getResponsibleUsers # fake sourcestamp with relevant user bits ss1 = Mock(name="sourcestamp") fake_change1 = Mock(name="change") fake_change1.number = 9123 ss1.changes = [fake_change1] ss1.patch, ss1.addPatch = None, None ss2 = Mock(name="sourcestamp") fake_change2 = Mock(name="change") fake_change2.number = 9124 ss2.changes = [fake_change2] ss2.patch, ss1.addPatch = None, None def fakeGetSSlist(ss): return lambda: [ss] build1.getSourceStamps = fakeGetSSlist(ss1) build2.getSourceStamps = fakeGetSSlist(ss2) mn.master = self # FIXME: Should be FakeMaster self.status = mn.master_status = mn.buildMessageDict = Mock() mn.master_status.getBuilder = fakeGetBuilder mn.buildMessageDict.return_value = {"body": "body", "type": "text"} mn.buildMessage(builder.name, [build1, build2], build1.result) self.assertEqual(m['To'], "tyler@mayhem.net, user2@example.net") def create_msgdict(funny_chars=u'\u00E5\u00E4\u00F6'): unibody = u'Unicode body with non-ascii (%s).' % funny_chars msg_dict = dict(body=unibody, type='plain') return msg_dict buildbot-0.8.8/buildbot/test/unit/test_status_master.py000066400000000000000000000061411222546025000234040ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from twisted.internet import defer from buildbot.status import master, base from buildbot.test.fake import fakedb class FakeStatusReceiver(base.StatusReceiver): pass class TestStatus(unittest.TestCase): def makeStatus(self): m = mock.Mock(name='master') self.db = m.db = fakedb.FakeDBConnector(self) m.basedir = r'C:\BASEDIR' s = master.Status(m) return s def test_getBuildSets(self): s = self.makeStatus() self.db.insertTestData([ fakedb.Buildset(id=91, sourcestampsetid=234, complete=0, complete_at=298297875, results=-1, submitted_at=266761875, external_idstring='extid', reason='rsn1'), fakedb.Buildset(id=92, sourcestampsetid=234, complete=1, complete_at=298297876, results=7, submitted_at=266761876, external_idstring='extid', reason='rsn2'), ]) d = s.getBuildSets() def check(bslist): self.assertEqual([ bs.id for bs in bslist ], [ 91 ]) d.addCallback(check) return d @defer.inlineCallbacks def test_reconfigService(self): m = mock.Mock(name='master') status = master.Status(m) status.startService() config = mock.Mock() # add a status reciever sr0 = FakeStatusReceiver() config.status = [ sr0 ] yield status.reconfigService(config) self.assertTrue(sr0.running) self.assertIdentical(sr0.master, m) # add a status reciever sr1 = FakeStatusReceiver() sr2 = FakeStatusReceiver() config.status = [ sr1, sr2 ] yield status.reconfigService(config) self.assertFalse(sr0.running) self.assertIdentical(sr0.master, None) self.assertTrue(sr1.running) self.assertIdentical(sr1.master, m) self.assertTrue(sr2.running) self.assertIdentical(sr2.master, m) # reconfig with those two (a regression check) sr1 = FakeStatusReceiver() sr2 = FakeStatusReceiver() config.status = [ sr1, sr2 ] yield status.reconfigService(config) # and back to nothing config.status = [ ] yield status.reconfigService(config) self.assertIdentical(sr0.master, None) self.assertIdentical(sr1.master, None) self.assertIdentical(sr2.master, None) buildbot-0.8.8/buildbot/test/unit/test_status_persistent_queue.py000066400000000000000000000141251222546025000255160ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os from twisted.trial import unittest from buildbot.test.util import dirs from buildbot.status.persistent_queue import MemoryQueue, DiskQueue, \ IQueue, PersistentQueue, WriteFile class test_Queues(dirs.DirsMixin, unittest.TestCase): def setUp(self): self.setUpDirs('fake_dir') def tearDown(self): self.assertEqual([], os.listdir('fake_dir')) self.tearDownDirs() def testQueued(self): # Verify behavior when starting up with queued items on disk. WriteFile(os.path.join('fake_dir', '3'), 'foo3') WriteFile(os.path.join('fake_dir', '5'), 'foo5') WriteFile(os.path.join('fake_dir', '8'), 'foo8') queue = PersistentQueue(MemoryQueue(3), DiskQueue('fake_dir', 5, pickleFn=str, unpickleFn=str)) self.assertEqual(['foo3', 'foo5', 'foo8'], queue.items()) self.assertEqual(3, queue.nbItems()) self.assertEqual(['foo3', 'foo5', 'foo8'], queue.popChunk()) def _test_helper(self, q): self.assertTrue(IQueue.providedBy(q)) self.assertEqual(8, q.maxItems()) self.assertEqual(0, q.nbItems()) self.assertEqual([], q.items()) for i in range(4): self.assertEqual(None, q.pushItem(i), str(i)) self.assertEqual(i + 1, q.nbItems(), str(i)) self.assertEqual([0, 1, 2, 3], q.items()) self.assertEqual(4, q.nbItems()) if isinstance(q, PersistentQueue): self.assertEqual([0, 1, 2], q.primaryQueue.items()) self.assertEqual([3], q.secondaryQueue.items()) self.assertEqual(None, q.save()) self.assertEqual([0, 1, 2, 3], q.items()) self.assertEqual(4, q.nbItems()) if isinstance(q, PersistentQueue): self.assertEqual([], q.primaryQueue.items()) self.assertEqual([0, 1, 2, 3], q.secondaryQueue.items()) for i in range(4): self.assertEqual(None, q.pushItem(i + 4), str(i + 4)) self.assertEqual(i + 5, q.nbItems(), str(i + 4)) self.assertEqual([0, 1, 2, 3, 4, 5, 6, 7], q.items()) self.assertEqual(8, q.nbItems()) if isinstance(q, PersistentQueue): self.assertEqual([0, 1, 2], q.primaryQueue.items()) self.assertEqual([3, 4, 5, 6, 7], q.secondaryQueue.items()) self.assertEqual(0, q.pushItem(8)) self.assertEqual(8, q.nbItems()) self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], q.items()) if isinstance(q, PersistentQueue): self.assertEqual([1, 2, 3], q.primaryQueue.items()) self.assertEqual([4, 5, 6, 7, 8], q.secondaryQueue.items()) self.assertEqual([1, 2], q.popChunk(2)) self.assertEqual([3, 4, 5, 6, 7, 8], q.items()) self.assertEqual(6, q.nbItems()) if isinstance(q, PersistentQueue): self.assertEqual([3], q.primaryQueue.items()) self.assertEqual([4, 5, 6, 7, 8], q.secondaryQueue.items()) self.assertEqual([3], q.popChunk(1)) self.assertEqual([4, 5, 6, 7, 8], q.items()) self.assertEqual(5, q.nbItems()) if isinstance(q, PersistentQueue): self.assertEqual([], q.primaryQueue.items()) self.assertEqual([4, 5, 6, 7, 8], q.secondaryQueue.items()) self.assertEqual(None, q.save()) self.assertEqual(5, q.nbItems()) self.assertEqual([4, 5, 6, 7, 8], q.items()) if isinstance(q, PersistentQueue): self.assertEqual([], q.primaryQueue.items()) self.assertEqual([4, 5, 6, 7, 8], q.secondaryQueue.items()) self.assertEqual(None, q.insertBackChunk([2, 3])) self.assertEqual([2, 3, 4, 5, 6, 7, 8], q.items()) self.assertEqual(7, q.nbItems()) if isinstance(q, PersistentQueue): self.assertEqual([2, 3], q.primaryQueue.items()) self.assertEqual([4, 5, 6, 7, 8], q.secondaryQueue.items()) self.assertEqual([0], q.insertBackChunk([0, 1])) self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], q.items()) self.assertEqual(8, q.nbItems()) if isinstance(q, PersistentQueue): self.assertEqual([1, 2, 3], q.primaryQueue.items()) self.assertEqual([4, 5, 6, 7, 8], q.secondaryQueue.items()) self.assertEqual([10, 11], q.insertBackChunk([10, 11])) self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], q.items()) self.assertEqual(8, q.nbItems()) if isinstance(q, PersistentQueue): self.assertEqual([1, 2, 3], q.primaryQueue.items()) self.assertEqual([4, 5, 6, 7, 8], q.secondaryQueue.items()) self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], q.popChunk(8)) self.assertEqual([], q.items()) self.assertEqual(0, q.nbItems()) if isinstance(q, PersistentQueue): self.assertEqual([], q.primaryQueue.items()) self.assertEqual([], q.secondaryQueue.items()) self.assertEqual([], q.popChunk()) self.assertEqual(0, q.nbItems()) if isinstance(q, PersistentQueue): self.assertEqual([], q.primaryQueue.items()) self.assertEqual([], q.secondaryQueue.items()) def testMemoryQueue(self): self._test_helper(MemoryQueue(maxItems=8)) def testDiskQueue(self): self._test_helper(DiskQueue('fake_dir', maxItems=8)) def testPersistentQueue(self): self._test_helper(PersistentQueue(MemoryQueue(3), DiskQueue('fake_dir', 5))) # vim: set ts=4 sts=4 sw=4 et: buildbot-0.8.8/buildbot/test/unit/test_status_progress.py000066400000000000000000000032471222546025000237610ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.status import progress class TestExpectations(unittest.TestCase): def test_addNewStep(self): """ http://trac.buildbot.net/ticket/2252 """ buildProgress = progress.BuildProgress([]) expectations = progress.Expectations(buildProgress) stepProgress = progress.StepProgress("step", ["metric"]) newProgress = progress.BuildProgress([stepProgress]) stepProgress.start() stepProgress.finish() stepProgress.setProgress("metric", 42) expectations.update(newProgress) def test_removeOldStep(self): """ http://trac.buildbot.net/ticket/2281 """ stepProgress = progress.StepProgress("step", ["metric"]) oldProgress = progress.BuildProgress([stepProgress]) expectations = progress.Expectations(oldProgress) buildProgress = progress.BuildProgress([]) buildProgress.setExpectationsFrom(expectations) buildbot-0.8.8/buildbot/test/unit/test_status_web_auth_HTPasswdAprAuth.py000066400000000000000000000062161222546025000267540ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members """ Test Passwords desbuildmaster:yifux5rkzvI5w desbuildslave:W8SPURMnCs7Tc desbuildbot:IzclhyfHAq6Oc md5buildmaster:$apr1$pSepI8Wp$eJZcfhnpENrRlUn28wak50 md5buildslave:$apr1$dtX6FDei$vFB5BlnR9bjQisy7v3ZaC0 md5buildbot:$apr1$UcfsHmrF$i9fYa4OsPI3AK8UBbN3ju1 shabuildmaster:{SHA}vpAKSO3uPt6z8KL6cqf5W5Sredk= shabuildslave:{SHA}sNA10GbdONwGJ+a8VGRNtEyWd9I= shabuildbot:{SHA}TwEDa5Q31ZhI4GLmIbE1VrrAkpk= """ from twisted.trial import unittest from buildbot.status.web.auth import HTPasswdAprAuth from buildbot.test.util import compat class TestHTPasswdAprAuth(unittest.TestCase): htpasswd = HTPasswdAprAuth(__file__) @compat.skipUnlessPlatformIs('posix') # crypt module def test_authenticate_des(self): for key in ('buildmaster','buildslave','buildbot'): if self.htpasswd.authenticate('des'+key, key) == False: self.fail("authenticate failed for '%s'" % ('des'+key)) def test_authenticate_md5(self): if not self.htpasswd.apr: raise unittest.SkipTest("libaprutil-1 not found") for key in ('buildmaster','buildslave','buildbot'): if self.htpasswd.authenticate('md5'+key, key) == False: self.fail("authenticate failed for '%s'" % ('md5'+key)) def test_authenticate_sha(self): if not self.htpasswd.apr: raise unittest.SkipTest("libaprutil-1 not found") for key in ('buildmaster','buildslave','buildbot'): if self.htpasswd.authenticate('sha'+key, key) == False: self.fail("authenticate failed for '%s'" % ('sha'+key)) def test_authenticate_unknown(self): if self.htpasswd.authenticate('foo', 'bar') == True: self.fail("authenticate succeed for 'foo:bar'") @compat.skipUnlessPlatformIs('posix') # crypt module def test_authenticate_wopassword(self): for algo in ('des','md5','sha'): if self.htpasswd.authenticate(algo+'buildmaster', '') == True: self.fail("authenticate succeed for %s w/o password" % (algo+'buildmaster')) @compat.skipUnlessPlatformIs('posix') # crypt module def test_authenticate_wrongpassword(self): for algo in ('des','md5','sha'): if self.htpasswd.authenticate(algo+'buildmaster', algo) == True: self.fail("authenticate succeed for %s w/ wrong password" % (algo+'buildmaster')) buildbot-0.8.8/buildbot/test/unit/test_status_web_auth_HTPasswdAuth.py000066400000000000000000000042371222546025000263120ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members """ Test Passwords desbuildmaster:yifux5rkzvI5w desbuildslave:W8SPURMnCs7Tc desbuildbot:IzclhyfHAq6Oc """ from twisted.trial import unittest from buildbot.status.web.auth import HTPasswdAuth from buildbot.test.util import compat class TestHTPasswdAuth(unittest.TestCase): htpasswd = HTPasswdAuth(__file__) @compat.skipUnlessPlatformIs('posix') # crypt module def test_authenticate_des(self): for key in ('buildmaster','buildslave','buildbot'): if self.htpasswd.authenticate('des'+key, key) == False: self.fail("authenticate failed for '%s'" % ('des'+key)) def test_authenticate_unknown(self): if self.htpasswd.authenticate('foo', 'bar') == True: self.fail("authenticate succeed for 'foo:bar'") @compat.skipUnlessPlatformIs('posix') # crypt module def test_authenticate_wopassword(self): for algo in ('des','md5','sha'): if self.htpasswd.authenticate(algo+'buildmaster', '') == True: self.fail("authenticate succeed for %s w/o password" % (algo+'buildmaster')) @compat.skipUnlessPlatformIs('posix') # crypt module def test_authenticate_wrongpassword(self): for algo in ('des','md5','sha'): if self.htpasswd.authenticate(algo+'buildmaster', algo) == True: self.fail("authenticate succeed for %s w/ wrong password" % (algo+'buildmaster')) buildbot-0.8.8/buildbot/test/unit/test_status_web_authz_Authz.py000066400000000000000000000171351222546025000252610ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from twisted.trial import unittest from twisted.internet import defer from buildbot.status.web.authz import Authz from buildbot.status.web.auth import IAuth, AuthBase class StubRequest(object): # all we need from a request is username/password def __init__(self, username=None, passwd=None): self.args = { 'username' : [ username ], 'passwd' : [ passwd ], } self.received_cookies = {} self.send_cookies = [] def getUser(self): return '' def getPassword(self): return None def addCookie(self, key, cookie, expires, path): self.send_cookies.append((key, cookie, expires, path)) class StubHttpAuthRequest(object): # all we need from a request is username/password def __init__(self, username, passwd): self.args = {} self.username = username self.passwd = passwd def getUser(self): return self.username def getPassword(self): return self.passwd class StubAuth(AuthBase): implements(IAuth) def __init__(self, user): self.user = user def authenticate(self, user, pw): return user == self.user class TestAuthz(unittest.TestCase): def test_actionAllowed_Defaults(self): "by default, nothing is allowed" z = Authz() self.failedActions = [] self.dl = [] for a in Authz.knownActions: md = z.actionAllowed(a, StubRequest('foo', 'bar')) def check(res): if res: self.failedActions.append(a) return md.addCallback(check) self.dl.append(md) d = defer.DeferredList(self.dl) def check_failed(_): if self.failedActions: raise unittest.FailTest("action(s) %s do not default to False" % (self.failedActions,)) d.addCallback(check_failed) return d def test_actionAllowed_Positive(self): "'True' should always permit access" z = Authz(forceBuild=True) d = z.actionAllowed('forceBuild', StubRequest('foo', 'bar')) def check(res): self.assertEqual(res, True) d.addCallback(check) return d def test_actionAllowed_AuthPositive(self): z = Authz(auth=StubAuth('jrobinson'), stopBuild='auth') d = z.actionAllowed('stopBuild', StubRequest('jrobinson', 'bar')) def check(res): self.assertEqual(res, True) d.addCallback(check) return d def test_actionAllowed_AuthNegative(self): z = Authz(auth=StubAuth('jrobinson'), stopBuild='auth') d = z.actionAllowed('stopBuild', StubRequest('apeterson', 'bar')) def check(res): self.assertEqual(res, False) d.addCallback(check) return d def test_actionAllowed_AuthCallable(self): myargs = [] def myAuthzFn(*args): myargs.extend(args) z = Authz(auth=StubAuth('uu'), stopBuild=myAuthzFn) d = z.actionAllowed('stopBuild', StubRequest('uu', 'shh'), 'arg', 'arg2') def check(res): self.assertEqual(myargs, ['uu', 'arg', 'arg2']) d.addCallback(check) return d def test_actionAllowed_AuthCallableTrue(self): def myAuthzFn(*args): return True z = Authz(auth=StubAuth('uu'), stopBuild=myAuthzFn) d = z.actionAllowed('stopBuild', StubRequest('uu', 'shh')) def check(res): self.assertEqual(res, True) d.addCallback(check) return d def test_actionAllowed_AuthCallableFalse(self): def myAuthzFn(*args): return False z = Authz(auth=StubAuth('uu'), stopBuild=myAuthzFn) d = z.actionAllowed('stopBuild', StubRequest('uu', 'shh')) def check(res): self.assertEqual(res, False) d.addCallback(check) return d def test_advertiseAction_False(self): z = Authz(forceBuild = False) assert not z.advertiseAction('forceBuild',StubRequest()) def test_advertiseAction_True(self): z = Authz(forceAllBuilds = True) assert z.advertiseAction('forceAllBuilds',StubRequest()) def test_advertiseAction_auth(self): z = Authz(stopBuild = 'auth') assert not z.advertiseAction('stopBuild',StubRequest()) def test_advertiseAction_auth_authenticated(self): z = Authz(auth=StubAuth('uu'),stopBuild = 'auth') r = StubRequest('uu','aa') d = z.login(r) def check(c): assert z.advertiseAction('stopBuild',r) d.addCallback(check) def test_advertiseAction_callable(self): z = Authz(auth=StubAuth('uu'), stopAllBuilds = lambda u : False) r = StubRequest('uu','aa') d = z.login(r) @d.addCallback def check(c): assert z.advertiseAction('stopAllBuilds',r) return d def test_authenticated_False(self): z = Authz(forceBuild = False) assert not z.authenticated(StubRequest()) def test_authenticated_True(self): z = Authz(auth=StubAuth('uu'), forceBuild = True) r = StubRequest('uu','aa') d = z.login(r) @d.addCallback def check(c): assert z.authenticated(r) return d def test_authenticated_http_False(self): z = Authz(useHttpHeader = True) assert not z.authenticated(StubRequest()) def test_authenticated_http_True(self): z = Authz(useHttpHeader = True) assert z.authenticated(StubHttpAuthRequest('foo', 'bar')) def test_constructor_invalidAction(self): self.assertRaises(ValueError, Authz, someRandomAction=3) def test_getUsername_http(self): z = Authz(useHttpHeader = True) assert z.getUsername(StubHttpAuthRequest('foo', 'bar')) == 'foo' def test_getPassword_http(self): z = Authz(useHttpHeader = True) assert z.getPassword(StubHttpAuthRequest('foo', 'bar')) == 'bar' def test_getUsername_http_missing(self): z = Authz(useHttpHeader = True) assert z.getUsername(StubRequest('foo', 'bar')) == '' def test_getPassword_http_missing(self): z = Authz(useHttpHeader = True) assert z.getPassword(StubRequest('foo', 'bar')) == None def test_getUsername_request(self): z = Authz() assert z.getUsername(StubRequest('foo', 'bar')) == 'foo' def test_getPassword_request(self): z = Authz() assert z.getPassword(StubRequest('foo', 'bar')) == 'bar' def test_advertiseAction_invalidAction(self): z = Authz() self.assertRaises(KeyError, z.advertiseAction, 'someRandomAction', StubRequest('snow', 'foo')) def test_actionAllowed_invalidAction(self): z = Authz() self.assertRaises(KeyError, z.actionAllowed, 'someRandomAction', StubRequest('snow', 'foo')) buildbot-0.8.8/buildbot/test/unit/test_status_web_base.py000066400000000000000000000064021222546025000236600ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from buildbot.status.web import base from twisted.internet import defer from twisted.trial import unittest from buildbot.test.fake.web import FakeRequest class ActionResource(unittest.TestCase): def test_ActionResource_success(self): class MyActionResource(base.ActionResource): def performAction(self, request): self.got_request = request return defer.succeed('http://buildbot.net') rsrc = MyActionResource() request = FakeRequest() rsrc.render(request) d = request.deferred def check(_): self.assertIdentical(rsrc.got_request, request) self.assertTrue(request.finished) self.assertIn('buildbot.net', request.written) self.assertEqual(request.redirected_to, 'http://buildbot.net') d.addCallback(check) return d def test_ActionResource_exception(self): class MyActionResource(base.ActionResource): def performAction(self, request): return defer.fail(RuntimeError('sacrebleu')) rsrc = MyActionResource() request = FakeRequest() rsrc.render(request) d = request.deferred def check(f): f.trap(RuntimeError) # pass - all good! d.addErrback(check) return d class Functions(unittest.TestCase): def do_test_getRequestCharset(self, hdr, exp): req = mock.Mock() req.getHeader.return_value = hdr self.assertEqual(base.getRequestCharset(req), exp) def fakeRequest(self, prepath): r = mock.Mock() r.prepath = prepath return r def test_getRequestCharset_empty(self): return self.do_test_getRequestCharset(None, 'utf-8') def test_getRequestCharset_specified(self): return self.do_test_getRequestCharset( 'application/x-www-form-urlencoded ; charset=ISO-8859-1', 'ISO-8859-1') def test_getRequestCharset_other_params(self): return self.do_test_getRequestCharset( 'application/x-www-form-urlencoded ; charset=UTF-16 ; foo=bar', 'UTF-16') def test_path_to_root_from_root(self): self.assertEqual(base.path_to_root(self.fakeRequest([])), './') def test_path_to_root_from_one_level(self): self.assertEqual(base.path_to_root(self.fakeRequest(['waterfall'])), './') def test_path_to_root_from_two_level(self): self.assertEqual(base.path_to_root(self.fakeRequest(['a', 'b'])), '../') buildbot-0.8.8/buildbot/test/unit/test_status_web_change_hook.py000066400000000000000000000166101222546025000252150ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.status.web import change_hook from buildbot.util import json from buildbot.test.util import compat from buildbot.test.fake.web import FakeRequest from twisted.trial import unittest class TestChangeHookUnconfigured(unittest.TestCase): def setUp(self): self.request = FakeRequest() self.changeHook = change_hook.ChangeHookResource() # A bad URI should cause an exception inside check_hook. # After writing the test, it became apparent this can't happen. # I'll leave the test anyway def testDialectReMatchFail(self): self.request.uri = "/garbage/garbage" self.request.method = "GET" d = self.request.test_render(self.changeHook) def check(ret): expected = "URI doesn't match change_hook regex: /garbage/garbage" self.assertEqual(self.request.written, expected) self.request.setResponseCode.assert_called_with(400, expected) d.addCallback(check) return d def testUnkownDialect(self): self.request.uri = "/change_hook/garbage" self.request.method = "GET" d = self.request.test_render(self.changeHook) def check(ret): expected = "The dialect specified, 'garbage', wasn't whitelisted in change_hook" self.assertEqual(self.request.written, expected) self.request.setResponseCode.assert_called_with(400, expected) d.addCallback(check) return d def testDefaultDialect(self): self.request.uri = "/change_hook/" self.request.method = "GET" d = self.request.test_render(self.changeHook) def check(ret): expected = "The dialect specified, 'base', wasn't whitelisted in change_hook" self.assertEqual(self.request.written, expected) self.request.setResponseCode.assert_called_with(400, expected) d.addCallback(check) return d class TestChangeHookConfigured(unittest.TestCase): def setUp(self): self.request = FakeRequest() self.changeHook = change_hook.ChangeHookResource(dialects={'base' : True}) def testDefaultDialectGetNullChange(self): self.request.uri = "/change_hook/" self.request.method = "GET" d = self.request.test_render(self.changeHook) def check_changes(r): self.assertEquals(len(self.request.addedChanges), 1) change = self.request.addedChanges[0] self.assertEquals(change["category"], None) self.assertEquals(len(change["files"]), 0) self.assertEquals(change["repository"], None) self.assertEquals(change["when"], None) self.assertEquals(change["author"], None) self.assertEquals(change["revision"], None) self.assertEquals(change["comments"], None) self.assertEquals(change["project"], None) self.assertEquals(change["branch"], None) self.assertEquals(change["revlink"], None) self.assertEquals(len(change["properties"]), 0) self.assertEquals(change["revision"], None) d.addCallback(check_changes) return d # Test 'base' hook with attributes. We should get a json string representing # a Change object as a dictionary. All values show be set. def testDefaultDialectWithChange(self): self.request.uri = "/change_hook/" self.request.method = "GET" self.request.args = { "category" : ["mycat"], "files" : [json.dumps(['file1', 'file2'])], "repository" : ["myrepo"], "when" : ["1234"], "author" : ["Santa Claus"], "number" : ["2"], "comments" : ["a comment"], "project" : ["a project"], "at" : ["sometime"], "branch" : ["a branch"], "revlink" : ["a revlink"], "properties" : [json.dumps( { "prop1" : "val1", "prop2" : "val2" })], "revision" : ["99"] } d = self.request.test_render(self.changeHook) def check_changes(r): self.assertEquals(len(self.request.addedChanges), 1) change = self.request.addedChanges[0] self.assertEquals(change["category"], "mycat") self.assertEquals(change["repository"], "myrepo") self.assertEquals(change["when"], 1234) self.assertEquals(change["author"], "Santa Claus") self.assertEquals(change["src"], None) self.assertEquals(change["revision"], "99") self.assertEquals(change["comments"], "a comment") self.assertEquals(change["project"], "a project") self.assertEquals(change["branch"], "a branch") self.assertEquals(change["revlink"], "a revlink") self.assertEquals(change['properties'], dict(prop1='val1', prop2='val2')) self.assertEquals(change['files'], ['file1', 'file2']) d.addCallback(check_changes) return d @compat.usesFlushLoggedErrors def testDefaultWithNoChange(self): self.request = FakeRequest() self.request.uri = "/change_hook/" self.request.method = "GET" def namedModuleMock(name): if name == 'buildbot.status.web.hooks.base': class mock_hook_module(object): def getChanges(self, request, options): raise AssertionError return mock_hook_module() self.patch(change_hook, "namedModule", namedModuleMock) d = self.request.test_render(self.changeHook) def check_changes(r): expected = "Error processing changes." self.assertEquals(len(self.request.addedChanges), 0) self.assertEqual(self.request.written, expected) self.request.setResponseCode.assert_called_with(500, expected) self.assertEqual(len(self.flushLoggedErrors(AssertionError)), 1) d.addCallback(check_changes) return d class TestChangeHookConfiguredBogus(unittest.TestCase): def setUp(self): self.request = FakeRequest() self.changeHook = change_hook.ChangeHookResource(dialects={'garbage' : True}) @compat.usesFlushLoggedErrors def testBogusDialect(self): self.request.uri = "/change_hook/garbage" self.request.method = "GET" d = self.request.test_render(self.changeHook) def check(ret): expected = "Error processing changes." self.assertEqual(self.request.written, expected) self.request.setResponseCode.assert_called_with(500, expected) self.assertEqual(len(self.flushLoggedErrors()), 1) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_status_web_change_hooks_github.py000066400000000000000000000165161222546025000267470ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import buildbot.status.web.change_hook as change_hook from buildbot.test.fake.web import FakeRequest from buildbot.test.util import compat from twisted.trial import unittest # Sample GITHUB commit payload from http://help.github.com/post-receive-hooks/ # Added "modfied" and "removed", and change email gitJsonPayload = """ { "before": "5aef35982fb2d34e9d9d4502f6ede1072793222d", "repository": { "url": "http://github.com/defunkt/github", "name": "github", "description": "You're lookin' at it.", "watchers": 5, "forks": 2, "private": 1, "owner": { "email": "fred@flinstone.org", "name": "defunkt" } }, "commits": [ { "id": "41a212ee83ca127e3c8cf465891ab7216a705f59", "url": "http://github.com/defunkt/github/commit/41a212ee83ca127e3c8cf465891ab7216a705f59", "author": { "email": "fred@flinstone.org", "name": "Fred Flinstone" }, "message": "okay i give in", "timestamp": "2008-02-15T14:57:17-08:00", "added": ["filepath.rb"] }, { "id": "de8251ff97ee194a289832576287d6f8ad74e3d0", "url": "http://github.com/defunkt/github/commit/de8251ff97ee194a289832576287d6f8ad74e3d0", "author": { "email": "fred@flinstone.org", "name": "Fred Flinstone" }, "message": "update pricing a tad", "timestamp": "2008-02-15T14:36:34-08:00", "modified": ["modfile"], "removed": ["removedFile"] } ], "after": "de8251ff97ee194a289832576287d6f8ad74e3d0", "ref": "refs/heads/master" } """ gitJsonPayloadNonBranch = """ { "before": "5aef35982fb2d34e9d9d4502f6ede1072793222d", "repository": { "url": "http://github.com/defunkt/github", "name": "github", "description": "You're lookin' at it.", "watchers": 5, "forks": 2, "private": 1, "owner": { "email": "fred@flinstone.org", "name": "defunkt" } }, "commits": [ { "id": "41a212ee83ca127e3c8cf465891ab7216a705f59", "url": "http://github.com/defunkt/github/commit/41a212ee83ca127e3c8cf465891ab7216a705f59", "author": { "email": "fred@flinstone.org", "name": "Fred Flinstone" }, "message": "okay i give in", "timestamp": "2008-02-15T14:57:17-08:00", "added": ["filepath.rb"] } ], "after": "de8251ff97ee194a289832576287d6f8ad74e3d0", "ref": "refs/garbage/master" } """ gitJsonPayloadEmpty = """ { "before": "5aef35982fb2d34e9d9d4502f6ede1072793222d", "repository": { "url": "http://github.com/defunkt/github", "name": "github", "description": "You're lookin' at it.", "watchers": 5, "forks": 2, "private": 1, "owner": { "email": "fred@flinstone.org", "name": "defunkt" } }, "commits": [ ], "after": "de8251ff97ee194a289832576287d6f8ad74e3d0", "ref": "refs/heads/master" } """ class TestChangeHookConfiguredWithGitChange(unittest.TestCase): def setUp(self): self.changeHook = change_hook.ChangeHookResource(dialects={'github' : True}) # Test 'base' hook with attributes. We should get a json string representing # a Change object as a dictionary. All values show be set. def testGitWithChange(self): changeDict={"payload" : [gitJsonPayload]} self.request = FakeRequest(changeDict) self.request.uri = "/change_hook/github" self.request.method = "GET" d = self.request.test_render(self.changeHook) def check_changes(r): self.assertEquals(len(self.request.addedChanges), 2) change = self.request.addedChanges[0] self.assertEquals(change['files'], ['filepath.rb']) self.assertEquals(change["repository"], "http://github.com/defunkt/github") self.assertEquals(change["when"], 1203116237) self.assertEquals(change["who"], "Fred Flinstone ") self.assertEquals(change["revision"], '41a212ee83ca127e3c8cf465891ab7216a705f59') self.assertEquals(change["comments"], "okay i give in") self.assertEquals(change["branch"], "master") self.assertEquals(change["revlink"], "http://github.com/defunkt/github/commit/41a212ee83ca127e3c8cf465891ab7216a705f59") change = self.request.addedChanges[1] self.assertEquals(change['files'], [ 'modfile', 'removedFile' ]) self.assertEquals(change["repository"], "http://github.com/defunkt/github") self.assertEquals(change["when"], 1203114994) self.assertEquals(change["who"], "Fred Flinstone ") self.assertEquals(change["src"], "git") self.assertEquals(change["revision"], 'de8251ff97ee194a289832576287d6f8ad74e3d0') self.assertEquals(change["comments"], "update pricing a tad") self.assertEquals(change["branch"], "master") self.assertEquals(change["revlink"], "http://github.com/defunkt/github/commit/de8251ff97ee194a289832576287d6f8ad74e3d0") d.addCallback(check_changes) return d @compat.usesFlushLoggedErrors def testGitWithNoJson(self): self.request = FakeRequest() self.request.uri = "/change_hook/github" self.request.method = "GET" d = self.request.test_render(self.changeHook) def check_changes(r): expected = "Error processing changes." self.assertEquals(len(self.request.addedChanges), 0) self.assertEqual(self.request.written, expected) self.request.setResponseCode.assert_called_with(500, expected) self.assertEqual(len(self.flushLoggedErrors()), 1) d.addCallback(check_changes) return d def testGitWithNoChanges(self): changeDict={"payload" : [gitJsonPayloadEmpty]} self.request = FakeRequest(changeDict) self.request.uri = "/change_hook/github" self.request.method = "GET" d = self.request.test_render(self.changeHook) def check_changes(r): expected = "no changes found" self.assertEquals(len(self.request.addedChanges), 0) self.assertEqual(self.request.written, expected) d.addCallback(check_changes) return d def testGitWithNonBranchChanges(self): changeDict={"payload" : [gitJsonPayloadNonBranch]} self.request = FakeRequest(changeDict) self.request.uri = "/change_hook/github" self.request.method = "GET" d = self.request.test_render(self.changeHook) def check_changes(r): expected = "no changes found" self.assertEquals(len(self.request.addedChanges), 0) self.assertEqual(self.request.written, expected) d.addCallback(check_changes) return d buildbot-0.8.8/buildbot/test/unit/test_status_web_change_hooks_googlecode.py000066400000000000000000000106331222546025000275660ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright 2011 Louis Opter # # Written from the github change hook unit test import StringIO import buildbot.status.web.change_hook as change_hook from buildbot.test.fake.web import FakeRequest from twisted.trial import unittest # Sample Google Code commit payload extracted from a Google Code test project # { # "repository_path": "https://code.google.com/p/webhook-test/", # "project_name": "webhook-test", # "revision_count": 1, # "revisions": [ # { # "added": [], # "parents": ["6574485e26a09a0e743e0745374056891d6a836a"], # "author": "Louis Opter \\u003Clouis@lse.epitech.net\\u003E", # "url": "http://webhook-test.googlecode.com/hg-history/68e5df283a8e751cdbf95516b20357b2c46f93d4/", # "timestamp": 1324082130, # "message": "Print a message", # "path_count": 1, # "removed": [], # "modified": ["/CMakeLists.txt"], # "revision": "68e5df283a8e751cdbf95516b20357b2c46f93d4" # } # ] # } googleCodeJsonBody = '{"repository_path":"https://code.google.com/p/webhook-test/","project_name":"webhook-test","revisions":[{"added":[],"parents":["6574485e26a09a0e743e0745374056891d6a836a"],"author":"Louis Opter \u003Clouis@lse.epitech.net\u003E","url":"http://webhook-test.googlecode.com/hg-history/68e5df283a8e751cdbf95516b20357b2c46f93d4/","timestamp":1324082130,"message":"Print a message","path_count":1,"removed":[],"modified":["/CMakeLists.txt"],"revision":"68e5df283a8e751cdbf95516b20357b2c46f93d4"}],"revision_count":1}' class TestChangeHookConfiguredWithGoogleCodeChange(unittest.TestCase): def setUp(self): self.request = FakeRequest() # Google Code simply transmit the payload as an UTF-8 JSON body self.request.content = StringIO.StringIO(googleCodeJsonBody) self.request.received_headers = { 'Google-Code-Project-Hosting-Hook-Hmac': '85910bf93ba5c266402d9328b0c7a856', 'Content-Length': '509', 'Accept-Encoding': 'gzip', 'User-Agent': 'Google Code Project Hosting (+http://code.google.com/p/support/wiki/PostCommitWebHooks)', 'Host': 'buildbot6-lopter.dotcloud.com:19457', 'Content-Type': 'application/json; charset=UTF-8' } self.changeHook = change_hook.ChangeHookResource(dialects={ 'googlecode': { 'secret_key': 'FSP3p-Ghdn4T0oqX', 'branch': 'test' } }) # Test 'base' hook with attributes. We should get a json string representing # a Change object as a dictionary. All values show be set. def testGoogleCodeWithHgChange(self): self.request.uri = "/change_hook/googlecode" self.request.method = "GET" d = self.request.test_render(self.changeHook) def check_changes(r): # Only one changeset has been submitted. self.assertEquals(len(self.request.addedChanges), 1) # First changeset. change = self.request.addedChanges[0] self.assertEquals(change['files'], ['/CMakeLists.txt']) self.assertEquals(change["repository"], "https://code.google.com/p/webhook-test/") self.assertEquals(change["when"], 1324082130) self.assertEquals(change["author"], "Louis Opter ") self.assertEquals(change["revision"], '68e5df283a8e751cdbf95516b20357b2c46f93d4') self.assertEquals(change["comments"], "Print a message") self.assertEquals(change["branch"], "test") self.assertEquals(change["revlink"], "http://webhook-test.googlecode.com/hg-history/68e5df283a8e751cdbf95516b20357b2c46f93d4/") d.addCallback(check_changes) return d buildbot-0.8.8/buildbot/test/unit/test_status_web_change_hooks_poller.py000066400000000000000000000106771222546025000267640ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.internet import defer from buildbot.changes import base import buildbot.status.web.change_hook as change_hook from buildbot.test.fake.web import FakeRequest from buildbot.changes.manager import ChangeManager class TestPollingChangeHook(unittest.TestCase): class Subclass(base.PollingChangeSource): pollInterval = None called = False def poll(self): self.called = True def setUpRequest(self, args, options=True): self.changeHook = change_hook.ChangeHookResource(dialects={'poller' : options}) self.request = FakeRequest(args=args) self.request.uri = "/change_hook/poller" self.request.method = "GET" master = self.request.site.buildbot_service.master master.change_svc = ChangeManager(master) self.changesrc = self.Subclass("example", None) self.changesrc.setServiceParent(master.change_svc) self.disabledChangesrc = self.Subclass("disabled", None) self.disabledChangesrc.setServiceParent(master.change_svc) anotherchangesrc = base.ChangeSource() anotherchangesrc.setName("notapoller") anotherchangesrc.setServiceParent(master.change_svc) return self.request.test_render(self.changeHook) @defer.inlineCallbacks def test_no_args(self): yield self.setUpRequest({}) self.assertEqual(self.request.written, "no changes found") self.assertEqual(self.changesrc.called, True) self.assertEqual(self.disabledChangesrc.called, True) @defer.inlineCallbacks def test_no_poller(self): yield self.setUpRequest({"poller": ["nosuchpoller"]}) expected = "Could not find pollers: nosuchpoller" self.assertEqual(self.request.written, expected) self.request.setResponseCode.assert_called_with(400, expected) self.assertEqual(self.changesrc.called, False) self.assertEqual(self.disabledChangesrc.called, False) @defer.inlineCallbacks def test_invalid_poller(self): yield self.setUpRequest({"poller": ["notapoller"]}) expected = "Could not find pollers: notapoller" self.assertEqual(self.request.written, expected) self.request.setResponseCode.assert_called_with(400, expected) self.assertEqual(self.changesrc.called, False) self.assertEqual(self.disabledChangesrc.called, False) @defer.inlineCallbacks def test_trigger_poll(self): yield self.setUpRequest({"poller": ["example"]}) self.assertEqual(self.request.written, "no changes found") self.assertEqual(self.changesrc.called, True) self.assertEqual(self.disabledChangesrc.called, False) @defer.inlineCallbacks def test_allowlist_deny(self): yield self.setUpRequest({"poller": ["disabled"]}, options={"allowed": ["example"]}) expected = "Could not find pollers: disabled" self.assertEqual(self.request.written, expected) self.request.setResponseCode.assert_called_with(400, expected) self.assertEqual(self.changesrc.called, False) self.assertEqual(self.disabledChangesrc.called, False) @defer.inlineCallbacks def test_allowlist_allow(self): yield self.setUpRequest({"poller": ["example"]}, options={"allowed": ["example"]}) self.assertEqual(self.request.written, "no changes found") self.assertEqual(self.changesrc.called, True) self.assertEqual(self.disabledChangesrc.called, False) @defer.inlineCallbacks def test_allowlist_all(self): yield self.setUpRequest({}, options={"allowed": ["example"]}) self.assertEqual(self.request.written, "no changes found") self.assertEqual(self.changesrc.called, True) self.assertEqual(self.disabledChangesrc.called, False) buildbot-0.8.8/buildbot/test/unit/test_status_web_links.py000066400000000000000000000217631222546025000240750ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import buildbot.status.web.base as wb import jinja2, re from twisted.trial import unittest class RevisionLinks(unittest.TestCase): """ Test webstatus revision link filters """ def setUp(self): pass def _test(self, env, should_have_links=True): for name in ['shortrev', 'revlink']: f = env.filters[name] for r in [None, 'repo', 'repo2', 'sub/repo']: self.assertNotSubstring('\g<0>') r2_sub = jinja2.Markup(r'\g<0>') def my_changelink(changehtml, project): if project == 'nonexistingproject': return changehtml html1 = r1.sub(r1_sub, changehtml) html2 = r2.sub(r2_sub, html1) return html2 env = wb.createJinjaEnv(changecommentlink=my_changelink) self._test(env) f = env.filters['changecomment'] self.assertNotSubstring('', env.filters['repolink']('a')) self.assertSubstring('', env.filters['projectlink']('b')) class EmailFilter(unittest.TestCase): ''' test that the email filter actually obfuscates email addresses''' def test_emailfilter(self): self.assertNotSubstring('me@the.net', wb.emailfilter('me@the.net')) self.assertSubstring('me', wb.emailfilter('me@the.net')) self.assertSubstring('@', wb.emailfilter('me@the.net')) self.assertSubstring('the.net', wb.emailfilter('me@the.net')) class UserFilter(unittest.TestCase): '''test commit user names filtering, should be safe from complete email addresses and split user/email into separate HTML instances''' def test_emailfilter(self): self.assertNotSubstring('me@the.net', wb.userfilter('me@the.net')) self.assertNotSubstring('me@the.net', wb.userfilter('Me ')) def test_emailfilter_split(self): self.assertNotSubstring('Me ')) self.assertSubstring('me', wb.userfilter('Me ')) self.assertSubstring('the.net', wb.userfilter('Me ')) self.assertSubstring('John Doe', wb.userfilter('John Doe ')) buildbot-0.8.8/buildbot/test/unit/test_status_words.py000066400000000000000000000526251222546025000232570ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from twisted.application import internet from twisted.internet import task, reactor from buildbot.status import words from buildbot.test.util import compat, config class TestIrcContactChannel(unittest.TestCase): def setUp(self): self.bot = mock.Mock(name='IRCStatusBot-instance') self.bot.nickname = 'nick' self.bot.notify_events = { 'success' : 1, 'failure' : 1 } # fake out subscription/unsubscription self.subscribed = False def subscribe(contact): self.subscribed = True self.bot.status.subscribe = subscribe def unsubscribe(contact): self.subscribed = False self.bot.status.unsubscribe = unsubscribe # fake out clean shutdown self.bot.master = mock.Mock(name='IRCStatusBot-instance.master') self.bot.master.botmaster = mock.Mock(name='IRCStatusBot-instance.master.botmaster') self.bot.master.botmaster.shuttingDown = False def cleanShutdown(): self.bot.master.botmaster.shuttingDown = True self.bot.master.botmaster.cleanShutdown = cleanShutdown def cancelCleanShutdown(): self.bot.master.botmaster.shuttingDown = False self.bot.master.botmaster.cancelCleanShutdown = cancelCleanShutdown self.contact = words.IRCContact(self.bot, '#buildbot') def patch_send(self): self.sent = [] def send(msg): self.sent.append(msg) self.contact.send = send def patch_act(self): self.actions = [] def act(msg): self.actions.append(msg) self.contact.act = act def do_test_command(self, command, args='', who='me', clock_ticks=None, exp_usage=True, exp_UsageError=False, allowShutdown=False, shuttingDown=False): cmd = getattr(self.contact, 'command_' + command.upper()) if exp_usage: self.assertTrue(hasattr(cmd, 'usage')) clock = task.Clock() self.patch(reactor, 'callLater', clock.callLater) self.patch_send() self.patch_act() self.bot.factory.allowShutdown = allowShutdown self.bot.master.botmaster.shuttingDown = shuttingDown if exp_UsageError: try: cmd(args, who) except words.UsageError: return else: self.fail("no UsageError") else: cmd(args, who) if clock_ticks: clock.pump(clock_ticks) # tests def test_doSilly(self): clock = task.Clock() self.patch(reactor, 'callLater', clock.callLater) self.patch_send() silly_prompt, silly_response = self.contact.silly.items()[0] self.contact.doSilly(silly_prompt) clock.pump([0.5] * 20) self.assertEqual(self.sent, silly_response) # TODO: remaining commands # (all depend on status, which interface will change soon) def test_command_mute(self): self.do_test_command('mute') self.assertTrue(self.contact.muted) def test_command_unmute(self): self.contact.muted = True self.do_test_command('unmute') self.assertFalse(self.contact.muted) def test_command_unmute_not_muted(self): self.do_test_command('unmute') self.assertFalse(self.contact.muted) self.assertIn("hadn't told me to be quiet", self.sent[0]) def test_command_help_noargs(self): self.do_test_command('help') self.assertIn('help on what', self.sent[0]) def test_command_help_arg(self): self.contact.command_FOO = lambda : None self.contact.command_FOO.usage = 'foo - bar' self.do_test_command('help', args='foo') self.assertIn('Usage: foo - bar', self.sent[0]) def test_command_help_no_usage(self): self.contact.command_FOO = lambda : None self.do_test_command('help', args='foo') self.assertIn('No usage info for', self.sent[0]) def test_command_help_dict_command(self): self.contact.command_FOO = lambda : None self.contact.command_FOO.usage = { None : 'foo - bar' } self.do_test_command('help', args='foo') self.assertIn('Usage: foo - bar', self.sent[0]) def test_command_help_dict_command_no_usage(self): self.contact.command_FOO = lambda : None self.contact.command_FOO.usage = {} self.do_test_command('help', args='foo') self.assertIn("No usage info for 'foo'", self.sent[0]) def test_command_help_dict_command_arg(self): self.contact.command_FOO = lambda : None self.contact.command_FOO.usage = { 'this' : 'foo this - bar' } self.do_test_command('help', args='foo this') self.assertIn('Usage: foo this - bar', self.sent[0]) def test_command_help_dict_command_arg_no_usage(self): self.contact.command_FOO = lambda : None self.contact.command_FOO.usage = { # nothing for arg 'this' ('this', 'first') : 'foo this first - bar' } self.do_test_command('help', args='foo this') self.assertIn("No usage info for 'foo' 'this'", self.sent[0]) def test_command_help_dict_command_arg_subarg(self): self.contact.command_FOO = lambda : None self.contact.command_FOO.usage = { ('this', 'first') : 'foo this first - bar' } self.do_test_command('help', args='foo this first') self.assertIn('Usage: foo this first - bar', self.sent[0]) def test_command_help_dict_command_arg_subarg_no_usage(self): self.contact.command_FOO = lambda : None self.contact.command_FOO.usage = { None : 'foo - bar', 'this' : 'foo this - bar', ('this', 'first') : 'foo this first - bar' # nothing for subarg 'missing' } self.do_test_command('help', args='foo this missing') self.assertIn("No usage info for 'foo' 'this' 'missing'", self.sent[0]) def test_command_help_nosuch(self): self.do_test_command('help', args='foo', exp_UsageError=True) def test_command_shutdown(self): self.do_test_command('shutdown', exp_UsageError=True) self.assertEqual(self.bot.factory.allowShutdown, False) self.assertEqual(self.bot.master.botmaster.shuttingDown, False) def test_command_shutdown_dissalowed(self): self.do_test_command('shutdown', args='check', exp_UsageError=True) self.assertEqual(self.bot.factory.allowShutdown, False) self.assertEqual(self.bot.master.botmaster.shuttingDown, False) def test_command_shutdown_check_running(self): self.do_test_command('shutdown', args='check', allowShutdown=True, shuttingDown=False) self.assertEqual(self.bot.factory.allowShutdown, True) self.assertEqual(self.bot.master.botmaster.shuttingDown, False) self.assertIn('buildbot is running', self.sent[0]) def test_command_shutdown_check_shutting_down(self): self.do_test_command('shutdown', args='check', allowShutdown=True, shuttingDown=True) self.assertEqual(self.bot.factory.allowShutdown, True) self.assertEqual(self.bot.master.botmaster.shuttingDown, True) self.assertIn('buildbot is shutting down', self.sent[0]) def test_command_shutdown_start(self): self.do_test_command('shutdown', args='start', allowShutdown=True, shuttingDown=False) self.assertEqual(self.bot.factory.allowShutdown, True) self.assertEqual(self.bot.master.botmaster.shuttingDown, True) def test_command_shutdown_stop(self): self.do_test_command('shutdown', args='stop', allowShutdown=True, shuttingDown=True) self.assertEqual(self.bot.factory.allowShutdown, True) self.assertEqual(self.bot.master.botmaster.shuttingDown, False) def test_command_shutdown_now(self): stop = mock.Mock() self.patch(reactor, 'stop', stop) self.do_test_command('shutdown', args='now', allowShutdown=True) self.assertEqual(self.bot.factory.allowShutdown, True) self.assertEqual(self.bot.master.botmaster.shuttingDown, False) stop.assert_called_with() def test_command_source(self): self.do_test_command('source') self.assertIn('My source', self.sent[0]) def test_command_commands(self): self.do_test_command('commands') self.assertIn('buildbot commands', self.sent[0]) def test_command_destroy(self): self.do_test_command('destroy', exp_usage=False) self.assertEqual(self.actions, [ 'readies phasers' ]) def test_command_dance(self): self.do_test_command('dance', clock_ticks=[1.0]*10, exp_usage=False) self.assertTrue(self.sent) # doesn't matter what it sent def test_send(self): events = [] def msgOrNotice(dest, msg): events.append((dest, msg)) self.contact.bot.msgOrNotice = msgOrNotice self.contact.send("unmuted") self.contact.send(u"unmuted, unicode \N{SNOWMAN}") self.contact.muted = True self.contact.send("muted") self.assertEqual(events, [ ('#buildbot', 'unmuted'), ('#buildbot', 'unmuted, unicode ?'), ]) def test_act(self): events = [] def describe(dest, msg): events.append((dest, msg)) self.contact.bot.describe = describe self.contact.act("unmuted") self.contact.act(u"unmuted, unicode \N{SNOWMAN}") self.contact.muted = True self.contact.act("muted") self.assertEqual(events, [ ('#buildbot', 'unmuted'), ('#buildbot', 'unmuted, unicode ?'), ]) def test_handleMessage_silly(self): silly_prompt = self.contact.silly.keys()[0] self.contact.doSilly = mock.Mock() d = self.contact.handleMessage(silly_prompt, 'me') @d.addCallback def cb(_): self.contact.doSilly.assert_called_with(silly_prompt) return d def test_handleMessage_short_command(self): self.contact.command_TESTY = mock.Mock() d = self.contact.handleMessage('testy', 'me') @d.addCallback def cb(_): self.contact.command_TESTY.assert_called_with('', 'me') return d def test_handleMessage_long_command(self): self.contact.command_TESTY = mock.Mock() d = self.contact.handleMessage('testy westy boo', 'me') @d.addCallback def cb(_): self.contact.command_TESTY.assert_called_with('westy boo', 'me') return d def test_handleMessage_excited(self): self.patch_send() d = self.contact.handleMessage('hi!', 'me') @d.addCallback def cb(_): self.assertEqual(len(self.sent), 1) # who cares what it says.. return d @compat.usesFlushLoggedErrors def test_handleMessage_exception(self): self.patch_send() def command_TESTY(msg, who): raise RuntimeError("FAIL") self.contact.command_TESTY = command_TESTY d = self.contact.handleMessage('testy boom', 'me') @d.addCallback def cb(_): self.assertEqual(self.sent, [ "Something bad happened (see logs)" ]) self.assertEqual(len(self.flushLoggedErrors(RuntimeError)), 1) return d def test_handleMessage_UsageError(self): self.patch_send() def command_TESTY(msg, who): raise words.UsageError("oh noes") self.contact.command_TESTY = command_TESTY d = self.contact.handleMessage('testy boom', 'me') @d.addCallback def cb(_): self.assertEqual(self.sent, [ "oh noes" ]) return d def test_handleAction_ignored(self): self.patch_act() self.contact.handleAction('waves hi', 'me') self.assertEqual(self.actions, []) def test_handleAction_kick(self): self.patch_act() self.contact.handleAction('kicks nick', 'me') self.assertEqual(self.actions, ['kicks back']) def test_handleAction_stpuid(self): self.patch_act() self.contact.handleAction('stupids nick', 'me') self.assertEqual(self.actions, ['stupids me too']) def test_unclosed_quote(self): self.do_test_command('list', args='args\'', exp_UsageError=True) self.do_test_command('status', args='args\'', exp_UsageError=True) self.do_test_command('notify', args='args\'', exp_UsageError=True) self.do_test_command('watch', args='args\'', exp_UsageError=True) self.do_test_command('force', args='args\'', exp_UsageError=True) self.do_test_command('stop', args='args\'', exp_UsageError=True) self.do_test_command('last', args='args\'', exp_UsageError=True) self.do_test_command('help', args='args\'', exp_UsageError=True) class FakeContact(object): def __init__(self, bot, name): self.bot = bot self.name = name self.messages = [] self.actions = [] def handleMessage(self, message, user): self.messages.append((message, user)) def handleAction(self, data, user): self.actions.append((data, user)) class TestIrcStatusBot(unittest.TestCase): def setUp(self): self.status = mock.Mock(name='status') def makeBot(self, *args, **kwargs): if not args: args = ('nick', 'pass', ['#ch'], [], self.status, [], {}) return words.IrcStatusBot(*args, **kwargs) def test_msgOrNotice(self): b = self.makeBot(noticeOnChannel=False) b.notice = lambda d, m : evts.append(('n', d, m)) b.msg = lambda d, m : evts.append(('m', d, m)) evts = [] b.msgOrNotice('nick', 'hi') self.assertEqual(evts, [('m', 'nick', 'hi')]) evts = [] b.msgOrNotice('#chan', 'hi') self.assertEqual(evts, [('m', '#chan', 'hi')]) b.noticeOnChannel = True evts = [] b.msgOrNotice('#chan', 'hi') self.assertEqual(evts, [('n', '#chan', 'hi')]) def test_getContact(self): b = self.makeBot() c1 = b.getContact('c1') c2 = b.getContact('c2') c1b = b.getContact('c1') self.assertIdentical(c1, c1b) self.assertIsInstance(c2, words.IRCContact) def test_getContact_case_insensitive(self): b = self.makeBot() c1 = b.getContact('c1') c1b = b.getContact('C1') self.assertIdentical(c1, c1b) def test_privmsg_user(self): b = self.makeBot() b.contactClass = FakeContact b.privmsg('jimmy!~foo@bar', 'nick', 'hello') c = b.getContact('jimmy') self.assertEqual(c.messages, [('hello', 'jimmy')]) def test_privmsg_user_uppercase(self): b = self.makeBot('NICK', 'pass', ['#ch'], [], self.status, [], {}) b.contactClass = FakeContact b.privmsg('jimmy!~foo@bar', 'NICK', 'hello') c = b.getContact('jimmy') self.assertEqual(c.messages, [('hello', 'jimmy')]) def test_privmsg_channel_unrelated(self): b = self.makeBot() b.contactClass = FakeContact b.privmsg('jimmy!~foo@bar', '#ch', 'hello') c = b.getContact('#ch') self.assertEqual(c.messages, []) def test_privmsg_channel_related(self): b = self.makeBot() b.contactClass = FakeContact b.privmsg('jimmy!~foo@bar', '#ch', 'nick: hello') c = b.getContact('#ch') self.assertEqual(c.messages, [(' hello', 'jimmy')]) def test_action_unrelated(self): b = self.makeBot() b.contactClass = FakeContact b.action('jimmy!~foo@bar', '#ch', 'waves') c = b.getContact('#ch') self.assertEqual(c.actions, []) def test_action_unrelated_buildbot(self): b = self.makeBot() b.contactClass = FakeContact b.action('jimmy!~foo@bar', '#ch', 'waves at buildbot')# b.nickname is not 'buildbot' c = b.getContact('#ch') self.assertEqual(c.actions, []) def test_action_related(self): b = self.makeBot() b.contactClass = FakeContact b.action('jimmy!~foo@bar', '#ch', 'waves at nick') c = b.getContact('#ch') self.assertEqual(c.actions, [('waves at nick', 'jimmy')]) def test_signedOn(self): b = self.makeBot('nick', 'pass', ['#ch1', dict(channel='#ch2', password='sekrits')], ['jimmy', 'bobby'], self.status, [], {}) evts = [] def msg(d, m): evts.append(('m', d, m)) b.msg = msg def join(channel, key): evts.append(('k', channel, key)) b.join = join b.contactClass = FakeContact b.signedOn() self.assertEqual(sorted(evts), [ ('k', '#ch1', None), ('k', '#ch2', 'sekrits'), ('m', 'Nickserv', 'IDENTIFY pass'), ]) self.assertEqual(sorted(b.contacts.keys()), # channels don't get added until joined() is called sorted(['jimmy', 'bobby'])) def test_joined(self): b = self.makeBot() b.joined('#ch1') b.joined('#ch2') self.assertEqual(sorted(b.contacts.keys()), sorted(['#ch1', '#ch2'])) def test_other(self): # these methods just log, but let's get them covered anyway b = self.makeBot() b.left('#ch1') b.kickedFrom('#ch1', 'dustin', 'go away!') class TestIrcStatusFactory(unittest.TestCase): def makeFactory(self, *args, **kwargs): if not args: args = ('nick', 'pass', ['ch'], [], [], {}) return words.IrcStatusFactory(*args, **kwargs) def test_shutdown(self): # this is kinda lame, but the factory would be better tested # in an integration-test environment f = self.makeFactory() self.assertFalse(f.shuttingDown) f.shutdown() self.assertTrue(f.shuttingDown) class TestIRC(config.ConfigErrorsMixin, unittest.TestCase): def makeIRC(self, **kwargs): kwargs.setdefault('host', 'localhost') kwargs.setdefault('nick', 'russo') kwargs.setdefault('channels', ['#buildbot']) self.factory = None def TCPClient(host, port, factory): client = mock.Mock(name='tcp-client') client.host = host client.port = port client.factory = factory # keep for later self.factory = factory self.client = client return client self.patch(internet, 'TCPClient', TCPClient) return words.IRC(**kwargs) def test_constr(self): irc = self.makeIRC(host='foo', port=123) self.client.setServiceParent.assert_called_with(irc) self.assertEqual(self.client.host, 'foo') self.assertEqual(self.client.port, 123) self.assertIsInstance(self.client.factory, words.IrcStatusFactory) def test_constr_args(self): # test that the args to IRC(..) make it all the way down to # the IrcStatusBot class self.makeIRC( host='host', nick='nick', channels=['channels'], pm_to_nicks=['pm', 'to', 'nicks'], port=1234, allowForce=True, categories=['categories'], password='pass', notify_events={ 'successToFailure': 1, }, noticeOnChannel=True, showBlameList=False, useRevisions=True, useSSL=False, lostDelay=10, failedDelay=20, useColors=False) # patch it up factory = self.factory proto_obj = mock.Mock(name='proto_obj') factory.protocol = mock.Mock(name='protocol', return_value=proto_obj) factory.status = 'STATUS' # run it p = factory.buildProtocol('address') self.assertIdentical(p, proto_obj) factory.protocol.assert_called_with( 'nick', 'pass', ['channels'], ['pm', 'to', 'nicks'], factory.status, ['categories'], { 'successToFailure': 1 }, noticeOnChannel=True, useColors=False, useRevisions=True, showBlameList=False) def test_allowForce_notBool(self): """ When L{IRCClient} is called with C{allowForce} not a boolean, a config error is reported. """ self.assertRaisesConfigError("allowForce must be boolean, not", lambda: self.makeIRC(allowForce=object())) def test_allowShutdown_notBool(self): """ When L{IRCClient} is called with C{allowShutdown} not a boolean, a config error is reported. """ self.assertRaisesConfigError("allowShutdown must be boolean, not", lambda: self.makeIRC(allowShutdown=object())) def test_service(self): irc = self.makeIRC() # just put it through its paces irc.startService() return irc.stopService() buildbot-0.8.8/buildbot/test/unit/test_steps_master.py000066400000000000000000000226271222546025000232260ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os import sys from twisted.python import failure, runtime from twisted.internet import error, reactor from twisted.trial import unittest from buildbot.test.util import steps from buildbot.status.results import SUCCESS, FAILURE, EXCEPTION from buildbot.steps import master from buildbot.process.properties import WithProperties from buildbot.process.properties import Interpolate import pprint class TestMasterShellCommand(steps.BuildStepMixin, unittest.TestCase): def setUp(self): if runtime.platformType == 'win32': self.comspec = os.environ.get('COMPSPEC') os.environ['COMSPEC'] = r'C:\WINDOWS\system32\cmd.exe' return self.setUpBuildStep() def tearDown(self): if runtime.platformType == 'win32': if self.comspec: os.environ['COMSPEC'] = self.comspec else: del os.environ['COMSPEC'] return self.tearDownBuildStep() def patchSpawnProcess(self, exp_cmd, exp_argv, exp_path, exp_usePTY, exp_env, outputs): def spawnProcess(pp, cmd, argv, path, usePTY, env): self.assertEqual([cmd, argv, path, usePTY, env], [exp_cmd, exp_argv, exp_path, exp_usePTY, exp_env]) for output in outputs: if output[0] == 'out': pp.outReceived(output[1]) elif output[0] == 'err': pp.errReceived(output[1]) elif output[0] == 'rc': if output[1] != 0: so = error.ProcessTerminated(exitCode=output[1]) else: so = error.ProcessDone(None) pp.processEnded(failure.Failure(so)) self.patch(reactor, 'spawnProcess', spawnProcess) def test_real_cmd(self): cmd = [ sys.executable, '-c', 'print "hello"' ] self.setupStep( master.MasterShellCommand(command=cmd)) if runtime.platformType == 'win32': self.expectLogfile('stdio', "hello\r\n") else: self.expectLogfile('stdio', "hello\n") self.expectOutcome(result=SUCCESS, status_text=["Ran"]) return self.runStep() def test_real_cmd_interrupted(self): cmd = [ sys.executable, '-c', 'while True: pass' ] self.setupStep( master.MasterShellCommand(command=cmd)) self.expectLogfile('stdio', "") if runtime.platformType == 'win32': # windows doesn't have signals, so we don't get 'killed' self.expectOutcome(result=EXCEPTION, status_text=["failed (1)", "interrupted"]) else: self.expectOutcome(result=EXCEPTION, status_text=["killed (9)", "interrupted"]) d = self.runStep() self.step.interrupt("KILL") return d def test_real_cmd_fails(self): cmd = [ sys.executable, '-c', 'import sys; sys.exit(1)' ] self.setupStep( master.MasterShellCommand(command=cmd)) self.expectLogfile('stdio', "") self.expectOutcome(result=FAILURE, status_text=["failed (1)"]) return self.runStep() def test_constr_args(self): self.setupStep( master.MasterShellCommand(description='x', descriptionDone='y', env={'a':'b'}, path=['/usr/bin'], usePTY=True, command='true')) self.assertEqual(self.step.describe(), ['x']) if runtime.platformType == 'win32': exp_argv = [ r'C:\WINDOWS\system32\cmd.exe', '/c', 'true' ] else: exp_argv = [ '/bin/sh', '-c', 'true' ] self.patchSpawnProcess( exp_cmd=exp_argv[0], exp_argv=exp_argv, exp_path=['/usr/bin'], exp_usePTY=True, exp_env={'a':'b'}, outputs=[ ('out', 'hello!\n'), ('err', 'world\n'), ('rc', 0), ]) self.expectOutcome(result=SUCCESS, status_text=['y']) return self.runStep() def test_env_subst(self): cmd = [ sys.executable, '-c', 'import os; print os.environ["HELLO"]' ] os.environ['WORLD'] = 'hello' self.setupStep( master.MasterShellCommand(command=cmd, env={'HELLO': '${WORLD}'})) if runtime.platformType == 'win32': self.expectLogfile('stdio', "hello\r\n") else: self.expectLogfile('stdio', "hello\n") self.expectOutcome(result=SUCCESS, status_text=["Ran"]) def _restore_env(res): del os.environ['WORLD'] return res d = self.runStep() d.addBoth(_restore_env) return d def test_env_list_subst(self): cmd = [ sys.executable, '-c', 'import os; print os.environ["HELLO"]' ] os.environ['WORLD'] = 'hello' os.environ['LIST'] = 'world' self.setupStep( master.MasterShellCommand(command=cmd, env={'HELLO': ['${WORLD}', '${LIST}']})) if runtime.platformType == 'win32': self.expectLogfile('stdio', "hello;world\r\n") else: self.expectLogfile('stdio', "hello:world\n") self.expectOutcome(result=SUCCESS, status_text=["Ran"]) def _restore_env(res): del os.environ['WORLD'] del os.environ['LIST'] return res d = self.runStep() d.addBoth(_restore_env) return d def test_prop_rendering(self): cmd = [ sys.executable, '-c', WithProperties( 'import os; print "%s"; print os.environ[\"BUILD\"]', 'project') ] self.setupStep( master.MasterShellCommand(command=cmd, env={'BUILD': WithProperties('%s', "project")})) self.properties.setProperty("project", "BUILDBOT-TEST", "TEST") if runtime.platformType == 'win32': self.expectLogfile('stdio', "BUILDBOT-TEST\r\nBUILDBOT-TEST\r\n") else: self.expectLogfile('stdio', "BUILDBOT-TEST\nBUILDBOT-TEST\n") self.expectOutcome(result=SUCCESS, status_text=["Ran"]) return self.runStep() def test_constr_args_descriptionSuffix(self): self.setupStep( master.MasterShellCommand(description='x', descriptionDone='y', descriptionSuffix='z', env={'a':'b'}, path=['/usr/bin'], usePTY=True, command='true')) # call twice to make sure the suffix doesn't get double added self.assertEqual(self.step.describe(), ['x', 'z']) self.assertEqual(self.step.describe(), ['x', 'z']) if runtime.platformType == 'win32': exp_argv = [ r'C:\WINDOWS\system32\cmd.exe', '/c', 'true' ] else: exp_argv = [ '/bin/sh', '-c', 'true' ] self.patchSpawnProcess( exp_cmd=exp_argv[0], exp_argv=exp_argv, exp_path=['/usr/bin'], exp_usePTY=True, exp_env={'a':'b'}, outputs=[ ('out', 'hello!\n'), ('err', 'world\n'), ('rc', 0), ]) self.expectOutcome(result=SUCCESS, status_text=['y', 'z']) return self.runStep() class TestSetProperty(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_simple(self): self.setupStep(master.SetProperty(property="testProperty", value=Interpolate("sch=%(prop:scheduler)s, slave=%(prop:slavename)s"))) self.properties.setProperty('scheduler', 'force', source='SetProperty', runtime=True) self.properties.setProperty('slavename', 'testSlave', source='SetPropery', runtime=True) self.expectOutcome(result=SUCCESS, status_text=["SetProperty"]) self.expectProperty('testProperty', 'sch=force, slave=testSlave', source='SetProperty') return self.runStep() class TestLogRenderable(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_simple(self): self.setupStep(master.LogRenderable(content=Interpolate('sch=%(prop:scheduler)s, slave=%(prop:slavename)s'))) self.properties.setProperty('scheduler', 'force', source='TestSetProperty', runtime=True) self.properties.setProperty('slavename', 'testSlave', source='TestSetProperty', runtime=True) self.expectOutcome(result=SUCCESS, status_text=['LogRenderable']) self.expectLogfile('Output', pprint.pformat('sch=force, slave=testSlave')) return self.runStep() buildbot-0.8.8/buildbot/test/unit/test_steps_maxq.py000066400000000000000000000051011222546025000226650ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.test.util import steps from buildbot.status.results import SUCCESS, FAILURE from buildbot.test.fake.remotecommand import ExpectShell from buildbot.steps import maxq from buildbot import config class TestShellCommandExecution(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_testdir_required(self): self.assertRaises(config.ConfigErrors, lambda : maxq.MaxQ()) def test_success(self): self.setupStep( maxq.MaxQ(testdir='x')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command="run_maxq.py x") + ExpectShell.log('stdio', stdout='no failures\n') + 0 ) self.expectOutcome(result=SUCCESS, status_text=['maxq', 'tests']) return self.runStep() def test_nonzero_rc_no_failures(self): self.setupStep( maxq.MaxQ(testdir='x')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command="run_maxq.py x") + ExpectShell.log('stdio', stdout='no failures\n') + 2 ) self.expectOutcome(result=FAILURE, status_text=['1', 'maxq', 'failures']) return self.runStep() def test_failures(self): self.setupStep( maxq.MaxQ(testdir='x')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command="run_maxq.py x") + ExpectShell.log('stdio', stdout='\nTEST FAILURE: foo\n' * 10) + 2 ) self.expectOutcome(result=FAILURE, status_text=['10', 'maxq', 'failures']) return self.runStep() buildbot-0.8.8/buildbot/test/unit/test_steps_package_deb_lintian.py000066400000000000000000000042131222546025000256450ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.status.results import SUCCESS from buildbot.steps.package.deb import lintian from buildbot.test.fake.remotecommand import ExpectShell from buildbot.test.util import steps from twisted.trial import unittest from buildbot import config class TestDebLintian(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_no_fileloc(self): self.assertRaises(config.ConfigErrors, lambda : lintian.DebLintian()) def test_success(self): self.setupStep(lintian.DebLintian('foo_0.23_i386.changes')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['lintian', '-v', 'foo_0.23_i386.changes']) +0) self.expectOutcome(result=SUCCESS, status_text=['Lintian']) return self.runStep() def test_success_suppressTags(self): self.setupStep(lintian.DebLintian('foo_0.23_i386.changes', suppressTags=['bad-distribution-in-changes-file'])) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['lintian', '-v', 'foo_0.23_i386.changes', '--suppress-tags', 'bad-distribution-in-changes-file']) +0) self.expectOutcome(result=SUCCESS, status_text=['Lintian']) return self.runStep() buildbot-0.8.8/buildbot/test/unit/test_steps_package_deb_pbuilder.py000066400000000000000000000431051222546025000260200ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import stat import time from twisted.trial import unittest from buildbot.steps.package.deb import pbuilder from buildbot.status.results import SUCCESS, FAILURE from buildbot.test.util import steps from buildbot.test.fake.remotecommand import ExpectShell, Expect from buildbot import config class TestDebPbuilder(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_new(self): self.setupStep(pbuilder.DebPbuilder()) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/stable-local-buildbot.tgz'}) + 1, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sudo', '/usr/sbin/pbuilder', '--create', '--basetgz', '/var/cache/pbuilder/stable-local-buildbot.tgz', '--distribution', 'stable', '--mirror', 'http://cdn.debian.net/debian/']) +0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/pbuilder', '--', '--buildresult', '.', '--basetgz', '/var/cache/pbuilder/stable-local-buildbot.tgz']) +0) self.expectOutcome(result=SUCCESS, status_text=['pdebuild']) return self.runStep() def test_update(self): self.setupStep(pbuilder.DebPbuilder()) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/stable-local-buildbot.tgz'}) + Expect.update('stat', [stat.S_IFREG, 99, 99, 1, 0, 0, 99, 0, 0, 0]) + 0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sudo', '/usr/sbin/pbuilder', '--update', '--basetgz', '/var/cache/pbuilder/stable-local-buildbot.tgz',]) +0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/pbuilder', '--', '--buildresult', '.', '--basetgz', '/var/cache/pbuilder/stable-local-buildbot.tgz']) +0) self.expectOutcome(result=SUCCESS, status_text=['pdebuild']) return self.runStep() def test_buildonly(self): self.setupStep(pbuilder.DebPbuilder()) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/stable-local-buildbot.tgz'}) + Expect.update('stat', [stat.S_IFREG, 99, 99, 1, 0, 0, 99, 0, int(time.time()), 0]) + 0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/pbuilder', '--', '--buildresult', '.', '--basetgz', '/var/cache/pbuilder/stable-local-buildbot.tgz']) +0) self.expectOutcome(result=SUCCESS, status_text=['pdebuild']) return self.runStep() def test_architecture(self): self.setupStep(pbuilder.DebPbuilder(architecture='amd64')) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/stable-amd64-buildbot.tgz'}) + 1, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sudo', '/usr/sbin/pbuilder', '--create', '--basetgz', '/var/cache/pbuilder/stable-amd64-buildbot.tgz', '--distribution', 'stable', '--mirror', 'http://cdn.debian.net/debian/', '--architecture', 'amd64']) +0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/pbuilder', '--architecture', 'amd64', '--', '--buildresult', '.', '--basetgz', '/var/cache/pbuilder/stable-amd64-buildbot.tgz']) +0) self.expectOutcome(result=SUCCESS, status_text=['pdebuild']) return self.runStep() def test_distribution(self): self.setupStep(pbuilder.DebPbuilder(distribution='woody')) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/woody-local-buildbot.tgz'}) + 1, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sudo', '/usr/sbin/pbuilder', '--create', '--basetgz', '/var/cache/pbuilder/woody-local-buildbot.tgz', '--distribution', 'woody', '--mirror', 'http://cdn.debian.net/debian/']) +0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/pbuilder', '--', '--buildresult', '.', '--basetgz', '/var/cache/pbuilder/woody-local-buildbot.tgz']) +0) self.expectOutcome(result=SUCCESS, status_text=['pdebuild']) return self.runStep() def test_basetgz(self): self.setupStep(pbuilder.DebPbuilder(basetgz='/buildbot/%(distribution)s-%(architecture)s.tgz')) self.expectCommands( Expect('stat', {'file': '/buildbot/stable-local.tgz'}) + 1, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sudo', '/usr/sbin/pbuilder', '--create', '--basetgz', '/buildbot/stable-local.tgz', '--distribution', 'stable', '--mirror', 'http://cdn.debian.net/debian/']) +0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/pbuilder', '--', '--buildresult', '.', '--basetgz', '/buildbot/stable-local.tgz']) +0) self.expectOutcome(result=SUCCESS, status_text=['pdebuild']) return self.runStep() def test_mirror(self): self.setupStep(pbuilder.DebPbuilder(mirror='http://apt:9999/debian')) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/stable-local-buildbot.tgz'}) + 1, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sudo', '/usr/sbin/pbuilder', '--create', '--basetgz', '/var/cache/pbuilder/stable-local-buildbot.tgz', '--distribution', 'stable', '--mirror', 'http://apt:9999/debian']) +0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/pbuilder', '--', '--buildresult', '.', '--basetgz', '/var/cache/pbuilder/stable-local-buildbot.tgz']) +0) self.expectOutcome(result=SUCCESS, status_text=['pdebuild']) return self.runStep() def test_extrapackages(self): self.setupStep(pbuilder.DebPbuilder(extrapackages=['buildbot'])) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/stable-local-buildbot.tgz'}) + 1, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sudo', '/usr/sbin/pbuilder', '--create', '--basetgz', '/var/cache/pbuilder/stable-local-buildbot.tgz', '--distribution', 'stable', '--mirror', 'http://cdn.debian.net/debian/', '--extrapackages', 'buildbot']) +0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/pbuilder', '--', '--buildresult', '.', '--basetgz', '/var/cache/pbuilder/stable-local-buildbot.tgz', '--extrapackages', 'buildbot']) +0) self.expectOutcome(result=SUCCESS, status_text=['pdebuild']) return self.runStep() def test_keyring(self): self.setupStep(pbuilder.DebPbuilder(keyring='/builbot/buildbot.gpg')) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/stable-local-buildbot.tgz'}) + 1, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sudo', '/usr/sbin/pbuilder', '--create', '--basetgz', '/var/cache/pbuilder/stable-local-buildbot.tgz', '--distribution', 'stable', '--mirror', 'http://cdn.debian.net/debian/', '--debootstrapopts', '--keyring=/builbot/buildbot.gpg']) +0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/pbuilder', '--', '--buildresult', '.', '--basetgz', '/var/cache/pbuilder/stable-local-buildbot.tgz']) +0) self.expectOutcome(result=SUCCESS, status_text=['pdebuild']) return self.runStep() def test_components(self): self.setupStep(pbuilder.DebPbuilder(components='main universe')) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/stable-local-buildbot.tgz'}) + 1, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sudo', '/usr/sbin/pbuilder', '--create', '--basetgz', '/var/cache/pbuilder/stable-local-buildbot.tgz', '--distribution', 'stable', '--mirror', 'http://cdn.debian.net/debian/', '--components', 'main universe']) +0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/pbuilder', '--', '--buildresult', '.', '--basetgz', '/var/cache/pbuilder/stable-local-buildbot.tgz']) +0) self.expectOutcome(result=SUCCESS, status_text=['pdebuild']) return self.runStep() class TestDebCowbuilder(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_new(self): self.setupStep(pbuilder.DebCowbuilder()) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/stable-local-buildbot.cow/'}) + 1, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sudo', '/usr/sbin/cowbuilder', '--create', '--basepath', '/var/cache/pbuilder/stable-local-buildbot.cow/', '--distribution', 'stable', '--mirror', 'http://cdn.debian.net/debian/']) +0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/cowbuilder', '--', '--buildresult', '.', '--basepath', '/var/cache/pbuilder/stable-local-buildbot.cow/']) +0) self.expectOutcome(result=SUCCESS, status_text=['pdebuild']) return self.runStep() def test_update(self): self.setupStep(pbuilder.DebCowbuilder()) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/stable-local-buildbot.cow/'}) + Expect.update('stat', [stat.S_IFDIR, 99, 99, 1, 0, 0, 99, 0, 0, 0]) + 0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sudo', '/usr/sbin/cowbuilder', '--update', '--basepath', '/var/cache/pbuilder/stable-local-buildbot.cow/',]) +0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/cowbuilder', '--', '--buildresult', '.', '--basepath', '/var/cache/pbuilder/stable-local-buildbot.cow/']) +0) self.expectOutcome(result=SUCCESS, status_text=['pdebuild']) return self.runStep() def test_buildonly(self): self.setupStep(pbuilder.DebCowbuilder()) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/stable-local-buildbot.cow/'}) + Expect.update('stat', [stat.S_IFDIR, 99, 99, 1, 0, 0, 99, 0, int(time.time()), 0]) + 0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/cowbuilder', '--', '--buildresult', '.', '--basepath', '/var/cache/pbuilder/stable-local-buildbot.cow/']) +0) self.expectOutcome(result=SUCCESS, status_text=['pdebuild']) return self.runStep() def test_update_reg(self): self.setupStep(pbuilder.DebCowbuilder(basetgz='/var/cache/pbuilder/stable-local-buildbot.cow')) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/stable-local-buildbot.cow'}) + Expect.update('stat', [stat.S_IFREG, 99, 99, 1, 0, 0, 99, 0, 0, 0]) + 0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sudo', '/usr/sbin/cowbuilder', '--update', '--basepath', '/var/cache/pbuilder/stable-local-buildbot.cow']) + 1) self.expectOutcome(result=FAILURE, status_text=['PBuilder update.']) return self.runStep() def test_buildonly_reg(self): self.setupStep(pbuilder.DebCowbuilder(basetgz='/var/cache/pbuilder/stable-local-buildbot.cow')) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/stable-local-buildbot.cow'}) + Expect.update('stat', [stat.S_IFREG, 99, 99, 1, 0, 0, 99, 0, int(time.time()), 0]) + 0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/cowbuilder', '--', '--buildresult', '.', '--basepath', '/var/cache/pbuilder/stable-local-buildbot.cow']) + 1) self.expectOutcome(result=FAILURE, status_text=['pdebuild', 'failed']) return self.runStep() class TestUbuPbuilder(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_no_distribution(self): self.assertRaises(config.ConfigErrors, lambda : pbuilder.UbuPbuilder()) def test_new(self): self.setupStep(pbuilder.UbuPbuilder(distribution='oneiric')) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/oneiric-local-buildbot.tgz'}) + 1, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sudo', '/usr/sbin/pbuilder', '--create', '--basetgz', '/var/cache/pbuilder/oneiric-local-buildbot.tgz', '--distribution', 'oneiric', '--mirror', 'http://archive.ubuntu.com/ubuntu/', '--components', 'main universe']) +0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/pbuilder', '--', '--buildresult', '.', '--basetgz', '/var/cache/pbuilder/oneiric-local-buildbot.tgz']) +0) self.expectOutcome(result=SUCCESS, status_text=['pdebuild']) return self.runStep() class TestUbuCowbuilder(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_no_distribution(self): self.assertRaises(config.ConfigErrors, lambda : pbuilder.UbuCowbuilder()) def test_new(self): self.setupStep(pbuilder.UbuCowbuilder(distribution='oneiric')) self.expectCommands( Expect('stat', {'file': '/var/cache/pbuilder/oneiric-local-buildbot.cow/'}) + 1, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sudo', '/usr/sbin/cowbuilder', '--create', '--basepath', '/var/cache/pbuilder/oneiric-local-buildbot.cow/', '--distribution', 'oneiric', '--mirror', 'http://archive.ubuntu.com/ubuntu/', '--components', 'main universe']) +0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['pdebuild', '--buildresult', '.', '--pbuilder', '/usr/sbin/cowbuilder', '--', '--buildresult', '.', '--basepath', '/var/cache/pbuilder/oneiric-local-buildbot.cow/']) +0) self.expectOutcome(result=SUCCESS, status_text=['pdebuild']) return self.runStep() buildbot-0.8.8/buildbot/test/unit/test_steps_package_rpm_mock.py000066400000000000000000000123021222546025000252020ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.steps.package.rpm import mock from buildbot.status.results import SUCCESS from buildbot.test.util import steps from buildbot.test.fake.remotecommand import ExpectShell, Expect from buildbot import config class TestMock(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_no_root(self): self.assertRaises(config.ConfigErrors, lambda : mock.Mock()) def test_class_attrs(self): step = self.setupStep(mock.Mock(root='TESTROOT')) self.assertEqual(step.command, ['mock', '--root', 'TESTROOT']) def test_success(self): self.setupStep(mock.Mock(root='TESTROOT')) self.expectCommands( Expect('rmdir', {'dir': ['build/build.log', 'build/root.log', 'build/state.log']}) + 0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['mock', '--root', 'TESTROOT'], logfiles={'build.log': 'build.log', 'root.log': 'root.log', 'state.log': 'state.log'}) +0) self.expectOutcome(result=SUCCESS, status_text=["'mock", '--root', "...'"]) return self.runStep() def test_resultdir_success(self): self.setupStep(mock.Mock(root='TESTROOT', resultdir='RESULT')) self.expectCommands( Expect('rmdir', {'dir': ['build/RESULT/build.log', 'build/RESULT/root.log', 'build/RESULT/state.log']}) + 0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['mock', '--root', 'TESTROOT', '--resultdir', 'RESULT'], logfiles={'build.log': 'RESULT/build.log', 'root.log': 'RESULT/root.log', 'state.log': 'RESULT/state.log'}) +0) self.expectOutcome(result=SUCCESS, status_text=["'mock", '--root', "...'"]) return self.runStep() class TestMockBuildSRPM(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_no_spec(self): self.assertRaises(config.ConfigErrors, lambda : mock.MockBuildSRPM(root='TESTROOT')) def test_success(self): self.setupStep(mock.MockBuildSRPM(root='TESTROOT', spec="foo.spec")) self.expectCommands( Expect('rmdir', {'dir': ['build/build.log', 'build/root.log', 'build/state.log']}) + 0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['mock', '--root', 'TESTROOT', '--buildsrpm', '--spec', 'foo.spec', '--sources', '.'], logfiles={'build.log': 'build.log', 'root.log': 'root.log', 'state.log': 'state.log'},) +0) self.expectOutcome(result=SUCCESS, status_text=['mock buildsrpm']) return self.runStep() class TestMockRebuild(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_no_srpm(self): self.assertRaises(config.ConfigErrors, lambda : mock.MockRebuild(root='TESTROOT')) def test_success(self): self.setupStep(mock.MockRebuild(root='TESTROOT', srpm="foo.src.rpm")) self.expectCommands( Expect('rmdir', {'dir': ['build/build.log', 'build/root.log', 'build/state.log']}) + 0, ExpectShell(workdir='wkdir', usePTY='slave-config', command=['mock', '--root', 'TESTROOT', '--rebuild', 'foo.src.rpm'], logfiles={'build.log': 'build.log', 'root.log': 'root.log', 'state.log': 'state.log'},) +0) self.expectOutcome(result=SUCCESS, status_text=['mock rebuild srpm']) return self.runStep() buildbot-0.8.8/buildbot/test/unit/test_steps_package_rpm_rpmbuild.py000066400000000000000000000054001222546025000260700ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.status.results import SUCCESS from buildbot.steps.package.rpm import rpmbuild from buildbot.test.fake.remotecommand import ExpectShell from buildbot.test.util import steps from twisted.trial import unittest from buildbot import config class RpmBuild(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_no_specfile(self): self.assertRaises(config.ConfigErrors, lambda : rpmbuild.RpmBuild()) def test_success(self): self.setupStep(rpmbuild.RpmBuild(specfile="foo.spec", dist=".el6")) self.expectCommands( ExpectShell(workdir='wkdir', command='rpmbuild --define "_topdir ' '`pwd`" --define "_builddir `pwd`" --define "_rpmdir ' '`pwd`" --define "_sourcedir `pwd`" --define "_specdir ' '`pwd`" --define "_srcrpmdir `pwd`" --define "dist .el6" ' '-ba foo.spec', usePTY='slave-config') + ExpectShell.log('stdio', stdout='lalala') +0) self.expectOutcome(result=SUCCESS, status_text=['RPMBUILD']) return self.runStep() def test_autoRelease(self): self.setupStep(rpmbuild.RpmBuild(specfile="foo.spec", dist=".el6", autoRelease=True)) self.expectCommands( ExpectShell(workdir='wkdir', command='rpmbuild --define "_topdir ' '`pwd`" --define "_builddir `pwd`" --define "_rpmdir `pwd`" ' '--define "_sourcedir `pwd`" --define "_specdir `pwd`" ' '--define "_srcrpmdir `pwd`" --define "dist .el6" ' '--define "_release 0" -ba foo.spec', usePTY='slave-config') + ExpectShell.log('stdio', stdout='Your code has been rated at 10/10') +0) self.expectOutcome(result=SUCCESS, status_text=['RPMBUILD']) return self.runStep() buildbot-0.8.8/buildbot/test/unit/test_steps_package_rpm_rpmlint.py000066400000000000000000000043111222546025000257370ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.status.results import SUCCESS from buildbot.steps.package.rpm import rpmlint from buildbot.test.fake.remotecommand import ExpectShell from buildbot.test.util import steps from twisted.trial import unittest class TestRpmLint(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_success(self): self.setupStep(rpmlint.RpmLint()) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['rpmlint', '-i', '.']) +0) self.expectOutcome(result=SUCCESS, status_text=['Finished checking RPM/SPEC issues']) return self.runStep() def test_fileloc_success(self): self.setupStep(rpmlint.RpmLint(fileloc='RESULT')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['rpmlint', '-i', 'RESULT']) +0) self.expectOutcome(result=SUCCESS, status_text=['Finished checking RPM/SPEC issues']) return self.runStep() def test_config_success(self): self.setupStep(rpmlint.RpmLint(config='foo.cfg')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['rpmlint', '-i', '-f', 'foo.cfg', '.']) +0) self.expectOutcome(result=SUCCESS, status_text=['Finished checking RPM/SPEC issues']) return self.runStep() buildbot-0.8.8/buildbot/test/unit/test_steps_python.py000066400000000000000000000434711222546025000232540ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.status.results import FAILURE, SUCCESS, WARNINGS from buildbot.steps import python from buildbot.test.fake.remotecommand import ExpectShell from buildbot.test.util import steps from twisted.trial import unittest from buildbot import config log_output_success = '''\ Making output directory... Running Sphinx v1.0.7 loading pickled environment... not yet created No builder selected, using default: html building [html]: targets for 24 source files that are out of date updating environment: 24 added, 0 changed, 0 removed reading sources... [ 4%] index reading sources... [ 8%] manual/cfg-builders ... copying static files... done dumping search index... done dumping object inventory... done build succeeded. ''' log_output_nochange = '''\ Running Sphinx v1.0.7 loading pickled environment... done No builder selected, using default: html building [html]: targets for 0 source files that are out of date updating environment: 0 added, 0 changed, 0 removed looking for now-outdated files... none found no targets are out of date. ''' log_output_warnings = '''\ Running Sphinx v1.0.7 loading pickled environment... done building [html]: targets for 1 source files that are out of date updating environment: 0 added, 1 changed, 0 removed reading sources... [100%] file file.rst:18: (WARNING/2) Literal block expected; none found. looking for now-outdated files... none found pickling environment... done checking consistency... done preparing documents... done writing output... [ 50%] index writing output... [100%] file index.rst:: WARNING: toctree contains reference to document 'preamble' that \ doesn't have a title: no link will be generated writing additional files... search copying static files... done dumping search index... done dumping object inventory... done build succeeded, 2 warnings.''' warnings = '''\ file.rst:18: (WARNING/2) Literal block expected; none found. index.rst:: WARNING: toctree contains reference to document 'preamble' that \ doesn't have a title: no link will be generated\ ''' class PyLint(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_success(self): self.setupStep(python.PyLint(command=['pylint'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['pylint'], usePTY='slave-config') + ExpectShell.log('stdio', stdout='Your code has been rated at 10/10') + python.PyLint.RC_OK) self.expectOutcome(result=SUCCESS, status_text=['pylint']) return self.runStep() def test_error(self): self.setupStep(python.PyLint(command=['pylint'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['pylint'], usePTY='slave-config') + ExpectShell.log( 'stdio', stdout=('W: 11: Bad indentation. Found 6 spaces, expected 4\n' 'E: 12: Undefined variable \'foo\'\n')) + (python.PyLint.RC_WARNING|python.PyLint.RC_ERROR)) self.expectOutcome(result=FAILURE, status_text=['pylint', 'error=1', 'warning=1', 'failed']) self.expectProperty('pylint-warning', 1) self.expectProperty('pylint-error', 1) return self.runStep() def test_failure(self): self.setupStep(python.PyLint(command=['pylint'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['pylint'], usePTY='slave-config') + ExpectShell.log( 'stdio', stdout=('W: 11: Bad indentation. Found 6 spaces, expected 4\n' 'F: 13: something really strange happened\n')) + (python.PyLint.RC_WARNING|python.PyLint.RC_FATAL)) self.expectOutcome(result=FAILURE, status_text=['pylint', 'fatal=1', 'warning=1', 'failed']) self.expectProperty('pylint-warning', 1) self.expectProperty('pylint-fatal', 1) return self.runStep() def test_failure_zero_returncode(self): # Make sure that errors result in a failed step when pylint's # return code is 0, e.g. when run through a wrapper script. self.setupStep(python.PyLint(command=['pylint'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['pylint'], usePTY='slave-config') + ExpectShell.log( 'stdio', stdout=('W: 11: Bad indentation. Found 6 spaces, expected 4\n' 'E: 12: Undefined variable \'foo\'\n')) + 0) self.expectOutcome(result=FAILURE, status_text=['pylint', 'error=1', 'warning=1', 'failed']) self.expectProperty('pylint-warning', 1) self.expectProperty('pylint-error', 1) return self.runStep() def test_regex_text(self): self.setupStep(python.PyLint(command=['pylint'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['pylint'], usePTY='slave-config') + ExpectShell.log( 'stdio', stdout=('W: 11: Bad indentation. Found 6 spaces, expected 4\n' 'C: 1:foo123: Missing docstring\n')) + (python.PyLint.RC_WARNING|python.PyLint.RC_CONVENTION)) self.expectOutcome(result=WARNINGS, status_text=['pylint', 'convention=1', 'warning=1', 'warnings']) self.expectProperty('pylint-warning', 1) self.expectProperty('pylint-convention', 1) self.expectProperty('pylint-total', 2) return self.runStep() def test_regex_text_0_24(self): # pylint >= 0.24.0 prints out column offsets when using text format self.setupStep(python.PyLint(command=['pylint'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['pylint'], usePTY='slave-config') + ExpectShell.log( 'stdio', stdout=('W: 11,0: Bad indentation. Found 6 spaces, expected 4\n' 'C: 3,10:foo123: Missing docstring\n')) + (python.PyLint.RC_WARNING|python.PyLint.RC_CONVENTION)) self.expectOutcome(result=WARNINGS, status_text=['pylint', 'convention=1', 'warning=1', 'warnings']) self.expectProperty('pylint-warning', 1) self.expectProperty('pylint-convention', 1) self.expectProperty('pylint-total', 2) return self.runStep() def test_regex_text_ids(self): self.setupStep(python.PyLint(command=['pylint'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['pylint'], usePTY='slave-config') + ExpectShell.log( 'stdio', stdout=('W0311: 11: Bad indentation.\n' 'C0111: 1:funcName: Missing docstring\n')) + (python.PyLint.RC_WARNING|python.PyLint.RC_CONVENTION)) self.expectOutcome(result=WARNINGS, status_text=['pylint', 'convention=1', 'warning=1', 'warnings']) self.expectProperty('pylint-warning', 1) self.expectProperty('pylint-convention', 1) self.expectProperty('pylint-total', 2) return self.runStep() def test_regex_text_ids_0_24(self): # pylint >= 0.24.0 prints out column offsets when using text format self.setupStep(python.PyLint(command=['pylint'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['pylint'], usePTY='slave-config') + ExpectShell.log( 'stdio', stdout=('W0311: 11,0: Bad indentation.\n' 'C0111: 3,10:foo123: Missing docstring\n')) + (python.PyLint.RC_WARNING|python.PyLint.RC_CONVENTION)) self.expectOutcome(result=WARNINGS, status_text=['pylint', 'convention=1', 'warning=1', 'warnings']) self.expectProperty('pylint-warning', 1) self.expectProperty('pylint-convention', 1) self.expectProperty('pylint-total', 2) return self.runStep() def test_regex_parseable_ids(self): self.setupStep(python.PyLint(command=['pylint'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['pylint'], usePTY='slave-config') + ExpectShell.log( 'stdio', stdout=('test.py:9: [W0311] Bad indentation.\n' 'test.py:3: [C0111, foo123] Missing docstring\n')) + (python.PyLint.RC_WARNING|python.PyLint.RC_CONVENTION)) self.expectOutcome(result=WARNINGS, status_text=['pylint', 'convention=1', 'warning=1', 'warnings']) self.expectProperty('pylint-warning', 1) self.expectProperty('pylint-convention', 1) self.expectProperty('pylint-total', 2) return self.runStep() def test_regex_parseable(self): self.setupStep(python.PyLint(command=['pylint'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['pylint'], usePTY='slave-config') + ExpectShell.log( 'stdio', stdout=('test.py:9: [W] Bad indentation.\n' 'test.py:3: [C, foo123] Missing docstring\n')) + (python.PyLint.RC_WARNING|python.PyLint.RC_CONVENTION)) self.expectOutcome(result=WARNINGS, status_text=['pylint', 'convention=1', 'warning=1', 'warnings']) self.expectProperty('pylint-warning', 1) self.expectProperty('pylint-convention', 1) self.expectProperty('pylint-total', 2) return self.runStep() class PyFlakes(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_success(self): self.setupStep(python.PyFlakes()) self.expectCommands( ExpectShell(workdir='wkdir', command=['make', 'pyflakes'], usePTY='slave-config') + 0) self.expectOutcome(result=SUCCESS, status_text=['pyflakes']) return self.runStep() def test_unused(self): self.setupStep(python.PyFlakes()) self.expectCommands( ExpectShell(workdir='wkdir', command=['make', 'pyflakes'], usePTY='slave-config') + ExpectShell.log( 'stdio', stdout="foo.py:1: 'bar' imported but unused\n") + 1) self.expectOutcome(result=WARNINGS, status_text=['pyflakes', 'unused=1', 'warnings']) self.expectProperty('pyflakes-unused', 1) self.expectProperty('pyflakes-total', 1) return self.runStep() def test_undefined(self): self.setupStep(python.PyFlakes()) self.expectCommands( ExpectShell(workdir='wkdir', command=['make', 'pyflakes'], usePTY='slave-config') + ExpectShell.log( 'stdio', stdout="foo.py:1: undefined name 'bar'\n") + 1) self.expectOutcome(result=FAILURE, status_text=['pyflakes', 'undefined=1', 'failed']) self.expectProperty('pyflakes-undefined', 1) self.expectProperty('pyflakes-total', 1) return self.runStep() def test_redefs(self): self.setupStep(python.PyFlakes()) self.expectCommands( ExpectShell(workdir='wkdir', command=['make', 'pyflakes'], usePTY='slave-config') + ExpectShell.log( 'stdio', stdout="foo.py:2: redefinition of unused 'foo' from line 1\n") + 1) self.expectOutcome(result=WARNINGS, status_text=['pyflakes', 'redefs=1', 'warnings']) self.expectProperty('pyflakes-redefs', 1) self.expectProperty('pyflakes-total', 1) return self.runStep() def test_importstar(self): self.setupStep(python.PyFlakes()) self.expectCommands( ExpectShell(workdir='wkdir', command=['make', 'pyflakes'], usePTY='slave-config') + ExpectShell.log( 'stdio', stdout="foo.py:1: 'from module import *' used; unable to detect undefined names\n") + 1) self.expectOutcome(result=WARNINGS, status_text=['pyflakes', 'import*=1', 'warnings']) self.expectProperty('pyflakes-import*', 1) self.expectProperty('pyflakes-total', 1) return self.runStep() def test_misc(self): self.setupStep(python.PyFlakes()) self.expectCommands( ExpectShell(workdir='wkdir', command=['make', 'pyflakes'], usePTY='slave-config') + ExpectShell.log( 'stdio', stdout="foo.py:2: redefinition of function 'bar' from line 1\n") + 1) self.expectOutcome(result=WARNINGS, status_text=['pyflakes', 'misc=1', 'warnings']) self.expectProperty('pyflakes-misc', 1) self.expectProperty('pyflakes-total', 1) return self.runStep() class TestSphinx(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_builddir_required(self): self.assertRaises(config.ConfigErrors, lambda : python.Sphinx()) def test_bad_mode(self): self.assertRaises(config.ConfigErrors, lambda: python.Sphinx( sphinx_builddir="_build", mode="don't care")) def test_success(self): self.setupStep(python.Sphinx(sphinx_builddir="_build")) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sphinx-build', '.', '_build']) + ExpectShell.log('stdio', stdout=log_output_success) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["sphinx", "0 warnings"]) return self.runStep() def test_failure(self): self.setupStep(python.Sphinx(sphinx_builddir="_build")) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sphinx-build', '.', '_build']) + ExpectShell.log('stdio', stdout='oh noes!') + 1 ) self.expectOutcome(result=FAILURE, status_text=["sphinx", "0 warnings", "failed"]) return self.runStep() def test_nochange(self): self.setupStep(python.Sphinx(sphinx_builddir="_build")) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sphinx-build', '.', '_build']) + ExpectShell.log('stdio', stdout=log_output_nochange) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["sphinx", "0 warnings"]) return self.runStep() def test_warnings(self): self.setupStep(python.Sphinx(sphinx_builddir="_build")) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['sphinx-build', '.', '_build']) + ExpectShell.log('stdio', stdout=log_output_warnings) + 0 ) self.expectOutcome(result=WARNINGS, status_text=["sphinx", "2 warnings", "warnings"]) self.expectLogfile("warnings", warnings) d = self.runStep() def check(_): self.assertEqual(self.step_statistics, { 'warnings' : 2 }) d.addCallback(check) return d def test_constr_args(self): self.setupStep(python.Sphinx(sphinx_sourcedir='src', sphinx_builddir="bld", sphinx_builder='css', sphinx="/path/to/sphinx-build", tags=['a', 'b'], defines=dict(empty=None, t=True, f=False, s="str"), mode='full')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['/path/to/sphinx-build', '-b', 'css', '-t', 'a', '-t', 'b', '-D', 'empty', '-D', 'f=0', '-D', 's=str', '-D', 't=1', '-E', 'src', 'bld']) + ExpectShell.log('stdio', stdout=log_output_success) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["sphinx", "0 warnings"]) return self.runStep() buildbot-0.8.8/buildbot/test/unit/test_steps_python_twisted.py000066400000000000000000000203171222546025000250110ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.steps import python_twisted from buildbot.status.results import SUCCESS from buildbot.test.util import steps from buildbot.test.fake.remotecommand import ExpectShell from buildbot.process.properties import Property class Trial(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_run_env(self): self.setupStep( python_twisted.Trial(workdir='build', tests = 'testname', testpath = None, env = {'PYTHONPATH': 'somepath'})) self.expectCommands( ExpectShell(workdir='build', command=['trial', '--reporter=bwverbose', 'testname'], usePTY="slave-config", logfiles={'test.log': '_trial_temp/test.log'}, env=dict(PYTHONPATH='somepath')) + ExpectShell.log('stdio', stdout="Ran 0 tests\n") + 0 ) self.expectOutcome(result=SUCCESS, status_text=['no tests', 'run']) return self.runStep() def test_run_env_supplement(self): self.setupStep( python_twisted.Trial(workdir='build', tests = 'testname', testpath = 'path1', env = {'PYTHONPATH': ['path2','path3']})) self.expectCommands( ExpectShell(workdir='build', command=['trial', '--reporter=bwverbose', 'testname'], usePTY="slave-config", logfiles={'test.log': '_trial_temp/test.log'}, env=dict(PYTHONPATH=['path1', 'path2', 'path3'])) + ExpectShell.log('stdio', stdout="Ran 0 tests\n") + 0 ) self.expectOutcome(result=SUCCESS, status_text=['no tests', 'run']) return self.runStep() def test_run_env_nodupe(self): self.setupStep( python_twisted.Trial(workdir='build', tests = 'testname', testpath = 'path2', env = {'PYTHONPATH': ['path1','path2']})) self.expectCommands( ExpectShell(workdir='build', command=['trial', '--reporter=bwverbose', 'testname'], usePTY="slave-config", logfiles={'test.log': '_trial_temp/test.log'}, env=dict(PYTHONPATH=['path1','path2'])) + ExpectShell.log('stdio', stdout="Ran 0 tests\n") + 0 ) self.expectOutcome(result=SUCCESS, status_text=['no tests', 'run']) return self.runStep() def test_run_singular(self): self.setupStep( python_twisted.Trial(workdir='build', tests = 'testname', testpath=None)) self.expectCommands( ExpectShell(workdir='build', command=['trial', '--reporter=bwverbose', 'testname'], usePTY="slave-config", logfiles={'test.log': '_trial_temp/test.log'}) + ExpectShell.log('stdio', stdout="Ran 1 tests\n") + 0 ) self.expectOutcome(result=SUCCESS, status_text=['1 test', 'passed']) return self.runStep() def test_run_plural(self): self.setupStep( python_twisted.Trial(workdir='build', tests = 'testname', testpath=None)) self.expectCommands( ExpectShell(workdir='build', command=['trial', '--reporter=bwverbose', 'testname'], usePTY="slave-config", logfiles={'test.log': '_trial_temp/test.log'}) + ExpectShell.log('stdio', stdout="Ran 2 tests\n") + 0 ) self.expectOutcome(result=SUCCESS, status_text=['2 tests', 'passed']) return self.runStep() def testProperties(self): self.setupStep(python_twisted.Trial(workdir='build', tests = Property('test_list'), testpath=None)) self.properties.setProperty('test_list',['testname'], 'Test') self.expectCommands( ExpectShell(workdir='build', command=['trial', '--reporter=bwverbose', 'testname'], usePTY="slave-config", logfiles={'test.log': '_trial_temp/test.log'}) + ExpectShell.log('stdio', stdout="Ran 2 tests\n") + 0 ) self.expectOutcome(result=SUCCESS, status_text=['2 tests', 'passed']) return self.runStep() def test_run_jobs(self): """ The C{jobs} kwarg should correspond to trial's -j option ( included since Twisted 12.3.0), and make corresponding changes to logfiles. """ self.setupStep(python_twisted.Trial(workdir='build', tests = 'testname', testpath = None, jobs=2)) self.expectCommands( ExpectShell(workdir='build', command=['trial', '--reporter=bwverbose', '--jobs=2', 'testname'], usePTY="slave-config", logfiles={ 'test.0.log': '_trial_temp/0/test.log', 'err.0.log': '_trial_temp/0/err.log', 'out.0.log': '_trial_temp/0/out.log', 'test.1.log': '_trial_temp/1/test.log', 'err.1.log': '_trial_temp/1/err.log', 'out.1.log': '_trial_temp/1/out.log', }) + ExpectShell.log('stdio', stdout="Ran 1 tests\n") + 0 ) self.expectOutcome(result=SUCCESS, status_text=['1 test', 'passed']) return self.runStep() def test_run_jobsProperties(self): """ C{jobs} should accept Properties """ self.setupStep(python_twisted.Trial(workdir='build', tests = 'testname', jobs=Property('jobs_count'), testpath=None)) self.properties.setProperty('jobs_count', '2', 'Test') self.expectCommands( ExpectShell(workdir='build', command=['trial', '--reporter=bwverbose', '--jobs=2', 'testname'], usePTY="slave-config", logfiles={ 'test.0.log': '_trial_temp/0/test.log', 'err.0.log': '_trial_temp/0/err.log', 'out.0.log': '_trial_temp/0/out.log', 'test.1.log': '_trial_temp/1/test.log', 'err.1.log': '_trial_temp/1/err.log', 'out.1.log': '_trial_temp/1/out.log', }) + ExpectShell.log('stdio', stdout="Ran 1 tests\n") + 0 ) self.expectOutcome(result=SUCCESS, status_text=['1 test', 'passed']) return self.runStep() buildbot-0.8.8/buildbot/test/unit/test_steps_shell.py000066400000000000000000001040001222546025000230240ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import re import textwrap from twisted.trial import unittest from buildbot.steps import shell from buildbot.status.results import SKIPPED, SUCCESS, WARNINGS, FAILURE from buildbot.status.results import EXCEPTION from buildbot.test.util import steps, compat from buildbot.test.util import config as configmixin from buildbot.test.fake.remotecommand import ExpectShell, Expect from buildbot.test.fake.remotecommand import ExpectRemoteRef from buildbot import config from buildbot.process import properties class TestShellCommandExecution(steps.BuildStepMixin, unittest.TestCase, configmixin.ConfigErrorsMixin): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_doStepIf_False(self): self.setupStep( shell.ShellCommand(command="echo hello", doStepIf=False)) self.expectOutcome(result=SKIPPED, status_text=["'echo", "hello'", "skipped"]) return self.runStep() def test_constructor_args_strings(self): step = shell.ShellCommand(workdir='build', command="echo hello", usePTY=False, description="echoing", descriptionDone="echoed") self.assertEqual(step.description, ['echoing']) self.assertEqual(step.descriptionDone, ['echoed']) def test_constructor_args_lists(self): step = shell.ShellCommand(workdir='build', command="echo hello", usePTY=False, description=["echoing"], descriptionDone=["echoed"]) self.assertEqual(step.description, ['echoing']) self.assertEqual(step.descriptionDone, ['echoed']) def test_constructor_args_kwargs(self): # this is an ugly way to define an API, but for now check that # the RemoteCommand arguments are properly passed on step = shell.ShellCommand(workdir='build', command="echo hello", want_stdout=0, logEnviron=False) self.assertEqual(step.remote_kwargs, dict(want_stdout=0, logEnviron=False, workdir='build', usePTY='slave-config')) def test_constructor_args_validity(self): # this checks that an exception is raised for invalid arguments self.assertRaisesConfigError( "Invalid argument(s) passed to RemoteShellCommand: ", lambda: shell.ShellCommand('build', "echo Hello World", wrongArg1=1, wrongArg2='two')) def test_describe_no_command(self): step = shell.ShellCommand(workdir='build') self.assertEqual((step.describe(), step.describe(done=True)), (['???'],)*2) def test_describe_from_empty_command(self): # this is more of a regression test for a potential failure, really step = shell.ShellCommand(workdir='build', command=' ') self.assertEqual((step.describe(), step.describe(done=True)), (['???'],)*2) def test_describe_from_short_command(self): step = shell.ShellCommand(workdir='build', command="true") self.assertEqual((step.describe(), step.describe(done=True)), (["'true'"],)*2) def test_describe_from_short_command_list(self): step = shell.ShellCommand(workdir='build', command=["true"]) self.assertEqual((step.describe(), step.describe(done=True)), (["'true'"],)*2) def test_describe_from_med_command(self): step = shell.ShellCommand(command="echo hello") self.assertEqual((step.describe(), step.describe(done=True)), (["'echo", "hello'"],)*2) def test_describe_from_med_command_list(self): step = shell.ShellCommand(command=["echo", "hello"]) self.assertEqual((step.describe(), step.describe(done=True)), (["'echo", "hello'"],)*2) def test_describe_from_long_command(self): step = shell.ShellCommand(command="this is a long command") self.assertEqual((step.describe(), step.describe(done=True)), (["'this", "is", "...'"],)*2) def test_describe_from_long_command_list(self): step = shell.ShellCommand(command="this is a long command".split()) self.assertEqual((step.describe(), step.describe(done=True)), (["'this", "is", "...'"],)*2) def test_describe_from_nested_command_list(self): step = shell.ShellCommand(command=["this", ["is", "a"], "nested"]) self.assertEqual((step.describe(), step.describe(done=True)), (["'this", "is", "...'"],)*2) def test_describe_from_nested_command_tuples(self): step = shell.ShellCommand(command=["this", ("is", "a"), "nested"]) self.assertEqual((step.describe(), step.describe(done=True)), (["'this", "is", "...'"],)*2) def test_describe_from_nested_command_list_empty(self): step = shell.ShellCommand(command=["this", [], ["is", "a"], "nested"]) self.assertEqual((step.describe(), step.describe(done=True)), (["'this", "is", "...'"],)*2) def test_describe_from_nested_command_list_deep(self): step = shell.ShellCommand(command=[["this", [[["is", ["a"]]]]]]) self.assertEqual((step.describe(), step.describe(done=True)), (["'this", "is", "...'"],)*2) def test_describe_custom(self): step = shell.ShellCommand(command="echo hello", description=["echoing"], descriptionDone=["echoed"]) self.assertEqual((step.describe(), step.describe(done=True)), (['echoing'], ['echoed'])) def test_describe_with_suffix(self): step = shell.ShellCommand(command="echo hello", descriptionSuffix="suffix") self.assertEqual((step.describe(), step.describe(done=True)), (["'echo", "hello'", 'suffix'],)*2) def test_describe_custom_with_suffix(self): step = shell.ShellCommand(command="echo hello", description=["echoing"], descriptionDone=["echoed"], descriptionSuffix="suffix") self.assertEqual((step.describe(), step.describe(done=True)), (['echoing', 'suffix'], ['echoed', 'suffix'])) def test_describe_no_command_with_suffix(self): step = shell.ShellCommand(workdir='build', descriptionSuffix="suffix") self.assertEqual((step.describe(), step.describe(done=True)), (['???', 'suffix'],)*2) def test_describe_unrendered_WithProperties(self): step = shell.ShellCommand(command=properties.WithProperties('')) self.assertEqual((step.describe(), step.describe(done=True)), (['???'],)*2) def test_describe_unrendered_WithProperties_list(self): step = shell.ShellCommand( command=[ 'x', properties.WithProperties(''), 'y' ]) self.assertEqual((step.describe(), step.describe(done=True)), (["'x", "y'"],)*2) @compat.usesFlushLoggedErrors def test_describe_fail(self): step = shell.ShellCommand(command=object()) self.assertEqual((step.describe(), step.describe(done=True)), (['???'],)*2) # (describe is called twice, so two exceptions) self.assertEqual(len(self.flushLoggedErrors(TypeError)), 2) def test_run_simple(self): self.setupStep( shell.ShellCommand(workdir='build', command="echo hello")) self.expectCommands( ExpectShell(workdir='build', command='echo hello', usePTY="slave-config") + 0 ) self.expectOutcome(result=SUCCESS, status_text=["'echo", "hello'"]) return self.runStep() def test_run_list(self): self.setupStep( shell.ShellCommand(workdir='build', command=['trial', '-b', '-B', 'buildbot.test'])) self.expectCommands( ExpectShell(workdir='build', command=['trial', '-b', '-B', 'buildbot.test'], usePTY="slave-config") + 0 ) self.expectOutcome(result=SUCCESS, status_text=["'trial", "-b", "...'"]) return self.runStep() def test_run_nested_command(self): self.setupStep( shell.ShellCommand(workdir='build', command=['trial', ['-b', '-B'], 'buildbot.test'])) self.expectCommands( ExpectShell(workdir='build', command=['trial', '-b', '-B', 'buildbot.test'], usePTY="slave-config") + 0 ) self.expectOutcome(result=SUCCESS, status_text=["'trial", "-b", "...'"]) return self.runStep() def test_run_nested_deeply_command(self): self.setupStep( shell.ShellCommand(workdir='build', command=[['trial', ['-b', ['-B']]], 'buildbot.test'])) self.expectCommands( ExpectShell(workdir='build', command=['trial', '-b', '-B', 'buildbot.test'], usePTY="slave-config") + 0 ) self.expectOutcome(result=SUCCESS, status_text=["'trial", "-b", "...'"]) return self.runStep() def test_run_nested_empty_command(self): self.setupStep( shell.ShellCommand(workdir='build', command=['trial', [], '-b', [], 'buildbot.test'])) self.expectCommands( ExpectShell(workdir='build', command=['trial', '-b', 'buildbot.test'], usePTY="slave-config") + 0 ) self.expectOutcome(result=SUCCESS, status_text=["'trial", "-b", "...'"]) return self.runStep() def test_run_env(self): self.setupStep( shell.ShellCommand(workdir='build', command="echo hello"), slave_env=dict(DEF='HERE')) self.expectCommands( ExpectShell(workdir='build', command='echo hello', usePTY="slave-config", env=dict(DEF='HERE')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["'echo", "hello'"]) return self.runStep() def test_run_env_override(self): self.setupStep( shell.ShellCommand(workdir='build', env={'ABC':'123'}, command="echo hello"), slave_env=dict(ABC='XXX', DEF='HERE')) self.expectCommands( ExpectShell(workdir='build', command='echo hello', usePTY="slave-config", env=dict(ABC='123', DEF='HERE')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["'echo", "hello'"]) return self.runStep() def test_run_usePTY(self): self.setupStep( shell.ShellCommand(workdir='build', command="echo hello", usePTY=False)) self.expectCommands( ExpectShell(workdir='build', command='echo hello', usePTY=False) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["'echo", "hello'"]) return self.runStep() def test_run_usePTY_old_slave(self): self.setupStep( shell.ShellCommand(workdir='build', command="echo hello", usePTY=True), slave_version=dict(shell='1.1')) self.expectCommands( ExpectShell(workdir='build', command='echo hello') + 0 ) self.expectOutcome(result=SUCCESS, status_text=["'echo", "hello'"]) return self.runStep() def test_run_decodeRC(self, rc=1, results=WARNINGS, extra_text = ["warnings"]): self.setupStep( shell.ShellCommand(workdir='build', command="echo hello", decodeRC={1:WARNINGS})) self.expectCommands( ExpectShell(workdir='build', command='echo hello', usePTY="slave-config") + rc ) self.expectOutcome(result=results, status_text=["'echo", "hello'"]+extra_text) return self.runStep() def test_run_decodeRC_defaults(self): return self.test_run_decodeRC(2, FAILURE,extra_text=["failed"]) def test_run_decodeRC_defaults_0_is_failure(self): return self.test_run_decodeRC(0, FAILURE,extra_text=["failed"]) class TreeSize(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_run_success(self): self.setupStep(shell.TreeSize()) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['du', '-s', '-k', '.']) + ExpectShell.log('stdio', stdout='9292 .\n') + 0 ) self.expectOutcome(result=SUCCESS, status_text=["treesize", "9292 KiB"]) self.expectProperty('tree-size-KiB', 9292) return self.runStep() def test_run_misparsed(self): self.setupStep(shell.TreeSize()) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['du', '-s', '-k', '.']) + ExpectShell.log('stdio', stdio='abcdef\n') + 0 ) self.expectOutcome(result=WARNINGS, status_text=["treesize", "unknown"]) return self.runStep() def test_run_failed(self): self.setupStep(shell.TreeSize()) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['du', '-s', '-k', '.']) + ExpectShell.log('stdio', stderr='abcdef\n') + 1 ) self.expectOutcome(result=FAILURE, status_text=["treesize", "unknown"]) return self.runStep() class SetPropertyFromCommand(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_constructor_conflict(self): self.assertRaises(config.ConfigErrors, lambda : shell.SetPropertyFromCommand(property='foo', extract_fn=lambda : None)) def test_run_property(self): self.setupStep(shell.SetPropertyFromCommand(property="res", command="cmd")) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command="cmd") + ExpectShell.log('stdio', stdout='\n\nabcdef\n') + 0 ) self.expectOutcome(result=SUCCESS, status_text=["property 'res' set"]) self.expectProperty("res", "abcdef") # note: stripped self.expectLogfile('property changes', r"res: 'abcdef'") return self.runStep() def test_run_property_no_strip(self): self.setupStep(shell.SetPropertyFromCommand(property="res", command="cmd", strip=False)) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command="cmd") + ExpectShell.log('stdio', stdout='\n\nabcdef\n') + 0 ) self.expectOutcome(result=SUCCESS, status_text=["property 'res' set"]) self.expectProperty("res", "\n\nabcdef\n") self.expectLogfile('property changes', r"res: '\n\nabcdef\n'") return self.runStep() def test_run_failure(self): self.setupStep(shell.SetPropertyFromCommand(property="res", command="blarg")) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command="blarg") + ExpectShell.log('stdio', stderr='cannot blarg: File not found') + 1 ) self.expectOutcome(result=FAILURE, status_text=["'blarg'", "failed"]) self.expectNoProperty("res") return self.runStep() def test_run_extract_fn(self): def extract_fn(rc, stdout, stderr): self.assertEqual((rc, stdout, stderr), (0, 'startend', 'STARTEND')) return dict(a=1, b=2) self.setupStep(shell.SetPropertyFromCommand(extract_fn=extract_fn, command="cmd")) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command="cmd") + ExpectShell.log('stdio', stdout='start', stderr='START') + ExpectShell.log('stdio', stdout='end') + ExpectShell.log('stdio', stderr='END') + 0 ) self.expectOutcome(result=SUCCESS, status_text=["2 properties set"]) self.expectLogfile('property changes', 'a: 1\nb: 2') self.expectProperty("a", 1) self.expectProperty("b", 2) return self.runStep() def test_run_extract_fn_cmdfail(self): def extract_fn(rc, stdout, stderr): self.assertEqual((rc, stdout, stderr), (3, '', '')) return dict(a=1, b=2) self.setupStep(shell.SetPropertyFromCommand(extract_fn=extract_fn, command="cmd")) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command="cmd") + 3 ) # note that extract_fn *is* called anyway self.expectOutcome(result=FAILURE, status_text=["2 properties set"]) self.expectLogfile('property changes', 'a: 1\nb: 2') return self.runStep() def test_run_extract_fn_cmdfail_empty(self): def extract_fn(rc, stdout, stderr): self.assertEqual((rc, stdout, stderr), (3, '', '')) return dict() self.setupStep(shell.SetPropertyFromCommand(extract_fn=extract_fn, command="cmd")) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command="cmd") + 3 ) # note that extract_fn *is* called anyway, but returns no properties self.expectOutcome(result=FAILURE, status_text=["'cmd'", "failed"]) return self.runStep() @compat.usesFlushLoggedErrors def test_run_extract_fn_exception(self): def extract_fn(rc, stdout, stderr): raise RuntimeError("oh noes") self.setupStep(shell.SetPropertyFromCommand(extract_fn=extract_fn, command="cmd")) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command="cmd") + 0 ) # note that extract_fn *is* called anyway, but returns no properties self.expectOutcome(result=EXCEPTION, status_text=["setproperty", "exception"]) d = self.runStep() d.addCallback(lambda _ : self.assertEqual(len(self.flushLoggedErrors(RuntimeError)), 1)) return d class SetPropertyDeprecation(unittest.TestCase): """ Tests for L{shell.SetProperty} """ def test_deprecated(self): """ Accessing L{shell.SetProperty} reports a deprecation error. """ shell.SetProperty warnings = self.flushWarnings([self.test_deprecated]) self.assertEqual(len(warnings), 1) self.assertIdentical(warnings[0]['category'], DeprecationWarning) self.assertEqual(warnings[0]['message'], "buildbot.steps.shell.SetProperty was deprecated in Buildbot 0.8.8: " "It has been renamed to SetPropertyFromCommand" ) class Configure(unittest.TestCase): def test_class_attrs(self): # nothing too exciting here, but at least make sure the class is present step = shell.Configure() self.assertEqual(step.command, ['./configure']) class WarningCountingShellCommand(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_no_warnings(self): self.setupStep(shell.WarningCountingShellCommand(workdir='w', command=['make'])) self.expectCommands( ExpectShell(workdir='w', usePTY='slave-config', command=["make"]) + ExpectShell.log('stdio', stdout='blarg success!') + 0 ) self.expectOutcome(result=SUCCESS, status_text=["'make'"]) self.expectProperty("warnings-count", 0) return self.runStep() def test_default_pattern(self): self.setupStep(shell.WarningCountingShellCommand(command=['make'])) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=["make"]) + ExpectShell.log('stdio', stdout='normal: foo\nwarning: blarg!\nalso normal') + 0 ) self.expectOutcome(result=WARNINGS, status_text=["'make'", "warnings"]) self.expectProperty("warnings-count", 1) self.expectLogfile("warnings (1)", "warning: blarg!\n") return self.runStep() def test_custom_pattern(self): self.setupStep(shell.WarningCountingShellCommand(command=['make'], warningPattern=r"scary:.*")) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=["make"]) + ExpectShell.log('stdio', stdout='scary: foo\nwarning: bar\nscary: bar') + 0 ) self.expectOutcome(result=WARNINGS, status_text=["'make'", "warnings"]) self.expectProperty("warnings-count", 2) self.expectLogfile("warnings (2)", "scary: foo\nscary: bar\n") return self.runStep() def test_maxWarnCount(self): self.setupStep(shell.WarningCountingShellCommand(command=['make'], maxWarnCount=9)) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=["make"]) + ExpectShell.log('stdio', stdout='warning: noo!\n' * 10) + 0 ) self.expectOutcome(result=FAILURE, status_text=["'make'", "failed"]) self.expectProperty("warnings-count", 10) return self.runStep() def test_fail_with_warnings(self): self.setupStep(shell.WarningCountingShellCommand(command=['make'])) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=["make"]) + ExpectShell.log('stdio', stdout='warning: I might fail') + 3 ) self.expectOutcome(result=FAILURE, status_text=["'make'", "failed"]) self.expectProperty("warnings-count", 1) self.expectLogfile("warnings (1)", "warning: I might fail\n") return self.runStep() def do_test_suppressions(self, step, supps_file='', stdout='', exp_warning_count=0, exp_warning_log='', exp_exception=False): self.setupStep(step) # Invoke the expected callbacks for the suppression file upload. Note # that this assumes all of the remote_* are synchronous, but can be # easily adapted to suit if that changes (using inlineCallbacks) def upload_behavior(command): writer = command.args['writer'] writer.remote_write(supps_file) writer.remote_close() self.expectCommands( # step will first get the remote suppressions file Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='supps', workdir='wkdir', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(upload_behavior), # and then run the command ExpectShell(workdir='wkdir', usePTY='slave-config', command=["make"]) + ExpectShell.log('stdio', stdout=stdout) + 0 ) if exp_exception: self.expectOutcome(result=EXCEPTION, status_text=["shell", "exception"]) else: if exp_warning_count != 0: self.expectOutcome(result=WARNINGS, status_text=["'make'", "warnings"]) self.expectLogfile("warnings (%d)" % exp_warning_count, exp_warning_log) else: self.expectOutcome(result=SUCCESS, status_text=["'make'"]) self.expectProperty("warnings-count", exp_warning_count) return self.runStep() def test_suppressions(self): step = shell.WarningCountingShellCommand(command=['make'], suppressionFile='supps') supps_file = textwrap.dedent("""\ # example suppressions file amar.c : .*unused variable.* holding.c : .*invalid access to non-static.* """).strip() stdout = textwrap.dedent("""\ /bin/sh ../libtool --tag=CC --silent --mode=link gcc blah /bin/sh ../libtool --tag=CC --silent --mode=link gcc blah amar.c: In function 'write_record': amar.c:164: warning: unused variable 'x' amar.c:164: warning: this should show up /bin/sh ../libtool --tag=CC --silent --mode=link gcc blah /bin/sh ../libtool --tag=CC --silent --mode=link gcc blah holding.c: In function 'holding_thing': holding.c:984: warning: invalid access to non-static 'y' """) exp_warning_log = textwrap.dedent("""\ amar.c:164: warning: this should show up """) return self.do_test_suppressions(step, supps_file, stdout, 1, exp_warning_log) def test_suppressions_directories(self): def warningExtractor(step, line, match): return line.split(':', 2) step = shell.WarningCountingShellCommand(command=['make'], suppressionFile='supps', warningExtractor=warningExtractor) supps_file = textwrap.dedent("""\ # these should be suppressed: amar-src/amar.c : XXX .*/server-src/.* : AAA # these should not, as the dirs do not match: amar.c : YYY server-src.* : BBB """).strip() # note that this uses the unicode smart-quotes that gcc loves so much stdout = textwrap.dedent(u"""\ make: Entering directory \u2019amar-src\u2019 amar.c:164: warning: XXX amar.c:165: warning: YYY make: Leaving directory 'amar-src' make: Entering directory "subdir" make: Entering directory 'server-src' make: Entering directory `one-more-dir` holding.c:999: warning: BBB holding.c:1000: warning: AAA """) exp_warning_log = textwrap.dedent("""\ amar.c:165: warning: YYY holding.c:999: warning: BBB """) return self.do_test_suppressions(step, supps_file, stdout, 2, exp_warning_log) def test_suppressions_directories_custom(self): def warningExtractor(step, line, match): return line.split(':', 2) step = shell.WarningCountingShellCommand(command=['make'], suppressionFile='supps', warningExtractor=warningExtractor, directoryEnterPattern="^IN: (.*)", directoryLeavePattern="^OUT:") supps_file = "dir1/dir2/abc.c : .*" stdout = textwrap.dedent(u"""\ IN: dir1 IN: decoy OUT: decoy IN: dir2 abc.c:123: warning: hello """) return self.do_test_suppressions(step, supps_file, stdout, 0, '') def test_suppressions_linenos(self): def warningExtractor(step, line, match): return line.split(':', 2) step = shell.WarningCountingShellCommand(command=['make'], suppressionFile='supps', warningExtractor=warningExtractor) supps_file = "abc.c:.*:100-199\ndef.c:.*:22" stdout = textwrap.dedent(u"""\ abc.c:99: warning: seen 1 abc.c:150: warning: unseen def.c:22: warning: unseen abc.c:200: warning: seen 2 """) exp_warning_log = textwrap.dedent(u"""\ abc.c:99: warning: seen 1 abc.c:200: warning: seen 2 """) return self.do_test_suppressions(step, supps_file, stdout, 2, exp_warning_log) @compat.usesFlushLoggedErrors def test_suppressions_warningExtractor_exc(self): def warningExtractor(step, line, match): raise RuntimeError("oh noes") step = shell.WarningCountingShellCommand(command=['make'], suppressionFile='supps', warningExtractor=warningExtractor) supps_file = 'x:y' # need at least one supp to trigger warningExtractor stdout = "abc.c:99: warning: seen 1" d = self.do_test_suppressions(step, supps_file, stdout, exp_exception=True) d.addCallback(lambda _ : self.assertEqual(len(self.flushLoggedErrors(RuntimeError)), 1)) return d def test_suppressions_addSuppression(self): # call addSuppression "manually" from a subclass class MyWCSC(shell.WarningCountingShellCommand): def start(self): self.addSuppression([('.*', '.*unseen.*', None, None)]) return shell.WarningCountingShellCommand.start(self) def warningExtractor(step, line, match): return line.split(':', 2) step = MyWCSC(command=['make'], suppressionFile='supps', warningExtractor=warningExtractor) stdout = textwrap.dedent(u"""\ abc.c:99: warning: seen 1 abc.c:150: warning: unseen abc.c:200: warning: seen 2 """) exp_warning_log = textwrap.dedent(u"""\ abc.c:99: warning: seen 1 abc.c:200: warning: seen 2 """) return self.do_test_suppressions(step, '', stdout, 2, exp_warning_log) def test_warnExtractFromRegexpGroups(self): step = shell.WarningCountingShellCommand(command=['make']) we = shell.WarningCountingShellCommand.warnExtractFromRegexpGroups line, pat, exp_file, exp_lineNo, exp_text = \ ('foo:123:text', '(.*):(.*):(.*)', 'foo', 123, 'text') self.assertEqual(we(step, line, re.match(pat, line)), (exp_file, exp_lineNo, exp_text)) class Compile(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_class_args(self): # since this step is just a pre-configured WarningCountingShellCommand, # there' not much to test! step = self.setupStep(shell.Compile()) self.assertEqual(step.name, "compile") self.assertTrue(step.haltOnFailure) self.assertTrue(step.flunkOnFailure) self.assertEqual(step.description, ["compiling"]) self.assertEqual(step.descriptionDone, ["compile"]) self.assertEqual(step.command, ["make", "all"]) class Test(steps.BuildStepMixin, unittest.TestCase): def setUp(self): self.setUpBuildStep() def tearDown(self): self.tearDownBuildStep() def test_setTestResults(self): step = self.setupStep(shell.Test()) step.setTestResults(total=10, failed=3, passed=5, warnings=3) self.assertEqual(self.step_statistics, { 'tests-total' : 10, 'tests-failed' : 3, 'tests-passed' : 5, 'tests-warnings' : 3, }) # ensure that they're additive step.setTestResults(total=1, failed=2, passed=3, warnings=4) self.assertEqual(self.step_statistics, { 'tests-total' : 11, 'tests-failed' : 5, 'tests-passed' : 8, 'tests-warnings' : 7, }) def test_describe_not_done(self): step = self.setupStep(shell.Test()) self.assertEqual(step.describe(), ['testing']) def test_describe_done(self): step = self.setupStep(shell.Test()) self.step_statistics['tests-total'] = 93 self.step_statistics['tests-failed'] = 10 self.step_statistics['tests-passed'] = 20 self.step_statistics['tests-warnings'] = 30 self.assertEqual(step.describe(done=True), [ 'test', '93 tests', '20 passed', '30 warnings', '10 failed']) def test_describe_done_no_total(self): step = self.setupStep(shell.Test()) self.step_statistics['tests-total'] = 0 self.step_statistics['tests-failed'] = 10 self.step_statistics['tests-passed'] = 20 self.step_statistics['tests-warnings'] = 30 # describe calculates 60 = 10+20+30 self.assertEqual(step.describe(done=True), [ 'test', '60 tests', '20 passed', '30 warnings', '10 failed']) buildbot-0.8.8/buildbot/test/unit/test_steps_slave.py000066400000000000000000000315531222546025000230430ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import stat from twisted.trial import unittest from twisted.internet import defer from buildbot.steps import slave from buildbot.status.results import SUCCESS, FAILURE, EXCEPTION from buildbot.process import properties, buildstep from buildbot.test.fake.remotecommand import Expect from buildbot.test.util import steps, compat from buildbot.interfaces import BuildSlaveTooOldError class TestSetPropertiesFromEnv(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_simple(self): self.setupStep(slave.SetPropertiesFromEnv( variables=["one", "two", "three", "five", "six"], source="me")) self.buildslave.slave_environ = { "one": "1", "two": None, "six": "6", "FIVE" : "555" } self.properties.setProperty("four", 4, "them") self.properties.setProperty("five", 5, "them") self.properties.setProperty("six", 99, "them") self.expectOutcome(result=SUCCESS, status_text=["Set"]) self.expectProperty('one', "1", source='me') self.expectNoProperty('two') self.expectNoProperty('three') self.expectProperty('four', 4, source='them') self.expectProperty('five', 5, source='them') self.expectProperty('six', '6', source='me') self.expectLogfile("properties", "one = '1'\nsix = '6'") return self.runStep() def test_case_folding(self): self.setupStep(slave.SetPropertiesFromEnv( variables=["eNv"], source="me")) self.buildslave.slave_environ = { "ENV": 'EE' } self.buildslave.slave_system = 'win32' self.expectOutcome(result=SUCCESS, status_text=["Set"]) self.expectProperty('eNv', 'EE', source='me') self.expectLogfile("properties", "eNv = 'EE'") return self.runStep() class TestFileExists(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_found(self): self.setupStep(slave.FileExists(file="x")) self.expectCommands( Expect('stat', { 'file' : 'x' }) + Expect.update('stat', [stat.S_IFREG, 99, 99]) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["File found."]) return self.runStep() def test_not_found(self): self.setupStep(slave.FileExists(file="x")) self.expectCommands( Expect('stat', { 'file' : 'x' }) + Expect.update('stat', [0, 99, 99]) + 0 ) self.expectOutcome(result=FAILURE, status_text=["Not a file."]) return self.runStep() def test_failure(self): self.setupStep(slave.FileExists(file="x")) self.expectCommands( Expect('stat', { 'file' : 'x' }) + 1 ) self.expectOutcome(result=FAILURE, status_text=["File not found."]) return self.runStep() def test_render(self): self.setupStep(slave.FileExists(file=properties.Property("x"))) self.properties.setProperty('x', 'XXX', 'here') self.expectCommands( Expect('stat', { 'file' : 'XXX' }) + 1 ) self.expectOutcome(result=FAILURE, status_text=["File not found."]) return self.runStep() @compat.usesFlushLoggedErrors def test_old_version(self): self.setupStep(slave.FileExists(file="x"), slave_version=dict()) self.expectOutcome(result=EXCEPTION, status_text=["FileExists", "exception"]) d = self.runStep() def check(_): self.assertEqual( len(self.flushLoggedErrors(BuildSlaveTooOldError)), 1) d.addCallback(check) return d class TestCopyDirectory(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_success(self): self.setupStep(slave.CopyDirectory(src="s", dest="d")) self.expectCommands( Expect('cpdir', { 'fromdir' : 's', 'todir' : 'd' }) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["Copied", "s", "to", "d"]) return self.runStep() def test_timeout(self): self.setupStep(slave.CopyDirectory(src="s", dest="d", timeout=300)) self.expectCommands( Expect('cpdir', { 'fromdir' : 's', 'todir' : 'd', 'timeout': 300 }) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["Copied", "s", "to", "d"]) return self.runStep() def test_maxTime(self): self.setupStep(slave.CopyDirectory(src="s", dest="d", maxTime=10)) self.expectCommands( Expect('cpdir', { 'fromdir' : 's', 'todir' : 'd', 'maxTime': 10 }) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["Copied", "s", "to", "d"]) return self.runStep() def test_failure(self): self.setupStep(slave.CopyDirectory(src="s", dest="d")) self.expectCommands( Expect('cpdir', { 'fromdir' : 's', 'todir' : 'd' }) + 1 ) self.expectOutcome(result=FAILURE, status_text=["Copying", "s", "to", "d", "failed."]) return self.runStep() def test_render(self): self.setupStep(slave.CopyDirectory(src=properties.Property("x"), dest=properties.Property("y"))) self.properties.setProperty('x', 'XXX', 'here') self.properties.setProperty('y', 'YYY', 'here') self.expectCommands( Expect('cpdir', { 'fromdir' : 'XXX', 'todir' : 'YYY' }) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["Copied", "XXX", "to", "YYY"]) return self.runStep() class TestRemoveDirectory(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_success(self): self.setupStep(slave.RemoveDirectory(dir="d")) self.expectCommands( Expect('rmdir', { 'dir' : 'd' }) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["Deleted"]) return self.runStep() def test_failure(self): self.setupStep(slave.RemoveDirectory(dir="d")) self.expectCommands( Expect('rmdir', { 'dir' : 'd' }) + 1 ) self.expectOutcome(result=FAILURE, status_text=["Delete failed."]) return self.runStep() def test_render(self): self.setupStep(slave.RemoveDirectory(dir=properties.Property("x"))) self.properties.setProperty('x', 'XXX', 'here') self.expectCommands( Expect('rmdir', { 'dir' : 'XXX' }) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["Deleted"]) return self.runStep() class TestMakeDirectory(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_success(self): self.setupStep(slave.MakeDirectory(dir="d")) self.expectCommands( Expect('mkdir', { 'dir' : 'd' }) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["Created"]) return self.runStep() def test_failure(self): self.setupStep(slave.MakeDirectory(dir="d")) self.expectCommands( Expect('mkdir', { 'dir' : 'd' }) + 1 ) self.expectOutcome(result=FAILURE, status_text=["Create failed."]) return self.runStep() def test_render(self): self.setupStep(slave.MakeDirectory(dir=properties.Property("x"))) self.properties.setProperty('x', 'XXX', 'here') self.expectCommands( Expect('mkdir', { 'dir' : 'XXX' }) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["Created"]) return self.runStep() class CompositeUser(buildstep.LoggingBuildStep, slave.CompositeStepMixin): def __init__(self, payload): self.payload = payload self.logEnviron=False buildstep.LoggingBuildStep.__init__(self) def start(self): self.addLogForRemoteCommands('stdio') d = self.payload(self) d.addCallback(self.commandComplete) d.addErrback(self.failed) def commandComplete(self,res): self.finished(FAILURE if res else SUCCESS) class TestCompositeStepMixin(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_runRemoteCommand(self): cmd_args = ('foo', {'bar': False}) def testFunc(x): x.runRemoteCommand(*cmd_args) self.setupStep(CompositeUser(testFunc)) self.expectCommands(Expect(*cmd_args)+0) self.expectOutcome(result=SUCCESS, status_text=["generic"]) def test_runRemoteCommandFail(self): cmd_args = ('foo', {'bar': False}) @defer.inlineCallbacks def testFunc(x): yield x.runRemoteCommand(*cmd_args) self.setupStep(CompositeUser(testFunc)) self.expectCommands(Expect(*cmd_args)+1) self.expectOutcome(result=FAILURE, status_text=["generic"]) return self.runStep() def test_runRemoteCommandFailNoAbandon(self): cmd_args = ('foo', {'bar': False}) @defer.inlineCallbacks def testFunc(x): res = yield x.runRemoteCommand(*cmd_args, **dict(abandonOnFailure=False)) x.step_status.setText([str(res)]) self.setupStep(CompositeUser(testFunc)) self.expectCommands(Expect(*cmd_args)+1) self.expectOutcome(result=SUCCESS, status_text=["True"]) return self.runStep() def test_mkdir(self): self.setupStep(CompositeUser(lambda x:x.runMkdir("d"))) self.expectCommands( Expect('mkdir', { 'dir' : 'd' , 'logEnviron': False}) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["generic"]) return self.runStep() def test_rmdir(self): self.setupStep(CompositeUser(lambda x:x.runRmdir("d"))) self.expectCommands( Expect('rmdir', { 'dir' : 'd' , 'logEnviron': False}) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["generic"]) return self.runStep() def test_mkdir_fail(self): self.setupStep(CompositeUser(lambda x:x.runMkdir("d"))) self.expectCommands( Expect('mkdir', { 'dir' : 'd' , 'logEnviron': False}) + 1 ) self.expectOutcome(result=FAILURE, status_text=["generic"]) return self.runStep() def test_abandonOnFailure(self): @defer.inlineCallbacks def testFunc(x): yield x.runMkdir("d") yield x.runMkdir("d") self.setupStep(CompositeUser(testFunc)) self.expectCommands( Expect('mkdir', { 'dir' : 'd' , 'logEnviron': False}) + 1 ) self.expectOutcome(result=FAILURE, status_text=["generic"]) return self.runStep() def test_notAbandonOnFailure(self): @defer.inlineCallbacks def testFunc(x): yield x.runMkdir("d", abandonOnFailure=False) yield x.runMkdir("d", abandonOnFailure=False) self.setupStep(CompositeUser(testFunc)) self.expectCommands( Expect('mkdir', { 'dir' : 'd' , 'logEnviron': False}) + 1, Expect('mkdir', { 'dir' : 'd' , 'logEnviron': False}) + 1 ) self.expectOutcome(result=SUCCESS, status_text=["generic"]) return self.runStep() buildbot-0.8.8/buildbot/test/unit/test_steps_source_base_Source.py000066400000000000000000000115111222546025000255330ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from buildbot.steps.source import Source from buildbot.test.util import steps, sourcesteps class TestSource(sourcesteps.SourceStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_start_alwaysUseLatest_True(self): step = self.setupStep(Source(alwaysUseLatest=True), { 'branch': 'other-branch', 'revision': 'revision', }, patch = 'patch' ) step.branch = 'branch' step.startVC = mock.Mock() step.startStep(mock.Mock()) self.assertEqual(step.startVC.call_args, (('branch', None, None), {})) def test_start_alwaysUseLatest_False(self): step = self.setupStep(Source(), { 'branch': 'other-branch', 'revision': 'revision', }, patch = 'patch' ) step.branch = 'branch' step.startVC = mock.Mock() step.startStep(mock.Mock()) self.assertEqual(step.startVC.call_args, (('other-branch', 'revision', 'patch'), {})) def test_start_alwaysUseLatest_False_no_branch(self): step = self.setupStep(Source()) step.branch = 'branch' step.startVC = mock.Mock() step.startStep(mock.Mock()) self.assertEqual(step.startVC.call_args, (('branch', None, None), {})) def test_start_no_codebase(self): step = self.setupStep(Source()) step.branch = 'branch' step.startVC = mock.Mock() step.build.getSourceStamp = mock.Mock() step.build.getSourceStamp.return_value = None self.assertEqual(step.describe(), ['updating']) self.assertEqual(step.name, Source.name) step.startStep(mock.Mock()) self.assertEqual(step.build.getSourceStamp.call_args[0], ('',)) self.assertEqual(step.description, ['updating']) def test_start_with_codebase(self): step = self.setupStep(Source(codebase='codebase')) step.branch = 'branch' step.startVC = mock.Mock() step.build.getSourceStamp = mock.Mock() step.build.getSourceStamp.return_value = None self.assertEqual(step.describe(), ['updating', 'codebase']) self.assertEqual(step.name, Source.name + " codebase") step.startStep(mock.Mock()) self.assertEqual(step.build.getSourceStamp.call_args[0], ('codebase',)) self.assertEqual(step.describe(True), ['update', 'codebase']) def test_start_with_codebase_and_descriptionSuffix(self): step = self.setupStep(Source(codebase='my-code', descriptionSuffix='suffix')) step.branch = 'branch' step.startVC = mock.Mock() step.build.getSourceStamp = mock.Mock() step.build.getSourceStamp.return_value = None self.assertEqual(step.describe(), ['updating', 'suffix']) self.assertEqual(step.name, Source.name + " my-code") step.startStep(mock.Mock()) self.assertEqual(step.build.getSourceStamp.call_args[0], ('my-code',)) self.assertEqual(step.describe(True), ['update', 'suffix']) class TestSourceDescription(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_constructor_args_strings(self): step = Source(workdir='build', description='svn update (running)', descriptionDone='svn update') self.assertEqual(step.description, ['svn update (running)']) self.assertEqual(step.descriptionDone, ['svn update']) def test_constructor_args_lists(self): step = Source(workdir='build', description=['svn', 'update', '(running)'], descriptionDone=['svn', 'update']) self.assertEqual(step.description, ['svn', 'update', '(running)']) self.assertEqual(step.descriptionDone, ['svn', 'update']) buildbot-0.8.8/buildbot/test/unit/test_steps_source_bzr.py000066400000000000000000000456771222546025000241220ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.python.reflect import namedModule from buildbot.steps.source import bzr from buildbot.status.results import SUCCESS, FAILURE from buildbot.test.util import sourcesteps from buildbot.test.fake.remotecommand import ExpectShell, Expect import os.path class TestBzr(sourcesteps.SourceStepMixin, unittest.TestCase): def setUp(self): return self.setUpSourceStep() def tearDown(self): return self.tearDownSourceStep() def test_mode_full(self): self.setupStep( bzr.Bzr(repourl='http://bzr.squid-cache.org/bzr/squid3/trunk', mode='full', method='fresh')) self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('stat', dict(file='wkdir/.bzr', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'clean-tree', '--force']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'update']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='100') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'Bzr') return self.runStep() def test_mode_full_win32path(self): self.setupStep( bzr.Bzr(repourl='http://bzr.squid-cache.org/bzr/squid3/trunk', mode='full', method='fresh')) self.build.path_module = namedModule('ntpath') self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('stat', dict(file=r'wkdir\.bzr', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'clean-tree', '--force']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'update']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='100') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_full_timeout(self): self.setupStep( bzr.Bzr(repourl='http://bzr.squid-cache.org/bzr/squid3/trunk', mode='full', method='fresh', timeout=1)) self.expectCommands( ExpectShell(workdir='wkdir', timeout=1, command=['bzr', '--version']) + 0, Expect('stat', dict(file='wkdir/.bzr', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', timeout=1, command=['bzr', 'clean-tree', '--force']) + 0, ExpectShell(workdir='wkdir', timeout=1, command=['bzr', 'update']) + 0, ExpectShell(workdir='wkdir', timeout=1, command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='100') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'Bzr') return self.runStep() def test_mode_full_revision(self): self.setupStep( bzr.Bzr(repourl='http://bzr.squid-cache.org/bzr/squid3/trunk', mode='full', method='fresh'), args=dict(revision='3730')) self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('stat', dict(file='wkdir/.bzr', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'clean-tree', '--force']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'update', '-r', '3730']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='100') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'Bzr') return self.runStep() def test_mode_full_clean(self): self.setupStep( bzr.Bzr(repourl='http://bzr.squid-cache.org/bzr/squid3/trunk', mode='full', method='clean')) self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('stat', dict(file='wkdir/.bzr', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'clean-tree', '--ignored', '--force']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'update']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='100') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'Bzr') return self.runStep() def test_mode_full_clean_revision(self): self.setupStep( bzr.Bzr(repourl='http://bzr.squid-cache.org/bzr/squid3/trunk', mode='full', method='clean'), args=dict(revision='2345')) self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('stat', dict(file='wkdir/.bzr', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'clean-tree', '--ignored', '--force']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'update', '-r', '2345']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='100') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'Bzr') return self.runStep() def test_mode_full_fresh(self): self.setupStep( bzr.Bzr(repourl='http://bzr.squid-cache.org/bzr/squid3/trunk', mode='full', method='fresh')) self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('stat', dict(file='wkdir/.bzr', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'clean-tree', '--force']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'update']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='100') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'Bzr') return self.runStep() def test_mode_full_clobber(self): self.setupStep( bzr.Bzr(repourl='http://bzr.squid-cache.org/bzr/squid3/trunk', mode='full', method='clobber')) self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'checkout', 'http://bzr.squid-cache.org/bzr/squid3/trunk', '.']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='100') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'Bzr') return self.runStep() def test_mode_full_clobber_revision(self): self.setupStep( bzr.Bzr(repourl='http://bzr.squid-cache.org/bzr/squid3/trunk', mode='full', method='clobber'), args=dict(revision='3730')) self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'checkout', 'http://bzr.squid-cache.org/bzr/squid3/trunk', '.', '-r', '3730']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='100') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'Bzr') return self.runStep() def test_mode_full_clobber_baseurl(self): self.setupStep( bzr.Bzr(baseURL='http://bzr.squid-cache.org/bzr/squid3', defaultBranch='trunk', mode='full', method='clobber')) self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'checkout', os.path.join('http://bzr.squid-cache.org/bzr/squid3', 'trunk'), '.']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='100') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'Bzr') return self.runStep() def test_mode_full_clobber_baseurl_nodefault(self): self.setupStep( bzr.Bzr(baseURL='http://bzr.squid-cache.org/bzr/squid3', defaultBranch='trunk', mode='full', method='clobber'), args=dict(branch='branches/SQUID_3_0')) self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'checkout', os.path.join('http://bzr.squid-cache.org/bzr/squid3', 'branches/SQUID_3_0'), '.']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='100') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'Bzr') return self.runStep() def test_mode_full_copy(self): self.setupStep( bzr.Bzr(repourl='http://bzr.squid-cache.org/bzr/squid3/trunk', mode='full', method='copy')) self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('rmdir', dict(dir='build', logEnviron=True)) + 0, Expect('stat', dict(file='source/.bzr', logEnviron=True)) + 0, ExpectShell(workdir='source', command=['bzr', 'update']) + 0, Expect('cpdir', {'fromdir': 'source', 'logEnviron': True, 'todir': 'build'}) + 0, ExpectShell(workdir='source', command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='100') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'Bzr') return self.runStep() def test_mode_incremental(self): self.setupStep( bzr.Bzr(repourl='http://bzr.squid-cache.org/bzr/squid3/trunk', mode='incremental')) self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('stat', dict(file='wkdir/.bzr', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'update']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='100') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'Bzr') return self.runStep() def test_mode_incremental_revision(self): self.setupStep( bzr.Bzr(repourl='http://bzr.squid-cache.org/bzr/squid3/trunk', mode='incremental'), args=dict(revision='9384')) self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('stat', dict(file='wkdir/.bzr', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'update', '-r', '9384']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='100') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'Bzr') return self.runStep() def test_mode_incremental_no_existing_repo(self): self.setupStep( bzr.Bzr(repourl='http://bzr.squid-cache.org/bzr/squid3/trunk', mode='incremental')) self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('stat', dict(file='wkdir/.bzr', logEnviron=True)) + 1, ExpectShell(workdir='wkdir', command=['bzr', 'checkout', 'http://bzr.squid-cache.org/bzr/squid3/trunk', '.']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='100\n') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100\n', 'Bzr') return self.runStep() def test_bad_revparse(self): self.setupStep( bzr.Bzr(repourl='http://bzr.squid-cache.org/bzr/squid3/trunk', mode='incremental')) self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('stat', dict(file='wkdir/.bzr', logEnviron=True)) + 1, ExpectShell(workdir='wkdir', command=['bzr', 'checkout', 'http://bzr.squid-cache.org/bzr/squid3/trunk', '.']) + 0, ExpectShell(workdir='wkdir', command=['bzr', 'version-info', '--custom', "--template='{revno}"]) + ExpectShell.log('stdio', stdout='oiasdfj010laksjfd') + 0, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) return self.runStep() def test_bad_checkout(self): self.setupStep( bzr.Bzr(repourl='http://bzr.squid-cache.org/bzr/squid3/trunk', mode='incremental')) self.expectCommands( ExpectShell(workdir='wkdir', command=['bzr', '--version']) + 0, Expect('stat', dict(file='wkdir/.bzr', logEnviron=True)) + 1, ExpectShell(workdir='wkdir', command=['bzr', 'checkout', 'http://bzr.squid-cache.org/bzr/squid3/trunk', '.']) + ExpectShell.log('stdio', stderr='failed\n') + 128, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) return self.runStep() buildbot-0.8.8/buildbot/test/unit/test_steps_source_cvs.py000066400000000000000000001043201222546025000240750ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import time from twisted.trial import unittest from buildbot.steps import shell from buildbot.steps.source import cvs from buildbot.status.results import SUCCESS, FAILURE from buildbot.test.util import sourcesteps from buildbot.test.fake.remotecommand import ExpectShell, Expect, ExpectRemoteRef def uploadString(cvsroot): def behavior(command): writer = command.args['writer'] writer.remote_write(cvsroot + "\n") writer.remote_close() return behavior class TestCVS(sourcesteps.SourceStepMixin, unittest.TestCase): def setUp(self): return self.setUpSourceStep() def tearDown(self): return self.tearDownSourceStep() def setupStep(self, step, *args, **kwargs): sourcesteps.SourceStepMixin.setupStep(self, step, *args, **kwargs) # make parseGotRevision return something consistent, patching the class # instead of the object since a new object is constructed by runTest. def parseGotRevision(self, res): self.updateSourceProperty('got_revision', '2012-09-09 12:00:39 +0000') return res self.patch(cvs.CVS, 'parseGotRevision', parseGotRevision) def test_parseGotRevision(self): def gmtime(): return time.struct_time((2012, 9, 9, 12, 9, 33, 6, 253, 0)) self.patch(time, 'gmtime', gmtime) step = cvs.CVS(cvsroot="x", cvsmodule="m", mode='full', method='clean') props = [] def updateSourceProperty(prop, name): props.append((prop, name)) step.updateSourceProperty = updateSourceProperty self.assertEqual(step.parseGotRevision(10), 10) # passes res along self.assertEqual(props, [('got_revision', '2012-09-09 12:09:33 +0000')]) def test_mode_full_clean(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='full', method='clean', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString(':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('mozilla/browser/')) + 0, ExpectShell(workdir='wkdir', command=['cvsdiscard']) + 0, ExpectShell(workdir='wkdir', command=['cvs', '-z3', 'update', '-dP']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_full_clean_timeout(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='full', method='clean', login=True, timeout=1)) self.expectCommands( ExpectShell(workdir='wkdir', timeout=1, command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString(':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('mozilla/browser/')) + 0, ExpectShell(workdir='wkdir', timeout=1, command=['cvsdiscard']) + 0, ExpectShell(workdir='wkdir', timeout=1, command=['cvs', '-z3', 'update', '-dP']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_full_clean_branch(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='full', method='clean', branch='branch', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString(':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('mozilla/browser/')) + 0, ExpectShell(workdir='wkdir', command=['cvsdiscard']) + 0, ExpectShell(workdir='wkdir', command=['cvs', '-z3', 'update', '-dP', '-r', 'branch']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_full_clean_branch_sourcestamp(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='full', method='clean', login=True), args={'branch':'my_branch'}) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString(':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('mozilla/browser/')) + 0, ExpectShell(workdir='wkdir', command=['cvsdiscard']) + 0, ExpectShell(workdir='wkdir', command=['cvs', '-z3', 'update', '-dP', '-r', 'my_branch']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_full_fresh(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='full', method='fresh', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString(':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('mozilla/browser/')) + 0, ExpectShell(workdir='wkdir', command=['cvsdiscard', '--ignore']) + 0, ExpectShell(workdir='wkdir', command=['cvs', '-z3', 'update', '-dP']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_full_clobber(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='full', method='clobber', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, ExpectShell(workdir='', command=['cvs', '-d', ':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot', '-z3', 'checkout', '-d', 'wkdir', 'mozilla/browser/']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_full_copy(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='full', method='copy', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='source/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString(':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='source/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('mozilla/browser/')) + 0, ExpectShell(workdir='source', command=['cvs', '-z3', 'update', '-dP']) + 0, Expect('cpdir', {'fromdir': 'source', 'todir': 'build', 'logEnviron': True}) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_full_copy_wrong_repo(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='full', method='copy', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='source/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('the-end-of-the-universe')) + 0, Expect('rmdir', dict(dir='source', logEnviron=True)) + 0, ExpectShell(workdir='', command=['cvs', '-d', ':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot', '-z3', 'checkout', '-d', 'source', 'mozilla/browser/']) + 0, Expect('cpdir', {'fromdir': 'source', 'todir': 'build', 'logEnviron': True}) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_incremental(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='incremental', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString(':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('mozilla/browser/')) + 0, ExpectShell(workdir='wkdir', command=['cvs', '-z3', 'update', '-dP']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_incremental_password_windows(self): self.setupStep( cvs.CVS(cvsroot=":pserver:dustin:secrets@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='incremental', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) # on Windows, this file does not contain the password, per # http://trac.buildbot.net/ticket/2355 + Expect.behavior(uploadString(':pserver:dustin@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('mozilla/browser/')) + 0, ExpectShell(workdir='wkdir', command=['cvs', '-z3', 'update', '-dP']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_incremental_branch(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='incremental', branch='my_branch', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString(':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('mozilla/browser/')) + 0, ExpectShell(workdir='wkdir', command=['cvs', '-z3', 'update', '-dP', '-r', 'my_branch']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_incremental_special_case(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='incremental', branch='HEAD', login=True), args=dict(revision='2012-08-16 16:05:16 +0000')) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString(':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('mozilla/browser/')) + 0, ExpectShell(workdir='wkdir', command=['cvs', '-z3', 'update', '-dP', # note, no -r HEAD here - that's the special case '-D', '2012-08-16 16:05:16 +0000']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_incremental_branch_sourcestamp(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='incremental', login=True), args={'branch':'my_branch'}) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString(':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('mozilla/browser/')) + 0, ExpectShell(workdir='wkdir', command=['cvs', '-z3', 'update', '-dP', '-r', 'my_branch']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_incremental_not_loggedin(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='incremental', login=False)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, ExpectShell(workdir='wkdir', command=['cvs', '-d', ':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot', 'login']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString(':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('mozilla/browser/')) + 0, ExpectShell(workdir='wkdir', command=['cvs', '-z3', 'update', '-dP']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_incremental_no_existing_repo(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='incremental', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + 1, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, ExpectShell(workdir='', command=['cvs', '-d', ':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot', '-z3', 'checkout', '-d', 'wkdir', 'mozilla/browser/']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_incremental_wrong_repo(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='incremental', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('the-end-of-the-universe')) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, ExpectShell(workdir='', command=['cvs', '-d', ':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot', '-z3', 'checkout', '-d', 'wkdir', 'mozilla/browser/']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_incremental_wrong_module(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='incremental', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString(':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('the-end-of-the-universe')) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, ExpectShell(workdir='', command=['cvs', '-d', ':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot', '-z3', 'checkout', '-d', 'wkdir', 'mozilla/browser/']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_full_clean_no_existing_repo(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='full', method='clean', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + 1, ExpectShell(workdir='', command=['cvs', '-d', ':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot', '-z3', 'checkout', '-d', 'wkdir', 'mozilla/browser/']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_full_clean_wrong_repo(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='full', method='clean', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('the-end-of-the-universe')) + 0, ExpectShell(workdir='', command=['cvs', '-d', ':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot', '-z3', 'checkout', '-d', 'wkdir', 'mozilla/browser/']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_full_no_method(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='full', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString(':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('mozilla/browser/')) + 0, ExpectShell(workdir='wkdir', command=['cvsdiscard', '--ignore']) + 0, ExpectShell(workdir='wkdir', command=['cvs', '-z3', 'update', '-dP']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_incremental_with_options(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='incremental', login=True, global_options=['-q'], extra_options=['-l'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + 1, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, ExpectShell(workdir='', command=['cvs', '-q', '-d', ':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot', '-z3', 'checkout', '-d', 'wkdir', '-l', 'mozilla/browser/']) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_mode_incremental_with_env_logEnviron(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='incremental', login=True, env={'abc': '123'}, logEnviron=False)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version'], env={'abc': '123'}, logEnviron=False) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString(':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('mozilla/browser/')) + 0, ExpectShell(workdir='wkdir', command=['cvs', '-z3', 'update', '-dP'], env={'abc': '123'}, logEnviron=False) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '2012-09-09 12:00:39 +0000', 'CVS') return self.runStep() def test_command_fails(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='incremental', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 128, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) return self.runStep() def test_cvsdiscard_fails(self): self.setupStep( cvs.CVS(cvsroot=":pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot", cvsmodule="mozilla/browser/", mode='full', method='fresh', login=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['cvs', '--version']) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Root', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString(':pserver:anonymous@cvs-mirror.mozilla.org:/cvsroot')) + 0, Expect('uploadFile', dict(blocksize=32768, maxsize=None, slavesrc='Repository', workdir='wkdir/CVS', writer=ExpectRemoteRef(shell.StringFileWriter))) + Expect.behavior(uploadString('mozilla/browser/')) + 0, ExpectShell(workdir='wkdir', command=['cvsdiscard', '--ignore']) + ExpectShell.log('stdio', stderr='FAIL!\n') + 1, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) return self.runStep() buildbot-0.8.8/buildbot/test/unit/test_steps_source_git.py000066400000000000000000001657401222546025000241020ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.python.reflect import namedModule from buildbot.steps.source import git from buildbot.status.results import SUCCESS, FAILURE from buildbot.test.util import config, sourcesteps from buildbot.test.fake.remotecommand import ExpectShell, Expect class TestGit(sourcesteps.SourceStepMixin, config.ConfigErrorsMixin, unittest.TestCase): def setUp(self): return self.setUpSourceStep() def tearDown(self): return self.tearDownSourceStep() def test_mode_full_clean(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clean')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clean', '-f', '-d']) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_clean_win32path(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clean')) self.build.path_module = namedModule('ntpath') self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file=r'wkdir\.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clean', '-f', '-d']) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_clean_timeout(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', timeout=1, mode='full', method='clean')) self.expectCommands( ExpectShell(workdir='wkdir', timeout=1, command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', timeout=1, command=['git', 'clean', '-f', '-d']) + 0, ExpectShell(workdir='wkdir', timeout=1, command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='wkdir', timeout=1, command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', timeout=1, command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_clean_patch(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clean'), patch=(1, 'patch')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clean', '-f', '-d']) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'apply', '--index', '-p', '1'], initialStdin='patch') + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_clean_patch_fail(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clean'), patch=(1, 'patch')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clean', '-f', '-d']) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'apply', '--index', '-p', '1'], initialStdin='patch') + 1, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) self.expectNoProperty('got_revision') return self.runStep() def test_mode_full_clean_branch(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clean', branch='test-branch')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clean', '-f', '-d']) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'test-branch']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'branch', '-M', 'test-branch']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_clean_parsefail(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clean')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clean', '-f', '-d']) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + ExpectShell.log('stdio', stderr="fatal: Could not parse object " "'b08076bc71c7813038f2cefedff9c5b678d225a8'.\n") + 128, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) self.expectNoProperty('got_revision') return self.runStep() def test_mode_full_clean_no_existing_repo(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clean')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 1, ExpectShell(workdir='wkdir', command=['git', 'clone', 'http://github.com/buildbot/buildbot.git', '.']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_full_clean_no_existing_repo_branch(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clean', branch='test-branch')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 1, ExpectShell(workdir='wkdir', command=['git', 'clone', '--branch', 'test-branch', 'http://github.com/buildbot/buildbot.git', '.']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_full_clobber(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clobber', progress=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True, timeout=1200)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clone', 'http://github.com/buildbot/buildbot.git', '.', '--progress']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_clobber_branch(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clobber', progress=True, branch='test-branch')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True, timeout=1200)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clone', '--branch', 'test-branch', 'http://github.com/buildbot/buildbot.git', '.', '--progress']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_incremental(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='incremental')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_incremental_branch(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='incremental', branch='test-branch')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'test-branch']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'branch', '-M', 'test-branch']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_fresh(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='fresh')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clean', '-f', '-d', '-x']) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_incremental_given_revision(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='incremental'), dict( revision='abcdef01', )) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'cat-file', '-e', 'abcdef01']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'abcdef01', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_fresh_submodule(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='fresh', submodules=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clean', '-f', '-d', '-x']) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'submodule', 'update', '--recursive']) + 0, ExpectShell(workdir='wkdir', command=['git', 'submodule', 'foreach', 'git', 'clean', '-f', '-d', '-x']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_clobber_shallow(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clobber', shallow=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True, timeout=1200)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clone', '--depth', '1', 'http://github.com/buildbot/buildbot.git', '.']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_clobber_no_shallow(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clobber')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True, timeout=1200)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clone', 'http://github.com/buildbot/buildbot.git', '.']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_incremental_retryFetch(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='incremental', retryFetch=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 1, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_incremental_retryFetch_branch(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='incremental', retryFetch=True, branch='test-branch')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'test-branch']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 1, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'test-branch']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'branch', '-M', 'test-branch']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_incremental_clobberOnFailure(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='incremental', clobberOnFailure=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 1, Expect('rmdir', dict(dir='wkdir', logEnviron=True, timeout=1200)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clone', 'http://github.com/buildbot/buildbot.git', '.']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_incremental_clobberOnFailure_branch(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='incremental', clobberOnFailure=True, branch = 'test-branch')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'test-branch']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 1, Expect('rmdir', dict(dir='wkdir', logEnviron=True, timeout=1200)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clone', '--branch', 'test-branch', 'http://github.com/buildbot/buildbot.git', '.']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_copy(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='copy')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True, timeout=1200)), Expect('stat', dict(file='source/.git', logEnviron=True)) + 0, ExpectShell(workdir='source', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='source', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, Expect('cpdir', {'fromdir': 'source', 'todir': 'build', 'logEnviron': True, 'timeout': 1200}) + 0, ExpectShell(workdir='build', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_copy_shallow(self): self.assertRaisesConfigError("shallow only possible with mode 'full' and method 'clobber'", lambda : git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='copy', shallow=True)) def test_mode_incremental_no_existing_repo(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='incremental')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 1, ExpectShell(workdir='wkdir', command=['git', 'clone', 'http://github.com/buildbot/buildbot.git', '.']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_clobber_given_revision(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clobber', progress=True), dict( revision='abcdef01', )) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True, timeout=1200)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clone', 'http://github.com/buildbot/buildbot.git', '.', '--progress']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'abcdef01', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_revparse_failure(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clobber', progress=True), dict( revision='abcdef01', )) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True, timeout=1200)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clone', 'http://github.com/buildbot/buildbot.git', '.', '--progress']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'abcdef01', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ada95a1d') # too short + 0, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) self.expectNoProperty('got_revision') return self.runStep() def test_mode_full_clobber_submodule(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clobber', submodules=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True, timeout=1200)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clone', 'http://github.com/buildbot/buildbot.git', '.']) + 0, ExpectShell(workdir='wkdir', command=['git', 'submodule', 'update', '--init', '--recursive']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_repourl(self): self.assertRaisesConfigError("must provide repourl", lambda : git.Git(mode="full")) def test_mode_full_fresh_revision(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='fresh', progress=True), dict( revision='abcdef01', )) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 1, ExpectShell(workdir='wkdir', command=['git', 'clone', 'http://github.com/buildbot/buildbot.git', '.', '--progress']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'abcdef01', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_fresh_clobberOnFailure(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='fresh', clobberOnFailure=True)) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 1, ExpectShell(workdir='wkdir', command=['git', 'clone', 'http://github.com/buildbot/buildbot.git', '.']) + 1, Expect('rmdir', dict(dir='wkdir', logEnviron=True, timeout=1200)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clone', 'http://github.com/buildbot/buildbot.git', '.']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_no_method(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full')) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clean', '-f', '-d', '-x']) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_with_env(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', env={'abc': '123'})) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version'], env={'abc': '123'}) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clean', '-f', '-d', '-x'], env={'abc': '123'}) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD'], env={'abc': '123'}) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--'], env={'abc': '123'}) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD'], env={'abc': '123'}) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_mode_full_logEnviron(self): self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', logEnviron=False)) self.expectCommands( ExpectShell(workdir='wkdir', command=['git', '--version'], logEnviron=False) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=False)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clean', '-f', '-d', '-x'], logEnviron=False) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD'], logEnviron=False) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--'], logEnviron=False) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD'], logEnviron=False) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') return self.runStep() def test_getDescription(self): # clone of: test_mode_incremental # only difference is to set the getDescription property self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='incremental', getDescription=True)) self.expectCommands( ## copied from test_mode_incremental: ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ## plus this to test describe: ExpectShell(workdir='wkdir', command=['git', 'describe', 'HEAD']) + ExpectShell.log('stdio', stdout='Tag-1234') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') self.expectProperty('commit-description', 'Tag-1234', 'Git') return self.runStep() def test_getDescription_failed(self): # clone of: test_mode_incremental # only difference is to set the getDescription property # this tests when 'git describe' fails; for example, there are no # tags in the repository self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='incremental', getDescription=True)) self.expectCommands( ## copied from test_mode_incremental: ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['git', 'fetch', '-t', 'http://github.com/buildbot/buildbot.git', 'HEAD']) + 0, ExpectShell(workdir='wkdir', command=['git', 'reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ## plus this to test describe: ExpectShell(workdir='wkdir', command=['git', 'describe', 'HEAD']) + ExpectShell.log('stdio', stdout='') + 128, # error, but it's suppressed ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') self.expectNoProperty('commit-description') return self.runStep() def setup_getDescription_test(self, setup_args, output_args, codebase=None): # clone of: test_mode_full_clobber # only difference is to set the getDescription property kwargs = {} if codebase is not None: kwargs.update(codebase=codebase) self.setupStep( git.Git(repourl='http://github.com/buildbot/buildbot.git', mode='full', method='clobber', progress=True, getDescription=setup_args, **kwargs)) self.expectCommands( ## copied from test_mode_full_clobber: ExpectShell(workdir='wkdir', command=['git', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True, timeout=1200)) + 0, ExpectShell(workdir='wkdir', command=['git', 'clone', 'http://github.com/buildbot/buildbot.git', '.', '--progress']) + 0, ExpectShell(workdir='wkdir', command=['git', 'rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ## plus this to test describe: ExpectShell(workdir='wkdir', command=['git', 'describe'] + output_args + ['HEAD']) + ExpectShell.log('stdio', stdout='Tag-1234') + 0, ) if codebase: self.expectOutcome(result=SUCCESS, status_text=["update", codebase]) self.expectProperty('got_revision', {codebase:'f6ad368298bd941e934a41f3babc827b2aa95a1d'}, 'Git') self.expectProperty('commit-description', {codebase:'Tag-1234'}, 'Git') else: self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'f6ad368298bd941e934a41f3babc827b2aa95a1d', 'Git') self.expectProperty('commit-description', 'Tag-1234', 'Git') def test_getDescription_empty_dict(self): self.setup_getDescription_test( setup_args = {}, output_args = [] ) return self.runStep() def test_getDescription_empty_dict_with_codebase(self): self.setup_getDescription_test( setup_args = {}, output_args = [], codebase = 'baz' ) return self.runStep() def test_getDescription_match(self): self.setup_getDescription_test( setup_args = { 'match': 'stuff-*' }, output_args = ['--match', 'stuff-*'] ) return self.runStep() def test_getDescription_match_false(self): self.setup_getDescription_test( setup_args = { 'match': None }, output_args = [] ) return self.runStep() def test_getDescription_tags(self): self.setup_getDescription_test( setup_args = { 'tags': True }, output_args = ['--tags'] ) return self.runStep() def test_getDescription_tags_false(self): self.setup_getDescription_test( setup_args = { 'tags': False }, output_args = [] ) return self.runStep() def test_getDescription_all(self): self.setup_getDescription_test( setup_args = { 'all': True }, output_args = ['--all'] ) return self.runStep() def test_getDescription_all_false(self): self.setup_getDescription_test( setup_args = { 'all': False }, output_args = [] ) return self.runStep() def test_getDescription_abbrev(self): self.setup_getDescription_test( setup_args = { 'abbrev': 7 }, output_args = ['--abbrev=7'] ) return self.runStep() def test_getDescription_abbrev_zero(self): self.setup_getDescription_test( setup_args = { 'abbrev': 0 }, output_args = ['--abbrev=0'] ) return self.runStep() def test_getDescription_abbrev_false(self): self.setup_getDescription_test( setup_args = { 'abbrev': False }, output_args = [] ) return self.runStep() def test_getDescription_dirty(self): self.setup_getDescription_test( setup_args = { 'dirty': True }, output_args = ['--dirty'] ) return self.runStep() def test_getDescription_dirty_empty_str(self): self.setup_getDescription_test( setup_args = { 'dirty': '' }, output_args = ['--dirty'] ) return self.runStep() def test_getDescription_dirty_str(self): self.setup_getDescription_test( setup_args = { 'dirty': 'foo' }, output_args = ['--dirty=foo'] ) return self.runStep() def test_getDescription_dirty_false(self): self.setup_getDescription_test( setup_args = { 'dirty': False }, output_args = [] ) return self.runStep() def test_getDescription_contains(self): self.setup_getDescription_test( setup_args = { 'contains': True }, output_args = ['--contains'] ) return self.runStep() def test_getDescription_contains_false(self): self.setup_getDescription_test( setup_args = { 'contains': False }, output_args = [] ) return self.runStep() def test_getDescription_candidates(self): self.setup_getDescription_test( setup_args = { 'candidates': 7 }, output_args = ['--candidates=7'] ) return self.runStep() def test_getDescription_candidates_zero(self): self.setup_getDescription_test( setup_args = { 'candidates': 0 }, output_args = ['--candidates=0'] ) return self.runStep() def test_getDescription_candidates_false(self): self.setup_getDescription_test( setup_args = { 'candidates': False }, output_args = [] ) return self.runStep() def test_getDescription_exact_match(self): self.setup_getDescription_test( setup_args = { 'exact-match': True }, output_args = ['--exact-match'] ) return self.runStep() def test_getDescription_exact_match_false(self): self.setup_getDescription_test( setup_args = { 'exact-match': False }, output_args = [] ) return self.runStep() def test_getDescription_debug(self): self.setup_getDescription_test( setup_args = { 'debug': True }, output_args = ['--debug'] ) return self.runStep() def test_getDescription_debug_false(self): self.setup_getDescription_test( setup_args = { 'debug': False }, output_args = [] ) return self.runStep() def test_getDescription_long(self): self.setup_getDescription_test( setup_args = { 'long': True }, output_args = ['--long'] ) def test_getDescription_long_false(self): self.setup_getDescription_test( setup_args = { 'long': False }, output_args = [] ) return self.runStep() def test_getDescription_always(self): self.setup_getDescription_test( setup_args = { 'always': True }, output_args = ['--always'] ) def test_getDescription_always_false(self): self.setup_getDescription_test( setup_args = { 'always': False }, output_args = [] ) return self.runStep() def test_getDescription_lotsa_stuff(self): self.setup_getDescription_test( setup_args = { 'match': 'stuff-*', 'abbrev': 6, 'exact-match': True}, output_args = ['--exact-match', '--match', 'stuff-*', '--abbrev=6'], codebase='baz' ) return self.runStep() def test_config_option(self): name = 'url.http://github.com.insteadOf' value = 'blahblah' self.setupStep( git.Git(repourl='%s/buildbot/buildbot.git' % (value,), mode='full', method='clean', config={name: value})) prefix = ['git', '-c', '%s=%s' % (name, value)] self.expectCommands( ExpectShell(workdir='wkdir', command=prefix + ['--version']) + 0, Expect('stat', dict(file='wkdir/.git', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=prefix + ['clean', '-f', '-d']) + 0, ExpectShell(workdir='wkdir', command=prefix + ['fetch', '-t', '%s/buildbot/buildbot.git' % (value,), 'HEAD']) + 0, ExpectShell(workdir='wkdir', command=prefix + ['reset', '--hard', 'FETCH_HEAD', '--']) + 0, ExpectShell(workdir='wkdir', command=prefix + ['rev-parse', 'HEAD']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() buildbot-0.8.8/buildbot/test/unit/test_steps_source_mercurial.py000066400000000000000000001116331222546025000252720ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.python.reflect import namedModule from buildbot.steps.source import mercurial from buildbot.status.results import SUCCESS, FAILURE from buildbot.test.util import sourcesteps from buildbot.test.fake.remotecommand import ExpectShell, Expect from buildbot import config class TestMercurial(sourcesteps.SourceStepMixin, unittest.TestCase): def setUp(self): return self.setUpSourceStep() def tearDown(self): return self.tearDownSourceStep() def patch_slaveVersionIsOlderThan(self, result): self.patch(mercurial.Mercurial, 'slaveVersionIsOlderThan', lambda x, y, z: result) def test_no_repourl(self): self.assertRaises(config.ConfigErrors, lambda : mercurial.Mercurial(mode="full")) def test_incorrect_mode(self): self.assertRaises(config.ConfigErrors, lambda : mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='invalid')) def test_incorrect_method(self): self.assertRaises(config.ConfigErrors, lambda : mercurial.Mercurial(repourl='http://hg.mozilla.org', method='invalid')) def test_incorrect_branchType(self): self.assertRaises(config.ConfigErrors, lambda : mercurial.Mercurial(repourl='http://hg.mozilla.org', branchType='invalid')) def test_mode_full_clean(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='full', method='clean', branchType='inrepo')) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--config', 'extensions.purge=', 'purge']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'pull', 'http://hg.mozilla.org']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'identify', '--branch']) + ExpectShell.log('stdio', stdout='default') + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'locate', 'set:added()']) + 1, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean', '--rev', 'default']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_full_clean_win32path(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='full', method='clean', branchType='inrepo')) self.build.path_module = namedModule('ntpath') self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file=r'wkdir\.hg', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--config', 'extensions.purge=', 'purge']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'pull', 'http://hg.mozilla.org']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'identify', '--branch']) + ExpectShell.log('stdio', stdout='default') + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'locate', 'set:added()']) + 1, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean', '--rev', 'default']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_full_clean_timeout(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', timeout=1, mode='full', method='clean', branchType='inrepo')) self.expectCommands( ExpectShell(workdir='wkdir', timeout=1, command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', timeout=1, command=['hg', '--verbose', '--config', 'extensions.purge=', 'purge']) + 0, ExpectShell(workdir='wkdir', timeout=1, command=['hg', '--verbose', 'pull', 'http://hg.mozilla.org']) + 0, ExpectShell(workdir='wkdir', timeout=1, command=['hg', '--verbose', 'identify', '--branch']) + ExpectShell.log('stdio', stdout='default') + 0, ExpectShell(workdir='wkdir', timeout=1, command=['hg', '--verbose', 'locate', 'set:added()']) + 1, ExpectShell(workdir='wkdir', timeout=1, command=['hg', '--verbose', 'update', '--clean', '--rev', 'default']) + 0, ExpectShell(workdir='wkdir', timeout=1, command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_full_clean_patch(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='full', method='clean', branchType='inrepo'), patch=(1, 'patch')) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--config', 'extensions.purge=', 'purge']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'pull', 'http://hg.mozilla.org']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'identify', '--branch']) + ExpectShell.log('stdio', stdout='default') + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'locate', 'set:added()']) + 1, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean', '--rev', 'default']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'import', '--no-commit', '-p', '1', '-'], initialStdin='patch') + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_full_clean_patch_fail(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='full', method='clean', branchType='inrepo'), patch=(1, 'patch')) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--config', 'extensions.purge=', 'purge']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'pull', 'http://hg.mozilla.org']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'identify', '--branch']) + ExpectShell.log('stdio', stdout='default') + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'locate', 'set:added()']) + 1, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean', '--rev', 'default']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'import', '--no-commit', '-p', '1', '-'], initialStdin='patch') + 1, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) return self.runStep() def test_mode_full_clean_no_existing_repo(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='full', method='clean', branchType='inrepo')) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 1, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'clone', 'http://hg.mozilla.org', '.']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_full_clobber(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='full', method='clobber', branchType='inrepo')) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'clone', '--noupdate', 'http://hg.mozilla.org', '.']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean', '--rev', 'default']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_full_fresh(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='full', method='fresh', branchType='inrepo')) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--config', 'extensions.purge=', 'purge', '--all']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'pull', 'http://hg.mozilla.org']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'identify', '--branch']) + ExpectShell.log('stdio', stdout='default') + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'locate', 'set:added()']) + 1, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean', '--rev', 'default']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_full_fresh_no_existing_repo(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='full', method='fresh', branchType='inrepo')) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 1, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'clone', 'http://hg.mozilla.org', '.']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_incremental_no_existing_repo_dirname(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='incremental', branchType='dirname'), ) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 1, # does not exist ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'clone', 'http://hg.mozilla.org', '.', '--noupdate']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'locate', 'set:added()']) + 1, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_incremental_branch_change_dirname(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org/', mode='incremental', branchType='dirname', defaultBranch='devel'), dict(branch='stable') ) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'pull', 'http://hg.mozilla.org/stable']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'clone', '--noupdate', 'http://hg.mozilla.org/stable', '.']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_incremental_no_existing_repo_inrepo(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='incremental', branchType='inrepo')) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 1, # does not exist ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'clone', 'http://hg.mozilla.org', '.', '--noupdate']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'identify', '--branch']) + ExpectShell.log('stdio', stdout='default') + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'locate', 'set:added()']) + 1, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean', '--rev', 'default']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_incremental_existing_repo(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='incremental', branchType='inrepo')) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 0, # directory exists ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'pull', 'http://hg.mozilla.org']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'identify', '--branch']) + ExpectShell.log('stdio', stdout='default') + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'locate', 'set:added()']) + 1, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean', '--rev', 'default']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_incremental_existing_repo_added_files(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='incremental', branchType='inrepo')) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 0, # directory exists ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'pull', 'http://hg.mozilla.org']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'identify', '--branch']) + ExpectShell.log('stdio', stdout='default') + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'locate', 'set:added()']) + ExpectShell.log('stdio', stdout='foo\nbar/baz\n') + 1, Expect('rmdir', dict(dir=['wkdir/foo','wkdir/bar/baz'], logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean', '--rev', 'default']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_incremental_existing_repo_added_files_old_rmdir(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='incremental', branchType='inrepo')) self.patch_slaveVersionIsOlderThan(True) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 0, # directory exists ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'pull', 'http://hg.mozilla.org']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'identify', '--branch']) + ExpectShell.log('stdio', stdout='default') + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'locate', 'set:added()']) + ExpectShell.log('stdio', stdout='foo\nbar/baz\n') + 1, Expect('rmdir', dict(dir='wkdir/foo', logEnviron=True)) + 0, Expect('rmdir', dict(dir='wkdir/bar/baz', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean', '--rev', 'default']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_incremental_given_revision(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='incremental', branchType='inrepo'), dict( revision='abcdef01', )) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'pull', 'http://hg.mozilla.org']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'identify', '--branch']) + ExpectShell.log('stdio', stdout='default') + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'locate', 'set:added()']) + 1, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean', '--rev', 'abcdef01']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_incremental_branch_change(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='incremental', branchType='inrepo'), dict( branch='stable', )) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'pull', 'http://hg.mozilla.org']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'identify', '--branch']) + ExpectShell.log('stdio', stdout='default') + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'clone', '--noupdate', 'http://hg.mozilla.org', '.']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean', '--rev', 'stable']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_incremental_branch_change_no_clobberOnBranchChange(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='incremental', branchType='inrepo', clobberOnBranchChange=False), dict( branch='stable', )) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'pull', 'http://hg.mozilla.org']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'identify', '--branch']) + ExpectShell.log('stdio', stdout='default') + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'locate', 'set:added()']) + 1, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean', '--rev', 'stable']) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n']) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_full_clean_env(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='full', method='clean', branchType='inrepo', env={'abc': '123'})) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version'], env={'abc': '123'}) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--config', 'extensions.purge=', 'purge'], env={'abc': '123'}) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'pull', 'http://hg.mozilla.org'], env={'abc': '123'}) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'identify', '--branch'], env={'abc': '123'}) + ExpectShell.log('stdio', stdout='default') + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'locate', 'set:added()'], env={'abc': '123'}) + 1, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean', '--rev', 'default'], env={'abc': '123'}) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n'], env={'abc': '123'}) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_full_clean_logEnviron(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='full', method='clean', branchType='inrepo', logEnviron=False)) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version'], logEnviron=False) + 0, Expect('stat', dict(file='wkdir/.hg', logEnviron=False)) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--config', 'extensions.purge=', 'purge'], logEnviron=False) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'pull', 'http://hg.mozilla.org'], logEnviron=False) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'identify', '--branch'], logEnviron=False) + ExpectShell.log('stdio', stdout='default') + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'locate', 'set:added()'], logEnviron=False) + 1, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'update', '--clean', '--rev', 'default'], logEnviron=False) + 0, ExpectShell(workdir='wkdir', command=['hg', '--verbose', 'parents', '--template', '{node}\\n'], logEnviron=False) + ExpectShell.log('stdio', stdout='\n') + ExpectShell.log('stdio', stdout='f6ad368298bd941e934a41f3babc827b2aa95a1d') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_command_fails(self): self.setupStep( mercurial.Mercurial(repourl='http://hg.mozilla.org', mode='full', method='fresh', branchType='inrepo')) self.expectCommands( ExpectShell(workdir='wkdir', command=['hg', '--verbose', '--version']) + 1, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) return self.runStep() buildbot-0.8.8/buildbot/test/unit/test_steps_source_oldsource.py000066400000000000000000000052631222546025000253070ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.trial import unittest from buildbot.steps.source import oldsource class SlaveSource(unittest.TestCase): def doCommandCompleteTest(self, cmdHasGotRevision=True, cmdGotRevision='rev', initialPropertyValue=None, expectedPropertyValue=None, expectSetProperty=True): # set up a step with getProperty and setProperty step = oldsource.SlaveSource(codebase='foo') def getProperty(prop, default=None): self.assert_(prop == 'got_revision') if initialPropertyValue is None: return default return initialPropertyValue step.getProperty = getProperty def setProperty(prop, value, source): raise RuntimeError("should not be calling setProperty directly") step.setProperty = setProperty def updateSourceProperty(prop, value): self.failUnlessEqual((prop, value), ('got_revision', expectedPropertyValue)) self.propSet = True step.updateSourceProperty = updateSourceProperty # fake RemoteCommand, optionally with a got_revision update cmd = mock.Mock() cmd.updates = dict() if cmdHasGotRevision: cmd.updates['got_revision'] = [ cmdGotRevision ] # run the method and ensure it set something; the set asserts the # value is correct self.propSet = False step.commandComplete(cmd) self.assertEqual(self.propSet, expectSetProperty) def test_commandComplete_got_revision(self): self.doCommandCompleteTest( expectedPropertyValue='rev') def test_commandComplete_no_got_revision(self): self.doCommandCompleteTest( cmdHasGotRevision=False, expectSetProperty=False) def test_commandComplete_None_got_revision(self): self.doCommandCompleteTest( cmdGotRevision=None, expectSetProperty=False) buildbot-0.8.8/buildbot/test/unit/test_steps_source_oldsource_ComputeRepositoryURL.py000066400000000000000000000066141222546025000314670ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.internet import defer from buildbot.interfaces import IRenderable from buildbot.process.properties import Properties, WithProperties from buildbot.steps.source.oldsource import _ComputeRepositoryURL class SourceStamp(object): repository = "test" class Build(object): s = SourceStamp() props = Properties(foo = "bar") def getSourceStamp(self, codebase): assert codebase == '' return self.s def getProperties(self): return self.props def render(self, value): self.props.build = self return defer.maybeDeferred(IRenderable(value).getRenderingFor, self.props) class FakeStep(object): codebase = '' class RepoURL(unittest.TestCase): def setUp(self): self.build = Build() def test_backward_compatibility(self): url = _ComputeRepositoryURL(FakeStep(), "repourl") d = self.build.render(url) @d.addCallback def callback(res): self.assertEquals(res, "repourl") return d def test_format_string(self): url = _ComputeRepositoryURL(FakeStep(), "http://server/%s") d = self.build.render(url) @d.addCallback def callback(res): self.assertEquals(res, "http://server/test") return d def test_dict(self): dict = {} dict['test'] = "ssh://server/testrepository" url = _ComputeRepositoryURL(FakeStep(), dict) d = self.build.render(url) @d.addCallback def callback(res): self.assertEquals(res, "ssh://server/testrepository") return d def test_callable(self): func = lambda x: x[::-1] url = _ComputeRepositoryURL(FakeStep(), func) d = self.build.render(url) @d.addCallback def callback(res): self.assertEquals(res, "tset") return d def test_backward_compatibility_render(self): url = _ComputeRepositoryURL(FakeStep(), WithProperties("repourl%(foo)s")) d = self.build.render(url) @d.addCallback def callback(res): self.assertEquals(res, "repourlbar") return d def test_dict_render(self): d = dict(test=WithProperties("repourl%(foo)s")) url = _ComputeRepositoryURL(FakeStep(), d) d = self.build.render(url) @d.addCallback def callback(res): self.assertEquals(res, "repourlbar") return d def test_callable_render(self): func = lambda x: WithProperties(x+"%(foo)s") url = _ComputeRepositoryURL(FakeStep(), func) d = self.build.render(url) @d.addCallback def callback(res): self.assertEquals(res, "testbar") return d buildbot-0.8.8/buildbot/test/unit/test_steps_source_oldsource_Repo.py000066400000000000000000000027171222546025000262750ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.steps.source import Repo class RepoURL(unittest.TestCase): def test_parse1(self): r = Repo() self.assertEqual(r.parseDownloadProperty("repo download test/bla 564/12"),["test/bla 564/12"]) def test_parse2(self): r = Repo() self.assertEqual(r.parseDownloadProperty("repo download test/bla 564/12 repo download test/bla 564/2"),["test/bla 564/12","test/bla 564/2"]) def test_parse3(self): r = Repo() self.assertEqual(r.parseDownloadProperty("repo download test/bla 564/12 repo download test/bla 564/2 test/foo 5/1"),["test/bla 564/12","test/bla 564/2","test/foo 5/1"]) self.assertEqual(r.parseDownloadProperty("repo download test/bla 564/12"),["test/bla 564/12"]) buildbot-0.8.8/buildbot/test/unit/test_steps_source_p4.py000066400000000000000000000345231222546025000236340ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # Portions Copyright 2013 Bad Dog Consulting from twisted.trial import unittest from buildbot.steps.source.p4 import P4 from buildbot.status.results import SUCCESS from buildbot.test.util import sourcesteps from buildbot.test.util.properties import ConstantRenderable from buildbot.test.fake.remotecommand import ExpectShell, Expect from buildbot import config import textwrap class TestP4(sourcesteps.SourceStepMixin, unittest.TestCase): def setUp(self): return self.setUpSourceStep() def tearDown(self): return self.tearDownSourceStep() def setupStep(self, step, args={}, patch=None, **kwargs): step = sourcesteps.SourceStepMixin.setupStep(self, step, args={}, patch=None, **kwargs) self.build.getSourceStamp().revision = args.get('revision', None) # builddir propety used to create absolute path required in perforce client spec. self.properties.setProperty('builddir', '/home/user/workspace', 'P4') def test_no_empty_step_config(self): self.assertRaises(config.ConfigErrors, lambda: P4()) def test_no_multiple_type_step_config(self): self.assertRaises(config.ConfigErrors, lambda: P4(p4viewspec=('//depot/trunk', ''), p4base='//depot', p4branch='trunk', p4extra_views=['src', 'doc'])) def test_no_p4viewspec_is_string_step_config(self): self.assertRaises(config.ConfigErrors, lambda: P4(p4viewspec='a_bad_idea')) def test_no_p4base_has_trailing_slash_step_config(self): self.assertRaises(config.ConfigErrors, lambda: P4(p4base='//depot/')) def test_no_p4branch_has_trailing_slash_step_config(self): self.assertRaises(config.ConfigErrors, lambda: P4(p4base='//depot', p4branch='blah/')) def test_no_p4branch_with_no_p4base_step_config(self): self.assertRaises(config.ConfigErrors, lambda: P4(p4branch='blah')) def test_no_p4extra_views_with_no_p4base_step_config(self): self.assertRaises(config.ConfigErrors, lambda: P4(p4extra_views='blah')) def test_incorrect_mode(self): self.assertRaises(config.ConfigErrors, lambda: P4(p4base='//depot', mode='invalid')) def test_mode_incremental_p4base_with_revision(self): self.setupStep(P4(p4port='localhost:12000', mode='incremental', p4base='//depot', p4branch='trunk', p4user='user', p4client='p4_client1', p4passwd='pass'), dict(revision='100',)) client_spec = textwrap.dedent('''\ Client: p4_client1 Owner: user Description: \tCreated by user Root:\t/home/user/workspace/wkdir Options:\tallwrite rmdir LineEnd:\tlocal View: \t//depot/trunk/... //p4_client1/... '''); self.expectCommands( ExpectShell(workdir='wkdir', # defaults to this, only changes if it has a copy mode. command=['p4', '-V']) # expected remote command + 0, # expected exit status ExpectShell(workdir='wkdir', command=['p4', '-p', 'localhost:12000', '-u', 'user', '-P', 'pass', '-c', 'p4_client1', 'client', '-i'], initialStdin=client_spec) + 0, ExpectShell(workdir='wkdir', command=['p4', '-p', 'localhost:12000', '-u', 'user', '-P', 'pass', '-c', 'p4_client1', 'sync', '//depot...@100']) + 0, ExpectShell(workdir='wkdir', command=['p4', '-p', 'localhost:12000', '-u', 'user', '-P', 'pass', '-c', 'p4_client1', 'changes', '-m1', '#have']) + ExpectShell.log('stdio', stdout="Change 100 on 2013/03/21 by user@machine \'duh\'") + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'P4') return self.runStep() def _incremental(self, client_stdin=''): self.expectCommands( ExpectShell(workdir='wkdir', # defaults to this, only changes if it has a copy mode. command=['p4', '-V']) # expected remote command + 0, # expected exit status ExpectShell(workdir='wkdir', command=['p4', '-p', 'localhost:12000', '-u', 'user', '-P', 'pass', '-c', 'p4_client1', 'client', '-i'], initialStdin=client_stdin,) + 0, ExpectShell(workdir='wkdir', command=['p4', '-p', 'localhost:12000', '-u', 'user', '-P', 'pass', '-c', 'p4_client1', 'sync']) + 0, ExpectShell(workdir='wkdir', command=['p4', '-p', 'localhost:12000', '-u', 'user', '-P', 'pass', '-c', 'p4_client1', 'changes', '-m1', '#have']) + ExpectShell.log('stdio', stdout="Change 100 on 2013/03/21 by user@machine \'duh\'") + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'P4') return self.runStep() def test_mode_incremental_p4base(self): self.setupStep(P4(p4port='localhost:12000', mode='incremental', p4base='//depot', p4branch='trunk', p4user='user', p4client='p4_client1', p4passwd='pass')) client_spec = textwrap.dedent('''\ Client: p4_client1 Owner: user Description: \tCreated by user Root:\t/home/user/workspace/wkdir Options:\tallwrite rmdir LineEnd:\tlocal View: \t//depot/trunk/... //p4_client1/... ''') self._incremental(client_stdin=client_spec) def test_mode_incremental_p4base_with_p4extra_views(self): self.setupStep(P4(p4port='localhost:12000', mode='incremental', p4base='//depot', p4branch='trunk', p4extra_views=[('-//depot/trunk/test', 'test'), ('-//depot/trunk/doc', 'doc')], p4user='user', p4client='p4_client1', p4passwd='pass')) client_spec = textwrap.dedent('''\ Client: p4_client1 Owner: user Description: \tCreated by user Root:\t/home/user/workspace/wkdir Options:\tallwrite rmdir LineEnd:\tlocal View: \t//depot/trunk/... //p4_client1/... \t-//depot/trunk/test/... //p4_client1/test/... \t-//depot/trunk/doc/... //p4_client1/doc/... ''') self._incremental(client_stdin=client_spec) def test_mode_incremental_p4viewspec(self): self.setupStep(P4(p4port='localhost:12000', mode='incremental', p4viewspec=[('//depot/trunk/', '')], p4user='user', p4client='p4_client1', p4passwd='pass')) client_spec = textwrap.dedent('''\ Client: p4_client1 Owner: user Description: \tCreated by user Root:\t/home/user/workspace/wkdir Options:\tallwrite rmdir LineEnd:\tlocal View: \t//depot/trunk/... //p4_client1/... ''') self._incremental(client_stdin=client_spec) def _full(self, client_stdin='', p4client='p4_client1', p4user='user'): self.expectCommands( ExpectShell(workdir='wkdir', # defaults to this, only changes if it has a copy mode. command=['p4', '-V']) # expected remote command + 0, # expected exit status ExpectShell(workdir='wkdir', command=['p4', '-p', 'localhost:12000', '-u', p4user, '-P', 'pass', '-c', p4client, 'client', '-i'], initialStdin=client_stdin) + 0, ExpectShell(workdir='wkdir', command=['p4', '-p', 'localhost:12000', '-u', p4user, '-P', 'pass', '-c', p4client, 'sync', '#none']) + 0, Expect('rmdir', {'dir': 'wkdir', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['p4', '-p', 'localhost:12000', '-u', p4user, '-P', 'pass', '-c', p4client, 'sync']) + 0, ExpectShell(workdir='wkdir', command=['p4', '-p', 'localhost:12000', '-u', p4user, '-P', 'pass', '-c', p4client, 'changes', '-m1', '#have']) + ExpectShell.log('stdio', stdout="Change 100 on 2013/03/21 by user@machine \'duh\'") + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'P4') return self.runStep() def test_mode_full_p4base(self): self.setupStep( P4(p4port='localhost:12000', mode='full', p4base='//depot', p4branch='trunk', p4user='user', p4client='p4_client1', p4passwd='pass')) client_stdin = textwrap.dedent('''\ Client: p4_client1 Owner: user Description: \tCreated by user Root:\t/home/user/workspace/wkdir Options:\tallwrite rmdir LineEnd:\tlocal View: \t//depot/trunk/... //p4_client1/...\n''') self._full(client_stdin=client_stdin) def test_mode_full_p4viewspec(self): self.setupStep( P4(p4port='localhost:12000', mode='full', p4viewspec=[('//depot/main/', '')], p4user='user', p4client='p4_client1', p4passwd='pass')) client_stdin = textwrap.dedent('''\ Client: p4_client1 Owner: user Description: \tCreated by user Root:\t/home/user/workspace/wkdir Options:\tallwrite rmdir LineEnd:\tlocal View: \t//depot/main/... //p4_client1/...\n''') self._full(client_stdin=client_stdin) def test_mode_full_renderable_p4base(self): # Note that the config check skips checking p4base if it's a renderable self.setupStep( P4(p4port='localhost:12000', mode='full', p4base=ConstantRenderable('//depot'), p4branch='release/1.0', p4user='user', p4client='p4_client2', p4passwd='pass')) client_stdin = textwrap.dedent('''\ Client: p4_client2 Owner: user Description: \tCreated by user Root:\t/home/user/workspace/wkdir Options:\tallwrite rmdir LineEnd:\tlocal View: \t//depot/release/1.0/... //p4_client2/...\n''') self._full(client_stdin=client_stdin, p4client='p4_client2') def test_mode_full_renderable_p4client(self): # Note that the config check skips checking p4base if it's a renderable self.setupStep( P4(p4port='localhost:12000', mode='full', p4base='//depot', p4branch='trunk', p4user='user', p4client=ConstantRenderable('p4_client_render'), p4passwd='pass')) client_stdin = textwrap.dedent('''\ Client: p4_client_render Owner: user Description: \tCreated by user Root:\t/home/user/workspace/wkdir Options:\tallwrite rmdir LineEnd:\tlocal View: \t//depot/trunk/... //p4_client_render/...\n''') self._full(client_stdin=client_stdin, p4client='p4_client_render') def test_mode_full_renderable_p4branch(self): # Note that the config check skips checking p4base if it's a renderable self.setupStep( P4(p4port='localhost:12000', mode='full', p4base='//depot', p4branch=ConstantRenderable('render_branch'), p4user='user', p4client='p4_client1', p4passwd='pass')) client_stdin = textwrap.dedent('''\ Client: p4_client1 Owner: user Description: \tCreated by user Root:\t/home/user/workspace/wkdir Options:\tallwrite rmdir LineEnd:\tlocal View: \t//depot/render_branch/... //p4_client1/...\n''') self._full(client_stdin=client_stdin) def test_mode_full_renderable_p4viewspec(self): self.setupStep( P4(p4port='localhost:12000', mode='full', p4viewspec=[(ConstantRenderable('//depot/render_trunk/'), '')], p4user='different_user', p4client='p4_client1', p4passwd='pass')) client_stdin = textwrap.dedent('''\ Client: p4_client1 Owner: different_user Description: \tCreated by different_user Root:\t/home/user/workspace/wkdir Options:\tallwrite rmdir LineEnd:\tlocal View: \t//depot/render_trunk/... //p4_client1/...\n''') self._full(client_stdin=client_stdin, p4user='different_user') buildbot-0.8.8/buildbot/test/unit/test_steps_source_repo.py000066400000000000000000000627521222546025000242630ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.steps.source import repo from buildbot.status.results import SUCCESS, FAILURE from buildbot.test.util import sourcesteps from buildbot.test.fake.remotecommand import ExpectShell, Expect from buildbot.process.properties import Properties from .test_changes_gerritchangesource import TestGerritChangeSource from buildbot.changes.changes import Change import os class RepoURL(unittest.TestCase): # testcases taken from old_source/Repo test def oneTest(self, props, expected): p = Properties() p.update(props, "test") r = repo.RepoDownloadsFromProperties(props.keys()) self.assertEqual(r.getRenderingFor(p), expected) def test_parse1(self): self.oneTest( {'a': "repo download test/bla 564/12"}, ["test/bla 564/12"]) def test_parse2(self): self.oneTest( {'a': "repo download test/bla 564/12 repo download test/bla 564/2"}, ["test/bla 564/12", "test/bla 564/2"]) self.oneTest({'a': "repo download test/bla 564/12", 'b': "repo download test/bla 564/2"}, [ "test/bla 564/12", "test/bla 564/2"]) def test_parse3(self): self.oneTest({'a': "repo download test/bla 564/12 repo download test/bla 564/2 test/foo 5/1"}, [ "test/bla 564/12", "test/bla 564/2", "test/foo 5/1"]) self.oneTest( {'a': "repo download test/bla 564/12"}, ["test/bla 564/12"]) class TestRepo(sourcesteps.SourceStepMixin, unittest.TestCase): def setUp(self): self.shouldRetry = False self.logEnviron = True return self.setUpSourceStep() def tearDown(self): return self.tearDownSourceStep() def shouldLogEnviron(self): r = self.logEnviron self.logEnviron = False return r def ExpectShell(self, **kw): if 'workdir' not in kw: kw['workdir'] = 'wkdir' if 'logEnviron' not in kw: kw['logEnviron'] = self.shouldLogEnviron() return ExpectShell(**kw) def mySetupStep(self, **kwargs): if "repoDownloads" not in kwargs: kwargs.update( dict(repoDownloads=repo.RepoDownloadsFromProperties(["repo_download", "repo_download2"]))) self.setupStep( repo.Repo(manifestURL='git://myrepo.com/manifest.git', manifestBranch="mb", manifestFile="mf", **kwargs)) self.build.allChanges = lambda x=None: [] def myRunStep(self, result=SUCCESS, status_text=["update"]): self.expectOutcome(result=result, status_text=status_text) d = self.runStep() def printlogs(res): text = self.step.stdio_log.getTextWithHeaders() if "Failure instance" in text and not self.shouldRetry: print text return res d.addBoth(printlogs) return d def expectClobber(self): # stat return 1 so we clobber self.expectCommands( Expect('stat', dict(file=os.path.join('wkdir', '.repo'), logEnviron=self.logEnviron)) + 1, Expect('rmdir', dict(dir='wkdir', logEnviron=self.logEnviron)) + 0, Expect('mkdir', dict(dir='wkdir', logEnviron=self.logEnviron)) + 0, ) def expectnoClobber(self): # stat return 0, so nothing self.expectCommands( Expect('stat', dict(file=os.path.join('wkdir', '.repo'), logEnviron=self.logEnviron)) + 0, ) def expectRepoSync(self, which_fail=-1, breakatfail=False, syncoptions=["-c"], override_commands=[]): commands = [ self.ExpectShell( command=[ 'bash', '-c', self.step._getCleanupCommand()]), self.ExpectShell( command=['repo', 'init', '-u', 'git://myrepo.com/manifest.git', '-b', 'mb', '-m', 'mf']) ] + override_commands + [ self.ExpectShell(command=['repo', 'sync'] + syncoptions), self.ExpectShell( command=['repo', 'manifest', '-r', '-o', 'manifest-original.xml']) ] for i in xrange(len(commands)): self.expectCommands(commands[i] + (which_fail == i and 1 or 0)) if which_fail == i and breakatfail: break def test_basic(self): """basic first time repo sync""" self.mySetupStep(repoDownloads=None) self.expectClobber() self.expectRepoSync() return self.myRunStep(status_text=["update"]) def test_update(self): """basic second time repo sync""" self.mySetupStep() self.expectnoClobber() self.expectRepoSync() return self.myRunStep(status_text=["update"]) def test_jobs(self): """basic first time repo sync with jobs""" self.mySetupStep(jobs=2) self.expectClobber() self.expectRepoSync(syncoptions=["-j2", "-c"]) return self.myRunStep(status_text=["update"]) def test_sync_all_branches(self): """basic first time repo sync with all branches""" self.mySetupStep(syncAllBranches=True) self.expectClobber() self.expectRepoSync(syncoptions=[]) return self.myRunStep(status_text=["update"]) def test_manifest_override(self): """repo sync with manifest_override_url property set download via wget """ self.mySetupStep(manifestOverrideUrl= "http://u.rl/test.manifest", syncAllBranches=True) self.expectClobber() override_commands = [ Expect( 'stat', dict(file=os.path.join('wkdir', 'http://u.rl/test.manifest'), logEnviron=False)), self.ExpectShell(logEnviron=False, command=['wget', 'http://u.rl/test.manifest', '-O', 'manifest_override.xml']), self.ExpectShell( logEnviron=False, workdir=os.path.join('wkdir', '.repo'), command=['ln', '-sf', '../manifest_override.xml', 'manifest.xml']) ] self.expectRepoSync(which_fail=2, syncoptions=[], override_commands=override_commands) return self.myRunStep(status_text=["update"]) def test_manifest_override_local(self): """repo sync with manifest_override_url property set copied from local FS """ self.mySetupStep(manifestOverrideUrl= "test.manifest", syncAllBranches=True) self.expectClobber() override_commands = [ Expect('stat', dict(file=os.path.join('wkdir', 'test.manifest'), logEnviron=False)), self.ExpectShell(logEnviron=False, command=[ 'cp', '-f', 'test.manifest', 'manifest_override.xml']), self.ExpectShell(logEnviron=False, workdir=os.path.join('wkdir', '.repo'), command=['ln', '-sf', '../manifest_override.xml', 'manifest.xml']) ] self.expectRepoSync( syncoptions=[], override_commands=override_commands) return self.myRunStep(status_text=["update"]) def test_tarball(self): """repo sync using the tarball cache """ self.mySetupStep(tarball="/tarball.tar") self.expectClobber() self.expectCommands( self.ExpectShell(command=['tar', '-xvf', '/tarball.tar']) + 0) self.expectRepoSync() self.expectCommands(self.ExpectShell(command=['stat', '-c%Y', '/tarball.tar']) + Expect.log('stdio', stdout=str(10000)) + 0) self.expectCommands(self.ExpectShell(command=['stat', '-c%Y', '.']) + Expect.log( 'stdio', stdout=str(10000 + 7 * 24 * 3600)) + 0) return self.myRunStep(status_text=["update"]) def test_create_tarball(self): """repo sync create the tarball if its not here """ self.mySetupStep(tarball="/tarball.tgz") self.expectClobber() self.expectCommands( self.ExpectShell( command=['tar', '-z', '-xvf', '/tarball.tgz']) + 1, self.ExpectShell(command=['rm', '-f', '/tarball.tgz']) + 1, Expect('rmdir', dict(dir=os.path.join('wkdir', '.repo'), logEnviron=False)) + 1) self.expectRepoSync() self.expectCommands(self.ExpectShell(command=['stat', '-c%Y', '/tarball.tgz']) + Expect.log('stdio', stderr="file not found!") + 1, self.ExpectShell(command=['tar', '-z', '-cvf', '/tarball.tgz', '.repo']) + 0) return self.myRunStep(status_text=["update"]) def do_test_update_tarball(self, suffix, option): """repo sync update the tarball cache at the end (tarball older than a week) """ self.mySetupStep(tarball="/tarball." + suffix) self.expectClobber() self.expectCommands( self.ExpectShell(command=['tar'] + option + ['-xvf', '/tarball.' + suffix]) + 0) self.expectRepoSync() self.expectCommands(self.ExpectShell(command=['stat', '-c%Y', '/tarball.' + suffix]) + Expect.log('stdio', stdout=str(10000)) + 0, self.ExpectShell(command=['stat', '-c%Y', '.']) + Expect.log( 'stdio', stdout=str(10001 + 7 * 24 * 3600)) + 0, self.ExpectShell(command=['tar'] + option + ['-cvf', '/tarball.' + suffix, '.repo']) + 0) return self.myRunStep(status_text=["update"]) def test_update_tarball(self): self.do_test_update_tarball("tar", []) def test_update_tarball_gz(self): """tarball compression variants""" self.do_test_update_tarball("tar.gz", ["-z"]) def test_update_tarball_tgz(self): self.do_test_update_tarball("tgz", ["-z"]) def test_update_tarball_bzip(self): self.do_test_update_tarball("tar.bz2", ["-j"]) def test_update_tarball_lzma(self): self.do_test_update_tarball("tar.lzma", ["--lzma"]) def test_update_tarball_lzop(self): self.do_test_update_tarball("tar.lzop", ["--lzop"]) def test_update_tarball_fail1(self, suffix="tar", option=[]): """tarball extract fail -> remove the tarball + remove .repo dir """ self.mySetupStep(tarball="/tarball." + suffix) self.expectClobber() self.expectCommands( self.ExpectShell( command=[ 'tar'] + option + ['-xvf', '/tarball.' + suffix]) + 1, self.ExpectShell( command=['rm', '-f', '/tarball.tar']) + 0, Expect( 'rmdir', dict(dir=os.path.join('wkdir', '.repo'), logEnviron=False)) + 0) self.expectRepoSync() self.expectCommands(self.ExpectShell(command=['stat', '-c%Y', '/tarball.' + suffix]) + Expect.log('stdio', stdout=str(10000)) + 0, self.ExpectShell(command=['stat', '-c%Y', '.']) + Expect.log( 'stdio', stdout=str(10001 + 7 * 24 * 3600)) + 0, self.ExpectShell(command=['tar'] + option + ['-cvf', '/tarball.' + suffix, '.repo']) + 0) return self.myRunStep(status_text=["update"]) def test_update_tarball_fail2(self, suffix="tar", option=[]): """tarball update fail -> remove the tarball + continue repo download """ self.mySetupStep(tarball="/tarball." + suffix) self.build.setProperty("repo_download", "repo download test/bla 564/12", "test") self.expectClobber() self.expectCommands( self.ExpectShell(command=['tar'] + option + ['-xvf', '/tarball.' + suffix]) + 0) self.expectRepoSync() self.expectCommands(self.ExpectShell(command=['stat', '-c%Y', '/tarball.' + suffix]) + Expect.log('stdio', stdout=str(10000)) + 0, self.ExpectShell(command=['stat', '-c%Y', '.']) + Expect.log( 'stdio', stdout=str(10001 + 7 * 24 * 3600)) + 0, self.ExpectShell(command=['tar'] + option + ['-cvf', '/tarball.' + suffix, '.repo']) + 1, self.ExpectShell( command=['rm', '-f', '/tarball.tar']) + 0, self.ExpectShell( command=['repo', 'download', 'test/bla', '564/12']) + 0) return self.myRunStep(status_text=["update"]) def test_repo_downloads(self): """basic repo download, and check that repo_downloaded is updated""" self.mySetupStep() self.build.setProperty("repo_download", "repo download test/bla 564/12", "test") self.expectnoClobber() self.expectRepoSync() self.expectCommands( self.ExpectShell( command=['repo', 'download', 'test/bla', '564/12']) + 0 + Expect.log( 'stdio', stderr="test/bla refs/changes/64/564/12 -> FETCH_HEAD\n") + Expect.log('stdio', stderr="HEAD is now at 0123456789abcdef...\n")) self.expectProperty( "repo_downloaded", "564/12 0123456789abcdef ", "Source") return self.myRunStep(status_text=["update"]) def test_repo_downloads2(self): """2 repo downloads""" self.mySetupStep() self.build.setProperty("repo_download", "repo download test/bla 564/12", "test") self.build.setProperty("repo_download2", "repo download test/bla2 565/12", "test") self.expectnoClobber() self.expectRepoSync() self.expectCommands( self.ExpectShell( command=['repo', 'download', 'test/bla', '564/12']) + 0, self.ExpectShell( command=['repo', 'download', 'test/bla2', '565/12']) + 0) return self.myRunStep(status_text=["update"]) def test_repo_download_manifest(self): """2 repo downloads, with one manifest patch""" self.mySetupStep() self.build.setProperty("repo_download", "repo download test/bla 564/12", "test") self.build.setProperty("repo_download2", "repo download manifest 565/12", "test") self.expectnoClobber() self.expectCommands( self.ExpectShell( command=['bash', '-c', self.step._getCleanupCommand()]) + 0, self.ExpectShell( command=['repo', 'init', '-u', 'git://myrepo.com/manifest.git', '-b', 'mb', '-m', 'mf']) + 0, self.ExpectShell( workdir=os.path.join('wkdir', '.repo', 'manifests'), command=[ 'git', 'fetch', 'git://myrepo.com/manifest.git', 'refs/changes/65/565/12']) + 0, self.ExpectShell( workdir=os.path.join('wkdir', '.repo', 'manifests'), command=['git', 'cherry-pick', 'FETCH_HEAD']) + 0, self.ExpectShell(command=['repo', 'sync', '-c']) + 0, self.ExpectShell( command=['repo', 'manifest', '-r', '-o', 'manifest-original.xml']) + 0) self.expectCommands( self.ExpectShell( command=['repo', 'download', 'test/bla', '564/12']) + 0) return self.myRunStep(status_text=["update"]) def test_repo_downloads_mirror_sync(self): """repo downloads, with mirror synchronization issues""" self.mySetupStep() self.step.mirror_sync_sleep = 0.001 # we dont really want the test to wait... self.build.setProperty("repo_download", "repo download test/bla 564/12", "test") self.expectnoClobber() self.expectRepoSync() self.expectCommands( self.ExpectShell( command=['repo', 'download', 'test/bla', '564/12']) + 1 + Expect.log( "stdio", stderr="fatal: Couldn't find remote ref \n"), self.ExpectShell( command=['repo', 'download', 'test/bla', '564/12']) + 1 + Expect.log( "stdio", stderr="fatal: Couldn't find remote ref \n"), self.ExpectShell( command=['repo', 'download', 'test/bla', '564/12']) + 0) return self.myRunStep(status_text=["update"]) def test_repo_downloads_change_missing(self): """repo downloads, with no actual mirror synchronization issues (still retries 2 times)""" self.mySetupStep() self.step.mirror_sync_sleep = 0.001 # we dont really want the test to wait... self.step.mirror_sync_retry = 1 # on retry once self.build.setProperty("repo_download", "repo download test/bla 564/12", "test") self.expectnoClobber() self.expectRepoSync() self.expectCommands( self.ExpectShell( command=['repo', 'download', 'test/bla', '564/12']) + 1 + Expect.log( "stdio", stderr="fatal: Couldn't find remote ref \n"), self.ExpectShell( command=['repo', 'download', 'test/bla', '564/12']) + 1 + Expect.log( "stdio", stderr="fatal: Couldn't find remote ref \n"), ) return self.myRunStep(result=FAILURE, status_text=["repo: change test/bla 564/12 does not exist"]) def test_repo_downloads_fail1(self): """repo downloads, cherry-pick returns 1""" self.mySetupStep() self.build.setProperty("repo_download", "repo download test/bla 564/12", "test") self.expectnoClobber() self.expectRepoSync() self.expectCommands( self.ExpectShell( command=['repo', 'download', 'test/bla', '564/12']) + 1 + Expect.log("stdio", stderr="patch \n"), self.ExpectShell( command=['repo', 'forall', '-c', 'git', 'diff', 'HEAD']) + 0 ) return self.myRunStep(result=FAILURE, status_text=["download failed: test/bla 564/12"]) def test_repo_downloads_fail2(self): """repo downloads, cherry-pick returns 0 but error in stderr""" self.mySetupStep() self.build.setProperty("repo_download", "repo download test/bla 564/12", "test") self.expectnoClobber() self.expectRepoSync() self.expectCommands( self.ExpectShell( command=['repo', 'download', 'test/bla', '564/12']) + 0 + Expect.log("stdio", stderr="Automatic cherry-pick failed \n"), self.ExpectShell( command=['repo', 'forall', '-c', 'git', 'diff', 'HEAD']) + 0 ) return self.myRunStep(result=FAILURE, status_text=["download failed: test/bla 564/12"]) def test_repo_downloads_from_change_source(self): """basic repo download from change source, and check that repo_downloaded is updated""" self.mySetupStep(repoDownloads=repo.RepoDownloadsFromChangeSource()) chdict = TestGerritChangeSource.expected_change change = Change(None, None, None, properties=chdict['properties']) self.build.allChanges = lambda x=None: [change] self.expectnoClobber() self.expectRepoSync() self.expectCommands( self.ExpectShell(command=['repo', 'download', 'pr', '4321/12']) + 0 + Expect.log( 'stdio', stderr="test/bla refs/changes/64/564/12 -> FETCH_HEAD\n") + Expect.log('stdio', stderr="HEAD is now at 0123456789abcdef...\n")) self.expectProperty( "repo_downloaded", "564/12 0123456789abcdef ", "Source") return self.myRunStep(status_text=["update"]) def test_repo_downloads_from_change_source_codebase(self): """basic repo download from change source, and check that repo_downloaded is updated""" self.mySetupStep(repoDownloads=repo.RepoDownloadsFromChangeSource("mycodebase")) chdict = TestGerritChangeSource.expected_change change = Change(None, None, None, properties=chdict['properties']) # getSourceStamp is faked by SourceStepMixin ss = self.build.getSourceStamp("") ss.changes = [change] self.expectnoClobber() self.expectRepoSync() self.expectCommands( self.ExpectShell(command=['repo', 'download', 'pr', '4321/12']) + 0 + Expect.log( 'stdio', stderr="test/bla refs/changes/64/564/12 -> FETCH_HEAD\n") + Expect.log('stdio', stderr="HEAD is now at 0123456789abcdef...\n")) self.expectProperty( "repo_downloaded", "564/12 0123456789abcdef ", "Source") return self.myRunStep(status_text=["update"]) def test_update_fail1(self): """ fail at cleanup: ignored""" self.mySetupStep() self.expectnoClobber() self.expectRepoSync(which_fail=0, breakatfail=False) return self.myRunStep(status_text=["update"]) def test_update_fail2(self): """fail at repo init: clobber""" self.mySetupStep() self.expectnoClobber() self.expectRepoSync(which_fail=1, breakatfail=True) self.expectClobber() self.expectRepoSync() self.shouldRetry = True return self.myRunStep(status_text=["update"]) def test_update_fail3(self): """ fail at repo sync: clobber""" self.mySetupStep() self.expectnoClobber() self.expectRepoSync(which_fail=2, breakatfail=True) self.expectClobber() self.expectRepoSync() self.shouldRetry = True return self.myRunStep(status_text=["update"]) def test_update_fail4(self): """fail at repo manifest: clobber""" self.mySetupStep() self.expectnoClobber() self.expectRepoSync(which_fail=3, breakatfail=True) self.expectClobber() self.expectRepoSync() self.shouldRetry = True return self.myRunStep(status_text=["update"]) def test_update_doublefail(self): """fail at repo manifest: clobber but still fail""" self.mySetupStep() self.expectnoClobber() self.expectRepoSync(which_fail=3, breakatfail=True) self.expectClobber() self.expectRepoSync(which_fail=3, breakatfail=True) self.shouldRetry = True return self.myRunStep(result=FAILURE, status_text=["repo failed at: repo manifest"]) def test_update_doublefail2(self): """fail at repo sync: clobber but still fail""" self.mySetupStep() self.expectnoClobber() self.expectRepoSync(which_fail=2, breakatfail=True) self.expectClobber() self.expectRepoSync(which_fail=2, breakatfail=True) self.shouldRetry = True return self.myRunStep(result=FAILURE, status_text=["repo failed at: repo sync"]) def test_update_doublefail3(self): """fail at repo init: clobber but still fail""" self.mySetupStep() self.expectnoClobber() self.expectRepoSync(which_fail=1, breakatfail=True) self.expectClobber() self.expectRepoSync(which_fail=1, breakatfail=True) self.shouldRetry = True return self.myRunStep(result=FAILURE, status_text=["repo failed at: repo init"]) def test_basic_fail(self): """fail at repo init: no need to re-clobber but still fail""" self.mySetupStep() self.expectClobber() self.expectRepoSync(which_fail=1, breakatfail=True) self.shouldRetry = True return self.myRunStep(result=FAILURE, status_text=["repo failed at: repo init"]) buildbot-0.8.8/buildbot/test/unit/test_steps_source_svn.py000066400000000000000000001736431222546025000241260ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.python.reflect import namedModule from buildbot.steps.source import svn from buildbot.status.results import SUCCESS, FAILURE from buildbot.test.util import sourcesteps from buildbot.process import buildstep from buildbot.test.fake.remotecommand import ExpectShell, Expect from buildbot.test.util.properties import ConstantRenderable from buildbot import config class TestSVN(sourcesteps.SourceStepMixin, unittest.TestCase): svn_st_xml = """ """ svn_st_xml_corrupt = """ """ svn_st_xml_empty = """ """ svn_info_stdout_xml = """ http://svn.red-bean.com/repos/test http://svn.red-bean.com/repos/test 5e7d134a-54fb-0310-bd04-b611643e5c25 normal infinity sally 2003-01-15T23:35:12.847647Z """ svn_info_stdout_xml_nonintegerrevision = """ http://svn.red-bean.com/repos/test http://svn.red-bean.com/repos/test 5e7d134a-54fb-0310-bd04-b611643e5c25 normal infinity sally 2003-01-15T23:35:12.847647Z """ def setUp(self): return self.setUpSourceStep() def tearDown(self): return self.tearDownSourceStep() def patch_slaveVersionIsOlderThan(self, result): self.patch(svn.SVN, 'slaveVersionIsOlderThan', lambda x, y, z: result) def test_no_repourl(self): self.assertRaises(config.ConfigErrors, lambda : svn.SVN()) def test_incorrect_mode(self): self.assertRaises(config.ConfigErrors, lambda : svn.SVN(repourl='http://svn.local/app/trunk', mode='invalid')) def test_incorrect_method(self): self.assertRaises(config.ConfigErrors, lambda : svn.SVN(repourl='http://svn.local/app/trunk', method='invalid')) def test_corrupt_xml(self): self.setupStep(svn.SVN(repourl='http://svn.local/app/trunk')) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', dict(file='wkdir/.svn', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache']) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_st_xml_corrupt) + 0, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) return self.runStep() def test_revision_noninteger(self): svnTestStep = svn.SVN(repourl='http://svn.local/app/trunk') self.setupStep(svnTestStep) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', dict(file='wkdir/.svn', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache']) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml_nonintegerrevision) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', 'a10', 'SVN') d = self.runStep() def _checkType(): revision = self.step.getProperty('got_revision') self.assertRaises(ValueError, lambda: int(revision)) d.addCallback(lambda _: _checkType()) return d def test_revision_missing(self): """Fail if 'revision' tag isnt there""" svn_info_stdout = self.svn_info_stdout_xml.replace('entry', 'Blah') svnTestStep = svn.SVN(repourl='http://svn.local/app/trunk') self.setupStep(svnTestStep) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', dict(file='wkdir/.svn', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache']) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=svn_info_stdout) + 0, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) return self.runStep() def test_mode_incremental(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='incremental',username='user', password='pass', extra_args=['--random'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', dict(file='wkdir/.svn', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random']) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_incremental_timeout(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='incremental',username='user', timeout=1, password='pass', extra_args=['--random'])) self.expectCommands( ExpectShell(workdir='wkdir', timeout=1, command=['svn', '--version']) + 0, Expect('stat', dict(file='wkdir/.svn', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', timeout=1, command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random']) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', timeout=1, command=['svn', 'update', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random']) + 0, ExpectShell(workdir='wkdir', timeout=1, command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_incremental_repourl_renderable(self): self.setupStep( svn.SVN(repourl=ConstantRenderable('http://svn.local/trunk'), mode='incremental')) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', dict(file='wkdir/.svn', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache']) + ExpectShell.log('stdio', stdout="""http://svn.local/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_incremental_repourl_not_updatable(self): self.setupStep( svn.SVN(repourl=ConstantRenderable('http://svn.local/trunk/app'), mode='incremental',)) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', dict(file='wkdir/.svn', logEnviron=True)) + 1, Expect('rmdir', {'dir': 'wkdir', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'checkout', 'http://svn.local/trunk/app', '.', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_incremental_repourl_not_updatable_svninfo_mismatch(self): self.setupStep( svn.SVN(repourl=ConstantRenderable('http://svn.local/trunk/app'), mode='incremental')) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', dict(file='wkdir/.svn', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache']) + ExpectShell.log('stdio', # expecting ../trunk/app stdout="""http://svn.local/branch/foo/app""") + 0, Expect('rmdir', {'dir': 'wkdir', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'checkout', 'http://svn.local/trunk/app', '.', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_incremental_given_revision(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='incremental'), dict( revision='100', )) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', dict(file='wkdir/.svn', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache']) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--revision', '100', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_incremental_win32path(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='incremental',username='user', password='pass', extra_args=['--random'])) self.build.path_module = namedModule("ntpath") self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', dict(file=r'wkdir\.svn', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random']) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) return self.runStep() def test_mode_incremental_preferLastChangedRev(self): """Give the last-changed rev if 'preferLastChangedRev' is set""" self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='incremental',username='user', preferLastChangedRev=True, password='pass', extra_args=['--random'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', dict(file='wkdir/.svn', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random']) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '90', 'SVN') return self.runStep() def test_mode_incremental_preferLastChangedRev_butMissing(self): """If 'preferLastChangedRev' is set, but missing, fall back to the regular revision value.""" svn_info_stdout = self.svn_info_stdout_xml.replace('commit', 'Blah') self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='incremental',username='user', preferLastChangedRev=True, password='pass', extra_args=['--random'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', dict(file='wkdir/.svn', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random']) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=svn_info_stdout) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_clobber(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='clobber')) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('rmdir', {'dir': 'wkdir', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'checkout', 'http://svn.local/app/trunk', '.', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_clobber_given_revision(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='clobber'),dict( revision='100', )) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('rmdir', {'dir': 'wkdir', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'checkout', 'http://svn.local/app/trunk', '.', '--revision', '100', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_fresh(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='fresh', depth='infinite')) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', {'file': 'wkdir/.svn', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache', '--depth', 'infinite' ]) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'status', '--xml', '--no-ignore', '--non-interactive', '--no-auth-cache', '--depth', 'infinite']) + ExpectShell.log('stdio', stdout=self.svn_st_xml_empty) + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache', '--depth', 'infinite']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + ExpectShell.log('stdio', stdout='\n') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_fresh_given_revision(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='fresh', depth='infinite'),dict( revision='100', )) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', {'file': 'wkdir/.svn', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache', '--depth', 'infinite' ]) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'status', '--xml', '--no-ignore', '--non-interactive', '--no-auth-cache', '--depth', 'infinite']) + ExpectShell.log('stdio', stdout=self.svn_st_xml_empty) + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--revision', '100', '--non-interactive', '--no-auth-cache', '--depth', 'infinite']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + ExpectShell.log('stdio', stdout='\n') + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_fresh_keep_on_purge(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', keep_on_purge=['svn_external_path/unversioned_file1'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', {'file': 'wkdir/.svn', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache' ]) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'status', '--xml', '--no-ignore', '--non-interactive', '--no-auth-cache']) + ExpectShell.log('stdio', stdout=self.svn_st_xml) + 0, Expect('rmdir', {'dir': ['wkdir/svn_external_path/unversioned_file2'], 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_clean(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='clean')) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', {'file': 'wkdir/.svn', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache' ]) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'status', '--xml', '--non-interactive', '--no-auth-cache']) + ExpectShell.log('stdio', stdout=self.svn_st_xml_empty) + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_clean_given_revision(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='clean'),dict( revision='100', )) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', {'file': 'wkdir/.svn', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache' ]) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'status', '--xml', '--non-interactive', '--no-auth-cache']) + ExpectShell.log('stdio', stdout=self.svn_st_xml_empty) + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--revision', '100', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_not_updatable(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='clean')) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', {'file': 'wkdir/.svn', 'logEnviron': True}) + 1, Expect('rmdir', {'dir': 'wkdir', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'checkout', 'http://svn.local/app/trunk', '.', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_not_updatable_given_revision(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='clean'),dict( revision='100', )) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', {'file': 'wkdir/.svn', 'logEnviron': True}) + 1, Expect('rmdir', {'dir': 'wkdir', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'checkout', 'http://svn.local/app/trunk', '.', '--revision', '100', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_clean_old_rmdir(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='clean')) self.patch_slaveVersionIsOlderThan(True) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', {'file': 'wkdir/.svn', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache' ]) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'status', '--xml', '--non-interactive', '--no-auth-cache']) + ExpectShell.log('stdio', stdout=self.svn_st_xml) + 0, Expect('rmdir', {'dir': 'wkdir/svn_external_path/unversioned_file1', 'logEnviron': True}) + 0, Expect('rmdir', {'dir': 'wkdir/svn_external_path/unversioned_file2', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_clean_new_rmdir(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='clean')) self.patch_slaveVersionIsOlderThan(False) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', {'file': 'wkdir/.svn', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache' ]) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'status', '--xml', '--non-interactive', '--no-auth-cache']) + ExpectShell.log('stdio', stdout=self.svn_st_xml) + 0, Expect('rmdir', {'dir': ['wkdir/svn_external_path/unversioned_file1', 'wkdir/svn_external_path/unversioned_file2'], 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_copy(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='copy')) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, Expect('stat', dict(file='source/.svn', logEnviron=True)) + 0, ExpectShell(workdir='source', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache' ]) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='source', command=['svn', 'update', '--non-interactive', '--no-auth-cache']) + 0, Expect('cpdir', {'fromdir': 'source', 'todir': 'wkdir', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_copy_given_revision(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='copy'),dict( revision='100', )) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, Expect('stat', dict(file='source/.svn', logEnviron=True)) + 0, ExpectShell(workdir='source', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache' ]) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='source', command=['svn', 'update', '--revision', '100', '--non-interactive', '--no-auth-cache']) + 0, Expect('cpdir', {'fromdir': 'source', 'todir': 'wkdir', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_export(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='export')) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, Expect('stat', dict(file='source/.svn', logEnviron=True)) + 0, ExpectShell(workdir='source', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache' ]) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='source', command=['svn', 'update', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='', command=['svn', 'export', 'source', 'wkdir']) + 0, ExpectShell(workdir='source', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_export_timeout(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', timeout=1, mode='full', method='export')) self.expectCommands( ExpectShell(workdir='wkdir', timeout=1, command=['svn', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, Expect('stat', dict(file='source/.svn', logEnviron=True)) + 0, ExpectShell(workdir='source', timeout=1, command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache' ]) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='source', timeout=1, command=['svn', 'update', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='', timeout=1, command=['svn', 'export', 'source', 'wkdir']) + 0, ExpectShell(workdir='source', timeout=1, command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_full_export_given_revision(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='export'),dict( revision='100', )) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, Expect('stat', dict(file='source/.svn', logEnviron=True)) + 0, ExpectShell(workdir='source', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache' ]) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='source', command=['svn', 'update', '--revision', '100', '--non-interactive', '--no-auth-cache']) + 0, ExpectShell(workdir='', command=['svn', 'export', '--revision', '100', 'source', 'wkdir']) + 0, ExpectShell(workdir='source', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_incremental_with_env(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='incremental',username='user', password='pass', extra_args=['--random'], env={'abc': '123'})) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version'], env={'abc': '123'}) + 0, Expect('stat', dict(file='wkdir/.svn', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random'], env={'abc': '123'}) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random'], env={'abc': '123'}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml'], env={'abc': '123'}) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_mode_incremental_logEnviron(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='incremental',username='user', password='pass', extra_args=['--random'], logEnviron=False)) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version'], logEnviron=False) + 0, Expect('stat', dict(file='wkdir/.svn', logEnviron=False)) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random'], logEnviron=False) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random'], logEnviron=False) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml'], logEnviron=False) + ExpectShell.log('stdio', stdout=self.svn_info_stdout_xml) + 0, ) self.expectOutcome(result=SUCCESS, status_text=["update"]) self.expectProperty('got_revision', '100', 'SVN') return self.runStep() def test_command_fails(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='incremental',username='user', password='pass', extra_args=['--random'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', dict(file='wkdir/.svn', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random']) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random']) + 1, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) return self.runStep() def test_bogus_svnversion(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='incremental',username='user', password='pass', extra_args=['--random'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', dict(file='wkdir/.svn', logEnviron=True)) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random']) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'update', '--non-interactive', '--no-auth-cache', '--username', 'user', '--password', 'pass', '--random']) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml']) + ExpectShell.log('stdio', stdout='1x0y0') + 0, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) return self.runStep() def test_rmdir_fails_clobber(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='clobber')) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('rmdir', {'dir': 'wkdir', 'logEnviron': True}) + 1, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) return self.runStep() def test_rmdir_fails_copy(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='copy')) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 1, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) return self.runStep() def test_cpdir_fails_copy(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', method='copy')) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('rmdir', dict(dir='wkdir', logEnviron=True)) + 0, Expect('stat', dict(file='source/.svn', logEnviron=True)) + 0, ExpectShell(workdir='source', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache' ]) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='source', command=['svn', 'update', '--non-interactive', '--no-auth-cache']) + 0, Expect('cpdir', {'fromdir': 'source', 'todir': 'wkdir', 'logEnviron': True}) + 1, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) return self.runStep() def test_rmdir_fails_purge(self): self.setupStep( svn.SVN(repourl='http://svn.local/app/trunk', mode='full', keep_on_purge=['svn_external_path/unversioned_file1'])) self.expectCommands( ExpectShell(workdir='wkdir', command=['svn', '--version']) + 0, Expect('stat', {'file': 'wkdir/.svn', 'logEnviron': True}) + 0, ExpectShell(workdir='wkdir', command=['svn', 'info', '--xml', '--non-interactive', '--no-auth-cache' ]) + ExpectShell.log('stdio', stdout="""http://svn.local/app/trunk""") + 0, ExpectShell(workdir='wkdir', command=['svn', 'status', '--xml', '--no-ignore', '--non-interactive', '--no-auth-cache']) + ExpectShell.log('stdio', stdout=self.svn_st_xml) + 0, Expect('rmdir', {'dir': ['wkdir/svn_external_path/unversioned_file2'], 'logEnviron': True}) + 1, ) self.expectOutcome(result=FAILURE, status_text=["updating"]) return self.runStep() class TestGetUnversionedFiles(unittest.TestCase): def test_getUnversionedFiles_does_not_list_externals(self): svn_st_xml = """ """ unversioned_files = list(svn.SVN.getUnversionedFiles(svn_st_xml, [])) self.assertEquals(["svn_external_path/unversioned_file"], unversioned_files) def test_getUnversionedFiles_does_not_list_missing(self): svn_st_xml = """ """ unversioned_files = list(svn.SVN.getUnversionedFiles(svn_st_xml, [])) self.assertEquals([], unversioned_files) def test_getUnversionedFiles_corrupted_xml(self): svn_st_xml_corrupt = """ """ self.assertRaises(buildstep.BuildStepFailed, lambda : list(svn.SVN.getUnversionedFiles(svn_st_xml_corrupt, []))) def test_getUnversionedFiles_no_path(self): svn_st_xml = """ """ unversioned_files = list(svn.SVN.getUnversionedFiles(svn_st_xml, [])) self.assertEquals([], unversioned_files) def test_getUnversionedFiles_no_item(self): svn_st_xml = """ """ unversioned_files = list(svn.SVN.getUnversionedFiles(svn_st_xml, [])) self.assertEquals(["svn_external_path/unversioned_file"], unversioned_files) buildbot-0.8.8/buildbot/test/unit/test_steps_subunit.py000066400000000000000000000064001222546025000234130ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock import StringIO from zope.interface import implements from twisted.trial import unittest from buildbot import interfaces from buildbot.steps import subunit from buildbot.process import subunitlogobserver from buildbot.status.results import SUCCESS, FAILURE from buildbot.test.fake.remotecommand import ExpectShell from buildbot.test.util import steps class StubLogObserver(mock.Mock): implements(interfaces.ILogObserver) class TestSetPropertiesFromEnv(steps.BuildStepMixin, unittest.TestCase): def setUp(self): self.logobserver = StubLogObserver() self.logobserver.failures = [] self.logobserver.errors = [] self.logobserver.skips = [] self.logobserver.testsRun = 0 self.logobserver.warningio = StringIO.StringIO() self.patch(subunitlogobserver, 'SubunitLogObserver', lambda : self.logobserver) return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_empty(self): self.setupStep(subunit.SubunitShellCommand(command='test')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command="test") + 0 ) self.expectOutcome(result=SUCCESS, status_text=["shell", "no tests", "run"]) return self.runStep() def test_empty_error(self): self.setupStep(subunit.SubunitShellCommand(command='test', failureOnNoTests=True)) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command="test") + 0 ) self.expectOutcome(result=FAILURE, status_text=["shell", "no tests", "run"]) return self.runStep() def test_warnings(self): self.setupStep(subunit.SubunitShellCommand(command='test')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command="test") + 0 ) self.logobserver.warnings.append('not quite up to snuff (list)') self.logobserver.warningio.write('not quite up to snuff (io)\n') self.logobserver.testsRun = 3 self.expectOutcome(result=SUCCESS, # N.B. not WARNINGS status_text=["shell", "3 tests", "passed"]) # note that the warnings list is ignored.. self.expectLogfile('warnings', 'not quite up to snuff (io)\n') return self.runStep() # TODO: test text2 generation? # TODO: tests are represented as objects?! buildbot-0.8.8/buildbot/test/unit/test_steps_transfer.py000066400000000000000000000226251222546025000235550ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import tempfile, os import shutil import tarfile from twisted.trial import unittest from mock import Mock from buildbot.process.properties import Properties from buildbot.util import json from buildbot.steps import transfer from buildbot.status.results import SUCCESS from buildbot import config from buildbot.test.util import steps from buildbot.test.fake.remotecommand import Expect, ExpectRemoteRef class TestFileUpload(unittest.TestCase): def setUp(self): fd, self.destfile = tempfile.mkstemp() os.close(fd) os.unlink(self.destfile) def tearDown(self): if os.path.exists(self.destfile): os.unlink(self.destfile) def test_constructor_mode_type(self): self.assertRaises(config.ConfigErrors, lambda : transfer.FileUpload(slavesrc=__file__, masterdest='xyz', mode='g+rwx')) def testBasic(self): s = transfer.FileUpload(slavesrc=__file__, masterdest=self.destfile) s.build = Mock() s.build.getProperties.return_value = Properties() s.build.getSlaveCommandVersion.return_value = 1 s.step_status = Mock() s.buildslave = Mock() s.remote = Mock() s.start() for c in s.remote.method_calls: name, command, args = c commandName = command[3] kwargs = command[-1] if commandName == 'uploadFile': self.assertEquals(kwargs['slavesrc'], __file__) writer = kwargs['writer'] with open(__file__, "rb") as f: writer.remote_write(f.read()) self.assert_(not os.path.exists(self.destfile)) writer.remote_close() break else: self.assert_(False, "No uploadFile command found") with open(self.destfile, "rb") as dest: with open(__file__, "rb") as expect: self.assertEquals(dest.read(), expect.read()) def testTimestamp(self): s = transfer.FileUpload(slavesrc=__file__, masterdest=self.destfile, keepstamp=True) s.build = Mock() s.build.getProperties.return_value = Properties() s.build.getSlaveCommandVersion.return_value = "2.13" s.step_status = Mock() s.buildslave = Mock() s.remote = Mock() s.start() timestamp = ( os.path.getatime(__file__), os.path.getmtime(__file__) ) for c in s.remote.method_calls: name, command, args = c commandName = command[3] kwargs = command[-1] if commandName == 'uploadFile': self.assertEquals(kwargs['slavesrc'], __file__) writer = kwargs['writer'] with open(__file__, "rb") as f: writer.remote_write(f.read()) self.assert_(not os.path.exists(self.destfile)) writer.remote_close() writer.remote_utime(timestamp) break else: self.assert_(False, "No uploadFile command found") desttimestamp = ( os.path.getatime(self.destfile), os.path.getmtime(self.destfile) ) timestamp = map(int, timestamp) desttimestamp = map(int, desttimestamp) self.assertEquals(timestamp[0],desttimestamp[0]) self.assertEquals(timestamp[1],desttimestamp[1]) def testURL(self): s = transfer.FileUpload(slavesrc=__file__, masterdest=self.destfile, url="http://server/file") s.build = Mock() s.build.getProperties.return_value = Properties() s.build.getSlaveCommandVersion.return_value = "2.13" s.step_status = Mock() s.step_status.addURL = Mock() s.buildslave = Mock() s.remote = Mock() s.start() for c in s.remote.method_calls: name, command, args = c commandName = command[3] kwargs = command[-1] if commandName == 'uploadFile': self.assertEquals(kwargs['slavesrc'], __file__) writer = kwargs['writer'] with open(__file__, "rb") as f: writer.remote_write(f.read()) self.assert_(not os.path.exists(self.destfile)) writer.remote_close() break else: self.assert_(False, "No uploadFile command found") s.step_status.addURL.assert_called_once_with( os.path.basename(self.destfile), "http://server/file") class TestDirectoryUpload(steps.BuildStepMixin, unittest.TestCase): def setUp(self): self.destdir = os.path.abspath('destdir') if os.path.exists(self.destdir): shutil.rmtree(self.destdir) return self.setUpBuildStep() def tearDown(self): if os.path.exists(self.destdir): shutil.rmtree(self.destdir) return self.tearDownBuildStep() def testBasic(self): self.setupStep( transfer.DirectoryUpload(slavesrc="srcdir", masterdest=self.destdir)) def upload_behavior(command): from cStringIO import StringIO f = StringIO() archive = tarfile.TarFile(fileobj=f, name='fake.tar', mode='w') archive.addfile(tarfile.TarInfo("test"), StringIO("Hello World!")) writer = command.args['writer'] writer.remote_write(f.getvalue()) writer.remote_unpack() self.expectCommands( Expect('uploadDirectory', dict( slavesrc="srcdir", workdir='wkdir', blocksize=16384, compress=None, maxsize=None, writer=ExpectRemoteRef(transfer._DirectoryWriter))) + Expect.behavior(upload_behavior) + 0) self.expectOutcome(result=SUCCESS, status_text=["uploading", "srcdir"]) d = self.runStep() return d class TestStringDownload(unittest.TestCase): def testBasic(self): s = transfer.StringDownload("Hello World", "hello.txt") s.build = Mock() s.build.getProperties.return_value = Properties() s.build.getSlaveCommandVersion.return_value = 1 s.step_status = Mock() s.buildslave = Mock() s.remote = Mock() s.start() for c in s.remote.method_calls: name, command, args = c commandName = command[3] kwargs = command[-1] if commandName == 'downloadFile': self.assertEquals(kwargs['slavedest'], 'hello.txt') reader = kwargs['reader'] data = reader.remote_read(100) self.assertEquals(data, "Hello World") break else: self.assert_(False, "No downloadFile command found") class TestJSONStringDownload(unittest.TestCase): def testBasic(self): msg = dict(message="Hello World") s = transfer.JSONStringDownload(msg, "hello.json") s.build = Mock() s.build.getProperties.return_value = Properties() s.build.getSlaveCommandVersion.return_value = 1 s.step_status = Mock() s.buildslave = Mock() s.remote = Mock() s.start() for c in s.remote.method_calls: name, command, args = c commandName = command[3] kwargs = command[-1] if commandName == 'downloadFile': self.assertEquals(kwargs['slavedest'], 'hello.json') reader = kwargs['reader'] data = reader.remote_read(100) self.assertEquals(data, json.dumps(msg)) break else: self.assert_(False, "No downloadFile command found") class TestJSONPropertiesDownload(unittest.TestCase): def testBasic(self): s = transfer.JSONPropertiesDownload("props.json") s.build = Mock() props = Properties() props.setProperty('key1', 'value1', 'test') s.build.getProperties.return_value = props s.build.getSlaveCommandVersion.return_value = 1 ss = Mock() ss.asDict.return_value = dict(revision="12345") s.build.getSourceStamp.return_value = ss s.step_status = Mock() s.buildslave = Mock() s.remote = Mock() s.start() for c in s.remote.method_calls: name, command, args = c commandName = command[3] kwargs = command[-1] if commandName == 'downloadFile': self.assertEquals(kwargs['slavedest'], 'props.json') reader = kwargs['reader'] data = reader.remote_read(100) self.assertEquals(data, json.dumps(dict(sourcestamp=ss.asDict(), properties={'key1': 'value1'}))) break else: self.assert_(False, "No downloadFile command found") buildbot-0.8.8/buildbot/test/unit/test_steps_trigger.py000066400000000000000000000533241222546025000233740ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from twisted.trial import unittest from twisted.python import failure from twisted.internet import defer, reactor from buildbot import config, interfaces from buildbot.process import properties from buildbot.status import master from buildbot.status.results import SUCCESS, FAILURE, EXCEPTION from buildbot.steps import trigger from buildbot.test.util import steps, compat from buildbot.test.fake import fakemaster, fakedb class FakeTriggerable(object): implements(interfaces.ITriggerableScheduler) triggered_with = None result = SUCCESS brids = {} exception = False def __init__(self, name): self.name = name def trigger(self, sourcestamps = None, set_props=None): self.triggered_with = (sourcestamps, set_props.properties) d = defer.Deferred() if self.exception: reactor.callLater(0, d.errback, RuntimeError('oh noes')) else: reactor.callLater(0, d.callback, (self.result, self.brids)) return d class FakeSourceStamp(object): def __init__(self, **kwargs): self.__dict__.update(kwargs) def asDict(self, includePatch = True): return self.__dict__.copy() # Magic numbers that relate brid to other build settings BRID_TO_BSID = lambda brid: brid+2000 BRID_TO_BID = lambda brid: brid+3000 BRID_TO_BUILD_NUMBER = lambda brid: brid+4000 class TestTrigger(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def setupStep(self, step, sourcestampsInBuild=None, gotRevisionsInBuild=None, *args, **kwargs): sourcestamps = sourcestampsInBuild or [] got_revisions = gotRevisionsInBuild or {} steps.BuildStepMixin.setupStep(self, step, *args, **kwargs) # This step reaches deeply into a number of parts of Buildbot. That # should be fixed! # set up a buildmaster that knows about two fake schedulers, a and b m = fakemaster.make_master() self.build.builder.botmaster = m.botmaster m.db = fakedb.FakeDBConnector(self) m.status = master.Status(m) m.config.buildbotURL = "baseurl/" self.scheduler_a = a = FakeTriggerable(name='a') self.scheduler_b = b = FakeTriggerable(name='b') def allSchedulers(): return [ a, b ] m.allSchedulers = allSchedulers a.brids = {'A': 11} b.brids = {'B': 22} make_fake_br = lambda brid, name: fakedb.BuildRequest(id=brid, buildsetid=BRID_TO_BSID(brid), buildername=name) make_fake_build = lambda brid: fakedb.Build(brid=brid, id=BRID_TO_BID(brid), number=BRID_TO_BUILD_NUMBER(brid)) m.db.insertTestData([ make_fake_br(11, "A"), make_fake_br(22, "B"), make_fake_build(11), make_fake_build(22), ]) def getAllSourceStamps(): return sourcestamps self.build.getAllSourceStamps = getAllSourceStamps def getAllGotRevisions(): return got_revisions self.build.build_status.getAllGotRevisions = getAllGotRevisions self.exp_add_sourcestamp = None self.exp_a_trigger = None self.exp_b_trigger = None self.exp_added_urls = [] def runStep(self, expect_waitForFinish=False): d = steps.BuildStepMixin.runStep(self) if expect_waitForFinish: # the build doesn't finish until after a callLater, so this has the # effect of checking whether the deferred has been fired already; # it should not have been! early = [] d.addCallback(early.append) self.assertEqual(early, []) def check(_): self.assertEqual(self.scheduler_a.triggered_with, self.exp_a_trigger) self.assertEqual(self.scheduler_b.triggered_with, self.exp_b_trigger) self.assertEqual(self.step_status.addURL.call_args_list, self.exp_added_urls) if self.exp_add_sourcestamp: self.assertEqual(self.addSourceStamp_kwargs, self.exp_add_sourcestamp) d.addCallback(check) # pause runStep's completion until after any other callLater's are done def wait(_): d = defer.Deferred() reactor.callLater(0, d.callback, None) return d d.addCallback(wait) return d def expectTriggeredWith(self, a=None, b=None): self.exp_a_trigger = a self.exp_b_trigger = b def expectAddedSourceStamp(self, **kwargs): self.exp_add_sourcestamp = kwargs def expectTriggeredLinks(self, *args): def get_args(sch, name): label = lambda name, num: "%s #%d" % (name, num) url = lambda name, num: "baseurl/builders/%s/builds/%d" % (name, num ) num = BRID_TO_BUILD_NUMBER(sch.brids[name]) #returns the *args and **kwargs that will be called on addURL... # which is just addURL('label', 'url') return ( (label(name,num), url(name,num)) , {} ) if 'a' in args: self.exp_added_urls.append(get_args(self.scheduler_a, 'A')) if 'b' in args: self.exp_added_urls.append(get_args(self.scheduler_b, 'B')) # tests def test_no_schedulerNames(self): self.assertRaises(config.ConfigErrors, lambda : trigger.Trigger()) def test_sourceStamp_and_updateSourceStamp(self): self.assertRaises(config.ConfigErrors, lambda : trigger.Trigger(schedulerNames=['c'], sourceStamp=dict(x=1), updateSourceStamp=True)) def test_sourceStamps_and_updateSourceStamp(self): self.assertRaises(config.ConfigErrors, lambda : trigger.Trigger(schedulerNames=['c'], sourceStamps=[dict(x=1), dict(x=2)], updateSourceStamp=True)) def test_updateSourceStamp_and_alwaysUseLatest(self): self.assertRaises(config.ConfigErrors, lambda : trigger.Trigger(schedulerNames=['c'], updateSourceStamp=True, alwaysUseLatest=True)) def test_sourceStamp_and_alwaysUseLatest(self): self.assertRaises(config.ConfigErrors, lambda : trigger.Trigger(schedulerNames=['c'], sourceStamp=dict(x=1), alwaysUseLatest=True)) def test_sourceStamps_and_alwaysUseLatest(self): self.assertRaises(config.ConfigErrors, lambda : trigger.Trigger(schedulerNames=['c'], sourceStamps=[dict(x=1), dict(x=2)], alwaysUseLatest=True)) def test_simple(self): self.setupStep(trigger.Trigger(schedulerNames=['a'], sourceStamps = {})) self.expectOutcome(result=SUCCESS, status_text=['triggered', 'a']) self.expectTriggeredWith(a=({}, {})) return self.runStep() def test_simple_failure(self): self.setupStep(trigger.Trigger(schedulerNames=['a'])) self.scheduler_a.result = FAILURE # not waitForFinish, so trigger step succeeds even though the build # didn't fail self.expectOutcome(result=SUCCESS, status_text=['triggered', 'a']) self.expectTriggeredWith(a=({}, {})) return self.runStep() @compat.usesFlushLoggedErrors def test_simple_exception(self): self.setupStep(trigger.Trigger(schedulerNames=['a'])) self.scheduler_a.exception = True self.expectOutcome(result=SUCCESS, status_text=['triggered', 'a']) self.expectTriggeredWith(a=( {}, {})) d = self.runStep() def flush(_): self.assertEqual(len(self.flushLoggedErrors(RuntimeError)), 1) d.addCallback(flush) return d def test_bogus_scheduler(self): self.setupStep(trigger.Trigger(schedulerNames=['a', 'x'])) self.expectOutcome(result=FAILURE, status_text=['not valid scheduler:', 'x']) self.expectTriggeredWith(a=None) # a is not triggered! return self.runStep() def test_updateSourceStamp(self): self.setupStep(trigger.Trigger(schedulerNames=['a'], updateSourceStamp=True), sourcestampsInBuild = [FakeSourceStamp(codebase='', repository='x', revision=11111) ], gotRevisionsInBuild = {'': 23456}, ) self.expectOutcome(result=SUCCESS, status_text=['triggered', 'a']) self.expectTriggeredWith(a=({'':{'codebase':'', 'repository': 'x', 'revision': 23456} }, {})) return self.runStep() def test_updateSourceStamp_no_got_revision(self): self.setupStep(trigger.Trigger(schedulerNames=['a'], updateSourceStamp=True), sourcestampsInBuild = [FakeSourceStamp(codebase='', repository='x', revision=11111) ]) self.expectOutcome(result=SUCCESS, status_text=['triggered', 'a']) self.expectTriggeredWith(a=({'':{'codebase':'', 'repository': 'x', 'revision': 11111} # uses old revision }, {})) return self.runStep() def test_not_updateSourceStamp(self): self.setupStep(trigger.Trigger(schedulerNames=['a'], updateSourceStamp=False), sourcestampsInBuild = [FakeSourceStamp(codebase='', repository='x', revision=11111) ], gotRevisionsInBuild = {'': 23456}, ) self.expectOutcome(result=SUCCESS, status_text=['triggered', 'a']) self.expectTriggeredWith(a=({'':{'codebase':'', 'repository': 'x', 'revision': 11111} }, {})) return self.runStep() def test_updateSourceStamp_multiple_repositories(self): self.setupStep(trigger.Trigger(schedulerNames=['a'], updateSourceStamp=True), sourcestampsInBuild = [ FakeSourceStamp(codebase='cb1', revision='12345'), FakeSourceStamp(codebase='cb2', revision='12345') ], gotRevisionsInBuild = {'cb1': 23456, 'cb2': 34567}, ) self.expectOutcome(result=SUCCESS, status_text=['triggered', 'a']) self.expectTriggeredWith(a=({'cb1': {'codebase':'cb1', 'revision':23456}, 'cb2': {'codebase':'cb2', 'revision':34567} }, {})) return self.runStep() def test_updateSourceStamp_prop_false(self): self.setupStep(trigger.Trigger(schedulerNames=['a'], updateSourceStamp=properties.Property('usess')), sourcestampsInBuild = [FakeSourceStamp(codebase='', repository='x', revision=11111) ], gotRevisionsInBuild = {'': 23456}, ) self.properties.setProperty('usess', False, 'me') self.expectOutcome(result=SUCCESS, status_text=['triggered', 'a']) # didn't use got_revision self.expectTriggeredWith(a=({'': { 'codebase':'', 'repository': 'x', 'revision': 11111 }}, {})) return self.runStep() def test_updateSourceStamp_prop_true(self): self.setupStep(trigger.Trigger(schedulerNames=['a'], updateSourceStamp=properties.Property('usess')), sourcestampsInBuild = [FakeSourceStamp(codebase='', repository='x', revision=11111) ], gotRevisionsInBuild = {'': 23456}, ) self.properties.setProperty('usess', True, 'me') self.expectOutcome(result=SUCCESS, status_text=['triggered', 'a']) # didn't use got_revision self.expectTriggeredWith(a=({'': { 'codebase':'', 'repository': 'x', 'revision': 23456 }}, {})) return self.runStep() def test_alwaysUseLatest(self): self.setupStep(trigger.Trigger(schedulerNames=['b'], alwaysUseLatest=True), sourcestampsInBuild = [FakeSourceStamp(codebase='', repository='x', revision=11111) ]) self.expectOutcome(result=SUCCESS, status_text=['triggered', 'b']) # Do not pass setid self.expectTriggeredWith(b=({}, {})) return self.runStep() def test_alwaysUseLatest_prop_false(self): self.setupStep(trigger.Trigger(schedulerNames=['b'], alwaysUseLatest=properties.Property('aul')), sourcestampsInBuild = [FakeSourceStamp(codebase='', repository='x', revision=11111) ]) self.properties.setProperty('aul', False, 'me') self.expectOutcome(result=SUCCESS, status_text=['triggered', 'b']) # didn't use latest self.expectTriggeredWith(b=({'': { 'codebase':'', 'repository': 'x', 'revision': 11111} }, {})) return self.runStep() def test_alwaysUseLatest_prop_true(self): self.setupStep(trigger.Trigger(schedulerNames=['b'], alwaysUseLatest=properties.Property('aul')), sourcestampsInBuild = [FakeSourceStamp(codebase='', repository='x', revision=11111) ]) self.properties.setProperty('aul', True, 'me') self.expectOutcome(result=SUCCESS, status_text=['triggered', 'b']) # didn't use latest self.expectTriggeredWith(b=({}, {})) return self.runStep() def test_sourceStamp(self): ss = dict(revision=9876, branch='dev') self.setupStep(trigger.Trigger(schedulerNames=['b'], sourceStamp=ss)) self.expectOutcome(result=SUCCESS, status_text=['triggered', 'b']) self.expectTriggeredWith(b=({'': ss}, {})) return self.runStep() def test_set_of_sourceStamps(self): ss1 = dict(codebase='cb1', repository='r1', revision=9876, branch='dev') ss2 = dict(codebase='cb2',repository='r2', revision=5432, branch='dev') self.setupStep(trigger.Trigger(schedulerNames=['b'], sourceStamps=[ss1,ss2])) self.expectOutcome(result=SUCCESS, status_text=['triggered', 'b']) self.expectTriggeredWith(b=({'cb1':ss1, 'cb2':ss2}, {})) return self.runStep() def test_set_of_sourceStamps_override_build(self): ss1 = dict(codebase='cb1', repository='r1', revision=9876, branch='dev') ss2 = dict(codebase='cb2',repository='r2', revision=5432, branch='dev') ss3 = FakeSourceStamp(codebase='cb3', repository='r3', revision=1234, branch='dev') ss4 = FakeSourceStamp(codebase='cb4',repository='r4', revision=2345, branch='dev') self.setupStep(trigger.Trigger(schedulerNames=['b'], sourceStamps=[ss1,ss2]), sourcestampsInBuild=[ss3, ss4]) self.expectOutcome(result=SUCCESS, status_text=['triggered', 'b']) self.expectTriggeredWith(b=({'cb1':ss1, 'cb2':ss2}, {})) return self.runStep() def test_sourceStamp_prop(self): self.setupStep(trigger.Trigger(schedulerNames=['b'], sourceStamp=dict(revision=properties.Property('rev'), branch='dev'))) self.properties.setProperty('rev', 602, 'me') expected_ss = dict(revision=602, branch='dev') self.expectOutcome(result=SUCCESS, status_text=['triggered', 'b']) self.expectTriggeredWith(b=({'': expected_ss}, {})) return self.runStep() def test_waitForFinish(self): self.setupStep(trigger.Trigger(schedulerNames=['a', 'b'], waitForFinish=True)) self.expectOutcome(result=SUCCESS, status_text=['triggered', 'a', 'b']) self.expectTriggeredWith( a=({}, {}), b=({}, {})) self.expectTriggeredLinks('a','b') return self.runStep(expect_waitForFinish=True) def test_waitForFinish_failure(self): self.setupStep(trigger.Trigger(schedulerNames=['a'], waitForFinish=True)) self.scheduler_a.result = FAILURE self.expectOutcome(result=FAILURE, status_text=['triggered', 'a']) self.expectTriggeredWith(a=({}, {})) self.expectTriggeredLinks('a') return self.runStep(expect_waitForFinish=True) @compat.usesFlushLoggedErrors def test_waitForFinish_exception(self): self.setupStep(trigger.Trigger(schedulerNames=['a', 'b'], waitForFinish=True)) self.scheduler_b.exception = True self.expectOutcome(result=EXCEPTION, status_text=['triggered', 'a', 'b']) self.expectTriggeredWith( a=({}, {}), b=({}, {})) self.expectTriggeredLinks('a') # b doesn't return a brid d = self.runStep(expect_waitForFinish=True) def flush(_): self.assertEqual(len(self.flushLoggedErrors(RuntimeError)), 1) d.addCallback(flush) return d def test_set_properties(self): self.setupStep(trigger.Trigger(schedulerNames=['a'], set_properties=dict(x=1, y=2))) self.expectOutcome(result=SUCCESS, status_text=['triggered', 'a']) self.expectTriggeredWith(a=({}, dict(x=(1, 'Trigger'), y=(2, 'Trigger')))) return self.runStep() def test_set_properties_prop(self): self.setupStep(trigger.Trigger(schedulerNames=['a'], set_properties=dict(x=properties.Property('X'), y=2))) self.properties.setProperty('X', 'xxx', 'here') self.expectOutcome(result=SUCCESS, status_text=['triggered', 'a']) self.expectTriggeredWith(a=({}, dict(x=('xxx', 'Trigger'), y=(2, 'Trigger')))) return self.runStep() def test_copy_properties(self): self.setupStep(trigger.Trigger(schedulerNames=['a'], copy_properties=['a', 'b'])) self.properties.setProperty('a', 'A', 'AA') self.properties.setProperty('b', 'B', 'BB') self.properties.setProperty('c', 'C', 'CC') self.expectOutcome(result=SUCCESS, status_text=['triggered', 'a']) self.expectTriggeredWith(a=({}, dict(a=('A', 'Trigger'), b=('B', 'Trigger')))) return self.runStep() def test_interrupt(self): self.setupStep(trigger.Trigger(schedulerNames=['a'], waitForFinish=True)) self.expectOutcome(result=EXCEPTION, status_text=['interrupted']) self.expectTriggeredWith(a=({}, {})) d = self.runStep(expect_waitForFinish=True) # interrupt before the callLater representing the Triggerable # schedulers completes self.step.interrupt(failure.Failure(RuntimeError('oh noes'))) return d buildbot-0.8.8/buildbot/test/unit/test_steps_vstudio.py000066400000000000000000000763741222546025000234400ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.status.results import SUCCESS, FAILURE, WARNINGS from buildbot.steps import vstudio from buildbot.test.fake.remotecommand import ExpectShell from buildbot.test.util import steps from twisted.trial import unittest from buildbot.process.properties import Property from mock import Mock real_log = r""" 1>------ Build started: Project: lib1, Configuration: debug Win32 ------ 1>Compiling... 1>SystemLog.cpp 1>c:\absolute\path\to\systemlog.cpp(7) : warning C4100: 'op' : unreferenced formal parameter 1>c:\absolute\path\to\systemlog.cpp(12) : warning C4100: 'statusword' : unreferenced formal parameter 1>c:\absolute\path\to\systemlog.cpp(12) : warning C4100: 'op' : unreferenced formal parameter 1>c:\absolute\path\to\systemlog.cpp(17) : warning C4100: 'retryCounter' : unreferenced formal parameter 1>c:\absolute\path\to\systemlog.cpp(17) : warning C4100: 'op' : unreferenced formal parameter 1>c:\absolute\path\to\systemlog.cpp(22) : warning C4100: 'op' : unreferenced formal parameter 1>Creating library... 1>Build log was saved at "file://c:\another\absolute\path\to\debug\BuildLog.htm" 1>lib1 - 0 error(s), 6 warning(s) 2>------ Build started: Project: product, Configuration: debug Win32 ------ 2>Linking... 2>LINK : fatal error LNK1168: cannot open ../../debug/directory/dllname.dll for writing 2>Build log was saved at "file://c:\another\similar\path\to\debug\BuildLog.htm" 2>product - 1 error(s), 0 warning(s) ========== Build: 1 succeeded, 1 failed, 6 up-to-date, 0 skipped ========== """ class TestAddEnvPath(unittest.TestCase): def do_test(self, initial_env, name, value, expected_env): vstudio.addEnvPath(initial_env, name, value) self.assertEqual(initial_env, expected_env) def test_new(self): self.do_test({}, 'PATH', r'C:\NOTHING', { 'PATH' : r'C:\NOTHING;' }) def test_new_semi(self): self.do_test({}, 'PATH', r'C:\NOTHING;', { 'PATH' : r'C:\NOTHING;' }) def test_existing(self): self.do_test({'PATH' : '/bin' }, 'PATH', r'C:\NOTHING', { 'PATH' : r'/bin;C:\NOTHING;' }) def test_existing_semi(self): self.do_test({'PATH' : '/bin;' }, 'PATH', r'C:\NOTHING', { 'PATH' : r'/bin;C:\NOTHING;' }) def test_existing_both_semi(self): self.do_test({'PATH' : '/bin;' }, 'PATH', r'C:\NOTHING;', { 'PATH' : r'/bin;C:\NOTHING;' }) class MSLogLineObserver(unittest.TestCase): def setUp(self): self.warnings = [] lw = Mock() lw.addStdout = lambda l : self.warnings.append(l.rstrip()) self.errors = [] self.errors_stderr = [] le = Mock() le.addStdout = lambda l : self.errors.append(('o', l.rstrip())) le.addStderr = lambda l : self.errors.append(('e', l.rstrip())) self.llo = vstudio.MSLogLineObserver(lw, le) self.progress = {} self.llo.step = Mock() self.llo.step.setProgress = \ lambda n,prog : self.progress.__setitem__(n, prog) def receiveLines(self, *lines): for line in lines: self.llo.outLineReceived(line) def assertResult(self, nbFiles=0, nbProjects=0, nbWarnings=0, nbErrors=0, errors=[], warnings=[], progress={}): self.assertEqual( dict(nbFiles=self.llo.nbFiles, nbProjects=self.llo.nbProjects, nbWarnings=self.llo.nbWarnings, nbErrors=self.llo.nbErrors, errors=self.errors, warnings=self.warnings, progress=self.progress), dict(nbFiles=nbFiles, nbProjects=nbProjects, nbWarnings=nbWarnings, nbErrors=nbErrors, errors=errors, warnings=warnings, progress=progress)) def test_outLineReceived_empty(self): self.llo.outLineReceived('abcd\r\n') self.assertResult() def test_outLineReceived_projects(self): lines = [ "123>----- some project 1 -----", "123>----- some project 2 -----", ] self.receiveLines(*lines) self.assertResult(nbProjects=2, progress=dict(projects=2), errors=[ ('o', l) for l in lines ], warnings=lines) def test_outLineReceived_files(self): lines = [ "123>SomeClass.cpp", "123>SomeStuff.c", "123>SomeStuff.h", # .h files not recognized ] self.receiveLines(*lines) self.assertResult(nbFiles=2, progress=dict(files=2)) def test_outLineReceived_warnings(self): lines = [ "abc: warning ABC123: xyz!", "def : warning DEF456: wxy!", ] self.receiveLines(*lines) self.assertResult(nbWarnings=2, progress=dict(warnings=2), warnings=lines) def test_outLineReceived_errors(self): lines = [ "error ABC123: foo", " error DEF456 : bar", " error : bar", " error: bar", # NOTE: not matched ] self.receiveLines(*lines) self.assertResult(nbErrors=3, # note: no progress errors=[ ('e', "error ABC123: foo"), ('e', " error DEF456 : bar"), ('e', " error : bar"), ]) def test_outLineReceived_real(self): # based on a real logfile donated by Ben Allard lines = real_log.split("\n") self.receiveLines(*lines) errors = [ ('o', '1>------ Build started: Project: lib1, Configuration: debug Win32 ------'), ('o', '2>------ Build started: Project: product, Configuration: debug Win32 ------'), ('e', '2>LINK : fatal error LNK1168: cannot open ../../debug/directory/dllname.dll for writing') ] warnings = [ '1>------ Build started: Project: lib1, Configuration: debug Win32 ------', "1>c:\\absolute\\path\\to\\systemlog.cpp(7) : warning C4100: 'op' : unreferenced formal parameter", "1>c:\\absolute\\path\\to\\systemlog.cpp(12) : warning C4100: 'statusword' : unreferenced formal parameter", "1>c:\\absolute\\path\\to\\systemlog.cpp(12) : warning C4100: 'op' : unreferenced formal parameter", "1>c:\\absolute\\path\\to\\systemlog.cpp(17) : warning C4100: 'retryCounter' : unreferenced formal parameter", "1>c:\\absolute\\path\\to\\systemlog.cpp(17) : warning C4100: 'op' : unreferenced formal parameter", "1>c:\\absolute\\path\\to\\systemlog.cpp(22) : warning C4100: 'op' : unreferenced formal parameter", '2>------ Build started: Project: product, Configuration: debug Win32 ------', ] self.assertResult(nbFiles=1, nbErrors=1, nbProjects=2, nbWarnings=6, progress={'files': 1, 'projects': 2, 'warnings': 6}, errors=errors, warnings=warnings) class VCx(vstudio.VisualStudio): def start(self): command = ["command", "here"] self.setCommand(command) return vstudio.VisualStudio.start(self) class VisualStudio(steps.BuildStepMixin, unittest.TestCase): """ Test L{VisualStudio} with a simple subclass, L{VCx}. """ def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_default_config(self): vs = vstudio.VisualStudio() self.assertEqual(vs.config, 'release') def test_simple(self): self.setupStep(VCx()) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['command', 'here']) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() def test_installdir(self): self.setupStep(VCx(installdir=r'C:\I')) self.step.exp_installdir = r'C:\I' self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['command', 'here']) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) d = self.runStep() def check_installdir(_): self.assertEqual(self.step.installdir, r'C:\I') d.addCallback(check_installdir) return d def test_evaluateCommand_failure(self): self.setupStep(VCx()) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['command', 'here']) + 1 ) self.expectOutcome(result=FAILURE, status_text=["compile", "0 projects", "0 files", "failed"]) return self.runStep() def test_evaluateCommand_errors(self): self.setupStep(VCx()) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['command', 'here']) + ExpectShell.log('stdio', stdout='error ABC123: foo\r\n') + 0 ) self.expectOutcome(result=FAILURE, status_text=["compile", "0 projects", "0 files", "1 errors", "failed"]) return self.runStep() def test_evaluateCommand_warnings(self): self.setupStep(VCx()) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['command', 'here']) + ExpectShell.log('stdio', stdout='foo: warning ABC123: foo\r\n') + 0 ) self.expectOutcome(result=WARNINGS, status_text=["compile", "0 projects", "0 files", "1 warnings", "warnings"]) return self.runStep() def test_env_setup(self): self.setupStep(VCx( INCLUDE=[ r'c:\INC1', r'c:\INC2' ], LIB=[ r'c:\LIB1', r'C:\LIB2' ], PATH=[ r'c:\P1', r'C:\P2' ])) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['command', 'here'], env=dict( INCLUDE=r'c:\INC1;c:\INC2;', LIB=r'c:\LIB1;C:\LIB2;', PATH=r'c:\P1;C:\P2;')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() def test_env_setup_existing(self): self.setupStep(VCx( INCLUDE=[ r'c:\INC1', r'c:\INC2' ], LIB=[ r'c:\LIB1', r'C:\LIB2' ], PATH=[ r'c:\P1', r'C:\P2' ])) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['command', 'here'], env=dict( INCLUDE=r'c:\INC1;c:\INC2;', LIB=r'c:\LIB1;C:\LIB2;', PATH=r'c:\P1;C:\P2;')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() def test_rendering(self): self.setupStep(VCx( projectfile=Property('a'), config=Property('b'), project=Property('c'))) self.properties.setProperty('a', 'aa', 'Test') self.properties.setProperty('b', 'bb', 'Test') self.properties.setProperty('c', 'cc', 'Test') self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['command', 'here']) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) d = self.runStep() def check_props(_): self.assertEqual( [ self.step.projectfile, self.step.config, self.step.project ], [ 'aa', 'bb', 'cc' ]) d.addCallback(check_props) return d class TestVC6(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def getExpectedEnv(self, installdir, l=None, p=None, i=None): include = [ installdir + r'\VC98\INCLUDE;', installdir + r'\VC98\ATL\INCLUDE;', installdir + r'\VC98\MFC\INCLUDE;', ] lib = [ installdir + r'\VC98\LIB;', installdir + r'\VC98\MFC\LIB;', ] path = [ installdir + r'\Common\msdev98\BIN;', installdir + r'\VC98\BIN;', installdir + r'\Common\TOOLS\WINNT;', installdir + r'\Common\TOOLS;', ] if p: path.insert(0, '%s;' % p) if i: include.insert(0, '%s;' % i) if l: lib.insert(0, '%s;' % l) return dict( INCLUDE = ''.join(include), LIB = ''.join(lib), PATH = ''.join(path), ) def test_args(self): self.setupStep(vstudio.VC6(projectfile='pf', config='cfg', project='pj')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['msdev', 'pf', '/MAKE', 'pj - cfg', '/REBUILD'], env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() def test_clean(self): self.setupStep(vstudio.VC6(projectfile='pf', config='cfg', project='pj', mode='clean')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['msdev', 'pf', '/MAKE', 'pj - cfg', '/CLEAN'], env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() def test_noproj_build(self): self.setupStep(vstudio.VC6(projectfile='pf', config='cfg', mode='build')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['msdev', 'pf', '/MAKE', 'ALL - cfg', '/BUILD'], env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() def test_env_prepend(self): self.setupStep(vstudio.VC6(projectfile='pf', config='cfg', project='pj', PATH=['p'], INCLUDE=['i'], LIB=['l'])) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['msdev', 'pf', '/MAKE', 'pj - cfg', '/REBUILD', '/USEENV'], # note extra param env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio', l='l', p='p', i='i')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() class TestVC7(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def getExpectedEnv(self, installdir, l=None, p=None, i=None): include = [ installdir + r'\VC7\INCLUDE;', installdir + r'\VC7\ATLMFC\INCLUDE;', installdir + r'\VC7\PlatformSDK\include;', installdir + r'\SDK\v1.1\include;', ] lib = [ installdir + r'\VC7\LIB;', installdir + r'\VC7\ATLMFC\LIB;', installdir + r'\VC7\PlatformSDK\lib;', installdir + r'\SDK\v1.1\lib;', ] path = [ installdir + r'\Common7\IDE;', installdir + r'\VC7\BIN;', installdir + r'\Common7\Tools;', installdir + r'\Common7\Tools\bin;', ] if p: path.insert(0, '%s;' % p) if i: include.insert(0, '%s;' % i) if l: lib.insert(0, '%s;' % l) return dict( INCLUDE = ''.join(include), LIB = ''.join(lib), PATH = ''.join(path), ) def test_args(self): self.setupStep(vstudio.VC7(projectfile='pf', config='cfg', project='pj')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['devenv.com', 'pf', '/Rebuild', 'cfg', '/Project', 'pj'], env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio .NET 2003')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() def test_clean(self): self.setupStep(vstudio.VC7(projectfile='pf', config='cfg', project='pj', mode='clean')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['devenv.com', 'pf', '/Clean', 'cfg', '/Project', 'pj'], env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio .NET 2003')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() def test_noproj_build(self): self.setupStep(vstudio.VC7(projectfile='pf', config='cfg', mode='build')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['devenv.com', 'pf', '/Build', 'cfg'], env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio .NET 2003')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() def test_env_prepend(self): self.setupStep(vstudio.VC7(projectfile='pf', config='cfg', project='pj', PATH=['p'], INCLUDE=['i'], LIB=['l'])) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['devenv.com', 'pf', '/Rebuild', 'cfg', '/UseEnv', '/Project', 'pj' ], env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio .NET 2003', l='l', p='p', i='i')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() class VC8ExpectedEnvMixin(object): # used for VC8 and VC9Express def getExpectedEnv(self, installdir, x64=False, l=None, i=None, p=None): include = [ installdir + r'\VC\INCLUDE;', installdir + r'\VC\ATLMFC\include;', installdir + r'\VC\PlatformSDK\include;', ] lib = [ installdir + r'\VC\LIB;', installdir + r'\VC\ATLMFC\LIB;', installdir + r'\VC\PlatformSDK\lib;', installdir + r'\SDK\v2.0\lib;', ] path = [ installdir + r'\Common7\IDE;', installdir + r'\VC\BIN;', installdir + r'\Common7\Tools;', installdir + r'\Common7\Tools\bin;', installdir + r'\VC\PlatformSDK\bin;', installdir + r'\SDK\v2.0\bin;', installdir + r'\VC\VCPackages;', r'${PATH};', ] if x64: path.insert(1, installdir + r'\VC\BIN\x86_amd64;') lib = [ lb[:-1] + r'\amd64;' for lb in lib ] if l: lib.insert(0, '%s;' % l) if p: path.insert(0, '%s;' % p) if i: include.insert(0, '%s;' % i) return dict( INCLUDE = ''.join(include), LIB = ''.join(lib), PATH = ''.join(path), ) class TestVC8(VC8ExpectedEnvMixin, steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_args(self): self.setupStep(vstudio.VC8(projectfile='pf', config='cfg', project='pj', arch='arch')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['devenv.com', 'pf', '/Rebuild', 'cfg', '/Project', 'pj' ], env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio 8')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() def test_args_x64(self): self.setupStep(vstudio.VC8(projectfile='pf', config='cfg', project='pj', arch='x64')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['devenv.com', 'pf', '/Rebuild', 'cfg', '/Project', 'pj' ], env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio 8', x64=True)) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() def test_clean(self): self.setupStep(vstudio.VC8(projectfile='pf', config='cfg', project='pj', mode='clean')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['devenv.com', 'pf', '/Clean', 'cfg', '/Project', 'pj' ], env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio 8')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() def test_rendering(self): self.setupStep(vstudio.VC8(projectfile='pf', config='cfg', arch=Property('a'))) self.properties.setProperty('a', 'x64', 'Test') self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['devenv.com', 'pf', '/Rebuild', 'cfg'], env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio 8', x64=True)) # property has expected effect + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) d = self.runStep() def check_props(_): self.assertEqual(self.step.arch, 'x64') d.addCallback(check_props) return d class TestVCExpress9(VC8ExpectedEnvMixin, steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_args(self): self.setupStep(vstudio.VCExpress9(projectfile='pf', config='cfg', project='pj')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['vcexpress', 'pf', '/Rebuild', 'cfg', '/Project', 'pj' ], env=self.getExpectedEnv( # note: still uses version 8 (?!) r'C:\Program Files\Microsoft Visual Studio 8')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() def test_clean(self): self.setupStep(vstudio.VCExpress9(projectfile='pf', config='cfg', project='pj', mode='clean')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['vcexpress', 'pf', '/Clean', 'cfg', '/Project', 'pj' ], env=self.getExpectedEnv( # note: still uses version 8 (?!) r'C:\Program Files\Microsoft Visual Studio 8')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() def test_mode_build_env(self): self.setupStep(vstudio.VCExpress9(projectfile='pf', config='cfg', project='pj', mode='build', INCLUDE=['i'])) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['vcexpress', 'pf', '/Build', 'cfg', '/UseEnv', '/Project', 'pj' ], env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio 8', i='i')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() class TestVC9(VC8ExpectedEnvMixin, steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_installdir(self): self.setupStep(vstudio.VC9(projectfile='pf', config='cfg', project='pj')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['devenv.com', 'pf', '/Rebuild', 'cfg', '/Project', 'pj' ], env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio 9.0')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() class TestVC10(VC8ExpectedEnvMixin, steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_installdir(self): self.setupStep(vstudio.VC10(projectfile='pf', config='cfg', project='pj')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['devenv.com', 'pf', '/Rebuild', 'cfg', '/Project', 'pj' ], env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio 10.0')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() class TestVC11(VC8ExpectedEnvMixin, steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_installdir(self): self.setupStep(vstudio.VC11(projectfile='pf', config='cfg', project='pj')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['devenv.com', 'pf', '/Rebuild', 'cfg', '/Project', 'pj' ], env=self.getExpectedEnv( r'C:\Program Files\Microsoft Visual Studio 11.0')) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["compile", "0 projects", "0 files"]) return self.runStep() class TestMsBuild(steps.BuildStepMixin, unittest.TestCase): def setUp(self): return self.setUpBuildStep() def tearDown(self): return self.tearDownBuildStep() def test_build_project(self): self.setupStep(vstudio.MsBuild(projectfile='pf', config='cfg', platform='Win32', project='pj')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['%VCENV_BAT%', 'x86', '&&', 'msbuild', 'pf', '/p:Configuration=cfg', '/p:Platform=Win32', '/t:pj'], env={'VCENV_BAT': '"${VS110COMNTOOLS}..\\..\\VC\\vcvarsall.bat"'}) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["built", "pj for", 'cfg|Win32']) return self.runStep() def test_build_solution(self): self.setupStep(vstudio.MsBuild(projectfile='pf', config='cfg', platform='x64')) self.expectCommands( ExpectShell(workdir='wkdir', usePTY='slave-config', command=['%VCENV_BAT%', 'x86', '&&', 'msbuild', 'pf', '/p:Configuration=cfg', '/p:Platform=x64'], env={'VCENV_BAT': '"${VS110COMNTOOLS}..\\..\\VC\\vcvarsall.bat"'}) + 0 ) self.expectOutcome(result=SUCCESS, status_text=["built", "solution for", 'cfg|x64']) return self.runStep() class Aliases(unittest.TestCase): def test_vs2003(self): self.assertIdentical(vstudio.VS2003, vstudio.VC7) def test_vs2005(self): self.assertIdentical(vstudio.VS2005, vstudio.VC8) def test_vs2008(self): self.assertIdentical(vstudio.VS2008, vstudio.VC9) def test_vs2010(self): self.assertIdentical(vstudio.VS2010, vstudio.VC10) def test_vs2012(self): self.assertIdentical(vstudio.VS2012, vstudio.VC11) buildbot-0.8.8/buildbot/test/unit/test_test_util_gpo.py000066400000000000000000000331151222546025000233700ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sys import twisted from twisted.trial import reporter, unittest from twisted.internet import utils from buildbot.test.util.gpo import GetProcessOutputMixin, Expect class TestGPOMixin(unittest.TestCase): # these tests use self.patch, but the SkipTest exception gets eaten, so # explicitly skip things here. if twisted.version.major <= 9 and sys.version_info[:2] == (2,7): skip = "unittest.TestCase.patch is not available" def runTestMethod(self, method): class TestCase(GetProcessOutputMixin, unittest.TestCase): def setUp(self): self.setUpGetProcessOutput() def runTest(self): return method(self) self.testcase = TestCase() result = reporter.TestResult() self.testcase.run(result) # This blocks return result def assertTestFailure(self, result, expectedFailure): self.assertEqual(result.errors, []) self.assertEqual(len(result.failures), 1) self.failUnless(result.failures[0][1].check(unittest.FailTest)) if expectedFailure: self.assertSubstring(expectedFailure, result.failures[0][1].getErrorMessage()) def assertSuccessful(self, result): if not result.wasSuccessful(): output = 'expected success' if result.failures: output += ('\ntest failed: %s' % result.failures[0][1].getErrorMessage()) if result.errors: output += ('\nerrors: %s' % map(lambda x: x[1].value, result.errors)) raise self.failureException(output) self.failUnless(result.wasSuccessful()) def test_patch(self): original_getProcessOutput = utils.getProcessOutput original_getProcessOutputAndValue = utils.getProcessOutputAndValue def method(testcase): testcase.expectCommands() self.assertEqual(utils.getProcessOutput, testcase.patched_getProcessOutput) self.assertEqual(utils.getProcessOutputAndValue, testcase.patched_getProcessOutputAndValue) result = self.runTestMethod(method) self.assertSuccessful(result) self.assertEqual(utils.getProcessOutput, original_getProcessOutput) self.assertEqual(utils.getProcessOutputAndValue, original_getProcessOutputAndValue) def test_methodChaining(self): expect = Expect('command') self.assertEqual(expect, expect.exit(0)) self.assertEqual(expect, expect.stdout("output")) self.assertEqual(expect, expect.stderr("error")) def test_gpo_oneCommand(self): def method(testcase): testcase.expectCommands(Expect("command")) d = utils.getProcessOutput("command", ()) d.addCallback(self.assertEqual, '') d.addCallback(lambda _: testcase.assertAllCommandsRan()) return d result = self.runTestMethod(method) self.assertSuccessful(result) def test_gpo_expectTwo_runOne(self): def method(testcase): testcase.expectCommands(Expect("command")) testcase.expectCommands(Expect("command2")) d = utils.getProcessOutput("command", ()) d.addCallback(self.assertEqual, '') d.addCallback(lambda _: testcase.assertAllCommandsRan()) return d result = self.runTestMethod(method) self.assertTestFailure(result, "assert all expected commands were run") def test_gpo_wrongCommand(self): def method(testcase): testcase.expectCommands(Expect("command2")) d = utils.getProcessOutput("command", ()) return d result = self.runTestMethod(method) self.assertTestFailure(result, "unexpected command run") def test_gpo_wrongArgs(self): def method(testcase): testcase.expectCommands(Expect("command", "arg")) d = utils.getProcessOutput("command", ("otherarg",)) d.addCallback(lambda _: testcase.assertAllCommandsRan()) return d result = self.runTestMethod(method) self.assertTestFailure(result, "unexpected command run") def test_gpo_missingPath(self): def method(testcase): testcase.expectCommands(Expect("command", "arg").path("/home")) d = utils.getProcessOutput("command", ("otherarg",)) d.addCallback(lambda _: testcase.assertAllCommandsRan()) return d result = self.runTestMethod(method) self.assertTestFailure(result, "unexpected command run") def test_gpo_wrongPath(self): def method(testcase): testcase.expectCommands(Expect("command", "arg").path("/home")) d = utils.getProcessOutput("command", ("otherarg",), path="/work") d.addCallback(lambda _: testcase.assertAllCommandsRan()) return d result = self.runTestMethod(method) self.assertTestFailure(result, "unexpected command run") def test_gpo_notCurrentPath(self): def method(testcase): testcase.expectCommands(Expect("command", "arg")) d = utils.getProcessOutput("command", ("otherarg",), path="/work") d.addCallback(lambda _: testcase.assertAllCommandsRan()) return d result = self.runTestMethod(method) self.assertTestFailure(result, "unexpected command run") def test_gpo_errorOutput(self): def method(testcase): testcase.expectCommands(Expect("command").stderr("some test")) d = testcase.assertFailure(utils.getProcessOutput("command", ()), [IOError]) return d result = self.runTestMethod(method) self.assertTestFailure(result, "got stderr: 'some test'") def test_gpo_errorOutput_errtoo(self): def method(testcase): testcase.expectCommands(Expect("command").stderr("some test")) d = utils.getProcessOutput("command", (), errortoo=True) d.addCallback(testcase.assertEqual, "some test") return d result = self.runTestMethod(method) self.assertSuccessful(result) def test_gpo_exitIgnored(self): def method(testcase): testcase.expectCommands(Expect("command").exit(1)) d = utils.getProcessOutput("command", ()) d.addCallback(self.assertEqual, '') return d result = self.runTestMethod(method) self.assertSuccessful(result) def test_gpo_output(self): def method(testcase): testcase.expectCommands(Expect("command").stdout("stdout")) d = utils.getProcessOutput("command", ()) d.addCallback(testcase.assertEqual, "stdout") return d result = self.runTestMethod(method) self.assertSuccessful(result) def test_gpo_outputAndError(self): def method(testcase): testcase.expectCommands(Expect("command").stdout("stdout").stderr("stderr")) d = utils.getProcessOutput("command", (), errortoo=True) @d.addCallback def cb(res): testcase.assertSubstring("stdout", res) testcase.assertSubstring("stderr", res) return d result = self.runTestMethod(method) self.assertSuccessful(result) def test_gpo_environ_success(self): def method(testcase): testcase.expectCommands(Expect("command")) testcase.addGetProcessOutputExpectEnv({'key': 'value'}) d = utils.getProcessOutput("command", (), env={'key': 'value'}) d.addCallback(self.assertEqual, '') d.addCallback(lambda _: testcase.assertAllCommandsRan()) return d result = self.runTestMethod(method) self.assertSuccessful(result) def test_gpo_environ_wrongValue(self): def method(testcase): testcase.expectCommands(Expect("command")) testcase.addGetProcessOutputExpectEnv({'key': 'value'}) d = utils.getProcessOutput("command", (), env={'key': 'wrongvalue'}) return d result = self.runTestMethod(method) self.assertTestFailure(result, "Expected environment to have key = 'value'") def test_gpo_environ_missing(self): def method(testcase): testcase.expectCommands(Expect("command")) testcase.addGetProcessOutputExpectEnv({'key': 'value'}) d = utils.getProcessOutput("command", ()) return d result = self.runTestMethod(method) self.assertTestFailure(result, "Expected environment to have key = 'value'") def test_gpoav_oneCommand(self): def method(testcase): testcase.expectCommands(Expect("command")) d = utils.getProcessOutputAndValue("command", ()) d.addCallback(self.assertEqual, ('','',0)) d.addCallback(lambda _: testcase.assertAllCommandsRan()) return d result = self.runTestMethod(method) self.assertSuccessful(result) def test_gpoav_expectTwo_runOne(self): def method(testcase): testcase.expectCommands(Expect("command")) testcase.expectCommands(Expect("command2")) d = utils.getProcessOutputAndValue("command", ()) d.addCallback(self.assertEqual, ('','',0)) d.addCallback(lambda _: testcase.assertAllCommandsRan()) return d result = self.runTestMethod(method) self.assertTestFailure(result, "assert all expected commands were run") def test_gpoav_wrongCommand(self): def method(testcase): testcase.expectCommands(Expect("command2")) d = utils.getProcessOutputAndValue("command", ()) d.addCallback(lambda _: testcase.assertAllCommandsRan()) return d result = self.runTestMethod(method) self.assertTestFailure(result, "unexpected command run") def test_gpoav_wrongArgs(self): def method(testcase): testcase.expectCommands(Expect("command", "arg")) d = utils.getProcessOutputAndValue("command", ("otherarg",)) d.addCallback(lambda _: testcase.assertAllCommandsRan()) return d result = self.runTestMethod(method) self.assertTestFailure(result, "unexpected command run") def test_gpoav_missingPath(self): def method(testcase): testcase.expectCommands(Expect("command", "arg").path("/home")) d = utils.getProcessOutputAndValue("command", ("otherarg",)) d.addCallback(lambda _: testcase.assertAllCommandsRan()) return d result = self.runTestMethod(method) self.assertTestFailure(result, "unexpected command run") def test_gpoav_wrongPath(self): def method(testcase): testcase.expectCommands(Expect("command", "arg").path("/home")) d = utils.getProcessOutputAndValue("command", ("otherarg",), path="/work") d.addCallback(lambda _: testcase.assertAllCommandsRan()) return d result = self.runTestMethod(method) self.assertTestFailure(result, "unexpected command run") def test_gpoav_notCurrentPath(self): def method(testcase): testcase.expectCommands(Expect("command", "arg")) d = utils.getProcessOutputAndValue("command", ("otherarg",), path="/work") d.addCallback(lambda _: testcase.assertAllCommandsRan()) return d result = self.runTestMethod(method) self.assertTestFailure(result, "unexpected command run") def test_gpoav_errorOutput(self): def method(testcase): testcase.expectCommands(Expect("command").stderr("some test")) d = utils.getProcessOutputAndValue("command", ()) d.addCallback(self.assertEqual, ('','some test',0)) return d result = self.runTestMethod(method) self.assertSuccessful(result) def test_gpoav_exit(self): def method(testcase): testcase.expectCommands(Expect("command").exit(1)) d = utils.getProcessOutputAndValue("command", ()) d.addCallback(self.assertEqual, ('','',1)) return d result = self.runTestMethod(method) self.assertSuccessful(result) def test_gpoav_output(self): def method(testcase): testcase.expectCommands(Expect("command").stdout("stdout")) d = utils.getProcessOutputAndValue("command", ()) d.addCallback(testcase.assertEqual, ("stdout",'',0)) return d result = self.runTestMethod(method) self.assertSuccessful(result) def test_gpoav_outputAndError(self): def method(testcase): testcase.expectCommands(Expect("command").stdout("stdout").stderr("stderr")) d = utils.getProcessOutputAndValue("command", ()) d.addCallback(testcase.assertEqual, ("stdout",'stderr',0)) return d result = self.runTestMethod(method) self.assertSuccessful(result) buildbot-0.8.8/buildbot/test/unit/test_util.py000066400000000000000000000151151222546025000214640ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import datetime from twisted.trial import unittest from buildbot import util class formatInterval(unittest.TestCase): def test_zero(self): self.assertEqual(util.formatInterval(0), "0 secs") def test_seconds_singular(self): self.assertEqual(util.formatInterval(1), "1 secs") def test_seconds(self): self.assertEqual(util.formatInterval(7), "7 secs") def test_minutes_one(self): self.assertEqual(util.formatInterval(60), "60 secs") def test_minutes_over_one(self): self.assertEqual(util.formatInterval(61), "1 mins, 1 secs") def test_minutes(self): self.assertEqual(util.formatInterval(300), "5 mins, 0 secs") def test_hours_one(self): self.assertEqual(util.formatInterval(3600), "60 mins, 0 secs") def test_hours_over_one_sec(self): self.assertEqual(util.formatInterval(3601), "1 hrs, 1 secs") def test_hours_over_one_min(self): self.assertEqual(util.formatInterval(3660), "1 hrs, 60 secs") def test_hours(self): self.assertEqual(util.formatInterval(7200), "2 hrs, 0 secs") def test_mixed(self): self.assertEqual(util.formatInterval(7392), "2 hrs, 3 mins, 12 secs") class safeTranslate(unittest.TestCase): def test_str_good(self): self.assertEqual(util.safeTranslate(str("full")), str("full")) def test_str_bad(self): self.assertEqual(util.safeTranslate(str("speed=slow;quality=high")), str("speed_slow_quality_high")) def test_str_pathological(self): # if you needed proof this wasn't for use with sensitive data self.assertEqual(util.safeTranslate(str("p\ath\x01ogy")), str("p\ath\x01ogy")) # bad chars still here! def test_unicode_good(self): self.assertEqual(util.safeTranslate(u"full"), str("full")) def test_unicode_bad(self): self.assertEqual(util.safeTranslate(unicode("speed=slow;quality=high")), str("speed_slow_quality_high")) def test_unicode_pathological(self): self.assertEqual(util.safeTranslate(u"\u0109"), str("\xc4\x89")) # yuck! class naturalSort(unittest.TestCase): def test_alpha(self): self.assertEqual( util.naturalSort(['x', 'aa', 'ab']), ['aa', 'ab', 'x']) def test_numeric(self): self.assertEqual( util.naturalSort(['1', '10', '11', '2', '20']), ['1', '2', '10', '11', '20']) def test_alphanum(self): l1 = 'aa10ab aa1ab aa10aa f a aa3 aa30 aa3a aa30a'.split() l2 = 'a aa1ab aa3 aa3a aa10aa aa10ab aa30 aa30a f'.split() self.assertEqual(util.naturalSort(l1), l2) class none_or_str(unittest.TestCase): def test_none(self): self.assertEqual(util.none_or_str(None), None) def test_str(self): self.assertEqual(util.none_or_str("hi"), "hi") def test_int(self): self.assertEqual(util.none_or_str(199), "199") class TimeFunctions(unittest.TestCase): def test_UTC(self): self.assertEqual(util.UTC.utcoffset(datetime.datetime.now()), datetime.timedelta(0)) self.assertEqual(util.UTC.dst(datetime.datetime.now()), datetime.timedelta(0)) self.assertEqual(util.UTC.tzname(), "UTC") def test_epoch2datetime(self): self.assertEqual(util.epoch2datetime(0), datetime.datetime(1970, 1, 1, 0, 0, 0, tzinfo=util.UTC)) self.assertEqual(util.epoch2datetime(1300000000), datetime.datetime(2011, 3, 13, 7, 6, 40, tzinfo=util.UTC)) def test_datetime2epoch(self): dt = datetime.datetime(1970, 1, 1, 0, 0, 0, tzinfo=util.UTC) self.assertEqual(util.datetime2epoch(dt), 0) dt = datetime.datetime(2011, 3, 13, 7, 6, 40, tzinfo=util.UTC) self.assertEqual(util.datetime2epoch(dt), 1300000000) class DiffSets(unittest.TestCase): def test_empty(self): removed, added = util.diffSets(set([]), set([])) self.assertEqual((removed, added), (set([]), set([]))) def test_no_lists(self): removed, added = util.diffSets([1, 2], [2, 3]) self.assertEqual((removed, added), (set([1]), set([3]))) def test_no_overlap(self): removed, added = util.diffSets(set([1, 2]), set([3, 4])) self.assertEqual((removed, added), (set([1, 2]), set([3, 4]))) def test_no_change(self): removed, added = util.diffSets(set([1, 2]), set([1, 2])) self.assertEqual((removed, added), (set([]), set([]))) def test_added(self): removed, added = util.diffSets(set([1, 2]), set([1, 2, 3])) self.assertEqual((removed, added), (set([]), set([3]))) def test_removed(self): removed, added = util.diffSets(set([1, 2]), set([1])) self.assertEqual((removed, added), (set([2]), set([]))) class MakeList(unittest.TestCase): def test_empty_string(self): self.assertEqual(util.makeList(''), [ '' ]) def test_None(self): self.assertEqual(util.makeList(None), [ ]) def test_string(self): self.assertEqual(util.makeList('hello'), [ 'hello' ]) def test_unicode(self): self.assertEqual(util.makeList(u'\N{SNOWMAN}'), [ u'\N{SNOWMAN}' ]) def test_list(self): self.assertEqual(util.makeList(['a','b']), [ 'a', 'b' ]) def test_tuple(self): self.assertEqual(util.makeList(('a','b')), [ 'a', 'b' ]) def test_copy(self): input = ['a', 'b'] output = util.makeList(input) input.append('c') self.assertEqual(output, [ 'a', 'b' ]) class Flatten(unittest.TestCase): def test_simple(self): self.assertEqual(util.flatten([1, 2, 3]), [1, 2, 3]) def test_deep(self): self.assertEqual(util.flatten([ [ 1, 2 ], 3, [ [ 4 ] ] ]), [1, 2, 3, 4]) def test_tuples(self): self.assertEqual(util.flatten([ ( 1, 2 ), 3 ]), [ (1, 2), 3 ]) buildbot-0.8.8/buildbot/test/unit/test_util_ComparableMixin.py000066400000000000000000000046671222546025000246300ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot import util class ComparableMixin(unittest.TestCase): class Foo(util.ComparableMixin): compare_attrs = ["a", "b"] def __init__(self, a, b, c): self.a, self.b, self.c = a,b,c class Bar(Foo, util.ComparableMixin): compare_attrs = ["b", "c"] def setUp(self): self.f123 = self.Foo(1, 2, 3) self.f124 = self.Foo(1, 2, 4) self.f134 = self.Foo(1, 3, 4) self.b123 = self.Bar(1, 2, 3) self.b223 = self.Bar(2, 2, 3) self.b213 = self.Bar(2, 1, 3) def test_equality_identity(self): self.assertEqual(self.f123, self.f123) def test_equality_same(self): another_f123 = self.Foo(1, 2, 3) self.assertEqual(self.f123, another_f123) def test_equality_unimportantDifferences(self): self.assertEqual(self.f123, self.f124) def test_equality_unimportantDifferences_subclass(self): # verify that the parent class's compare_attrs doesn't # affect the subclass self.assertEqual(self.b123, self.b223) def test_inequality_importantDifferences(self): self.assertNotEqual(self.f123, self.f134) def test_inequality_importantDifferences_subclass(self): self.assertNotEqual(self.b123, self.b213) def test_inequality_differentClasses(self): self.assertNotEqual(self.f123, self.b123) def test_inequality_sameClass_differentCompareAttrs(self): another_f123 = self.Foo(1, 2, 3) another_f123.compare_attrs = ["b", "a"] self.assertNotEqual(self.f123, another_f123) def test_lt_importantDifferences(self): assert self.f123 < self.f134 def test_lt_differentClasses(self): assert self.b123 < self.f123 buildbot-0.8.8/buildbot/test/unit/test_util_bbcollections.py000066400000000000000000000045241222546025000243700ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.util import bbcollections class KeyedSets(unittest.TestCase): def setUp(self): self.ks = bbcollections.KeyedSets() def test_getitem_default(self): self.assertEqual(self.ks['x'], set()) # remaining tests effectively cover __getitem__ def test_add(self): self.ks.add('y', 2) self.assertEqual(self.ks['y'], set([2])) def test_add_twice(self): self.ks.add('z', 2) self.ks.add('z', 4) self.assertEqual(self.ks['z'], set([2, 4])) def test_discard_noError(self): self.ks.add('full', 12) self.ks.discard('empty', 13) # should not fail self.ks.discard('full', 13) # nor this self.assertEqual(self.ks['full'], set([12])) def test_discard_existing(self): self.ks.add('yarn', 'red') self.ks.discard('yarn', 'red') self.assertEqual(self.ks['yarn'], set([])) def test_contains_true(self): self.ks.add('yarn', 'red') self.assertTrue('yarn' in self.ks) def test_contains_false(self): self.assertFalse('yarn' in self.ks) def test_contains_setNamesNotContents(self): self.ks.add('yarn', 'red') self.assertFalse('red' in self.ks) def test_pop_exists(self): self.ks.add('names', 'pop') self.ks.add('names', 'coke') self.ks.add('names', 'soda') popped = self.ks.pop('names') remaining = self.ks['names'] self.assertEqual((popped, remaining), (set(['pop', 'coke', 'soda']), set())) def test_pop_missing(self): self.assertEqual(self.ks.pop('flavors'), set()) buildbot-0.8.8/buildbot/test/unit/test_util_eventual.py000066400000000000000000000070421222546025000233670ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from twisted.internet import defer from twisted.python import log from buildbot.util import eventual class Eventually(unittest.TestCase): def setUp(self): # reset the queue to its base state eventual._theSimpleQueue = eventual._SimpleCallQueue() self.old_log_err = log.err self.results = [] def tearDown(self): log.err = self.old_log_err return eventual.flushEventualQueue() # utility callback def cb(self, *args, **kwargs): r = args if kwargs: r = r + (kwargs,) self.results.append(r) # flush the queue and assert results def assertResults(self, exp): d = eventual.flushEventualQueue() def cb(_): self.assertEqual(self.results, exp) d.addCallback(cb) return d ## tests def test_eventually_calls(self): eventual.eventually(self.cb) return self.assertResults([()]) def test_eventually_args(self): eventual.eventually(self.cb, 1, 2, a='a') return self.assertResults([(1, 2, dict(a='a'))]) def test_eventually_err(self): # monkey-patch log.err; this is restored by tearDown log.err = lambda : self.results.append("err") def cb_fails(): raise RuntimeError("should not cause test failure") eventual.eventually(cb_fails) return self.assertResults(['err']) def test_eventually_butNotNow(self): eventual.eventually(self.cb, 1) self.failIf(self.results != []) return self.assertResults([(1,)]) def test_eventually_order(self): eventual.eventually(self.cb, 1) eventual.eventually(self.cb, 2) eventual.eventually(self.cb, 3) return self.assertResults([(1,), (2,), (3,)]) def test_flush_waitForChainedEventuallies(self): def chain(n): self.results.append(n) if n <= 0: return eventual.eventually(chain, n-1) chain(3) # (the flush this tests is implicit in assertResults) return self.assertResults([3, 2, 1, 0]) def test_flush_waitForTreeEventuallies(self): # a more complex set of eventualities def tree(n): self.results.append(n) if n <= 0: return eventual.eventually(tree, n-1) eventual.eventually(tree, n-1) tree(2) # (the flush this tests is implicit in assertResults) return self.assertResults([2, 1, 1, 0, 0, 0, 0]) def test_flush_duringTurn(self): testd = defer.Deferred() def cb(): d = eventual.flushEventualQueue() d.addCallback(testd.callback) eventual.eventually(cb) return testd def test_fireEventually_call(self): d = eventual.fireEventually(13) d.addCallback(self.cb) return self.assertResults([(13,)]) buildbot-0.8.8/buildbot/test/unit/test_util_lru.py000066400000000000000000000441741222546025000223550ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import string import random import gc from twisted.trial import unittest from twisted.internet import defer, reactor from twisted.python import failure from buildbot.util import lru # construct weakref-able objects for particular keys def short(k): return set([k.upper() * 3]) def long(k): return set([k.upper() * 6]) class LRUCacheTest(unittest.TestCase): def setUp(self): lru.inv_failed = False self.lru = lru.LRUCache(short, 3) def tearDown(self): self.assertFalse(lru.inv_failed, "invariant failed; see logs") def check_result(self, r, exp, exp_hits=None, exp_misses=None, exp_refhits=None): self.assertEqual(r, exp) if exp_hits is not None: self.assertEqual(self.lru.hits, exp_hits) if exp_misses is not None: self.assertEqual(self.lru.misses, exp_misses) if exp_refhits is not None: self.assertEqual(self.lru.refhits, exp_refhits) def test_single_key(self): # just get an item val = self.lru.get('a') self.check_result(val, short('a'), 0, 1) # second time, it should be cached.. self.lru.miss_fn = long val = self.lru.get('a') self.check_result(val, short('a'), 1, 1) def test_simple_lru_expulsion(self): val = self.lru.get('a') self.check_result(val, short('a'), 0, 1) val = self.lru.get('b') self.check_result(val, short('b'), 0, 2) val = self.lru.get('c') self.check_result(val, short('c'), 0, 3) val = self.lru.get('d') self.check_result(val, short('d'), 0, 4) del(val) gc.collect() # now try 'a' again - it should be a miss self.lru.miss_fn = long val = self.lru.get('a') self.check_result(val, long('a'), 0, 5) # ..and that expelled B, but C is still in the cache val = self.lru.get('c') self.check_result(val, short('c'), 1, 5) def test_simple_lru_expulsion_maxsize_1(self): self.lru = lru.LRUCache(short, 1) val = self.lru.get('a') self.check_result(val, short('a'), 0, 1) val = self.lru.get('a') self.check_result(val, short('a'), 1, 1) val = self.lru.get('b') self.check_result(val, short('b'), 1, 2) del(val) gc.collect() # now try 'a' again - it should be a miss self.lru.miss_fn = long val = self.lru.get('a') self.check_result(val, long('a'), 1, 3) del(val) gc.collect() # ..and that expelled B val = self.lru.get('b') self.check_result(val, long('b'), 1, 4) def test_simple_lru_expulsion_maxsize_1_null_result(self): # a regression test for #2011 def miss_fn(k): if k == 'b': return None else: return short(k) self.lru = lru.LRUCache(miss_fn, 1) val = self.lru.get('a') self.check_result(val, short('a'), 0, 1) val = self.lru.get('b') self.check_result(val, None, 0, 2) del(val) # 'a' was not expelled since 'b' was None self.lru.miss_fn = long val = self.lru.get('a') self.check_result(val, short('a'), 1, 2) def test_queue_collapsing(self): # just to check that we're practicing with the right queue size (so # QUEUE_SIZE_FACTOR is 10) self.assertEqual(self.lru.max_queue, 30) for c in 'a' + 'x' * 27 + 'ab': res = self.lru.get(c) self.check_result(res, short('b'), 27, 3) # at this point, we should have 'x', 'a', and 'b' in the cache, and # 'axx..xxab' in the queue. self.assertEqual(len(self.lru.queue), 30) # This 'get' operation for an existing key should cause compaction res = self.lru.get('b') self.check_result(res, short('b'), 28, 3) self.assertEqual(len(self.lru.queue), 3) # expect a cached short('a') self.lru.miss_fn = long res = self.lru.get('a') self.check_result(res, short('a'), 29, 3) def test_all_misses(self): for i, c in enumerate(string.lowercase + string.uppercase): res = self.lru.get(c) self.check_result(res, short(c), 0, i+1) def test_get_exception(self): def fail_miss_fn(k): raise RuntimeError("oh noes") self.lru.miss_fn = fail_miss_fn got_exc = False try: self.lru.get('abc') except RuntimeError: got_exc = True self.assertEqual(got_exc, True) def test_all_hits(self): res = self.lru.get('a') self.check_result(res, short('a'), 0, 1) self.lru.miss_fn = long for i in xrange(100): res = self.lru.get('a') self.check_result(res, short('a'), i+1, 1) def test_weakrefs(self): res_a = self.lru.get('a') self.check_result(res_a, short('a')) # note that res_a keeps a reference to this value res_b = self.lru.get('b') self.check_result(res_b, short('b')) del res_b # discard reference to b # blow out the cache and the queue self.lru.miss_fn = long for c in (string.lowercase[2:] * 5): self.lru.get(c) # and fetch a again, expecting the cached value res = self.lru.get('a') self.check_result(res, res_a, exp_refhits=1) # but 'b' should give us a new value res = self.lru.get('b') self.check_result(res, long('b'), exp_refhits=1) def test_fuzz(self): chars = list(string.lowercase * 40) random.shuffle(chars) for i, c in enumerate(chars): res = self.lru.get(c) self.check_result(res, short(c)) def test_set_max_size(self): # load up the cache with three items for c in 'abc': res = self.lru.get(c) self.check_result(res, short(c)) del(res) # reset the size to 1 self.lru.set_max_size(1) gc.collect() # and then expect that 'b' is no longer in the cache self.lru.miss_fn = long res = self.lru.get('b') self.check_result(res, long('b')) def test_miss_fn_kwargs(self): def keep_kwargs_miss_fn(k, **kwargs): return set(kwargs.keys()) self.lru.miss_fn = keep_kwargs_miss_fn val = self.lru.get('a', a=1, b=2) self.check_result(val, set(['a', 'b']), 0, 1) def test_miss_fn_returns_none(self): calls = [] def none_miss_fn(k): calls.append(k) return None self.lru.miss_fn = none_miss_fn for i in range(2): self.assertEqual(self.lru.get('a'), None) # check that the miss_fn was called twice self.assertEqual(calls, ['a', 'a']) def test_put(self): self.assertEqual(self.lru.get('p'), short('p')) self.lru.put('p', set(['P2P2'])) self.assertEqual(self.lru.get('p'), set(['P2P2'])) def test_put_nonexistent_key(self): self.assertEqual(self.lru.get('p'), short('p')) self.lru.put('q', set(['new-q'])) self.assertEqual(self.lru.get('p'), set(['PPP'])) self.assertEqual(self.lru.get('q'), set(['QQQ'])) # not updated class AsyncLRUCacheTest(unittest.TestCase): def setUp(self): lru.inv_failed = False self.lru = lru.AsyncLRUCache(self.short_miss_fn, 3) def tearDown(self): self.assertFalse(lru.inv_failed, "invariant failed; see logs") def short_miss_fn(self, key): return defer.succeed(short(key)) def long_miss_fn(self, key): return defer.succeed(long(key)) def failure_miss_fn(self, key): return defer.succeed(None) def check_result(self, r, exp, exp_hits=None, exp_misses=None, exp_refhits=None): self.assertEqual(r, exp) if exp_hits is not None: self.assertEqual(self.lru.hits, exp_hits) if exp_misses is not None: self.assertEqual(self.lru.misses, exp_misses) if exp_refhits is not None: self.assertEqual(self.lru.refhits, exp_refhits) # tests def test_single_key(self): # just get an item d = self.lru.get('a') d.addCallback(self.check_result, short('a'), 0, 1) # second time, it should be cached.. self.lru.miss_fn = self.long_miss_fn d.addCallback(lambda _ : self.lru.get('a')) d.addCallback(self.check_result, short('a'), 1, 1) return d def test_simple_lru_expulsion(self): d = defer.succeed(None) d.addCallback(lambda _ : self.lru.get('a')) d.addCallback(self.check_result, short('a'), 0, 1) d.addCallback(lambda _ : self.lru.get('b')) d.addCallback(self.check_result, short('b'), 0, 2) d.addCallback(lambda _ : self.lru.get('c')) d.addCallback(self.check_result, short('c'), 0, 3) d.addCallback(lambda _ : self.lru.get('d')) d.addCallback(self.check_result, short('d'), 0, 4) gc.collect() # now try 'a' again - it should be a miss self.lru.miss_fn = self.long_miss_fn d.addCallback(lambda _ : self.lru.get('a')) d.addCallback(self.check_result, long('a'), 0, 5) # ..and that expelled B, but C is still in the cache d.addCallback(lambda _ : self.lru.get('c')) d.addCallback(self.check_result, short('c'), 1, 5) return d def test_simple_lru_expulsion_maxsize_1(self): self.lru = lru.AsyncLRUCache(self.short_miss_fn, 1) d = defer.succeed(None) d.addCallback(lambda _ : self.lru.get('a')) d.addCallback(self.check_result, short('a'), 0, 1) d.addCallback(lambda _ : self.lru.get('a')) d.addCallback(self.check_result, short('a'), 1, 1) d.addCallback(lambda _ : self.lru.get('b')) d.addCallback(self.check_result, short('b'), 1, 2) gc.collect() # now try 'a' again - it should be a miss self.lru.miss_fn = self.long_miss_fn d.addCallback(lambda _ : self.lru.get('a')) d.addCallback(self.check_result, long('a'), 1, 3) gc.collect() # ..and that expelled B d.addCallback(lambda _ : self.lru.get('b')) d.addCallback(self.check_result, long('b'), 1, 4) return d def test_simple_lru_expulsion_maxsize_1_null_result(self): # a regression test for #2011 def miss_fn(k): if k == 'b': return defer.succeed(None) else: return defer.succeed(short(k)) self.lru = lru.AsyncLRUCache(miss_fn, 1) d = defer.succeed(None) d.addCallback(lambda _ : self.lru.get('a')) d.addCallback(self.check_result, short('a'), 0, 1) d.addCallback(lambda _ : self.lru.get('b')) d.addCallback(self.check_result, None, 0, 2) # 'a' was not expelled since 'b' was None self.lru.miss_fn = self.long_miss_fn d.addCallback(lambda _ : self.lru.get('a')) d.addCallback(self.check_result, short('a'), 1, 2) return d @defer.inlineCallbacks def test_queue_collapsing(self): # just to check that we're practicing with the right queue size (so # QUEUE_SIZE_FACTOR is 10) self.assertEqual(self.lru.max_queue, 30) for c in 'a' + 'x' * 27 + 'ab': res = yield self.lru.get(c) self.check_result(res, short('b'), 27, 3) # at this point, we should have 'x', 'a', and 'b' in the cache, and # 'axx..xxab' in the queue. self.assertEqual(len(self.lru.queue), 30) # This 'get' operation for an existing key should cause compaction res = yield self.lru.get('b') self.check_result(res, short('b'), 28, 3) self.assertEqual(len(self.lru.queue), 3) # expect a cached short('a') self.lru.miss_fn = self.long_miss_fn res = yield self.lru.get('a') self.check_result(res, short('a'), 29, 3) @defer.inlineCallbacks def test_all_misses(self): for i, c in enumerate(string.lowercase + string.uppercase): res = yield self.lru.get(c) self.check_result(res, short(c), 0, i+1) @defer.inlineCallbacks def test_get_exception(self): def fail_miss_fn(k): return defer.fail(RuntimeError("oh noes")) self.lru.miss_fn = fail_miss_fn got_exc = False try: yield self.lru.get('abc') except RuntimeError: got_exc = True self.assertEqual(got_exc, True) @defer.inlineCallbacks def test_all_hits(self): res = yield self.lru.get('a') self.check_result(res, short('a'), 0, 1) self.lru.miss_fn = self.long_miss_fn for i in xrange(100): res = yield self.lru.get('a') self.check_result(res, short('a'), i+1, 1) @defer.inlineCallbacks def test_weakrefs(self): res_a = yield self.lru.get('a') self.check_result(res_a, short('a')) # note that res_a keeps a reference to this value res_b = yield self.lru.get('b') self.check_result(res_b, short('b')) del res_b # discard reference to b # blow out the cache and the queue self.lru.miss_fn = self.long_miss_fn for c in (string.lowercase[2:] * 5): yield self.lru.get(c) # and fetch a again, expecting the cached value res = yield self.lru.get('a') self.check_result(res, res_a, exp_refhits=1) # but 'b' should give us a new value res = yield self.lru.get('b') self.check_result(res, long('b'), exp_refhits=1) @defer.inlineCallbacks def test_fuzz(self): chars = list(string.lowercase * 40) random.shuffle(chars) for i, c in enumerate(chars): res = yield self.lru.get(c) self.check_result(res, short(c)) def test_massively_parallel(self): chars = list(string.lowercase * 5) misses = [ 0 ] def slow_short_miss_fn(key): d = defer.Deferred() misses[0] += 1 reactor.callLater(0, lambda : d.callback(short(key))) return d self.lru.miss_fn = slow_short_miss_fn def check(c, d): d.addCallback(self.check_result, short(c)) return d d = defer.gatherResults([ check(c, self.lru.get(c)) for c in chars ]) def post_check(_): self.assertEqual(misses[0], 26) self.assertEqual(self.lru.misses, 26) self.assertEqual(self.lru.hits, 4*26) d.addCallback(post_check) return d def test_slow_fetch(self): def slower_miss_fn(k): d = defer.Deferred() reactor.callLater(0.05, lambda : d.callback(short(k))) return d self.lru.miss_fn = slower_miss_fn def do_get(test_d, k): d = self.lru.get(k) d.addCallback(self.check_result, short(k)) d.addCallbacks(test_d.callback, test_d.errback) ds = [] for i in range(8): d = defer.Deferred() reactor.callLater(0.02*i, do_get, d, 'x') ds.append(d) d = defer.gatherResults(ds) def check(_): self.assertEqual((self.lru.hits, self.lru.misses), (7, 1)) d.addCallback(check) return d def test_slow_failure(self): def slow_fail_miss_fn(k): d = defer.Deferred() reactor.callLater(0.05, lambda : d.errback(failure.Failure(RuntimeError("oh noes")))) return d self.lru.miss_fn = slow_fail_miss_fn def do_get(test_d, k): d = self.lru.get(k) self.assertFailure(d, RuntimeError) d.addCallbacks(test_d.callback, test_d.errback) ds = [] for i in range(8): d = defer.Deferred() reactor.callLater(0.02*i, do_get, d, 'x') ds.append(d) d = defer.gatherResults(ds) return d @defer.inlineCallbacks def test_set_max_size(self): # load up the cache with three items for c in 'abc': res = yield self.lru.get(c) self.check_result(res, short(c)) # reset the size to 1 self.lru.set_max_size(1) gc.collect() # and then expect that 'b' is no longer in the cache self.lru.miss_fn = self.long_miss_fn res = yield self.lru.get('b') self.check_result(res, long('b')) def test_miss_fn_kwargs(self): def keep_kwargs_miss_fn(k, **kwargs): return defer.succeed(set(kwargs.keys())) self.lru.miss_fn = keep_kwargs_miss_fn d = self.lru.get('a', a=1, b=2) d.addCallback(self.check_result, set(['a', 'b']), 0, 1) return d @defer.inlineCallbacks def test_miss_fn_returns_none(self): calls = [] def none_miss_fn(k): calls.append(k) return defer.succeed(None) self.lru.miss_fn = none_miss_fn for i in range(2): self.assertEqual((yield self.lru.get('a')), None) # check that the miss_fn was called twice self.assertEqual(calls, ['a', 'a']) @defer.inlineCallbacks def test_put(self): self.assertEqual((yield self.lru.get('p')), short('p')) self.lru.put('p', set(['P2P2'])) self.assertEqual((yield self.lru.get('p')), set(['P2P2'])) buildbot-0.8.8/buildbot/test/unit/test_util_maildir.py000066400000000000000000000063761222546025000231760ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os from twisted.trial import unittest from twisted.internet import defer from buildbot.util import maildir from buildbot.test.util import dirs class TestMaildirService(dirs.DirsMixin, unittest.TestCase): def setUp(self): self.maildir = os.path.abspath("maildir") self.newdir = os.path.join(self.maildir, "new") self.curdir = os.path.join(self.maildir, "cur") self.tmpdir = os.path.join(self.maildir, "tmp") self.setUpDirs(self.maildir, self.newdir, self.curdir, self.tmpdir) self.svc = None def tearDown(self): if self.svc and self.svc.running: self.svc.stopService() self.tearDownDirs() # tests @defer.inlineCallbacks def test_start_stop_repeatedly(self): self.svc = maildir.MaildirService(self.maildir) self.svc.startService() yield self.svc.stopService() self.svc.startService() yield self.svc.stopService() self.assertEqual(len(list(self.svc)), 0) def test_messageReceived(self): self.svc = maildir.MaildirService(self.maildir) # add a fake messageReceived method messagesReceived = [] def messageReceived(filename): messagesReceived.append(filename) return defer.succeed(None) self.svc.messageReceived = messageReceived d = defer.maybeDeferred(self.svc.startService) def check_empty(_): self.assertEqual(messagesReceived, []) d.addCallback(check_empty) def add_msg(_): tmpfile = os.path.join(self.tmpdir, "newmsg") newfile = os.path.join(self.newdir, "newmsg") open(tmpfile, "w").close() os.rename(tmpfile, newfile) d.addCallback(add_msg) def trigger(_): # TODO: can we wait for a dnotify somehow, if enabled? return self.svc.poll() d.addCallback(trigger) def check_nonempty(_): self.assertEqual(messagesReceived, [ 'newmsg' ]) d.addCallback(check_nonempty) return d def test_moveToCurDir(self): self.svc = maildir.MaildirService(self.maildir) tmpfile = os.path.join(self.tmpdir, "newmsg") newfile = os.path.join(self.newdir, "newmsg") open(tmpfile, "w").close() os.rename(tmpfile, newfile) self.svc.moveToCurDir("newmsg") self.assertEqual([ os.path.exists(os.path.join(d, "newmsg")) for d in (self.newdir, self.curdir, self.tmpdir) ], [ False, True, False ]) buildbot-0.8.8/buildbot/test/unit/test_util_misc.py000066400000000000000000000102201222546025000224670ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.util import misc from buildbot import util from twisted.python import failure from twisted.internet import defer, reactor from buildbot.test.util import compat from buildbot.util.eventual import eventually class deferredLocked(unittest.TestCase): def test_name(self): self.assertEqual(util.deferredLocked, misc.deferredLocked) def test_fn(self): l = defer.DeferredLock() @util.deferredLocked(l) def check_locked(arg1, arg2): self.assertEqual([l.locked, arg1, arg2], [True, 1, 2]) return defer.succeed(None) d = check_locked(1, 2) def check_unlocked(_): self.assertFalse(l.locked) d.addCallback(check_unlocked) return d def test_fn_fails(self): l = defer.DeferredLock() @util.deferredLocked(l) def do_fail(): return defer.fail(RuntimeError("oh noes")) d = do_fail() def check_unlocked(_): self.assertFalse(l.locked) d.addCallbacks(lambda _ : self.fail("didn't errback"), lambda _ : self.assertFalse(l.locked)) return d def test_fn_exception(self): l = defer.DeferredLock() @util.deferredLocked(l) def do_fail(): raise RuntimeError("oh noes") d = do_fail() def check_unlocked(_): self.assertFalse(l.locked) d.addCallbacks(lambda _ : self.fail("didn't errback"), lambda _ : self.assertFalse(l.locked)) return d def test_method(self): testcase = self class C: @util.deferredLocked('aLock') def check_locked(self, arg1, arg2): testcase.assertEqual([self.aLock.locked, arg1, arg2], [True, 1, 2]) return defer.succeed(None) obj = C() obj.aLock = defer.DeferredLock() d = obj.check_locked(1, 2) def check_unlocked(_): self.assertFalse(obj.aLock.locked) d.addCallback(check_unlocked) return d class SerializedInvocation(unittest.TestCase): def waitForQuiet(self, si): d = defer.Deferred() si._quiet = lambda : d.callback(None) return d # tests def test_name(self): self.assertEqual(util.SerializedInvocation, misc.SerializedInvocation) def testCallFolding(self): events = [] def testfn(): d = defer.Deferred() def done(): events.append('TM') d.callback(None) eventually(done) return d si = misc.SerializedInvocation(testfn) # run three times - the first starts testfn, the second # requires a second run, and the third is folded. d1 = si() d2 = si() d3 = si() dq = self.waitForQuiet(si) d = defer.gatherResults([d1, d2, d3, dq]) def check(_): self.assertEqual(events, [ 'TM', 'TM' ]) d.addCallback(check) return d @compat.usesFlushLoggedErrors def testException(self): def testfn(): d = defer.Deferred() reactor.callLater(0, d.errback, failure.Failure(RuntimeError("oh noes"))) return d si = misc.SerializedInvocation(testfn) d = si() def check(_): self.assertEqual(len(self.flushLoggedErrors(RuntimeError)), 1) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_util_netstrings.py000066400000000000000000000032561222546025000237470ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.protocols import basic from twisted.trial import unittest from buildbot.util import netstrings class NetstringParser(unittest.TestCase): def test_valid_netstrings(self): p = netstrings.NetstringParser() p.feed("5:hello,5:world,") self.assertEqual(p.strings, ['hello', 'world']) def test_valid_netstrings_byte_by_byte(self): # (this is really testing twisted's support, but oh well) p = netstrings.NetstringParser() [ p.feed(c) for c in "5:hello,5:world," ] self.assertEqual(p.strings, ['hello', 'world']) def test_invalid_netstring(self): p = netstrings.NetstringParser() self.assertRaises(basic.NetstringParseError, lambda : p.feed("5-hello!")) def test_incomplete_netstring(self): p = netstrings.NetstringParser() p.feed("11:hello world,6:foob") # note that the incomplete 'foobar' does not appear here self.assertEqual(p.strings, ['hello world']) buildbot-0.8.8/buildbot/test/unit/test_util_sautils.py000066400000000000000000000016041222546025000232260ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.util import sautils class SAVersion(unittest.TestCase): def test_sa_version(self): self.failUnless(sautils.sa_version() > (0,5,0)) buildbot-0.8.8/buildbot/test/unit/test_util_state.py000066400000000000000000000050621222546025000226640ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.util import state from buildbot.test.fake.fakemaster import make_master class FakeObject(state.StateMixin): name = "fake-name" def __init__(self, master): self.master = master class TestStateMixin(unittest.TestCase): OBJECTID = 19 def setUp(self): self.master = make_master(wantDb=True, testcase=self) self.object = FakeObject(self.master) def test_getState(self): self.master.db.state.fakeState('fake-name', 'FakeObject', fav_color=['red','purple']) d = self.object.getState('fav_color') def check(res): self.assertEqual(res, ['red', 'purple']) d.addCallback(check) return d def test_getState_default(self): d = self.object.getState('fav_color', 'black') def check(res): self.assertEqual(res, 'black') d.addCallback(check) return d def test_getState_KeyError(self): self.master.db.state.fakeState('fake-name', 'FakeObject', fav_color=['red','purple']) d = self.object.getState('fav_book') def cb(_): self.fail("should not succeed") def check_exc(f): f.trap(KeyError) pass d.addCallbacks(cb, check_exc) return d def test_setState(self): d = self.object.setState('y', 14) def check(_): self.master.db.state.assertStateByClass('fake-name', 'FakeObject', y=14) d.addCallback(check) return d def test_setState_existing(self): self.master.db.state.fakeState('fake-name', 'FakeObject', x=13) d = self.object.setState('x', 14) def check(_): self.master.db.state.assertStateByClass('fake-name', 'FakeObject', x=14) d.addCallback(check) return d buildbot-0.8.8/buildbot/test/unit/test_util_subscriptions.py000066400000000000000000000041231222546025000244500ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.trial import unittest from buildbot.util import subscription from buildbot.test.util import compat class subscriptions(unittest.TestCase): def setUp(self): self.subpt = subscription.SubscriptionPoint('test_sub') def test_str(self): self.assertIn('test_sub', str(self.subpt)) def test_subscribe_unsubscribe(self): state = [] def cb(*args, **kwargs): state.append((args, kwargs)) # subscribe sub = self.subpt.subscribe(cb) self.assertTrue(isinstance(sub, subscription.Subscription)) self.assertEqual(state, []) # deliver self.subpt.deliver(1, 2, a=3, b=4) self.assertEqual(state, [((1,2), dict(a=3, b=4))]) state.pop() # unsubscribe sub.unsubscribe() # don't receive events anymore self.subpt.deliver(3, 4) self.assertEqual(state, []) @compat.usesFlushLoggedErrors def test_exception(self): def cb(*args, **kwargs): raise RuntimeError('mah bucket!') # subscribe self.subpt.subscribe(cb) try: self.subpt.deliver() except RuntimeError: self.fail("should not have seen exception here!") # log.err will cause Trial to complain about this error anyway, unless # we clean it up self.assertEqual(1, len(self.flushLoggedErrors(RuntimeError))) buildbot-0.8.8/buildbot/test/util/000077500000000000000000000000001222546025000170715ustar00rootroot00000000000000buildbot-0.8.8/buildbot/test/util/__init__.py000066400000000000000000000000001222546025000211700ustar00rootroot00000000000000buildbot-0.8.8/buildbot/test/util/change_import.py000066400000000000000000000042371222546025000222700ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import with_statement import os import shutil import cPickle from buildbot.test.util import db from buildbot.changes.changes import Change, OldChangeMaster class ChangeImportMixin(db.RealDatabaseMixin): """ We have a number of tests that examine the results of importing particular flavors of Change objects. This class uses some pickling to make this easy to test. This is a subclass of RealDatabaseMixin, so do not inherit from that class separately! >>> self.make_pickle(self.make_change(who=u'jimmy'), self.make_change(who='johnny')) """ def make_pickle(self, *changes, **kwargs): recode_fn = kwargs.pop('recode_fn', None) cm = OldChangeMaster() cm.changes = changes if recode_fn: recode_fn(cm) with open(self.changes_pickle, "wb") as f: cPickle.dump(cm, f) def make_change(self, **kwargs): return Change(**kwargs) def setUpChangeImport(self): self.basedir = os.path.abspath("basedir") if os.path.exists(self.basedir): shutil.rmtree(self.basedir) os.makedirs(self.basedir) self.changes_pickle = os.path.join(self.basedir, "changes.pck") return self.setUpRealDatabase() def tearDownChangeImport(self): d = self.tearDownRealDatabase() def rmtree(_): if os.path.exists(self.basedir): shutil.rmtree(self.basedir) d.addCallback(rmtree) return d buildbot-0.8.8/buildbot/test/util/changesource.py000066400000000000000000000053531222546025000221170ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.internet import defer from twisted.trial import unittest from buildbot.test.fake.fakemaster import make_master class ChangeSourceMixin(object): """ This class is used for testing change sources, and handles a few things: - starting and stopping a ChangeSource service - a fake C{self.master.addChange}, which adds its args to the list C{self.changes_added} """ changesource = None started = False def setUpChangeSource(self): "Set up the mixin - returns a deferred." self.changes_added = [] def addChange(**kwargs): # check for 8-bit strings for k,v in kwargs.items(): if type(v) == type(""): try: v.decode('ascii') except UnicodeDecodeError: raise unittest.FailTest( "non-ascii string for key '%s': %r" % (k,v)) self.changes_added.append(kwargs) return defer.succeed(mock.Mock()) self.master = make_master(testcase=self, wantDb=True) self.master.addChange = addChange return defer.succeed(None) def tearDownChangeSource(self): "Tear down the mixin - returns a deferred." if not self.started: return defer.succeed(None) if self.changesource.running: return defer.maybeDeferred(self.changesource.stopService) return defer.succeed(None) def attachChangeSource(self, cs): "Set up a change source for testing; sets its .master attribute" self.changesource = cs self.changesource.master = self.master def startChangeSource(self): "start the change source as a service" self.started = True self.changesource.startService() def stopChangeSource(self): "stop the change source again; returns a deferred" d = self.changesource.stopService() def mark_stopped(_): self.started = False d.addCallback(mark_stopped) return d buildbot-0.8.8/buildbot/test/util/compat.py000066400000000000000000000031461222546025000207320ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sys import twisted from twisted.python import versions, runtime def usesFlushLoggedErrors(test): "Decorate a test method that uses flushLoggedErrors with this decorator" if (sys.version_info[:2] == (2,7) and twisted.version <= versions.Version('twisted', 9, 0, 0)): test.skip = \ "flushLoggedErrors is broken on Python==2.7 and Twisted<=9.0.0" return test def usesFlushWarnings(test): "Decorate a test method that uses flushWarnings with this decorator" if (sys.version_info[:2] == (2,7) and twisted.version <= versions.Version('twisted', 9, 0, 0)): test.skip = \ "flushWarnings is broken on Python==2.7 and Twisted<=9.0.0" return test def skipUnlessPlatformIs(platform): def closure(test): if runtime.platformType != platform: test.skip = "not a %s platform" % platform return test return closure buildbot-0.8.8/buildbot/test/util/config.py000066400000000000000000000031701222546025000207110ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot import config class ConfigErrorsMixin(object): def assertConfigError(self, errors, substr_or_re): if len(errors.errors) > 1: self.fail("too many errors: %s" % (errors.errors,)) elif len(errors.errors) < 1: self.fail("expected error did not occur") elif isinstance(substr_or_re, str): if substr_or_re not in errors.errors[0]: self.fail("non-matching error: %s" % (errors.errors,)) else: if not substr_or_re.search(errors.errors[0]): self.fail("non-matching error: %s" % (errors.errors,)) def assertRaisesConfigError(self, substr_or_re, fn): try: fn() except config.ConfigErrors, e: self.assertConfigError(e, substr_or_re) else: self.fail("ConfigErrors not raised") def assertNoConfigErrors(self, errors): self.assertEqual(errors.errors, []) buildbot-0.8.8/buildbot/test/util/connector_component.py000066400000000000000000000041361222546025000235230ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from buildbot.db import model from buildbot.test.util import db from buildbot.test.fake import fakemaster class FakeDBConnector(object): pass class ConnectorComponentMixin(db.RealDatabaseMixin): """ Implements a mock DBConnector object, replete with a thread pool and a DB model. This includes a RealDatabaseMixin, so subclasses should not instantiate that class directly. The connector appears at C{self.db}, and the component should be attached to it as an attribute. @ivar db: fake database connector @ivar db.pool: DB thread pool @ivar db.model: DB model """ def setUpConnectorComponent(self, table_names=[], basedir='basedir'): """Set up C{self.db}, using the given db_url and basedir.""" d = self.setUpRealDatabase(table_names=table_names, basedir=basedir) def finish_setup(_): self.db = FakeDBConnector() self.db.pool = self.db_pool self.db.model = model.Model(self.db) self.db.master = fakemaster.make_master() d.addCallback(finish_setup) return d def tearDownConnectorComponent(self): d = self.tearDownRealDatabase() def finish_cleanup(_): self.db_pool.shutdown() # break some reference loops, just for fun del self.db.pool del self.db.model del self.db d.addCallback(finish_cleanup) return d buildbot-0.8.8/buildbot/test/util/db.py000066400000000000000000000134341222546025000200350ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os import sqlalchemy as sa from sqlalchemy.schema import MetaData from twisted.python import log from twisted.trial import unittest from twisted.internet import defer from buildbot.db import model, pool, enginestrategy def skip_for_dialect(dialect): """Decorator to skip a test for a particular SQLAlchemy dialect.""" def dec(fn): def wrap(self, *args, **kwargs): if self.db_engine.dialect.name == dialect: raise unittest.SkipTest( "Not supported on dialect '%s'" % dialect) return fn(self, *args, **kwargs) return wrap return dec class RealDatabaseMixin(object): """ A class that sets up a real database for testing. This sets self.db_url to the URL for the database. By default, it specifies an in-memory SQLite database, but if the BUILDBOT_TEST_DB_URL environment variable is set, it will use the specified database, being careful to clean out *all* tables in the database before and after the tests are run - so each test starts with a clean database. @ivar db_pool: a (real) DBThreadPool instance that can be used as desired @ivar db_url: the DB URL used to run these tests @ivar db_engine: the engine created for the test database """ # Note that this class uses the production database model. A # re-implementation would be virtually identical and just require extra # work to keep synchronized. # Similarly, this class uses the production DB thread pool. This achieves # a few things: # - affords more thorough tests for the pool # - avoids repetitive implementation # - cooperates better at runtime with thread-sensitive DBAPI's def __thd_clean_database(self, conn): # drop the known tables, although sometimes this misses dependencies try: model.Model.metadata.drop_all(bind=conn, checkfirst=True) except sa.exc.ProgrammingError: pass # see if we can find any other tables to drop meta = MetaData(bind=conn) meta.reflect() meta.drop_all() def __thd_create_tables(self, conn, table_names): all_table_names = set(table_names) ordered_tables = [ t for t in model.Model.metadata.sorted_tables if t.name in all_table_names ] for tbl in ordered_tables: tbl.create(bind=conn, checkfirst=True) def setUpRealDatabase(self, table_names=[], basedir='basedir', want_pool=True, sqlite_memory=True): """ Set up a database. Ordinarily sets up an engine and a pool and takes care of cleaning out any existing tables in the database. If C{want_pool} is false, then no pool will be created, and the database will not be cleaned. @param table_names: list of names of tables to instantiate @param basedir: (optional) basedir for the engine @param want_pool: (optional) false to not create C{self.db_pool} @param sqlite_memory: (optional) False to avoid using an in-memory db @returns: Deferred """ self.__want_pool = want_pool default = 'sqlite://' if not sqlite_memory: default = "sqlite:///tmp.sqlite" if not os.path.exists(basedir): os.makedirs(basedir) self.db_url = os.environ.get('BUILDBOT_TEST_DB_URL', default) self.db_engine = enginestrategy.create_engine(self.db_url, basedir=basedir) # if the caller does not want a pool, we're done. if not want_pool: return defer.succeed(None) self.db_pool = pool.DBThreadPool(self.db_engine) log.msg("cleaning database %s" % self.db_url) d = self.db_pool.do(self.__thd_clean_database) d.addCallback(lambda _ : self.db_pool.do(self.__thd_create_tables, table_names)) return d def tearDownRealDatabase(self): if self.__want_pool: return self.db_pool.do(self.__thd_clean_database) else: return defer.succeed(None) def insertTestData(self, rows): """Insert test data into the database for use during the test. @param rows: be a sequence of L{fakedb.Row} instances. These will be sorted by table dependencies, so order does not matter. @returns: Deferred """ # sort the tables by dependency all_table_names = set([ row.table for row in rows ]) ordered_tables = [ t for t in model.Model.metadata.sorted_tables if t.name in all_table_names ] def thd(conn): # insert into tables -- in order for tbl in ordered_tables: for row in [ r for r in rows if r.table == tbl.name ]: tbl = model.Model.metadata.tables[row.table] try: tbl.insert(bind=conn).execute(row.values) except: log.msg("while inserting %s - %s" % (row, row.values)) raise return self.db_pool.do(thd) buildbot-0.8.8/buildbot/test/util/dirs.py000066400000000000000000000026261222546025000204120ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os import shutil from twisted.internet import defer class DirsMixin(object): _dirs = None def setUpDirs(self, *dirs): """Make sure C{dirs} exist and are empty, and set them up to be deleted in tearDown.""" self._dirs = map(os.path.abspath, dirs) for dir in self._dirs: if os.path.exists(dir): shutil.rmtree(dir) os.makedirs(dir) # return a deferred to make chaining easier return defer.succeed(None) def tearDownDirs(self): for dir in self._dirs: if os.path.exists(dir): shutil.rmtree(dir) # return a deferred to make chaining easier return defer.succeed(None) buildbot-0.8.8/buildbot/test/util/gpo.py000066400000000000000000000073661222546025000202440ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.internet import defer, utils class Expect(object): _stdout = "" _stderr = "" _exit = 0 _path = None def __init__(self, bin, *args): self._bin = bin self._args = args def stdout(self, stdout): self._stdout = stdout return self def stderr(self, stderr): self._stderr = stderr return self def exit(self, exit): self._exit = exit return self def path(self, path): self._path = path return self def check(self, test, bin, path, args): test.assertEqual( dict(bin=bin, path=path, args=tuple(args)), dict(bin=self._bin, path=self._path, args=self._args), "unexpected command run") return (self._stdout, self._stderr, self._exit) def __repr__(self): return "" % (self._bin, self._args) class GetProcessOutputMixin: def setUpGetProcessOutput(self): self._gpo_patched = False self._expected_commands = [] self._gpo_expect_env = {} def assertAllCommandsRan(self): self.assertEqual(self._expected_commands, [], "assert all expected commands were run") def _check_env(self, env): env = env or {} for var, value in self._gpo_expect_env.items(): self.assertEqual(env.get(var), value, 'Expected environment to have %s = %r' % (var, value)) def patched_getProcessOutput(self, bin, args, env=None, errortoo=False, path=None): d = self.patched_getProcessOutputAndValue(bin, args, env=env, path=path) @d.addCallback def cb(res): stdout, stderr, exit = res if errortoo: return defer.succeed(stdout + stderr) else: if stderr: return defer.fail(IOError("got stderr: %r" % (stderr,))) else: return defer.succeed(stdout) return d def patched_getProcessOutputAndValue(self, bin, args, env=None, path=None): self._check_env(env) if not self._expected_commands: self.fail("got command %s %s when no further commands were expected" % (bin, args)) expect = self._expected_commands.pop(0) return defer.succeed(expect.check(self, bin, path, args)) def _patch_gpo(self): if not self._gpo_patched: self.patch(utils, "getProcessOutput", self.patched_getProcessOutput) self.patch(utils, "getProcessOutputAndValue", self.patched_getProcessOutputAndValue) self._gpo_patched = True def addGetProcessOutputExpectEnv(self, d): self._gpo_expect_env.update(d) def expectCommands(self, *exp): """ Add to the expected commands, along with their results. Each argument should be an instance of L{Expect}. """ self._patch_gpo() self._expected_commands.extend(exp) buildbot-0.8.8/buildbot/test/util/interfaces.py000066400000000000000000000024111222546025000215640ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import inspect class InterfaceTests(object): # assertions def assertArgSpecMatches(self, actual): def wrap(template): actual_argspec = inspect.getargspec(actual) template_argspec = inspect.getargspec(template) if actual_argspec != template_argspec: msg = "Expected: %s; got: %s" % ( inspect.formatargspec(*template_argspec), inspect.formatargspec(*actual_argspec)) self.fail(msg) return template # just in case it's useful return wrap buildbot-0.8.8/buildbot/test/util/logging.py000066400000000000000000000023231222546025000210710ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import re from twisted.python import log class LoggingMixin(object): def setUpLogging(self): self._logEvents = [] log.addObserver(self._logEvents.append) self.addCleanup(log.removeObserver, self._logEvents.append) def assertLogged(self, regexp): r = re.compile(regexp) for event in self._logEvents: msg = log.textFromEventDict(event) if msg is not None and r.search(msg): return self.fail("%r not matched in log output" % regexp) buildbot-0.8.8/buildbot/test/util/migration.py000066400000000000000000000061701222546025000214400ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os from twisted.python import log from twisted.internet import defer import sqlalchemy as sa import migrate import migrate.versioning.api from buildbot.db import connector from buildbot.test.util import db, dirs, querylog from buildbot.test.fake import fakemaster # test_upgrade vs. migration tests # # test_upgrade is an integration test -- it tests the whole upgrade process, # including the code in model.py. Migrate tests are unit tests, and test a # single db upgrade script. class MigrateTestMixin(db.RealDatabaseMixin, dirs.DirsMixin): def setUpMigrateTest(self): self.basedir = os.path.abspath("basedir") self.setUpDirs('basedir') d = self.setUpRealDatabase() def make_dbc(_): master = fakemaster.make_master() self.db = connector.DBConnector(master, self.basedir) self.db.pool = self.db_pool d.addCallback(make_dbc) return d def tearDownMigrateTest(self): self.tearDownDirs() return self.tearDownRealDatabase() def do_test_migration(self, base_version, target_version, setup_thd_cb, verify_thd_cb): d = defer.succeed(None) def setup_thd(conn): metadata = sa.MetaData() table = sa.Table('migrate_version', metadata, sa.Column('repository_id', sa.String(250), primary_key=True), sa.Column('repository_path', sa.Text), sa.Column('version', sa.Integer)) table.create(bind=conn) conn.execute(table.insert(), repository_id='Buildbot', repository_path=self.db.model.repo_path, version=base_version) setup_thd_cb(conn) d.addCallback(lambda _ : self.db.pool.do(setup_thd)) def upgrade_thd(engine): querylog.log_from_engine(engine) schema = migrate.versioning.schema.ControlledSchema(engine, self.db.model.repo_path) changeset = schema.changeset(target_version) for version, change in changeset: log.msg('upgrading to schema version %d' % (version+1)) schema.runchange(version, change, 1) d.addCallback(lambda _ : self.db.pool.do_with_engine(upgrade_thd)) d.addCallback(lambda _ : self.db.pool.do(verify_thd_cb)) return d buildbot-0.8.8/buildbot/test/util/misc.py000066400000000000000000000032451222546025000204020ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os import sys import cStringIO class PatcherMixin(object): """ Mix this in to get a few special-cased patching methods """ def patch_os_uname(self, replacement): # twisted's 'patch' doesn't handle the case where an attribute # doesn't exist.. if hasattr(os, 'uname'): self.patch(os, 'uname', replacement) else: def cleanup(): del os.uname self.addCleanup(cleanup) os.uname = replacement class StdoutAssertionsMixin(object): """ Mix this in to be able to assert on stdout during the test """ def setUpStdoutAssertions(self): self.stdout = cStringIO.StringIO() self.patch(sys, 'stdout', self.stdout) def assertWasQuiet(self): self.assertEqual(self.stdout.getvalue(), '') def assertInStdout(self, exp): self.assertIn(exp, self.stdout.getvalue()) def getStdout(self): return self.stdout.getvalue().strip() buildbot-0.8.8/buildbot/test/util/pbmanager.py000066400000000000000000000037171222546025000214070ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from twisted.internet import defer class PBManagerMixin: def setUpPBChangeSource(self): "Set up a fake self.pbmanager." self.registrations = [] self.unregistrations = [] pbm = self.pbmanager = mock.Mock() pbm.register = self._fake_register def _fake_register(self, portstr, username, password, factory): reg = mock.Mock() def unregister(): self.unregistrations.append((portstr, username, password)) return defer.succeed(None) reg.unregister = unregister self.registrations.append((portstr, username, password)) return reg def assertNotRegistered(self): self.assertEqual(self.registrations, []) def assertRegistered(self, portstr, username, password): for ps, un, pw in self.registrations: if ps == portstr and username == un and pw == password: return self.fail("not registered: %r not in %s" % ((portstr, username, password), self.registrations)) def assertUnregistered(self, portstr, username, password): for ps, un, pw in self.unregistrations: if ps == portstr and username == un and pw == password: return self.fail("still registered") buildbot-0.8.8/buildbot/test/util/properties.py000066400000000000000000000017151222546025000216430ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from zope.interface import implements from buildbot.interfaces import IRenderable class ConstantRenderable(object): implements(IRenderable) def __init__(self, value): self.value = value def getRenderingFor(self, props): return self.value buildbot-0.8.8/buildbot/test/util/querylog.py000066400000000000000000000026271222546025000213210ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from __future__ import absolute_import import logging from twisted.python import log # this class bridges Python's `logging` module into Twisted's log system. # SqlAlchemy query logging uses `logging`, so this provides a way to enter # queries into the Twisted log file. class PythonToTwistedHandler(logging.Handler): def emit(self, record): log.msg(record.getMessage()) def log_from_engine(engine): # add the handler *before* enabling logging, so that no "default" logger # is added automatically, but only do so once. This is important since # logging's loggers are singletons if not engine.logger.handlers: engine.logger.addHandler(PythonToTwistedHandler()) engine.echo = True buildbot-0.8.8/buildbot/test/util/scheduler.py000066400000000000000000000110021222546025000214130ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import os import mock from buildbot.test.fake import fakedb class FakeMaster(object): def __init__(self, basedir, db): self.basedir = basedir self.db = db self.changes_subscr_cb = None self.bset_subscr_cb = None self.bset_completion_subscr_cb = None self.caches = mock.Mock(name="caches") self.caches.get_cache = self.get_cache def addBuildset(self, **kwargs): return self.db.buildsets.addBuildset(**kwargs) # subscriptions # note that only one subscription of each type is supported def _makeSubscription(self, attr_to_clear): sub = mock.Mock() def unsub(): setattr(self, attr_to_clear, None) sub.unsubscribe = unsub return sub def subscribeToChanges(self, callback): assert not self.changes_subscr_cb self.changes_subscr_cb = callback return self._makeSubscription('changes_subscr_cb') def subscribeToBuildsets(self, callback): assert not self.bset_subscr_cb self.bset_subscr_cb = callback return self._makeSubscription('bset_subscr_cb') def subscribeToBuildsetCompletions(self, callback): assert not self.bset_completion_subscr_cb self.bset_completion_subscr_cb = callback return self._makeSubscription('bset_completion_subscr_cb') # caches def get_cache(self, cache_name, miss_fn): c = mock.Mock(name=cache_name) c.get = miss_fn return c # useful assertions def getSubscriptionCallbacks(self): """get the subscription callbacks set on the master, in a dictionary with keys @{buildsets}, @{buildset_completion}, and C{changes}.""" return dict(buildsets=self.bset_subscr_cb, buildset_completion=self.bset_completion_subscr_cb, changes=self.changes_subscr_cb) class SchedulerMixin(object): """ This class fakes out enough of a master and the various relevant database connectors to test schedulers. All of the database methods have identical signatures to the real database connectors, but for ease of testing always return an already-fired Deferred, meaning that there is no need to wait for events to complete. This class is tightly coupled with the various L{buildbot.test.fake.fakedb} module. All instance variables are only available after C{attachScheduler} has been called. @ivar sched: scheduler instance @ivar master: the fake master @ivar db: the fake db (same as C{self.master.db}, but shorter) """ def setUpScheduler(self): pass def tearDownScheduler(self): pass def attachScheduler(self, scheduler, objectid): """Set up a scheduler with a fake master and db; sets self.sched, and sets the master's basedir to the absolute path of 'basedir' in the test directory. @returns: scheduler """ scheduler.objectid = objectid # set up a fake master db = self.db = fakedb.FakeDBConnector(self) self.master = FakeMaster(os.path.abspath('basedir'), db) scheduler.master = self.master db.insertTestData([ fakedb.Object(id=objectid, name=scheduler.name, class_name='SomeScheduler'), ]) self.sched = scheduler return scheduler class FakeChange: who = '' files = [] comments = '' isdir=0 links=None revision=None when=None branch=None category=None revlink='' properties={} repository='' project='' codebase='' def makeFakeChange(self, **kwargs): """Utility method to make a fake Change object with the given attributes""" ch = self.FakeChange() ch.__dict__.update(kwargs) return ch buildbot-0.8.8/buildbot/test/util/sourcesteps.py000066400000000000000000000040261222546025000220240ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from buildbot.test.util import steps import os class SourceStepMixin(steps.BuildStepMixin): """ Support for testing source steps. Aside from the capabilities of L{BuildStepMixin}, this adds: - fake sourcestamps The following instance variables are available after C{setupSourceStep}, in addition to those made available by L{BuildStepMixin}: @ivar sourcestamp: fake SourceStamp for the build """ def setUpSourceStep(self): return steps.BuildStepMixin.setUpBuildStep(self) def tearDownSourceStep(self): return steps.BuildStepMixin.tearDownBuildStep(self) # utilities def setupStep(self, step, args={}, patch=None, **kwargs): """ Set up C{step} for testing. This calls L{BuildStepMixin}'s C{setupStep} and then does setup specific to a Source step. """ step = steps.BuildStepMixin.setupStep(self, step, **kwargs) ss = self.sourcestamp = mock.Mock(name="sourcestamp") ss.ssid = 9123 ss.branch = args.get('branch', None) ss.revision = args.get('revision', None) ss.project = '' ss.repository = '' ss.patch = patch ss.patch_info = None ss.changes = [] self.build.path_module = os.path self.build.getSourceStamp = lambda x=None: ss return step buildbot-0.8.8/buildbot/test/util/steps.py000066400000000000000000000215321222546025000206040ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import mock from buildbot import interfaces from buildbot.process import buildstep from buildbot.test.fake import remotecommand, fakebuild, slave class BuildStepMixin(object): """ Support for testing build steps. This class adds two capabilities: - patch out RemoteCommand with fake versions that check expected commands and produce the appropriate results - surround a step with the mock objects that it needs to execute The following instance variables are available after C{setupStep}: @ivar step: the step under test @ivar build: the fake build containing the step @ivar progress: mock progress object @ivar buildslave: mock buildslave object @ivar step_status: mock StepStatus object @ivar properties: build properties (L{Properties} instance) """ def setUpBuildStep(self): # make an (admittedly global) reference to this test case so that # the fakes can call back to us remotecommand.FakeRemoteCommand.testcase = self self.patch(buildstep, 'RemoteCommand', remotecommand.FakeRemoteCommand) self.patch(buildstep, 'RemoteShellCommand', remotecommand.FakeRemoteShellCommand) self.expected_remote_commands = [] def tearDownBuildStep(self): # delete the reference added in setUp del remotecommand.FakeRemoteCommand.testcase # utilities def setupStep(self, step, slave_version={'*':"99.99"}, slave_env={}): """ Set up C{step} for testing. This begins by using C{step} as a factory to create a I{new} step instance, thereby testing that the the factory arguments are handled correctly. It then creates a comfortable environment for the slave to run in, repleate with a fake build and a fake slave. As a convenience, it calls the step's setDefaultWorkdir method with C{'wkdir'}. @param slave_version: slave version to present, as a dictionary mapping command name to version. A command name of '*' will apply for all commands. @param slave_env: environment from the slave at slave startup """ factory = interfaces.IBuildStepFactory(step) step = self.step = factory.buildStep() # step.build b = self.build = fakebuild.FakeBuild() def getSlaveVersion(cmd, oldversion): if cmd in slave_version: return slave_version[cmd] if '*' in slave_version: return slave_version['*'] return oldversion b.getSlaveCommandVersion = getSlaveVersion b.slaveEnvironment = slave_env.copy() step.setBuild(b) # watch for properties being set self.properties = interfaces.IProperties(b) # step.progress step.progress = mock.Mock(name="progress") # step.buildslave self.buildslave = step.buildslave = slave.FakeSlave() # step.step_status ss = self.step_status = mock.Mock(name="step_status") ss.status_text = None ss.logs = {} def ss_setText(strings): ss.status_text = strings ss.setText = ss_setText ss.getLogs = lambda : ss.logs.values() self.step_statistics = {} ss.setStatistic = self.step_statistics.__setitem__ ss.getStatistic = self.step_statistics.get ss.hasStatistic = self.step_statistics.__contains__ self.step.setStepStatus(ss) # step overrides def addLog(name): l = remotecommand.FakeLogFile(name, step) ss.logs[name] = l return l step.addLog = addLog def addHTMLLog(name, html): l = remotecommand.FakeLogFile(name, step) l.addStdout(html) ss.logs[name] = l return l step.addHTMLLog = addHTMLLog def addCompleteLog(name, text): l = remotecommand.FakeLogFile(name, step) l.addStdout(text) ss.logs[name] = l return l step.addCompleteLog = addCompleteLog step.logobservers = self.logobservers = {} def addLogObserver(logname, observer): self.logobservers.setdefault(logname, []).append(observer) observer.step = step step.addLogObserver = addLogObserver # set defaults step.setDefaultWorkdir('wkdir') # expectations self.exp_outcome = None self.exp_properties = {} self.exp_missing_properties = [] self.exp_logfiles = {} self.exp_hidden = False return step def expectCommands(self, *exp): """ Add to the expected remote commands, along with their results. Each argument should be an instance of L{Expect}. """ self.expected_remote_commands.extend(exp) def expectOutcome(self, result, status_text): """ Expect the given result (from L{buildbot.status.results}) and status text (a list). """ self.exp_outcome = dict(result=result, status_text=status_text) def expectProperty(self, property, value, source=None): """ Expect the given property to be set when the step is complete. """ self.exp_properties[property] = (value, source) def expectNoProperty(self, property): """ Expect the given property is *not* set when the step is complete """ self.exp_missing_properties.append(property) def expectLogfile(self, logfile, contents): """ Expect a logfile with the given contents """ self.exp_logfiles[logfile] = contents def expectHidden(self, hidden): """ Set whether the step is expected to be hidden. """ self.exp_hidden = hidden def runStep(self): """ Run the step set up with L{setupStep}, and check the results. @returns: Deferred """ self.remote = mock.Mock(name="SlaveBuilder(remote)") # TODO: self.step.setupProgress() d = self.step.startStep(self.remote) def check(result): self.assertEqual(self.expected_remote_commands, [], "assert all expected commands were run") got_outcome = dict(result=result, status_text=self.step_status.status_text) self.assertEqual(got_outcome, self.exp_outcome, "expected step outcome") for pn, (pv, ps) in self.exp_properties.iteritems(): self.assertTrue(self.properties.hasProperty(pn), "missing property '%s'" % pn) self.assertEqual(self.properties.getProperty(pn), pv, "property '%s'" % pn) if ps is not None: self.assertEqual(self.properties.getPropertySource(pn), ps, "property '%s' source" % pn) for pn in self.exp_missing_properties: self.assertFalse(self.properties.hasProperty(pn), "unexpected property '%s'" % pn) for log, contents in self.exp_logfiles.iteritems(): self.assertEqual(self.step_status.logs[log].stdout, contents, "log '%s' contents" % log) self.step_status.setHidden.assert_called_once_with(self.exp_hidden) d.addCallback(check) return d # callbacks from the running step def _remotecommand_run(self, command, step, remote): self.assertEqual(step, self.step) self.assertEqual(remote, self.remote) got = (command.remote_command, command.args) if not self.expected_remote_commands: self.fail("got command %r when no further commands were expected" % (got,)) exp = self.expected_remote_commands.pop(0) # handle any incomparable args for arg in exp.incomparable_args: self.failUnless(arg in got[1], "incomparable arg '%s' not received" % (arg,)) del got[1][arg] # first check any ExpectedRemoteReference instances self.assertEqual((exp.remote_command, exp.args), got) # let the Expect object show any behaviors that are required d = exp.runBehaviors(command) d.addCallback(lambda _: command) return d buildbot-0.8.8/buildbot/util/000077500000000000000000000000001222546025000161125ustar00rootroot00000000000000buildbot-0.8.8/buildbot/util/__init__.py000066400000000000000000000137421222546025000202320ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import time, re, string import datetime import calendar from buildbot.util.misc import deferredLocked, SerializedInvocation def naturalSort(l): l = l[:] def try_int(s): try: return int(s) except ValueError: return s def key_func(item): return [try_int(s) for s in re.split('(\d+)', item)] # prepend integer keys to each element, sort them, then strip the keys keyed_l = [ (key_func(i), i) for i in l ] keyed_l.sort() l = [ i[1] for i in keyed_l ] return l def flatten(l): if l and type(l[0]) == list: rv = [] for e in l: if type(e) == list: rv.extend(flatten(e)) else: rv.append(e) return rv else: return l def now(_reactor=None): if _reactor and hasattr(_reactor, "seconds"): return _reactor.seconds() else: return time.time() def formatInterval(eta): eta_parts = [] if eta > 3600: eta_parts.append("%d hrs" % (eta / 3600)) eta %= 3600 if eta > 60: eta_parts.append("%d mins" % (eta / 60)) eta %= 60 eta_parts.append("%d secs" % eta) return ", ".join(eta_parts) class ComparableMixin: compare_attrs = [] class _None: pass def __hash__(self): alist = [self.__class__] + \ [getattr(self, name, self._None) for name in self.compare_attrs] return hash(tuple(map(str, alist))) def __cmp__(self, them): result = cmp(type(self), type(them)) if result: return result result = cmp(self.__class__.__name__, them.__class__.__name__) if result: return result result = cmp(self.compare_attrs, them.compare_attrs) if result: return result self_list = [getattr(self, name, self._None) for name in self.compare_attrs] them_list = [getattr(them, name, self._None) for name in self.compare_attrs] return cmp(self_list, them_list) def diffSets(old, new): if not isinstance(old, set): old = set(old) if not isinstance(new, set): new = set(new) return old - new, new - old # Remove potentially harmful characters from builder name if it is to be # used as the build dir. badchars_map = string.maketrans("\t !#$%&'()*+,./:;<=>?@[\\]^{|}~", "______________________________") def safeTranslate(str): if isinstance(str, unicode): str = str.encode('utf8') return str.translate(badchars_map) def none_or_str(x): if x is not None and not isinstance(x, str): return str(x) return x # place a working json module at 'buildbot.util.json'. Code is adapted from # Paul Wise : # http://lists.debian.org/debian-python/2010/02/msg00016.html # json doesn't exist as a standard module until python2.6 # However python2.6's json module is much slower than simplejson, so we prefer # to use simplejson if available. try: import simplejson as json assert json except ImportError: import json # python 2.6 or 2.7 try: _tmp = json.loads except AttributeError: import warnings import sys warnings.warn("Use simplejson, not the old json module.") sys.modules.pop('json') # get rid of the bad json module import simplejson as json # changes and schedulers consider None to be a legitimate name for a branch, # which makes default function keyword arguments hard to handle. This value # is always false. class NotABranch: def __nonzero__(self): return False NotABranch = NotABranch() # time-handling methods class UTC(datetime.tzinfo): """Simple definition of UTC timezone""" def utcoffset(self, dt): return datetime.timedelta(0) def dst(self, dt): return datetime.timedelta(0) def tzname(self): return "UTC" UTC = UTC() def epoch2datetime(epoch): """Convert a UNIX epoch time to a datetime object, in the UTC timezone""" if epoch is not None: return datetime.datetime.fromtimestamp(epoch, tz=UTC) def datetime2epoch(dt): """Convert a non-naive datetime object to a UNIX epoch timestamp""" if dt is not None: return calendar.timegm(dt.utctimetuple()) def makeList(input): if isinstance(input, basestring): return [ input ] elif input is None: return [ ] else: return list(input) def in_reactor(f): """decorate a function by running it with maybeDeferred in a reactor""" def wrap(*args, **kwargs): from twisted.internet import reactor, defer result = [ ] def async(): d = defer.maybeDeferred(f, *args, **kwargs) def eb(f): f.printTraceback() d.addErrback(eb) def do_stop(r): result.append(r) reactor.stop() d.addBoth(do_stop) reactor.callWhenRunning(async) reactor.run() return result[0] wrap.__doc__ = f.__doc__ wrap.__name__ = f.__name__ wrap._orig = f # for tests return wrap __all__ = [ 'naturalSort', 'now', 'formatInterval', 'ComparableMixin', 'json', 'safeTranslate', 'LRUCache', 'none_or_str', 'NotABranch', 'deferredLocked', 'SerializedInvocation', 'UTC', 'diffLists', 'makeList', 'in_reactor' ] buildbot-0.8.8/buildbot/util/bbcollections.py000066400000000000000000000025531222546025000213130ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # this is here for compatibility from collections import defaultdict assert defaultdict class KeyedSets: def __init__(self): self.d = dict() def add(self, key, value): if key not in self.d: self.d[key] = set() self.d[key].add(value) def discard(self, key, value): if key in self.d: self.d[key].discard(value) if not self.d[key]: del self.d[key] def __contains__(self, key): return key in self.d def __getitem__(self, key): return self.d.get(key, set()) def pop(self, key): if key in self.d: return self.d.pop(key) return set() buildbot-0.8.8/buildbot/util/croniter.py000066400000000000000000000241271222546025000203170ustar00rootroot00000000000000# Copied from croniter # https://github.com/taichino/croniter # Licensed under MIT license # Pyflakes warnings corrected #!/usr/bin/python # -*- coding: utf-8 -*- import re from time import time, mktime from datetime import datetime from dateutil.relativedelta import relativedelta search_re = re.compile(r'^([^-]+)-([^-/]+)(/(.*))?$') only_int_re = re.compile(r'^\d+$') any_int_re = re.compile(r'^\d+') star_or_int_re = re.compile(r'^(\d+|\*)$') __all__ = ('croniter',) class croniter(object): RANGES = ( (0, 59), (0, 23), (1, 31), (1, 12), (0, 6), (0, 59) ) DAYS = ( 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ) ALPHACONV = ( { }, { }, { }, { 'jan':1, 'feb':2, 'mar':3, 'apr':4, 'may':5, 'jun':6, 'jul':7, 'aug':8, 'sep':9, 'oct':10, 'nov':11, 'dec':12 }, { 'sun':0, 'mon':1, 'tue':2, 'wed':3, 'thu':4, 'fri':5, 'sat':0 }, { } ) LOWMAP = ( {}, {}, {0: 1}, {0: 1}, {7: 0}, {}, ) bad_length = 'Exactly 5 or 6 columns has to be specified for iterator' \ 'expression.' def __init__(self, expr_format, start_time=time()): if isinstance(start_time, datetime): start_time = mktime(start_time.timetuple()) self.cur = start_time self.exprs = expr_format.split() if len(self.exprs) != 5 and len(self.exprs) != 6: raise ValueError(self.bad_length) expanded = [] for i, expr in enumerate(self.exprs): e_list = expr.split(',') res = [] while len(e_list) > 0: e = e_list.pop() t = re.sub(r'^\*(/.+)$', r'%d-%d\1' % (self.RANGES[i][0], self.RANGES[i][1]), str(e)) m = search_re.search(t) if m: (low, high, step) = m.group(1), m.group(2), m.group(4) or 1 if not any_int_re.search(low): low = self.ALPHACONV[i][low.lower()] if not any_int_re.search(high): high = self.ALPHACONV[i][high.lower()] if (not low or not high or int(low) > int(high) or not only_int_re.search(str(step))): raise ValueError("[%s] is not acceptable" %expr_format) for j in xrange(int(low), int(high)+1): if j % int(step) == 0: e_list.append(j) else: if not star_or_int_re.search(t): t = self.ALPHACONV[i][t.lower()] try: t = int(t) except: pass if t in self.LOWMAP[i]: t = self.LOWMAP[i][t] if t != '*' and (int(t) < self.RANGES[i][0] or int(t) > self.RANGES[i][1]): raise ValueError("[%s] is not acceptable, out of range" % expr_format) res.append(t) res.sort() expanded.append(['*'] if (len(res) == 1 and res[0] == '*') else res) self.expanded = expanded def get_next(self, ret_type=float): return self._get_next(ret_type, is_prev=False) def get_prev(self, ret_type=float): return self._get_next(ret_type, is_prev=True) def _get_next(self, ret_type=float, is_prev=False): expanded = self.expanded[:] if ret_type not in (float, datetime): raise TypeError("Invalid ret_type, only 'float' or 'datetime' " \ "is acceptable.") if expanded[2][0] != '*' and expanded[4][0] != '*': bak = expanded[4] expanded[4] = ['*'] t1 = self._calc(self.cur, expanded, is_prev) expanded[4] = bak expanded[2] = ['*'] t2 = self._calc(self.cur, expanded, is_prev) if not is_prev: result = t1 if t1 < t2 else t2 else: result = t1 if t1 > t2 else t2 else: result = self._calc(self.cur, expanded, is_prev) self.cur = result if ret_type == datetime: result = datetime.fromtimestamp(result) return result def _calc(self, now, expanded, is_prev): if is_prev: nearest_diff_method = self._get_prev_nearest_diff sign = -1 else: nearest_diff_method = self._get_next_nearest_diff sign = 1 offset = len(expanded) == 6 and 1 or 60 dst = now = datetime.fromtimestamp(now + sign * offset) day, month, year = dst.day, dst.month, dst.year current_year = now.year DAYS = self.DAYS def proc_month(d): if expanded[3][0] != '*': diff_month = nearest_diff_method(d.month, expanded[3], 12) days = DAYS[month - 1] if month == 2 and self.is_leap(year) == True: days += 1 reset_day = days if is_prev else 1 if diff_month != None and diff_month != 0: if is_prev: d += relativedelta(months=diff_month) else: d += relativedelta(months=diff_month, day=reset_day, hour=0, minute=0, second=0) return True, d return False, d def proc_day_of_month(d): if expanded[2][0] != '*': days = DAYS[month - 1] if month == 2 and self.is_leap(year) == True: days += 1 diff_day = nearest_diff_method(d.day, expanded[2], days) if diff_day != None and diff_day != 0: if is_prev: d += relativedelta(days=diff_day) else: d += relativedelta(days=diff_day, hour=0, minute=0, second=0) return True, d return False, d def proc_day_of_week(d): if expanded[4][0] != '*': diff_day_of_week = nearest_diff_method(d.isoweekday() % 7, expanded[4], 7) if diff_day_of_week != None and diff_day_of_week != 0: if is_prev: d += relativedelta(days=diff_day_of_week) else: d += relativedelta(days=diff_day_of_week, hour=0, minute=0, second=0) return True, d return False, d def proc_hour(d): if expanded[1][0] != '*': diff_hour = nearest_diff_method(d.hour, expanded[1], 24) if diff_hour != None and diff_hour != 0: if is_prev: d += relativedelta(hours = diff_hour) else: d += relativedelta(hours = diff_hour, minute=0, second=0) return True, d return False, d def proc_minute(d): if expanded[0][0] != '*': diff_min = nearest_diff_method(d.minute, expanded[0], 60) if diff_min != None and diff_min != 0: if is_prev: d += relativedelta(minutes = diff_min) else: d += relativedelta(minutes = diff_min, second=0) return True, d return False, d def proc_second(d): if len(expanded) == 6: if expanded[5][0] != '*': diff_sec = nearest_diff_method(d.second, expanded[5], 60) if diff_sec != None and diff_sec != 0: d += relativedelta(seconds = diff_sec) return True, d else: d += relativedelta(second = 0) return False, d if is_prev: procs = [proc_second, proc_minute, proc_hour, proc_day_of_week, proc_day_of_month, proc_month] else: procs = [proc_month, proc_day_of_month, proc_day_of_week, proc_hour, proc_minute, proc_second] while abs(year - current_year) <= 1: next = False for proc in procs: (changed, dst) = proc(dst) if changed: next = True break if next: continue return mktime(dst.timetuple()) raise "failed to find prev date" def _get_next_nearest(self, x, to_check): small = [item for item in to_check if item < x] large = [item for item in to_check if item >= x] large.extend(small) return large[0] def _get_prev_nearest(self, x, to_check): small = [item for item in to_check if item <= x] large = [item for item in to_check if item > x] small.reverse() large.reverse() small.extend(large) return small[0] def _get_next_nearest_diff(self, x, to_check, range_val): for i, d in enumerate(to_check): if d >= x: return d - x return to_check[0] - x + range_val def _get_prev_nearest_diff(self, x, to_check, range_val): candidates = to_check[:] candidates.reverse() for d in candidates: if d <= x: return d - x return (candidates[0]) - x - range_val def is_leap(self, year): if year % 400 == 0 or (year % 4 == 0 and year % 100 != 0): return True else: return False if __name__ == '__main__': base = datetime(2010, 1, 25) itr = croniter('0 0 1 * *', base) n1 = itr.get_next(datetime) print n1 buildbot-0.8.8/buildbot/util/eventual.py000066400000000000000000000052641222546025000203160ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # copied from foolscap from twisted.internet import reactor, defer from twisted.python import log class _SimpleCallQueue(object): _reactor = reactor def __init__(self): self._events = [] self._flushObservers = [] self._timer = None self._in_turn = False def append(self, cb, args, kwargs): self._events.append((cb, args, kwargs)) if not self._timer: self._timer = self._reactor.callLater(0, self._turn) def _turn(self): self._timer = None self._in_turn = True # flush all the messages that are currently in the queue. If anything # gets added to the queue while we're doing this, those events will # be put off until the next turn. events, self._events = self._events, [] for cb, args, kwargs in events: try: cb(*args, **kwargs) except: log.err() self._in_turn = False if self._events and not self._timer: self._timer = self._reactor.callLater(0, self._turn) if not self._events: observers, self._flushObservers = self._flushObservers, [] for o in observers: o.callback(None) def flush(self): if not self._events and not self._in_turn: return defer.succeed(None) d = defer.Deferred() self._flushObservers.append(d) return d _theSimpleQueue = _SimpleCallQueue() def eventually(cb, *args, **kwargs): _theSimpleQueue.append(cb, args, kwargs) def fireEventually(value=None): d = defer.Deferred() eventually(d.callback, value) return d def flushEventualQueue(_ignored=None): return _theSimpleQueue.flush() def _setReactor(r=None): # This sets the reactor used to schedule future events to r. If r is None # (the default), the reactor is reset to its default value. # This should only be used for unit tests. if r is None: r = reactor _theSimpleQueue._reactor = r buildbot-0.8.8/buildbot/util/lru.py000066400000000000000000000160311222546025000172670ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from weakref import WeakValueDictionary from itertools import ifilterfalse from twisted.python import log from twisted.internet import defer from collections import deque from collections import defaultdict class LRUCache(object): """ A least-recently-used cache, with a fixed maximum size. See buildbot manual for more information. """ __slots__ = ('max_size max_queue miss_fn queue cache weakrefs ' 'refcount hits refhits misses'.split()) sentinel = object() QUEUE_SIZE_FACTOR = 10 def __init__(self, miss_fn, max_size=50): self.max_size = max_size self.max_queue = max_size * self.QUEUE_SIZE_FACTOR self.queue = deque() self.cache = {} self.weakrefs = WeakValueDictionary() self.hits = self.misses = self.refhits = 0 self.refcount = defaultdict(lambda : 0) self.miss_fn = miss_fn def put(self, key, value): if key in self.cache: self.cache[key] = value self.weakrefs[key] = value elif key in self.weakrefs: self.weakrefs[key] = value def get(self, key, **miss_fn_kwargs): try: return self._get_hit(key) except KeyError: pass self.misses += 1 result = self.miss_fn(key, **miss_fn_kwargs) if result is not None: self.cache[key] = result self.weakrefs[key] = result self._ref_key(key) self._purge() return result def keys(self): return self.cache.keys() def set_max_size(self, max_size): if self.max_size == max_size: return self.max_size = max_size self.max_queue = max_size * self.QUEUE_SIZE_FACTOR self._purge() def inv(self): global inv_failed # the keys of the queue and cache should be identical cache_keys = set(self.cache.keys()) queue_keys = set(self.queue) if queue_keys - cache_keys: log.msg("INV: uncached keys in queue:", queue_keys - cache_keys) inv_failed = True if cache_keys - queue_keys: log.msg("INV: unqueued keys in cache:", cache_keys - queue_keys) inv_failed = True # refcount should always represent the number of times each key appears # in the queue exp_refcount = dict() for k in self.queue: exp_refcount[k] = exp_refcount.get(k, 0) + 1 if exp_refcount != self.refcount: log.msg("INV: refcounts differ:") log.msg(" expected:", sorted(exp_refcount.items())) log.msg(" got:", sorted(self.refcount.items())) inv_failed = True def _ref_key(self, key): """Record a reference to the argument key.""" queue = self.queue refcount = self.refcount queue.append(key) refcount[key] = refcount[key] + 1 # periodically compact the queue by eliminating duplicate keys # while preserving order of most recent access. Note that this # is only required when the cache does not exceed its maximum # size if len(queue) > self.max_queue: refcount.clear() queue_appendleft = queue.appendleft queue_appendleft(self.sentinel) for k in ifilterfalse(refcount.__contains__, iter(queue.pop, self.sentinel)): queue_appendleft(k) refcount[k] = 1 def _get_hit(self, key): """Try to do a value lookup from the existing cache entries.""" try: result = self.cache[key] self.hits += 1 self._ref_key(key) return result except KeyError: pass result = self.weakrefs[key] self.refhits += 1 self.cache[key] = result self._ref_key(key) return result def _purge(self): """ Trim the cache down to max_size by evicting the least-recently-used entries. """ if len(self.cache) <= self.max_size: return cache = self.cache refcount = self.refcount queue = self.queue max_size = self.max_size # purge least recently used entries, using refcount to count entries # that appear multiple times in the queue while len(cache) > max_size: refc = 1 while refc: k = queue.popleft() refc = refcount[k] = refcount[k] - 1 del cache[k] del refcount[k] class AsyncLRUCache(LRUCache): """ An LRU cache with asynchronous locking to ensure that in the common case of multiple concurrent requests for the same key, only one fetch is performed. """ __slots__ = ['concurrent'] def __init__(self, miss_fn, max_size=50): LRUCache.__init__(self, miss_fn, max_size=max_size) self.concurrent = {} def get(self, key, **miss_fn_kwargs): try: result = self._get_hit(key) return defer.succeed(result) except KeyError: pass concurrent = self.concurrent conc = concurrent.get(key) if conc: self.hits += 1 d = defer.Deferred() conc.append(d) return d # if we're here, we've missed and need to fetch self.misses += 1 # create a list of waiting deferreds for this key d = defer.Deferred() assert key not in concurrent concurrent[key] = [ d ] miss_d = self.miss_fn(key, **miss_fn_kwargs) def handle_result(result): if result is not None: self.cache[key] = result self.weakrefs[key] = result # reference the key once, possibly standing in for multiple # concurrent accesses self._ref_key(key) self._purge() # and fire all of the waiting Deferreds dlist = concurrent.pop(key) for d in dlist: d.callback(result) def handle_failure(f): # errback all of the waiting Deferreds dlist = concurrent.pop(key) for d in dlist: d.errback(f) miss_d.addCallbacks(handle_result, handle_failure) miss_d.addErrback(log.err) return d # for tests inv_failed = False buildbot-0.8.8/buildbot/util/maildir.py000066400000000000000000000135221222546025000201100ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members # This is a class which watches a maildir for new messages. It uses the # linux dirwatcher API (if available) to look for new files. The # .messageReceived method is invoked with the filename of the new message, # relative to the top of the maildir (so it will look like "new/blahblah"). import os from twisted.python import log, runtime from twisted.application import service, internet from twisted.internet import reactor, defer dnotify = None try: import dnotify except: log.msg("unable to import dnotify, so Maildir will use polling instead") class NoSuchMaildir(Exception): pass class MaildirService(service.MultiService): pollinterval = 10 # only used if we don't have DNotify def __init__(self, basedir=None): service.MultiService.__init__(self) if basedir: self.setBasedir(basedir) self.files = [] self.dnotify = None self.timerService = None def setBasedir(self, basedir): # some users of MaildirService (scheduler.Try_Jobdir, in particular) # don't know their basedir until setServiceParent, since it is # relative to the buildmaster's basedir. So let them set it late. We # don't actually need it until our own startService. self.basedir = basedir self.newdir = os.path.join(self.basedir, "new") self.curdir = os.path.join(self.basedir, "cur") def startService(self): service.MultiService.startService(self) if not os.path.isdir(self.newdir) or not os.path.isdir(self.curdir): raise NoSuchMaildir("invalid maildir '%s'" % self.basedir) try: if dnotify: # we must hold an fd open on the directory, so we can get # notified when it changes. self.dnotify = dnotify.DNotify(self.newdir, self.dnotify_callback, [dnotify.DNotify.DN_CREATE]) except (IOError, OverflowError): # IOError is probably linux<2.4.19, which doesn't support # dnotify. OverflowError will occur on some 64-bit machines # because of a python bug log.msg("DNotify failed, falling back to polling") if not self.dnotify: self.timerService = internet.TimerService(self.pollinterval, self.poll) self.timerService.setServiceParent(self) self.poll() def dnotify_callback(self): log.msg("dnotify noticed something, now polling") # give it a moment. I found that qmail had problems when the message # was removed from the maildir instantly. It shouldn't, that's what # maildirs are made for. I wasn't able to eyeball any reason for the # problem, and safecat didn't behave the same way, but qmail reports # "Temporary_error_on_maildir_delivery" (qmail-local.c:165, # maildir_child() process exited with rc not in 0,2,3,4). Not sure # why, and I'd have to hack qmail to investigate further, so it's # easier to just wait a second before yanking the message out of new/ reactor.callLater(0.1, self.poll) def stopService(self): if self.dnotify: self.dnotify.remove() self.dnotify = None if self.timerService is not None: self.timerService.disownServiceParent() self.timerService = None return service.MultiService.stopService(self) @defer.inlineCallbacks def poll(self): try: assert self.basedir # see what's new for f in self.files: if not os.path.isfile(os.path.join(self.newdir, f)): self.files.remove(f) newfiles = [] for f in os.listdir(self.newdir): if not f in self.files: newfiles.append(f) self.files.extend(newfiles) for n in newfiles: try: yield self.messageReceived(n) except: log.err(None, "while reading '%s' from maildir '%s':" % (n, self.basedir)) except Exception: log.err(None, "while polling maildir '%s':" % (self.basedir,)) def moveToCurDir(self, filename): if runtime.platformType == "posix": # open the file before moving it, because I'm afraid that once # it's in cur/, someone might delete it at any moment path = os.path.join(self.newdir, filename) f = open(path, "r") os.rename(os.path.join(self.newdir, filename), os.path.join(self.curdir, filename)) elif runtime.platformType == "win32": # do this backwards under windows, because you can't move a file # that somebody is holding open. This was causing a Permission # Denied error on bear's win32-twisted1.3 buildslave. os.rename(os.path.join(self.newdir, filename), os.path.join(self.curdir, filename)) path = os.path.join(self.curdir, filename) f = open(path, "r") return f def messageReceived(self, filename): raise NotImplementedError buildbot-0.8.8/buildbot/util/misc.py000066400000000000000000000041551222546025000174240ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members """ Miscellaneous utilities; these should be imported from C{buildbot.util}, not directly from this module. """ from twisted.python import log from twisted.internet import defer def deferredLocked(lock_or_attr): def decorator(fn): def wrapper(*args, **kwargs): lock = lock_or_attr if isinstance(lock, basestring): lock = getattr(args[0], lock) return lock.run(fn, *args, **kwargs) return wrapper return decorator class SerializedInvocation(object): def __init__(self, method): self.method = method self.running = False self.pending_deferreds = [] def __call__(self): d = defer.Deferred() self.pending_deferreds.append(d) if not self.running: self.start() return d def start(self): self.running = True invocation_deferreds = self.pending_deferreds self.pending_deferreds = [] d = self.method() d.addErrback(log.err, 'in invocation of %r' % (self.method,)) def notify_callers(_): for d in invocation_deferreds: d.callback(None) d.addCallback(notify_callers) def next(_): self.running = False if self.pending_deferreds: self.start() else: self._quiet() d.addBoth(next) def _quiet(self): # hook for tests pass buildbot-0.8.8/buildbot/util/netstrings.py000066400000000000000000000043061222546025000206670ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.protocols import basic from zope.interface import implements from twisted.internet.interfaces import IAddress, ITransport class NullAddress(object): "an address for NullTransport" implements(IAddress) class NullTransport(object): "a do-nothing transport to make NetstringReceiver happy" implements(ITransport) def write(self, data): raise NotImplementedError def writeSequence(self, data): raise NotImplementedError def loseConnection(self): pass def getPeer(self): return NullAddress def getHost(self): return NullAddress class NetstringParser(basic.NetstringReceiver): """ Adapts the Twisted netstring support (which assumes it is on a socket) to work on simple strings, too. Call the C{feed} method with arbitrary blocks of data, and override the C{stringReceived} method to get called for each embedded netstring. The default implementation collects the netstrings in the list C{self.strings}. """ def __init__(self): # most of the complexity here is stubbing out the transport code so # that Twisted-10.2.0 and higher believes that this is a valid protocol self.makeConnection(NullTransport()) self.strings = [] def feed(self, data): """ """ self.dataReceived(data) # dataReceived handles errors unusually quietly! if self.brokenPeer: raise basic.NetstringParseError def stringReceived(self, string): self.strings.append(string) buildbot-0.8.8/buildbot/util/sautils.py000066400000000000000000000030601222546025000201470ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members import sqlalchemy as sa from sqlalchemy.ext import compiler from sqlalchemy.sql.expression import Executable, ClauseElement # from http://www.sqlalchemy.org/docs/core/compiler.html#compiling-sub-elements-of-a-custom-expression-construct class InsertFromSelect(Executable, ClauseElement): def __init__(self, table, select): self.table = table self.select = select @compiler.compiles(InsertFromSelect) def _visit_insert_from_select(element, compiler, **kw): return "INSERT INTO %s %s" % ( compiler.process(element.table, asfrom=True), compiler.process(element.select) ) def sa_version(): if hasattr(sa, '__version__'): def tryint(s): try: return int(s) except: return -1 return tuple(map(tryint, sa.__version__.split('.'))) return (0,0,0) # "it's old" buildbot-0.8.8/buildbot/util/state.py000066400000000000000000000016731222546025000176130ustar00rootroot00000000000000from twisted.internet import defer class StateMixin(object): ## state management _objectid = None @defer.inlineCallbacks def getState(self, *args, **kwargs): # get the objectid, if not known if self._objectid is None: self._objectid = yield self.master.db.state.getObjectId(self.name, self.__class__.__name__) rv = yield self.master.db.state.getState(self._objectid, *args, **kwargs) defer.returnValue(rv) @defer.inlineCallbacks def setState(self, key, value): # get the objectid, if not known if self._objectid is None: self._objectid = yield self.master.db.state.getObjectId(self.name, self.__class__.__name__) yield self.master.db.state.setState(self._objectid, key, value) buildbot-0.8.8/buildbot/util/subscription.py000066400000000000000000000032001222546025000212030ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from twisted.python import failure, log class SubscriptionPoint(object): def __init__(self, name): self.name = name self.subscriptions = set() def __str__(self): return "" % self.name def subscribe(self, callback): sub = Subscription(self, callback) self.subscriptions.add(sub) return sub def deliver(self, *args, **kwargs): for sub in list(self.subscriptions): try: sub.callback(*args, **kwargs) except: log.err(failure.Failure(), 'while invoking callback %s to %s' % (sub.callback, self)) def _unsubscribe(self, subscription): self.subscriptions.remove(subscription) class Subscription(object): def __init__(self, subpt, callback): self.subpt = subpt self.callback = callback def unsubscribe(self): self.subpt._unsubscribe(self) buildbot-0.8.8/contrib/000077500000000000000000000000001222546025000147715ustar00rootroot00000000000000buildbot-0.8.8/contrib/README.txt000066400000000000000000000046111222546025000164710ustar00rootroot00000000000000Utility scripts, things contributed by users but not strictly a part of buildbot: buildbot_json.py: Utility classes and standalone script to process data from /json status. fakechange.py: connect to a running bb and submit a fake change to trigger builders generate_changelog.py: generated changelog entry using git. Requires git to be installed. run_maxq.py: a builder-helper for running maxq under buildbot svn_buildbot.py: a script intended to be run from a subversion hook-script which submits changes to svn (requires python 2.3) svnpoller.py: this script is intended to be run from a cronjob, and uses 'svn log' to poll a (possibly remote) SVN repository for changes. For each change it finds, it runs 'buildbot sendchange' to deliver them to a waiting PBChangeSource on a (possibly remote) buildmaster. Modify the svnurl to point at your own SVN repository, and of course the user running the script must have read permissions to that repository. It keeps track of the last revision in a file, change 'fname' to set the location of this state file. Modify the --master argument to the 'buildbot sendchange' command to point at your buildmaster. Contributed by John Pye. Note that if there are multiple changes within a single polling interval, this will miss all but the last one. svn_watcher.py: adapted from svnpoller.py by Niklaus Giger to add options and run under windows. Runs as a standalone script (it loops internally rather than expecting to run from a cronjob), polls an SVN repository every 10 minutes. It expects the svnurl and buildmaster location as command-line arguments. viewcvspoll.py: a standalone script which loops every 60 seconds and polls a (local?) MySQL database (presumably maintained by ViewCVS?) for information about new CVS changes, then delivers them over PB to a remote buildmaster's PBChangeSource. Contributed by Stephen Kennedy. css/*.css: alternative HTML stylesheets to make the Waterfall display look prettier. Copy them somewhere, then pass the filename to the css= argument of the Waterfall() constructor. buildbot-0.8.8/contrib/bb_applet.py000077500000000000000000000345241222546025000173060ustar00rootroot00000000000000#! /usr/bin/python # This is a Gnome-2 panel applet that uses the # buildbot.status.client.PBListener interface to display a terse summary of # the buildmaster. It displays one column per builder, with a box on top for # the status of the most recent build (red, green, or orange), and a somewhat # smaller box on the bottom for the current state of the builder (white for # idle, yellow for building, red for offline). There are tooltips available # to tell you which box is which. # Edit the line at the beginning of the MyApplet class to fill in the host # and portnumber of your buildmaster's PBListener status port. Eventually # this will move into a preferences dialog, but first we must create a # preferences dialog. # See the notes at the end for installation hints and support files (you # cannot simply run this script from the shell). You must create a bonobo # .server file that points to this script, and put the .server file somewhere # that bonobo will look for it. Only then will this applet appear in the # panel's "Add Applet" menu. # Note: These applets are run in an environment that throws away stdout and # stderr. Any logging must be done with syslog or explicitly to a file. # Exceptions are particularly annoying in such an environment. # -Brian Warner, warner@lothar.com if 0: import sys dpipe = open("/tmp/applet.log", "a", 1) sys.stdout = dpipe sys.stderr = dpipe print "starting" from twisted.internet import gtk2reactor gtk2reactor.install() #@UndefinedVariable import gtk #@UnresolvedImport import gnomeapplet #@UnresolvedImport # preferences are not yet implemented MENU = """ """ from twisted.spread import pb from twisted.cred import credentials # sigh, these constants should cross the wire as strings, not integers SUCCESS, WARNINGS, FAILURE, SKIPPED, EXCEPTION = range(5) Results = ["success", "warnings", "failure", "skipped", "exception"] class Box: def __init__(self, buildername, hbox, tips, size, hslice): self.buildername = buildername self.hbox = hbox self.tips = tips self.state = "idle" self.eta = None self.last_results = None self.last_text = None self.size = size self.hslice = hslice def create(self): self.vbox = gtk.VBox(False) l = gtk.Label(".") self.current_box = box = gtk.EventBox() # these size requests are somewhat non-deterministic. I think it # depends upon how large label is, or how much space was already # consumed when the box is added. self.current_box.set_size_request(self.hslice, self.size * 0.75) box.add(l) self.vbox.pack_end(box) self.current_box.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse("gray50")) l2 = gtk.Label(".") self.last_box = gtk.EventBox() self.current_box.set_size_request(self.hslice, self.size * 0.25) self.last_box.add(l2) self.vbox.pack_end(self.last_box, True, True) self.vbox.show_all() self.hbox.pack_start(self.vbox, True, True) def remove(self): self.hbox.remove(self.box) def set_state(self, state): self.state = state self.update() def set_eta(self, eta): self.eta = eta self.update() def set_last_build_results(self, results): self.last_results = results self.update() def set_last_build_text(self, text): self.last_text = text self.update() def update(self): currentmap = {"offline": "red", "idle": "white", "waiting": "yellow", "interlocked": "yellow", "building": "yellow", } color = currentmap[self.state] self.current_box.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse(color)) lastmap = {None: "gray50", SUCCESS: "green", WARNINGS: "orange", FAILURE: "red", EXCEPTION: "purple", } last_color = lastmap[self.last_results] self.last_box.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse(last_color)) current_tip = "%s:\n%s" % (self.buildername, self.state) if self.eta is not None: current_tip += " (ETA=%ds)" % self.eta self.tips.set_tip(self.current_box, current_tip) last_tip = "%s:\n" % self.buildername if self.last_text: last_tip += "\n".join(self.last_text) else: last_tip += "no builds" self.tips.set_tip(self.last_box, last_tip) class MyApplet(pb.Referenceable): # CHANGE THIS TO POINT TO YOUR BUILDMASTER buildmaster = "buildmaster.example.org", 12345 filled = None def __init__(self, container): self.applet = container self.size = container.get_size() self.hslice = self.size / 4 container.set_size_request(self.size, self.size) self.fill_nut() verbs = [("Props", self.menu_preferences), ("Connect", self.menu_connect), ("Disconnect", self.menu_disconnect), ] container.setup_menu(MENU, verbs) self.boxes = {} self.connect() def fill(self, what): if self.filled: self.applet.remove(self.filled) self.filled = None self.applet.add(what) self.filled = what self.applet.show_all() def fill_nut(self): i = gtk.Image() i.set_from_file("/tmp/nut32.png") self.fill(i) def fill_hbox(self): self.hbox = gtk.HBox(True) self.fill(self.hbox) def connect(self): host, port = self.buildmaster cf = pb.PBClientFactory() creds = credentials.UsernamePassword("statusClient", "clientpw") d = cf.login(creds) reactor.connectTCP(host, port, cf) d.addCallback(self.connected) return d def connected(self, ref): print "connected" ref.notifyOnDisconnect(self.disconnected) self.remote = ref self.remote.callRemote("subscribe", "steps", 5, self) self.fill_hbox() self.tips = gtk.Tooltips() self.tips.enable() def disconnect(self): self.remote.broker.transport.loseConnection() def disconnected(self, *args): print "disconnected" self.fill_nut() def remote_builderAdded(self, buildername, builder): print "builderAdded", buildername box = Box(buildername, self.hbox, self.tips, self.size, self.hslice) self.boxes[buildername] = box box.create() self.applet.set_size_request(self.hslice * len(self.boxes), self.size) d = builder.callRemote("getLastFinishedBuild") def _got(build): if build: d1 = build.callRemote("getResults") d1.addCallback(box.set_last_build_results) d2 = build.callRemote("getText") d2.addCallback(box.set_last_build_text) d.addCallback(_got) def remote_builderRemoved(self, buildername): self.boxes[buildername].remove() del self.boxes[buildername] self.applet.set_size_request(self.hslice * len(self.boxes), self.size) def remote_builderChangedState(self, buildername, state, eta): self.boxes[buildername].set_state(state) self.boxes[buildername].set_eta(eta) print "change", buildername, state, eta def remote_buildStarted(self, buildername, build): print "buildStarted", buildername def remote_buildFinished(self, buildername, build, results): print "buildFinished", results box = self.boxes[buildername] box.set_eta(None) d1 = build.callRemote("getResults") d1.addCallback(box.set_last_build_results) d2 = build.callRemote("getText") d2.addCallback(box.set_last_build_text) def remote_buildETAUpdate(self, buildername, build, eta): self.boxes[buildername].set_eta(eta) print "ETA", buildername, eta def remote_stepStarted(self, buildername, build, stepname, step): print "stepStarted", buildername, stepname def remote_stepFinished(self, buildername, build, stepname, step, results): pass def menu_preferences(self, event, data=None): print "prefs!" p = Prefs(self) p.create() def set_buildmaster(self, buildmaster): host, port = buildmaster.split(":") self.buildmaster = host, int(port) self.disconnect() reactor.callLater(0.5, self.connect) def menu_connect(self, event, data=None): self.connect() def menu_disconnect(self, event, data=None): self.disconnect() class Prefs: def __init__(self, parent): self.parent = parent def create(self): self.w = w = gtk.Window() v = gtk.VBox() h = gtk.HBox() h.pack_start(gtk.Label("buildmaster (host:port) : ")) self.buildmaster_entry = b = gtk.Entry() if self.parent.buildmaster: host, port = self.parent.buildmaster b.set_text("%s:%d" % (host, port)) h.pack_start(b) v.add(h) b = gtk.Button("Ok") b.connect("clicked", self.done) v.add(b) w.add(v) w.show_all() def done(self, widget): buildmaster = self.buildmaster_entry.get_text() self.parent.set_buildmaster(buildmaster) self.w.unmap() def factory(applet, iid): MyApplet(applet) applet.show_all() return True from twisted.internet import reactor # instead of reactor.run(), we do the following: reactor.startRunning() reactor.simulate() gnomeapplet.bonobo_factory("OAFIID:GNOME_Buildbot_Factory", gnomeapplet.Applet.__gtype__, "buildbot", "0", factory) # code ends here: bonobo_factory runs gtk.mainloop() internally and # doesn't return until the program ends # SUPPORTING FILES: # save the following as ~/lib/bonobo/servers/bb_applet.server, and update all # the pathnames to match your system bb_applet_server = """ """ # a quick rundown on the Gnome2 applet scheme (probably wrong: there are # better docs out there that you should be following instead) # http://www.pycage.de/howto_bonobo.html describes a lot of # the base Bonobo stuff. # http://www.daa.com.au/pipermail/pygtk/2002-September/003393.html # bb_applet.server must be in your $BONOBO_ACTIVATION_PATH . I use # ~/lib/bonobo/servers . This environment variable is read by # bonobo-activation-server, so it must be set before you start any Gnome # stuff. I set it in ~/.bash_profile . You can also put it in # /usr/lib/bonobo/servers/ , which is probably on the default # $BONOBO_ACTIVATION_PATH, so you won't have to update anything. # It is safest to put this in place before bonobo-activation-server is # started, which may mean before any Gnome program is running. It may or may # not detect bb_applet.server if it is installed afterwards.. there seem to # be hooks, some of which involve FAM, but I never managed to make them work. # The file must have a name that ends in .server or it will be ignored. # The .server file registers two OAF ids and tells the activation-server how # to create those objects. The first is the GNOME_Buildbot_Factory, and is # created by running the bb_applet.py script. The second is the # GNOME_Buildbot applet itself, and is created by asking the # GNOME_Buildbot_Factory to make it. # gnome-panel's "Add To Panel" menu will gather all the OAF ids that claim # to implement the "IDL:GNOME/Vertigo/PanelAppletShell:1.0" in its # "repo_ids" attribute. The sub-menu is determined by the "panel:category" # attribute. The icon comes from "panel:icon", the text displayed in the # menu comes from "name", the text in the tool-tip comes from "description". # The factory() function is called when a new applet is created. It receives # a container that should be populated with the actual applet contents (in # this case a Button). # If you're hacking on the code, just modify bb_applet.py and then kill -9 # the running applet: the panel will ask you if you'd like to re-load the # applet, and when you say 'yes', bb_applet.py will be re-executed. Note that # 'kill PID' won't work because the program is sitting in C code, and SIGINT # isn't delivered until after it surfaces to python, which will be never. # Running bb_applet.py by itself will result in a factory instance being # created and then sitting around forever waiting for the activation-server # to ask it to make an applet. This isn't very useful. # The "location" filename in bb_applet.server must point to bb_applet.py, and # bb_applet.py must be executable. # Enjoy! # -Brian Warner buildbot-0.8.8/contrib/bitbucket_buildbot.py000077500000000000000000000150761222546025000212170ustar00rootroot00000000000000#!/usr/bin/env python """Change source forwarder for bitbucket.org POST service. bitbucket_buildbot.py will determine the repository information from the JSON HTTP POST it receives from bitbucket.org and build the appropriate repository. If your bitbucket repository is private, you must add a ssh key to the bitbucket repository for the user who initiated bitbucket_buildbot.py bitbucket_buildbot.py is based on github_buildbot.py """ import logging from optparse import OptionParser import sys import tempfile import traceback from twisted.web import server, resource from twisted.internet import reactor from twisted.spread import pb from twisted.cred import credentials try: import json except ImportError: import simplejson as json class BitBucketBuildBot(resource.Resource): """ BitBucketBuildBot creates the webserver that responds to the BitBucket POST Service Hook. """ isLeaf = True bitbucket = None master = None port = None private = False def render_POST(self, request): """ Reponds only to POST events and starts the build process :arguments: request the http request object """ try: payload = json.loads(request.args['payload'][0]) logging.debug("Payload: " + str(payload)) self.process_change(payload) except Exception: logging.error("Encountered an exception:") for msg in traceback.format_exception(*sys.exc_info()): logging.error(msg.strip()) def process_change(self, payload): """ Consumes the JSON as a python object and actually starts the build. :arguments: payload Python Object that represents the JSON sent by Bitbucket POST Service Hook. """ if self.private: repo_url = 'ssh://hg@%s%s' % ( self.bitbucket, payload['repository']['absolute_url'], ) else: repo_url = 'http://%s%s' % ( self.bitbucket, payload['repository']['absolute_url'], ) changes = [] for commit in payload['commits']: files = [file_info['file'] for file_info in commit['files']] revlink = 'http://%s%s/changeset/%s/' % ( self.bitbucket, payload['repository']['absolute_url'], commit['node'], ) change = { 'revision': commit['node'], 'revlink': revlink, 'comments': commit['message'], 'who': commit['author'], 'files': files, 'repository': repo_url, 'properties': dict(), } changes.append(change) # Submit the changes, if any if not changes: logging.warning("No changes found") return host, port = self.master.split(':') port = int(port) factory = pb.PBClientFactory() deferred = factory.login(credentials.UsernamePassword("change", "changepw")) logging.debug('Trying to connect to: %s:%d'%(host,port)) reactor.connectTCP(host, port, factory) deferred.addErrback(self.connectFailed) deferred.addCallback(self.connected, changes) def connectFailed(self, error): """ If connection is failed. Logs the error. """ logging.error("Could not connect to master: %s" % error.getErrorMessage()) return error def addChange(self, dummy, remote, changei, src='hg'): """ Sends changes from the commit to the buildmaster. """ logging.debug("addChange %s, %s" % (repr(remote), repr(changei))) try: change = changei.next() except StopIteration: remote.broker.transport.loseConnection() return None logging.info("New revision: %s" % change['revision'][:8]) for key, value in change.iteritems(): logging.debug(" %s: %s" % (key, value)) change['src'] = src deferred = remote.callRemote('addChange', change) deferred.addCallback(self.addChange, remote, changei, src) return deferred def connected(self, remote, changes): """ Reponds to the connected event. """ return self.addChange(None, remote, changes.__iter__()) def main(): """ The main event loop that starts the server and configures it. """ usage = "usage: %prog [options]" parser = OptionParser(usage) parser.add_option( "-p", "--port", help="Port the HTTP server listens to for the Bitbucket Service Hook" " [default: %default]", default=4000, type=int, dest="port") parser.add_option( "-m", "--buildmaster", help="Buildbot Master host and port. ie: localhost:9989 [default:" + " %default]", default="localhost:9989", dest="buildmaster") parser.add_option( "-l", "--log", help="The absolute path, including filename, to save the log to" " [default: %default]", default = tempfile.gettempdir() + "/bitbucket_buildbot.log", dest="log") parser.add_option( "-L", "--level", help="The logging level: debug, info, warn, error, fatal [default:" " %default]", default='warn', dest="level") parser.add_option( "-g", "--bitbucket", help="The bitbucket serve [default: %default]", default='bitbucket.org', dest="bitbucket") parser.add_option( '-P', '--private', help='Use SSH to connect, for private repositories.', dest='private', default=False, action='store_true', ) (options, _) = parser.parse_args() # Set up logging. levels = { 'debug': logging.DEBUG, 'info': logging.INFO, 'warn': logging.WARNING, 'error': logging.ERROR, 'fatal': logging.FATAL, } filename = options.log log_format = "%(asctime)s - %(levelname)s - %(message)s" logging.basicConfig(filename=filename, format=log_format, level=levels[options.level]) # Start listener. bitbucket_bot = BitBucketBuildBot() bitbucket_bot.bitbucket = options.bitbucket bitbucket_bot.master = options.buildmaster bitbucket_bot.private = options.private site = server.Site(bitbucket_bot) reactor.listenTCP(options.port, site) reactor.run() if __name__ == '__main__': main() buildbot-0.8.8/contrib/bk_buildbot.py000077500000000000000000000110531222546025000176260ustar00rootroot00000000000000#!/usr/local/bin/python # # BitKeeper hook script. # # svn_buildbot.py was used as a base for this file, if you find any bugs or # errors please email me. # # Amar Takhar ''' /path/to/bk_buildbot.py --repository "$REPOS" --revision "$REV" --branch \ "" --bbserver localhost --bbport 9989 ''' import commands import sys import os import re if sys.version_info < (2, 6): import sets # We have hackish "-d" handling here rather than in the Options # subclass below because a common error will be to not have twisted in # PYTHONPATH; we want to be able to print that error to the log if # debug mode is on, so we set it up before the imports. DEBUG = None if '-d' in sys.argv: i = sys.argv.index('-d') DEBUG = sys.argv[i+1] del sys.argv[i] del sys.argv[i] if DEBUG: f = open(DEBUG, 'a') sys.stderr = f sys.stdout = f from twisted.internet import defer, reactor from twisted.python import usage from twisted.spread import pb from twisted.cred import credentials class Options(usage.Options): optParameters = [ ['repository', 'r', None, "The repository that was changed."], ['revision', 'v', None, "The revision that we want to examine (default: latest)"], ['branch', 'b', None, "Name of the branch to insert into the branch field. (REQUIRED)"], ['category', 'c', None, "Schedular category."], ['bbserver', 's', 'localhost', "The hostname of the server that buildbot is running on"], ['bbport', 'p', 8007, "The port that buildbot is listening on"] ] optFlags = [ ['dryrun', 'n', "Do not actually send changes"], ] def __init__(self): usage.Options.__init__(self) def postOptions(self): if self['repository'] is None: raise usage.error("You must pass --repository") class ChangeSender: def getChanges(self, opts): """Generate and stash a list of Change dictionaries, ready to be sent to the buildmaster's PBChangeSource.""" # first we extract information about the files that were changed repo = opts['repository'] print "Repo:", repo rev_arg = '' if opts['revision']: rev_arg = '-r"%s"' % (opts['revision'], ) changed = commands.getoutput("bk changes -v %s -d':GFILE:\\n' '%s'" % ( rev_arg, repo)).split('\n') # Remove the first line, it's an info message you can't remove (annoying) del changed[0] change_info = commands.getoutput("bk changes %s -d':USER:\\n$each(:C:){(:C:)\\n}' '%s'" % ( rev_arg, repo)).split('\n') # Remove the first line, it's an info message you can't remove (annoying) del change_info[0] who = change_info.pop(0) branch = opts['branch'] message = '\n'.join(change_info) revision = opts.get('revision') changes = {'who': who, 'branch': branch, 'files': changed, 'comments': message, 'revision': revision} if opts.get('category'): changes['category'] = opts.get('category') return changes def sendChanges(self, opts, changes): pbcf = pb.PBClientFactory() reactor.connectTCP(opts['bbserver'], int(opts['bbport']), pbcf) d = pbcf.login(credentials.UsernamePassword('change', 'changepw')) d.addCallback(self.sendAllChanges, changes) return d def sendAllChanges(self, remote, changes): dl = remote.callRemote('addChange', changes) return dl def run(self): opts = Options() try: opts.parseOptions() if not opts['branch']: print "You must supply a branch with -b or --branch." sys.exit(1); except usage.error, ue: print opts print "%s: %s" % (sys.argv[0], ue) sys.exit() changes = self.getChanges(opts) if opts['dryrun']: for k in changes.keys(): print "[%10s]: %s" % (k, changes[k]) print "*NOT* sending any changes" return d = self.sendChanges(opts, changes) def quit(*why): print "quitting! because", why reactor.stop() def failed(f): print "FAILURE: %s" % f reactor.stop() d.addErrback(failed) d.addCallback(quit, "SUCCESS") reactor.callLater(60, quit, "TIMEOUT") reactor.run() if __name__ == '__main__': s = ChangeSender() s.run() buildbot-0.8.8/contrib/buildbot_cvs_mail.py000077500000000000000000000173111222546025000210320ustar00rootroot00000000000000#!/usr/bin/env python # # Buildbot CVS Mail # # This script was derrived from syncmail, # Copyright (c) 2002-2006 Barry Warsaw, Fred Drake, and contributors # # http://cvs-syncmail.cvs.sourceforge.net # # The script was re-written with the sole pupose of providing updates to # Buildbot master by Andy Howell # # Options handling done right by djmitche """ -t --testing Construct message and send to stdout for testing The rest of the command line arguments are: %%{sVv} CVS %%{sVv} loginfo expansion. When invoked by CVS, this will be a single string containing the files that are changing. """ __version__ = '$Revision: 1.3 $' import os import re import sys import time import getopt import socket import smtplib import textwrap import optparse from cStringIO import StringIO from email.Utils import formataddr try: import pwd except: # pwd is not available on Windows.. pwd = None COMMASPACE = ', ' PROGRAM = sys.argv[0] class SmtplibMock: """I stand in for smtplib for testing purposes. """ class SMTP: """I stand in for smtplib.SMTP connection for testing purposes. I copy the message to stdout. """ def close(self): pass def connect(self, mailhost, mailport): pass def sendmail(self, address, email, msg): sys.stdout.write( msg ) rfc822_specials_re = re.compile(r'[\(\)\<\>\@\,\;\:\\\"\.\[\]]') def quotename(name): if name and rfc822_specials_re.search(name): return '"%s"' % name.replace('"', '\\"') else: return name def send_mail(options): # Create the smtp connection to the localhost conn = options.smtplib.SMTP() conn.connect(options.mailhost, options.mailport) if pwd: pwinfo = pwd.getpwuid(os.getuid()) user = pwinfo[0] name = pwinfo[4] else: user = 'cvs' name = 'CVS' domain = options.fromhost if not domain: # getfqdn is not good for use in unit tests if options.amTesting: domain = 'testing.com' else: domain = socket.getfqdn() address = '%s@%s' % (user, domain) s = StringIO() datestamp = time.strftime('%a, %d %b %Y %H:%M:%S +0000', time.gmtime(time.time())) fileList = ' '.join(map(str, options.files)) vars = {'author' : formataddr((name, address)), 'email' : options.email, 'subject' : 'cvs update for project %s' % options.project, 'version' : __version__, 'date' : datestamp, } print >> s, '''\ From: %(author)s To: %(email)s''' % vars if options.replyto: print >> s, 'Reply-To: %s' % options.replyto print >>s, '''\ Subject: %(subject)s Date: %(date)s X-Mailer: Python buildbot-cvs-mail %(version)s ''' % vars print >> s, 'Cvsmode: %s' % options.cvsmode print >> s, 'Category: %s' % options.category print >> s, 'CVSROOT: %s' % options.cvsroot print >> s, 'Files: %s' % fileList if options.path: print >> s, 'Path: %s' % options.path print >> s, 'Project: %s' % options.project s.write(sys.stdin.read()) print >> s resp = conn.sendmail(address, options.email, s.getvalue()) conn.close() def fork_and_send_mail(options): # cannot wait for child process or that will cause parent to retain cvs # lock for too long. Urg! if not os.fork(): # in the child # give up the lock you cvs thang! time.sleep(2) send_mail(options) os._exit(0) description=""" This script is used to provide email notifications of changes to the CVS repository to a buildbot master. It is invoked via a CVS loginfo file (see $CVSROOT/CVSROOT/loginfo). See the Buildbot manual for more information. """ usage="%prog [options] %{sVv}" parser = optparse.OptionParser(description=description, usage=usage, add_help_option=True, version=__version__) parser.add_option("-C", "--category", dest='category', metavar="CAT", help=textwrap.dedent("""\ Category for change. This becomes the Change.category attribute, which can be used within the buildmaster to filter changes. """)) parser.add_option("-c", "--cvsroot", dest='cvsroot', metavar="PATH", help=textwrap.dedent("""\ CVSROOT for use by buildbot slaves to checkout code. This becomes the Change.repository attribute. Exmaple: :ext:myhost:/cvsroot """)) parser.add_option("-e", "--email", dest='email', metavar="EMAIL", help=textwrap.dedent("""\ Email address of the buildbot. """)) parser.add_option("-f", "--fromhost", dest='fromhost', metavar="HOST", help=textwrap.dedent("""\ The hostname that email messages appear to be coming from. The From: header of the outgoing message will look like user@hostname. By default, hostname is the machine's fully qualified domain name. """)) parser.add_option("-m", "--mailhost", dest='mailhost', metavar="HOST", default="localhost", help=textwrap.dedent("""\ The hostname of an available SMTP server. The default is 'localhost'. """)) parser.add_option("--mailport", dest='mailport', metavar="PORT", default=25, type="int", help=textwrap.dedent("""\ The port number of SMTP server. The default is '25'. """)) parser.add_option("-q", "--quiet", dest='verbose', action="store_false", default=True, help=textwrap.dedent("""\ Don't print as much status to stdout. """)) parser.add_option("-p", "--path", dest='path', metavar="PATH", help=textwrap.dedent("""\ The path for the files in this update. This comes from the %p parameter in loginfo for CVS version 1.12.x. Do not use this for CVS version 1.11.x """)) parser.add_option("-P", "--project", dest='project', metavar="PROJ", help=textwrap.dedent("""\ The project for the source. Often set to the CVS module being modified. This becomes the Change.project attribute. """)) parser.add_option("-R", "--reply-to", dest='replyto', metavar="ADDR", help=textwrap.dedent("""\ Add a "Reply-To: ADDR" header to the email message. """)) parser.add_option("-t", "--testing", action="store_true", dest="amTesting", default=False) parser.set_defaults(smtplib=smtplib) def get_options(): options, args = parser.parse_args() # rest of command line are the files. options.files = args if options.path is None: options.cvsmode = '1.11' else: options.cvsmode = '1.12' if options.cvsroot is None: parser.error('--cvsroot is required') if options.email is None: parser.error('--email is required') # set up for unit tests if options.amTesting: options.verbose = 0 options.smtplib = SmtplibMock return options # scan args for options def main(): options = get_options() if options.verbose: print 'Mailing %s...' % options.email print 'Generating notification message...' if options.amTesting: send_mail(options) else: fork_and_send_mail(options) if options.verbose: print 'Generating notification message... done.' return 0 if __name__ == '__main__': ret = main() sys.exit(ret) buildbot-0.8.8/contrib/buildbot_json.py000077500000000000000000001236401222546025000202110ustar00rootroot00000000000000#!/usr/bin/env python # Copyright (c) 2012 The Chromium Authors. All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # NOTE: This file is NOT under GPL. See above. """Queries buildbot through the json interface. """ __author__ = 'maruel@chromium.org' __version__ = '1.2' import code import datetime import functools import json import logging import optparse import time import urllib import urllib2 import sys try: from natsort import natsorted except ImportError: # natsorted is a simple helper to sort "naturally", e.g. "vm40" is sorted # after "vm7". Defaults to normal sorting. natsorted = sorted # These values are buildbot constants used for Build and BuildStep. # This line was copied from master/buildbot/status/builder.py. SUCCESS, WARNINGS, FAILURE, SKIPPED, EXCEPTION, RETRY = range(6) ## Generic node caching code. class Node(object): """Root class for all nodes in the graph. Provides base functionality for any node in the graph, independent if it has children or not or if its content can be addressed through an url or needs to be fetched as part of another node. self.printable_attributes is only used for self documentation and for str() implementation. """ printable_attributes = [] def __init__(self, parent, url): self.printable_attributes = self.printable_attributes[:] if url: self.printable_attributes.append('url') url = url.rstrip('/') if parent is not None: self.printable_attributes.append('parent') self.url = url self.parent = parent def __str__(self): return self.to_string() def __repr__(self): """Embeds key if present.""" key = getattr(self, 'key', None) if key is not None: return '<%s key=%s>' % (self.__class__.__name__, key) cached_keys = getattr(self, 'cached_keys', None) if cached_keys is not None: return '<%s keys=%s>' % (self.__class__.__name__, cached_keys) return super(Node, self).__repr__() def to_string(self, maximum=100): out = ['%s:' % self.__class__.__name__] assert not 'printable_attributes' in self.printable_attributes def limit(txt): txt = str(txt) if maximum > 0: if len(txt) > maximum + 2: txt = txt[:maximum] + '...' return txt for k in sorted(self.printable_attributes): if k == 'parent': # Avoid infinite recursion. continue out.append(limit(' %s: %r' % (k, getattr(self, k)))) return '\n'.join(out) def refresh(self): """Refreshes the data.""" self.discard() return self.cache() def cache(self): # pragma: no cover """Caches the data.""" raise NotImplementedError() def discard(self): # pragma: no cover """Discards cached data. Pretty much everything is temporary except completed Build. """ raise NotImplementedError() class AddressableBaseDataNode(Node): # pylint: disable=W0223 """A node that contains a dictionary of data that can be fetched with an url. The node is directly addressable. It also often can be fetched by the parent. """ printable_attributes = Node.printable_attributes + ['data'] def __init__(self, parent, url, data): super(AddressableBaseDataNode, self).__init__(parent, url) self._data = data @property def cached_data(self): return self._data @property def data(self): self.cache() return self._data def cache(self): if self._data is None: self._data = self._readall() return True return False def discard(self): self._data = None def read(self, suburl): assert self.url, self.__class__.__name__ url = self.url if suburl: url = '%s/%s' % (self.url, suburl) return self.parent.read(url) def _readall(self): return self.read('') class AddressableDataNode(AddressableBaseDataNode): # pylint: disable=W0223 """Automatically encodes the url.""" def __init__(self, parent, url, data): super(AddressableDataNode, self).__init__(parent, urllib.quote(url), data) class NonAddressableDataNode(Node): # pylint: disable=W0223 """A node that cannot be addressed by an unique url. The data comes directly from the parent. """ def __init__(self, parent, subkey): super(NonAddressableDataNode, self).__init__(parent, None) self.subkey = subkey @property def cached_data(self): if self.parent.cached_data is None: return None return self.parent.cached_data[self.subkey] @property def data(self): return self.parent.data[self.subkey] def cache(self): self.parent.cache() def discard(self): # pragma: no cover """Avoid invalid state when parent recreate the object.""" raise AttributeError('Call parent discard() instead') class VirtualNodeList(Node): """Base class for every node that has children. Adds partial supports for keys and iterator functionality. 'key' can be a string or a int. Not to be used directly. """ printable_attributes = Node.printable_attributes + ['keys'] def __init__(self, parent, url): super(VirtualNodeList, self).__init__(parent, url) # Keeps the keys independently when ordering is needed. self._is_cached = False self._has_keys_cached = False def __contains__(self, key): """Enables 'if i in obj:'.""" return key in self.keys def __iter__(self): """Enables 'for i in obj:'. It returns children.""" self.cache_keys() for key in self.keys: yield self[key] def __len__(self): """Enables 'len(obj)' to get the number of childs.""" return len(self.keys) def discard(self): """Discards data. The default behavior is to not invalidate cached keys. The only place where keys need to be invalidated is with Builds. """ self._is_cached = False self._has_keys_cached = False @property def cached_children(self): # pragma: no cover """Returns an iterator over the children that are cached.""" raise NotImplementedError() @property def cached_keys(self): # pragma: no cover raise NotImplementedError() @property def keys(self): # pragma: no cover """Returns the keys for every children.""" raise NotImplementedError() def __getitem__(self, key): # pragma: no cover """Returns a child, without fetching its data. The children could be invalid since no verification is done. """ raise NotImplementedError() def cache(self): # pragma: no cover """Cache all the children.""" raise NotImplementedError() def cache_keys(self): # pragma: no cover """Cache all children's keys.""" raise NotImplementedError() class NodeList(VirtualNodeList): # pylint: disable=W0223 """Adds a cache of the keys.""" def __init__(self, parent, url): super(NodeList, self).__init__(parent, url) self._keys = [] @property def cached_keys(self): return self._keys @property def keys(self): self.cache_keys() return self._keys class NonAddressableNodeList(VirtualNodeList): # pylint: disable=W0223 """A node that contains children but retrieves all its data from its parent. I.e. there's no url to get directly this data. """ # Child class object for children of this instance. For example, BuildSteps # has BuildStep children. _child_cls = None def __init__(self, parent, subkey): super(NonAddressableNodeList, self).__init__(parent, None) self.subkey = subkey assert ( not isinstance(self._child_cls, NonAddressableDataNode) and issubclass(self._child_cls, NonAddressableDataNode)), ( self._child_cls.__name__) @property def cached_children(self): if self.parent.cached_data is not None: for i in xrange(len(self.parent.cached_data[self.subkey])): yield self[i] @property def cached_data(self): if self.parent.cached_data is None: return None return self.parent.data.get(self.subkey, None) @property def cached_keys(self): if self.parent.cached_data is None: return None return range(len(self.parent.data.get(self.subkey, []))) @property def data(self): return self.parent.data[self.subkey] def cache(self): self.parent.cache() def cache_keys(self): self.parent.cache() def discard(self): # pragma: no cover """Avoid infinite recursion by having the caller calls the parent's discard() explicitely. """ raise AttributeError('Call parent discard() instead') def __iter__(self): """Enables 'for i in obj:'. It returns children.""" if self.data: for i in xrange(len(self.data)): yield self[i] def __getitem__(self, key): """Doesn't cache the value, it's not needed. TODO(maruel): Cache? """ if isinstance(key, int) and key < 0: key = len(self.data) + key # pylint: disable=E1102 return self._child_cls(self, key) class AddressableNodeList(NodeList): """A node that has children that can be addressed with an url.""" # Child class object for children of this instance. For example, Builders has # Builder children and Builds has Build children. _child_cls = None def __init__(self, parent, url): super(AddressableNodeList, self).__init__(parent, url) self._cache = {} assert ( not isinstance(self._child_cls, AddressableDataNode) and issubclass(self._child_cls, AddressableDataNode)), ( self._child_cls.__name__) @property def cached_children(self): for item in self._cache.itervalues(): if item.cached_data is not None: yield item @property def cached_keys(self): return self._cache.keys() def __getitem__(self, key): """Enables 'obj[i]'.""" if self._has_keys_cached and not key in self._keys: raise KeyError(key) if not key in self._cache: # Create an empty object. self._create_obj(key, None) return self._cache[key] def cache(self): if not self._is_cached: data = self._readall() for key in sorted(data): self._create_obj(key, data[key]) self._is_cached = True self._has_keys_cached = True def cache_partial(self, children): """Caches a partial number of children. This method is more efficient since it does a single request for all the children instead of one request per children. It only grab objects not already cached. """ # pylint: disable=W0212 if not self._is_cached: to_fetch = [ child for child in children if not (child in self._cache and self._cache[child].cached_data) ] if to_fetch: # Similar to cache(). The only reason to sort is to simplify testing. params = '&'.join( 'select=%s' % urllib.quote(str(v)) for v in sorted(to_fetch)) data = self.read('?' + params) for key in sorted(data): self._create_obj(key, data[key]) def cache_keys(self): """Implement to speed up enumeration. Defaults to call cache().""" if not self._has_keys_cached: self.cache() assert self._has_keys_cached def discard(self): """Discards temporary children.""" super(AddressableNodeList, self).discard() for v in self._cache.itervalues(): v.discard() def read(self, suburl): assert self.url, self.__class__.__name__ url = self.url if suburl: url = '%s/%s' % (self.url, suburl) return self.parent.read(url) def _create_obj(self, key, data): """Creates an object of type self._child_cls.""" # pylint: disable=E1102 obj = self._child_cls(self, key, data) # obj.key and key may be different. # No need to overide cached data with None. if data is not None or obj.key not in self._cache: self._cache[obj.key] = obj if obj.key not in self._keys: self._keys.append(obj.key) def _readall(self): return self.read('') class SubViewNodeList(VirtualNodeList): # pylint: disable=W0223 """A node that shows a subset of children that comes from another structure. The node is not addressable. E.g. the keys are retrieved from parent but the actual data comes from virtual_parent. """ def __init__(self, parent, virtual_parent, subkey): super(SubViewNodeList, self).__init__(parent, None) self.subkey = subkey self.virtual_parent = virtual_parent assert isinstance(self.parent, AddressableDataNode) assert isinstance(self.virtual_parent, NodeList) @property def cached_children(self): if self.parent.cached_data is not None: for item in self.keys: if item in self.virtual_parent.keys: child = self[item] if child.cached_data is not None: yield child @property def cached_keys(self): return (self.parent.cached_data or {}).get(self.subkey, []) @property def keys(self): self.cache_keys() return self.parent.data.get(self.subkey, []) def cache(self): """Batch request for each child in a single read request.""" if not self._is_cached: self.virtual_parent.cache_partial(self.keys) self._is_cached = True def cache_keys(self): if not self._has_keys_cached: self.parent.cache() self._has_keys_cached = True def discard(self): if self.parent.cached_data is not None: for child in self.virtual_parent.cached_children: if child.key in self.keys: child.discard() self.parent.discard() super(SubViewNodeList, self).discard() def __getitem__(self, key): """Makes sure the key is in our key but grab it from the virtual parent.""" return self.virtual_parent[key] def __iter__(self): self.cache() return super(SubViewNodeList, self).__iter__() ############################################################################### ## Buildbot-specific code class Slave(AddressableDataNode): printable_attributes = AddressableDataNode.printable_attributes + [ 'name', 'key', 'connected', 'version', ] def __init__(self, parent, name, data): super(Slave, self).__init__(parent, name, data) self.name = name self.key = self.name # TODO(maruel): Add SlaveBuilders and a 'builders' property. # TODO(maruel): Add a 'running_builds' property. @property def connected(self): return self.data.get('connected', False) @property def version(self): return self.data.get('version') class Slaves(AddressableNodeList): _child_cls = Slave printable_attributes = AddressableNodeList.printable_attributes + ['names'] def __init__(self, parent): super(Slaves, self).__init__(parent, 'slaves') @property def names(self): return self.keys class BuilderSlaves(SubViewNodeList): """Similar to Slaves but only list slaves connected to a specific builder. """ printable_attributes = SubViewNodeList.printable_attributes + ['names'] def __init__(self, parent): super(BuilderSlaves, self).__init__( parent, parent.parent.parent.slaves, 'slaves') @property def names(self): return self.keys class BuildStep(NonAddressableDataNode): printable_attributes = NonAddressableDataNode.printable_attributes + [ 'name', 'number', 'start_time', 'end_time', 'duration', 'is_started', 'is_finished', 'is_running', 'result', 'simplified_result', ] def __init__(self, parent, number): """It's already pre-loaded by definition since the data is retrieve via the Build object. """ assert isinstance(number, int) super(BuildStep, self).__init__(parent, number) self.number = number @property def start_time(self): if self.data.get('times'): return int(round(self.data['times'][0])) @property def end_time(self): times = self.data.get('times') if times and len(times) == 2 and times[1]: return int(round(times[1])) @property def duration(self): if self.start_time: return (self.end_time or int(round(time.time()))) - self.start_time @property def name(self): return self.data['name'] @property def is_started(self): return self.data.get('isStarted', False) @property def is_finished(self): return self.data.get('isFinished', False) @property def is_running(self): return self.is_started and not self.is_finished @property def result(self): result = self.data.get('results') if result is None: # results may be 0, in that case with filter=1, the value won't be # present. if self.data.get('isFinished'): result = self.data.get('results', 0) while isinstance(result, list): result = result[0] return result @property def simplified_result(self): """Returns a simplified 3 state value, True, False or None.""" result = self.result if result in (SUCCESS, WARNINGS): return True elif result in (FAILURE, EXCEPTION, RETRY): return False assert result in (None, SKIPPED), (result, self.data) return None class BuildSteps(NonAddressableNodeList): """Duplicates keys to support lookup by both step number and step name.""" printable_attributes = NonAddressableNodeList.printable_attributes + [ 'failed', ] _child_cls = BuildStep def __init__(self, parent): """It's already pre-loaded by definition since the data is retrieve via the Build object. """ super(BuildSteps, self).__init__(parent, 'steps') @property def keys(self): """Returns the steps name in order.""" return [i['name'] for i in (self.data or [])] @property def failed(self): """Shortcuts that lists the step names of steps that failed.""" return [step.name for step in self if step.simplified_result is False] def __getitem__(self, key): """Accept step name in addition to index number.""" if isinstance(key, basestring): # It's a string, try to find the corresponding index. for i, step in enumerate(self.data): if step['name'] == key: key = i break else: raise KeyError(key) return super(BuildSteps, self).__getitem__(key) class Build(AddressableDataNode): printable_attributes = AddressableDataNode.printable_attributes + [ 'key', 'number', 'steps', 'blame', 'reason', 'revision', 'result', 'simplified_result', 'start_time', 'end_time', 'duration', 'slave', 'properties', 'completed', ] def __init__(self, parent, key, data): super(Build, self).__init__(parent, str(key), data) self.number = int(key) self.key = self.number self.steps = BuildSteps(self) @property def blame(self): return self.data.get('blame', []) @property def builder(self): """Returns the Builder object. Goes up the hierarchy to find the Buildbot.builders[builder] instance. """ return self.parent.parent.parent.parent.builders[self.data['builderName']] @property def start_time(self): if self.data.get('times'): return int(round(self.data['times'][0])) @property def end_time(self): times = self.data.get('times') if times and len(times) == 2 and times[1]: return int(round(times[1])) @property def duration(self): if self.start_time: return (self.end_time or int(round(time.time()))) - self.start_time @property def eta(self): return self.data.get('eta', 0) @property def completed(self): return self.data.get('currentStep') is None @property def properties(self): return self.data.get('properties', []) @property def reason(self): return self.data.get('reason') @property def result(self): result = self.data.get('results') while isinstance(result, list): result = result[0] if result is None and self.steps: # results may be 0, in that case with filter=1, the value won't be # present. result = self.steps[-1].result return result @property def revision(self): return self.data.get('sourceStamp', {}).get('revision') @property def simplified_result(self): """Returns a simplified 3 state value, True, False or None.""" result = self.result if result in (SUCCESS, WARNINGS, SKIPPED): return True elif result in (FAILURE, EXCEPTION, RETRY): return False assert result is None, (result, self.data) return None @property def slave(self): """Returns the Slave object. Goes up the hierarchy to find the Buildbot.slaves[slave] instance. """ return self.parent.parent.parent.parent.slaves[self.data['slave']] def discard(self): """Completed Build isn't discarded.""" if self._data and self.result is None: assert not self.steps or not self.steps[-1].data.get('isFinished') self._data = None class CurrentBuilds(SubViewNodeList): """Lists of the current builds.""" def __init__(self, parent): super(CurrentBuilds, self).__init__( parent, parent.builds, 'currentBuilds') class PendingBuilds(AddressableDataNode): def __init__(self, parent): super(PendingBuilds, self).__init__(parent, 'pendingBuilds', None) class Builds(AddressableNodeList): """Supports iteration. Recommends using .cache() to speed up if a significant number of builds are iterated over. """ _child_cls = Build def __init__(self, parent): super(Builds, self).__init__(parent, 'builds') def __getitem__(self, key): """Adds supports for negative reference and enables retrieving non-cached builds. e.g. -1 is the last build, -2 is the previous build before the last one. """ key = int(key) if key < 0: # Convert negative to positive build number. self.cache_keys() # Since the negative value can be outside of the cache keys range, use the # highest key value and calculate from it. key = max(self._keys) + key + 1 if not key in self._cache: # Create an empty object. self._create_obj(key, None) return self._cache[key] def __iter__(self): """Returns cached Build objects in reversed order. The most recent build is returned first and then in reverse chronological order, up to the oldest cached build by the server. Older builds can be accessed but will trigger significantly more I/O so they are not included by default in the iteration. To access the older builds, use self.iterall() instead. """ self.cache() return reversed(self._cache.values()) def iterall(self): """Returns Build objects in decreasing order unbounded up to build 0. The most recent build is returned first and then in reverse chronological order. Older builds can be accessed and will trigger significantly more I/O so use this carefully. """ # Only cache keys here. self.cache_keys() if self._keys: for i in xrange(max(self._keys), -1, -1): yield self[i] def cache_keys(self): """Grabs the keys (build numbers) from the builder.""" if not self._has_keys_cached: for i in self.parent.data.get('cachedBuilds', []): i = int(i) self._cache.setdefault(i, Build(self, i, None)) if i not in self._keys: self._keys.append(i) self._has_keys_cached = True def discard(self): super(Builds, self).discard() # Can't keep keys. self._has_keys_cached = False def _readall(self): return self.read('_all') class Builder(AddressableDataNode): printable_attributes = AddressableDataNode.printable_attributes + [ 'name', 'key', 'builds', 'slaves', 'pending_builds', 'current_builds', ] def __init__(self, parent, name, data): super(Builder, self).__init__(parent, name, data) self.name = name self.key = name self.builds = Builds(self) self.slaves = BuilderSlaves(self) self.current_builds = CurrentBuilds(self) self.pending_builds = PendingBuilds(self) def discard(self): super(Builder, self).discard() self.builds.discard() self.slaves.discard() self.current_builds.discard() class Builders(AddressableNodeList): """Root list of builders.""" _child_cls = Builder def __init__(self, parent): super(Builders, self).__init__(parent, 'builders') class Buildbot(AddressableBaseDataNode): """If a master restart occurs, this object should be recreated as it caches data. """ # Throttle fetches to not kill the server. auto_throttle = None printable_attributes = AddressableDataNode.printable_attributes + [ 'slaves', 'builders', 'last_fetch', ] def __init__(self, url): super(Buildbot, self).__init__(None, url.rstrip('/') + '/json', None) self._builders = Builders(self) self._slaves = Slaves(self) self.last_fetch = None @property def builders(self): return self._builders @property def slaves(self): return self._slaves def discard(self): """Discards information about Builders and Slaves.""" super(Buildbot, self).discard() self._builders.discard() self._slaves.discard() def read(self, suburl): if self.auto_throttle: if self.last_fetch: delta = datetime.datetime.utcnow() - self.last_fetch remaining = (datetime.timedelta(seconds=self.auto_throttle) - delta) if remaining > datetime.timedelta(seconds=0): logging.debug('Sleeping for %ss' % remaining) time.sleep(remaining.seconds) self.last_fetch = datetime.datetime.utcnow() url = '%s/%s' % (self.url, suburl) if '?' in url: url += '&filter=1' else: url += '?filter=1' logging.info('read(%s)' % suburl) channel = urllib.urlopen(url) data = channel.read() try: return json.loads(data) except ValueError: if channel.getcode() >= 400: # Convert it into an HTTPError for easier processing. raise urllib2.HTTPError( url, channel.getcode(), '%s:\n%s' % (url, data), channel.headers, None) raise def _readall(self): return self.read('project') ############################################################################### ## Controller code def usage(more): def hook(fn): fn.func_usage_more = more return fn return hook def need_buildbot(fn): """Post-parse args to create a buildbot object.""" @functools.wraps(fn) def hook(parser, args, *extra_args, **kwargs): old_parse_args = parser.parse_args def new_parse_args(args): options, args = old_parse_args(args) if len(args) < 1: parser.error('Need to pass the root url of the buildbot') url = args.pop(0) if not url.startswith('http'): url = 'http://' + url buildbot = Buildbot(url) buildbot.auto_throttle = options.throttle return options, args, buildbot parser.parse_args = new_parse_args # Call the original function with the modified parser. return fn(parser, args, *extra_args, **kwargs) hook.func_usage_more = '[options] ' return hook @need_buildbot def CMDpending(parser, args): """Lists pending jobs.""" parser.add_option( '-b', '--builder', dest='builders', action='append', default=[], help='Builders to filter on') options, args, buildbot = parser.parse_args(args) if args: parser.error('Unrecognized parameters: %s' % ' '.join(args)) if not options.builders: options.builders = buildbot.builders.keys for builder in options.builders: builder = buildbot.builders[builder] pending_builds = builder.data.get('pendingBuilds', 0) if not pending_builds: continue print 'Builder %s: %d' % (builder.name, pending_builds) if not options.quiet: for pending in builder.pending_builds.data: if 'revision' in pending['source']: print ' revision: %s' % pending['source']['revision'] for change in pending['source']['changes']: print ' change:' print ' comment: %r' % unicode(change['comments'][:50]) print ' who: %s' % change['who'] return 0 @usage('[options] [commands] ...') @need_buildbot def CMDrun(parser, args): """Runs commands passed as parameters. When passing commands on the command line, each command will be run as if it was on its own line. """ parser.add_option('-f', '--file', help='Read script from file') parser.add_option( '-i', dest='use_stdin', action='store_true', help='Read script on stdin') # Variable 'buildbot' is not used directly. # pylint: disable=W0612 options, args, buildbot = parser.parse_args(args) if (bool(args) + bool(options.use_stdin) + bool(options.file)) != 1: parser.error('Need to pass only one of: , -f or -i') if options.use_stdin: cmds = sys.stdin.read() elif options.file: cmds = open(options.file).read() else: cmds = '\n'.join(args) compiled = compile(cmds, '', 'exec') eval(compiled, globals(), locals()) return 0 @need_buildbot def CMDinteractive(parser, args): """Runs an interactive shell to run queries.""" _, args, buildbot = parser.parse_args(args) if args: parser.error('Unrecognized parameters: %s' % ' '.join(args)) prompt = ( 'Buildbot interactive console for "%s".\n' 'Hint: Start with typing: \'buildbot.printable_attributes\' or ' '\'print str(buildbot)\' to explore.') % buildbot.url[:-len('/json')] local_vars = { 'buildbot': buildbot, 'b': buildbot, } code.interact(prompt, None, local_vars) @need_buildbot def CMDidle(parser, args): """Lists idle slaves.""" return find_idle_busy_slaves(parser, args, True) @need_buildbot def CMDbusy(parser, args): """Lists idle slaves.""" return find_idle_busy_slaves(parser, args, False) @need_buildbot def CMDdisconnected(parser, args): """Lists disconnected slaves.""" _, args, buildbot = parser.parse_args(args) if args: parser.error('Unrecognized parameters: %s' % ' '.join(args)) for slave in buildbot.slaves: if not slave.connected: print slave.name return 0 def find_idle_busy_slaves(parser, args, show_idle): parser.add_option( '-b', '--builder', dest='builders', action='append', default=[], help='Builders to filter on') parser.add_option( '-s', '--slave', dest='slaves', action='append', default=[], help='Slaves to filter on') options, args, buildbot = parser.parse_args(args) if args: parser.error('Unrecognized parameters: %s' % ' '.join(args)) if not options.builders: options.builders = buildbot.builders.keys for builder in options.builders: builder = buildbot.builders[builder] if options.slaves: # Only the subset of slaves connected to the builder. slaves = list(set(options.slaves).intersection(set(builder.slaves.names))) if not slaves: continue else: slaves = builder.slaves.names busy_slaves = [build.slave.name for build in builder.current_builds] if show_idle: slaves = natsorted(set(slaves) - set(busy_slaves)) else: slaves = natsorted(set(slaves) & set(busy_slaves)) if options.quiet: for slave in slaves: print slave else: if slaves: print 'Builder %s: %s' % (builder.name, ', '.join(slaves)) return 0 def last_failure( buildbot, builders=None, slaves=None, steps=None, no_cache=False): """Generator returning Build object that were the last failure with the specific filters. """ builders = builders or buildbot.builders.keys for builder in builders: builder = buildbot.builders[builder] if slaves: # Only the subset of slaves connected to the builder. builder_slaves = list(set(slaves).intersection(set(builder.slaves.names))) if not builder_slaves: continue else: builder_slaves = builder.slaves.names if not no_cache and len(builder.slaves) > 2: # Unless you just want the last few builds, it's often faster to # fetch the whole thing at once, at the cost of a small hickup on # the buildbot. # TODO(maruel): Cache only N last builds or all builds since # datetime. builder.builds.cache() found = [] for build in builder.builds: if build.slave.name not in builder_slaves or build.slave.name in found: continue # Only add the slave for the first completed build but still look for # incomplete builds. if build.completed: found.append(build.slave.name) if steps: if any(build.steps[step].simplified_result is False for step in steps): yield build elif build.simplified_result is False: yield build if len(found) == len(builder_slaves): # Found all the slaves, quit. break @need_buildbot def CMDlast_failure(parser, args): """Lists all slaves that failed on that step on their last build. Example: to find all slaves where their last build was a compile failure, run with --step compile""" parser.add_option( '-S', '--step', dest='steps', action='append', default=[], help='List all slaves that failed on that step on their last build') parser.add_option( '-b', '--builder', dest='builders', action='append', default=[], help='Builders to filter on') parser.add_option( '-s', '--slave', dest='slaves', action='append', default=[], help='Slaves to filter on') parser.add_option( '-n', '--no_cache', action='store_true', help='Don\'t load all builds at once') options, args, buildbot = parser.parse_args(args) if args: parser.error('Unrecognized parameters: %s' % ' '.join(args)) print_builders = not options.quiet and len(options.builders) != 1 last_builder = None for build in last_failure( buildbot, builders=options.builders, slaves=options.slaves, steps=options.steps, no_cache=options.no_cache): if print_builders and last_builder != build.builder: print build.builder.name last_builder = build.builder if options.quiet: if options.slaves: print '%s: %s' % (build.builder.name, build.slave.name) else: print build.slave.name else: out = '%d on %s: blame:%s' % ( build.number, build.slave.name, ', '.join(build.blame)) if print_builders: out = ' ' + out print out if len(options.steps) != 1: for step in build.steps: if step.simplified_result is False: # Assume the first line is the text name anyway. summary = ', '.join(step.data['text'][1:])[:40] out = ' %s: "%s"' % (step.data['name'], summary) if print_builders: out = ' ' + out print out return 0 @need_buildbot def CMDcurrent(parser, args): """Lists current jobs.""" parser.add_option( '-b', '--builder', dest='builders', action='append', default=[], help='Builders to filter on') parser.add_option( '--blame', action='store_true', help='Only print the blame list') options, args, buildbot = parser.parse_args(args) if args: parser.error('Unrecognized parameters: %s' % ' '.join(args)) if not options.builders: options.builders = buildbot.builders.keys if options.blame: blame = set() for builder in options.builders: for build in buildbot.builders[builder].current_builds: if build.blame: for blamed in build.blame: blame.add(blamed) print '\n'.join(blame) return 0 for builder in options.builders: builder = buildbot.builders[builder] if not options.quiet and builder.current_builds: print builder.name for build in builder.current_builds: if options.quiet: print build.slave.name else: out = '%4d: slave=%10s' % (build.number, build.slave.name) out += ' duration=%5d' % (build.duration or 0) if build.eta: out += ' eta=%5.0f' % build.eta else: out += ' ' if build.blame: out += ' blame=' + ', '.join(build.blame) print out return 0 @need_buildbot def CMDbuilds(parser, args): """Lists all builds. Example: to find all builds on a single slave, run with -b bar -s foo """ parser.add_option( '-r', '--result', type='int', help='Build result to filter on') parser.add_option( '-b', '--builder', dest='builders', action='append', default=[], help='Builders to filter on') parser.add_option( '-s', '--slave', dest='slaves', action='append', default=[], help='Slaves to filter on') parser.add_option( '-n', '--no_cache', action='store_true', help='Don\'t load all builds at once') options, args, buildbot = parser.parse_args(args) if args: parser.error('Unrecognized parameters: %s' % ' '.join(args)) builders = options.builders or buildbot.builders.keys for builder in builders: builder = buildbot.builders[builder] for build in builder.builds: if not options.slaves or build.slave.name in options.slaves: if options.quiet: out = '' if options.builders: out += '%s/' % builder.name if len(options.slaves) != 1: out += '%s/' % build.slave.name out += '%d revision:%s result:%s blame:%s' % ( build.number, build.revision, build.result, ','.join(build.blame)) print out else: print build return 0 @need_buildbot def CMDcount(parser, args): """Count the number of builds that occured during a specific period. """ parser.add_option( '-o', '--over', type='int', help='Number of seconds to look for') parser.add_option( '-b', '--builder', dest='builders', action='append', default=[], help='Builders to filter on') options, args, buildbot = parser.parse_args(args) if args: parser.error('Unrecognized parameters: %s' % ' '.join(args)) if not options.over: parser.error( 'Specify the number of seconds, e.g. --over 86400 for the last 24 ' 'hours') builders = options.builders or buildbot.builders.keys counts = {} since = time.time() - options.over for builder in builders: builder = buildbot.builders[builder] counts[builder.name] = 0 if not options.quiet: print builder.name for build in builder.builds.iterall(): try: start_time = build.start_time except urllib2.HTTPError: # The build was probably trimmed. print >> sys.stderr, ( 'Failed to fetch build %s/%d' % (builder.name, build.number)) continue if start_time >= since: counts[builder.name] += 1 else: break if not options.quiet: print '.. %d' % counts[builder.name] align_name = max(len(b) for b in counts) align_number = max(len(str(c)) for c in counts.itervalues()) for builder in sorted(counts): print '%*s: %*d' % (align_name, builder, align_number, counts[builder]) print 'Total: %d' % sum(counts.itervalues()) return 0 def gen_parser(): """Returns an OptionParser instance with default options. It should be then processed with gen_usage() before being used. """ parser = optparse.OptionParser( version=__version__) # Remove description formatting parser.format_description = lambda x: parser.description # Add common parsing. old_parser_args = parser.parse_args def Parse(*args, **kwargs): options, args = old_parser_args(*args, **kwargs) if options.verbose >= 2: logging.basicConfig(level=logging.DEBUG) elif options.verbose: logging.basicConfig(level=logging.INFO) else: logging.basicConfig(level=logging.WARNING) return options, args parser.parse_args = Parse parser.add_option( '-v', '--verbose', action='count', help='Use multiple times to increase logging leve') parser.add_option( '-q', '--quiet', action='store_true', help='Reduces the output to be parsed by scripts, independent of -v') parser.add_option( '--throttle', type='float', help='Minimum delay to sleep between requests') return parser ############################################################################### ## Generic subcommand handling code def Command(name): return getattr(sys.modules[__name__], 'CMD' + name, None) @usage('') def CMDhelp(parser, args): """Print list of commands or use 'help '.""" _, args = parser.parse_args(args) if len(args) == 1: return main(args + ['--help']) parser.print_help() return 0 def gen_usage(parser, command): """Modifies an OptionParser object with the command's documentation. The documentation is taken from the function's docstring. """ obj = Command(command) more = getattr(obj, 'func_usage_more') # OptParser.description prefer nicely non-formatted strings. parser.description = obj.__doc__ + '\n' parser.set_usage('usage: %%prog %s %s' % (command, more)) def main(args=None): # Do it late so all commands are listed. # pylint: disable=E1101 CMDhelp.__doc__ += '\n\nCommands are:\n' + '\n'.join( ' %-12s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n', 1)[0]) for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')) parser = gen_parser() if args is None: args = sys.argv[1:] if args: command = Command(args[0]) if command: # "fix" the usage and the description now that we know the subcommand. gen_usage(parser, args[0]) return command(parser, args[1:]) # Not a known command. Default to help. gen_usage(parser, 'help') return CMDhelp(parser, args) if __name__ == '__main__': sys.exit(main()) buildbot-0.8.8/contrib/bzr_buildbot.py000066400000000000000000000436171222546025000200370ustar00rootroot00000000000000# Copyright (C) 2008-2009 Canonical # # 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 2 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 . """\ bzr buildbot integration ======================== This file contains both bzr commit/change hooks and a bzr poller. ------------ Requirements ------------ This has been tested with buildbot 0.7.9, bzr 1.10, and Twisted 8.1.0. It should work in subsequent releases. For the hook to work, Twisted must be installed in the same Python that bzr uses. ----- Hooks ----- To install, put this file in a bzr plugins directory (e.g., ~/.bazaar/plugins). Then, in one of your bazaar conf files (e.g., ~/.bazaar/locations.conf), set the location you want to connect with buildbot with these keys: - buildbot_on: one of 'commit', 'push, or 'change'. Turns the plugin on to report changes via commit, changes via push, or any changes to the trunk. 'change' is recommended. - buildbot_server: (required to send to a buildbot master) the URL of the buildbot master to which you will connect (as of this writing, the same server and port to which slaves connect). - buildbot_port: (optional, defaults to 9989) the port of the buildbot master to which you will connect (as of this writing, the same server and port to which slaves connect) - buildbot_auth: (optional, defaults to change:changepw) the credentials expected by the change source configuration in the master. Takes the "user:password" form. - buildbot_pqm: (optional, defaults to not pqm) Normally, the user that commits the revision is the user that is responsible for the change. When run in a pqm (Patch Queue Manager, see https://launchpad.net/pqm) environment, the user that commits is the Patch Queue Manager, and the user that committed the *parent* revision is responsible for the change. To turn on the pqm mode, set this value to any of (case-insensitive) "Yes", "Y", "True", or "T". - buildbot_dry_run: (optional, defaults to not a dry run) Normally, the post-commit hook will attempt to communicate with the configured buildbot server and port. If this parameter is included and any of (case-insensitive) "Yes", "Y", "True", or "T", then the hook will simply print what it would have sent, but not attempt to contact the buildbot master. - buildbot_send_branch_name: (optional, defaults to not sending the branch name) If your buildbot's bzr source build step uses a repourl, do *not* turn this on. If your buildbot's bzr build step uses a baseURL, then you may set this value to any of (case-insensitive) "Yes", "Y", "True", or "T" to have the buildbot master append the branch name to the baseURL. Note: The bzr smart server (as of version 2.2.2) doesn't know how to resolve bzr:// urls into absolute paths so any paths in locations.conf won't match, hence no change notifications will be sent to Buildbot. Setting configuration parameters globally or in-branch might still work. When buildbot no longer has a hardcoded password, it will be a configuration option here as well. ------ Poller ------ See the Buildbot manual. ------------------- Contact Information ------------------- Maintainer/author: gary.poster@canonical.com """ try: import buildbot.util import buildbot.changes.base import buildbot.changes.changes except ImportError: DEFINE_POLLER = False else: DEFINE_POLLER = True import bzrlib.branch import bzrlib.errors import bzrlib.trace import twisted.cred.credentials import twisted.internet.base import twisted.internet.defer import twisted.internet.reactor import twisted.internet.selectreactor import twisted.internet.task import twisted.internet.threads import twisted.python.log import twisted.spread.pb ############################################################################# # This is the code that the poller and the hooks share. def generate_change(branch, old_revno=None, old_revid=None, new_revno=None, new_revid=None, blame_merge_author=False): """Return a dict of information about a change to the branch. Dict has keys of "files", "who", "comments", and "revision", as used by the buildbot Change (and the PBChangeSource). If only the branch is given, the most recent change is returned. If only the new_revno is given, the comparison is expected to be between it and the previous revno (new_revno -1) in the branch. Passing old_revid and new_revid is only an optimization, included because bzr hooks usually provide this information. blame_merge_author means that the author of the merged branch is identified as the "who", not the person who committed the branch itself. This is typically used for PQM. """ change = {} # files, who, comments, revision; NOT branch (= branch.nick) if new_revno is None: new_revno = branch.revno() if new_revid is None: new_revid = branch.get_rev_id(new_revno) # TODO: This falls over if this is the very first revision if old_revno is None: old_revno = new_revno -1 if old_revid is None: old_revid = branch.get_rev_id(old_revno) repository = branch.repository new_rev = repository.get_revision(new_revid) if blame_merge_author: # this is a pqm commit or something like it change['who'] = repository.get_revision( new_rev.parent_ids[-1]).get_apparent_authors()[0] else: change['who'] = new_rev.get_apparent_authors()[0] # maybe useful to know: # name, email = bzrtools.config.parse_username(change['who']) change['comments'] = new_rev.message change['revision'] = new_revno files = change['files'] = [] changes = repository.revision_tree(new_revid).changes_from( repository.revision_tree(old_revid)) for (collection, name) in ((changes.added, 'ADDED'), (changes.removed, 'REMOVED'), (changes.modified, 'MODIFIED')): for info in collection: path = info[0] kind = info[2] files.append(' '.join([path, kind, name])) for info in changes.renamed: oldpath, newpath, id, kind, text_modified, meta_modified = info elements = [oldpath, kind,'RENAMED', newpath] if text_modified or meta_modified: elements.append('MODIFIED') files.append(' '.join(elements)) return change ############################################################################# # poller # We don't want to make the hooks unnecessarily depend on buildbot being # installed locally, so we conditionally create the BzrPoller class. if DEFINE_POLLER: FULL = object() SHORT = object() class BzrPoller(buildbot.changes.base.PollingChangeSource, buildbot.util.ComparableMixin): compare_attrs = ['url'] def __init__(self, url, poll_interval=10*60, blame_merge_author=False, branch_name=None, category=None): # poll_interval is in seconds, so default poll_interval is 10 # minutes. # bzr+ssh://bazaar.launchpad.net/~launchpad-pqm/launchpad/devel/ # works, lp:~launchpad-pqm/launchpad/devel/ doesn't without help. if url.startswith('lp:'): url = 'bzr+ssh://bazaar.launchpad.net/' + url[3:] self.url = url self.poll_interval = poll_interval self.loop = twisted.internet.task.LoopingCall(self.poll) self.blame_merge_author = blame_merge_author self.branch_name = branch_name self.category = category def startService(self): twisted.python.log.msg("BzrPoller(%s) starting" % self.url) if self.branch_name is FULL: ourbranch = self.url elif self.branch_name is SHORT: # We are in a bit of trouble, as we cannot really know what our # branch is until we have polled new changes. # Seems we would have to wait until we polled the first time, # and only then do the filtering, grabbing the branch name from # whatever we polled. # For now, leave it as it was previously (compare against # self.url); at least now things work when specifying the # branch name explicitly. ourbranch = self.url else: ourbranch = self.branch_name for change in reversed(self.parent.changes): if change.branch == ourbranch: self.last_revision = change.revision break else: self.last_revision = None buildbot.changes.base.PollingChangeSource.startService(self) def stopService(self): twisted.python.log.msg("BzrPoller(%s) shutting down" % self.url) return buildbot.changes.base.PollingChangeSource.stopService(self) def describe(self): return "BzrPoller watching %s" % self.url @twisted.internet.defer.inlineCallbacks def poll(self): # On a big tree, even individual elements of the bzr commands # can take awhile. So we just push the bzr work off to a # thread. try: changes = yield twisted.internet.threads.deferToThread( self.getRawChanges) except (SystemExit, KeyboardInterrupt): raise except: # we'll try again next poll. Meanwhile, let's report. twisted.python.log.err() else: for change in changes: yield self.addChange( buildbot.changes.changes.Change(**change)) self.last_revision = change['revision'] def getRawChanges(self): branch = bzrlib.branch.Branch.open_containing(self.url)[0] if self.branch_name is FULL: branch_name = self.url elif self.branch_name is SHORT: branch_name = branch.nick else: # presumably a string or maybe None branch_name = self.branch_name changes = [] change = generate_change( branch, blame_merge_author=self.blame_merge_author) if (self.last_revision is None or change['revision'] > self.last_revision): change['branch'] = branch_name change['category'] = self.category changes.append(change) if self.last_revision is not None: while self.last_revision + 1 < change['revision']: change = generate_change( branch, new_revno=change['revision']-1, blame_merge_author=self.blame_merge_author) change['branch'] = branch_name changes.append(change) changes.reverse() return changes def addChange(self, change): d = twisted.internet.defer.Deferred() def _add_change(): d.callback( self.parent.addChange(change, src='bzr')) twisted.internet.reactor.callLater(0, _add_change) return d ############################################################################# # hooks HOOK_KEY = 'buildbot_on' SERVER_KEY = 'buildbot_server' PORT_KEY = 'buildbot_port' AUTH_KEY = 'buildbot_auth' DRYRUN_KEY = 'buildbot_dry_run' PQM_KEY = 'buildbot_pqm' SEND_BRANCHNAME_KEY = 'buildbot_send_branch_name' PUSH_VALUE = 'push' COMMIT_VALUE = 'commit' CHANGE_VALUE = 'change' def _is_true(config, key): val = config.get_user_option(key) return val is not None and val.lower().strip() in ( 'y', 'yes', 't', 'true') def _installed_hook(branch): value = branch.get_config().get_user_option(HOOK_KEY) if value is not None: value = value.strip().lower() if value not in (PUSH_VALUE, COMMIT_VALUE, CHANGE_VALUE): raise bzrlib.errors.BzrError( '%s, if set, must be one of %s, %s, or %s' % ( HOOK_KEY, PUSH_VALUE, COMMIT_VALUE, CHANGE_VALUE)) return value ########################## # Work around Twisted bug. # See http://twistedmatrix.com/trac/ticket/3591 import operator import socket from twisted.internet import defer from twisted.python import failure # replaces twisted.internet.thread equivalent def _putResultInDeferred(reactor, deferred, f, args, kwargs): """ Run a function and give results to a Deferred. """ try: result = f(*args, **kwargs) except: f = failure.Failure() reactor.callFromThread(deferred.errback, f) else: reactor.callFromThread(deferred.callback, result) # would be a proposed addition. deferToThread could use it def deferToThreadInReactor(reactor, f, *args, **kwargs): """ Run function in thread and return result as Deferred. """ d = defer.Deferred() reactor.callInThread(_putResultInDeferred, reactor, d, f, args, kwargs) return d # uses its own reactor for the threaded calls, unlike Twisted's class ThreadedResolver(twisted.internet.base.ThreadedResolver): def getHostByName(self, name, timeout = (1, 3, 11, 45)): if timeout: timeoutDelay = reduce(operator.add, timeout) else: timeoutDelay = 60 userDeferred = defer.Deferred() lookupDeferred = deferToThreadInReactor( self.reactor, socket.gethostbyname, name) cancelCall = self.reactor.callLater( timeoutDelay, self._cleanup, name, lookupDeferred) self._runningQueries[lookupDeferred] = (userDeferred, cancelCall) lookupDeferred.addBoth(self._checkTimeout, name, lookupDeferred) return userDeferred ########################## def send_change(branch, old_revno, old_revid, new_revno, new_revid, hook): config = branch.get_config() server = config.get_user_option(SERVER_KEY) if not server: bzrlib.trace.warning( 'bzr_buildbot: ERROR. If %s is set, %s must be set', HOOK_KEY, SERVER_KEY) return change = generate_change( branch, old_revno, old_revid, new_revno, new_revid, blame_merge_author=_is_true(config, PQM_KEY)) if _is_true(config, SEND_BRANCHNAME_KEY): change['branch'] = branch.nick # as of this writing (in Buildbot 0.7.9), 9989 is the default port when # you make a buildbot master. port = int(config.get_user_option(PORT_KEY) or 9989) # if dry run, stop. if _is_true(config, DRYRUN_KEY): bzrlib.trace.note("bzr_buildbot DRY RUN " "(*not* sending changes to %s:%d on %s)", server, port, hook) keys = change.keys() keys.sort() for k in keys: bzrlib.trace.note("[%10s]: %s", k, change[k]) return # We instantiate our own reactor so that this can run within a server. reactor = twisted.internet.selectreactor.SelectReactor() # See other reference to http://twistedmatrix.com/trac/ticket/3591 # above. This line can go away with a release of Twisted that addresses # this issue. reactor.resolver = ThreadedResolver(reactor) pbcf = twisted.spread.pb.PBClientFactory() reactor.connectTCP(server, port, pbcf) auth = config.get_user_option(AUTH_KEY) if auth: user, passwd = [s.strip() for s in auth.split(':', 1)] else: user, passwd = ('change', 'changepw') deferred = pbcf.login( twisted.cred.credentials.UsernamePassword(user, passwd)) def sendChanges(remote): """Send changes to buildbot.""" bzrlib.trace.mutter("bzrbuildout sending changes: %s", change) change['src'] = 'bzr' return remote.callRemote('addChange', change) deferred.addCallback(sendChanges) def quit(ignore, msg): bzrlib.trace.note("bzrbuildout: %s", msg) reactor.stop() def failed(failure): bzrlib.trace.warning("bzrbuildout: FAILURE\n %s", failure) reactor.stop() deferred.addCallback(quit, "SUCCESS") deferred.addErrback(failed) reactor.callLater(60, quit, None, "TIMEOUT") bzrlib.trace.note( "bzr_buildbot: SENDING CHANGES to buildbot master %s:%d on %s", server, port, hook) reactor.run(installSignalHandlers=False) # run in a thread when in server def post_commit(local_branch, master_branch, # branch is the master_branch old_revno, old_revid, new_revno, new_revid): if _installed_hook(master_branch) == COMMIT_VALUE: send_change(master_branch, old_revid, old_revid, new_revno, new_revid, COMMIT_VALUE) def post_push(result): if _installed_hook(result.target_branch) == PUSH_VALUE: send_change(result.target_branch, result.old_revid, result.old_revid, result.new_revno, result.new_revid, PUSH_VALUE) def post_change_branch_tip(result): if _installed_hook(result.branch) == CHANGE_VALUE: send_change(result.branch, result.old_revid, result.old_revid, result.new_revno, result.new_revid, CHANGE_VALUE) bzrlib.branch.Branch.hooks.install_named_hook( 'post_commit', post_commit, 'send change to buildbot master') bzrlib.branch.Branch.hooks.install_named_hook( 'post_push', post_push, 'send change to buildbot master') bzrlib.branch.Branch.hooks.install_named_hook( 'post_change_branch_tip', post_change_branch_tip, 'send change to buildbot master') buildbot-0.8.8/contrib/check_buildbot.py000077500000000000000000000051511222546025000203110ustar00rootroot00000000000000#!/usr/bin/env python """check_buildbot.py -H hostname -p httpport [options] nagios check for buildbot. requires that both metrics and web status enabled. Both hostname and httpport must be set, or alternatively use url which should be the full url to the metrics json resource""" try: import simplejson as json except ImportError: import json import sys import urllib OK, WARNING, CRITICAL, UNKNOWN = range(4) STATUS_TEXT = ["OK", "Warning", "Critical", "Unknown"] STATUS_CODES = dict(OK=OK, WARNING=WARNING, CRIT=CRITICAL) def exit(level, msg): print "%s: %s" % (STATUS_TEXT[level], msg) sys.exit(level) def main(): from optparse import OptionParser parser = OptionParser(__doc__) parser.set_defaults( hostname=None, httpport=None, url=None, verbosity=0 ) parser.add_option("-H", "--host", dest="hostname", help="Hostname") parser.add_option("-p", "--port", dest="httpport", type="int", help="WebStatus port") parser.add_option("-u", "--url", dest="url", help="Metrics url") parser.add_option("-v", "--verbose", dest="verbosity", action="count", help="Increase verbosity") options, args = parser.parse_args() if options.hostname and options.httpport: url = "http://%s:%s/json/metrics" % (options.hostname, options.httpport) elif options.url: url = options.url else: exit(UNKNOWN, "You must specify both hostname and httpport, or just url") try: data = urllib.urlopen(url).read() except: exit(CRITICAL, "Error connecting to %s" % url) try: data = json.loads(data) except: exit(CRITICAL, "Could not parse output of %s as json" % url) if not data: exit(WARNING, "%s returned null; are metrics disabled?" % url) alarms = data['alarms'] status = OK messages = [] for alarm_name, alarm_state in alarms.items(): if options.verbosity >= 2: messages.append("%s: %s" % (alarm_name, alarm_state)) try: alarm_code = STATUS_CODES[alarm_state[0]] alarm_msg = alarm_state[1] except: status = UNKNOWN messages.append("%s has unknown alarm state %s" % (alarm_name, alarm_state)) continue status = max(status, alarm_code) if alarm_code > OK and options.verbosity < 2: messages.append("%s: %s" % (alarm_name, alarm_state)) if not messages and status == OK: messages.append("no problems") exit(status, ";".join(messages)) if __name__ == '__main__': main() buildbot-0.8.8/contrib/coverage2text.py000077500000000000000000000077721222546025000201450ustar00rootroot00000000000000#!/usr/bin/env python import sys from coverage import coverage from coverage.results import Numbers from coverage.summary import SummaryReporter from twisted.python import usage # this is an adaptation of the code behind "coverage report", modified to # display+sortby "lines uncovered", which (IMHO) is more important of a # metric than lines covered or percentage covered. Concentrating on the files # with the most uncovered lines encourages getting the tree and test suite # into a state that provides full line-coverage on all files. # much of this code was adapted from coverage/summary.py in the 'coverage' # distribution, and is used under their BSD license. class Options(usage.Options): optParameters = [ ("sortby", "s", "uncovered", "how to sort: uncovered, covered, name"), ] class MyReporter(SummaryReporter): def report(self, outfile=None, sortby="uncovered"): self.find_code_units(None, ["/System", "/Library", "/usr/lib", "buildbot/test", "simplejson"]) # Prepare the formatting strings max_name = max([len(cu.name) for cu in self.code_units] + [5]) fmt_name = "%%- %ds " % max_name fmt_err = "%s %s: %s\n" header1 = (fmt_name % "" ) + " Statements " header2 = (fmt_name % "Name") + " Uncovered Covered" fmt_coverage = fmt_name + "%9d %7d " if self.branches: header1 += " Branches " header2 += " Found Excutd" fmt_coverage += " %6d %6d" header1 += " Percent" header2 += " Covered" fmt_coverage += " %7d%%" if self.show_missing: header1 += " " header2 += " Missing" fmt_coverage += " %s" rule = "-" * len(header1) + "\n" header1 += "\n" header2 += "\n" fmt_coverage += "\n" if not outfile: outfile = sys.stdout # Write the header outfile.write(header1) outfile.write(header2) outfile.write(rule) total = Numbers() total_uncovered = 0 lines = [] for cu in self.code_units: try: analysis = self.coverage._analyze(cu) nums = analysis.numbers uncovered = nums.n_statements - nums.n_executed total_uncovered += uncovered args = (cu.name, uncovered, nums.n_executed) if self.branches: args += (nums.n_branches, nums.n_executed_branches) args += (nums.pc_covered,) if self.show_missing: args += (analysis.missing_formatted(),) if sortby == "covered": sortkey = nums.pc_covered elif sortby == "uncovered": sortkey = uncovered else: sortkey = cu.name lines.append((sortkey, fmt_coverage % args)) total += nums except KeyboardInterrupt: # pragma: no cover raise except: if not self.ignore_errors: typ, msg = sys.exc_info()[:2] outfile.write(fmt_err % (cu.name, typ.__name__, msg)) lines.sort() if sortby in ("uncovered", "covered"): lines.reverse() for sortkey,line in lines: outfile.write(line) if total.n_files > 1: outfile.write(rule) args = ("TOTAL", total_uncovered, total.n_executed) if self.branches: args += (total.n_branches, total.n_executed_branches) args += (total.pc_covered,) if self.show_missing: args += ("",) outfile.write(fmt_coverage % args) def report(o): c = coverage() c.load() r = MyReporter(c, show_missing=False, ignore_errors=False) r.report(sortby=o['sortby']) if __name__ == '__main__': o = Options() o.parseOptions() report(o) buildbot-0.8.8/contrib/css/000077500000000000000000000000001222546025000155615ustar00rootroot00000000000000buildbot-0.8.8/contrib/css/sample1.css000066400000000000000000000014721222546025000176410ustar00rootroot00000000000000* { font-family: Verdana, sans-serif; font-size: 10px; font-weight: bold; } a:link,a:visited,a:active { color: #666666; } a:hover { color: #FFFFFF; } .table { border-spacing: 2px; } td.Event, td.Activity, td.Change, td.Time, td.Builder { color: #333333; border: 1px solid #666666; background-color: #CCCCCC; } /* LastBuild, BuildStep states */ .success { color: #FFFFFF; border: 1px solid #2f8f0f; background-color: #8fdf5f; } .failure { color: #FFFFFF; border: 1px solid #f33636; background-color: #e98080; } .warnings { color: #FFFFFF; border: 1px solid #fc901f; background-color: #ffc343; } .exception, td.offline { color: #FFFFFF; border: 1px solid #8000c0; background-color: #e0b0ff; } .start,.running, td.building { color: #666666; border: 1px solid #ffff00; background-color: #fffc6c; } buildbot-0.8.8/contrib/css/sample2.css000066400000000000000000000017311222546025000176400ustar00rootroot00000000000000* { font-family: Verdana, sans-serif; font-size: 12px; font-weight: bold; } a:link,a:visited,a:active { color: #666666; } a:hover { color: #FFFFFF; } .table { border-spacing: 2px; } td.Event, td.Activity, td.Change, td.Time, td.Builder { color: #333333; border: 1px solid #666666; background-color: #CCCCCC; } /* LastBuild, BuildStep states */ .success { color: #FFFFFF; border: 1px solid #2f8f0f; background-color: #72ff75; } .failure { color: #FFFFFF; border: 1px solid #f33636; background-color: red; } .warnings { color: #FFFFFF; border: 1px solid #fc901f; background-color: #ffc343; } .exception, td.offline { color: #FFFFFF; border: 1px solid #8000c0; background-color: red; } .start,.running, td.building { color: #666666; border: 1px solid #ffff00; background-color: yellow; } buildbot-0.8.8/contrib/darcs_buildbot.py000077500000000000000000000144401222546025000203310ustar00rootroot00000000000000#! /usr/bin/python # This is a script which delivers Change events from Darcs to the buildmaster # each time a patch is pushed into a repository. Add it to the 'apply' hook # on your canonical "central" repository, by putting something like the # following in the _darcs/prefs/defaults file of that repository: # # apply posthook /PATH/TO/darcs_buildbot.py BUILDMASTER:PORT # apply run-posthook # # (the second command is necessary to avoid the usual "do you really want to # run this hook" prompt. Note that you cannot have multiple 'apply posthook' # lines: if you need this, you must create a shell script to run all your # desired commands, then point the posthook at that shell script.) # # Note that both Buildbot and Darcs must be installed on the repository # machine. You will also need the Python/XML distribution installed (the # "python2.3-xml" package under debian). import os import sys import commands import xml from buildbot.clients import sendchange from twisted.internet import defer, reactor from xml.dom import minidom def getText(node): return "".join([cn.data for cn in node.childNodes if cn.nodeType == cn.TEXT_NODE]) def getTextFromChild(parent, childtype): children = parent.getElementsByTagName(childtype) if not children: return "" return getText(children[0]) def makeChange(p): author = p.getAttribute("author") revision = p.getAttribute("hash") comments = (getTextFromChild(p, "name") + "\n" + getTextFromChild(p, "comment")) summary = p.getElementsByTagName("summary")[0] files = [] for filenode in summary.childNodes: if filenode.nodeName in ("add_file", "modify_file", "remove_file"): filename = getText(filenode).strip() files.append(filename) elif filenode.nodeName == "move": from_name = filenode.getAttribute("from") to_name = filenode.getAttribute("to") files.append(to_name) # note that these are all unicode. Because PB can't handle unicode, we # encode them into ascii, which will blow up early if there's anything we # can't get to the far side. When we move to something that *can* handle # unicode (like newpb), remove this. author = author.encode("ascii", "replace") comments = comments.encode("ascii", "replace") files = [f.encode("ascii", "replace") for f in files] revision = revision.encode("ascii", "replace") change = { # note: this is more likely to be a full email address, which would # make the left-hand "Changes" column kind of wide. The buildmaster # should probably be improved to display an abbreviation of the # username. 'username': author, 'revision': revision, 'comments': comments, 'files': files, } return change def getChangesFromCommand(cmd, count): out = commands.getoutput(cmd) try: doc = minidom.parseString(out) except xml.parsers.expat.ExpatError, e: print "failed to parse XML" print str(e) print "purported XML is:" print "--BEGIN--" print out print "--END--" sys.exit(1) c = doc.getElementsByTagName("changelog")[0] changes = [] for i, p in enumerate(c.getElementsByTagName("patch")): if i >= count: break changes.append(makeChange(p)) return changes def getSomeChanges(count): cmd = "darcs changes --last=%d --xml-output --summary" % count return getChangesFromCommand(cmd, count) LASTCHANGEFILE = ".darcs_buildbot-lastchange" def findNewChanges(): if os.path.exists(LASTCHANGEFILE): f = open(LASTCHANGEFILE, "r") lastchange = f.read() f.close() else: return getSomeChanges(1) lookback = 10 while True: changes = getSomeChanges(lookback) # getSomeChanges returns newest-first, so changes[0] is the newest. # we want to scan the newest first until we find the changes we sent # last time, then deliver everything newer than that (and send them # oldest-first). for i, c in enumerate(changes): if c['revision'] == lastchange: newchanges = changes[:i] newchanges.reverse() return newchanges if 2*lookback > 100: raise RuntimeError("unable to find our most recent change " "(%s) in the last %d changes" % (lastchange, lookback)) lookback = 2*lookback def sendChanges(master): changes = findNewChanges() s = sendchange.Sender(master) d = defer.Deferred() reactor.callLater(0, d.callback, None) if not changes: print "darcs_buildbot.py: weird, no changes to send" return elif len(changes) == 1: print "sending 1 change to buildmaster:" else: print "sending %d changes to buildmaster:" % len(changes) # the Darcs Source class expects revision to be a context, not a # hash of a patch (which is what we have in c['revision']). For # the moment, we send None for everything but the most recent, because getting # contexts is Hard. # get the context for the most recent change latestcontext = commands.getoutput("darcs changes --context") changes[-1]['context'] = latestcontext def _send(res, c): branch = None print " %s" % c['revision'] return s.send(branch, c.get('context'), c['comments'], c['files'], c['username'], vc='darcs') for c in changes: d.addCallback(_send, c) def printSuccess(res): num_changes = len(changes) if num_changes > 1: print "%d changes sent successfully" % num_changes elif num_changes == 1: print "change sent successfully" else: print "no changes to send" def printFailure(why): print "change(s) NOT sent, something went wrong: " + str(why) d.addCallbacks(printSuccess, printFailure) d.addBoth(lambda _ : reactor.stop) reactor.run() if changes: lastchange = changes[-1]['revision'] f = open(LASTCHANGEFILE, "w") f.write(lastchange) f.close() if __name__ == '__main__': MASTER = sys.argv[1] sendChanges(MASTER) buildbot-0.8.8/contrib/fakechange.py000077500000000000000000000046411222546025000174270ustar00rootroot00000000000000#! /usr/bin/python """ This is an example of how to use the remote ChangeMaster interface, which is a port that allows a remote program to inject Changes into the buildmaster. The buildmaster can either pull changes in from external sources (see buildbot.changes.changes.ChangeMaster.addSource for an example), or those changes can be pushed in from outside. This script shows how to do the pushing. Changes are just dictionaries with three keys: 'who': a simple string with a username. Responsibility for this change will be assigned to the named user (if something goes wrong with the build, they will be blamed for it). 'files': a list of strings, each with a filename relative to the top of the source tree. 'comments': a (multiline) string with checkin comments. Each call to .addChange injects a single Change object: each Change represents multiple files, all changed by the same person, and all with the same checkin comments. The port that this script connects to is the same 'slavePort' that the buildslaves and other debug tools use. The ChangeMaster service will only be available on that port if 'change' is in the list of services passed to buildbot.master.makeApp (this service is turned ON by default). """ import sys import commands import random import os.path from twisted.spread import pb from twisted.cred import credentials from twisted.internet import reactor from twisted.python import log def done(*args): reactor.stop() users = ('zaphod', 'arthur', 'trillian', 'marvin', 'sbfast') dirs = ('src', 'doc', 'tests') sources = ('foo.c', 'bar.c', 'baz.c', 'Makefile') docs = ('Makefile', 'index.html', 'manual.texinfo') def makeFilename(): d = random.choice(dirs) if d in ('src', 'tests'): f = random.choice(sources) else: f = random.choice(docs) return os.path.join(d, f) def send_change(remote): who = random.choice(users) if len(sys.argv) > 1: files = sys.argv[1:] else: files = [makeFilename()] comments = commands.getoutput("fortune") change = {'who': who, 'files': files, 'comments': comments} d = remote.callRemote('addChange', change) d.addCallback(done) print "%s: %s" % (who, " ".join(files)) f = pb.PBClientFactory() d = f.login(credentials.UsernamePassword("change", "changepw")) reactor.connectTCP("localhost", 8007, f) err = lambda f: (log.err(), reactor.stop()) d.addCallback(send_change).addErrback(err) reactor.run() buildbot-0.8.8/contrib/fakemaster.py000077500000000000000000000102561222546025000174740ustar00rootroot00000000000000#!/usr/bin/env python # usage: python fakemaster.py # # This starts a fake master instance listenting on port 9010 # You should then connect a buildslave to localhost:9010 # commands are read from fakemaster's stdin (one line per command) and executed # on the buildslave. stderr/stdout from the buildslave are output on # fakemaster's stdout/stderr. # # Original Author: Chris AtLee # Licensed under the MPL version 2.0 import sys from twisted.internet import reactor, defer from twisted.cred import portal, checkers from twisted.spread import pb from twisted.application import strports, service from zope.interface import implements from twisted.internet import stdio from twisted.protocols import basic from buildbot.process.buildstep import RemoteShellCommand class Dispatcher: implements(portal.IRealm) def __init__(self): self.names = {} def register(self, name, afactory): self.names[name] = afactory def unregister(self, name): del self.names[name] def requestAvatar(self, avatarID, mind, interface): assert interface == pb.IPerspective afactory = self.names.get(avatarID) if afactory: p = afactory.getPerspective() else: p = self.master.getPerspective(mind, avatarID) if not p: raise ValueError("no perspective for '%s'" % avatarID) d = defer.maybeDeferred(p.attached, mind) def _avatarAttached(_, mind): return (pb.IPerspective, p, lambda: p.detached(mind)) d.addCallback(_avatarAttached, mind) return d class DontCareChecker(checkers.InMemoryUsernamePasswordDatabaseDontUse): def requestAvatarId(self, credentials): return credentials.username class FakeLog: def addStdout(self, data): sys.stdout.write(data) def addHeader(self, data): print ">>> ", data, def addStderr(self, data): sys.stderr.write(data) class FakeBot(pb.Avatar): parent = None def attached(self, remote): self.remote = remote remote.callRemote('print', 'attached') d = remote.callRemote('setBuilderList', [('shell', '.')]) def setBuilderList_cb(builders): self.builder = builders['shell'] d.addCallbacks(setBuilderList_cb) def detached(self, mind): self.parent.stdio.bot = None self.parent.stdio.transport.write('\ndetached\n# ') def messageReceivedFromSlave(self): pass def perspective_keepalive(self): pass def slaveVersion(self, name, version): pass def runCommand(self, cmd): cmd = RemoteShellCommand(workdir='.', command=cmd) cmd.buildslave = self cmd.logs['stdio'] = FakeLog() cmd._closeWhenFinished['stdio'] = False d = cmd.run(self, self.builder) return d class CmdInterface(basic.LineReceiver): delimiter = '\n' bot = None def connectionMade(self): self.transport.write("# ") def lineReceived(self, line): if not self.bot: self.transport.write('not attached\n# ') return def _done(res): self.transport.write("\n# ") d = self.bot.runCommand(line) d.addBoth(_done) class FakeMaster(service.MultiService): def __init__(self, port): service.MultiService.__init__(self) self.setName("fakemaster") self.dispatcher = Dispatcher() self.dispatcher.master = self self.portal = p = portal.Portal(self.dispatcher) p.registerChecker(DontCareChecker()) self.slavefactory = pb.PBServerFactory(p) self.slavePort = port self.stdio = CmdInterface() def startService(self): service.MultiService.startService(self) self.slavePort = strports.service(self.slavePort, self.slavefactory) self.slavePort.setServiceParent(self) stdio.StandardIO(self.stdio) def getPerspective(self, mind, avatarID): self.bot = FakeBot() self.bot.parent = self self.stdio.bot = self.bot self.stdio.transport.write('\nattached\n# ') return self.bot if __name__ == '__main__': m = FakeMaster("tcp:9010") m.startService() reactor.run() buildbot-0.8.8/contrib/fix_changes_pickle_encoding.py000077500000000000000000000023231222546025000230210ustar00rootroot00000000000000#!/usr/bin/python """%prog [options] [changes.pck] old_encoding Re-encodes changes in a pickle file to UTF-8 from the given encoding """ if __name__ == '__main__': import sys, os from cPickle import load, dump from optparse import OptionParser parser = OptionParser(__doc__) options, args = parser.parse_args() if len(args) == 2: changes_file = args[0] old_encoding = args[1] elif len(args) == 1: changes_file = "changes.pck" old_encoding = args[0] else: parser.error("Need at least one argument") print "opening %s" % (changes_file,) try: fp = open(changes_file) except IOError, e: parser.error("Couldn't open %s: %s" % (changes_file, str(e))) changemgr = load(fp) fp.close() print "decoding bytestrings in %s using %s" % (changes_file, old_encoding) changemgr.recode_changes(old_encoding) changes_backup = changes_file + ".old" i = 0 while os.path.exists(changes_backup): i += 1 changes_backup = changes_file + ".old.%i" % i print "backing up %s to %s" % (changes_file, changes_backup) os.rename(changes_file, changes_backup) dump(changemgr, open(changes_file, "w")) buildbot-0.8.8/contrib/generate_changelog.py000077500000000000000000000031221222546025000211450ustar00rootroot00000000000000#!/usr/bin/env python # # Copyright 2008 # Steve 'Ashcrow' Milner # # This software may be freely redistributed under the terms of the GNU # general public license. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. """ Generates changelog information using git. """ __docformat__ = 'restructuredtext' import os import sys def print_err(msg): """ Wrapper to make printing to stderr nicer. :Parameters: - `msg`: the message to print. """ sys.stderr.write(msg) sys.stderr.write('\n') def usage(): """ Prints out usage information to stderr. """ print_err('Usage: %s git-binary since' % sys.argv[0]) print_err(('Example: %s /usr/bin/git f5067523dfae9c7cdefc82' '8721ec593ac7be62db' % sys.argv[0])) def main(args): """ Main entry point. :Parameters: - `args`: same as sys.argv[1:] """ # Make sure we have the arguments we need, else show usage try: git_bin = args[0] since = args[1] except IndexError, ie: usage() return 1 if not os.access(git_bin, os.X_OK): print_err('Can not access %s' % git_bin) return 1 # Open a pipe and force the format pipe = os.popen((git_bin + ' log --pretty="format:%ad %ae%n' ' * %s" ' + since + '..')) print pipe.read() pipe.close() return 0 if __name__ == '__main__': raise SystemExit(main(sys.argv[1:])) buildbot-0.8.8/contrib/git_buildbot.py000077500000000000000000000307331222546025000200230ustar00rootroot00000000000000#! /usr/bin/env python # This script expects one line for each new revision on the form # # # For example: # aa453216d1b3e49e7f6f98441fa56946ddcd6a20 # 68f7abf4e6f922807889f52bc043ecd31b79f814 refs/heads/master # # Each of these changes will be passed to the buildbot server along # with any other change information we manage to extract from the # repository. # # This script is meant to be run from hooks/post-receive in the git # repository. It can also be run at client side with hooks/post-merge # after using this wrapper: #!/bin/sh # PRE=$(git rev-parse 'HEAD@{1}') # POST=$(git rev-parse HEAD) # SYMNAME=$(git rev-parse --symbolic-full-name HEAD) # echo "$PRE $POST $SYMNAME" | git_buildbot.py # # Largely based on contrib/hooks/post-receive-email from git. import commands import logging import os import re import sys from twisted.spread import pb from twisted.cred import credentials from twisted.internet import reactor, defer from optparse import OptionParser # Modify this to fit your setup, or pass in --master server:port on the # command line master = "localhost:9989" # When sending the notification, send this category if (and only if) # it's set (via --category) category = None # When sending the notification, send this repository if (and only if) # it's set (via --repository) repository = None # When sending the notification, send this project if (and only if) # it's set (via --project) project = None # When sending the notification, send this codebase. If this is None, no # codebase will be sent. This can also be set via --project codebase = None # Username portion of PB login credentials to send the changes to the master username = "change" # Password portion of PB login credentials to send the changes to the master auth = "changepw" # When converting strings to unicode, assume this encoding. # (set with --encoding) encoding = 'utf8' # The GIT_DIR environment variable must have been set up so that any # git commands that are executed will operate on the repository we're # installed in. changes = [] def connectFailed(error): logging.error("Could not connect to %s: %s" % (master, error.getErrorMessage())) return error def addChanges(remote, changei, src='git'): logging.debug("addChanges %s, %s" % (repr(remote), repr(changei))) def addChange(c): logging.info("New revision: %s" % c['revision'][:8]) for key, value in c.iteritems(): logging.debug(" %s: %s" % (key, value)) c['src'] = src d = remote.callRemote('addChange', c) return d finished_d = defer.Deferred() def iter(): try: c = changei.next() d = addChange(c) # handle successful completion by re-iterating, but not immediately # as that will blow out the Python stack def cb(_): reactor.callLater(0, iter) d.addCallback(cb) # and pass errors along to the outer deferred d.addErrback(finished_d.errback) except StopIteration: remote.broker.transport.loseConnection() finished_d.callback(None) iter() return finished_d def connected(remote): return addChanges(remote, changes.__iter__()) def grab_commit_info(c, rev): # Extract information about committer and files using git show f = os.popen("git show --raw --pretty=full %s" % rev, 'r') files = [] comments = [] while True: line = f.readline() if not line: break if line.startswith(4*' '): comments.append(line[4:]) m = re.match(r"^:.*[MAD]\s+(.+)$", line) if m: logging.debug("Got file: %s" % m.group(1)) files.append(unicode(m.group(1), encoding=encoding)) continue m = re.match(r"^Author:\s+(.+)$", line) if m: logging.debug("Got author: %s" % m.group(1)) c['who'] = unicode(m.group(1), encoding=encoding) if re.match(r"^Merge: .*$", line): files.append('merge') c['comments'] = ''.join(comments) c['files'] = files status = f.close() if status: logging.warning("git show exited with status %d" % status) def gen_changes(input, branch): while True: line = input.readline() if not line: break logging.debug("Change: %s" % line) m = re.match(r"^([0-9a-f]+) (.*)$", line.strip()) c = {'revision': m.group(1), 'branch': unicode(branch, encoding=encoding), } if category: c['category'] = unicode(category, encoding=encoding) if repository: c['repository'] = unicode(repository, encoding=encoding) if project: c['project'] = unicode(project, encoding=encoding) if codebase: c['codebase'] = unicode(codebase, encoding=encoding) grab_commit_info(c, m.group(1)) changes.append(c) def gen_create_branch_changes(newrev, refname, branch): # A new branch has been created. Generate changes for everything # up to `newrev' which does not exist in any branch but `refname'. # # Note that this may be inaccurate if two new branches are created # at the same time, pointing to the same commit, or if there are # commits that only exists in a common subset of the new branches. logging.info("Branch `%s' created" % branch) f = os.popen("git rev-parse --not --branches" + "| grep -v $(git rev-parse %s)" % refname + "| git rev-list --reverse --pretty=oneline --stdin %s" % newrev, 'r') gen_changes(f, branch) status = f.close() if status: logging.warning("git rev-list exited with status %d" % status) def gen_update_branch_changes(oldrev, newrev, refname, branch): # A branch has been updated. If it was a fast-forward update, # generate Change events for everything between oldrev and newrev. # # In case of a forced update, first generate a "fake" Change event # rewinding the branch to the common ancestor of oldrev and # newrev. Then, generate Change events for each commit between the # common ancestor and newrev. logging.info("Branch `%s' updated %s .. %s" % (branch, oldrev[:8], newrev[:8])) baserev = commands.getoutput("git merge-base %s %s" % (oldrev, newrev)) logging.debug("oldrev=%s newrev=%s baserev=%s" % (oldrev, newrev, baserev)) if baserev != oldrev: c = {'revision': baserev, 'comments': "Rewind branch", 'branch': unicode(branch, encoding=encoding), 'who': "dummy", } logging.info("Branch %s was rewound to %s" % (branch, baserev[:8])) files = [] f = os.popen("git diff --raw %s..%s" % (oldrev, baserev), 'r') while True: line = f.readline() if not line: break file = re.match(r"^:.*[MAD]\s+(.+)$", line).group(1) logging.debug(" Rewound file: %s" % file) files.append(unicode(file, encoding=encoding)) status = f.close() if status: logging.warning("git diff exited with status %d" % status) if category: c['category'] = unicode(category, encoding=encoding) if repository: c['repository'] = unicode(repository, encoding=encoding) if project: c['project'] = unicode(project, encoding=encoding) if codebase: c['codebase'] = unicode(codebase, encoding=encoding) if files: c['files'] = files changes.append(c) if newrev != baserev: # Not a pure rewind f = os.popen("git rev-list --reverse --pretty=oneline %s..%s" % (baserev, newrev), 'r') gen_changes(f, branch) status = f.close() if status: logging.warning("git rev-list exited with status %d" % status) def cleanup(res): reactor.stop() def process_changes(): # Read branch updates from stdin and generate Change events while True: line = sys.stdin.readline() if not line: break [oldrev, newrev, refname] = line.split(None, 2) # We only care about regular heads, i.e. branches m = re.match(r"^refs\/heads\/(.+)$", refname) if not m: logging.info("Ignoring refname `%s': Not a branch" % refname) continue branch = m.group(1) # Find out if the branch was created, deleted or updated. Branches # being deleted aren't really interesting. if re.match(r"^0*$", newrev): logging.info("Branch `%s' deleted, ignoring" % branch) continue elif re.match(r"^0*$", oldrev): gen_create_branch_changes(newrev, refname, branch) else: gen_update_branch_changes(oldrev, newrev, refname, branch) # Submit the changes, if any if not changes: logging.warning("No changes found") return host, port = master.split(':') port = int(port) f = pb.PBClientFactory() d = f.login(credentials.UsernamePassword(username, auth)) reactor.connectTCP(host, port, f) d.addErrback(connectFailed) d.addCallback(connected) d.addBoth(cleanup) reactor.run() def parse_options(): parser = OptionParser() parser.add_option("-l", "--logfile", action="store", type="string", help="Log to the specified file") parser.add_option("-v", "--verbose", action="count", help="Be more verbose. Ignored if -l is not specified.") master_help = ("Build master to push to. Default is %(master)s" % { 'master' : master }) parser.add_option("-m", "--master", action="store", type="string", help=master_help) parser.add_option("-c", "--category", action="store", type="string", help="Scheduler category to notify.") parser.add_option("-r", "--repository", action="store", type="string", help="Git repository URL to send.") parser.add_option("-p", "--project", action="store", type="string", help="Project to send.") parser.add_option("--codebase", action="store", type="string", help="Codebase to send.") encoding_help = ("Encoding to use when converting strings to " "unicode. Default is %(encoding)s." % { "encoding" : encoding }) parser.add_option("-e", "--encoding", action="store", type="string", help=encoding_help) username_help = ("Username used in PB connection auth, defaults to " "%(username)s." % { "username" : username }) parser.add_option("-u", "--username", action="store", type="string", help=username_help) auth_help = ("Password used in PB connection auth, defaults to " "%(auth)s." % { "auth" : auth }) # 'a' instead of 'p' due to collisions with the project short option parser.add_option("-a", "--auth", action="store", type="string", help=auth_help) options, args = parser.parse_args() return options # Log errors and critical messages to stderr. Optionally log # information to a file as well (we'll set that up later.) stderr = logging.StreamHandler(sys.stderr) fmt = logging.Formatter("git_buildbot: %(levelname)s: %(message)s") stderr.setLevel(logging.ERROR) stderr.setFormatter(fmt) logging.getLogger().addHandler(stderr) logging.getLogger().setLevel(logging.DEBUG) try: options = parse_options() level = logging.WARNING if options.verbose: level -= 10 * options.verbose if level < 0: level = 0 if options.logfile: logfile = logging.FileHandler(options.logfile) logfile.setLevel(level) fmt = logging.Formatter("%(asctime)s %(levelname)s: %(message)s") logfile.setFormatter(fmt) logging.getLogger().addHandler(logfile) if options.master: master=options.master if options.category: category = options.category if options.repository: repository = options.repository if options.project: project = options.project if options.codebase: codebase = options.codebase if options.username: username = options.username if options.auth: auth = options.auth if options.encoding: encoding = options.encoding process_changes() except SystemExit: pass except: logging.exception("Unhandled exception") sys.exit(1) buildbot-0.8.8/contrib/github_buildbot.py000077500000000000000000000166271222546025000205300ustar00rootroot00000000000000#!/usr/bin/env python """ github_buildbot.py is based on git_buildbot.py github_buildbot.py will determine the repository information from the JSON HTTP POST it receives from github.com and build the appropriate repository. If your github repository is private, you must add a ssh key to the github repository for the user who initiated the build on the buildslave. """ import tempfile import logging import os import re import sys import traceback from twisted.web import server, resource from twisted.internet import reactor from twisted.spread import pb from twisted.cred import credentials from optparse import OptionParser try: import json except ImportError: import simplejson as json class GitHubBuildBot(resource.Resource): """ GitHubBuildBot creates the webserver that responds to the GitHub Service Hook. """ isLeaf = True master = None port = None def render_POST(self, request): """ Reponds only to POST events and starts the build process :arguments: request the http request object """ try: payload = json.loads(request.args['payload'][0]) user = payload['repository']['owner']['name'] repo = payload['repository']['name'] repo_url = payload['repository']['url'] self.private = payload['repository']['private'] project = request.args.get('project', None) if project: project = project[0] logging.debug("Payload: " + str(payload)) self.process_change(payload, user, repo, repo_url, project) except Exception: logging.error("Encountered an exception:") for msg in traceback.format_exception(*sys.exc_info()): logging.error(msg.strip()) def process_change(self, payload, user, repo, repo_url, project): """ Consumes the JSON as a python object and actually starts the build. :arguments: payload Python Object that represents the JSON sent by GitHub Service Hook. """ changes = [] newrev = payload['after'] refname = payload['ref'] # We only care about regular heads, i.e. branches match = re.match(r"^refs\/heads\/(.+)$", refname) if not match: logging.info("Ignoring refname `%s': Not a branch" % refname) branch = match.group(1) # Find out if the branch was created, deleted or updated. Branches # being deleted aren't really interesting. if re.match(r"^0*$", newrev): logging.info("Branch `%s' deleted, ignoring" % branch) else: for commit in payload['commits']: files = [] files.extend(commit['added']) files.extend(commit['modified']) files.extend(commit['removed']) change = {'revision': commit['id'], 'revlink': commit['url'], 'comments': commit['message'], 'branch': branch, 'who': commit['author']['name'] + " <" + commit['author']['email'] + ">", 'files': files, 'repository': repo_url, 'project': project, } changes.append(change) # Submit the changes, if any if not changes: logging.warning("No changes found") return host, port = self.master.split(':') port = int(port) factory = pb.PBClientFactory() deferred = factory.login(credentials.UsernamePassword("change", "changepw")) reactor.connectTCP(host, port, factory) deferred.addErrback(self.connectFailed) deferred.addCallback(self.connected, changes) def connectFailed(self, error): """ If connection is failed. Logs the error. """ logging.error("Could not connect to master: %s" % error.getErrorMessage()) return error def addChange(self, dummy, remote, changei, src='git'): """ Sends changes from the commit to the buildmaster. """ logging.debug("addChange %s, %s" % (repr(remote), repr(changei))) try: change = changei.next() except StopIteration: remote.broker.transport.loseConnection() return None logging.info("New revision: %s" % change['revision'][:8]) for key, value in change.iteritems(): logging.debug(" %s: %s" % (key, value)) change['src'] = src deferred = remote.callRemote('addChange', change) deferred.addCallback(self.addChange, remote, changei, src) return deferred def connected(self, remote, changes): """ Reponds to the connected event. """ return self.addChange(None, remote, changes.__iter__()) def main(): """ The main event loop that starts the server and configures it. """ usage = "usage: %prog [options]" parser = OptionParser(usage) parser.add_option("-p", "--port", help="Port the HTTP server listens to for the GitHub Service Hook" + " [default: %default]", default=4000, type=int, dest="port") parser.add_option("-m", "--buildmaster", help="Buildbot Master host and port. ie: localhost:9989 [default:" + " %default]", default="localhost:9989", dest="buildmaster") parser.add_option("-l", "--log", help="The absolute path, including filename, to save the log to" + " [default: %default]", default = tempfile.gettempdir() + "/github_buildbot.log", dest="log") parser.add_option("-L", "--level", help="The logging level: debug, info, warn, error, fatal [default:" + " %default]", default='warn', dest="level") parser.add_option("-g", "--github", help="The github server. Changing this is useful if you've specified" + " a specific HOST handle in ~/.ssh/config for github " + "[default: %default]", default='github.com', dest="github") parser.add_option("--pidfile", help="Write the process identifier (PID) to this file on start." + " The file is removed on clean exit. [default: %default]", default=None, dest="pidfile") (options, _) = parser.parse_args() if options.pidfile: with open(options.pidfile, 'w') as f: f.write(str(os.getpid())) levels = { 'debug':logging.DEBUG, 'info':logging.INFO, 'warn':logging.WARNING, 'error':logging.ERROR, 'fatal':logging.FATAL, } filename = options.log log_format = "%(asctime)s - %(levelname)s - %(message)s" logging.basicConfig(filename=filename, format=log_format, level=levels[options.level]) github_bot = GitHubBuildBot() github_bot.github = options.github github_bot.master = options.buildmaster site = server.Site(github_bot) reactor.listenTCP(options.port, site) reactor.run() if options.pidfile and os.path.exists(options.pidfile): os.unlink(options.pidfile) if __name__ == '__main__': main() buildbot-0.8.8/contrib/googlecode_atom.py000066400000000000000000000146341222546025000205020ustar00rootroot00000000000000# GoogleCode Atom Feed Poller # Author: Srivats P. # Based on Mozilla's HgPoller # http://bonsai.mozilla.org/cvsblame.cgi?file=/mozilla/tools/buildbot/buildbot/changes/Attic/hgpoller.py&revision=1.1.4.2 # # Description: # Use this ChangeSource for projects hosted on http://code.google.com/ # # This ChangeSource uses the project's commit Atom feed. Depending upon the # frequency of commits, you can tune the polling interval for the feed # (default is 1 hour) # # Parameters: # feedurl (MANDATORY): The Atom feed URL of the GoogleCode repo # pollinterval (OPTIONAL): Polling frequency for the feed (in seconds) # # Example: # To poll the Ostinato project's commit feed every 3 hours, use - # from googlecode_atom import GoogleCodeAtomPoller # poller = GoogleCodeAtomPoller( # feedurl="http://code.google.com/feeds/p/ostinato/hgchanges/basic", # pollinterval=10800) # c['change_source'] = [ poller ] # import datetime from xml.dom import minidom from twisted.python import log from twisted.internet import defer from twisted.web.client import getPage from buildbot.changes import base def googleCodePollerForProject(project, vcs, pollinterval=3600): return GoogleCodeAtomPoller( 'http://code.google.com/feeds/p/%s/%schanges/basic' % (project, vcs), pollinterval=pollinterval) class GoogleCodeAtomPoller(base.PollingChangeSource): """This source will poll a GoogleCode Atom feed for changes and submit them to the change master. Works for both Svn, Git, and Hg repos. TODO: branch processing """ compare_attrs = ['feedurl', 'pollinterval'] parent = None loop = None volatile = ['loop'] working = False def __init__(self, feedurl, pollinterval=3600): """ @type feedurl: string @param feedurl: The Atom feed URL of the GoogleCode repo (e.g. http://code.google.com/feeds/p/ostinato/hgchanges/basic) @type pollinterval: int @param pollinterval: The time (in seconds) between queries for changes (default is 1 hour) """ base.PollingChangeSource(pollInterval = pollinterval) self.feedurl = feedurl self.branch = None self.lastChange = None self.src = None for word in self.feedurl.split('/'): if word == 'svnchanges': self.src = 'svn' break elif word == 'hgchanges': self.src = 'hg' break elif word == 'gitchanges': self.src = 'git' break def startService(self): log.msg("GoogleCodeAtomPoller starting") base.PollingChangeSource.startService(self) def stopService(self): log.msg("GoogleCodeAtomPoller stoppping") return base.PollingChangeSource.stopService(self) def describe(self): return ("Getting changes from the GoogleCode repo changes feed %s" % self._make_url()) def poll(self): if self.working: log.msg("Not polling because last poll is still working") else: self.working = True d = self._get_changes() d.addCallback(self._process_changes) d.addCallbacks(self._finished_ok, self._finished_failure) def _finished_ok(self, res): assert self.working self.working = False log.msg("GoogleCodeAtomPoller poll success") return res def _finished_failure(self, res): log.msg("GoogleCodeAtomPoller poll failed: %s" % res) assert self.working self.working = False return None def _make_url(self): return "%s" % (self.feedurl) def _get_changes(self): url = self._make_url() log.msg("GoogleCodeAtomPoller polling %s" % url) return getPage(url, timeout=self.pollinterval) def _parse_changes(self, query): dom = minidom.parseString(query) entries = dom.getElementsByTagName("entry") changes = [] # Entries come in reverse chronological order for i in entries: d = {} # revision is the last part of the 'id' url d["revision"] = i.getElementsByTagName( "id")[0].firstChild.data.split('/')[-1] if d["revision"] == self.lastChange: break # no more new changes d["when"] = datetime.datetime.strptime( i.getElementsByTagName("updated")[0].firstChild.data, "%Y-%m-%dT%H:%M:%SZ") d["author"] = i.getElementsByTagName( "author")[0].getElementsByTagName("name")[0].firstChild.data # files and commit msg are separated by 2 consecutive
    content = i.getElementsByTagName( "content")[0].firstChild.data.split("
    \n
    ") # Remove the action keywords from the file list fl = content[0].replace( u' \xa0\xa0\xa0\xa0Add\xa0\xa0\xa0\xa0', '').replace( u' \xa0\xa0\xa0\xa0Delete\xa0\xa0\xa0\xa0', '').replace( u' \xa0\xa0\xa0\xa0Modify\xa0\xa0\xa0\xa0', '') # Get individual files and remove the 'header' d["files"] = fl.encode("ascii", "replace").split("
    ")[1:] d["files"] = [f.strip() for f in d["files"]] try: d["comments"] = content[1].encode("ascii", "replace") except: d["comments"] = "No commit message provided" changes.append(d) changes.reverse() # want them in chronological order return changes @defer.inlineCallbacks def _process_changes(self, query): change_list = self._parse_changes(query) # Skip calling addChange() if this is the first successful poll. if self.lastChange is not None: for change in change_list: yield self.master.addChange(author=change["author"], revision=change["revision"], files=change["files"], comments=change["comments"], when_timestamp=change["when"], branch=self.branch, src=self.src) if change_list: self.lastChange = change_list[-1]["revision"] buildbot-0.8.8/contrib/init-scripts/000077500000000000000000000000001222546025000174215ustar00rootroot00000000000000buildbot-0.8.8/contrib/init-scripts/buildmaster.default000066400000000000000000000011751222546025000233060ustar00rootroot00000000000000MASTER_RUNNER=/usr/bin/buildbot # NOTE: MASTER_ENABLED has changed its behaviour in version 0.8.4. Use # 'true|yes|1' to enable instance and 'false|no|0' to disable. Other # values will be considered as syntax error. MASTER_ENABLED[1]=0 # 1-enabled, 0-disabled MASTER_NAME[1]="buildmaster #1" # short name printed on start/stop MASTER_USER[1]="buildbot" # user to run master as MASTER_BASEDIR[1]="" # basedir to master (absolute path) MASTER_OPTIONS[1]="" # buildbot options MASTER_PREFIXCMD[1]="" # prefix command, i.e. nice, linux32, dchroot buildbot-0.8.8/contrib/init-scripts/buildmaster.init.sh000077500000000000000000000117111222546025000232360ustar00rootroot00000000000000#!/bin/bash ### Maintain compatibility with chkconfig # chkconfig: 2345 83 17 # description: buildmaster ### BEGIN INIT INFO # Provides: buildmaster # Required-Start: $remote_fs # Required-Stop: $remote_fs # Default-Start: 2 3 4 5 # Default-Stop: 0 1 6 # Short-Description: Buildbot master init script # Description: This file allows running buildbot master instances at # startup ### END INIT INFO PATH=/sbin:/bin:/usr/sbin:/usr/bin MASTER_RUNNER=/usr/bin/buildbot . /lib/lsb/init-functions # Source buildmaster configuration [[ -r /etc/default/buildmaster ]] && . /etc/default/buildmaster #[[ -r /etc/sysconfig/buildmaster ]] && . /etc/sysconfig/buildmaster # Or define/override the configuration here #MASTER_ENABLED[1]=0 # 1-enabled, 0-disabled #MASTER_NAME[1]="buildmaster #1" # short name printed on start/stop #MASTER_USER[1]="buildbot" # user to run master as #MASTER_BASEDIR[1]="" # basedir to master (absolute path) #MASTER_OPTIONS[1]="" # buildbot options #MASTER_PREFIXCMD[1]="" # prefix command, i.e. nice, linux32, dchroot if [[ ! -x ${MASTER_RUNNER} ]]; then log_failure_msg "does not exist or not an executable file: ${MASTER_RUNNER}" exit 1 fi function is_enabled() { ANSWER=`echo $1|tr "[:upper:]" "[:lower:]"` [[ "$ANSWER" == "yes" ]] || [[ "$ANSWER" == "true" ]] || [[ "$ANSWER" == "1" ]] return $? } function is_disabled() { ANSWER=`echo $1|tr "[:upper:]" "[:lower:]"` [[ "$ANSWER" == "no" ]] || [[ "$ANSWER" == "false" ]] || [[ "$ANSWER" == "0" ]] return $? } function master_config_valid() { # Function validates buildmaster instance startup variables based on array # index local errors=0 local index=$1 if ! is_enabled "${MASTER_ENABLED[$index]}" && ! is_disabled "${MASTER_ENABLED[$index]}" ; then log_warning_msg "buildmaster #${i}: invalid enabled status" errors=$(($errors+1)) fi if [[ -z ${MASTER_NAME[$index]} ]]; then log_failure_msg "buildmaster #${i}: no name" errors=$(($errors+1)) fi if [[ -z ${MASTER_USER[$index]} ]]; then log_failure_msg "buildmaster #${i}: no run user specified" errors=$( ($errors+1) ) elif ! getent passwd ${MASTER_USER[$index]} >/dev/null; then log_failure_msg "buildmaster #${i}: unknown user ${MASTER_USER[$index]}" errors=$(($errors+1)) fi if [[ ! -d "${MASTER_BASEDIR[$index]}" ]]; then log_failure_msg "buildmaster ${i}: basedir does not exist ${MASTER_BASEDIR[$index]}" errors=$(($errors+1)) fi return $errors } function check_config() { itemcount="${#MASTER_ENABLED[@]} ${#MASTER_NAME[@]} ${#MASTER_USER[@]} ${#MASTER_BASEDIR[@]} ${#MASTER_OPTIONS[@]} ${#MASTER_PREFIXCMD[@]}" if [[ $(echo "$itemcount" | tr -d ' ' | sort -u | wc -l) -ne 1 ]]; then log_failure_msg "MASTER_* arrays must have an equal number of elements!" return 1 fi errors=0 for i in $( seq ${#MASTER_ENABLED[@]} ); do if is_disabled "${MASTER_ENABLED[$i]}" ; then log_warning_msg "buildmaster #${i}: disabled" continue fi master_config_valid $i errors=$(($errors+$?)) done [[ $errors == 0 ]]; return $? } check_config || exit $? function iscallable () { type $1 2>/dev/null | grep -q 'shell function'; } function master_op () { op=$1 ; mi=$2 ${MASTER_PREFIXCMD[$mi]} \ su -s /bin/sh \ -c "$MASTER_RUNNER $op --quiet ${MASTER_OPTIONS[$mi]} ${MASTER_BASEDIR[$mi]}" \ - ${MASTER_USER[$mi]} return $? } function do_op () { errors=0 for i in $( seq ${#MASTER_ENABLED[@]} ); do if is_disabled "${MASTER_ENABLED[$i]}" ; then continue fi # Some rhels don't come with all the lsb goodies if iscallable log_daemon_msg; then log_daemon_msg "$3 \"${MASTER_NAME[$i]}\"" if eval $1 $2 $i; then log_end_msg 0 else log_end_msg 1 errors=$(($errors+1)) fi else if eval $1 $2 $i; then log_success_msg "$3 \"${MASTER_NAME[$i]}\"" else log_failure_msg "$3 \"${MASTER_NAME[$i]}\"" errors=$(($errors+1)) fi fi done return $errors } case "$1" in start) do_op "master_op" "start" "Starting buildmaster" exit $? ;; stop) do_op "master_op" "stop" "Stopping buildmaster" exit $? ;; reload) do_op "master_op" "reconfig" "Reloading buildmaster" exit $? ;; restart|force-reload) do_op "master_op" "restart" "Restarting buildmaster" exit $? ;; *) echo "Usage: $0 {start|stop|restart|reload|force-reload}" exit 1 ;; esac exit 0 buildbot-0.8.8/contrib/libvirt/000077500000000000000000000000001222546025000164445ustar00rootroot00000000000000buildbot-0.8.8/contrib/libvirt/network.xml000066400000000000000000000012321222546025000206550ustar00rootroot00000000000000 buildbot-network buildbot-0.8.8/contrib/libvirt/vmbuilder000077500000000000000000000106131222546025000203640ustar00rootroot00000000000000#! /usr/bin/env python """ This script can be used to generate an Ubuntu VM that is suitable for use by the libvirt backend of buildbot. It creates a buildbot slave and then changes the buildbot.tac to get its username from the hostname. The hostname is set by changing the DHCP script. See network.xml for how to map a MAC address to an IP address and a hostname. You can load that configuration on to your master by running:: virsh net-define network.xml Note that the VM's also need their MAC address set, and configuring to use the new network, or this won't work.. """ import os, platform, tempfile if platform.machine() == "x86_64": arch = "amd64" else: arch = "i386" postboot = """\ #!/bin/sh chroot $1 update-rc.d -f buildbot remove chroot $1 addgroup --system minion chroot $1 adduser --system --home /var/local/buildbot --shell /bin/bash --ingroup zope --disabled-password --disabled-login minion mkdir -p $1/var/local/buildbot chroot $1 chown minion: /var/local/buildbot chroot $1 sudo -u minion /usr/bin/buildbot create-slave /var/local/buildbot %(master_host)s:%(master_port)s %(slave)s %(slave_password)s cat > $1/etc/default/buildbot << HERE BB_NUMBER[0]=0 BB_NAME[0]="minion" BB_USER[0]="minion" BB_BASEDIR[0]="/var/local/buildbot" BB_OPTIONS[0]="" BB_PREFIXCMD[0]="" HERE cat > $1/var/local/buildbot/buildbot.tac << HERE from twisted.application import service from buildbot.slave.bot import BuildSlave import socket basedir = r'/var/local/buildbot' buildmaster_host = '%(master_host)s' port = %(master_port)s slavename = socket.gethostname() passwd = "%(slave_password)s" keepalive = 600 usepty = 0 umask = None maxdelay = 300 rotateLength = 1000000 maxRotatedFiles = None application = service.Application('buildslave') s = BuildSlave(buildmaster_host, port, slavename, passwd, basedir, keepalive, usepty, umask=umask, maxdelay=maxdelay) s.setServiceParent(application) HERE cat > $1/etc/dhcp3/dhclient-exit-hooks.d/update-hostname << HERE if [ x\$reason != xBOUND ] && [ x\$reason != xREBIND ] && [ x\$reason != xREBOOT ]; then exit; fi echo Updating hostname: \$new_host_name hostname \$new_host_name echo Starting buildbot /etc/init.d/buildbot stop || true /etc/init.d/buildbot start HERE cat > $1/etc/udev/rules.d/virtio.rules << HERE KERNEL=="vda*", SYMLINK+="sda%%n" HERE """ class VMBuilder: """ Class that executes ubuntu-vm-builder with appropriate options """ postboot = postboot defaults = { "rootsize": 8192, "mem": 1024, "domain": 'yourdomain.com', "hostname": "ubuntu", "arch": arch, "variant": "minbase", "components": "main,universe,multiverse,restricted", "lang": "en_GB.UTF-8", "timezone": "Europe/London", "execscript": os.path.realpath(os.path.join(os.curdir, "postboot.sh")), "addpkg": [ "standard^", "server^", "gpgv", "openssh-server", "buildbot", "subversion", ], } def __init__(self, hypervisor="kvm", suite="karmic", destdir="ubuntu", **kw): self.hypervisor = hypervisor self.suite = suite self.destdir = destdir self.options = self.defaults.copy() self.options.update(**kw) f = tempfile.NamedTemporaryFile(delete=False, prefix="/var/tmp/") print >>f, self.postboot % { 'master_host': '192.168.201.1', 'master_port': '8081', 'slave': 'slave', 'slave_password': 'password', } f.close() os.chmod(f.name, 0755) self.options['execscript'] = f.name def build(self): optstring = [] for k, v in self.options.items(): if type(v) == type([]): for i in v: if i: optstring.append("--%s=%s" % (k, i)) else: if v: optstring.append("--%s=%s" % (k, v)) execute=("ubuntu-vm-builder %s %s -d%s %s" % ( self.hypervisor, self.suite, self.destdir, " ".join(optstring))) print execute os.system(execute) if __name__ == "__main__": import sys, socket, optparse parser = optparse.OptionParser(usage="%prog [options] project") parser.add_option("-p", "--proxy", help="http proxy URL") (options, args) = parser.parse_args() builder = VMBuilder(proxy=options.proxy) builder.build() buildbot-0.8.8/contrib/os-x/000077500000000000000000000000001222546025000156575ustar00rootroot00000000000000buildbot-0.8.8/contrib/os-x/README000066400000000000000000000017531222546025000165450ustar00rootroot00000000000000Mark Pauley contributed the two launchd plist files for OS-X (10.4+) to start a buildmaster or buildslave automatically at startup: contrib/OS-X/net.sourceforge.buildbot.master.plist contrib/OS-X/net.sourceforge.buildbot.slave.plist His email message is as follows: Message-Id: From: Mark Pauley To: buildbot-devel Date: Wed, 24 Jan 2007 11:05:44 -0800 Subject: [Buildbot-devel] Sample buildbot launchd plists for MacOS 10.4+ Hi guys, I've had these kicking around for a while and thought that maybe someone would like to see them. Installing either of these two to / Library/LaunchDaemons will cause the bulidbot slave or master to auto- start as whatever user you like on launch. This is the "right way to do this" going forward, startupitems are deprecated. Please note that this means any tests that require a windowserver connection on os x won't work. buildbot-0.8.8/contrib/os-x/net.sourceforge.buildbot.master.plist000066400000000000000000000024531222546025000251450ustar00rootroot00000000000000 Label net.sourceforge.buildbot.slave UserName buildbot WorkingDirectory /Users/buildbot/Buildbot_Master ProgramArguments /usr/bin/twistd --nodaemon --python=buildbot.tac --logfile=buildbot.log --prefix=master QueueDirectories / KeepAlive SuccessfulExit RunAtLoad StandardErrorPath /var/log/build_master.log buildbot-0.8.8/contrib/post_build_request.py000077500000000000000000000173371222546025000212750ustar00rootroot00000000000000#!/usr/bin/env python import httplib, urllib import getopt import optparse import textwrap import getpass import os # Find a working json module. Code is from # Paul Wise : # http://lists.debian.org/debian-python/2010/02/msg00016.html try: import json # python 2.6 except ImportError: import simplejson as json # python 2.4 to 2.5 try: _tmp = json.loads except AttributeError: import warnings import sys warnings.warn("Use simplejson, not the old json module.") sys.modules.pop('json') # get rid of the bad json module import simplejson as json # Make a dictionary with options from command line def buildURL( options ): urlDict = {} if options.author: author = options.author else: author = getpass.getuser() urlDict['author'] = author if options.files: urlDict['files'] = json.dumps(options.files) if options.comments: urlDict['comments'] = options.comments else: # A comment is required by the buildbot DB urlDict['comments'] = 'post_build_request submission' if options.revision: urlDict['revision'] = options.revision if options.when: urlDict['when'] = options.when if options.branch: urlDict['branch'] = options.branch if options.category: urlDict['category'] = options.category if options.revlink: urlDict['revlink'] = options.revlink if options.properties: urlDict['properties'] = json.dumps(options.properties) if options.repository: urlDict['repository'] = options.repository if options.project: urlDict['project'] = options.project return urlDict def propertyCB(option, opt, value, parser): pdict=eval(value) for key in pdict.keys(): parser.values.properties[key]=pdict[key] __version__='0.1' description="" usage="""%prog [options] This script is used to submit a change to the buildbot master using the /change_hook web interface. Options are url encoded and submitted using a HTTP POST. The repository and project must be specified. This can be used to force a build. For example, create a scheduler that listens for changes on a category 'release': releaseFilt = ChangeFilter(category="release") s=Scheduler(name="Release", change_filter=releaseFilt, treeStableTimer=10, builderNames=["UB10.4 x86_64 Release"])) c['schedulers'].append(s) Then run this script with the options: --repository --project --category release """ parser = optparse.OptionParser(description=description, usage=usage, add_help_option=True, version=__version__) parser.add_option("-w", "--who", dest='author', metavar="AUTHOR", help=textwrap.dedent("""\ Who is submitting this request. This becomes the Change.author attribute. This defaults to the name of the user running this script """)) parser.add_option("-f", "--file", dest='files', action="append", metavar="FILE", help=textwrap.dedent("""\ Add a file to the change request. This is added to the Change.files attribute. NOTE: Setting the file URL is not supported """)) parser.add_option("-c", "--comments", dest='comments', metavar="COMMENTS", help=textwrap.dedent("""\ Comments for the change. This becomes the Change.comments attribute """)) parser.add_option("-R", "--revision", dest='revision', metavar="REVISION", help=textwrap.dedent("""\ This is the revision of the change. This becomes the Change.revision attribute. """)) parser.add_option("-W", "--when", dest='when', metavar="WHEN", help=textwrap.dedent("""\ This this the date of the change. This becomes the Change.when attribute. """)) parser.add_option("-b", "--branch", dest='branch', metavar="BRANCH", help=textwrap.dedent("""\ This this the branch of the change. This becomes the Change.branch attribute. """)) parser.add_option("-C", "--category", dest='category', metavar="CAT", help=textwrap.dedent("""\ Category for change. This becomes the Change.category attribute, which can be used within the buildmaster to filter changes. """)) parser.add_option("--revlink", dest='revlink', metavar="REVLINK", help=textwrap.dedent("""\ This this the revlink of the change. This becomes the Change.revlink. """)) parser.add_option("-p", "--property", dest='properties', action="callback", callback=propertyCB, type="string", metavar="PROP", help=textwrap.dedent("""\ This adds a single property. This can be specified multiple times. The argument is a string representing python dictionary. For example, {'foo' : [ 'bar', 'baz' ]} This becomes the Change.properties attribute. """)) parser.add_option("-r", "--repository", dest='repository', metavar="PATH", help=textwrap.dedent("""\ Repository for use by buildbot slaves to checkout code. This becomes the Change.repository attribute. Exmaple: :ext:myhost:/cvsroot """)) parser.add_option("-P", "--project", dest='project', metavar="PROJ", help=textwrap.dedent("""\ The project for the source. Often set to the CVS module being modified. This becomes the Change.project attribute. """)) parser.add_option("-v", "--verbose", dest='verbosity', action="count", help=textwrap.dedent("""\ Print more detail. If specified once, show status. If secified twice, print all data returned. Normally this will be the json version of the Change. """)) parser.add_option("-H", "--host", dest='host', metavar="HOST", default='localhost:8010', help=textwrap.dedent("""\ Host and optional port of buildbot. For example, bbhost:8010 Defaults to %default """)) parser.add_option("-u", "--urlpath", dest='urlpath', metavar="URLPATH", default='/change_hook/base', help=textwrap.dedent("""\ Path portion of URL. Defaults to %default """)) parser.add_option("-t", "--testing", action="store_true", dest="amTesting", default=False, help=textwrap.dedent("""\ Just print values and exit. """)) parser.set_defaults(properties={}) (options, args) = parser.parse_args() if options.repository is None: print "repository must be specified" parser.print_usage() os._exit(2) if options.project is None: print "project must be specified" parser.print_usage() os._exit(2) urlDict = buildURL(options) params = urllib.urlencode(urlDict) headers = {"Content-type": "application/x-www-form-urlencoded", "Accept": "text/plain"} if options.amTesting: print "params: %s" % params print "host: %s" % options.host print "urlpath: %s" % options.urlpath else: conn = httplib.HTTPConnection(options.host) conn.request("POST", options.urlpath, params, headers) response = conn.getresponse() data = response.read() exitCode=0 if response.status is not 200: exitCode=1 if options.verbosity >= 1: print response.status, response.reason if response.status is 200: res =json.loads(data) print "Request %d at %s" % (res[0]['number'], res[0]['at']) if options.verbosity >= 2: print "Raw response %s" % (data) conn.close() os._exit(exitCode) buildbot-0.8.8/contrib/run_maxq.py000077500000000000000000000020221222546025000171740ustar00rootroot00000000000000#!/usr/bin/env jython import sys import glob testdir = sys.argv[1] orderfiles = glob.glob(testdir + '/*.tests') # wee. just be glad I didn't make this one gigantic nested listcomp. # anyway, this builds a once-nested list of files to test. #open! files = [open(fn) for fn in orderfiles] #create prelim list of lists of files! files = [f.readlines() for f in files] #shwack newlines and filter out empties! files = [filter(None, [fn.strip() for fn in fs]) for fs in files] #prefix with testdir files = [[testdir + '/' + fn.strip() for fn in fs] for fs in files] print "Will run these tests:", files i = 0 for testlist in files: print "===========================" print "running tests from testlist", orderfiles[i] print "---------------------------" i = i + 1 for test in testlist: print "running test", test try: execfile(test, globals().copy()) except: ei = sys.exc_info() print "TEST FAILURE:", ei[1] else: print "SUCCESS" buildbot-0.8.8/contrib/svn_buildbot.py000077500000000000000000000222601222546025000200420ustar00rootroot00000000000000#!/usr/bin/python # this requires python >=2.3 for the 'sets' module. # The sets.py from python-2.3 appears to work fine under python2.2 . To # install this script on a host with only python2.2, copy # /usr/lib/python2.3/sets.py from a newer python into somewhere on your # PYTHONPATH, then edit the #! line above to invoke python2.2 # python2.1 is right out # If you run this program as part of your SVN post-commit hooks, it will # deliver Change notices to a buildmaster that is running a PBChangeSource # instance. # edit your svn-repository/hooks/post-commit file, and add lines that look # like this: ''' # set up PYTHONPATH to contain Twisted/buildbot perhaps, if not already # installed site-wide . ~/.environment /path/to/svn_buildbot.py --repository "$REPOS" --revision "$REV" \ --bbserver localhost --bbport 9989 --username myuser --auth passwd ''' import commands import sys import os import re import sets # We have hackish "-d" handling here rather than in the Options # subclass below because a common error will be to not have twisted in # PYTHONPATH; we want to be able to print that error to the log if # debug mode is on, so we set it up before the imports. DEBUG = None if '-d' in sys.argv: i = sys.argv.index('-d') DEBUG = sys.argv[i+1] del sys.argv[i] del sys.argv[i] if DEBUG: f = open(DEBUG, 'a') sys.stderr = f sys.stdout = f from twisted.internet import defer, reactor from twisted.python import usage from twisted.spread import pb from twisted.cred import credentials class Options(usage.Options): optParameters = [ ['repository', 'r', None, "The repository that was changed."], ['slave-repo', 'c', None, "In case the repository differs for the slaves."], ['revision', 'v', None, "The revision that we want to examine (default: latest)"], ['bbserver', 's', 'localhost', "The hostname of the server that buildbot is running on"], ['bbport', 'p', 8007, "The port that buildbot is listening on"], ['username', 'u', 'change', "Username used in PB connection auth"], ['auth', 'a', 'changepw', "Password used in PB connection auth"], ['include', 'f', None, '''\ Search the list of changed files for this regular expression, and if there is at least one match notify buildbot; otherwise buildbot will not do a build. You may provide more than one -f argument to try multiple patterns. If no filter is given, buildbot will always be notified.'''], ['filter', 'f', None, "Same as --include. (Deprecated)"], ['exclude', 'F', None, '''\ The inverse of --filter. Changed files matching this expression will never be considered for a build. You may provide more than one -F argument to try multiple patterns. Excludes override includes, that is, patterns that match both an include and an exclude will be excluded.'''], ['encoding', 'e', "utf8", "The encoding of the strings from subversion (default: utf8)" ], ['project', 'P', None, "The project for the source."] ] optFlags = [ ['dryrun', 'n', "Do not actually send changes"], ] def __init__(self): usage.Options.__init__(self) self._includes = [] self._excludes = [] self['includes'] = None self['excludes'] = None def opt_include(self, arg): self._includes.append('.*%s.*' % (arg, )) opt_filter = opt_include def opt_exclude(self, arg): self._excludes.append('.*%s.*' % (arg, )) def postOptions(self): if self['repository'] is None: raise usage.error("You must pass --repository") if self._includes: self['includes'] = '(%s)' % ('|'.join(self._includes), ) if self._excludes: self['excludes'] = '(%s)' % ('|'.join(self._excludes), ) def split_file_dummy(changed_file): """Split the repository-relative filename into a tuple of (branchname, branch_relative_filename). If you have no branches, this should just return (None, changed_file). """ return (None, changed_file) # this version handles repository layouts that look like: # trunk/files.. -> trunk # branches/branch1/files.. -> branches/branch1 # branches/branch2/files.. -> branches/branch2 # def split_file_branches(changed_file): pieces = changed_file.split(os.sep) if pieces[0] == 'branches': return (os.path.join(*pieces[:2]), os.path.join(*pieces[2:])) if pieces[0] == 'trunk': return (pieces[0], os.path.join(*pieces[1:])) ## there are other sibilings of 'trunk' and 'branches'. Pretend they are ## all just funny-named branches, and let the Schedulers ignore them. #return (pieces[0], os.path.join(*pieces[1:])) raise RuntimeError("cannot determine branch for '%s'" % changed_file) split_file = split_file_dummy class ChangeSender: def getChanges(self, opts): """Generate and stash a list of Change dictionaries, ready to be sent to the buildmaster's PBChangeSource.""" # first we extract information about the files that were changed repo = opts['repository'] slave_repo = opts['slave-repo'] or repo print "Repo:", repo rev_arg = '' if opts['revision']: rev_arg = '-r %s' % (opts['revision'], ) changed = commands.getoutput('svnlook changed %s "%s"' % ( rev_arg, repo)).split('\n') # the first 4 columns can contain status information changed = [x[4:] for x in changed] message = commands.getoutput('svnlook log %s "%s"' % (rev_arg, repo)) who = commands.getoutput('svnlook author %s "%s"' % (rev_arg, repo)) revision = opts.get('revision') if revision is not None: revision = str(int(revision)) # see if we even need to notify buildbot by looking at filters first changestring = '\n'.join(changed) fltpat = opts['includes'] if fltpat: included = sets.Set(re.findall(fltpat, changestring)) else: included = sets.Set(changed) expat = opts['excludes'] if expat: excluded = sets.Set(re.findall(expat, changestring)) else: excluded = sets.Set([]) if len(included.difference(excluded)) == 0: print changestring print """\ Buildbot was not interested, no changes matched any of these filters:\n %s or all the changes matched these exclusions:\n %s\ """ % (fltpat, expat) sys.exit(0) # now see which branches are involved files_per_branch = {} for f in changed: branch, filename = split_file(f) if branch in files_per_branch.keys(): files_per_branch[branch].append(filename) else: files_per_branch[branch] = [filename] # now create the Change dictionaries changes = [] encoding = opts['encoding'] for branch in files_per_branch.keys(): d = {'who': unicode(who, encoding=encoding), 'repository': unicode(slave_repo, encoding=encoding), 'comments': unicode(message, encoding=encoding), 'revision': revision, 'project' : unicode(opts['project'] or "", encoding=encoding), 'src' : 'svn', } if branch: d['branch'] = unicode(branch, encoding=encoding) else: d['branch'] = branch files = [] for file in files_per_branch[branch]: files.append(unicode(file, encoding=encoding)) d['files'] = files changes.append(d) return changes def sendChanges(self, opts, changes): pbcf = pb.PBClientFactory() reactor.connectTCP(opts['bbserver'], int(opts['bbport']), pbcf) creds = credentials.UsernamePassword(opts['username'], opts['auth']) d = pbcf.login(creds) d.addCallback(self.sendAllChanges, changes) return d def sendAllChanges(self, remote, changes): dl = [remote.callRemote('addChange', change) for change in changes] return defer.DeferredList(dl) def run(self): opts = Options() try: opts.parseOptions() except usage.error, ue: print opts print "%s: %s" % (sys.argv[0], ue) sys.exit() changes = self.getChanges(opts) if opts['dryrun']: for i, c in enumerate(changes): print "CHANGE #%d" % (i+1) keys = c.keys() keys.sort() for k in keys: print "[%10s]: %s" % (k, c[k]) print "*NOT* sending any changes" return d = self.sendChanges(opts, changes) def quit(*why): print "quitting! because", why reactor.stop() def failed(f): print "FAILURE" print f reactor.stop() d.addCallback(quit, "SUCCESS") d.addErrback(failed) reactor.callLater(60, quit, "TIMEOUT") reactor.run() if __name__ == '__main__': s = ChangeSender() s.run() buildbot-0.8.8/contrib/svn_watcher.py000077500000000000000000000162201222546025000176720ustar00rootroot00000000000000#!/usr/bin/python # This is a program which will poll a (remote) SVN repository, looking for # new revisions. It then uses the 'buildbot sendchange' command to deliver # information about the Change to a (remote) buildmaster. It can be run from # a cron job on a periodic basis, or can be told (with the 'watch' option) to # automatically repeat its check every 10 minutes. # This script does not store any state information, so to avoid spurious # changes you must use the 'watch' option and let it run forever. # You will need to provide it with the location of the buildmaster's # PBChangeSource port (in the form hostname:portnum), and the svnurl of the # repository to watch. # 15.03.06 by John Pye # 29.03.06 by Niklaus Giger, added support to run under windows, # added invocation option # 22.03.10 by Johnnie Pittman, added support for category and interval # options. import subprocess import xml.dom.minidom from xml.parsers.expat import ExpatError import sys import time from optparse import OptionParser import os if sys.platform == 'win32': import win32pipe def getoutput(cmd): p = subprocess.Popen(cmd, stdout=subprocess.PIPE) return p.stdout.read() def sendchange_cmd(master, revisionData): cmd = [ "buildbot", "sendchange", "--master=%s" % master, "--revision=%s" % revisionData['revision'], "--username=%s" % revisionData['author'], "--comments=%s" % revisionData['comments'], "--vc=%s" % 'svn', ] if opts.category: cmd.append("--category=%s" % opts.category) for path in revisionData['paths']: cmd.append(path) if opts.verbose == True: print cmd return cmd def parseChangeXML(raw_xml): """Parse the raw xml and return a dict with key pairs set. Commmand we're parsing: svn log --non-interactive --xml --verbose --limit=1 With an output that looks like this: mwiggins 2009-11-11T17:16:48.012357Z /tags/Latest Updates/latest """ data = dict() # parse the xml string and grab the first log entry. try: doc = xml.dom.minidom.parseString(raw_xml) except ExpatError: print "\nError: Got an empty response with an empty changeset.\n" raise log_entry = doc.getElementsByTagName("logentry")[0] # grab the appropriate meta data we need data['revision'] = log_entry.getAttribute("revision") data['author'] = "".join([t.data for t in log_entry.getElementsByTagName("author")[0].childNodes]) data['comments'] = "".join([t.data for t in log_entry.getElementsByTagName("msg")[0].childNodes]) # grab the appropriate file paths that changed. pathlist = log_entry.getElementsByTagName("paths")[0] paths = [] for path in pathlist.getElementsByTagName("path"): paths.append("".join([t.data for t in path.childNodes])) data['paths'] = paths return data def checkChanges(repo, master, oldRevision=-1): cmd = ["svn", "log", "--non-interactive", "--xml", "--verbose", "--limit=1", repo] if opts.verbose == True: print "Getting last revision of repository: " + repo if sys.platform == 'win32': f = win32pipe.popen(cmd) xml1 = ''.join(f.readlines()) f.close() else: xml1 = getoutput(cmd) if opts.verbose == True: print "XML\n-----------\n"+xml1+"\n\n" revisionData = parseChangeXML(xml1) if opts.verbose == True: print "PATHS" print revisionData['paths'] if revisionData['revision'] != oldRevision: cmd = sendchange_cmd(master, revisionData) if sys.platform == 'win32': f = win32pipe.popen(cmd) pretty_time = time.strftime("%H.%M.%S ") print "%s Revision %s: %s" % (pretty_time, revisionData['revision'], ''.join(f.readlines())) f.close() else: xml1 = getoutput(cmd) else: pretty_time = time.strftime("%H.%M.%S ") print "%s nothing has changed since revision %s" % (pretty_time, revisionData['revision']) return revisionData['revision'] def build_parser(): usagestr = "%prog [options] " parser = OptionParser(usage=usagestr) parser.add_option( "-c", "--category", dest="category", action="store", default="", help="""Store a category name to be associated with sendchange msg.""" ) parser.add_option( "-i", "--interval", dest="interval", action="store", default=0, help="Implies watch option and changes the time in minutes to the value specified.", ) parser.add_option( "-v", "--verbose", dest="verbose", action="store_true", default=False, help="Enables more information to be presented on the command line.", ) parser.add_option( "", "--watch", dest="watch", action="store_true", default=False, help="Automatically check the repo url every 10 minutes.", ) return parser def validate_args(args): """Validate our arguments and exit if we don't have what we want.""" if not args: print "\nError: No arguments were specified.\n" parser.print_help() sys.exit(1) elif len(args) > 2: print "\nToo many arguments specified.\n" parser.print_help() sys.exit(2) if __name__ == '__main__': # build our parser and validate our args parser = build_parser() (opts, args) = parser.parse_args() validate_args(args) if opts.interval: try: int(opts.interval) except ValueError: print "\nError: Value of the interval option must be a number." parser.print_help() sys.exit(3) # grab what we need repo_url = args[0] bbmaster = args[1] # if watch is specified, run until stopped if opts.watch or opts.interval: oldRevision = -1 print "Watching for changes in repo %s for master %s." % (repo_url, bbmaster) while 1: try: oldRevision = checkChanges(repo_url, bbmaster, oldRevision) except ExpatError: # had an empty changeset. Trapping the exception and moving on. pass try: if opts.interval: # Check the repository every interval in minutes the user specified. time.sleep(int(opts.interval) * 60) else: # Check the repository every 10 minutes time.sleep(10*60) except KeyboardInterrupt: print "\nReceived interrupt via keyboard. Shutting Down." sys.exit(0) # default action if watch isn't specified checkChanges(repo_url, bbmaster) buildbot-0.8.8/contrib/svnpoller.py000077500000000000000000000062031222546025000173730ustar00rootroot00000000000000#!/usr/bin/python """ svn.py Script for BuildBot to monitor a remote Subversion repository. Copyright (C) 2006 John Pye """ # This script is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. # # This library 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 # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 # USA import commands import xml.dom.minidom import ConfigParser import os.path # change these settings to match your project svnurl = "https://pse.cheme.cmu.edu/svn/ascend/code/trunk" statefilename = "~/changemonitor/config.ini" buildmaster = "buildbot.example.org:9989" # connects to a PBChangeSource xml1 = commands.getoutput( "svn log --non-interactive --verbose --xml --limit=1 " + svnurl) #print "XML\n-----------\n"+xml1+"\n\n" try: doc = xml.dom.minidom.parseString(xml1) el = doc.getElementsByTagName("logentry")[0] revision = el.getAttribute("revision") author = "".join([t.data for t in el.getElementsByTagName( "author")[0].childNodes]) comments = "".join([t.data for t in el.getElementsByTagName( "msg")[0].childNodes]) pathlist = el.getElementsByTagName("paths")[0] paths = [] for p in pathlist.getElementsByTagName("path"): paths.append("".join([t.data for t in p.childNodes])) #print "PATHS" #print paths except xml.parsers.expat.ExpatError, e: print "FAILED TO PARSE 'svn log' XML:" print str(e) print "----" print "RECEIVED TEXT:" print xml1 import sys sys.exit(1) fname = statefilename fname = os.path.expanduser(fname) ini = ConfigParser.SafeConfigParser() try: ini.read(fname) except: print "Creating changemonitor config.ini:", fname ini.add_section("CurrentRevision") ini.set("CurrentRevision", -1) try: lastrevision = ini.get("CurrentRevision", "changeset") except ConfigParser.NoOptionError: print "NO OPTION FOUND" lastrevision = -1 except ConfigParser.NoSectionError: print "NO SECTION FOUND" lastrevision = -1 if lastrevision != revision: #comments = codecs.encodings.unicode_escape.encode(comments) cmd = "buildbot sendchange --master="+buildmaster+" --branch=trunk \ --revision=\""+revision+"\" --username=\""+author+"\" --vc=\"svn\" \ --comments=\""+comments+"\" "+" ".join(paths) #print cmd res = commands.getoutput(cmd) print "SUBMITTING NEW REVISION", revision if not ini.has_section("CurrentRevision"): ini.add_section("CurrentRevision") try: ini.set("CurrentRevision", "changeset", revision) f = open(fname, "w") ini.write(f) #print "WROTE CHANGES TO",fname except: print "FAILED TO RECORD INI FILE" buildbot-0.8.8/contrib/trac/000077500000000000000000000000001222546025000157225ustar00rootroot00000000000000buildbot-0.8.8/contrib/trac/README.md000066400000000000000000000014141222546025000172010ustar00rootroot00000000000000# What Is It? BuildBot Watcher is a [Trac](http://trac.edgewall.org) plugin. It watches a BuildBot status webserver and incorporates build information into the standard Trac timeline. # Prereqs ## For the Trac site This plugin does not require anything other than Trac 0.11+. It makes use of the standard Trac CSS classes, so it will theme appropriately. ## For the BuildMaster For now, you'll need to run a BuildBot using my patched XML-RPC server. git clone git://github.com/djmitche/buildbot.git buildbot cd buildbot git pull git://github.com/rbosetti/buildbot.git # Installation Follow the standard fetch, cook, copy procedure: git clone git://github.com/rbosetti/buildbot.git buildbot cd buildbot python setup.py bdist_egg cp dist/*.egg /path/to/trac/env/plugins buildbot-0.8.8/contrib/trac/TODO.md000066400000000000000000000002751222546025000170150ustar00rootroot00000000000000The plan is to eventually offer the same controls that webstatus does. # Missing * Force Builds * The grid display * The waterfall display (replaced by a Timeline contributor for Trac) buildbot-0.8.8/contrib/trac/bbwatcher/000077500000000000000000000000001222546025000176635ustar00rootroot00000000000000buildbot-0.8.8/contrib/trac/bbwatcher/__init__.py000066400000000000000000000000241222546025000217700ustar00rootroot00000000000000__version__ = '0.1' buildbot-0.8.8/contrib/trac/bbwatcher/api.py000066400000000000000000000014441222546025000210110ustar00rootroot00000000000000import xmlrpclib import urlparse from model import Builder, Build class BuildBotSystem(object): def __init__(self, url): try: scheme, loc, _, _, _ = urlparse.urlsplit(url, scheme='http') url = '%s://%s/xmlrpc'%(scheme, loc) self.server = xmlrpclib.ServerProxy(url) except Exception, e: raise ValueError('Invalid BuildBot XML-RPC server %s: %s'%(url, e)) def getAllBuildsInInterval(self, start, stop): return self.server.getAllBuildsInInterval(start, stop) def getBuilder(self, name): builds = [] for i in range(1, 5+1): try: builds.append(Build(self.server.getBuild(name, -i))) except Exception, e: self.env.log.debug('Cannot fetch build-info: %s'%(e)) break return Builder(name, builds, []) def getAllBuilders(self): return self.server.getAllBuilders() buildbot-0.8.8/contrib/trac/bbwatcher/model.py000066400000000000000000000012471222546025000213410ustar00rootroot00000000000000from datetime import datetime from trac.util.datefmt import utc class Builder(object): def __init__(self, name, builds, slaves): self.name = name self.current = builds[0] self.recent = builds self.slaves = slaves class Build(object): def __init__(self, build_results): for attr in ('builder_name', 'reason', 'slavename', 'results', 'text', 'start', 'end', 'steps', 'branch', 'revision', 'number'): setattr(self, attr, build_results.get(attr, 'UNDEFINED')) try: self.start = datetime.fromtimestamp(self.start, utc) self.end = datetime.fromtimestamp(self.end, utc) except Exception, e: pass def __str__(self): return 'Slave <%s>'%(self.slave) buildbot-0.8.8/contrib/trac/bbwatcher/templates/000077500000000000000000000000001222546025000216615ustar00rootroot00000000000000buildbot-0.8.8/contrib/trac/bbwatcher/templates/bbw_allbuilders.html000066400000000000000000000013001222546025000256750ustar00rootroot00000000000000 $title
    buildbot-0.8.8/contrib/trac/bbwatcher/templates/bbw_builder.html000066400000000000000000000033661222546025000250370ustar00rootroot00000000000000 $title

    Builder: ${builder.name}

    Running Build

    • Slave: ${builder.current.slavename}
    • Revision: ${wiki_to_oneliner(context, '[wiki:TryBuildUsage Forced Build]')}
    • Status: ${builder.current.results and 'failed' or 'successful'}

    Recent Builds

    (${format_date(build.end)}) rev=[${build.branch or 'trunk'}@${build.revision and href.changeset(build.revision) or '??'}] #${build.number}
    ${build.reason}

    Buildslaves

    1. Status: ${slave.connected and 'Connected' or 'Disconnected'}
      Admin: ${author_info(slave.admin)}
      Host:
      ${slave.description}
    buildbot-0.8.8/contrib/trac/bbwatcher/templates/bbw_welcome.html000066400000000000000000000014441222546025000250370ustar00rootroot00000000000000 $title

    BuildBot Watcher

    This Trac instance is watching the BuildBot server at

    ${url}
    .

    For now, you can obtain information about:

    buildbot-0.8.8/contrib/trac/bbwatcher/web_ui.py000066400000000000000000000067141222546025000215170ustar00rootroot00000000000000import pkg_resources import re from genshi.builder import tag from trac.core import Component, implements from trac.config import Option from trac.wiki.formatter import format_to_oneliner from trac.util.datefmt import to_timestamp, to_datetime from trac.mimeview.api import Context # Interfaces from trac.timeline.api import ITimelineEventProvider from trac.web import IRequestHandler from trac.web.chrome import INavigationContributor, ITemplateProvider from api import BuildBotSystem class TracBuildBotWatcher(Component): implements(ITimelineEventProvider, IRequestHandler, ITemplateProvider, INavigationContributor) buildbot_url = Option('bbwatcher', 'buildmaster', '127.0.0.1:8010', 'The location of the BuildBot webserver. Do not include the /xmlrpc') BUILDER_REGEX = r'/buildbot/builder(?:/(.+))?$' BUILDER_RE = re.compile(BUILDER_REGEX) # Template Provider def get_htdocs_dirs(self): return [] def get_templates_dirs(self): return [pkg_resources.resource_filename('bbwatcher', 'templates')] # Nav Contributor def get_active_navigation_item(self, req): return 'buildbot' def get_navigation_items(self, req): yield 'mainnav', 'buildbot', tag.a('BuildBot',href=req.href.buildbot()) # Timeline Methods def get_timeline_filters(self, req): yield ('bbwatcher', 'Builds', False) def get_timeline_events(self, req, start, stop, filters): #if not 'bbwatcher' in filters: # return try: master = BuildBotSystem(self.buildbot_url) except Exception, e: print 'Error hitting BuildBot', e return # This was a comprehension: the loop is clearer for build in master.getAllBuildsInInterval(to_timestamp(start), to_timestamp(stop)): # BuildBot builds are reported as # (builder_name, num, end, branch, rev, results, text) print 'Reporting build', build yield ('build', to_datetime(build[2]), '', build) def render_timeline_event(self, context, field, event): builder_name, num, end, branch, rev, results, text = event[3] if field == 'url': return None elif field == 'title': return tag('Build ', tag.a('#%s'%num, href=context.href.buildbot('builder/%s/%s'%(builder_name, num))), ' of ', builder_name, ' ', results == 'success' and tag.span('passed', style="color: #080") or tag.span('failed', style="color: #f00")) elif field == 'description': return format_to_oneliner(self.env, context, 'Built from %s'%(rev and 'r%s sources'%rev or 'local changes (see TryBuildUsage)')) # RequestHandler def _handle_builder(self, req): m = self.BUILDER_RE.match(req.path_info) try: builder = m.group(1) or None except Exception, e: builder = None master = BuildBotSystem(self.buildbot_url) if builder is None: data = { 'names': master.getAllBuilders() } return 'bbw_allbuilders.html', data, 'text/html' else: class Foo: pass b = Foo() b.name = str(builder) b.current = 'CURRENT-TEXT' b.recent = [] b.slaves = [] data = { 'builder': b } try: master = BuildBotSystem(self.buildbot_url) data = { 'builder': master.getBuilder(builder) } except Exception, e: print 'Error fetching builder stats', e data['context'] = Context.from_request(req, ('buildbot', builder)) return 'bbw_builder.html', data, 'text/html' def match_request(self, req): return req.path_info.startswith('/buildbot') and 1 or 0 def process_request(self, req): if req.path_info.startswith('/buildbot/builder'): return self._handle_builder(req) return 'bbw_welcome.html', { 'url': self.buildbot_url }, 'text/html' buildbot-0.8.8/contrib/trac/setup.py000066400000000000000000000007431222546025000174400ustar00rootroot00000000000000from setuptools import setup from bbwatcher import __version__ setup( name='Trac-BuildBot-Watcher', version=__version__, packages=['bbwatcher'], package_data={ 'bbwatcher': ['htdocs/*', 'templates/*.html'], }, author='Randall Bosetti', description='A plugin to fetch/integrate status updates from the BuildBot XML-RPC interface', license='GPL', entry_points={ 'trac.plugins': 'bbwatcher.web_ui=bbwatcher.web_ui', } ) buildbot-0.8.8/contrib/viewcvspoll.py000077500000000000000000000062171222546025000177310ustar00rootroot00000000000000#! /usr/bin/python """Based on the fakechanges.py contrib script""" import os.path import time import MySQLdb #@UnresolvedImport from twisted.spread import pb from twisted.cred import credentials from twisted.internet import reactor from twisted.python import log class ViewCvsPoller: def __init__(self): def _load_rc(): import user ret = {} for line in open(os.path.join( user.home, ".cvsblamerc")).readlines(): if line.find("=") != -1: key, val = line.split("=") ret[key.strip()] = val.strip() return ret # maybe add your own keys here db=xxx, user=xxx, passwd=xxx self.cvsdb = MySQLdb.connect("cvs", **_load_rc()) #self.last_checkin = "2005-05-11" # for testing self.last_checkin = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()) def get_changes(self): changes = [] def empty_change(): return {'who': None, 'files': [], 'comments': None} change = empty_change() cursor = self.cvsdb.cursor() cursor.execute("""SELECT whoid, descid, fileid, dirid, branchid, \ ci_when FROM checkins WHERE ci_when>='%s'""" % self.last_checkin) last_checkin = None for whoid, descid, fileid, dirid, branchid, ci_when in \ cursor.fetchall(): if branchid != 1: # only head continue cursor.execute("""SELECT who from people where id=%s""" % whoid) who = cursor.fetchone()[0] cursor.execute("""SELECT description from descs where id=%s""" % ( descid)) desc = cursor.fetchone()[0] cursor.execute("""SELECT file from files where id=%s""" % fileid) filename = cursor.fetchone()[0] cursor.execute("""SELECT dir from dirs where id=%s""" % dirid) dirname = cursor.fetchone()[0] if who == change["who"] and desc == change["comments"]: change["files"].append("%s/%s" % (dirname, filename)) elif change["who"]: changes.append(change) change = empty_change() else: change["who"] = who change["files"].append("%s/%s" % (dirname, filename)) change["comments"] = desc if last_checkin == None or ci_when > last_checkin: last_checkin = ci_when if last_checkin: self.last_checkin = last_checkin return changes poller = ViewCvsPoller() def error(*args): log.err() reactor.stop() def poll_changes(remote): print "GET CHANGES SINCE", poller.last_checkin, changes = poller.get_changes() for change in changes: print change["who"], "\n *", "\n * ".join(change["files"]) change['src'] = 'cvs' remote.callRemote('addChange', change).addErrback(error) print reactor.callLater(60, poll_changes, remote) factory = pb.PBClientFactory() reactor.connectTCP("localhost", 9999, factory) deferred = factory.login(credentials.UsernamePassword("change", "changepw")) deferred.addCallback(poll_changes).addErrback(error) reactor.run() buildbot-0.8.8/contrib/webhook_status.py000066400000000000000000000154371222546025000204160ustar00rootroot00000000000000import urllib from twisted.python import log from twisted.internet import reactor from twisted.web import client, error from buildbot import status MAX_ATTEMPTS = 10 RETRY_MULTIPLIER = 5 class WebHookTransmitter(status.base.StatusReceiverMultiService): """ A webhook status listener for buildbot. WebHookTransmitter listens for build events and sends the events as http POSTs to one or more webhook URLs. The easiest way to deploy this is to place it next to your master.cfg and do something like this (assuming you've got a postbin URL for purposes of demonstration): from webhook_status import WebHookTransmitter c['status'].append(WebHookTransmitter('http://www.postbin.org/xxxxxxx')) Alternatively, you may provide a list of URLs and each one will receive information on every event. The following optional parameters influence when and what data is transmitted: categories: If provided, only events belonging to one of the categories listed will be transmitted. extra_params: Additional parameters to be supplied with every request. max_attempts: The maximum number of times to retry transmission on failure. Default: 10 retry_multiplier: A value multiplied by the retry number to wait before attempting a retry. Default 5 """ agent = 'buildbot webhook' def __init__(self, url, categories=None, extra_params={}, max_attempts=MAX_ATTEMPTS, retry_multiplier=RETRY_MULTIPLIER): status.base.StatusReceiverMultiService.__init__(self) if isinstance(url, basestring): self.urls = [url] else: self.urls = url self.categories = categories self.extra_params = extra_params self.max_attempts = max_attempts self.retry_multiplier = retry_multiplier def _transmit(self, event, params={}): cat = dict(params).get('category', None) if (cat and self.categories) and cat not in self.categories: log.msg("Ignoring request for unhandled category: %s" % cat) return new_params = [('event', event)] new_params.extend(list(self.extra_params.items())) if hasattr(params, "items"): new_params.extend(params.items()) else: new_params.extend(params) encoded_params = urllib.urlencode(new_params) log.msg("WebHookTransmitter announcing a %s event" % event) for u in self.urls: self._retrying_fetch(u, encoded_params, event, 0) def _retrying_fetch(self, u, data, event, attempt): d = client.getPage(u, method='POST', agent=self.agent, postdata=data, followRedirect=0) def _maybe_retry(e): log.err() if attempt < self.max_attempts: reactor.callLater(attempt * self.retry_multiplier, self._retrying_fetch, u, data, event, attempt + 1) else: return e def _trap_status(x, *acceptable): x.trap(error.Error) if int(x.value.status) in acceptable: log.msg("Terminating retries of event %s with a %s response" % (event, x.value.status)) return None else: return x # Any sort of redirect is considered success d.addErrback(lambda x: x.trap(error.PageRedirect)) # Any of these status values are considered delivered, or at # least not something that should be retried. d.addErrback(_trap_status, # These are all actually successes 201, 202, 204, # These tell me I'm sending stuff it doesn't want. 400, 401, 403, 405, 406, 407, 410, 413, 414, 415, # This tells me the server can't deal with what I sent 501) d.addCallback(lambda x: log.msg("Completed %s event hook on attempt %d" % (event, attempt+1))) d.addErrback(_maybe_retry) d.addErrback(lambda e: log.err("Giving up delivering %s to %s" % (event, u))) def builderAdded(self, builderName, builder): builder.subscribe(self) self._transmit('builderAdded', {'builder': builderName, 'category': builder.getCategory()}) def builderRemoved(self, builderName, builder): self._transmit('builderRemoved', {'builder': builderName, 'category': builder.getCategory()}) def buildStarted(self, builderName, build): build.subscribe(self) args = {'builder': builderName, 'category': build.getBuilder().getCategory(), 'reason': build.getReason(), 'revision': build.getSourceStamp().revision, 'buildNumber': build.getNumber()} if build.getSourceStamp().patch: args['patch'] = build.getSourceStamp().patch[1] self._transmit('buildStarted', args) def buildFinished(self, builderName, build, results): self._transmit('buildFinished', {'builder': builderName, 'category': build.getBuilder().getCategory(), 'result': status.builder.Results[results], 'revision': build.getSourceStamp().revision, 'had_patch': bool(build.getSourceStamp().patch), 'buildNumber': build.getNumber()}) def stepStarted(self, build, step): step.subscribe(self) self._transmit('stepStarted', [('builder', build.getBuilder().getName()), ('category', build.getBuilder().getCategory()), ('buildNumber', build.getNumber()), ('step', step.getName())]) def stepFinished(self, build, step, results): gu = self.status.getURLForThing self._transmit('stepFinished', [('builder', build.getBuilder().getName()), ('category', build.getBuilder().getCategory()), ('buildNumber', build.getNumber()), ('resultStatus', status.builder.Results[results[0]]), ('resultString', ' '.join(results[1])), ('step', step.getName())] + [('logFile', gu(l)) for l in step.getLogs()]) def _subscribe(self): self.status.subscribe(self) def setServiceParent(self, parent): status.base.StatusReceiverMultiService.setServiceParent(self, parent) self.status = parent.getStatus() self._transmit('startup') self._subscribe() buildbot-0.8.8/contrib/windows/000077500000000000000000000000001222546025000164635ustar00rootroot00000000000000buildbot-0.8.8/contrib/windows/buildbot.bat000066400000000000000000000013221222546025000207550ustar00rootroot00000000000000@echo off REM This file is used to run buildbot when installed into a python installation or deployed in virtualenv setlocal set BB_BUILDBOT="%~dp0buildbot" IF EXIST "%~dp0..\python.exe" ( REM Normal system install of python (buildbot.bat is in scripts dir, just below python.exe) set BB_PYTHON="%~dp0..\python" ) ELSE IF EXIST "%~dp0python.exe" ( REM virtualenv install (buildbot.bat is in same dir as python.exe) set BB_PYTHON="%~dp0python" ) ELSE ( REM Not found nearby. Use system version and hope for the best echo Warning! Unable to find python.exe near buildbot.bat. Using python on PATH, which might be a mismatch. echo. set BB_PYTHON=python ) %BB_PYTHON% %BB_BUILDBOT% %* exit /b %ERRORLEVEL%buildbot-0.8.8/contrib/windows/buildbot_service.py000077500000000000000000000533161222546025000223740ustar00rootroot00000000000000# Runs the build-bot as a Windows service. # To use: # * Install and configure buildbot as per normal (ie, running # 'setup.py install' from the source directory). # # * Configure any number of build-bot directories (slaves or masters), as # per the buildbot instructions. Test these directories normally by # using the (possibly modified) "buildbot.bat" file and ensure everything # is working as expected. # # * Install the buildbot service. Execute the command: # % python buildbot_service.py # To see installation options. You probably want to specify: # + --username and --password options to specify the user to run the # + --startup auto to have the service start at boot time. # # For example: # % python buildbot_service.py --user mark --password secret \ # --startup auto install # Alternatively, you could execute: # % python buildbot_service.py install # to install the service with default options, then use Control Panel # to configure it. # # * Start the service specifying the name of all buildbot directories as # service args. This can be done one of 2 ways: # - Execute the command: # % python buildbot_service.py start "dir_name1" "dir_name2" # or: # - Start Control Panel->Administrative Tools->Services # - Locate the previously installed buildbot service. # - Open the "properties" for the service. # - Enter the directory names into the "Start Parameters" textbox. The # directory names must be fully qualified, and surrounded in quotes if # they include spaces. # - Press the "Start"button. # Note that the service will automatically use the previously specified # directories if no arguments are specified. This means the directories # need only be specified when the directories to use have changed (and # therefore also the first time buildbot is configured) # # * The service should now be running. You should check the Windows # event log. If all goes well, you should see some information messages # telling you the buildbot has successfully started. # # * If you change the buildbot configuration, you must restart the service. # There is currently no way to ask a running buildbot to reload the # config. You can restart by executing: # % python buildbot_service.py restart # # Troubleshooting: # * Check the Windows event log for any errors. # * Check the "twistd.log" file in your buildbot directories - once each # bot has been started it just writes to this log as normal. # * Try executing: # % python buildbot_service.py debug # This will execute the buildbot service in "debug" mode, and allow you to # see all messages etc generated. If the service works in debug mode but # not as a real service, the error probably relates to the environment or # permissions of the user configured to run the service (debug mode runs as # the currently logged in user, not the service user) # * Ensure you have the latest pywin32 build available, at least version 206. # Written by Mark Hammond, 2006. import sys import os import threading import pywintypes import winerror import win32con import win32api import win32event import win32file import win32pipe import win32process import win32security import win32service import win32serviceutil import servicemanager # Are we running in a py2exe environment? is_frozen = hasattr(sys, "frozen") # Taken from the Zope service support - each "child" is run as a sub-process # (trying to run multiple twisted apps in the same process is likely to screw # stdout redirection etc). # Note that unlike the Zope service, we do *not* attempt to detect a failed # client and perform restarts - buildbot itself does a good job # at reconnecting, and Windows itself provides restart semantics should # everything go pear-shaped. # We execute a new thread that captures the tail of the output from our child # process. If the child fails, it is written to the event log. # This process is unconditional, and the output is never written to disk # (except obviously via the event log entry) # Size of the blocks we read from the child process's output. CHILDCAPTURE_BLOCK_SIZE = 80 # The number of BLOCKSIZE blocks we keep as process output. CHILDCAPTURE_MAX_BLOCKS = 200 class BBService(win32serviceutil.ServiceFramework): _svc_name_ = 'BuildBot' _svc_display_name_ = _svc_name_ _svc_description_ = 'Manages local buildbot slaves and masters - ' \ 'see http://buildbot.sourceforge.net' def __init__(self, args): win32serviceutil.ServiceFramework.__init__(self, args) # Create an event which we will use to wait on. The "service stop" # request will set this event. # * We must make it inheritable so we can pass it to the child # process via the cmd-line # * Must be manual reset so each child process and our service # all get woken from a single set of the event. sa = win32security.SECURITY_ATTRIBUTES() sa.bInheritHandle = True self.hWaitStop = win32event.CreateEvent(sa, True, False, None) self.args = args self.dirs = None self.runner_prefix = None # Patch up the service messages file in a frozen exe. # (We use the py2exe option that magically bundles the .pyd files # into the .zip file - so servicemanager.pyd doesn't exist.) if is_frozen and servicemanager.RunningAsService(): msg_file = os.path.join(os.path.dirname(sys.executable), "buildbot.msg") if os.path.isfile(msg_file): servicemanager.Initialize("BuildBot", msg_file) else: self.warning("Strange - '%s' does not exist" % (msg_file, )) def _checkConfig(self): # Locate our child process runner (but only when run from source) if not is_frozen: # Running from source python_exe = os.path.join(sys.prefix, "python.exe") if not os.path.isfile(python_exe): # for ppl who build Python itself from source. python_exe = os.path.join(sys.prefix, "PCBuild", "python.exe") if not os.path.isfile(python_exe): # virtualenv support python_exe = os.path.join(sys.prefix, "Scripts", "python.exe") if not os.path.isfile(python_exe): self.error("Can not find python.exe to spawn subprocess") return False me = __file__ if me.endswith(".pyc") or me.endswith(".pyo"): me = me[:-1] self.runner_prefix = '"%s" "%s"' % (python_exe, me) else: # Running from a py2exe built executable - our child process is # us (but with the funky cmdline args!) self.runner_prefix = '"' + sys.executable + '"' # Now our arg processing - this may be better handled by a # twisted/buildbot style config file - but as of time of writing, # MarkH is clueless about such things! # Note that the "arguments" you type into Control Panel for the # service do *not* persist - they apply only when you click "start" # on the service. When started by Windows, args are never presented. # Thus, it is the responsibility of the service to persist any args. # so, when args are presented, we save them as a "custom option". If # they are not presented, we load them from the option. self.dirs = [] if len(self.args) > 1: dir_string = os.pathsep.join(self.args[1:]) save_dirs = True else: dir_string = win32serviceutil.GetServiceCustomOption(self, "directories") save_dirs = False if not dir_string: self.error("You must specify the buildbot directories as " "parameters to the service.\nStopping the service.") return False dirs = dir_string.split(os.pathsep) for d in dirs: d = os.path.abspath(d) sentinal = os.path.join(d, "buildbot.tac") if os.path.isfile(sentinal): self.dirs.append(d) else: msg = "Directory '%s' is not a buildbot dir - ignoring" \ % (d, ) self.warning(msg) if not self.dirs: self.error("No valid buildbot directories were specified.\n" "Stopping the service.") return False if save_dirs: dir_string = os.pathsep.join(self.dirs).encode("mbcs") win32serviceutil.SetServiceCustomOption(self, "directories", dir_string) return True def SvcStop(self): # Tell the SCM we are starting the stop process. self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) # Set the stop event - the main loop takes care of termination. win32event.SetEvent(self.hWaitStop) # SvcStop only gets triggered when the user explictly stops (or restarts) # the service. To shut the service down cleanly when Windows is shutting # down, we also need to hook SvcShutdown. SvcShutdown = SvcStop def SvcDoRun(self): if not self._checkConfig(): # stopped status set by caller. return self.logmsg(servicemanager.PYS_SERVICE_STARTED) child_infos = [] for bbdir in self.dirs: self.info("Starting BuildBot in directory '%s'" % (bbdir, )) hstop = self.hWaitStop cmd = '%s --spawn %d start --nodaemon %s' % (self.runner_prefix, hstop, bbdir) #print "cmd is", cmd h, t, output = self.createProcess(cmd) child_infos.append((bbdir, h, t, output)) while child_infos: handles = [self.hWaitStop] + [i[1] for i in child_infos] rc = win32event.WaitForMultipleObjects(handles, 0, # bWaitAll win32event.INFINITE) if rc == win32event.WAIT_OBJECT_0: # user sent a stop service request break else: # A child process died. For now, just log the output # and forget the process. index = rc - win32event.WAIT_OBJECT_0 - 1 bbdir, dead_handle, dead_thread, output_blocks = \ child_infos[index] status = win32process.GetExitCodeProcess(dead_handle) output = "".join(output_blocks) if not output: output = "The child process generated no output. " \ "Please check the twistd.log file in the " \ "indicated directory." self.warning("BuildBot for directory %r terminated with " "exit code %d.\n%s" % (bbdir, status, output)) del child_infos[index] if not child_infos: self.warning("All BuildBot child processes have " "terminated. Service stopping.") # Either no child processes left, or stop event set. self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) # The child processes should have also seen our stop signal # so wait for them to terminate. for bbdir, h, t, output in child_infos: for i in range(10): # 30 seconds to shutdown... self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) rc = win32event.WaitForSingleObject(h, 3000) if rc == win32event.WAIT_OBJECT_0: break # Process terminated - no need to try harder. if rc == win32event.WAIT_OBJECT_0: break self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) # If necessary, kill it if win32process.GetExitCodeProcess(h)==win32con.STILL_ACTIVE: self.warning("BuildBot process at %r failed to terminate - " "killing it" % (bbdir, )) win32api.TerminateProcess(h, 3) self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) # Wait for the redirect thread - it should have died as the remote # process terminated. # As we are shutting down, we do the join with a little more care, # reporting progress as we wait (even though we never will ) for i in range(5): t.join(1) self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) if not t.isAlive(): break else: self.warning("Redirect thread did not stop!") # All done. self.logmsg(servicemanager.PYS_SERVICE_STOPPED) # # Error reporting/logging functions. # def logmsg(self, event): # log a service event using servicemanager.LogMsg try: servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE, event, (self._svc_name_, " (%s)" % self._svc_display_name_)) except win32api.error, details: # Failed to write a log entry - most likely problem is # that the event log is full. We don't want this to kill us try: print "FAILED to write INFO event", event, ":", details except IOError: # No valid stdout! Ignore it. pass def _dolog(self, func, msg): try: func(msg) except win32api.error, details: # Failed to write a log entry - most likely problem is # that the event log is full. We don't want this to kill us try: print "FAILED to write event log entry:", details print msg except IOError: pass def info(self, s): self._dolog(servicemanager.LogInfoMsg, s) def warning(self, s): self._dolog(servicemanager.LogWarningMsg, s) def error(self, s): self._dolog(servicemanager.LogErrorMsg, s) # Functions that spawn a child process, redirecting any output. # Although buildbot itself does this, it is very handy to debug issues # such as ImportErrors that happen before buildbot has redirected. def createProcess(self, cmd): hInputRead, hInputWriteTemp = self.newPipe() hOutReadTemp, hOutWrite = self.newPipe() pid = win32api.GetCurrentProcess() # This one is duplicated as inheritable. hErrWrite = win32api.DuplicateHandle(pid, hOutWrite, pid, 0, 1, win32con.DUPLICATE_SAME_ACCESS) # These are non-inheritable duplicates. hOutRead = self.dup(hOutReadTemp) hInputWrite = self.dup(hInputWriteTemp) # dup() closed hOutReadTemp, hInputWriteTemp si = win32process.STARTUPINFO() si.hStdInput = hInputRead si.hStdOutput = hOutWrite si.hStdError = hErrWrite si.dwFlags = win32process.STARTF_USESTDHANDLES | \ win32process.STARTF_USESHOWWINDOW si.wShowWindow = win32con.SW_HIDE # pass True to allow handles to be inherited. Inheritance is # problematic in general, but should work in the controlled # circumstances of a service process. create_flags = win32process.CREATE_NEW_CONSOLE # info is (hProcess, hThread, pid, tid) info = win32process.CreateProcess(None, cmd, None, None, True, create_flags, None, None, si) # (NOTE: these really aren't necessary for Python - they are closed # as soon as they are collected) hOutWrite.Close() hErrWrite.Close() hInputRead.Close() # We don't use stdin hInputWrite.Close() # start a thread collecting output blocks = [] t = threading.Thread(target=self.redirectCaptureThread, args = (hOutRead, blocks)) t.start() return info[0], t, blocks def redirectCaptureThread(self, handle, captured_blocks): # One of these running per child process we are watching. It # handles both stdout and stderr on a single handle. The read data is # never referenced until the thread dies - so no need for locks # around self.captured_blocks. #self.info("Redirect thread starting") while 1: try: ec, data = win32file.ReadFile(handle, CHILDCAPTURE_BLOCK_SIZE) except pywintypes.error, err: # ERROR_BROKEN_PIPE means the child process closed the # handle - ie, it terminated. if err[0] != winerror.ERROR_BROKEN_PIPE: self.warning("Error reading output from process: %s" % err) break captured_blocks.append(data) del captured_blocks[CHILDCAPTURE_MAX_BLOCKS:] handle.Close() #self.info("Redirect capture thread terminating") def newPipe(self): sa = win32security.SECURITY_ATTRIBUTES() sa.bInheritHandle = True return win32pipe.CreatePipe(sa, 0) def dup(self, pipe): # create a duplicate handle that is not inherited, so that # it can be closed in the parent. close the original pipe in # the process. pid = win32api.GetCurrentProcess() dup = win32api.DuplicateHandle(pid, pipe, pid, 0, 0, win32con.DUPLICATE_SAME_ACCESS) pipe.Close() return dup # Service registration and startup def RegisterWithFirewall(exe_name, description): # Register our executable as an exception with Windows Firewall. # taken from http://msdn.microsoft.com/library/default.asp?url=\ #/library/en-us/ics/ics/wf_adding_an_application.asp from win32com.client import Dispatch # Set constants NET_FW_PROFILE_DOMAIN = 0 NET_FW_PROFILE_STANDARD = 1 # Scope NET_FW_SCOPE_ALL = 0 # IP Version - ANY is the only allowable setting for now NET_FW_IP_VERSION_ANY = 2 fwMgr = Dispatch("HNetCfg.FwMgr") # Get the current profile for the local firewall policy. profile = fwMgr.LocalPolicy.CurrentProfile app = Dispatch("HNetCfg.FwAuthorizedApplication") app.ProcessImageFileName = exe_name app.Name = description app.Scope = NET_FW_SCOPE_ALL # Use either Scope or RemoteAddresses, but not both #app.RemoteAddresses = "*" app.IpVersion = NET_FW_IP_VERSION_ANY app.Enabled = True # Use this line if you want to add the app, but disabled. #app.Enabled = False profile.AuthorizedApplications.Add(app) # A custom install function. def CustomInstall(opts): # Register this process with the Windows Firewaall import pythoncom try: RegisterWithFirewall(sys.executable, "BuildBot") except pythoncom.com_error, why: print "FAILED to register with the Windows firewall" print why # Magic code to allow shutdown. Note that this code is executed in # the *child* process, by way of the service process executing us with # special cmdline args (which includes the service stop handle!) def _RunChild(runfn): del sys.argv[1] # The --spawn arg. # Create a new thread that just waits for the event to be signalled. t = threading.Thread(target=_WaitForShutdown, args = (int(sys.argv[1]), ) ) del sys.argv[1] # The stop handle # This child process will be sent a console handler notification as # users log off, or as the system shuts down. We want to ignore these # signals as the service parent is responsible for our shutdown. def ConsoleHandler(what): # We can ignore *everything* - ctrl+c will never be sent as this # process is never attached to a console the user can press the # key in! return True win32api.SetConsoleCtrlHandler(ConsoleHandler, True) t.setDaemon(True) # we don't want to wait for this to stop! t.start() if hasattr(sys, "frozen"): # py2exe sets this env vars that may screw our child process - reset del os.environ["PYTHONPATH"] # Start the buildbot/buildslave app runfn() print "Service child process terminating normally." def _WaitForShutdown(h): win32event.WaitForSingleObject(h, win32event.INFINITE) print "Shutdown requested" from twisted.internet import reactor reactor.callLater(0, reactor.stop) def DetermineRunner(bbdir): '''Checks if the given directory is a buildslave or a master and returns the appropriate run function.''' try: import buildslave.scripts.runner tacfile = os.path.join(bbdir, 'buildbot.tac') if os.path.exists(tacfile): with open(tacfile, 'r') as f: contents = f.read() if 'import BuildSlave' in contents: return buildslave.scripts.runner.run except ImportError: # Use the default pass import buildbot.scripts.runner return buildbot.scripts.runner.run # This function is also called by the py2exe startup code. def HandleCommandLine(): if len(sys.argv)>1 and sys.argv[1] == "--spawn": # Special command-line created by the service to execute the # child-process. # First arg is the handle to wait on # Fourth arg is the config directory to use for the buildbot/slave _RunChild(DetermineRunner(sys.argv[5])) else: win32serviceutil.HandleCommandLine(BBService, customOptionHandler=CustomInstall) if __name__ == '__main__': HandleCommandLine() buildbot-0.8.8/docs/000077500000000000000000000000001222546025000142615ustar00rootroot00000000000000buildbot-0.8.8/docs/Makefile000066400000000000000000000125631222546025000157300ustar00rootroot00000000000000all: docs.tgz .PHONY: images images-png images-eps tutorial manual VERSION=$(shell if [ -n "$$VERSION" ]; then echo $$VERSION; else PYTHONPATH=..:$${PYTHONPATH} python -c 'from buildbot import version; print version'; fi) TAR_TRANSFORM = $(shell if [[ `tar --version` =~ "bsdtar" ]]; then echo "-s '/^html/$(VERSION)/'"; else echo "--transform 's/^html/$(VERSION)/'"; fi) docs.tgz: clean html singlehtml images-png sed -e 's!href="index.html#!href="#!g' < _build/singlehtml/index.html > _build/html/full.html tar -C _build $(TAR_TRANSFORM) -zcf $@ html images: $(MAKE) -C images all images-png: # rule disabled, since the .png's are in git #$(MAKE) -C images images-png images-eps: $(MAKE) -C images images-eps # -- Makefile for Sphinx documentation -- # You can set these variables from the command line. SPHINXOPTS = -q -W SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* html: conf.py $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: conf.py $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: conf.py $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: conf.py $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: conf.py $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: conf.py $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: conf.py $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/BuildbotTutorial.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/BuildbotTutorial.qhc" devhelp: conf.py $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/BuildbotTutorial" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/BuildbotTutorial" @echo "# devhelp" epub: conf.py $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: conf.py $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: conf.py $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." make -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: conf.py $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: conf.py $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." changes: conf.py $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: conf.py $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: conf.py $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." buildbot-0.8.8/docs/_images/000077500000000000000000000000001222546025000156655ustar00rootroot00000000000000buildbot-0.8.8/docs/_images/header-text-transparent.png000066400000000000000000000336301222546025000231510ustar00rootroot00000000000000PNG  IHDRDٗsRGBgAMA a cHRMz&u0`:pQ< pHYs  ~tIME9'dqtEXtCommentCreated with The GIMPd%ntEXtSoftwarePaint.NET v3.5.5I6IDATx^ՕW 0"0AdLT (I4yg'zrNJBA!16ذkcaAzpH6sWU{~5h 8{SYx_2/%%sk1 +~w9cwoFh DWƌ؃sy,ޓ(>aqڤY-LkJLI1K3LZ|I41ML- Zy~2wNʎ@.&n<M^4sGZM̯JZ1aq;IN.01=!פehBM[84m(ny8f7gQqѢsj`mI#d AQvJ欤<2CsR MjlZ@}%^ke*izT7Cp 1SLT;r7.~sg!ր'!|Z`\ D})`AeRO5K奲2u}$.!xQQT q֪jl~m)9/ɾy-_ 1<&Cf%-E&G\ֱ8rx,A{\m0:ʼ-z IK2B'ηY.rm< =h l7kdz|A BZ`-6y饦0~VGYye&[fd[Mcki%Hg d9I+@D(Wi0ͱ9E-Lo,9=zA5@Fro3/‰f&+<`nKy3(U]J hX׫mY6V5.bg+laΔ*g&i_R MYr.9%.o%ŏc/Z]?Z ۢL@-55_mE= 3+:Geq-  ʕ )B =񮠗":E9Ra敁eYab'̲dȩjck,̎wwޙ:239PY`E1t4XUV.ʪ2UELee.$PWf=``*uUC.sEmYwmB+A|Le{ՋWEd;ʢ(KrM@,<o$W.7-7'ֽa6=C̦mY~fY:S_iaOX-ͣ@tЂPeMR2e?IM99g{ gzʎ-ΪPdU:S|v)!Xֳ<,e_fh`{7}ϙzlxmooy̷_/7ϼl4uŭ 8ׁ2@POqSKM,+%fowr @BL¬j LU^RZTP*,@i]`-R2ĵ$k7E?hzoY`M3 +~jOO[/¼O,o4ļ.p_{m)-/>sֺ>⢲6oò,-A]JZ~\vrRsg=a\HU]xւX!hCZgj M@- $*MWzbM4gt@znw['d†w~gU s?6{'P v]Y1`Wb~U&_L/s@[י>D%R_dsr?~_ (/̫|S-PQU恑L0*Vh90(.płE&6%} mI0{&Y/7Ui{~f#h_y]=WO RG_6/{<'͚~SGƬNUW`Zߙ@}yUVu3]zRFk@^V 5Mk(mZWh*:6*cۢv;K\_fj MlL , fkjbųLYRt= ny7 Ro>)5}V&)wѼr_ګfê]ͲV]Vgڋ*Ls~bjSj;uק21}9Xh\XURXi✟=X7ޑ1J1+j2\iJ$*BJkUIHE5f]i6, c_qQWDJX(۬]M$7/?캗ýMwUi*(7"S+Դش2RPjj|BRQ2ĸL9O;UUJU*Kg͌}b8^9 ( ,(iKE-ĚLFQkZNʻdhs|Y[!kLbJ@mŲxhS[4.)3e6i^/M4O1S)[hQՖRӔW"+E6Njv̔ @ZUkRZ6]vs0Sn5=^n+7صպ %-vחjyuؖTa1;;/_mhI8+q'L|r̙3Sǜ|Q#Gr,6=[TB -ږe٤WFQLQ<)ռbrFSuR[Ư..jΞ4mpf5pmޑ;rUu[7mRwTUPLQ#Yj\_nI6J2ܗcōM4NR\bK]--50B~csv `Me[gamlԵ!Ȕx2_q}Qb+̼O&Ÿ6в>G5f3'Nh] ͂;Zkat]F 7[t f f][nOt`dok8b3fAFsϗkjʤXƴ0)@a}Ae8$@kYk-ICI)<3ܫ6p =aTyp{mI*ZT&Av(i{M], fq[[of[%Dx 5v)wMMl䈑fg:3:o~R)f |/w-[J:f><[{؝{~:떙>^cmS N\dTN&w[͂YX2ӮcN:ygHGaN88]t-bu^Yl*-2 *ju}Z ̔R/Ŝ3qʆaw'4jRnBb*jP~l! (@qQ&Xv]\zs[̑Go{;#Gc4b2ru1b/yuĨѯLj=ȣL=uK4-05D6PVoeA)[Ldcrͤ6gMR;nl] zl)'V+7ttMr /1+ڭ,CUc(lsE]@e>~{wZ{y#=h.hpenTU F`|M] l_oY~)ŝfKISY.qaF|We}Cv.^O;G"{T',4!pLNV/;pǿns6Jz2*2\c~^a:j[[kԖiW\)RWF@GN;L>,Yİq|6-J Kr1|S+qKsimY)xYEX.-o5qKr̤s2L|fu9ȕ7p6uuumVAf…WL;ٿQG,[c~|wBbZ[[?Kc;xQzUW8S__}_{ׇ\p;wLٴ4ʾ>A Ua&V^Y`*ZI,+({,R[AI&),6WNAݬZŽ)fν&qDzjYY(8*Y'VU{C2=mUprMmIy6~={•2iPyg(7/ſ{퍕&??ߠXc_r%ن(==ݜs9 ]Gyq=襤$?5:1G~.(.[YOv{slv iH"2 F_n]cMVvIU[p;Z5rU\\M5KC5Bj*[/ԕv]\خ9.nR%PkK^n*ĎwUT)ī(o ^f:ŜyOr@jyɁwnn{;8='%ɓ*Gnz/ZE*fcc7nsm'Fj+w;D@ζu_R_7G*&tmog(ǑF"C7Z .[/oM}eڑDU B6xZ& }6Xiz`JeV0,qw`&Ε6UVP-WFwvjR3fg̽O,꼵ryw緵%HWH髖1J|IUCP֗;]Vu2L8mY Üw5f+!i璓]=øuِ̪;spȑ#Q]2 5wkx #9b; ,?"E;Xp?~89SMllYhucpY$5 o3@ƨ݁eRX*xumAm<[1Ƹ̨mRur*k[ vͷT/3M}(Q@|].E|eoU귴 }U5+wdKiMaY[md}WMe6̲fsϛj?ٜvUm6ԕ}0jԨ&1͡ {qDž+s< oq^t`V/Gןn%x4538>COȂ~MA׸+/w@n,[la\ d$R^UZ2u,I|gVpkXnW`TYwpS _c.k[ AUR`q9X,x).pOTK@!.%%qsgptD:kwx 9/:{ˣXyg{?=E, -mUOEWVn +{fe3e>(lW)JmA(ig`@Jgxk/j Mkߥڊ_lr rYpL@&ƭ- മv=w%Zw B(u>*//wuCʤ!,rv'\* ܸ;k؉?=6)'_*\\d)/o^c&# E?mmvl*#xy6^;r;PY@$E<%porr(O" $sX0HA7*Gs0yCc Ⱦ.KʍW\wb\bFeQ*(6ߒ|]yiloIC͂ a}d#ύFI  Rlk8%$~ |ТkF!]%;8qrBlYyOf-MzQX)N*#p7ºU1mCv8e]FۺZZWX7 W[iv,@,%ehx^p`mHUPh;Xn6RcmblNz)ܖKݶ].U '`oțxz [;g 0ln7 n9ꫯfi;,u111I>b<01qot1bA|?h"됲w e>E,J/A4@Ї->Nտ[*%ϟoaph6i~9IʍK +pQY=nSŭ⶗7dX0vP`]xJt1*Xt$PUvĪ,g;e6'f\o_ k3%M7͗;|C8u{F ^hfϞm\HJ'$$ MCa$.x]7p"of`R঍,,OIݯVݠAz8 %rߌP'@r P`lA~6CL1cew1J-V.>S엨|tIZV,#x&MU^B)+Y f|=\FD9]?N%H(pB]WT]f6n-dZv71VAĽ@h'Z%seMmլ1uST\1Ā| IMi84m'h>m4kĆȶȨ~s]4l!KYbMcdvA^d9uwP.r_җ ʓq>=o@뤡Qpe&+z5\~LWl_=&Oܼ‹7_tݟ~k3tW&N,skIz[8N7on%9Dn`w1wGD:r; ,!1`DŽ*OxžĴnYs Zϛ6![o"&`=Jӳe{ۻsij "|pgGZKAE+PVkqa)|]9{絎r,ƣpC`*ST6GS2YpgN6\xֹ/fʮ],Э(/2e+d8uÿ28/K!q `'{͵ΕeG)[Wk(!uTkvF`J۹{ں?xMDljgB,P_&ק$ס6= !:)PvZ:1WU/ e^gDeQMܒUUgRF} lwNUms-]+WvciNwt),_N? ؽ}z: {j]I6 ӱ z4u+ kJNC<=H7쩲dnJB}˼i/V:㐁ٟU/X[LN}=٤{LvVZLKog͵* d@廅;laB-GE^yTpY@]<媙fgiй2bdȎ; zcp8y9 XOn]E<ê/2ԑ@UUU9]5Q{W@]7;=%AnV7(qWdeRd2n˟KLį.cbjP[;:J@ƹ&Cfެ,G5\2> ('%N6 6"Z1@LmPZD1!^f!tŗN7 Y&e 2bZ"cS>NB7z]XZ/j&Z0 L2dAXm{@<*jLy^ r9J<orJ@Ͽ^eŪ5\d2Ylzٹce`y˃HR1,u^7}*rvK~ p6I'NwL1s\Z7ڊ4ȡĞcL0lFo ŀ{hr@ oALmNޝldݲeO^XR@kX\d7<_>zQشz *)n.pgI%QU\Ntlzԕy`QKN*:mYSbinܸй<jh2d dNE)D_YJYkF\/l\ˍ>$p#o Zs7Gnȇ̶`*@~F{P| 6Ӌncm#^ʴZT(%[ f~uDŽJB}҇Rv{dKG2eeı2tq2hǩçAM7+n|LQOtscŭ,c|$v m|0c UoAn.4O3 Ϲ/Ѿ6L5 G oq!B5 s-[d<ֺ?[5$ 6A2pSPJȋCOt<SOKq4Z^һu?F9h^IKTGЈ{Lt)1nkgt#tsIF}t{I%[!C3V-+;6i39N#K#@ : Ny'`KQ)dIL05e ̳uƵfHQ(/\g\4H .v.> įJYV$K-&/;R<ǬUt~S(cRWA.p";7c eTpi[Bzo-# skD8nS9+jJ2XCh(1Sveh!K#ss[}d'̱Ps|xĜ'NP/,g߀zŭO?#7IþyЃs^/^`9*22dJɘ9ۃQ۰?[GJ&ɜT92B &SH%>F':l)oKjtczy eQqE<Ǿ܁=.҈St!]?V6 ==e}d<n3:ybVPbBt]|Dٖ.E]2(17O)R͵+3{օt󣺣edqQ/f2 Kd| Cq# e(%e(640F“ͦW@y4ibjѣ5 0xcn3 ,j@'iKπE/;07Ha[4 s'&Fdkfg5 hP`2͌y[@&~$>"ͣ@3r2`Q7}DP!G@2ǜ﬋v|.hw[ zIENDB`buildbot-0.8.8/docs/_static/000077500000000000000000000000001222546025000157075ustar00rootroot00000000000000buildbot-0.8.8/docs/_static/buildbot.ico000066400000000000000000000047661222546025000202240ustar00rootroot00000000000000 h&h(  ___mo͎qqʏrrsshss7ff halxyyyzzyyyyxxwwԖvuvvI]Ye?x~~~}|"[H[sl{߫e`oE[WfCPwť֡؞TzGufn.((~ OHQɔ~YmUSS@>#0.5{y~|{x]}datqvs~xw~u|s{aU~}|v|zyšvrzpwqxixv{ts}s}zvnv|ls}krymmnw<|ow^|kro|ltzjqwfnwfnt_j(  $.()0.5S@>PHQVQ]mUSXS`\Xe]Xfk_je`oiamvdkufnwgnzjr}kr|lt}lt~nwnvtm|dahhmsnprrpxrzs}tt{u|w~v|qpsssststqwvvsxwxyxxyyzzzy~}~~~{~vwvvxzzy{|}yz}~{|~~{~š )+*)013231--/ Aie66868659 snghfd7~~}ttsu O|{|romlkj;zzxqp^]^\YXWFcwyvTVRQLba`_U MEKJI@S[ZUD,.N>&%$BHGC'4{{ _('Table Of Contents') }} {{ toctree(collapse=True, maxdepth=-1, titles_only=True) }} {%- endblock %} buildbot-0.8.8/docs/bbdocs/000077500000000000000000000000001222546025000155155ustar00rootroot00000000000000buildbot-0.8.8/docs/bbdocs/__init__.py000066400000000000000000000000001222546025000176140ustar00rootroot00000000000000buildbot-0.8.8/docs/bbdocs/ext.py000066400000000000000000000225751222546025000167020ustar00rootroot00000000000000# This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members from docutils import nodes from sphinx.domains import Domain, ObjType, Index from sphinx.roles import XRefRole from sphinx.util.compat import Directive from sphinx.util import ws_re from sphinx.util.nodes import make_refnode from sphinx import addnodes class BBRefTargetDirective(Directive): """ A directive that can be a target for references. Attributes: @cvar ref_type: same as directive name @cvar indextemplates: templates for main index entries, if any """ has_content = False required_arguments = 1 optional_arguments = 0 final_argument_whitespace = True option_spec = {} def run(self): env = self.state.document.settings.env # normalize whitespace in fullname like XRefRole does fullname = ws_re.sub(' ', self.arguments[0].strip()) targetname = '%s-%s' % (self.ref_type, fullname) # keep the target; this may be used to generate a BBIndex later targets = env.domaindata['bb']['targets'].setdefault(self.ref_type, {}) targets[fullname] = env.docname, targetname # make up the descriptor: a target and potentially an index descriptor node = nodes.target('', '', ids=[targetname]) ret = [node] # add the target to the document self.state.document.note_explicit_target(node) # append the index node if necessary entries = [] for tpl in self.indextemplates: colon = tpl.find(':') if colon != -1: indextype = tpl[:colon].strip() indexentry = tpl[colon+1:].strip() % (fullname,) else: indextype = 'single' indexentry = tpl % (fullname,) entries.append((indextype, indexentry, targetname, targetname)) if entries: inode = addnodes.index(entries=entries) ret.insert(0, inode) return ret @classmethod def resolve_ref(cls, domain, env, fromdocname, builder, typ, target, node, contnode): """ Resolve a reference to a directive of this class """ targets = domain.data['targets'].get(cls.ref_type, {}) try: todocname, targetname = targets[target] except KeyError: print "MISSING BB REFERENCE: bb:%s:%s" % (cls.ref_type, target) return None return make_refnode(builder, fromdocname, todocname, targetname, contnode, target) def make_ref_target_directive(ref_type, indextemplates=None): """ Create and return a L{BBRefTargetDirective} subclass. """ return type("BB%sRefTargetDirective" % (ref_type.capitalize(),), (BBRefTargetDirective,), dict(ref_type=ref_type, indextemplates=indextemplates)) class BBIndex(Index): """ A Buildbot-specific index. @cvar name: same name as the directive and xref role @cvar localname: name of the index document """ def generate(self, docnames=None): content = {} idx_targets = self.domain.data['targets'].get(self.name, {}) for name, (docname, targetname) in idx_targets.iteritems(): letter = name[0].upper() content.setdefault(letter, []).append( (name, 0, docname, targetname, '', '', '')) content = [ (l, sorted(content[l], key=lambda tup : tup[0].lower())) for l in sorted(content.keys()) ] return (content, False) @classmethod def resolve_ref(cls, domain, env, fromdocname, builder, typ, target, node, contnode): """ Resolve a reference to an index to the document containing the index, using the index's C{localname} as the content of the link. """ # indexes appear to be automatically generated at doc DOMAIN-NAME todocname = "bb-%s" % target node = nodes.reference('', '', internal=True) node['refuri'] = builder.get_relative_uri(fromdocname, todocname) node['reftitle'] = cls.localname node.append(nodes.emphasis(cls.localname, cls.localname)) return node def make_index(name, localname): """ Create and return a L{BBIndex} subclass, for use in the domain's C{indices} """ return type("BB%sIndex" % (name.capitalize(),), (BBIndex,), dict(name=name, localname=localname)) class BugRole(object): """ A role to create a link to a Trac bug, by number """ def __call__(self, typ, rawtext, text, lineno, inliner, options={}, content=[]): bugnum = text.lstrip('#') node = nodes.reference('', '') node['refuri'] = 'http://trac.buildbot.net/ticket/%s' % bugnum node['reftitle'] = title = 'bug #%s' % bugnum node.append(nodes.Text(title)) return [ node ], [] class SrcRole(object): """ A role to link to buildbot source on master """ def __call__(self, typ, rawtext, text, lineno, inliner, options={}, content=[]): node = nodes.reference('', '') node['refuri'] = ( 'https://github.com/buildbot/buildbot/blob/master/%s' % text ) node['reftitle'] = title = '%s' % text node.append(nodes.literal(title, title)) return [ node ], [] class PullRole(object): """ A role to link to a buildbot pull request """ def __call__(self, typ, rawtext, text, lineno, inliner, options={}, content=[]): node = nodes.reference('', '') node['refuri'] = ('https://github.com/buildbot/buildbot/pull/' + text) node['reftitle'] = title = 'pull request %s' % text node.append(nodes.Text(title, title)) return [ node ], [] class BBDomain(Domain): name = 'bb' label = 'Buildbot' object_types = { 'cfg' : ObjType('cfg', 'cfg'), 'sched' : ObjType('sched', 'sched'), 'chsrc' : ObjType('chsrc', 'chsrc'), 'step' : ObjType('step', 'step'), 'status' : ObjType('status', 'status'), 'cmdline' : ObjType('cmdline', 'cmdline'), } directives = { 'cfg' : make_ref_target_directive('cfg', indextemplates=[ 'single: Buildmaster Config; %s', 'single: %s (Buildmaster Config)', ]), 'sched' : make_ref_target_directive('sched', indextemplates=[ 'single: Schedulers; %s', 'single: %s Scheduler', ]), 'chsrc' : make_ref_target_directive('chsrc', indextemplates=[ 'single: Change Sources; %s', 'single: %s Change Source', ]), 'step' : make_ref_target_directive('step', indextemplates=[ 'single: Build Steps; %s', 'single: %s Build Step', ]), 'status' : make_ref_target_directive('status', indextemplates=[ 'single: Status Targets; %s', 'single: %s Status Target', ]), 'cmdline' : make_ref_target_directive('cmdline', indextemplates=[ 'single: Command Line Subcommands; %s', 'single: %s Command Line Subcommand', ]), } roles = { 'cfg' : XRefRole(), 'sched' : XRefRole(), 'chsrc' : XRefRole(), 'step' : XRefRole(), 'status' : XRefRole(), 'cmdline' : XRefRole(), 'index' : XRefRole(), 'bug' : BugRole(), 'src' : SrcRole(), 'pull' : PullRole(), } initial_data = { 'targets' : {}, # type -> target -> (docname, targetname) } indices = [ make_index("cfg", "Buildmaster Configuration Index"), make_index("sched", "Scheduler Index"), make_index("chsrc", "Change Source Index"), make_index("step", "Build Step Index"), make_index("status", "Status Target Index"), make_index("cmdline", "Command Line Index"), ] def resolve_xref(self, env, fromdocname, builder, typ, target, node, contnode): if typ == 'index': for idx in self.indices: if idx.name == target: break else: raise KeyError("no index named '%s'" % target) return idx.resolve_ref(self, env, fromdocname, builder, typ, target, node, contnode) elif typ in self.directives: dir = self.directives[typ] return dir.resolve_ref(self, env, fromdocname, builder, typ, target, node, contnode) def setup(app): app.add_domain(BBDomain) buildbot-0.8.8/docs/buildbot.1000066400000000000000000000234111222546025000161500ustar00rootroot00000000000000.\" This file is part of Buildbot. Buildbot 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, version 2. .\" .\" 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, write to the Free Software Foundation, Inc., 51 .\" Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. .\" .\" Copyright Buildbot Team Members .TH BUILDBOT "1" "August 2010" "Buildbot" "User Commands" .SH NAME buildbot \- a tool for managing buildbot master instances .SH SYNOPSIS .SS General Invocation .PP .B buildbot [ .BR "global options" ] .I command [ .BR "command options" ] .PP .B buildbot .I command .BR \-h | \-\-help .SS Command Options .PP .B buildbot create-master [ .BR \-q | \-\-quiet ] [ .BR \-f | \-\-force ] [ .BR \-r | \-\-relocatable ] [ .BR \-n | \-\-no-logrotate ] [ .BR \-s | \-\-log-size .I SIZE ] [ .BR \-l | \-\-log-count .I COUNT ] [ .BR \-c | \-\-config .I CONFIG ] [ .BR \-\-db .I DATABASE ] [ .I PATH ] .PP .B buildbot upgrade-master [ .BR \-q | \-\-quiet ] [ .BR \-r | \-\-replace ] [ .BR \-\-db .I DATABASE ] [ .I PATH ] .PP .B buildbot [ .BR \-\-verbose ] { .BR start | stop | restart | sighup | reconfig } [ .I PATH ] .PP .B buildbot sendchange [ .BR \-m | \-\-master .I MASTER ] [ .BR \-u | \-\-username .I USERNAME ] [ .BR \-R | \-\-repository .I REPOSITORY ] [ .BR \-P | \-\-project .I PROJECT ] [ .BR \-b | \-\-branch .I BRANCH ] [ .BR \-C | \-\-category .I CATEGORY ] [ .BR \-r | \-\-revision .I REVISION ] [ .BR \-\-revision-file .I REVISIONFILE ] [ .BR \-p | \-\-property .I PROPERTY ] [ .BR \-c | \-\-comments .I MESSAGE ] [ .BR \-F | \-\-logfile .I LOGFILE ] [ .BR \-w | \-\-when .I TIMESTAMP ] .IR FILES ... .PP .B buildbot debugclient [ .BR \-m | \-\-master .I MASTER ] [ .BR \-p | \-\-passwd .I PASSWORD ] .PP .B buildbot statuslog [ .BR \-m | \-\-master .I MASTER ] [ .BR \-u | \-\-username .I USERNAME ] [ .BR \-p | \-\-passwd .I PASSWORD ] .PP .B buildbot statusgui [ .BR \-m | \-\-master .I MASTER ] [ .BR \-u | \-\-username .I USERNAME ] [ .BR \-p | \-\-passwd .I PASSWORD ] .PP .B buildbot try [ .BR \-\-wait ] [ .BR \-n | \-\-dry-run ] [ .BR \-\-get-builder-names ] [ .BR \-c | \-\-connect {ssh|pb} ] [ .BR \-\-tryhost .I HOSTNAME ] [ .BR \-\-trydir .I PATH ] [ .BR \-m | \-\-master .I MASTER ] [ .BR \-u | \-\-username .I USERNAME ] [ .BR \-\-passwd .I PASSWORD ] [ .BR \-\-diff .I DIFF ] [ .BR \-\-patchlevel .I PATCHLEVEL ] [ .BR \-\-baserev .I BASEREV ] [ .BR \-\-vc {cvs|svn|tla|baz|darcs|p4} ] [ .BR \-\-branch .I BRANCH ] [ .BR \-b | \-\-builder .I BUILDER ] [ .BR \-\-properties .I PROPERTIES ] [ .BR \-\-try-topfile .I FILE ] [ .BR \-\-try-topdir .I PATH ] .PP .B buildbot tryserver [ .BR \-\-jobdir .I PATH ] .PP .B buildbot checkconfig [ .I CONFIGFILE ] .PP .B buildbot [ .BR \-\-verbose ] { .BR start | stop | restart | sighup | reconfig } [ .I PATH ] .PP .B buildbot [ .BR \-\-verbose ] { .BR \-\-help | \-\-version } .SH DESCRIPTION The `buildbot' command-line tool can be used to start or stop a buildmaster and to interact with a running buildmaster instance. Some of its subcommands are intended for buildmaster admins, while some are for developers who are editing the code that the buildbot is monitoring. .SH OPTIONS .SS Commands .TP .BR create-master Create and populate a directory for a new buildmaster .TP .BR upgrade-master Upgrade an existing buildmaster directory for the current version .TP .BR start Start a buildmaster .TP .BR stop Stop a buildmaster .TP .BR restart Restart a buildmaster .TP .BR sighup | reconfig Send SIGHUP signal to buildmaster to make it re-read the config file .TP .BR sendchange Send a change to the buildmaster .TP .BR debugclient Launch a small debug panel gui .TP .BR statuslog Emit current builder status to stdout .TP .BR statusgui Display a small window showing current builder status .TP .BR try Run a build with your local changes. This command requires in-advance configuration of the buildmaster to accept such build requests. Please see the documentation for details about this command. .TP .BR tryserver buildmaster-side \'try\' support function, not for users .TP .BR checkconfig Validate buildbot master config file. .SS Global options .TP .BR \-h | \-\-help Print the list of available commands and global options. All subsequent commands are ignored. .TP .BR --version Print twistd and buildslave version. All subsequent commands are ignored. .TP .BR --verbose Verbose output. .SS create-master command options .TP .BR \-q | \-\-quiet Do not emit the commands being run .TP .BR \-f | \-\-force Re-use an existing directory (will not overwrite master.cfg file) .TP .BR \-r | \-\-relocatable Create a relocatable buildbot.tac .TP .BR \-n | \-\-no-logrotate Do not permit buildmaster rotate logs by itself. .TP .BR \-c | \-\-config Set name of the buildbot master config file to .IR CONFIG . Default file name is master.cfg. .TP .BR \-s | \-\-log-size Set size at which twisted lof file is rotated to .I SIZE bytes. Default value is 1000000 bytes. .TP .BR \-l | \-\-log-count Limit the number of kept old twisted log files to .IR COUNT . All files are kept by default. .TP .BR \-\-db Set the database connection for storing scheduler/status state to .IR DATABASE . Default value is .BR "sqlite:///state.sqlite" . .TP .I PATH Directory where buildbot master files will be stored. .SS upgrade-master command options .TP .BR \-q | \-\-quiet Do not emit the commands being run. .TP .BR \-r | \-\-replace Replace any modified files without confirmation. .TP .BR \-\-db Set the database connection for storing scheduler/status state to .IR DATABASE . Default value is .BR "sqlite:///state.sqlite" . .TP .I PATH Directory where buildbot master files are stored. .SS sendchange command options .TP .B \-\-master Set the location of buildmaster's PBListener to attach to in form .IR HOST : PORT . .TP .BR \-u | \-\-username Set commiter's username to .IR USERNAME . .TP .BR \-R | \-\-repository Set repository URL to .IR REPOSITORY . .TP .BR \-P | \-\-project Set project specifier to .IR PROJECT . .TP .BR \-b | \-\-branch Set branch name to .IR BRANCH . .TP .BR \-c | \-\-category Set category of repository to .IR CATEGORY . .TP .BR \-r | \-\-revision Set revision being built to .IR REVISION . .TP .BR \-\-revision-file Use .I REVISIONFILE file to read revision spec data from. .TP .BR \-p | \-\-property Set property for the change to .IR PROPERTY . It should be in format .IR NAME : VALUE . .TP .BR \-m | \-\-comments Set log message to .IR MESSAGE . .TP .BR \-F | \-\-logfile Set logfile to .IR LOGFILE . .TP .BR \-w | \-\-when Set timestamp used as the change time to .IR TIMESTAMP . .TP .I FILES Lis of files have been changed. .SS debugclient command options .TP .BR \-m | \-\-master Set the location of buildmaster's PBListener to attach to in form .IR HOST : PORT . .TP .BR \-p | \-\-passwd Debug password to use. .SS statuslog command options .TP .BR \-m | \-\-master Set the location of buildmaster's PBListener to attach to in form .IR HOST : PORT . .TP .BR \-u | \-\-username Set username for PB authentication to .IR USERNAME . Default is .BR statusClient . .TP .BR \-p | \-\-passwd Set password for PB authentication to .IR PASSWORD . Default is .BR clientpw . .SS statusgui command options .TP .BR \-m | \-\-master Set the location of buildmaster's PBListener to attach to in form .IR HOST : PORT . .TP .BR \-u | \-\-username Set username for PB authentication to .IR USERNAME . Default is .BR statusClient . .TP .BR \-p | \-\-passwd Set password for PB authentication to .IR PASSWORD . Default is .BR clientpw . .SS try command options .TP .BR \-\-wait Wait until the builds have finished. .TP .BR \-n | \-\-dry-run Gather info, but don't actually submit. .TP .BR \-\-get-builder-names Get the names of available builders. Doesn't submit anything. Only supported for 'pb' connections. .TP .BR \-c | \-\-connect Connection type. Can be either \'ssh\' or \'pb\'. .TP .BR \-\-tryhost Set the hostname (used by ssh) for the buildmaster to .IR HOSTNAME . .TP .BR \-\-trydir Specify trydir (on the tryhost) where tryjobs are deposited. .TP .BR \-m | \-\-master Set the location of the buildmaster's PBListener in form .IR HOST : PORT .TP .BR \-u | \-\-username Set the username performing the trial build to .IR USERNAME . .TP .BR \-\-passwd Set password for PB authentication to .IR PASSWORD . .TP .BR \-\-diff Use .I DIFF file to use as a patch instead of scanning a local tree. Use \'-\' for stdin. .TP .BR \-\-patchlevel Specify the patchlevel to apply with. Defaults to 0. See .BR patch for details. .TP .BR \-\-baserev Use .I BASEREV revision instead of scanning a local tree. .TP .BR \-\-vc Specify version control system in use. Possible values: cvs, svn, tla, baz, darcs, p4. .TP .BR \-\-branch Specify the branch in use, for VC systems that can't figure it out themselves. .TP .BR \-b | \-\-builder Run the trial build on the specified Builder. Can be used multiple times. .TP .BR \-\-properties Specify the set of properties made available in the build environment in format .IR prop1 = value1 , prop2 = value2 ... .TP .BR \-\-try-topfile Specify name of a file at the top of the tree. This option is used to find the top. Only needed for SVN and CVS. .TP .BR \-\-try-topdir Specify the path to the top of the working copy. Only needed for SVN and CVS. .SS tryserver command options .TP .BR \-\-jobdir The jobdir (maildir) for submitting jobs .SH FILES .TP master.cfg Buildbot master configuration file .SH "SEE ALSO" .BR buildslave (1), .BR patch (1) .PP The complete documentation is available in texinfo format. To use it, run .BR "info buildbot" . buildbot-0.8.8/docs/conf.py000077500000000000000000000172151222546025000155710ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Buildbot documentation build configuration file, created by # sphinx-quickstart on Tue Aug 10 15:13:31 2010. # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys, os, textwrap # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.append(os.path.abspath('.')) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ 'sphinx.ext.todo', 'bbdocs.ext' ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = u'Buildbot' copyright = u'Buildbot Team Members' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. if 'VERSION' in os.environ: version = os.environ['VERSION'] else: gl = {'__file__': '../buildbot/__init__.py'} execfile('../buildbot/__init__.py', gl) version = gl['version'] # The full version, including alpha/beta/rc tags. release = version # add a loud note for anyone loking at the latest docs if release == 'latest': rst_prolog = textwrap.dedent("""\ .. caution:: This page documents the latest, unreleased version of Buildbot. For documentation for released versions, see http://buildbot.net/buildbot/docs. """) # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = [ '_build', 'release-notes/*.rst' ] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. add_function_parentheses = False # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] intersphinx_mapping = { 'python': ('http://python.readthedocs.org/en/latest/', None), 'sqlalchemy': ('http://sqlalchemy.readthedocs.org/en/latest/', None), } # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'agogo' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. #html_theme_options = {'stickysidebar': 'true'} # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. html_logo = os.path.join('_images', 'header-text-transparent.png') # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. html_favicon = 'buildbot.ico' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. html_use_smartypants = False # Custom sidebar templates, maps document names to template names. #html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True html_use_index = True html_use_modindex = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. #html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = '' # Output file base name for HTML help builder. htmlhelp_basename = 'BuildBotdoc' # -- Options for LaTeX output -------------------------------------------------- # The paper size ('letter' or 'a4'). latex_paper_size = 'a4' # The font size ('10pt', '11pt' or '12pt'). #latex_font_size = '11pt' # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'BuildBot.tex', u'BuildBot Documentation', u'Brian Warner', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. latex_logo = os.path.join('_images', 'header-text-transparent.png') # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. latex_show_urls = True # Additional stuff for the LaTeX preamble. #latex_preamble = '' # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'buildbot', u'BuildBot Documentation', [u'Brian Warner'], 1) ] buildbot-0.8.8/docs/developer/000077500000000000000000000000001222546025000162465ustar00rootroot00000000000000buildbot-0.8.8/docs/developer/classes.rst000066400000000000000000000010141222546025000204310ustar00rootroot00000000000000Classes ======= The sections contained here document classes that can be used or subclassed. .. note:: Some of this information duplicates information available in the source code itself. Consider this information authoritative, and the source code a demonstration of the current implementation which is subject to change. .. toctree:: :maxdepth: 1 cls-buildfactory cls-remotecommands cls-buildsteps cls-forcesched cls-irenderable cls-iproperties .. todo:: cls-logobserver buildbot-0.8.8/docs/developer/cls-buildfactory.rst000066400000000000000000000077641222546025000222640ustar00rootroot00000000000000BuildFactory ============ BuildFactory Implementation Note -------------------------------- The default :class:`BuildFactory`, provided in the :mod:`buildbot.process.factory` module, contains an internal list of `BuildStep specifications`: a list of ``(step_class, kwargs)`` tuples for each. These specification tuples are constructed when the config file is read, by asking the instances passed to :meth:`addStep` for their subclass and arguments. To support config files from buildbot-0.7.5 and earlier, :meth:`addStep` also accepts the ``f.addStep(shell.Compile, command=["make","build"])`` form, although its use is discouraged because then the ``Compile`` step doesn't get to validate or complain about its arguments until build time. The modern pass-by-instance approach allows this validation to occur while the config file is being loaded, where the admin has a better chance of noticing problems. When asked to create a :class:`Build`, the :class:`BuildFactory` puts a copy of the list of step specifications into the new :class:`Build` object. When the :class:`Build` is actually started, these step specifications are used to create the actual set of :class:`BuildStep`\s, which are then executed one at a time. This serves to give each Build an independent copy of each step. Each step can affect the build process in the following ways: * If the step's :attr:`haltOnFailure` attribute is ``True``, then a failure in the step (i.e. if it completes with a result of ``FAILURE``) will cause the whole build to be terminated immediately: no further steps will be executed, with the exception of steps with :attr:`alwaysRun` set to ``True``. :attr:`haltOnFailure` is useful for setup steps upon which the rest of the build depends: if the CVS checkout or :command:`./configure` process fails, there is no point in trying to compile or test the resulting tree. * If the step's :attr:`alwaysRun` attribute is ``True``, then it will always be run, regardless of if previous steps have failed. This is useful for cleanup steps that should always be run to return the build directory or build slave into a good state. * If the :attr:`flunkOnFailure` or :attr:`flunkOnWarnings` flag is set, then a result of ``FAILURE`` or ``WARNINGS`` will mark the build as a whole as ``FAILED``. However, the remaining steps will still be executed. This is appropriate for things like multiple testing steps: a failure in any one of them will indicate that the build has failed, however it is still useful to run them all to completion. * Similarly, if the :attr:`warnOnFailure` or :attr:`warnOnWarnings` flag is set, then a result of ``FAILURE`` or ``WARNINGS`` will mark the build as having ``WARNINGS``, and the remaining steps will still be executed. This may be appropriate for certain kinds of optional build or test steps. For example, a failure experienced while building documentation files should be made visible with a ``WARNINGS`` result but not be serious enough to warrant marking the whole build with a ``FAILURE``. In addition, each :class:`Step` produces its own results, may create logfiles, etc. However only the flags described above have any effect on the build as a whole. The pre-defined :class:`BuildStep`\s like :class:`CVS` and :class:`Compile` have reasonably appropriate flags set on them already. For example, without a source tree there is no point in continuing the build, so the :class:`CVS` class has the :attr:`haltOnFailure` flag set to ``True``. Look in :file:`buildbot/steps/*.py` to see how the other :class:`Step`\s are marked. Each :class:`Step` is created with an additional ``workdir`` argument that indicates where its actions should take place. This is specified as a subdirectory of the slave builder's base directory, with a default value of :file:`build`. This is only implemented as a step argument (as opposed to simply being a part of the base directory) because the CVS/SVN steps need to perform their checkouts from the parent directory. buildbot-0.8.8/docs/developer/cls-buildsteps.rst000066400000000000000000000456101222546025000217430ustar00rootroot00000000000000BuildSteps ========== .. py:module:: buildbot.process.buildstep There are a few parent classes that are used as base classes for real buildsteps. This section describes the base classes. The "leaf" classes are described in :doc:`../manual/cfg-buildsteps`. BuildStep --------- .. py:class:: BuildStep(name, locks, haltOnFailure, flunkOnWarnings, flunkOnFailure, warnOnWarnings, warnOnFailure, alwaysRun, progressMetrics, useProgress, doStepIf, hideStepIf) All constructor arguments must be given as keyword arguments. Each constructor parameter is copied to the corresponding attribute. .. py:attribute:: name The name of the step. .. py:attribute:: locks List of locks for this step; see :ref:`Interlocks`. .. py:attribute:: progressMetrics List of names of metrics that should be used to track the progress of this build, and build ETA's for users. This is generally set in the .. py:attribute:: useProgress If true (the default), then ETAs will be calculated for this step using progress metrics. If the step is known to have unpredictable timing (e.g., an incremental build), then this should be set to false. .. py:attribute:: doStepIf A callable or bool to determine whether this step should be executed. See :ref:`Buildstep-Common-Parameters` for details. .. py:attribute:: hideStepIf A callable or bool to determine whether this step should be shown in the waterfall and build details pages. See :ref:`Buildstep-Common-Parameters` for details. The following attributes affect the behavior of the containing build: .. py:attribute:: haltOnFailure If true, the build will halt on a failure of this step, and not execute subsequent tests (except those with ``alwaysRun``). .. py:attribute:: flunkOnWarnings If true, the build will be marked as a failure if this step ends with warnings. .. py:attribute:: flunkOnFailure If true, the build will be marked as a failure if this step fails. .. py:attribute:: warnOnWarnings If true, the build will be marked as warnings, or worse, if this step ends with warnings. .. py:attribute:: warnOnFailure If true, the build will be marked as warnings, or worse, if this step fails. .. py:attribute:: alwaysRun If true, the step will run even if a previous step halts the build with ``haltOnFailure``. A few important pieces of information are not available when a step is constructed, and are added later. These are set by the following methods; the order in which these methods are called is not defined. .. py:method:: setBuild(build) :param build: the :class:`~buildbot.process.build.Build` instance controlling this step. This method is called during setup to set the build instance controlling this slave. Subclasses can override this to get access to the build object as soon as it is available. The default implementation sets the :attr:`build` attribute. .. py:attribute:: build The build object controlling this step. .. py:method:: setBuildSlave(build) :param build: the :class:`~buildbot.buildslave.BuildSlave` instance on which this step will run. Similarly, this method is called with the build slave that will run this step. The default implementation sets the :attr:`buildslave` attribute. .. py:attribute:: buildslave The build slave that will run this step. .. py:method:: setDefaultWorkdir(workdir) :param workdir: the default workdir, from the build This method is called at build startup with the default workdir for the build. Steps which allow a workdir to be specified, but want to override it with the build's default workdir, can use this method to apply the default. .. py:method:: setStepStatus(status) :param status: step status :type status: :class:`~buildbot.status.buildstep.BuildStepStatus` This method is called to set the status instance to which the step should report. The default implementation sets :attr:`step_status`. .. py:attribute:: step_status The :class:`~buildbot.status.buildstep.BuildStepStatus` object tracking the status of this step. .. py:method:: setupProgress() This method is called during build setup to give the step a chance to set up progress tracking. It is only called if the build has :attr:`useProgress` set. There is rarely any reason to override this method. .. py:attribute:: progress If the step is tracking progress, this is a :class:`~buildbot.status.progress.StepProgress` instance performing that task. Execution of the step itself is governed by the following methods and attributes. .. py:method:: startStep(remote) :param remote: a remote reference to the slave-side :class:`~buildslave.bot.SlaveBuilder` instance :returns: Deferred Begin the step. This is the build's interface to step execution. Subclasses should override :meth:`start` to implement custom behaviors. The method returns a Deferred that fires when the step finishes. It fires with a tuple of ``(result, [extra text])``, where ``result`` is one of the constants from :mod:`buildbot.status.builder`. The extra text is a list of short strings which should be appended to the Build's text results. For example, a test step may add ``17 failures`` to the Build's status by this mechanism. The deferred will errback if the step encounters an exception, including an exception on the slave side (or if the slave goes away altogether). Normal build/test failures will *not* cause an errback. .. py:method:: start() :returns: ``None`` or :data:`~buildbot.status.results.SKIPPED`, optionally via a Deferred. Begin the step. Subclasses should override this method to do local processing, fire off remote commands, etc. The parent method raises :exc:`NotImplementedError`. When the step is done, it should call :meth:`finished`, with a result -- a constant from :mod:`buildbot.status.results`. The result will be handed off to the :class:`~buildbot.process.build.Build`. If the step encounters an exception, it should call :meth:`failed` with a Failure object. This method automatically fails the whole build with an exception. A common idiom is to add :meth:`failed` as an errback on a Deferred:: cmd = RemoteCommand(args) d = self.runCommand(cmd) def suceed(_): self.finished(results.SUCCESS) d.addCallback(succeed) d.addErrback(self.failed) If the step decides it does not need to be run, :meth:`start` can return the constant :data:`~buildbot.status.results.SKIPPED`. In this case, it is not necessary to call :meth:`finished` directly. .. py:method:: finished(results) :param results: a constant from :mod:`~buildbot.status.results` A call to this method indicates that the step is finished and the build should analyze the results and perhaps proceed to the next step. The step should not perform any additional processing after calling this method. .. py:method:: failed(failure) :param failure: a :class:`~twisted.python.failure.Failure` instance Similar to :meth:`finished`, this method indicates that the step is finished, but handles exceptions with appropriate logging and diagnostics. This method handles :exc:`BuildStepFailed` specially, by calling ``finished(FAILURE)``. This provides subclasses with a shortcut to stop execution of a step by raising this failure in a context where :meth:`failed` will catch it. .. py:method:: interrupt(reason) :param reason: why the build was interrupted :type reason: string or :class:`~twisted.python.failure.Failure` This method is used from various control interfaces to stop a running step. The step should be brought to a halt as quickly as possible, by cancelling a remote command, killing a local process, etc. The step must still finish with either :meth:`finished` or :meth:`failed`. The ``reason`` parameter can be a string or, when a slave is lost during step processing, a :exc:`~twisted.internet.error.ConnectionLost` failure. The parent method handles any pending lock operations, and should be called by implementations in subclasses. .. py:attribute:: stopped If false, then the step is running. If true, the step is not running, or has been interrupted. This method provides a convenient way to summarize the status of the step for status displays: .. py:method:: describe(done=False) :param done: If true, the step is finished. :returns: list of strings Describe the step succinctly. The return value should be a sequence of short strings suitable for display in a horizontally constrained space. .. note:: Be careful not to assume that the step has been started in this method. In relatively rare circumstances, steps are described before they have started. Ideally, unit tests should be used to ensure that this method is resilient. Build steps support progress metrics - values that increase roughly linearly during the execution of the step, and can thus be used to calculate an expected completion time for a running step. A metric may be a count of lines logged, tests executed, or files compiled. The build mechanics will take care of translating this progress information into an ETA for the user. .. py:method:: setProgress(metric, value) :param metric: the metric to update :type metric: string :param value: the new value for the metric :type value: integer Update a progress metric. This should be called by subclasses that can provide useful progress-tracking information. The specified metric name must be included in :attr:`progressMetrics`. The following methods are provided as utilities to subclasses. These methods should only be invoked after the step is started. .. py:method:: slaveVersion(command, oldVersion=None) :param command: command to examine :type command: string :param oldVersion: return value if the slave does not specify a version :returns: string Fetch the version of the named command, as specified on the slave. In practice, all commands on a slave have the same version, but passing ``command`` is still useful to ensure that the command is implemented on the slave. If the command is not implemented on the slave, :meth:`slaveVersion` will return ``None``. Versions take the form ``x.y`` where ``x`` and ``y`` are integers, and are compared as expected for version numbers. Buildbot versions older than 0.5.0 did not support version queries; in this case, :meth:`slaveVersion` will return ``oldVersion``. Since such ancient versions of Buildbot are no longer in use, this functionality is largely vestigial. .. py:method:: slaveVersionIsOlderThan(command, minversion) :param command: command to examine :type command: string :param minversion: minimum version :returns: boolean This method returns true if ``command`` is not implemented on the slave, or if it is older than ``minversion``. .. py:method:: getSlaveName() :returns: string Get the name of the buildslave assigned to this step. .. py:method:: runCommand(command) :returns: Deferred This method connects the given command to the step's buildslave and runs it, returning the Deferred from :meth:`~buildbot.process.buildstep.RemoteCommand.run`. .. py:method:: addURL(name, url) :param name: URL name :param url: the URL Add a link to the given ``url``, with the given ``name`` to displays of this step. This allows a step to provide links to data that is not available in the log files. The :class:`BuildStep` class provides minimal support for log handling, that is extended by the :class:`LoggingBuildStep` class. The following methods provide some useful behaviors. These methods can be called while the step is running, but not before. .. py:method:: addLog(name) :param name: log name :returns: :class:`~buildbot.status.logfile.LogFile` instance Add a new logfile with the given name to the step, and return the log file instance. .. py:method:: getLog(name) :param name: log name :returns: :class:`~buildbot.status.logfile.LogFile` instance :raises: :exc:`KeyError` if the log is not found Get an existing logfile by name. .. py:method:: addCompleteLog(name, text) :param name: log name :param text: content of the logfile This method adds a new log and sets ``text`` as its content. This is often useful to add a short logfile describing activities performed on the master. The logfile is immediately closed, and no further data can be added. .. py:method:: addHTMLLog(name, html) :param name: log name :param html: content of the logfile Similar to :meth:`addCompleteLog`, this adds a logfile containing pre-formatted HTML, allowing more expressiveness than the text format supported by :meth:`addCompleteLog`. .. py:method:: addLogObserver(logname, observer) :param logname: log name :param observer: log observer instance Add a log observer for the named log. The named log need not have been added already: the observer will be connected when the log is added. See :ref:`Adding-LogObservers` for more information on log observers. LoggingBuildStep ---------------- .. py:class:: LoggingBuildStep(logfiles, lazylogfiles, log_eval_func, name, locks, haltOnFailure, flunkOnWarnings, flunkOnFailure, warnOnWarnings, warnOnFailure, alwaysRun, progressMetrics, useProgress, doStepIf, hideStepIf) :param logfiles: see :bb:step:`ShellCommand` :param lazylogfiles: see :bb:step:`ShellCommand` :param log_eval_func: see :bb:step:`ShellCommand` The remaining arguments are passed to the :class:`BuildStep` constructor. This subclass of :class:`BuildStep` is designed to help its subclasses run remote commands that produce standard I/O logfiles. It: * tracks progress using the length of the stdout logfile * provides hooks for summarizing and evaluating the command's result * supports lazy logfiles * handles the mechanics of starting, interrupting, and finishing remote commands * detects lost slaves and finishes with a status of :data:`~buildbot.status.results.RETRY` .. py:attribute:: logfiles The logfiles to track, as described for :bb:step:`ShellCommand`. The contents of the class-level ``logfiles`` attribute are combined with those passed to the constructor, so subclasses may add log files with a class attribute:: class MyStep(LoggingBuildStep): logfiles = dict(debug='debug.log') Note that lazy logfiles cannot be specified using this method; they must be provided as constructor arguments. .. py:method:: startCommand(command) :param command: the :class:`~buildbot.process.buildstep.RemoteCommand` instance to start .. note:: This method permits an optional ``errorMessages`` parameter, allowing errors detected early in the command process to be logged. It will be removed, and its use is deprecated. Handle all of the mechanics of running the given command. This sets up all required logfiles, keeps status text up to date, and calls the utility hooks described below. When the command is finished, the step is finished as well, making this class is unsuitable for steps that run more than one command in sequence. Subclasses should override :meth:`~buildbot.process.buildstep.BuildStep.start` and, after setting up an appropriate command, call this method. :: def start(self): cmd = RemoteShellCommand(..) self.startCommand(cmd, warnings) To refine the status output, override one or more of the following methods. The :class:`LoggingBuildStep` implementations are stubs, so there is no need to call the parent method. .. py:method:: commandComplete(command) :param command: the just-completed remote command This is a general-purpose hook method for subclasses. It will be called after the remote command has finished, but before any of the other hook functions are called. .. py:method:: createSummary(stdio) :param stdio: stdio :class:`~buildbot.status.logfile.LogFile` This hook is designed to perform any summarization of the step, based either on the contents of the stdio logfile, or on instance attributes set earlier in the step processing. Implementations of this method often call e.g., :meth:`~BuildStep.addURL`. .. py:method:: evaluateCommand(command) :param command: the just-completed remote command :returns: step result from :mod:`buildbot.status.results` This hook should decide what result the step should have. The default implementation invokes ``log_eval_func`` if it exists, and looks at :attr:`~buildbot.process.buildstep.RemoteCommand.rc` to distinguish :data:`~buildbot.status.results.SUCCESS` from :data:`~buildbot.status.results.FAILURE`. The remaining methods provide an embarrassment of ways to set the summary of the step that appears in the various status interfaces. The easiest way to affect this output is to override :meth:`~BuildStep.describe`. If that is not flexible enough, override :meth:`getText` and/or :meth:`getText2`. .. py:method:: getText(command, results) :param command: the just-completed remote command :param results: step result from :meth:`evaluateCommand` :returns: a list of short strings This method is the primary means of describing the step. The default implementation calls :meth:`~BuildStep.describe`, which is usually the easiest method to override, and then appends a string describing the step status if it was not successful. .. py:method:: getText2(command, results) :param command: the just-completed remote command :param results: step result from :meth:`evaluateCommand` :returns: a list of short strings Like :meth:`getText`, this method summarizes the step's result, but it is only called when that result affects the build, either by making it halt, flunk, or end with warnings. Exceptions ---------- .. py:exception:: BuildStepFailed This exception indicates that the buildstep has failed. It is useful as a way to skip all subsequent processing when a step goes wrong. It is handled by :meth:`BuildStep.failed`. buildbot-0.8.8/docs/developer/cls-forcesched.rst000066400000000000000000000142131222546025000216650ustar00rootroot00000000000000.. -*- rst -*- .. _ForceScheduler: ForceScheduler -------------- The force scheduler has a symbiotic relationship with the web status, so it deserves some further description. Parameters ~~~~~~~~~~ The force scheduler comes with a fleet of parameter classes. This section contains information to help users or developers who are interested in adding new parameter types or hacking the existing types. .. py:module:: buildbot.schedulers.forceshed .. py:class:: BaseParameter(name, label, regex, **kwargs) This is the base implementation for most parameters, it will check validity, ensure the arg is present if the :py:attr:`~IParameter.required` attribute is set, and implement the default value. It will finally call :py:meth:`~IParameter.updateFromKwargs` to process the string(s) from the HTTP POST. The :py:class:`BaseParameter` constructor converts all keyword arguments into instance attributes, so it is generally not necessary for subclasses to implement a constructor. For custom parameters that set properties, one simple customization point is `getFromKwargs`: .. py:method:: getFromKwargs(kwargs) :param kwargs: a dictionary of the posted values Given the passed-in POST parameters, return the value of the property that should be set. For more control over parameter parsing, including modifying sourcestamps or changeids, override the ``updateFromKwargs`` function, which is the function that :py:class:`ForceScheduler` invokes for processing: .. py:method:: updateFromKwargs(master, properties, changes, sourcestamps, kwargs) :param master: the :py:class:`~buildbot.master.BuildMaster` instance :param properties: a dictionary of properties :param changes: a list of changeids that will be used to build the SourceStamp for the forced builds :param sourcestamps: the SourceStamp dictionary that will be passed to the build; some parameters modify sourcestamps rather than properties. :param kwargs: a dictionary of the posted values This method updates ``properties``, ``changes``, and/or ``sourcestamps`` according to the request. The default implementation is good for many simple uses, but can be overridden for more complex purposes. When overriding this function, take all parameters by name (not by position), and include an ``**unused`` catch-all to guard against future changes. The remaining attributes and methods should be overridden by subclasses, although :py:class:`BaseParameter` provides appropriate defaults. .. py:attribute:: name The name of the parameter. This corresponds to the name of the property that your parameter will set. This name is also used internally as identifier for http POST arguments .. py:attribute:: label The label of the parameter, as displayed to the user. This value can contain raw HTML. .. py:method:: fullName A fully-qualified name that uniquely identifies the parameter in the scheduler. This name is used internally as the identifier for HTTP POST arguments. It is a mix of `name` and the parent's `name` (in the case of nested parameters). This field is not modifiable. .. py:attribute:: type A list of types that the parameter conforms to. These are used by the jinja template to create appropriate html form widget. The available values are visible in :bb:src:`master/buildbot/status/web/template/forms.html` in the ``force_build_one_scheduler`` macro. .. py:attribute:: default The default value to use if there is no user input. This is also used to fill in the form presented to the user. .. py:attribute:: required If true, an error will be shown to user if there is no input in this field .. py:attribute:: multiple If true, this parameter represents a list of values (e.g. list of tests to run) .. py:attribute:: regex A string that will be compiled as a regex and used to validate the string value of this parameter. If None, then no validation will take place. .. py:method:: parse_from_args(l) return the list of object corresponding to the list or string passed default function will just call :py:func:`parse_from_arg` with the first argument .. py:method:: parse_from_arg(s) return the object corresponding to the string passed default function will just return the unmodified string Nested Parameters ~~~~~~~~~~~~~~~~~ The :py:class:`NestedParameter` class is a container for parameters. The motivating purpose for this feature is the multiple-codebase configuration, which needs to provide the user with a form to control the branch (et al) for each codebase independently. Each branch parameter is a string field with name 'branch' and these must be disambiguated. Each of the child parameters mixes in the parent's name to create the fully qualified ``fullName``. This allows, for example, each of the 'branch' fields to have a unique name in the POST request. The `NestedParameter` handles adding this extra bit to the name to each of the children. When the `kwarg` dictionary is posted back, this class also converts the flat POST dictionary into a richer structure that represents the nested structure. As illustration, if the nested parameter has the name 'foo', and has children 'bar1' and 'bar2', then the POST will have entries like "foo-bar1" and "foo-bar2". The nested parameter will translate this into a dictionary in the 'kwargs' structure, resulting in something like:: kwargs = { # ... 'foo': { 'bar1': '...', 'bar2': '...' } } Arbitrary nesting is allowed and results in a deeper dictionary structure. Nesting can also be used for presentation purposes. If the name of the :py:class:`NestedParameter` is empty, the nest is "anonymous" and does not mangle the child names. However, in the HTML layout, the nest will be presented as a logical group. buildbot-0.8.8/docs/developer/cls-iproperties.rst000066400000000000000000000014011222546025000221200ustar00rootroot00000000000000.. index:: single: Properties; IProperties IProperties =========== .. class:: buildbot.interfaces.IProperties:: Providers of this interface allow get and set access to a build's properties. .. method:: getProperty(propname, default=None) Get a named property, returning the default value if the property is not found. .. method:: hasProperty(propname) Determine whether the named property exists. .. method:: setProperty(propname, value, source) Set a property's value, also specifying the source for this value. .. method:: getProperties() Get a :class:`buildbot.process.properties.Properties` instance. The interface of this class is not finalized; where possible, use the other ``IProperties`` methods. buildbot-0.8.8/docs/developer/cls-irenderable.rst000066400000000000000000000007201222546025000220320ustar00rootroot00000000000000.. index:: single: Properties; IRenderable IRenderable =========== .. class:: buildbot.interfaces.IRenderable:: Providers of this class can be "rendered", based on available properties, when a build is started. .. method:: getRenderingFor(iprops) :param iprops: the :class:`~buildbot.interfaces.IProperties` provider supplying the properties of the build. Returns the interpretation of the given properties, optionally in a Deferred. buildbot-0.8.8/docs/developer/cls-remotecommands.rst000066400000000000000000000217251222546025000226030ustar00rootroot00000000000000RemoteCommands ============== .. py:currentmodule:: buildbot.process.buildstep Most of the action in build steps consists of performing operations on the slave. This is accomplished via :class:`RemoteCommand` and its subclasses. Each represents a single operation on the slave. Most data is returned to a command via updates. These updates are described in detail in :ref:`master-slave-updates`. RemoteCommand ~~~~~~~~~~~~~ .. py:class:: RemoteCommand(remote_command, args, collectStdout=False, ignore_updates=False, decodeRC=dict(0)) :param remote_command: command to run on the slave :type remote_command: string :param args: arguments to pass to the command :type args: dictionary :param collectStdout: if True, collect the command's stdout :param ignore_updates: true to ignore remote updates :param decodeRC: dictionary associating ``rc`` values to buildsteps results constants (e.g. ``SUCCESS``, ``FAILURE``, ``WARNINGS``) This class handles running commands, consisting of a command name and a dictionary of arguments. If true, ``ignore_updates`` will suppress any updates sent from the slave. This class handles updates for ``stdout``, ``stderr``, and ``header`` by appending them to a ``stdio`` logfile, if one is in use. It handles updates for ``rc`` by recording the value in its ``rc`` attribute. Most slave-side commands, even those which do not spawn a new process on the slave, generate logs and an ``rc``, requiring this class or one of its subclasses. See :ref:`master-slave-updates` for the updates that each command may send. .. py:attribute:: active True if the command is currently running .. py:method:: run(step, remote) :param step: the buildstep invoking this command :param remote: a reference to the remote :class:`SlaveBuilder` instance :returns: Deferred Run the command. Call this method to initiate the command; the returned Deferred will fire when the command is complete. The Deferred fires with the :class:`RemoteCommand` instance as its value. .. py:method:: interrupt(why) :param why: reason for interrupt :type why: Twisted Failure :returns: Deferred This method attempts to stop the running command early. The Deferred it returns will fire when the interrupt request is received by the slave; this may be a long time before the command itself completes, at which time the Deferred returned from :meth:`run` will fire. .. py:method:: results() :returns: results constant This method checks the ``rc`` against the decodeRC dictionary, and returns results constant .. py:method:: didFail() :returns: bool This method returns True if the results() function returns FAILURE The following methods are invoked from the slave. They should not be called directly. .. py:method:: remote_update(updates) :param updates: new information from the slave Handles updates from the slave on the running command. See :ref:`master-slave-updates` for the content of the updates. This class splits the updates out, and handles the ``ignore_updates`` option, then calls :meth:`remoteUpdate` to process the update. .. py:method:: remote_complete(failure=None) :param failure: the failure that caused the step to complete, or None for success Called by the slave to indicate that the command is complete. Normal completion (even with a nonzero ``rc``) will finish with no failure; if ``failure`` is set, then the step should finish with status :attr:`~buildbot.status.results.EXCEPTION`. These methods are hooks for subclasses to add functionality. .. py:method:: remoteUpdate(update) :param update: the update to handle Handle a single update. Subclasses must override this method. .. py:method:: remoteComplete(failure) :param failure: the failure that caused the step to complete, or None for success :returns: Deferred Handle command completion, performing any necessary cleanup. Subclasses should override this method. If ``failure`` is not None, it should be returned to ensure proper processing. .. py:attribute:: logs A dictionary of :class:`~buildbot.status.logfile.LogFile` instances representing active logs. Do not modify this directly -- use :meth:`useLog` instead. .. py:attribute:: rc Set to the return code of the command, after the command has completed. For compatibility with shell commands, 0 is taken to indicate success, while nonzero return codes indicate failure. .. py:attribute:: stdout If the ``collectStdout`` constructor argument is true, then this attribute will contain all data from stdout, as a single string. This is helpful when running informational commands (e.g., ``svnversion``), but is not appropriate for commands that will produce a large amount of output, as that output is held in memory. To set up logging, use :meth:`useLog` or :meth:`useLogDelayed` before starting the command: .. py:method:: useLog(log, closeWhenFinished=False, logfileName=None) :param log: the :class:`~buildbot.status.logfile.LogFile` instance to add to. :param closeWhenFinished: if true, call :meth:`~buildbot.status.logfile.LogFile.finish` when the command is finished. :param logfileName: the name of the logfile, as given to the slave. This is ``stdio`` for standard streams. Route log-related updates to the given logfile. Note that ``stdio`` is not included by default, and must be added explicitly. The ``logfileName`` must match the name given by the slave in any ``log`` updates. .. py:method:: useLogDelayed(log, logfileName, activateCallback, closeWhenFinished=False) :param log: the :class:`~buildbot.status.logfile.LogFile` instance to add to. :param logfileName: the name of the logfile, as given to the slave. This is ``stdio`` for standard streams. :param activateCallback: callback for when the log is added; see below :param closeWhenFinished: if true, call :meth:`~buildbot.status.logfile.LogFile.finish` when the command is finished. Similar to :meth:`useLog`, but the logfile is only actually added when an update arrives for it. The callback, ``activateCallback``, will be called with the :class:`~buildbot.process.buildstep.RemoteCommand` instance when the first update for the log is delivered. With that finished, run the command using the inherited :meth:`~buildbot.process.buildstep.RemoteCommand.run` method. During the run, you can inject data into the logfiles with any of these methods: .. py:method:: addStdout(data) :param data: data to add to the logfile Add stdout data to the ``stdio`` log. .. py:method:: addStderr(data) :param data: data to add to the logfile Add stderr data to the ``stdio`` log. .. py:method:: addHeader(data) :param data: data to add to the logfile Add header data to the ``stdio`` log. .. py:method:: addToLog(logname, data) :param logname: the logfile to receive the data :param data: data to add to the logfile Add data to a logfile other than ``stdio``. .. py:class:: RemoteShellCommand(workdir, command, env=None, want_stdout=True, want_stderr=True, timeout=20*60, maxTime=None, logfiles={}, usePTY="slave-config", logEnviron=True, collectStdio=False) :param workdir: directory in which command should be executed, relative to the builder's basedir. :param command: shell command to run :type command: string or list :param want_stdout: If false, then no updates will be sent for stdout. :param want_stderr: If false, then no updates will be sent for stderr. :param timeout: Maximum time without output before the command is killed. :param maxTime: Maximum overall time from the start before the command is killed. :param env: A dictionary of environment variables to augment or replace the existing environment on the slave. :param logfiles: Additional logfiles to request from the slave. :param usePTY: True to use a PTY, false to not use a PTY; the default value uses the default configured on the slave. :param logEnviron: If false, do not log the environment on the slave. :param collectStdout: If True, collect the command's stdout. Most of the constructor arguments are sent directly to the slave; see :ref:`shell-command-args` for the details of the formats. The ``collectStdout`` parameter is as described for the parent class. This class is used by the :bb:step:`ShellCommand` step, and by steps that run multiple customized shell commands. buildbot-0.8.8/docs/developer/config.rst000066400000000000000000000413161222546025000202520ustar00rootroot00000000000000Configuration ============= .. py:module:: buildbot.config Wherever possible, Buildbot components should access configuration information as needed from the canonical source, ``master.config``, which is an instance of :py:class:`MasterConfig`. For example, components should not keep a copy of the ``buildbotURL`` locally, as this value may change throughout the lifetime of the master. Components which need to be notified of changes in the configuration should be implemented as services, subclassing :py:class:`ReconfigurableServiceMixin`, as described in :ref:`developer-Reconfiguration`. .. py:class:: MasterConfig The master object makes much of the configuration available from an object named ``master.config``. Configuration is stored as attributes of this object. Where possible, other Buildbot components should access this configuration directly and not cache the configuration values anywhere else. This avoids the need to ensure that update-from-configuration methods are called on a reconfig. Aside from validating the configuration, this class handles any backward-compatibility issues - renamed parameters, type changes, and so on - removing those concerns from other parts of Buildbot. This class may be instantiated directly, creating an entirely default configuration, or via :py:meth:`loadConfig`, which will load the configuration from a config file. The following attributes are available from this class, representing the current configuration. This includes a number of global parameters: .. py:attribute:: title The title of this buildmaster, from :bb:cfg:`title`. .. py:attribute:: titleURL The URL corresponding to the title, from :bb:cfg:`titleURL`. .. py:attribute:: buildbotURL The URL of this buildmaster, for use in constructing WebStatus URLs; from :bb:cfg:`buildbotURL`. .. py:attribute:: changeHorizon The current change horizon, from :bb:cfg:`changeHorizon`. .. py:attribute:: eventHorizon The current event horizon, from :bb:cfg:`eventHorizon`. .. py:attribute:: logHorizon The current log horizon, from :bb:cfg:`logHorizon`. .. py:attribute:: buildHorizon The current build horizon, from :bb:cfg:`buildHorizon`. .. py:attribute:: logCompressionLimit The current log compression limit, from :bb:cfg:`logCompressionLimit`. .. py:attribute:: logCompressionMethod The current log compression method, from :bb:cfg:`logCompressionMethod`. .. py:attribute:: logMaxSize The current log maximum size, from :bb:cfg:`logMaxSize`. .. py:attribute:: logMaxTailSize The current log maximum size, from :bb:cfg:`logMaxTailSize`. .. py:attribute:: properties A :py:class:`~buildbot.process.properties.Properties` instance containing global properties, from :bb:cfg:`properties`. .. py:attribute:: mergeRequests A callable, or True or False, describing how to merge requests; from :bb:cfg:`mergeRequests`. .. py:attribute:: prioritizeBuilders A callable, or None, used to prioritize builders; from :bb:cfg:`prioritizeBuilders`. .. py:attribute:: codebaseGenerator A callable, or None, used to determine the codebase from an incoming :py:class:`~buildbot.changes.changes.Change`, from :bb:cfg:`codebaseGenerator` .. py:attribute:: slavePortnum The strports specification for the slave (integer inputs are normalized to a string), or None; based on :bb:cfg:`slavePortnum`. .. py:attribute:: multiMaster If true, then this master is part of a cluster; based on :bb:cfg:`multiMaster`. .. py:attribute:: debugPassword The password for the debug client, or None; from :bb:cfg:`debugPassword`. .. py:attribute:: manhole The manhole instance to use, or None; from :bb:cfg:`manhole`. The remaining attributes contain compound configuration structures, usually dictionaries: .. py:attribute:: validation Validation regular expressions, a dictionary from :bb:cfg:`validation`. It is safe to assume that all expected keys are present. .. py:attribute:: db Database specification, a dictionary with keys :bb:cfg:`db_url` and :bb:cfg:`db_poll_interval`. It is safe to assume that both keys are present. .. py:attribute:: metrics The metrics configuration from :bb:cfg:`metrics`, or an empty dictionary by default. .. py:attribute:: caches The cache configuration, from :bb:cfg:`caches` as well as the deprecated :bb:cfg:`buildCacheSize` and :bb:cfg:`changeCacheSize` parameters. The keys ``Builds`` and ``Caches`` are always available; other keys should use ``config.caches.get(cachename, 1)``. .. py:attribute:: schedulers The dictionary of scheduler instances, by name, from :bb:cfg:`schedulers`. .. py:attribute:: builders The list of :py:class:`BuilderConfig` instances from :bb:cfg:`builders`. Builders specified as dictionaries in the configuration file are converted to instances. .. py:attribute:: slaves The list of :py:class:`BuildSlave` instances from :bb:cfg:`slaves`. .. py:attribute:: change_sources The list of :py:class:`IChangeSource` providers from :bb:cfg:`change_source`. .. py:attribute:: status The list of :py:class:`IStatusReceiver` providers from :bb:cfg:`status`. .. py:attribute:: user_managers The list of user managers providers from :bb:cfg:`user_managers`. Loading of the configuration file is generally triggered by the master, using the following methods: .. py:classmethod:: loadConfig(basedir, filename) :param string basedir: directory to which config is relative :param string filename: the configuration file to load :raises: :py:exc:`ConfigErrors` if any errors occur :returns: new :py:class:`MasterConfig` instance Load the configuration in the given file. Aside from syntax errors, this will also detect a number of semantic errors such as multiple schedulers with the same name. The filename is treated as relative to the basedir, if it is not absolute. Builder Configuration --------------------- .. py:class:: BuilderConfig([keyword args]) This class parameterizes configuration of builders; see :ref:`Builder-Configuration` for its arguments. The constructor checks for errors and applies defaults, and sets the properties described here. Most are simply copied from the constructor argument of the same name. Users may subclass this class to add defaults, for example. .. py:attribute:: name The builder's name. .. py:attribute:: factory The builder's factory. .. py:attribute:: slavenames The builder's slave names (a list, regardless of whether the names were specified with ``slavename`` or ``slavenames``). .. py:attribute:: builddir The builder's builddir. .. py:attribute:: slavebuilddir The builder's slave-side builddir. .. py:attribute:: category The builder's category. .. py:attribute:: nextSlave The builder's nextSlave callable. .. py:attribute:: nextBuild The builder's nextBuild callable. .. py:attribute:: canStartBuild The builder's canStartBuild callable. .. py:attribute:: locks The builder's locks. .. py:attribute:: env The builder's environmnet variables. .. py:attribute:: properties The builder's properties, as a dictionary. .. py:attribute:: mergeRequests The builder's mergeRequests callable. .. py:attribute:: description The builder's description, displayed in the web status. Error Handling ============== If any errors are encountered while loading the configuration :py:func:`buildbot.config.error` should be called. This can occur both in the configuration-loading code, and in the constructors of any objects that are instantiated in the configuration - change sources, slaves, schedulers, build steps, and so on. .. py:function:: error(error) :param error: error to report :raises: :py:exc:`ConfigErrors` if called at build-time This function reports a configuration error. If a config file is being loaded, then the function merely records the error, and allows the rest of the configuration to be loaded. At any other time, it raises :py:exc:`ConfigErrors`. This is done so all config errors can be reported, rather than just the first. .. py:exception:: ConfigErrors([errors]) :param list errors: errors to report This exception represents errors in the configuration. It supports reporting multiple errors to the user simultaneously, e.g., when several consistency checks fail. .. py:attribute:: errors A list of detected errors, each given as a string. .. py:method:: addError(msg) :param string msg: the message to add Add another error message to the (presumably not-yet-raised) exception. .. _developer-Reconfiguration: Reconfiguration =============== When the buildmaster receives a signal to begin a reconfig, it re-reads the configuration file, generating a new :py:class:`MasterConfig` instance, and then notifies all of its child services via the reconfig mechanism described below. The master ensures that at most one reconfiguration is taking place at any time. See :ref:`master-service-hierarchy` for the structure of the Buildbot service tree. To simplify initialization, a reconfiguration is performed immediately on master startup. As a result, services only need to implement their configuration handling once, and can use ``startService`` for initialization. See below for instructions on implementing configuration of common types of components in Buildbot. .. note:: Because Buildbot uses a pure-Python configuration file, it is not possible to support all forms of reconfiguration. In particular, when the configuration includes custom subclasses or modules, reconfiguration can turn up some surprising behaviors due to the dynamic nature of Python. The reconfig support in Buildbot is intended for "intermediate" uses of the software, where there are fewer surprises. Reconfigurable Services ----------------------- Instances which need to be notified of a change in configuration should be implemented as Twisted services, and mix in the :py:class:`ReconfigurableServiceMixin` class, overriding the :py:meth:`~ReconfigurableServiceMixin.reconfigService` method. .. py:class:: ReconfigurableServiceMixin .. py:method:: reconfigService(new_config) :param new_config: new master configuration :type new_config: :py:class:`MasterConfig` :returns: Deferred This method notifies the service that it should make any changes necessary to adapt to the new configuration values given. This method will be called automatically after a service is started. It is generally too late at this point to roll back the reconfiguration, so if possible any errors should be detected in the :py:class:`MasterConfig` implementation. Errors are handled as best as possible and communicated back to the top level invocation, but such errors may leave the master in an inconsistent state. :py:exc:`ConfigErrors` exceptions will be displayed appropriately to the user on startup. Subclasses should always call the parent class's implementation. For :py:class:`MultiService` instances, this will call any child services' :py:meth:`reconfigService` methods, as appropriate. This will be done sequentially, such that the Deferred from one service must fire before the next service is reconfigured. .. py:attribute:: priority Child services are reconfigured in order of decreasing priority. The default priority is 128, so a service that must be reconfigured before others should be given a higher priority. Change Sources -------------- When reconfiguring, there is no method by which Buildbot can determine that a new :py:class:`~buildbot.changes.base.ChangeSource` represents the same source as an existing :py:class:`~buildbot.changes.base.ChangeSource`, but with different configuration parameters. As a result, the change source manager compares the lists of existing and new change sources using equality, stops any existing sources that are not in the new list, and starts any new change sources that do not already exist. :py:class:`~buildbot.changes.base.ChangeSource` inherits :py:class:`~buildbot.util.ComparableMixin`, so change sources are compared based on the attributes described in their ``compare_attrs``. If a change source does not make reference to any global configuration parameters, then there is no need to inherit :py:class:`ReconfigurableServiceMixin`, as a simple comparison and ``startService`` and ``stopService`` will be sufficient. If the change source does make reference to global values, e.g., as default values for its parameters, then it must inherit :py:class:`ReconfigurableServiceMixin` to support the case where the global values change. Schedulers ---------- Schedulers have names, so Buildbot can determine whether a scheduler has been added, removed, or changed during a reconfig. Old schedulers will be stopped, new schedulers will be started, and both new and existing schedulers will see a call to :py:meth:`~ReconfigurableServiceMixin.reconfigService`, if such a method exists. For backward compatibility, schedulers which do not support reconfiguration will be stopped, and the new scheduler started, when their configuration changes. If, during a reconfiguration, a new and old scheduler's fully qualified class names differ, then the old class will be stopped and the new class started. This supports the case when a user changes, for example, a Nightly scheduler to a Periodic scheduler without changing the name. Because Buildbot uses :py:class:`~buildbot.schedulers.base.BaseScheduler` instances directly in the configuration file, a reconfigured scheduler must extract its new configuration information from another instance of itself. :py:class:`~buildbot.schedulers.base.BaseScheduler` implements a helper method, :py:meth:`~buildbot.schedulers.base.BaseScheduler.findNewSchedulerInstance`, which will return the new instance of the scheduler in the given :py:class:`MasterConfig` object. Custom Subclasses ~~~~~~~~~~~~~~~~~ Custom subclasses are most often defined directly in the configuration file, or in a Python module that is reloaded with ``reload`` every time the configuration is loaded. Because of the dynamic nature of Python, this creates a new object representing the subclass every time the configuration is loaded -- even if the class definition has not changed. Note that if a scheduler's class changes in a reconfig, but the scheduler's name does not, it will still be treated as a reconfiguration of the existing scheduler. This means that implementation changes in custom scheduler subclasses will not be activated with a reconfig. This behavior avoids stopping and starting such schedulers on every reconfig, but can make development difficult. One workaround for this is to change the name of the scheduler before each reconfig - this will cause the old scheduler to be stopped, and the new scheduler (with the new name and class) to be started. Slaves ------ Similar to schedulers, slaves are specified by name, so new and old configurations are first compared by name, and any slaves to be added or removed are noted. Slaves for which the fully-qualified class name has changed are also added and removed. All slaves have their :py:meth:`~ReconfigurableServiceMixin.reconfigService` method called. This method takes care of the basic slave attributes, including changing the PB registration if necessary. Any subclasses that add configuration parameters should override :py:meth:`~ReconfigurableServiceMixin.reconfigService` and update those parameters. As with Schedulers, because the :py:class:`~buildbot.buildslave.AbstractBuildSlave` instance is given directly in the configuration, on reconfig instances must extract the configuration from a new instance. The :py:meth:`~buildbot.buildslave.AbstractBuildSlave.findNewSlaveInstance` method can be used to find the new instance. User Managers ------------- Since user managers are rarely used, and their purpose is unclear, they are always stopped and re-started on every reconfig. This may change in figure versions. Status Receivers ---------------- At every reconfig, all status listeners are stopped and new versions started. buildbot-0.8.8/docs/developer/database.rst000066400000000000000000001342641222546025000205560ustar00rootroot00000000000000.. _developer-database: Database ======== As of version 0.8.0, Buildbot has used a database as part of its storage backend. This section describes the database connector classes, which allow other parts of Buildbot to access the database. It also describes how to modify the database schema and the connector classes themselves. .. note:: Buildbot is only half-migrated to a database backend. Build and builder status information is still stored on disk in pickle files. This is difficult to fix, although work is underway. Database Overview ----------------- All access to the Buildbot database is mediated by database connector classes. These classes provide a functional, asynchronous interface to other parts of Buildbot, and encapsulate the database-specific details in a single location in the codebase. The connector API, defined below, is a stable API in Buildbot, and can be called from any other component. Given a master ``master``, the root of the database connectors is available at ``master.db``, so, for example, the state connector's ``getState`` method is ``master.db.state.getState``. The connectors all use `SQLAlchemy Core `_ to achieve (almost) database-independent operation. Note that the SQLAlchemy ORM is not used in Buildbot. Database queries are carried out in threads, and report their results back to the main thread via Twisted Deferreds. Schema ------ The database schema is maintained with `SQLAlchemy-Migrate `_. This package handles the details of upgrading users between different schema versions. The schema itself is considered an implementation detail, and may change significantly from version to version. Users should rely on the API (below), rather than performing queries against the database itself. API --- buildrequests ~~~~~~~~~~~~~ .. py:module:: buildbot.db.buildrequests .. index:: double: BuildRequests; DB Connector Component .. py:exception:: AlreadyClaimedError Raised when a build request is already claimed, usually by another master. .. py:exception:: NotClaimedError Raised when a build request is not claimed by this master. .. py:class:: BuildRequestsConnectorComponent This class handles the complex process of claiming and unclaiming build requests, based on a polling model: callers poll for unclaimed requests with :py:meth:`getBuildRequests`, then attempt to claim the requests with :py:meth:`claimBuildRequests`. The claim can fail if another master has claimed the request in the interim. An instance of this class is available at ``master.db.buildrequests``. .. index:: brdict, brid Build requests are indexed by an ID referred to as a *brid*. The contents of a request are represented as build request dictionaries (brdicts) with keys * ``brid`` * ``buildsetid`` * ``buildername`` * ``priority`` * ``claimed`` (boolean, true if the request is claimed) * ``claimed_at`` (datetime object, time this request was last claimed) * ``mine`` (boolean, true if the request is claimed by this master) * ``complete`` (boolean, true if the request is complete) * ``complete_at`` (datetime object, time this request was completed) .. py:method:: getBuildRequest(brid) :param brid: build request id to look up :returns: brdict or ``None``, via Deferred Get a single BuildRequest, in the format described above. This method returns ``None`` if there is no such buildrequest. Note that build requests are not cached, as the values in the database are not fixed. .. py:method:: getBuildRequests(buildername=None, complete=None, claimed=None, bsid=None, branch=None, repository=None)) :param buildername: limit results to buildrequests for this builder :type buildername: string :param complete: if true, limit to completed buildrequests; if false, limit to incomplete buildrequests; if ``None``, do not limit based on completion. :param claimed: see below :param bsid: see below :param repository: the repository associated with the sourcestamps originating the requests :param branch: the branch associated with the sourcestamps originating the requests :returns: list of brdicts, via Deferred Get a list of build requests matching the given characteristics. Pass all parameters as keyword parameters to allow future expansion. The ``claimed`` parameter can be ``None`` (the default) to ignore the claimed status of requests; ``True`` to return only claimed builds, ``False`` to return only unclaimed builds, or ``"mine"`` to return only builds claimed by this master instance. A request is considered unclaimed if its ``claimed_at`` column is either NULL or 0, and it is not complete. If ``bsid`` is specified, then only build requests for that buildset will be returned. A build is considered completed if its ``complete`` column is 1; the ``complete_at`` column is not consulted. .. py:method:: claimBuildRequests(brids[, claimed_at=XX]) :param brids: ids of buildrequests to claim :type brids: list :param datetime claimed_at: time at which the builds are claimed :returns: Deferred :raises: :py:exc:`AlreadyClaimedError` Try to "claim" the indicated build requests for this buildmaster instance. The resulting deferred will fire normally on success, or fail with :py:exc:`AlreadyClaimedError` if *any* of the build requests are already claimed by another master instance. In this case, none of the claims will take effect. If ``claimed_at`` is not given, then the current time will be used. As of 0.8.5, this method can no longer be used to re-claim build requests. All given ID's must be unclaimed. Use :py:meth:`reclaimBuildRequests` to reclaim. .. index:: single: MySQL; limitations .. index:: single: SQLite; limitations .. note:: On database backends that do not enforce referential integrity (e.g., SQLite), this method will not prevent claims for nonexistent build requests. On database backends that do not support transactions (MySQL), this method will not properly roll back any partial claims made before an :py:exc:`AlreadyClaimedError` is generated. .. py:method:: reclaimBuildRequests(brids) :param brids: ids of buildrequests to reclaim :type brids: list :returns: Deferred :raises: :py:exc:`AlreadyClaimedError` Re-claim the given build requests, updating the timestamp, but checking that the requests are owned by this master. The resulting deferred will fire normally on success, or fail with :py:exc:`AlreadyClaimedError` if *any* of the build requests are already claimed by another master instance, or don't exist. In this case, none of the reclaims will take effect. .. py:method:: unclaimBuildRequests(brids) :param brids: ids of buildrequests to unclaim :type brids: list :returns: Deferred Release this master's claim on all of the given build requests. This will not unclaim requests that are claimed by another master, but will not fail in this case. The method does not check whether a request is completed. .. py:method:: completeBuildRequests(brids, results[, complete_at=XX]) :param brids: build request IDs to complete :type brids: integer :param results: integer result code :type results: integer :param datetime complete_at: time at which the buildset was completed :returns: Deferred :raises: :py:exc:`NotClaimedError` Complete a set of build requests, all of which are owned by this master instance. This will fail with :py:exc:`NotClaimedError` if the build request is already completed or does not exist. If ``complete_at`` is not given, the current time will be used. .. py:method:: unclaimExpiredRequests(old) :param old: number of seconds after which a claim is considered old :type old: int :returns: Deferred Find any incomplete claimed builds which are older than ``old`` seconds, and clear their claim information. This is intended to catch builds that were claimed by a master which has since disappeared. As a side effect, it will log a message if any requests are unclaimed. builds ~~~~~~ .. py:module:: buildbot.db.builds .. index:: double: Builds; DB Connector Component .. py:class:: BuildsConnectorComponent This class handles a little bit of information about builds. .. note:: The interface for this class will change - the builds table duplicates some information available in pickles, without including all such information. Do not depend on this API. An instance of this class is available at ``master.db.builds``. .. index:: bdict, bid Builds are indexed by *bid* and their contents represented as *bdicts* (build dictionaries), with keys * ``bid`` (the build ID, globally unique) * ``number`` (the build number, unique only within this master and builder) * ``brid`` (the ID of the build request that caused this build) * ``start_time`` * ``finish_time`` (datetime objects, or None). .. py:method:: getBuild(bid) :param bid: build id :type bid: integer :returns: Build dictionary as above or ``None``, via Deferred Get a single build, in the format described above. Returns ``None`` if there is no such build. .. py:method:: getBuildsForRequest(brid) :param brids: list of build request ids :returns: List of build dictionaries as above, via Deferred Get a list of builds for the given build request. The resulting build dictionaries are in exactly the same format as for :py:meth:`getBuild`. .. py:method:: addBuild(brid, number) :param brid: build request id :param number: build number :returns: build ID via Deferred Add a new build to the db, recorded as having started at the current time. .. py:method:: finishBuilds(bids) :param bids: build ids :type bids: list :returns: Deferred Mark the given builds as finished, with ``finish_time`` set to the current time. This is done unconditionally, even if the builds are already finished. buildsets ~~~~~~~~~ .. py:module:: buildbot.db.buildsets .. index:: double: Buildsets; DB Connector Component .. py:class:: BuildsetsConnectorComponent This class handles getting buildsets into and out of the database. Buildsets combine multiple build requests that were triggered together. An instance of this class is available at ``master.db.buildsets``. .. index:: bsdict, bsid Buildsets are indexed by *bsid* and their contents represented as *bsdicts* (buildset dictionaries), with keys * ``bsid`` * ``external_idstring`` (arbitrary string for mapping builds externally) * ``reason`` (string; reason these builds were triggered) * ``sourcestampsetid`` (source stamp set for this buildset) * ``submitted_at`` (datetime object; time this buildset was created) * ``complete`` (boolean; true if all of the builds for this buildset are complete) * ``complete_at`` (datetime object; time this buildset was completed) * ``results`` (aggregate result of this buildset; see :ref:`Build-Result-Codes`) .. py:method:: addBuildset(sourcestampsetid, reason, properties, builderNames, external_idstring=None) :param sourcestampsetid: id of the SourceStampSet for this buildset :type sourcestampsetid: integer :param reason: reason for this buildset :type reason: short unicode string :param properties: properties for this buildset :type properties: dictionary, where values are tuples of (value, source) :param builderNames: builders specified by this buildset :type builderNames: list of strings :param external_idstring: external key to identify this buildset; defaults to None :type external_idstring: unicode string :returns: buildset ID and buildrequest IDs, via a Deferred Add a new Buildset to the database, along with BuildRequests for each named builder, returning the resulting bsid via a Deferred. Arguments should be specified by keyword. The return value is a tuple ``(bsid, brids)`` where ``bsid`` is the inserted buildset ID and ``brids`` is a dictionary mapping buildernames to build request IDs. .. py:method:: completeBuildset(bsid, results[, complete_at=XX]) :param bsid: buildset ID to complete :type bsid: integer :param results: integer result code :type results: integer :param datetime complete_at: time the buildset was completed :returns: Deferred :raises: :py:exc:`KeyError` if the buildset does not exist or is already complete Complete a buildset, marking it with the given ``results`` and setting its ``completed_at`` to the current time, if the ``complete_at`` argument is omitted. .. py:method:: getBuildset(bsid) :param bsid: buildset ID :returns: bsdict, or ``None``, via Deferred Get a bsdict representing the given buildset, or ``None`` if no such buildset exists. Note that buildsets are not cached, as the values in the database are not fixed. .. py:method:: getBuildsets(complete=None) :param complete: if true, return only complete buildsets; if false, return only incomplete buildsets; if ``None`` or omitted, return all buildsets :returns: list of bsdicts, via Deferred Get a list of bsdicts matching the given criteria. .. py:method:: getRecentBuildsets(count, branch=None, repository=None, complete=None): :param count: maximum number of buildsets to retrieve. :type branch: integer :param branch: optional branch name. If specified, only buildsets affecting such branch will be returned. :type branch: string :param repository: optional repository name. If specified, only buildsets affecting such repository will be returned. :type repository: string :param complete: if true, return only complete buildsets; if false, return only incomplete buildsets; if ``None`` or omitted, return all buildsets :type complete: Boolean :returns: list of bsdicts, via Deferred .. py:method:: getBuildsetProperties(buildsetid) :param buildsetid: buildset ID :returns: dictionary mapping property name to ``value, source``, via Deferred Return the properties for a buildset, in the same format they were given to :py:meth:`addBuildset`. Note that this method does not distinguish a nonexistent buildset from a buildset with no properties, and returns ``{}`` in either case. changes ~~~~~~~ .. py:module:: buildbot.db.changes .. index:: double: Changes; DB Connector Component .. py:class:: ChangesConnectorComponent This class handles changes in the buildbot database, including pulling information from the changes sub-tables. An instance of this class is available at ``master.db.changes``. .. index:: chdict, changeid Changes are indexed by *changeid*, and are represented by a *chdict*, which has the following keys: * ``changeid`` (the ID of this change) * ``author`` (unicode; the author of the change) * ``files`` (list of unicode; source-code filenames changed) * ``comments`` (unicode; user comments) * ``is_dir`` (deprecated) * ``links`` (list of unicode; links for this change, e.g., to web views, review) * ``revision`` (unicode string; revision for this change, or ``None`` if unknown) * ``when_timestamp`` (datetime instance; time of the change) * ``branch`` (unicode string; branch on which the change took place, or ``None`` for the "default branch", whatever that might mean) * ``category`` (unicode string; user-defined category of this change, or ``None``) * ``revlink`` (unicode string; link to a web view of this change) * ``properties`` (user-specified properties for this change, represented as a dictionary mapping keys to (value, source)) * ``repository`` (unicode string; repository where this change occurred) * ``project`` (unicode string; user-defined project to which this change corresponds) .. py:method:: addChange(author=None, files=None, comments=None, is_dir=0, links=None, revision=None, when_timestamp=None, branch=None, category=None, revlink='', properties={}, repository='', project='', uid=None) :param author: the author of this change :type author: unicode string :param files: a list of filenames that were changed :type branch: list of unicode strings :param comments: user comments on the change :type branch: unicode string :param is_dir: deprecated :param links: a list of links related to this change, e.g., to web viewers or review pages :type links: list of unicode strings :param revision: the revision identifier for this change :type revision: unicode string :param when_timestamp: when this change occurred, or the current time if None :type when_timestamp: datetime instance or None :param branch: the branch on which this change took place :type branch: unicode string :param category: category for this change (arbitrary use by Buildbot users) :type category: unicode string :param revlink: link to a web view of this revision :type revlink: unicode string :param properties: properties to set on this change, where values are tuples of (value, source). At the moment, the source must be ``'Change'``, although this may be relaxed in later versions. :type properties: dictionary :param repository: the repository in which this change took place :type repository: unicode string :param project: the project this change is a part of :type project: unicode string :param uid: uid generated for the change author :type uid: integer :returns: new change's ID via Deferred Add a Change with the given attributes to the database, returning the changeid via a Deferred. All arguments should be given as keyword arguments. The ``project`` and ``repository`` arguments must be strings; ``None`` is not allowed. .. py:method:: getChange(changeid, no_cache=False) :param changeid: the id of the change instance to fetch :param no_cache: bypass cache and always fetch from database :type no_cache: boolean :returns: chdict via Deferred Get a change dictionary for the given changeid, or ``None`` if no such change exists. .. py:method:: getChangeUids(changeid) :param changeid: the id of the change instance to fetch :returns: list of uids via Deferred Get the userids associated with the given changeid. .. py:method:: getRecentChanges(count) :param count: maximum number of instances to return :returns: list of dictionaries via Deferred, ordered by changeid Get a list of the ``count`` most recent changes, represented as dictionaries; returns fewer if that many do not exist. .. note:: For this function, "recent" is determined by the order of the changeids, not by ``when_timestamp``. This is most apparent in DVCS's, where the timestamp of a change may be significantly earlier than the time at which it is merged into a repository monitored by Buildbot. .. py:method:: getLatestChangeid() :returns: changeid via Deferred Get the most-recently-assigned changeid, or ``None`` if there are no changes at all. schedulers ~~~~~~~~~~ .. py:module:: buildbot.db.schedulers .. index:: double: Schedulers; DB Connector Component .. py:class:: SchedulersConnectorComponent This class manages the state of the Buildbot schedulers. This state includes classifications of as-yet un-built changes. An instance of this class is available at ``master.db.changes``. .. index:: objectid Schedulers are identified by a their objectid - see :py:class:`StateConnectorComponent`. .. py:method:: classifyChanges(objectid, classifications) :param objectid: scheduler classifying the changes :param classifications: mapping of changeid to boolean, where the boolean is true if the change is important, and false if it is unimportant :type classifications: dictionary :returns: Deferred Record the given classifications. This method allows a scheduler to record which changes were important and which were not immediately, even if the build based on those changes will not occur for some time (e.g., a tree stable timer). Schedulers should be careful to flush classifications once they are no longer needed, using :py:meth:`flushChangeClassifications`. .. py:method: flushChangeClassifications(objectid, less_than=None) :param objectid: scheduler owning the flushed changes :param less_than: (optional) lowest changeid that should *not* be flushed :returns: Deferred Flush all scheduler_changes for the given scheduler, limiting to those with changeid less than ``less_than`` if the parameter is supplied. .. py:method:: getChangeClassifications(objectid[, branch]) :param objectid: scheduler to look up changes for :type objectid: integer :param branch: (optional) limit to changes with this branch :type branch: string or None (for default branch) :returns: dictionary via Deferred Return the classifications made by this scheduler, in the form of a dictionary mapping changeid to a boolean, just as supplied to :py:meth:`classifyChanges`. If ``branch`` is specified, then only changes on that branch will be given. Note that specifying ``branch=None`` requests changes for the default branch, and is not the same as omitting the ``branch`` argument altogether. sourcestamps ~~~~~~~~~~~~ .. py:module:: buildbot.db.sourcestamps .. index:: double: SourceStamps; DB Connector Component .. py:class:: SourceStampsConnectorComponent This class manages source stamps, as stored in the database. Source stamps are linked to changes. Source stamps with the same sourcestampsetid belong to the same sourcestampset. Buildsets link to one or more source stamps via a sourcestampset id. An instance of this class is available at ``master.db.sourcestamps``. .. index:: ssid, ssdict Source stamps are identified by a *ssid*, and represented internally as a *ssdict*, with keys * ``ssid`` * ``sourcestampsetid`` (set to which the sourcestamp belongs) * ``branch`` (branch, or ``None`` for default branch) * ``revision`` (revision, or ``None`` to indicate the latest revision, in which case this is a relative source stamp) * ``patch_body`` (body of the patch, or ``None``) * ``patch_level`` (directory stripping level of the patch, or ``None``) * ``patch_subdir`` (subdirectory in which to apply the patch, or ``None``) * ``patch_author`` (author of the patch, or ``None``) * ``patch_comment`` (comment for the patch, or ``None``) * ``repository`` (repository containing the source; never ``None``) * ``project`` (project this source is for; never ``None``) * ``changeids`` (list of changes, by id, that generated this sourcestamp) .. note:: Presently, no attempt is made to ensure uniqueness of source stamps, so multiple ssids may correspond to the same source stamp. This may be fixed in a future version. .. py:method:: addSourceStamp(branch, revision, repository, project, patch_body=None, patch_level=0, patch_author="", patch_comment="", patch_subdir=None, changeids=[]) :param branch: :type branch: unicode string :param revision: :type revision: unicode string :param repository: :type repository: unicode string :param project: :type project: string :param patch_body: (optional) :type patch_body: string :param patch_level: (optional) :type patch_level: int :param patch_author: (optional) :type patch_author: unicode string :param patch_comment: (optional) :type patch_comment: unicode string :param patch_subdir: (optional) :type patch_subdir: unicode string :param changeids: :type changeids: list of ints :returns: ssid, via Deferred Create a new SourceStamp instance with the given attributes, and return its ssid. The arguments all have the same meaning as in an ssdict. Pass them as keyword arguments to allow for future expansion. .. py:method:: getSourceStamp(ssid) :param ssid: sourcestamp to get :param no_cache: bypass cache and always fetch from database :type no_cache: boolean :returns: ssdict, or ``None``, via Deferred Get an ssdict representing the given source stamp, or ``None`` if no such source stamp exists. .. py:method:: getSourceStamps(sourcestampsetid) :param sourcestampsetid: identification of the set, all returned sourcestamps belong to this set :type sourcestampsetid: integer :returns: sslist of ssdict Get a set of sourcestamps identified by a set id. The set is returned as a sslist that contains one or more sourcestamps (represented as ssdicts). The list is empty if the set does not exist or no sourcestamps belong to the set. sourcestampset ~~~~~~~~~~~~~~ .. py:module:: buildbot.db.sourcestampsets .. index:: double: SourceStampSets; DB Connector Component .. py:class:: SourceStampSetsConnectorComponent This class is responsible for adding new sourcestampsets to the database. Build sets link to sourcestamp sets, via their (set) id's. An instance of this class is available at ``master.db.sourcestampsets``. Sourcestamp sets are identified by a sourcestampsetid. .. py:method:: addSourceStampSet() :returns: new sourcestampsetid as integer, via Deferred Add a new (empty) sourcestampset to the database. The unique identification of the set is returned as integer. The new id can be used to add new sourcestamps to the database and as reference in a buildset. state ~~~~~ .. py:module:: buildbot.db.state .. index:: double: State; DB Connector Component .. py:class:: StateConnectorComponent This class handles maintaining arbitrary key/value state for Buildbot objects. Each object can store arbitrary key/value pairs, where the values are any JSON-encodable value. Each pair can be set and retrieved atomically. Objects are identified by their (user-visible) name and their class. This allows, for example, a ``nightly_smoketest`` object of class ``NightlyScheduler`` to maintain its state even if it moves between masters, but avoids cross-contaminating state between different classes of objects with the same name. Note that "class" is not interpreted literally, and can be any string that will uniquely identify the class for the object; if classes are renamed, they can continue to use the old names. An instance of this class is available at ``master.db.state``. .. index:: objectid, objdict Objects are identified by *objectid*. .. py:method:: getObjectId(name, class_name) :param name: name of the object :param class_name: object class name :returns: the objectid, via a Deferred. Get the object ID for this combination of a name and a class. This will add a row to the 'objects' table if none exists already. .. py:method:: getState(objectid, name[, default]) :param objectid: objectid on which the state should be checked :param name: name of the value to retrieve :param default: (optional) value to return if C{name} is not present :returns: state value via a Deferred :raises KeyError: if ``name`` is not present and no default is given :raises: TypeError if JSON parsing fails Get the state value for key ``name`` for the object with id ``objectid``. .. py:method:: setState(objectid, name, value) :param objectid: the objectid for which the state should be changed :param name: the name of the value to change :param value: the value to set :type value: JSON-able value :param returns: Deferred :raises: TypeError if JSONification fails Set the state value for ``name`` for the object with id ``objectid``, overwriting any existing value. users ~~~~~ .. py:module:: buildbot.db.users .. index:: double: Users; DB Connector Component .. py:class:: UsersConnectorComponent This class handles Buildbot's notion of users. Buildbot tracks the usual information about users -- username and password, plus a display name. The more complicated task is to recognize each user across multiple interfaces with Buildbot. For example, a user may be identified as 'djmitche' in Subversion, 'dustin@v.igoro.us' in Git, and 'dustin' on IRC. To support this functionality, each user as a set of attributes, keyed by type. The :py:meth:`findUserByAttr` method uses these attributes to match users, adding a new user if no matching user is found. Users are identified canonically by *uid*, and are represented by *usdicts* (user dictionaries) with keys * ``uid`` * ``identifier`` (display name for the user) * ``bb_username`` (buildbot login username) * ``bb_password`` (hashed login password) All attributes are also included in the dictionary, keyed by type. Types colliding with the keys above are ignored. .. py:method:: findUserByAttr(identifier, attr_type, attr_data) :param identifier: identifier to use for a new user :param attr_type: attribute type to search for and/or add :param attr_data: attribute data to add :returns: userid via Deferred Get an existing user, or add a new one, based on the given attribute. This method is intended for use by other components of Buildbot to search for a user with the given attributes. Note that ``identifier`` is *not* used in the search for an existing user. It is only used when creating a new user. The identifier should be based deterministically on the attributes supplied, in some fashion that will seem natural to users. For future compatibility, always use keyword parameters to call this method. .. py:method:: getUser(uid) :param uid: user id to look up :type key: int :param no_cache: bypass cache and always fetch from database :type no_cache: boolean :returns: usdict via Deferred Get a usdict for the given user, or ``None`` if no matching user is found. .. py:method:: getUserByUsername(username) :param username: username portion of user credentials :type username: string :returns: usdict or None via deferred Looks up the user with the bb_username, returning the usdict or ``None`` if no matching user is found. .. py:method:: getUsers() :returns: list of partial usdicts via Deferred Get the entire list of users. User attributes are not included, so the results are not full userdicts. .. py:method:: updateUser(uid=None, identifier=None, bb_username=None, bb_password=None, attr_type=None, attr_data=None) :param uid: the user to change :type uid: int :param identifier: (optional) new identifier for this user :type identifier: string :param bb_username: (optional) new buildbot username :type bb_username: string :param bb_password: (optional) new hashed buildbot password :type bb_password: string :param attr_type: (optional) attribute type to update :type attr_type: string :param attr_data: (optional) value for ``attr_type`` :type attr_data: string :returns: Deferred Update information about the given user. Only the specified attributes are updated. If no user with the given uid exists, the method will return silently. Note that ``bb_password`` must be given if ``bb_username`` appears; similarly, ``attr_type`` requires ``attr_data``. .. py:method:: removeUser(uid) :param uid: the user to remove :type uid: int :returns: Deferred Remove the user with the given uid from the database. This will remove the user from any associated tables as well. .. py:method:: identifierToUid(identifier) :param identifier: identifier to search for :type identifier: string :returns: uid or ``None``, via Deferred Fetch a uid for the given identifier, if one exists. Writing Database Connector Methods ---------------------------------- The information above is intended for developers working on the rest of Buildbot, and treating the database layer as an abstraction. The remainder of this section describes the internals of the database implementation, and is intended for developers modifying the schema or adding new methods to the database layer. .. warning:: It's difficult to change the database schema significantly after it has been released, and very disruptive to users to change the database API. Consider very carefully the future-proofing of any changes here! The DB Connector and Components ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. py:module:: buildbot.db.connector .. py:class:: DBConnector The root of the database connectors, ``master.db``, is a :class:`~buildbot.db.connector.DBConnector` instance. Its main purpose is to hold reference to each of the connector components, but it also handles timed cleanup tasks. If you are adding a new connector component, import its module and create an instance of it in this class's constructor. .. py:module:: buildbot.db.base .. py:class:: DBConnectorComponent This is the base class for connector components. There should be no need to override the constructor defined by this base class. .. py:attribute:: db A reference to the :class:`~buildbot.db.connector.DBConnector`, so that connector components can use e.g., ``self.db.pool`` or ``self.db.model``. In the unusual case that a connector component needs access to the master, the easiest path is ``self.db.master``. Direct Database Access ~~~~~~~~~~~~~~~~~~~~~~ .. py:module:: buildbot.db.pool The connectors all use `SQLAlchemy Core `_ as a wrapper around database client drivers. Unfortunately, SQLAlchemy is a synchronous library, so some extra work is required to use it in an asynchronous context like Buildbot. This is accomplished by deferring all database operations to threads, and returning a Deferred. The :class:`~buildbot.db.pool.Pool` class takes care of the details. A connector method should look like this:: def myMethod(self, arg1, arg2): def thd(conn): q = ... # construct a query for row in conn.execute(q): ... # do something with the results return ... # return an interesting value return self.db.pool.do(thd) Picking that apart, the body of the method defines a function named ``thd`` taking one argument, a :class:`Connection ` object. It then calls ``self.db.pool.do``, passing the ``thd`` function. This function is called in a thread, and can make blocking calls to SQLAlchemy as desired. The ``do`` method will return a Deferred that will fire with the return value of ``thd``, or with a failure representing any exceptions raised by ``thd``. The return value of ``thd`` must not be an SQLAlchemy object - in particular, any :class:`ResultProxy ` objects must be parsed into lists or other data structures before they are returned. .. warning:: As the name ``thd`` indicates, the function runs in a thread. It should not interact with any other part of Buildbot, nor with any of the Twisted components that expect to be accessed from the main thread -- the reactor, Deferreds, etc. Queries can be constructed using any of the SQLAlchemy core methods, using tables from :class:`~buildbot.db.model.Model`, and executed with the connection object, ``conn``. .. py:class:: DBThreadPool .. py:method:: do(callable, ...) :returns: Deferred Call ``callable`` in a thread, with a :class:`Connection ` object as first argument. Returns a deferred that will fire with the results of the callable, or with a failure representing any exception raised during its execution. Any additional positional or keyword arguments are passed to ``callable``. .. py:method:: do_with_engine(callable, ...) :returns: Deferred Similar to :meth:`do`, call ``callable`` in a thread, but with an :class:`Engine ` object as first argument. This method is only used for schema manipulation, and should not be used in a running master. Database Schema ~~~~~~~~~~~~~~~ .. py:module:: buildbot.db.model Database connector methods access the database through SQLAlchemy, which requires access to Python objects representing the database tables. That is handled through the model. .. py:class:: Model This class contains the canonical description of the buildbot schema, It is presented in the form of SQLAlchemy :class:`Table ` instances, as class variables. At runtime, the model is available at ``master.db.model``, so for example the ``buildrequests`` table can be referred to as ``master.db.model.buildrequests``, and columns are available in its ``c`` attribute. The source file, :bb:src:`master/buildbot/db/model.py`, contains comments describing each table; that information is not replicated in this documentation. Note that the model is not used for new installations or upgrades of the Buildbot database. See :ref:`Modifying-the-Database-Schema` for more information. .. py:attribute:: metadata The model object also has a ``metadata`` attribute containing a :class:`MetaData ` instance. Connector methods should not need to access this object. The metadata is not bound to an engine. The :py:class:`Model` class also defines some migration-related methods: .. py:method:: is_current() :returns: boolean via Deferred Returns true if the current database's version is current. .. py:method:: upgrade() :returns: Deferred Upgrades the database to the most recent schema version. Caching ~~~~~~~ .. py:currentmodule:: buildbot.db.base Connector component methods that get an object based on an ID are good candidates for caching. The :func:`~buildbot.db.base.cached` decorator makes this automatic: .. py:function:: cached(cachename) :param cache_name: name of the cache to use A decorator for "getter" functions that fetch an object from the database based on a single key. The wrapped method will only be called if the named cache does not contain the key. The wrapped function must take one argument (the key); the wrapper will take a key plus an optional ``no_cache`` argument which, if true, will cause it to invoke the underlying method even if the key is in the cache. The resulting method will have a ``cache`` attribute which can be used to access the underlying cache. In most cases, getter methods return a well-defined dictionary. Unfortunately, Python does not handle weak references to bare dictionaries, so components must instantiate a subclass of ``dict``. The whole assembly looks something like this:: class ThDict(dict): pass class ThingConnectorComponent(base.DBConnectorComponent): @base.cached('thdicts') def getThing(self, thid): def thd(conn): ... thdict = ThDict(thid=thid, attr=row.attr, ...) return thdict return self.db.pool.do(thd) Tests ~~~~~ It goes without saying that any new connector methods must be fully tested! You will also want to add an in-memory implementation of the methods to the fake classes in ``master/buildbot/test/fake/fakedb.py``. Non-DB Buildbot code is tested using these fake implementations in order to isolate that code from the database code. .. _Modifying-the-Database-Schema: Modifying the Database Schema ----------------------------- Changes to the schema are accomplished through migration scripts, supported by `SQLAlchemy-Migrate `_. In fact, even new databases are created with the migration scripts -- a new database is a migrated version of an empty database. The schema is tracked by a version number, stored in the ``migrate_version`` table. This number is incremented for each change to the schema, and used to determine whether the database must be upgraded. The master will refuse to run with an out-of-date database. To make a change to the schema, first consider how to handle any existing data. When adding new columns, this may not be necessary, but table refactorings can be complex and require caution so as not to lose information. Create a new script in :bb:src:`master/buildbot/db/migrate/versions`, following the numbering scheme already present. The script should have an ``update`` method, which takes an engine as a parameter, and upgrades the database, both changing the schema and performing any required data migrations. The engine passed to this parameter is "enhanced" by SQLAlchemy-Migrate, with methods to handle adding, altering, and dropping columns. See the SQLAlchemy-Migrate documentation for details. Next, modify :bb:src:`master/buildbot/db/model.py` to represent the updated schema. Buildbot's automated tests perform a rudimentary comparison of an upgraded database with the model, but it is important to check the details - key length, nullability, and so on can sometimes be missed by the checks. If the schema and the upgrade scripts get out of sync, bizarre behavior can result. Also, adjust the fake database table definitions in :bb:src:`master/buildbot/test/fake/fakedb.py` according to your changes. Your upgrade script should have unit tests. The classes in :bb:src:`master/buildbot/test/util/migration.py` make this straightforward. Unit test scripts should be named e.g., :file:`test_db_migrate_versions_015_remove_bad_master_objectid.py`. The :file:`master/buildbot/test/integration/test_upgrade.py` also tests upgrades, and will confirm that the resulting database matches the model. If you encounter implicit indexes on MySQL, that do not appear on SQLite or Postgres, add them to ``implied_indexes`` in :file:`master/buidlbot/db/model.py`. Database Compatibility Notes ---------------------------- Or: "If you thought any database worked right, think again" Because Buildbot works over a wide range of databases, it is generally limited to database features present in all supported backends. This section highlights a few things to watch out for. In general, Buildbot should be functional on all supported database backends. If use of a backend adds minor usage restrictions, or cannot implement some kinds of error checking, that is acceptable if the restrictions are well-documented in the manual. The metabuildbot tests Buildbot against all supported databases, so most compatibility errors will be caught before a release. Index Length in MySQL ~~~~~~~~~~~~~~~~~~~~~ .. index:: single: MySQL; limitations MySQL only supports about 330-character indexes. The actual index length is 1000 bytes, but MySQL uses 3-byte encoding for UTF8 strings. This is a longstanding bug in MySQL - see `"Specified key was too long; max key length is 1000 bytes" with utf8 `_. While this makes sense for indexes used for record lookup, it limits the ability to use unique indexes to prevent duplicate rows. InnoDB has even more severe restrictions on key lengths, which is why the MySQL implementation requires a MyISAM storage engine. Transactions in MySQL ~~~~~~~~~~~~~~~~~~~~~ .. index:: single: MySQL; limitations Unfortunately, use of the MyISAM storage engine precludes real transactions in MySQL. ``transaction.commit()`` and ``transaction.rollback()`` are essentially no-ops: modifications to data in the database are visible to other users immediately, and are not reverted in a rollback. Referential Integrity in SQLite and MySQL ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. index:: single: SQLite; limitations .. index:: single: MySQL; limitations Neither MySQL nor SQLite enforce referential integrity based on foreign keys. Postgres does enforce, however. If possible, test your changes on Postgres before committing, to check that tables are added and removed in the proper order. Subqueries in MySQL ~~~~~~~~~~~~~~~~~~~ .. index:: single: MySQL; limitations MySQL's query planner is easily confused by subqueries. For example, a DELETE query specifying id's that are IN a subquery will not work. The workaround is to run the subquery directly, and then execute a DELETE query for each returned id. If this weakness has a significant performance impact, it would be acceptable to conditionalize use of the subquery on the database dialect. buildbot-0.8.8/docs/developer/definitions.rst000066400000000000000000000024611222546025000213160ustar00rootroot00000000000000Definitions =========== Buildbot uses some terms and concepts that have specific meanings. Repository ---------- See :ref:`Attr-Repository`. Project ------- See :ref:`Attr-Project`. Version Control Comparison -------------------------- Buildbot supports a number of version control systems, and they don't all agree on their terms. This table should help to disambiguate them. =========== =========== =========== =================== Name Change Revision Branches =========== =========== =========== =================== CVS patch [1] timestamp unnamed Subversion revision integer directories Git commit sha1 hash named refs Mercurial changeset sha1 hash different repos or (permanently) named commits Darcs ? none [2] different repos Bazaar ? ? ? Perforce ? ? ? BitKeeper changeset ? different repos =========== =========== =========== =================== * [1] note that CVS only tracks patches to individual files. Buildbot tries to recognize coordinated changes to multiple files by correlating change times. * [2] Darcs does not have a concise way of representing a particular revision of the source. buildbot-0.8.8/docs/developer/encodings.rst000066400000000000000000000024301222546025000207500ustar00rootroot00000000000000String Encodings ~~~~~~~~~~~~~~~~ Buildbot expects all strings used internally to be valid Unicode strings - not bytestrings. Note that Buildbot rarely feeds strings back into external tools in such a way that those strings must match. For example, Buildbot does not attempt to access the filenames specified in a Change. So it is more important to store strings in a manner that will be most useful to a human reader (e.g., in logfiles, web status, etc.) than to store them in a lossless format. Inputs ++++++ On input, strings should be decoded, if their encoding is known. Where necessary, the assumed input encoding should be configurable. In some cases, such as filenames, this encoding is not known or not well-defined (e.g., a utf-8 encoded filename in a latin-1 directory). In these cases, the input mechanisms should make a best effort at decoding, and use e.g., the ``errors='replace'`` option to fail gracefully on un-decodable characters. Outputs +++++++ At most points where Buildbot outputs a string, the target encoding is known. For example, the web status can encode to utf-8. In cases where it is not known, it should be configurable, with a safe fallback (e.g., ascii with ``errors='replace'``. For HTML/XML outputs, consider using ``errors='xmlcharrefreplace'`` instead. buildbot-0.8.8/docs/developer/formats.rst000066400000000000000000000010651222546025000204550ustar00rootroot00000000000000File Formats ============ Log File Format --------------- .. py:class:: buildbot.status.logfile.LogFile The master currently stores each logfile in a single file, which may have a standard compression applied. The format is a special case of the netstrings protocol - see http://cr.yp.to/proto/netstrings.txt. The text in each netstring consists of a one-digit channel identifier followed by the data from that channel. The formatting is implemented in the LogFile class in :file:`buildbot/status/logfile.py`, and in particular by the :meth:`merge` method. buildbot-0.8.8/docs/developer/index.rst000066400000000000000000000010141222546025000201030ustar00rootroot00000000000000.. _Buildbot Development: Buildbot Development ==================== This chapter is the official repository for the collected wisdom of the Buildbot hackers. It is intended both for developers writing patches that will be included in Buildbot itself, and for advanced users who wish to customize Buildbot. .. toctree:: :maxdepth: 2 master-overview definitions style tests config utils database results formats webstatus master-slave encodings metrics classes buildbot-0.8.8/docs/developer/master-overview.rst000066400000000000000000000057301222546025000221440ustar00rootroot00000000000000.. _master-service-hierarchy: Master Organization =================== Buildbot makes heavy use of Twisted Python's support for services - software modules that can be started and stopped dynamically. Buildbot adds the ability to reconfigure such services, too - see :ref:`developer-reconfiguration`. Twisted arranges services into trees; the following section describes the service tree on a running master. Buildmaster Service Hierarchy ----------------------------- The hierarchy begins with the master, a :py:class:`buildbot.master.BuildMaster` instance. Most other services contain a reference to this object in their ``master`` attribute, and in general the appropriate way to access other objects or services is to begin with ``self.master`` and navigate from there. The master has several child services: ``master.metrics`` A :py:class:`buildbot.process.metrics.MetricLogObserver` instance that handles tracking and reporting on master metrics. ``master.caches`` A :py:class:`buildbot.process.caches.CacheManager` instance that provides access to object caches. ``master.pbmanager`` A :py:class:`buildbot.pbmanager.PBManager` instance that handles incoming PB connections, potentially on multiple ports, and dispatching those connections to appropriate components based on the supplied username. ``master.change_svc`` A :py:class:`buildbot.changes.manager.ChangeManager` instance that manages the active change sources, as well as the stream of changes received from those sources. All active change sources are child services of this instance. ``master.botmaster`` A :py:class:`buildbot.process.botmaster.BotMaster` instance that manages all of the slaves and builders as child services. The botmaster acts as the parent service for a :py:class:`buildbot.process.botmaster.BuildRequestDistributor` instance (at ``master.botmaster.brd``) as well as all active slaves (:py:class:`buildbot.buildslave.AbstractBuildSlave` instances) and builders (:py:class:`buildbot.process.builder.Builder` instances). ``master.scheduler_manager`` A :py:class:`buildbot.schedulers.manager.SchedulerManager` instance that manages the active schedulers. All active schedulers are child services of this instance. ``master.user_manager`` A :py:class:`buildbot.process.users.manager.UserManagerManager` instance that manages access to users. All active user managers are child services of this instance. ``master.db`` A :py:class:`buildbot.db.connector.DBConnector` instance that manages access to the buildbot database. See :ref:`developer-database` for more information. ``master.debug`` A :py:class:`buildbot.process.debug.DebugServices` instance that manages debugging-related access -- the debug client and manhole. ``master.status`` A :py:class:`buildbot.status.master.Status` instance that provides access to all status data. This instance is also the service parent for all status listeners. buildbot-0.8.8/docs/developer/master-slave.rst000066400000000000000000000330641222546025000214110ustar00rootroot00000000000000Master-Slave API ================ This section describes the master-slave interface. Connection ---------- The interface is based on Twisted's Perspective Broker, which operates over TCP connections. The slave connects to the master, using the parameters supplied to :command:`buildslave create-slave`. It uses a reconnecting process with an exponential backoff, and will automatically reconnect on disconnection. Once connected, the slave authenticates with the Twisted Cred (newcred) mechanism, using the username and password supplied to :command:`buildslave create-slave`. The *mind* is the slave bot instance (class :class:`buildslave.bot.Bot`). On the master side, the realm is implemented by :class:`buildbot.master.Dispatcher`, which examines the username of incoming avatar requests. There are special cases for ``change``, ``debug``, and ``statusClient``, which are not discussed here. For all other usernames, the botmaster is consulted, and if a slave with that name is configured, its :class:`buildbot.buildslave.BuildSlave` instance is returned as the perspective. Build Slaves ------------ At this point, the master-side BuildSlave object has a pointer to the remote, slave-side Bot object in its ``self.slave``, and the slave-side Bot object has a reference to the master-side BuildSlave object in its ``self.perspective``. Bot methods ~~~~~~~~~~~ The slave-side Bot object has the following remote methods: :meth:`~buildslave.bot.Bot.remote_getCommands` Returns a list of ``(name, version)`` for all commands the slave recognizes :meth:`~buildslave.bot.Bot.remote_setBuilderList` Given a list of builders and their build directories, ensures that those builders, and only those builders, are running. This can be called after the initial connection is established, with a new list, to add or remove builders. This method returns a dictionary of :class:`SlaveBuilder` objects - see below :meth:`~buildslave.bot.Bot.remote_print` Adds a message to the slave logfile :meth:`~buildslave.bot.Bot.remote_getSlaveInfo` Returns the contents of the slave's :file:`info/` directory. This also contains the keys ``environ`` copy of the slaves environment ``system`` OS the slave is running (extracted from Python's os.name) ``basedir`` base directory where slave is running :meth:`~buildslave.bot.Bot.remote_getVersion` Returns the slave's version BuildSlave methods ~~~~~~~~~~~~~~~~~~ The master-side object has the following method: :meth:`~buildbot.buildslave.BuildSlave.perspective_keepalive` Does nothing - used to keep traffic flowing over the TCP connection Setup ----- After the initial connection and trading of a mind (Bot) for an avatar (BuildSlave), the master calls the Bot's :meth:`setBuilderList` method to set up the proper slave builders on the slave side. This method returns a reference to each of the new slave-side :class:`~buildslave.bot.SlaveBuilder` objects, described below. Each of these is handed to the corresponding master-side :class:`~buildbot.process.slavebuilder.SlaveBuilder` object. This immediately calls the remote :meth:`setMaster` method, then the :meth:`print` method. Pinging ------- To ping a remote SlaveBuilder, the master calls its :meth:`print` method. Building -------- When a build starts, the master calls the slave's :meth:`startBuild` method. Each BuildStep instance will subsequently call the :meth:`startCommand` method, passing a reference to itself as the ``stepRef`` parameter. The :meth:`startCommand` method returns immediately, and the end of the command is signalled with a call to a method on the master-side BuildStep object. Slave Builders -------------- Each build slave has a set of builders which can run on it. These are represented by distinct classes on the master and slave, just like the BuildSlave and Bot objects described above. On the slave side, builders are represented as instances of the :class:`buildslave.bot.SlaveBuilder` class. On the master side, they are represented by the :class:`buildbot.process.slavebuilder.SlaveBuilder` class. The identical names are a source of confusion. The following will refer to these as the slave-side and master-side SlaveBuilder classes. Each object keeps a reference to its opposite in ``self.remote``. Slave-Side SlaveBuilder Methods ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ :meth:`~buildslave.bot.SlaveBuilder.remote_setMaster` Provides a reference to the master-side SlaveBuilder :meth:`~buildslave.bot.SlaveBuilder.remote_print` Adds a message to the slave logfile; used to check round-trip connectivity :meth:`~buildslave.bot.SlaveBuilder.remote_startBuild` Indicates that a build is about to start, and that any subsequent commands are part of that build :meth:`~buildslave.bot.SlaveBuilder.remote_startCommand` Invokes a command on the slave side :meth:`~buildslave.bot.SlaveBuilder.remote_interruptCommand` Interrupts the currently-running command :meth:`~buildslave.bot.SlaveBuilder.remote_shutdown` Shuts down the slave cleanly Master-side SlaveBuilder Methods ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The master side does not have any remotely-callable methods. Commands -------- Actual work done by the slave is represented on the master side by a :class:`buildbot.process.buildstep.RemoteCommand` instance. The command instance keeps a reference to the slave-side :class:`buildslave.bot.SlaveBuilder`, and calls methods like :meth:`~buildslave.bot.SlaveBuilder.remote_startCommand` to start new commands. Once that method is called, the :class:`~buildslave.bot.SlaveBuilder` instance keeps a reference to the command, and calls the following methods on it: Master-Side RemoteCommand Methods ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ :meth:`~buildbot.process.buildstep.RemoteCommand.remote_update` Update information about the running command. See below for the format. :meth:`~buildbot.process.buildstep.RemoteCommand.remote_complete` Signal that the command is complete, either successfully or with a Twisted failure. .. _master-slave-updates: Updates ------- Updates from the slave, sent via :meth:`~buildbot.process.buildstep.RemoteCommand.remote_update`, are a list of individual update elements. Each update element is, in turn, a list of the form ``[data, 0]`` where the 0 is present for historical reasons. The data is a dictionary, with keys describing the contents. The updates are handled by :meth:`~buildbot.process.buildstep.RemoteCommand.remoteUpdate`. Updates with different keys can be combined into a single dictionary or delivered sequentially as list elements, at the slave's option. To summarize, an ``updates`` parameter to :meth:`~buildbot.process.buildstep.RemoteCommand.remote_update` might look like this:: [ [ { 'header' : 'running command..' }, 0 ], [ { 'stdout' : 'abcd', 'stderr' : 'local modifications' }, 0 ], [ { 'log' : ( 'cmd.log', 'cmd invoked at 12:33 pm\n' ) }, 0 ], [ { 'rc' : 0 }, 0 ], ] Defined Commands ~~~~~~~~~~~~~~~~ The following commands are defined on the slaves. .. _shell-command-args: shell ..... Runs a shell command on the slave. This command takes the following arguments: ``command`` The command to run. If this is a string, will be passed to the system shell as a string. Otherwise, it must be a list, which will be executed directly. ``workdir`` Directory in which to run the command, relative to the builder dir. ``env`` A dictionary of environment variables to augment or replace the existing environment on the slave. In this dictionary, ``PYTHONPATH`` is treated specially: it should be a list of path components, rather than a string, and will be prepended to the existing Python path. ``initial_stdin`` A string which will be written to the command's standard input before it is closed. ``want_stdout`` If false, then no updates will be sent for stdout. ``want_stderr`` If false, then no updates will be sent for stderr. ``usePTY`` If true, the command should be run with a PTY (POSIX only). This defaults to the value specified in the slave's ``buildbot.tac``. ``not_really`` If true, skip execution and return an update with rc=0. ``timeout`` Maximum time without output before the command is killed. ``maxTime`` Maximum overall time from the start before the command is killed. ``logfiles`` A dictionary specifying logfiles other than stdio. Keys are the logfile names, and values give the workdir-relative filename of the logfile. Alternately, a value can be a dictionary; in this case, the dictionary must have a ``filename`` key specifying the filename, and can also have the following keys: ``follow`` Only follow the file from its current end-of-file, rather that starting from the beginning. ``logEnviron`` If false, the command's environment will not be logged. The ``shell`` command sends the following updates: ``stdout`` The data is a bytestring which represents a continuation of the stdout stream. Note that the bytestring boundaries are not necessarily aligned with newlines. ``stderr`` Similar to ``stdout``, but for the error stream. ``header`` Similar to ``stdout``, but containing data for a stream of buildbot-specific metadata. ``rc`` The exit status of the command, where -- in keeping with UNIX tradition -- 0 indicates success and any nonzero value is considered a failure. No further updates should be sent after an ``rc``. ``log`` This update contains data for a logfile other than stdio. The data associated with the update is a tuple of the log name and the data for that log. Note that non-stdio logs do not distinguish output, error, and header streams. uploadFile .......... Upload a file from the slave to the master. The arguments are ``workdir`` The base directory for the filename, relative to the builder's basedir. ``slavesrc`` Name of the filename to read from., relative to the workdir. ``writer`` A remote reference to a writer object, described below. ``maxsize`` Maximum size, in bytes, of the file to write. The operation will fail if the file exceeds this size. ``blocksize`` The block size with which to transfer the file. ``keepstamp`` If true, preserve the file modified and accessed times. The slave calls a few remote methods on the writer object. First, the ``write`` method is called with a bytestring containing data, until all of the data has been transmitted. Then, the slave calls the writer's ``close``, followed (if ``keepstamp`` is true) by a call to ``upload(atime, mtime)``. This command sends ``rc`` and ``stderr`` updates, as defined for the ``shell`` command. uploadDirectory ............... Similar to ``uploadFile``, this command will upload an entire directory to the master, in the form of a tarball. It takes the following arguments: ``workdir`` ``slavesrc`` ``writer`` ``maxsize`` ``blocksize`` See ``uploadFile`` ``compress`` Compression algorithm to use -- one of ``None``, ``'bz2'``, or ``'gz'``. The writer object is treated similarly to the ``uploadFile`` command, but after the file is closed, the slave calls the master's ``unpack`` method with no arguments to extract the tarball. This command sends ``rc`` and ``stderr`` updates, as defined for the ``shell`` command. downloadFile ............ This command will download a file from the master to the slave. It takes the following arguments: ``workdir`` Base directory for the destination filename, relative to the builder basedir. ``slavedest`` Filename to write to, relative to the workdir. ``reader`` A remote reference to a reader object, described below. ``maxsize`` Maximum size of the file. ``blocksize`` The block size with which to transfer the file. ``mode`` Access mode for the new file. The reader object's ``read(maxsize)`` method will be called with a maximum size, which will return no more than that number of bytes as a bytestring. At EOF, it will return an empty string. Once EOF is received, the slave will call the remote ``close`` method. This command sends ``rc`` and ``stderr`` updates, as defined for the ``shell`` command. mkdir ..... This command will create a directory on the slave. It will also create any intervening directories required. It takes the following argument: ``dir`` Directory to create. The ``mkdir`` command produces the same updates as ``shell``. rmdir ..... This command will remove a directory or file on the slave. It takes the following arguments: ``dir`` Directory to remove. ``timeout`` ``maxTime`` See ``shell``, above. The ``rmdir`` command produces the same updates as ``shell``. cpdir ..... This command will copy a directory from place to place on the slave. It takes the following arguments: ``fromdir`` Source directory for the copy operation, relative to the builder's basedir. ``todir`` Destination directory for the copy operation, relative to the builder's basedir. ``timeout`` ``maxTime`` See ``shell``, above. The ``cpdir`` command produces the same updates as ``shell``. stat .... This command returns status information about a file or directory. It takes a single parameter, ``file``, specifying the filename relative to the builder's basedir. It produces two status updates: ``stat`` The return value from Python's ``os.stat``. ``rc`` 0 if the file is found, otherwise 1. Source Commands ............... The source commands (``bk``, ``cvs``, ``darcs``, ``git``, ``repo``, ``bzr``, ``hg``, ``p4``, ``p4sync``, and ``mtn``) are deprecated. See the docstrings in the source code for more information. buildbot-0.8.8/docs/developer/metrics.rst000066400000000000000000000075161222546025000204570ustar00rootroot00000000000000.. _Metrics: Metrics ======= New in buildbot 0.8.4 is support for tracking various performance metrics inside the buildbot master process. Currently these are logged periodically according to the ``log_interval`` configuration setting of the @ref{Metrics Options} configuration. If :bb:status:`WebStatus` is enabled, the metrics data is also available via ``/json/metrics``. The metrics subsystem is implemented in :mod:`buildbot.process.metrics`. It makes use of twisted's logging system to pass metrics data from all over buildbot's code to a central :class:`MetricsLogObserver` object, which is available at ``BuildMaster.metrics`` or via ``Status.getMetrics()``. Metric Events ------------- :class:`MetricEvent` objects represent individual items to monitor. There are three sub-classes implemented: :class:`MetricCountEvent` Records incremental increase or decrease of some value, or an absolute measure of some value. :: from buildbot.process.metrics import MetricCountEvent # We got a new widget! MetricCountEvent.log('num_widgets', 1) # We have exactly 10 widgets MetricCountEvent.log('num_widgets', 10, absolute=True) :class:`MetricTimeEvent` Measures how long things take. By default the average of the last 10 times will be reported. :: from buildbot.process.metrics import MetricTimeEvent # function took 0.001s MetricTimeEvent.log('time_function', 0.001) :class:`MetricAlarmEvent` Indicates the health of various metrics. :: from buildbot.process.metrics import MetricAlarmEvent, ALARM_OK # num_slaves looks ok MetricAlarmEvent.log('num_slaves', level=ALARM_OK) Metric Handlers --------------- :class:`MetricsHandler` objects are responsible for collecting :class:`MetricEvent`\s of a specific type and keeping track of their values for future reporting. There are :class:`MetricsHandler` classes corresponding to each of the :class:`MetricEvent` types. Metric Watchers --------------- Watcher objects can be added to :class:`MetricsHandlers` to be called when metric events of a certain type are received. Watchers are generally used to record alarm events in response to count or time events. Metric Helpers -------------- :func:`countMethod(name)` A function decorator that counts how many times the function is called. :: from buildbot.process.metrics import countMethod @countMethod('foo_called') def foo(): return "foo!" :func:`Timer(name)` :class:`Timer` objects can be used to make timing events easier. When ``Timer.stop()`` is called, a :class:`MetricTimeEvent` is logged with the elapsed time since ``timer.start()`` was called. :: from buildbot.process.metrics import Timer def foo(): t = Timer('time_foo') t.start() try: for i in range(1000): calc(i) return "foo!" finally: t.stop() :class:`Timer` objects also provide a pair of decorators, :func:`startTimer`/\ :func:`stopTimer` to decorate other functions. :: from buildbot.process.metrics import Timer t = Timer('time_thing') @t.startTimer def foo(): return "foo!" @t.stopTimer def bar(): return "bar!" foo() bar() :func:`timeMethod(name)` A function decorator that measures how long a function takes to execute. Note that many functions in buildbot return deferreds, so may return before all the work they set up has completed. Using an explicit :class:`Timer` is better in this case. :: from buildbot.process.metrics import timeMethod @timeMethod('time_foo') def foo(): for i in range(1000): calc(i) return "foo!" buildbot-0.8.8/docs/developer/results.rst000066400000000000000000000026251222546025000205060ustar00rootroot00000000000000.. _Build-Result-Codes: Build Result Codes ================== .. py:module:: buildbot.status.results Buildbot represents the status of a step, build, or buildset using a set of numeric constants. From Python, these constants are available in the module ``buildbot.status.results``, but the values also appear in the database and in external tools, so the values are fixed. .. py:data:: SUCCESS Value: 0; color: green; a successful run. .. py:data:: WARNINGS Value: 1; color: orange; a successful run, with some warnings. .. py:data:: FAILURE Value: 2; color: red; a failed run, due to problems in the build itself, as opposed to a Buildbot misconfiguration or bug. .. py:data:: SKIPPED Value: 3; color: white; a run that was skipped -- usually a step skipped by ``doStepIf`` (see :ref:`Buildstep-Common-Parameters`) .. py:data:: EXCEPTION Value: 4; color: purple; a run that failed due to a problem in Buildbot itself. .. py:data:: RETRY Value: 4; color: purple; a run that should be retried, usually due to a slave disconnection. .. py:data:: Results A dictionary mapping result codes to their lowercase names. .. py:function:: worst_status(a, b) This function takes two status values, and returns the "worst" status of the two. This is used (with exceptions) to aggregate step statuses into build statuses, and build statuses into buildset statuses. buildbot-0.8.8/docs/developer/style.rst000066400000000000000000000173241222546025000201470ustar00rootroot00000000000000Buildbot Coding Style ===================== Symbol Names ------------ Buildbot follows `PEP8 `_ regarding the formatting of symbol names. The single exception in naming of functions and methods. Because Buildbot uses Twisted so heavily, and Twisted uses interCaps, Buildbot methods should do the same. That is, you should spell methods and functions with the first character in lower-case, and the first letter of subsequent words capitalized, e.g., ``compareToOther`` or ``getChangesGreaterThan``. This point is not applied very consistently in Buildbot, but let's try to be consistent in new code. Twisted Idioms -------------- Programming with Twisted Python can be daunting. But sticking to a few well-defined patterns can help avoid surprises. Prefer to Return Deferreds ~~~~~~~~~~~~~~~~~~~~~~~~~~ If you're writing a method that doesn't currently block, but could conceivably block sometime in the future, return a Deferred and document that it does so. Just about anything might block - even getters and setters! Helpful Twisted Classes ~~~~~~~~~~~~~~~~~~~~~~~ Twisted has some useful, but little-known classes. Brief descriptions follow, but you should consult the API documentation or source code for the full details. :class:`twisted.internet.task.LoopingCall` Calls an asynchronous function repeatedly at set intervals. Note that this will stop looping if the function fails. In general, you will want to wrap the function to capture and log errors. :class:`twisted.application.internet.TimerService` Similar to ``t.i.t.LoopingCall``, but implemented as a service that will automatically start and stop the function calls when the service starts and stops. See the warning about failing functions for ``t.i.t.LoopingCall``. Sequences of Operations ~~~~~~~~~~~~~~~~~~~~~~~ Especially in Buildbot, we're often faced with executing a sequence of operations, many of which may block. In all cases where this occurs, there is a danger of pre-emption, so exercise the same caution you would if writing a threaded application. For simple cases, you can use nested callback functions. For more complex cases, deferredGenerator is appropriate. Nested Callbacks ................ First, an admonition: do not create extra class methods that represent the continuations of the first:: def myMethod(self): d = ... d.addCallback(self._myMethod_2) # BAD! def _myMethod_2(self, res): # BAD! # ... Invariably, this extra method gets separated from its parent as the code evolves, and the result is completely unreadable. Instead, include all of the code for a particular function or method within the same indented block, using nested functions:: def getRevInfo(revname): results = {} d = defer.succeed(None) def rev_parse(_): # note use of '_' to quietly indicate an ignored parameter return utils.getProcessOutput(git, [ 'rev-parse', revname ]) d.addCallback(rev_parse) def parse_rev_parse(res): results['rev'] = res.strip() return utils.getProcessOutput(git, [ 'log', '-1', '--format=%s%n%b', results['rev'] ]) d.addCallback(parse_rev_parse) def parse_log(res): results['comments'] = res.strip() d.addCallback(parse_log) def set_results(_): return results d.addCallback(set_results) return d it is usually best to make the first operation occur within a callback, as the deferred machinery will then handle any exceptions as a failure in the outer Deferred. As a shortcut, ``d.addCallback`` works as a decorator:: d = defer.succeed(None) @d.addCallback def rev_parse(_): # note use of '_' to quietly indicate an ignored parameter return utils.getProcessOutput(git, [ 'rev-parse', revname ]) Be careful with local variables. For example, if ``parse_rev_parse``, above, merely assigned ``rev = res.strip()``, then that variable would be local to ``parse_rev_parse`` and not available in ``set_results``. Mutable variables (dicts and lists) at the outer function level are appropriate for this purpose. .. note:: do not try to build a loop in this style by chaining multiple Deferreds! Unbounded chaining can result in stack overflows, at least on older versions of Twisted. Use ``deferredGenerator`` instead. inlineCallbacks ............... :class:`twisted.internet.defer.inlineCallbacks` is a great help to writing code that makes a lot of asynchronous calls, particularly if those calls are made in loop or conditionals. Refer to the Twisted documentation for the details, but the style within Buildbot is as follows:: from twisted.internet import defer @defer.inlineCallbacks def mymethod(self, x, y): xval = yield getSomething(x) for z in (yield getZValues()): y += z if xval > 10: defer.returnValue(xval + y) return self.someOtherMethod() The key points to notice here: * Always import ``defer`` as a module, not the names within it. * Use the decorator form of ``inlineCallbacks``. * In most cases, the result of a ``yield`` expression should be assigned to a variable. It can be used in a larger expression, but remember that Python requires that you enclose the expression in its own set of parentheses. * Python does not permit returning a value from a generator, so statements like ``return xval + y`` are invalid. Instead, yield the result of ``defer.returnValue``. Although this function does cause an immediate function exit, for clarity follow it with a bare ``return``, as in the example, unless it is the last statement in a function. The great advantage of ``inlineCallbacks`` is that it allows you to use all of the usual Pythonic control structures in their natural form. In particular, it is easy to represent a loop, or even nested loops, in this style without losing any readability. Note that code using ``deferredGenerator`` is no longer acceptable in Buildbot. Locking ....... Remember that asynchronous programming does not free you from the need to worry about concurrency issues. Particularly if you are executing a sequence of operations, each time you wait for a Deferred, arbitrary other actions can take place. In general, you should try to perform actions atomically, but for the rare situations that require synchronization, the following might be useful: * :py:class:`twisted.internet.defer.DeferredLock` * :py:func:`buildbot.util.misc.deferredLocked` * :py:func:`buildbot.util.misc.SerializedInvocation` Joining Sequences ~~~~~~~~~~~~~~~~~ It's often the case that you'll want to perform multiple operations in parallel, and re-join the results at the end. For this purpose, you'll want to use a `DeferredList `_ :: def getRevInfo(revname): results = {} finished = dict(rev_parse=False, log=False) rev_parse_d = utils.getProcessOutput(git, [ 'rev-parse', revname ]) def parse_rev_parse(res): return res.strip() rev_parse_d.addCallback(parse_rev_parse) log_d = utils.getProcessOutput(git, [ 'log', '-1', '--format=%s%n%b', results['rev'] ])) def parse_log(res): return res.strip() log_d.addCallback(parse_log) d = defer.DeferredList([rev_parse_d, log_d], consumeErrors=1, fireOnFirstErrback=1) def handle_results(results): return dict(rev=results[0][1], log=results[1][1]) d.addCallback(handle_results) return d Here the deferred list will wait for both ``rev_parse_d`` and ``log_d`` to fire, or for one of them to fail. You may attach callbacks and errbacks to a ``DeferredList`` just as for a deferred. buildbot-0.8.8/docs/developer/tests.rst000066400000000000000000000317631222546025000201540ustar00rootroot00000000000000Buildbot's Test Suite ===================== Buildbot's tests are under ``buildbot.test`` and, for the buildslave, ``buildslave.test``. Tests for the slave are similar to the master, although in some cases helpful functionality on the master is not re-implemented on the slave. Suites ------ Tests are divided into a few suites: * Unit tests (``buildbot.test.unit``) - these follow unit-testing practices and attempt to maximally isolate the system under test. Unit tests are the main mechanism of achieving test coverage, and all new code should be well-covered by corresponding unit tests. * Interface tests (``buildbot.test.interface``). In many cases, Buildbot has multiple implementations of the same interface -- at least one "real" implementation and a fake implementation used in unit testing. The interface tests ensure that these implementations all meet the same standards. This ensures consistency between implementations, and also ensures that the unit tests are testing against realistic fakes. * Integration tests (``buildbot.test.integration``) - these test combinations of multiple units. Of necessity, integration tests are incomplete - they cannot test every condition; difficult to maintain - they tend to be complex and touch a lot of code; and slow - they usually require considerable setup and execute a lot of code. As such, use of integration tests is limited to a few, broad tests to act as a failsafe for the unit and interface tests. * Regression tests (``buildbot.test.regrssions``) - these test to prevent re-occurrence of historical bugs. In most cases, a regression is better tested by a test in the other suites, or unlike to recur, so this suite tends to be small. * Fuzz tests (``buildbot.test.fuzz``) - these tests run for a long time and apply randomization to try to reproduce rare or unusual failures. The Buildbot project does not currently have a framework to run fuzz tests regularly. Unit Tests ~~~~~~~~~~ Every code module should have corresponding unit tests. This is not currently true of Buildbot, due to a large body of legacy code, but is a goal of the project. All new code must meet this requirement. Unit test modules are be named after the package or class they test, replacing ``.`` with ``_`` and omitting the ``buildbot_``. For example, :file:`test_status_web_authz_Authz.py` tests the :class:`Authz` class in :file:`buildbot/status/web/authz.py`. Modules with only one class, or a few trivial classes, can be tested in a single test module. For more complex situations, prefer to use multiple test modules. Interface Tests ~~~~~~~~~~~~~~~ Interface tests exist to verify that multiple implementations of an interface meet the same requirements. Note that the name 'interface' should not be confused with the sparse use of Zope Interfaces in the Buildbot code -- in this context, an interface is any boundary between testable units. Ideally, all interfaces, both public and private, should be tested. Certainly, any *public* interfaces need interface tests. Interface test modules are named after the interface they are testing, e.g., :file:`test_mq.py`. They generally begin as follows:: from buildbot.test.util import interfaces from twistd.trial import unittest class Tests(interfaces.InterfaceTests): # define methods that must be overridden per implementation def someSetupMethod(self): raise NotImplementedError # tests that all implementations must pass def test_signature_someMethod(self): @self.assertArgSpecMatches(self.systemUnderTest.someMethod) def someMethod(self, arg1, arg2): pass def test_something(self): pass # ... class RealTests(Tests): # tests that all *real* implementations must pass def test_something_else(self): pass # ... All of the test methods are defined here, segregated into tests that all implementations must pass, and tests that the fake implementation is not expected to pass. The ``test_signature_someMethod`` test above illustrates the ``assertArgSpecMatches`` decorator, which can be used to compare the argument specification of a callable with a reference implementation conveniently written as a nested function. At the bottom of the test module, a subclass is created for each implementation, implementing the setup methods that were stubbed out in the parent classes:: class TestFakeThing(unittest.TestCase, Tests): def someSetupMethod(self): pass # ... class TestRealThing(unittest.TestCase, RealTests): def someSetupMethod(self): pass # ... For implementations which require optional software, this is the appropriate place to signal that tests should be skipped when their prerequisites are not available. Integration Tests ~~~~~~~~~~~~~~~~~ Integration test modules test several units at once, including their interactions. In general, they serve as a catch-all for failures and bugs that were not detected by the unit and interface tests. As such, they should not aim to be exhaustive, but merely representative. Integration tests are very difficult to maintain if they reach into the internals of any part of Buildbot. Where possible, try to use the same means as a user would to set up, run, and check the results of an integration test. That may mean writing a :file:`master.cfg` to be parsed, and checking the results by examining the database (or fake DB API) afterward. Regression Tests ~~~~~~~~~~~~~~~~ Regression tests are even more rare in Buildbot than integration tests. In many cases, a regression test is not necessary -- either the test is better-suited as a unit or interface test, or the failure is so specific that a test will never fail again. Regression tests tend to be closely tied to the code in which the error occurred. When that code is refactored, the regression test generally becomes obsolete, and is deleted. Fuzz Tests ~~~~~~~~~~ Fuzz tests generally run for a fixed amount of time, running randomized tests against a system. They do not run at all during normal runs of the Buildbot tests, unless ``BUILDBOT_FUZZ`` is defined. This is accomplished with something like the following at the end of each test module:: if 'BUILDBOT_FUZZ' not in os.environ: del LRUCacheFuzzer Mixins ------ Buildbot provides a number of purpose-specific mixin classes in :bb:src:`master/buildbot/util`. These generally define a set of utility functions as well as ``setUpXxx`` and ``tearDownXxx`` methods. These methods should be called explicitly from your subclass's ``setUp`` and ``tearDown`` methods. Note that some of these methods return Deferreds, which should be handled properly by the caller. .. _Fakes: Fakes ----- Buildbot provides a number of pre-defined fake implementations of internal interfaces, in :bb:src:`master/buildbot/fake`. These are designed to be used in unit tests to limit the scope of the test. For example, the fake DB API eliminates the need to create a real database when testing code that uses the DB API, and isolates bugs in the system under test from bugs in the real DB implementation. The danger of using fakes is that the fake interface and the real interface can differ. The interface tests exist to solve this problem. All fakes should be fully tested in an integration test, so that the fakes pass the same tests as the "real" thing. It is particularly important that the method signatures be compared. Good Tests ---------- Bad tests are worse than no tests at all, since they waste developers' time wondering "was that a spurious failure?" or "what the heck is this test trying to do?" Buildbot needs good tests. So what makes a good test? .. _Tests-Independent-of-Time: Independent of Time ~~~~~~~~~~~~~~~~~~~ Tests that depend on wall time will fail. As a bonus, they run very slowly. Do not use :meth:`reactor.callLater` to wait "long enough" for something to happen. For testing things that themselves depend on time, consider using :class:`twisted.internet.tasks.Clock`. This may mean passing a clock instance to the code under test, and propagating that instance as necessary to ensure that all of the code using :meth:`callLater` uses it. Refactoring code for testability is difficult, but worthwhile. For testing things that do not depend on time, but for which you cannot detect the "end" of an operation: add a way to detect the end of the operation! Clean Code ~~~~~~~~~~ Make your tests readable. This is no place to skimp on comments! Others will attempt to learn about the expected behavior of your class by reading the tests. As a side note, if you use a :class:`Deferred` chain in your test, write the callbacks as nested functions, rather than using methods with funny names:: def testSomething(self): d = doThisFirst() def andThisNext(res): pass # ... d.addCallback(andThisNext) return d This isolates the entire test into one indented block. It is OK to add methods for common functionality, but give them real names and explain in detail what they do. Good Name ~~~~~~~~~ Test method names should follow the pattern :samp:`test_{METHOD}_{CONDITION}` where *METHOD* is the method being tested, and *CONDITION* is the condition under which it's tested. Since we can't always test a single method, this is not a hard-and-fast rule. Assert Only One Thing ~~~~~~~~~~~~~~~~~~~~~ Where practical, each test should have a single assertion. This may require a little bit of work to get several related pieces of information into a single Python object for comparison. The problem with multiple assertions is that, if the first assertion fails, the remainder are not tested. The test results then do not tell the entire story. Prefer Fakes to Mocks ~~~~~~~~~~~~~~~~~~~~~ Mock objects are too "compliant", and this often masks errors in the system under test. For example, a mis-spelled method name on a mock object will not raise an exception. Where possible, use one of the pre-written fake objects (see :ref:`Fakes`) instead of a mock object. Fakes themselves should be well-tested using interface tests. Where they are appropriate, Mock objects can be constructed easily using the aptly-named `mock `_ module, which is a requirement for Buildbot's tests. Small Tests ~~~~~~~~~~~ The shorter each test is, the better. Test as little code as possible in each test. It is fine, and in fact encouraged, to write the code under test in such a way as to facilitate this. As an illustrative example, if you are testing a new Step subclass, but your tests require instantiating a BuildMaster, you're probably doing something wrong! This also applies to test modules. Several short, easily-digested test modules are preferred over a 1000-line monster. Isolation ~~~~~~~~~ Each test should be maximally independent of other tests. Do not leave files laying around after your test has finished, and do not assume that some other test has run beforehand. It's fine to use caching techniques to avoid repeated, lengthy setup times. Be Correct ~~~~~~~~~~ Tests should be as robust as possible, which at a basic level means using the available frameworks correctly. All Deferreds should have callbacks and be chained properly. Error conditions should be checked properly. Race conditions should not exist (see :ref:`Tests-Independent-of-Time`, above). Be Helpful ~~~~~~~~~~ Note that tests will pass most of the time, but the moment when they are most useful is when they fail. When the test fails, it should produce output that is helpful to the person chasing it down. This is particularly important when the tests are run remotely, in which case the person chasing down the bug does not have access to the system on which the test fails. A test which fails sporadically with no more information than "AssertionFailed" is a prime candidate for deletion if the error isn't obvious. Making the error obvious also includes adding comments describing the ways a test might fail. Keeping State ~~~~~~~~~~~~~ Python does not allow assignment to anything but the innermost local scope or the global scope with the ``global`` keyword. This presents a problem when creating nested functions:: def test_localVariable(self): cb_called = False def cb(): cb_called = True cb() self.assertTrue(cb_called) # will fail! The ``cb_called = True`` assigns to a *different variable* than ``cb_called = False``. In production code, it's usually best to work around such problems, but in tests this is often the clearest way to express the behavior under test. The solution is to change something in a common mutable object. While a simple list can serve as such a mutable object, this leads to code that is hard to read. Instead, use :class:`State`:: from buildbot.test.state import State def test_localVariable(self): state = State(cb_called=False) def cb(): state.cb_called = True cb() self.assertTrue(state.cb_called) # passes This is almost as readable as the first example, but it actually works. buildbot-0.8.8/docs/developer/utils.rst000066400000000000000000000474131222546025000201510ustar00rootroot00000000000000Utilities ========= .. py:module:: buildbot.util Several small utilities are available at the top-level :mod:`buildbot.util` package. .. py:function:: naturalSort(list) :param list: list of strings :returns: sorted strings This function sorts strings "naturally", with embedded numbers sorted numerically. This ordering is good for objects which might have a numeric suffix, e.g., ``winslave1``, ``winslave2`` .. py:function:: formatInterval(interval) :param interval: duration in seconds :returns: human-readable (English) equivalent This function will return a human-readable string describing a length of time, given a number of seconds. .. py:class:: ComparableMixin This mixin class adds comparability to a subclass. Use it like this:: class Widget(FactoryProduct, ComparableMixin): compare_attrs = [ 'radius', 'thickness' ] # ... Any attributes not in ``compare_attrs`` will not be considered when comparing objects. This is particularly useful in implementing buildbot's reconfig logic, where a simple comparison between the new and existing objects can determine whether the new object should replace the existing object. .. py:function:: safeTranslate(str) :param str: input string :returns: safe version of the input This function will filter out some inappropriate characters for filenames; it is suitable for adapting strings from the configuration for use as filenames. It is not suitable for use with strings from untrusted sources. .. py:function:: epoch2datetime(epoch) :param epoch: an epoch time (integer) :returns: equivalent datetime object Convert a UNIX epoch timestamp to a Python datetime object, in the UTC timezone. Note that timestamps specify UTC time (modulo leap seconds and a few other minor details). .. py:function:: datetime2epoch(datetime) :param datetime: a datetime object :returns: equivalent epoch time (integer) Convert an arbitrary Python datetime object into a UNIX epoch timestamp. .. py:data:: UTC A ``datetime.tzinfo`` subclass representing UTC time. A similar class has finally been added to Python in version 3.2, but the implementation is simple enough to include here. This is mostly used in tests to create timezone-aware datetime objects in UTC:: dt = datetime.datetime(1978, 6, 15, 12, 31, 15, tzinfo=UTC) .. py:function:: diffSets(old, new) :param old: old set :type old: set or iterable :param new: new set :type new: set or iterable :returns: a tuple, (removed, added) This function compares two sets of objects, returning elements that were added and elements that were removed. This is largely a convenience function for reconfiguring services. .. py:function:: makeList(input) :param input: a thing :returns: a list of zero or more things This function is intended to support the many places in Buildbot where the user can specify either a string or a list of strings, but the implementation wishes to always consider lists. It converts any string to a single-element list, ``None`` to an empty list, and any iterable to a list. Input lists are copied, avoiding aliasing issues. .. py:function:: now() :returns: epoch time (integer) Return the current time, using either ``reactor.seconds`` or ``time.time()``. .. py:function:: flatten(list) :param list: potentially nested list :returns: flat list Flatten nested lists into a list containing no other lists. For example: .. code-block:: none >>> flatten([ [ 1, 2 ], 3, [ [ 4 ] ] ]) [ 1, 2, 3, 4 ] Note that this looks strictly for lists -- tuples, for example, are not flattened. .. py:function:: none_or_str(obj) :param obj: input value :returns: string or ``None`` If ``obj`` is not None, return its string representation. .. py:data:: NotABranch This is a sentinel value used to indicate that no branch is specified. It is necessary since schedulers and change sources consider ``None`` a valid name for a branch. This is generally used as a default value in a method signature, and then tested against with ``is``:: if branch is NotABranch: pass # ... .. py:function:: in_reactor(fn) This decorator will cause the wrapped function to be run in the Twisted reactor, with the reactor stopped when the function completes. It returns the result of the wrapped function. If the wrapped function fails, its traceback will be printed, the reactor halted, and ``None`` returned. buildbot.util.lru ~~~~~~~~~~~~~~~~~ .. py:module:: buildbot.util.lru .. py:class:: LRUCache(miss_fn, max_size=50): :param miss_fn: function to call, with key as parameter, for cache misses. The function should return the value associated with the key argument, or None if there is no value associated with the key. :param max_size: maximum number of objects in the cache. This is a simple least-recently-used cache. When the cache grows beyond the maximum size, the least-recently used items will be automatically removed from the cache. This cache is designed to control memory usage by minimizing duplication of objects, while avoiding unnecessary re-fetching of the same rows from the database. All values are also stored in a weak valued dictionary, even after they have expired from the cache. This allows values that are used elsewhere in Buildbot to "stick" in the cache in case they are needed by another component. Weak references cannot be used for some types, so these types are not compatible with this class. Note that dictionaries can be weakly referenced if they are an instance of a subclass of ``dict``. If the result of the ``miss_fn`` is ``None``, then the value is not cached; this is intended to avoid caching negative results. This is based on `Raymond Hettinger's implementation `_, licensed under the PSF license, which is GPL-compatiblie. .. py:attribute:: hits cache hits so far .. py:attribute:: refhits cache misses found in the weak ref dictionary, so far .. py:attribute:: misses cache misses leading to re-fetches, so far .. py:attribute:: max_size maximum allowed size of the cache .. py:method:: get(key, \*\*miss_fn_kwargs) :param key: cache key :param miss_fn_kwargs: keyword arguments to the ``miss_fn`` :returns: value via Deferred Fetch a value from the cache by key, invoking ``miss_fn(key, **miss_fn_kwargs)`` if the key is not in the cache. Any additional keyword arguments are passed to the ``miss_fn`` as keyword arguments; these can supply additional information relating to the key. It is up to the caller to ensure that this information is functionally identical for each key value: if the key is already in the cache, the ``miss_fn`` will not be invoked, even if the keyword arguments differ. .. py:method:: put(key, value) :param key: key at which to place the value :param value: value to place there Update the cache with the given key and value, but only if the key is already in the cache. The purpose of this method is to insert a new value into the cache *without* invoking the miss_fn (e.g., to avoid unnecessary overhead). .. py:method set_max_size(max_size) :param max_size: new maximum cache size Change the cache's maximum size. If the size is reduced, cached elements will be evicted. This method exists to support dynamic reconfiguration of cache sizes in a running process. .. py:method:: inv() Check invariants on the cache. This is intended for debugging purposes. .. py:class:: AsyncLRUCache(miss_fn, max_size=50): :param miss_fn: This is the same as the miss_fn for class LRUCache, with the difference that this function *must* return a Deferred. :param max_size: maximum number of objects in the cache. This class has the same functional interface as LRUCache, but asynchronous locking is used to ensure that in the common case of multiple concurrent requests for the same key, only one fetch is performed. buildbot.util.bbcollections ~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. py:module:: buildbot.util.bbcollections This package provides a few useful collection objects. .. note:: This module used to be named ``collections``, but without absolute imports (:pep:`328`), this precluded using the standard library's ``collections`` module. .. py:class:: defaultdict This is a clone of the Python :class:`collections.defaultdict` for use in Python-2.4. In later versions, this is simply a reference to the built-in :class:`defaultdict`, so buildbot code can simply use :class:`buildbot.util.collections.defaultdict` everywhere. .. py:class:: KeyedSets This is a collection of named sets. In principal, it contains an empty set for every name, and you can add things to sets, discard things from sets, and so on. :: >>> ks = KeyedSets() >>> ks['tim'] # get a named set set([]) >>> ks.add('tim', 'friendly') # add an element to a set >>> ks.add('tim', 'dexterous') >>> ks['tim'] set(['friendly', 'dexterous']) >>> 'tim' in ks # membership testing True >>> 'ron' in ks False >>> ks.discard('tim', 'friendly')# discard set element >>> ks.pop('tim') # return set and reset to empty set(['dexterous']) >>> ks['tim'] set([]) This class is careful to conserve memory space - empty sets do not occupy any space. buildbot.util.eventual ~~~~~~~~~~~~~~~~~~~~~~ .. py:module:: buildbot.util.eventual This function provides a simple way to say "please do this later". For example:: from buildbot.util.eventual import eventually def do_what_I_say(what, where): # ... return d eventually(do_what_I_say, "clean up", "your bedroom") The package defines "later" as "next time the reactor has control", so this is a good way to avoid long loops that block other activity in the reactor. .. py:function:: eventually(cb, *args, \*\*kwargs) :param cb: callable to invoke later :param args: args to pass to ``cb`` :param kwargs: kwargs to pass to ``cb`` Invoke the callable ``cb`` in a later reactor turn. Callables given to :func:`eventually` are guaranteed to be called in the same order as the calls to :func:`eventually` -- writing ``eventually(a); eventually(b)`` guarantees that ``a`` will be called before ``b``. Any exceptions that occur in the callable will be logged with ``log.err()``. If you really want to ignore them, provide a callable that catches those exceptions. This function returns None. If you care to know when the callable was run, be sure to provide a callable that notifies somebody. .. py:function:: fireEventually(value=None) :param value: value with which the Deferred should fire :returns: Deferred This function returns a Deferred which will fire in a later reactor turn, after the current call stack has been completed, and after all other Deferreds previously scheduled with :py:func:`eventually`. The returned Deferred will never fail. .. py:function:: flushEventualQueue() :returns: Deferred This returns a Deferred which fires when the eventual-send queue is finally empty. This is useful for tests and other circumstances where it is useful to know that "later" has arrived. buildbot.util.json ~~~~~~~~~~~~~~~~~~ .. py:module:: buildbot.util.json This package is just an import of the best available JSON module. Use it instead of a more complex conditional import of :mod:`simplejson` or :mod:`json`:: from buildbot.util import json buildbot.util.maildir ~~~~~~~~~~~~~~~~~~~~~ .. py:module:: buildbot.util.maildir Several Buildbot components make use of `maildirs `_ to hand off messages between components. On the receiving end, there's a need to watch a maildir for incoming messages and trigger some action when one arrives. .. py:class:: MaildirService(basedir) :param basedir: (optional) base directory of the maildir A :py:class:`MaildirService` instance watches a maildir for new messages. It should be a child service of some :py:class:`~twisted.application.service.MultiService` instance. When running, this class uses the linux dirwatcher API (if available) or polls for new files in the 'new' maildir subdirectory. When it discovers a new message, it invokes its :py:meth:`messageReceived` method. To use this class, subclass it and implement a more interesting :py:meth:`messageReceived` function. .. py:method:: setBasedir(basedir) :param basedir: base directory of the maildir If no ``basedir`` is provided to the constructor, this method must be used to set the basedir before the service starts. .. py:method:: messageReceived(filename) :param filename: unqualified filename of the new message This method is called with the short filename of the new message. The full name of the new file can be obtained with ``os.path.join(maildir, 'new', filename)``. The method is un-implemented in the :py:class:`MaildirService` class, and must be implemented in subclasses. .. py:method:: moveToCurDir(filename) :param filename: unqualified filename of the new message :returns: open file object Call this from :py:meth:`messageReceived` to start processing the message; this moves the message file to the 'cur' directory and returns an open file handle for it. buildbot.util.misc ~~~~~~~~~~~~~~~~~~ .. py:module:: buildbot.util.misc .. py:function:: deferredLocked(lock) :param lock: a :py:class:`twisted.internet.defer.DeferredLock` instance or a string naming an instance attribute containing one This is a decorator to wrap an event-driven method (one returning a ``Deferred``) in an acquire/release pair of a designated :py:class:`~twisted.internet.defer.DeferredLock`. For simple functions with a static lock, this is as easy as:: someLock = defer.DeferredLock() @util.deferredLocked(someLock) def someLockedFunction(): # .. return d For class methods which must access a lock that is an instance attribute, the lock can be specified by a string, which will be dynamically resolved to the specific instance at runtime:: def __init__(self): self.someLock = defer.DeferredLock() @util.deferredLocked('someLock') def someLockedFunction(): # .. return d .. py:class:: SerializedInvocation(method) This is a method wrapper that will serialize calls to an asynchronous method. If a second call occurs while the first call is still executing, it will not begin until the first call has finished. If multiple calls queue up, they will be collapsed into a single call. The effect is that the underlying method is guaranteed to be called at least once after every call to the wrapper. Note that if this class is used as a decorator on a method, it will serialize invocations across all class instances. For synchronization specific to each instance, wrap the method in the constructor:: def __init__(self): self.someMethod = SerializedInovcation(self.someMethod) Tests can monkey-patch the ``_quiet`` method of the class to be notified when all planned invocations are complete. buildbot.util.netstrings ~~~~~~~~~~~~~~~~~~~~~~~~ .. py:module:: buildbot.util.netstrings Similar to maildirs, `netstrings `_ are used occasionally in Buildbot to encode data for interchange. While Twisted supports a basic netstring receiver protocol, it does not have a simple way to apply that to a non-network situation. .. py:class:: NetstringParser This class parses strings piece by piece, either collecting the accumulated strings or invoking a callback for each one. .. py:method:: feed(data) :param data: a portion of netstring-formatted data :raises: :py:exc:`twisted.protocols.basic.NetstringParseError` Add arbitrarily-sized ``data`` to the incoming-data buffer. Any complete netstrings will trigger a call to the :py:meth:`stringReceived` method. Note that this method (like the Twisted class it is based on) cannot detect a trailing partial netstring at EOF - the data will be silently ignored. .. py:method:: stringReceived(string): :param string: the decoded string This method is called for each decoded string as soon as it is read completely. The default implementation appends the string to the :py:attr:`strings` attribute, but subclasses can do anything. .. py:attribute:: strings The strings decoded so far, if :py:meth:`stringReceived` is not overridden. buildbot.util.sautils ~~~~~~~~~~~~~~~~~~~~~ .. py:module:: buildbot.util.sautils This module contains a few utilities that are not included with SQLAlchemy. .. py:class:: InsertFromSelect(table, select) :param table: table into which insert should be performed :param select: select query from which data should be drawn This class is taken directly from SQLAlchemy's `compiler.html `_, and allows a Pythonic representation of ``INSERT INTO .. SELECT ..`` queries. .. py:function:: sa_version() Return a 3-tuple representing the SQLAlchemy version. Note that older versions that did not have a ``__version__`` attribute are represented by ``(0,0,0)``. buildbot.util.subscription ~~~~~~~~~~~~~~~~~~~~~~~~~~ The classes in the :py:mod:`buildbot.util.subscription` module are used for master-local subscriptions. In the near future, all uses of this module will be replaced with message-queueing implementations that allow subscriptions and subscribers to span multiple masters. buildbot.util.croniter ~~~~~~~~~~~~~~~~~~~~~~ This module is a copy of https://github.com/taichino/croniter, and provides support for converting cron-like time specifications into actual times. buildbot.util.state ~~~~~~~~~~~~~~~~~~~ .. py:module:: buildbot.util.state The classes in the :py:mod:`buildbot.util.subscription` module are used for dealing with object state stored in the database. .. py:class:: StateMixin This class provides helper methods for accessing the object state stored in the database. .. py:attribute:: name This must be set to the name to be used to identify this object in the database. .. py:attribute:: master This must point to the :py:class:`BuildMaster` object. .. py:method:: getState(name, default) :param name: name of the value to retrieve :param default: (optional) value to return if `name` is not present :returns: state value via a Deferred :raises KeyError: if `name` is not present and no default is given :raises TypeError: if JSON parsing fails Get a named state value from the object's state. .. py:method:: getState(name, value) :param name: the name of the value to change :param value: the value to set - must be a JSONable object :param returns: Deferred :raises TypeError: if JSONification fails Set a named state value in the object's persistent state. Note that value must be json-able. buildbot-0.8.8/docs/developer/webstatus.rst000066400000000000000000000056341222546025000210310ustar00rootroot00000000000000Web Status ========== .. _Jinja-Web-Templates: Jinja Web Templates ~~~~~~~~~~~~~~~~~~~ Buildbot uses Jinja2 to render its web interface. The authoritative source for this templating engine is `its own documentation `_, of course, but a few notes are in order for those who are making only minor modifications. Whitespace ++++++++++ Jinja directives are enclosed in ``{% .. %}``, and sometimes also have dashes. These dashes strip whitespace in the output. For example: .. code-block:: none {% for entry in entries %}
  • {{ entry }}
  • {% endfor %} will produce output with too much whitespace: .. code-block:: html
  • pigs
  • cows
  • But adding the dashes will collapse that whitespace completely: .. code-block:: none {% for entry in entries -%}
  • {{ entry }}
  • {%- endfor %} yields .. code-block:: html
  • pigs
  • cows
  • .. _Web-Authorization-Framework: Web Authorization Framework ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Whenever any part of the web framework wants to perform some action on the buildmaster, it should check the user's authorization first. Always check authorization twice: once to decide whether to show the option to the user (link, button, form, whatever); and once before actually performing the action. To check whether to display the option, you'll usually want to pass an authz object to the Jinja template in your :class:`HtmlResource` subclass:: def content(self, req, cxt): # ... cxt['authz'] = self.getAuthz(req) template = ... return template.render(**cxt) and then determine whether to advertise the action in the template: .. code-block:: none {{ if authz.advertiseAction('myNewTrick') }}
    ... {{ endif }} Actions can optionally require authentication, so use ``needAuthForm`` to determine whether to require a 'username' and 'passwd' field in the generated form. These fields are usually generated by :meth:`authFormIfNeeded()`: .. code-block:: none {{ authFormIfNeeded(authz, 'myNewTrick') }} Once the POST request comes in, it's time to check authorization again. This usually looks something like :: res = yield self.getAuthz(req).actionAllowed('myNewTrick', req, someExtraArg) if not res: defer.returnValue(Redirect(path_to_authfail(req))) return The ``someExtraArg`` is optional (it's handled with ``*args``, so you can have several if you want), and is given to the user's authorization function. For example, a build-related action should pass the build status, so that the user's authorization function could ensure that devs can only operate on their own builds. Note that ``actionAllowed`` returns a ``Deferred`` instance, so you must wait for the ``Deferred`` and yield the ``Redirect`` instead of returning it. The available actions are described in :bb:status:`WebStatus`. buildbot-0.8.8/docs/examples/000077500000000000000000000000001222546025000160775ustar00rootroot00000000000000buildbot-0.8.8/docs/examples/hello.cfg000066400000000000000000000043071222546025000176670ustar00rootroot00000000000000#! /usr/bin/python from buildbot import master from buildbot.buildslave import BuildSlave from buildbot.process import factory from buildbot.steps.source import CVS, SVN, Darcs from buildbot.steps.shell import Configure, Compile, Test from buildbot.status import html, client from buildbot.changes.pb import PBChangeSource BuildmasterConfig = c = {} c['slaves'] = [BuildSlave("bot1", "sekrit")] c['change_source'] = PBChangeSource(prefix="trunk") c['builders'] = [] if True: f = factory.BuildFactory() f.addStep(CVS(cvsroot="/usr/home/warner/stuff/Projects/BuildBot/demo/Repository", cvsmodule="hello", mode="clobber", checkoutDelay=6, alwaysUseLatest=True, )) f.addStep(Configure()) f.addStep(Compile()) f.addStep(Test(command=["make", "check"])) b1 = {"name": "cvs-hello", "slavename": "bot1", "builddir": "cvs-hello", "factory": f, } c['builders'].append(b1) if True: svnrep="file:///usr/home/warner/stuff/Projects/BuildBot/demo/SVN-Repository" f = factory.BuildFactory() f.addStep(SVN(svnurl=svnrep+"/hello", mode="update")) f.addStep(Configure()) f.addStep(Compile()), f.addStep(Test(command=["make", "check"])) b1 = {"name": "svn-hello", "slavename": "bot1", "builddir": "svn-hello", "factory": f, } c['builders'].append(b1) if True: f = factory.BuildFactory() f.addStep(Darcs(repourl="http://localhost/~warner/hello-darcs", mode="copy")) f.addStep(Configure(command=["/bin/sh", "./configure"])) f.addStep(Compile()) f.addStep(Test(command=["make", "check"])) b1 = {"name": "darcs-hello", "slavename": "bot1", "builddir": "darcs-hello", "factory": f, } c['builders'].append(b1) c['title'] = "Hello" c['titleURL'] = "http://www.hello.example.com/" c['buildbotURL'] = "http://localhost:8080" c['slavePortnum'] = 8007 c['debugPassword'] = "asdf" c['manhole'] = master.Manhole(9900, "username", "password") c['status'] = [html.WebStatus(http_port=8080), client.PBListener(port=8008), ] buildbot-0.8.8/docs/examples/repo_gerrit.cfg000066400000000000000000000134001222546025000210770ustar00rootroot00000000000000# -*- python -*- # ex: set syntax=python: manifest_url = "git://github.com/CyanogenMod/android.git" manifest_branch="froyo" slaves = [ "slave%02d"%(i) for i in xrange(1,2) ] repotarball="/local/android/cyanogen/cyanogen_bootstrap.tgz" gerrit_server = "review.cyanogenmod.com" gerrit_user = "yourid" proprietary_url = "http://where.to.find.com/proprietaries/%(device)s.tgz" build_branches = [] #for i in "passion inc hero heroc sholes dream_sapphire bravo bravoc espresso supersonic liberty vibrant legend vision".split(" "): for i in "passion hero dream_sapphire".split(" "): build_branches.append([i,"default.xml","froyo"]) # This is the dictionary that the buildmaster pays attention to. We also use # a shorter alias to save typing. c = BuildmasterConfig = {} ## DB URL # This specifies what database buildbot uses to store change and scheduler # state c['db_url'] = "sqlite:///state.sqlite" ## BUILDSLAVES from buildbot.buildslave import BuildSlave c['slaves'] = [ BuildSlave(i, i+"pw",max_builds=1) for i in slaves ] c['slavePortnum'] = 9989 ## CHANGESOURCES from buildbot.changes.gerritchangesource import GerritChangeSource c['change_source'] = GerritChangeSource(gerrit_server, gerrit_user) ## SCHEDULERS ## configure the Schedulers buildernames = [ "%s_%s"%(board,manifest) for board, manifest, gerrit_branch in build_branches] from buildbot.scheduler import Scheduler c['schedulers'] = [] c['schedulers'].append(Scheduler(name="all", branch=None, treeStableTimer=2*60, builderNames=buildernames)) branches = {} for board, manifest, gerrit_branch in build_branches: if not branches.has_key(gerrit_branch): branches[gerrit_branch] = [] branches[gerrit_branch].append("%s_%s"%(board,manifest)) for branch in branches.keys(): print branch,branches[branch] c['schedulers'].append(Scheduler(name=branch, branch=branch, treeStableTimer=None, builderNames=branches[branch])) ## BUILDERS from buildbot.process import factory from buildbot.steps.source import Repo from buildbot.steps.shell import Compile from buildbot.steps.master import MasterShellCommand from buildbot.steps.transfer import FileUpload from buildbot.steps.python_twisted import Trial from buildbot.config import BuilderConfig from buildbot.process.properties import Interpolate getOutputDir = Interpolate("/var/www/builds/build-%(prop:buildername)s-%(prop:changenumber)s") getWebDir = Interpolate("http://buildmaster.mysite.com/builds/build-%(prop:buildername)s-%(prop:changenumber)s") builders = [] for board, manifest_file, gerrit_branch in build_branches: f1 = factory.BuildFactory() f1.workdir="system" f1.addStep(Repo(manifest_url=manifest_url, manifest_branch=manifest_branch, manifest_file=manifest_file, tarball=repotarball)) f1.addStep(Compile(name="clobber old output",command="rm -rf out")) f1.addStep(Compile( name="download proprietaries", command="" + \ "curl \""+proprietary_url+"\" > props.tgz;" % ({'device':board}) + \ "tar zxvf props.tgz;" + \ "rm props.tgz;" ) ) f1.addStep(Compile(name="get rommanager",command="./vendor/cyanogen/get-rommanager")) buildcommand = """ set -e export LANG=C . build/envsetup.sh lunch cyanogen_%s-eng make -j4 make bacon -j4 repo manifest -o out/target/product/%s/manifest.xml """%(board,board) f1.addStep(Compile(name="compile everything",command=["/bin/bash","-c", buildcommand])) # todo should upload result of compilation somewhere else builddir="%s_%s"%(board,manifest_file) b1 = BuilderConfig(name=builddir, slavenames=slaves, builddir=builddir, factory=f1) builders.append(b1) c['builders'] = builders ## STATUS TARGETS # 'status' is a list of Status Targets. The results of each build will be # pushed to these targets. buildbot/status/*.py has a variety to choose from, # including web pages, email senders, and IRC bots. c['status'] = [] from buildbot.status import html from buildbot.status.web import auth, authz from buildbot.status.status_gerrit import GerritStatusPush from buildbot.status.builder import Results authz_cfg=authz.Authz( # change any of these to True to enable; see the manual for more # options gracefulShutdown = True, forceBuild = True, forceAllBuilds = True, pingBuilder = True, stopBuild = True, stopAllBuilds = True, cancelPendingBuild = True, ) c['status'].append(html.WebStatus(http_port=8010, authz=authz_cfg)) def gerritMessageCB(buildername, build, results): sep="-------------------------------\n" message = "buildbot finished compiling your patchset\n" message += sep message += "on configuration %s\n"%(buildername) message += sep message += "the result is %s\n"%(Results[results]) message += sep message += "more details %s/builders/%s/builds/%d\n"%(c['buildbotURL'],buildername,build.getNumber()) return message,0,0 c['status'].append(GerritStatusPush(gerrit_server,gerrit_user,gerritMessageCB)) ## PROJECT IDENTITY # the 'title' string will appear at the top of this buildbot # installation's html.WebStatus home page (linked to the 'titleURL') # and is embedded in the title of the waterfall HTML page. c['title'] = "froyo" c['titleURL'] = "http://review.android.com" # the 'buildbotURL' string should point to the location where the buildbot's # internal web server (usually the html.WebStatus page) is visible. This # typically uses the port number set in the Waterfall 'status' entry, but # with an externally-visible host name which the buildbot cannot figure out # without some help. c['buildbotURL'] = "http://buildbot.cyanogenmod.com" buildbot-0.8.8/docs/examples/twisted_master.cfg000066400000000000000000000271301222546025000216210ustar00rootroot00000000000000#! /usr/bin/python # NOTE: this configuration file is from the buildbot-0.7.5 era or earlier. It # has not been brought up-to-date with the standards of buildbot-0.7.6 . For # examples of modern usage, please see hello.cfg, or the sample.cfg which is # installed when you run 'buildbot create-master'. # This configuration file is described in $BUILDBOT/docs/config.xhtml # This is used (with online=True) to run the Twisted Buildbot at # http://www.twistedmatrix.com/buildbot/ . Passwords and other secret # information are loaded from a neighboring file called 'private.py'. import sys sys.path.append('/home/buildbot/BuildBot/support-master') import os.path from buildbot.changes.pb import PBChangeSource from buildbot.scheduler import Scheduler, Try_Userpass from buildbot.steps.source import SVN from buildbot.process.factory import s from buildbot.process.process_twisted import \ QuickTwistedBuildFactory, \ FullTwistedBuildFactory, \ TwistedReactorsBuildFactory from buildbot.status import html, words, client, mail import extra_factory reload(extra_factory) from extra_factory import GoodTwistedBuildFactory import private # holds passwords reload(private) # make it possible to change the contents without a restart BuildmasterConfig = c = {} # I set really=False when testing this configuration at home really = True usePBChangeSource = True c['slaves'] = [] for slave in private.bot_passwords.keys(): c['slaves'].append(BuildSlave(slave, private.bot_passwords[slave])) c['sources'] = [] # the Twisted buildbot currently uses the contrib/svn_buildbot.py script. # This makes a TCP connection to the ChangeMaster service to push Changes # into the build master. The script is invoked by # /svn/Twisted/hooks/post-commit, so it will only be run for things inside # the Twisted repository. However, the standard SVN practice is to put the # actual trunk in a subdirectory named "trunk/" (to leave room for # "branches/" and "tags/"). We want to only pay attention to the trunk, so # we use "trunk" as a prefix for the ChangeSource. This also strips off that # prefix, so that the Builders all see sensible pathnames (which means they # can do things like ignore the sandbox properly). source = PBChangeSource(prefix="trunk/") c['sources'].append(source) ## configure the builders if 0: # always build on trunk svnurl = "svn://svn.twistedmatrix.com/svn/Twisted/trunk" source_update = s(SVN, svnurl=svnurl, mode="update") source_copy = s(SVN, svnurl=svnurl, mode="copy") source_export = s(SVN, svnurl=svnurl, mode="export") else: # for build-on-branch, we use these instead baseURL = "svn://svn.twistedmatrix.com/svn/Twisted/" defaultBranch = "trunk" source_update = s(SVN, baseURL=baseURL, defaultBranch=defaultBranch, mode="update") source_copy = s(SVN, baseURL=baseURL, defaultBranch=defaultBranch, mode="copy") source_export = s(SVN, baseURL=baseURL, defaultBranch=defaultBranch, mode="export") builders = [] b24compile_opts = [ "-Wignore::PendingDeprecationWarning:distutils.command.build_py", "-Wignore::PendingDeprecationWarning:distutils.command.build_ext", ] b25compile_opts = b24compile_opts # FIXME b1 = {'name': "quick", 'slavename': "bot1", 'builddir': "quick", 'factory': QuickTwistedBuildFactory(source_update, python=["python2.3", "python2.4"]), } builders.append(b1) b23compile_opts = [ "-Wignore::PendingDeprecationWarning:distutils.command.build_py", "-Wignore::PendingDeprecationWarning:distutils.command.build_ext", ] b23 = {'name': "debian-py2.3-select", 'slavename': "bot-exarkun", 'builddir': "full2.3", 'factory': FullTwistedBuildFactory(source_copy, python=["python2.3", "-Wall"], # use -Werror soon compileOpts=b23compile_opts, processDocs=1, runTestsRandomly=1), } builders.append(b23) b24 = {'name': "debian-py2.4-select", 'slavenames': ["bot-exarkun"], 'builddir': "full2.4", 'factory': FullTwistedBuildFactory(source_copy, python=["python2.4", "-Wall"], # use -Werror soon compileOpts=b24compile_opts, runTestsRandomly=1), } builders.append(b24) b24debian64 = { 'name': 'debian64-py2.4-select', 'slavenames': ['bot-idnar-debian64'], 'builddir': 'full2.4-debian64', 'factory': FullTwistedBuildFactory(source_copy, python=["python2.4", "-Wall"], compileOpts=b24compile_opts), } builders.append(b24debian64) b25debian = { 'name': 'debian-py2.5-select', 'slavenames': ['bot-idnar-debian'], 'builddir': 'full2.5-debian', 'factory': FullTwistedBuildFactory(source_copy, python=["python2.5", "-Wall"], compileOpts=b24compile_opts)} builders.append(b25debian) b25suse = { 'name': 'suse-py2.5-select', 'slavenames': ['bot-scmikes-2.5'], 'builddir': 'bot-scmikes-2.5', 'factory': FullTwistedBuildFactory(source_copy, python=["python2.5", "-Wall"], compileOpts=b24compile_opts), } builders.append(b25suse) reactors = ['poll', 'epoll', 'gtk', 'gtk2'] b4 = {'name': "debian-py2.4-reactors", 'slavename': "bot2", 'builddir': "reactors", 'factory': TwistedReactorsBuildFactory(source_copy, python="python2.4", reactors=reactors), } builders.append(b4) bosx24 = { 'name': 'osx-py2.4-select', 'slavenames': ['bot-exarkun-osx'], 'builddir': 'full2.4-exarkun-osx', 'factory': FullTwistedBuildFactory(source_copy, python=["python2.4", "-Wall"], compileOpts=b24compile_opts, runTestsRandomly=1)} builders.append(bosx24) forcegc = { 'name': 'osx-py2.4-select-gc', 'slavenames': ['bot-exarkun-osx'], 'builddir': 'full2.4-force-gc-exarkun-osx', 'factory': GoodTwistedBuildFactory(source_copy, python="python2.4")} builders.append(forcegc) # debuild is offline while we figure out how to build 2.0 .debs from SVN # b3 = {'name': "debuild", # 'slavename': "bot2", # 'builddir': "debuild", # 'factory': TwistedDebsBuildFactory(source_export, # python="python2.4"), # } # builders.append(b3) b24w32_scmikes_select = { 'name': "win32-py2.4-select", 'slavename': "bot-scmikes-win32", 'builddir': "W32-full2.4-scmikes-select", 'factory': TwistedReactorsBuildFactory(source_copy, python="python", compileOpts2=["-c","mingw32"], reactors=["default"]), } builders.append(b24w32_scmikes_select) b25w32_scmikes_select = { 'name': "win32-py2.5-select", 'slavename': "bot-scmikes-win32-2.5", 'builddir': "W32-full2.5-scmikes-select", 'factory': TwistedReactorsBuildFactory(source_copy, python="python", compileOpts2=["-c","mingw32"], reactors=["default"]), } builders.append(b25w32_scmikes_select) b24w32_win32er = { 'name': "win32-py2.4-er", 'slavename': "bot-win32-win32er", 'builddir': "W32-full2.4-win32er", 'factory': TwistedReactorsBuildFactory(source_copy, python="python", compileOpts2=["-c","mingw32"], reactors=["win32"]), } builders.append(b24w32_win32er) b24w32_iocp = { 'name': "win32-py2.4-iocp", 'slavename': "bot-win32-iocp", 'builddir': "W32-full2.4-iocp", 'factory': TwistedReactorsBuildFactory(source_copy, python="python", compileOpts2=[], reactors=["iocp"]), } builders.append(b24w32_iocp) b24freebsd = {'name': "freebsd-py2.4-select-kq", 'slavename': "bot-landonf", 'builddir': "freebsd-full2.4", 'factory': TwistedReactorsBuildFactory(source_copy, python="python2.4", reactors=["default", "kqueue", ]), } builders.append(b24freebsd) osxtsr = {'name': "osx-py2.4-tsr", 'slavename': "bot-exarkun-osx", 'builddir': "osx-tsr", 'factory': TwistedReactorsBuildFactory( source_copy, python="python2.4", reactors=["tsr"])} builders.append(osxtsr) bpypyc = {'name': 'osx-pypyc-select', 'slavename': 'bot-jerub-pypy', 'builddir': 'pypy-c', 'factory': TwistedReactorsBuildFactory(source_copy, python="pypy-c", reactors=["default"])} builders.append(bpypyc) c['builders'] = builders # now set up the schedulers. We do this after setting up c['builders'] so we # can auto-generate a list of all of them. all_builders = [b['name'] for b in c['builders']] all_builders.sort() all_builders.remove("quick") ## configure the schedulers s_quick = Scheduler(name="quick", branch=None, treeStableTimer=30, builderNames=["quick"]) s_try = Try_Userpass("try", all_builders, port=9989, userpass=private.try_users) s_all = [] for i, builderName in enumerate(all_builders): s_all.append(Scheduler(name="all-" + builderName, branch=None, builderNames=[builderName], treeStableTimer=(5 * 60 + i * 30))) c['schedulers'] = [s_quick, s_try] + s_all # configure other status things c['slavePortnum'] = 9987 c['status'] = [] if really: p = os.path.expanduser("~/.twistd-web-pb") c['status'].append(html.Waterfall(distrib_port=p)) else: c['status'].append(html.Waterfall(http_port=9988)) if really: c['status'].append(words.IRC(host="irc.freenode.net", nick='buildbot', channels=["twisted"])) c['debugPassword'] = private.debugPassword #c['interlocks'] = [("do-deb", ["full-2.2"], ["debuild"])] if hasattr(private, "manhole"): from buildbot import manhole c['manhole'] = manhole.PasswordManhole(*private.manhole) c['status'].append(client.PBListener(9936)) m = mail.MailNotifier(fromaddr="buildbot@twistedmatrix.com", builders=["quick", "debian-py2.3-select"], sendToInterestedUsers=True, extraRecipients=["warner@lothar.com"], mode="problem", ) c['status'].append(m) c['title'] = "Twisted" c['titleURL'] = "http://twistedmatrix.com/" c['buildbotURL'] = "http://twistedmatrix.com/buildbot/" buildbot-0.8.8/docs/index.rst000066400000000000000000000030431222546025000161220ustar00rootroot00000000000000.. ================================== BuildBot Documentation - |version| ================================== This is the BuildBot documentation for Buildbot version |version|. If you are evaluating Buildbot and would like to get started quickly, start with the :doc:`Tutorial `. Regular users of Buildbot should consult the :doc:`Manual `, and those wishing to modify Buildbot directly will want to be familiar with the :doc:`Developer's Documentation `. Table Of Contents ----------------- .. toctree:: :maxdepth: 2 tutorial/index manual/index developer/index relnotes/index Indices and Tables ================== * :ref:`genindex` * :bb:index:`cfg` * :bb:index:`sched` * :bb:index:`chsrc` * :bb:index:`step` * :bb:index:`status` * :bb:index:`cmdline` * :ref:`modindex` * :ref:`search` Copyright ========= This documentation is part of Buildbot. Buildbot 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, version 2. 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, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. Copyright Buildbot Team Members buildbot-0.8.8/docs/manual/000077500000000000000000000000001222546025000155365ustar00rootroot00000000000000buildbot-0.8.8/docs/manual/_images/000077500000000000000000000000001222546025000171425ustar00rootroot00000000000000buildbot-0.8.8/docs/manual/_images/Makefile000066400000000000000000000006251222546025000206050ustar00rootroot00000000000000 SOURCES = overview.svg slaves.svg master.svg status.svg PNGS = $(patsubst %.svg,%.png,$(SOURCES)) EPSS = $(patsubst %.svg,%.eps,$(SOURCES)) .PHONY: images-png images-eps all: $(PNGS) $(EPSS) images-png: $(PNGS) images-eps: $(EPSS) %.png: %.svg inkscape -b white --export-png $@ $< mogrify -trim +repage $@ %.eps: %.svg inkscape --export-eps $@ $< mogrify -trim +repage $@ clean: rm -f *.png *.eps buildbot-0.8.8/docs/manual/_images/icon.blend000066400000000000000000002737241222546025000211170ustar00rootroot00000000000000BLENDER_v236REND SceneSRd+WRSR1-AnimationL, /L/33 M5DATAL,X,DATA,X,L,DATA,X -,DATA -XL-,DATAL-X- -DATA-X-L-DATA-X .-DATA .XL.-DATAL.X. .DATA.X.L.DATA.X /.DATA /X.DATAL/Y/,,DATA/Y/L/L, -DATA/Y 0/L--DATA 0YL0/,-DATAL0Y0 0, .DATA0Y0L0- .DATA0Y 10L-L.DATA 1YL10-L.DATAL1Y1 1-.DATA1Y1L1 ..DATA1Y 21L..DATA 2YL21- .DATAL2Y2 2L--DATA2Y2L2L,.DATA2Y 32L-.DATA 3YL32- /DATAL3Y3 3 - /DATA3YL3. /DATA3[<L,. / -J9:48DATA4Z5Link and MaterialsEditing>DATA5ZT64MeshEditingF>DATAT6Z$75Anim settingsObject>DATA$7Z7T6DrawObjectF>DATA7Z8$7ConstraintsObject>DATA8Z7EffectsObjectDATA9I:333?\<@DhC)DhCC(BDC?z?DATAT:K9333?\</9DATA<[<3-,, .DATA<[DC<L--.L.|+9=U_=o?  #$S?A=>DATA=Z>Transform PropertiesView3d>DATA>Z=3D Viewport propertiesView3d>"DATA?DA333?e|????????|+9=U_=o?;AkA?|+9=U_=o??????;A B?=CFF DATATAK?333?e|/9DATADC[ M<L.. .-??Pף  #$DK,D,DDATA,DZTransform PropertiesIpo!>DATADH F333?kzC̽̌?zC@ #< #<`jFzD OBzC̽̌?DATA FLHD333?k@zAAQAQAB A@CC #<@G\HDATA,GnHETADATA,Hn\HGBO`ADATA,\HnHBOp=ADATApHOLI F 333?k6 j>DATALIDKH333?k??? ???? A???PA A!O?j?}GCHB? A B? #<C@h@hDATATKKLI333?k/9DATA M[DC.L-- / 'O\QMNDATAMZNLink and MaterialsEditing>DATANZMMeshEditingF>DATAO\P 333?v<zCCHBC'?CFC= ADATA\PI\QO333?v<#DhC`DpJgChCC(BDC?z?DATAT\QK\P333?v</9SRdRW$+SR2-Model lS,UlUWWDATAlSXSDATASXSlSDATASX,TSDATA,TXlTSDATAlTXT,TDATATXTlTDATATX,UTDATA,UXTDATAlUYUSSDATAUYUlUlS,TDATAUY,VUlSlTDATA,VYlVU,TTDATAlVYV,VlTTDATAVYVlVSTDATAVY,WVS,UDATA,WYlWVT,UDATAlWYW,WlTTDATAWYlWT,UDATAW[lSlTT,T?@Pף4{mX4~DATAXZYPreviewLamp>DATAYZtZXLampLampF>DATAtZZD[YSpotLamp>DATAD[Z\tZTexture and InputLamp>DATA\Z\D[Map ToLamp>D[DATA\Z]\PreviewMaterial>DATA]Z^\MaterialMaterialF>DATA^ZT_]PreviewWorld>DATAT_Z$`^WorldWorldF>DATA$`Z`T_Mist Stars PhysicsWorld>DATA`Za$`Texture and InputWorld>DATAaZb`Map ToWorld>`DATAbZdcaOutputRender>DATAdcZ4dbRenderRenderF>DATA4dZedcAnimRender>DATAeZe4dFormatRender>DATAeZfeLink and MaterialsEditing>DATAfZtgeMeshEditingF>DATAtgZDhfMesh ToolsEditing>DATADhZitgMesh Tools 1Editing>DATAiZiDhCameraEditingF>DATAiZjiShadersMaterial>DATAjZkiTextureMaterial>oDATAkZTljAnim settingsObject>DATATlZ$mkDrawObjectF>DATA$mZmTlConstraintsObject>DATAmZn$mScriptlinksScript>DATAnZomEffectsObject$mDATAoZdpnMap InputMaterial>DATAdpZ4qoMap ToMaterial>oDATA4qZrdpAnimAnim>DATArZr4qSoundSound>DATArZsrListenerSoundF>DATAsZttrSequencerSound>DATAttZDusRampsMaterialF>]DATADuZvttMirror TranspMaterial>iDATAvZvDuShadow and SpotLamp>DATAvZwvMist / Stars / PhysicsWorld>DATAwZxvAmb OccWorld>vDATAxZTywPreviewTexture>DATATyZ$zxTextureTextureF>DATA$zZzTyColorsTextureF>TyDATAzZ{$zVoronoiTexture>DATA{Z|zRadio RenderRadio>DATA|Zd}{Radio ToolRadioF>DATAd}Z4~|HooksObjectF>TlDATA4~Zd}Particle InteractionObject>$mDATAI333?WDhC?e1D~>pCC(BDC?z?>mDATADD333?W??? ???? A??@PA Aj?c3>}GCHB? A B? #<CzzDATADL4333?W@̌AR|B1@lA A@CC #<@DATAT4KD333?WSave PNG/usr/home/warner/stuff/Projects/BuildBot/sourceforge/docs/images/logo.png :DATA[WTSS,ŰDATA[lTT,UT?p? JL  gTT̀T̀DATAZTransform PropertiesView3d>"DATATD333?DwF?Oؾ>!?Dt?jCl^1?F7?3?o̿b?CwF?!?ClDؾ>t?`1?>j@7?r@Z orQA?rY?8U^𾺚,0?v?x?j?410?7F7?2JhւAbAt5?8?W4[gy>d>Q{Z,BžA^*i~B@?p? JLDwF?Oؾ>!?Dt?jCl^1?F7?3?o̿b?`?! d:IA B?=C=r @lH3@DD DATAIT333?DdC8CnD fCC(BDC?z?DATAH333?zC AzC A #< #<`jFzD OBDATATK333?Save PNGTPUT PICTURES/usr/home/warner/stuff/Projects/BuildBot/sourceforge/docs/images/logo.pngicsblend >SRd$W|ŁRSR3-Materialodel Sing4ľ5DATAXDATAX4DATA4XtDATAtX4DATAXt,DATAX4,DATA4XtDATAtX4DATAXt,DATAX4DATA4Xt DATAtX4 DATAXt DATAXDATA4Yt4DATAtY4tDATAYtDATAY4tDATA4YtDATAtY44DATAYt4tDATAY44tDATA4YttDATAtY4DATAYtDATAY4tDATA4YtDATAtY44DATAYt44DATAY4tDATA4YttDATAtY44tDATAYt4DATAY4tDATA4Yt4DATAtY4DATAYtDATA[t?@Pף++ ,ܗLDATAܗZOutputRender>DATAZ|ܗRenderRenderF>DATA|ZLAnimRender>DATALZ|FormatRender>DATA䛁 333?zCCHBC,?CFC= ADATA䛁I䜁333?DhC?DhCC(BDC?z?mDATA䜁D$䛁333???? ???? A??@PA Aj?c3>}GCHB? A B? #<CzzDATA$L䜁333?@̌AR|B1@lA A@CC #<@DATATK$333?/9DATA[|44tDATA|[TtG@? JL - -G Hh~ԯdDATAdZ4OutputRender>DATA4ZdRenderRender>DATAZԥ4AnimRender0>DATAԥZFormatRenderH>DATAZtԥPreviewMaterial>DATAtZDMaterialMaterial>DATADZtShadersMaterial0>DATAZ䩁DTextureMaterialH>DATA䩁ZMap InputMaterialD>DATAZ䩁Map ToMaterial`>DATAI333?lCqhCC~qLmCC(BDC?z?hyDATADĮ333?lJ>H!?)xu?i6>Pbܗ=(?^L?a?滎z?J>#xu?ܗ=Hi6>(? ?Pb^L?\FAA?4[?[!?@jG>gb>Pb>4Im>k&?]M^L<0@AzA=>Kc<2֍7&> ?:x¸A1vB,jB~@G@? JLJ>H!?)xu?i6>Pbܗ=(?^L?a?滎z?@?$$OA  B?=Ch?j(4?t'>SSdDATAĮHԯ333?lzC AzC A #< #<`jFzD OBDATATԯKĮ333?l;SAVE FILE/usr/home/intrr/blender/blend/untitled.blendDATAT[ |4tU.=z=o?- -GH S<DATA<D|333?\???h?j(4?t'>????hj(4t'?U.=z=o??OA  B?=Ch?j(4?t'>dDATA|I|<333?\DdC>9C,DeCC(BDC?z?DATA|H|333?\zC AzC A #< #<`jFzD OBDATATK|333?\ SAVE FILE/Users/ton/Desktop/der/blend/untitled.blendDATA [ľTte?8?AHM    SDDATAD4333?,L?В ?K?ȳ>?M?K?<ȳ>T5L?В ?ZI? @μ@?ƾ\?lU В (??3>>]`b4AAVl>K?x<4쓾S>>3ApןAAA)@e?8?AHML?В ?K?ȳ>?L?ՙξAE;OA  B?=Ch?j(4?t'>dDATA4I4333?,DdC>9C,DeCC(BDC?z?DATA4HD4333?,zC AzC A #< #<`jFzD OBDATATDK4333?, SAVE FILE/Users/ton/Desktop/der/blend/untitled.blendDATAľ[ 44(.=^=o?    SÁDATAD333??3?3^I2(o(4?? 3?3^=i(4J2(A?(.=0n;^=ꉖW5jOT{:?OA2 5AC^=k(4J2(A?(.=^=o??3?3^I2(o(4?5?5OA  B?=C^j(4?J2( Z ZdDATAI333?DdC>9C,DeCC(BDC?z?DATAHÁ333?zC AzC A #< #<`jFzD OBDATATÁK333? SAVE FILE/Users/ton/Desktop/der/blend/untitled.blendSRd|ŁW$SR4-Sequence Ɓȁ ɁĹ́ 5DATA ƁXLƁDATALƁXƁ ƁDATAƁXƁLƁDATAƁX ǁƁDATA ǁXLǁƁDATALǁXǁ ǁDATAǁXǁLǁDATAǁX ȁǁDATA ȁXLȁǁ\DATALȁXȁ ȁ\DATAȁXȁLȁ\DATAȁXȁDATA ɁYLɁLƁƁDATALɁYɁ Ɂ ƁƁDATAɁYɁLɁ Ɓ ǁDATAɁY ʁɁƁLǁDATA ʁYLʁɁ ǁLǁDATALʁYʁ ʁLƁǁDATAʁYʁLʁƁǁDATAʁY ˁʁǁǁDATA ˁYLˁʁ ǁ ȁDATALˁYˁ ˁǁ ȁDATAˁYˁLˁǁLȁDATAˁY ́ˁLǁLȁDATA ́YĹˁ ȁLȁDATAĹÝ ́ ȁȁDATÁÝĹLȁȁDATÁY ́́ǁȁDATA ́YĹ́ǁȁDATAĹY ́ȁȁDATÁ[4ԁ Ɓ ǁLǁƁсҁt΁ЁDATAt΁ZDρOutputRender>DATADρZЁt΁RenderRenderF>DATAЁZЁDρAnimRender>DATAЁZЁFormatRender>DATAсIҁ333?uDhC&ԓDhCC(BDC?z?DATATҁKс333?u/9DATA4ԁ[Ձ́ǁLƁƁǁDATAՁ[ځ4ԁ ǁ ȁLȁLǁ8=i>o?[  [P ցفDATAցJց333?}|zCAzCAPP A@FB= A DATAցDفց333?}|????????8=i>o?fffAD&@??fffA B? #<CDATATفKց333?}|AVE TARGA/t1.blend9DATAځ[ ՁȁȁǁLȁ8=H>o?]]]]S |ہށDATA|ہJL܁333? zCAzCA1||1 A@FB= A DATAL܁Dށ|ہ333? ????????8=H>o?fffA*@??fffA B? #<C>>DATATށKL܁333? AVE TARGA/t1.blend9DATA [ځ ȁǁȁȁ8=>o?]]wx8DDATAH333?ሜB̽̌?B̽̌?88 #< #<`jFzD SQB̽̌?DATADD333?ሜ????????8=>o?fffA@??fffA B? #<CzzDATATDK333?ሜOAD FILE/9SCBSCScenetageainT Ǿ,e^R@<dd??< dXdd?? Z@@???//backbuf/usr/home/warner/stuff/Projects/BuildBot/sourceforge/docs/images/pics//ftype@&#@^@&^#DDATA< DATA<$lDATA<d 2dDATAd< 2DATA<dDATA<'(DATA(<;,d'=A@CAlCACameraamera.001=BA?LA$<LASpot?[?d??+r?AB>??@ AA4B?@@???LA<$LASpot.001*`@?coF???+r?AB>??@ AA4B?@@???WOT:WOWorldg=pb>>===??A@pA A?L=DATAX??????????L>OB8dOBCameraamera.001 뽾-@???B?;ļb"N???Nb<9?Z/?Lռh/]9?뽾-@??????/N0\0??2ޯn?^1_ S_3?OBd??)d??>)d????$DATA$7OBd8lOBCylinder.001 A=A> =G>G>???G>G>?A=A> =?????G @˄<`D?|>Uj>=?@???OBl8dOBCylinder.002dOtdOttA=A> =Ǣu?Ǣu?z?Fӿ??{t{t?z?A=A> =?????dH?ݐ<*@ U>O7?E=+?@/c> K@?DOBd?? #=?>=?@???DATAtOB8lOBLamp $L'XE@???[M{?[⽈ r??P?JNv=>>IS?54߾_ ?L'XE@?????$?l>0uyZ?k=s? >s98?x@!@=i?DOBd?? #=?>=@???OB8OBCylinderA=A> =????????A=A> =??????Nc<9?[/?Lռh/\9?q9&̝&@?DOBd?? #=?>=?@???OB8OBLamp.001 </{@@??? {?9?>D>??)3j?>>şsI ?RP?~>r_>z?9\%iϾP,L>gkd?B`7@V?DOBd?? #=?>=@???MA`!MAmetal"h?"h?"h???????????L????2 ?????@?=?=??D?DATAXD ??????????L>TETETex.001\>@???????@@????? @ ??<?ME+ MECylinder  ٮ?ٮ??DATA0??GGٮ?gag>ٮ@IIٮg>@gٮ?a???GGNٮ?g?aNg>ٮ?@N?IINٮg>?@Ngٮ??aN?DATAh ,             @@   @ @ DATA  1H;;;<<<555;;;CCC<<<;;;@@@CCC;;;888@@@;;;444888;;;555444555<<`[-<?I3*]<?B`[<u=?Ћ VY<?L#IwM><?A <L#I?3<Ћ ?u=#<B>-<%e֢<B<ދ r=Y<Y#I>̉< <?#I><x=‹ Y<B<gH5֢<B>-<o= ?#<f#I?3< ?A<3#I ?>wM< {=?YV<hB?`[<[6?*]<B>?,`[< ?l=?#V<r#I? ?3wM<???AAW%?&#I??wM3W~=? ??V#W?JB>?`[-W?RU?*]W?C?`[Wn=? ?VYW?c#I?wM>W??A WI#I??3W׋ ?t=?#WB>?-W ~5?֢WqB?W |=?YW$#I%?>̉W? W#I?>We= ?YW}[C?W¶?֢WA>?,W=h ??#W:"I??3W0??AW#I??>wMWb W=??YVWCu??`[W,??*]WJA>??,`[W# ?=??#VW"I?O??3wMW?DATA,,`@A! @A"!@A#"@A$#@A%$@A&%@A'&@A('@ A)(@ A*)@ A+*@ A,+@ A-,@ A.-@A/.@A0/@A10@A21@A32@A43@A54@A65@A76@A87@A98@A:9@A;:@A<;@A=<@A>=@A?>@A ? !@!"@"#@#$@$%@%&@&'@'(@()  )*  *+  +,  ,-  -. ./ /0 01@12@23@34@45@56@67@78@89 9: :; ;< <= => >?  ? ME+ MEMeshQt48c48c<2Q( ?@ ?@6,?DATADATA 0(?hFǾ6,ʶ?V6,?@N)36,?B>6,į?E>6,ɤ?ɤ?6,T @T @6,GG?@uM6,aɤ?ɤ?6,?į?%F>6,??B>6,??@K6,?ʶ?6,??WFǾ6,? ?@suM6,?aNT @T @6,?GGN}?Ry6,鷼?V6,`?~ 6,?跼?V6,?{?Ry6,?_? 6,??Z?@6,llW?@6,?llWZz=? ?6,!?0#I?6,? i?6,??6,? i?6,?%?*#I?6,?s=?̋ ?6,???6,?fw??6,P:>)/@6,llWfw??6,?:>"/@6,?llWZ۾?6,@6,?puM?@6,a۾?6,?uM?@6,?aNȊ@6,??Z*-?D?6,S ?=?6,UA>?6,>ʶ?6,w>ʶ?6,?MA>?6,? ?~=?6,?+-?:?6,?6,1p>6,B>6,9>?6,uuM? ?6,@T T 6,IIQ>=6,?B>6,?1p>6,?6,?T T 6,?IINuM??6,?@N]; 6,X; 6,?ZӋ ?u=6,]+-?6,^+-?6,?֋ ?u=6,?Ý?6,zt1p?6,̿9>6,?uM?6,@s?6,?̿>6,?y࿁1p?6,?Ý?6,?q?6,??Z!?SuM?6,?@N5ys6,GpDp6,2yt6,?ApMp6,?Ay6,+C6,j= 6,n+-6, +-6,?g= 6,?B6,?Aܿ6,? *x4?6,gB?6,r]?6,aB?6,?@_ ?6,?@x]?6,?V淼6,i6,Lʶ6,56,$v56,?Zʶ6,?i6,?V緼6,???6,??6,?_#I??6, #I?3?6,?J#I?6,?6,??-Q6,?=-Q6,??6,?J#I?6,?5@?6, }=?6,Ipep?6,Mpkp?6,? =?6,?tB?6,??B6,=?B6,?B6,?@?66,??|+-6,n=? 6,s=?Ӌ 6,??z+-6,?K#I6,6,|#I6,? 6,?#I-6,O+-6,L+-6,?2#I"6,?ºfw?6,~+-?6,t=׋ ?6,?U>6,=~ ?6,?+-?6,?ƺfw?6,?H">6,?B>6,ʶ>6,ʶ>6,?B>6,?5#I!?6, i?6, i?6,?>#I%?6,??6,6 #I?6,a#I?6,??6,?>56,|56,?׋ t=6,B6,B6,? {=6,??P#I6,?_#I6,?`? *>Z];  &^͊@ =Z?@[> !{ڒ?R?6,?ͮ?^w?6,ͮ?F#I?6,#?.#I?6,???6,???6,1? ?6,?f;?>?6,u=?͋ ?6,}=? ?6,?ݯ?MX>6,?6,?B>6,w?[B>6,?w,t?7=6,?uM?ml>6,u?j6,?]'16,?J?l6,? ,t?76, ?B6,w?B6,?w6?6,?'%ݯ?X6,'%t=?֋ 6,0p=? 6,?0^;?>6,?V<(?/6,V<?P#I6,G?_#I6,?G ?kw6,?3Q ڒ?R6,3Q?6,Z?6,?ZR? ڒ6,?ͮbcw?6,ͮbL#I?6,ljJ#I?6,?lj1?)6,?p>?d;6,pҋ ?v=6,@v֋ ?u=6,?@vX>ݯ6,?|z><꨿6,|zB>6,}B>6,?}a7=+t6,?ual>N6,ua56,$v56,?+lP6,? a]7,t6, aB6,}B6,?}yA꨿6,?'%|zXݯ6,'%|z׋ t=6,0@v {=6,?0@v>n;6,?V6,pV<u=ы 6,@v0i= 6,?@v0ݯ,Y6,?|z'%=꨿6,|z'%B6,}9C6,?},t*76,?a Pl6,a Y46,t6,?V>l>6,?au.tj7=6,auB>6,}B>6,?}O꨿>6,?|zݯX>6,|zt=׋ ?6,@v=~ ?6,?@v;>?6,?p+2?6,pQ#I?6,lj3"I?6,?lj6w?6,?bͮ ڒR?6,bͮ?6,Z'?6,?ZLRْ?6,?3Qdw?6,3QK#I?6,G#I?6,?G?6,?V<>f;?6,V<ы v=?6,0F ]=?6,?0Yݯ?6,?'%??6,'%B?6,wCy?6,?w7,t?6,? lP?6,  *x4?6,@_ ?6,?Ql>\?6,?ut7=/t?6,uB>?6,wA>?6,?w>Y?6,?ڄX>ݯ?6,ڄ׋ ?t=?6,G ?=?6,?n>?;?6,?4?+?6,R#I??6,且"I?D?6,?且R?ْ?6,ͮw??6,?ͮDATA<2,         ! !"##"%&$)'(! *$&!!*+-$!!+,,-!('.1"##(.01##.//0#672456623346=89:;<<=99:<7>2<;?65A5@@ABC8==BBC=GHDEFGGDEMIJKLMMJK2ONN32;:PPQ;>GFO2>>FRUO>>RSTU>>ST?;QYIMM?QXYMMQVWXMMVW$-Z[\$$Z[^.''_]]^'H%$\DHH$\LK__'))L_3N`c433`abc33abgP::9dfg::deef:hihj* j*"1kki"knAlmnlmqBopqopED\\rstE\\st_KJvw__Juuv_\[rw]_yxx zz{ ny}n|}|o {~{~OOUVQQO``NOQPggQ`a``gfgghiFEtFFtFFuJJIJJJFRFFIYIItsttvuuux z RSRYYXA@lqCBabaaffefnm|npooUTWV*j+0k1-,Z^/.bcde|}~ @@=6  6 @=@@6<7 <6= >7@MG>@>M@?M@7<?@?7@HG (&% H( (%H )(@GML@)G@GL)@ &(# & &# !&  ! ! @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@                                @@@@@@@@@@@@@@@@@@@@@@@@@@@@ @ @@@                                 !  !   %"# #$%  #" "!  $&' '%$ '& & DATAQ1 ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssGLOB\R DNA1V?SDNANAME*next*prev*first*lastxyzwxminxmaxyminymax*newid*libname[24]usflagpadid*idblock*filedataname[160]totcurvecurblocktypeshowkeypostyperttotelem*dataname[32]sliderminslidermax*refkeyelemstr[32]elemsizecurvalblock*ipo*fromtotkeyslurphactkey**scripts*flagactscripttotscript*linelenblen*nameflagsnlineslines*curl*sellcurcselc*undo_bufundo_posundo_len*compiledsizeseekdrawzoomholdclipstaclipendlensdrawsizeYF_dofdistYF_apertureYF_bkhtypeYF_bkhbiasYF_bkhrotscriptlink*anim*ibuf*mipmap[10]oklastframelastqualitytpageflagtotbindxrepyreptwstatwendbindcode*repbind*packedfilelastupdateanimspeedreserved1texcomaptomaptonegblendtype*object*texprojxprojyprojzmappingofs[3]size[3]texflagcolormodelrgbkdef_varcolfacnorfacvarfacdispfacwarpfac*handle*pname*stnamesstypesvars*varstr*result*cfradata[32](*doit)()(*callback)()versionaipotypedata[16]*ima*cube[6]imat[4][4]stypenotlaycuberesdepthrecalclastsizepad1noisesizeturbulbrightcontrastrfacgfacbfacfiltersizemg_Hmg_lacunaritymg_octavesmg_offsetmg_gaindist_amountns_outscalevn_w1vn_w2vn_w3vn_w4vn_mexpvn_distmvn_coltypenoisedepthnoisetypenoisebasisnoisebasis2imaflagcropxmincropymincropxmaxcropymaxxrepeatyrepeatextendcheckerdistnablaframesoffsetsfrafie_ima*nor*plugin*coba*envfradur[4][2]modetotexenergydistspotsizespotblendhaintatt1att2bufsizesampshadspotsizebiassoftray_sampray_sampyray_sampzray_samp_typearea_shapearea_sizearea_sizeyarea_sizeztexactshadhalostepYF_numphotonsYF_numsearchYF_phdepthYF_useqmcYF_bufsizeYF_padYF_causticblurYF_ltradius*mtex[10]layspecrspecgspecbmirrmirgmirbambrambbambgambemitangspectraray_mirroralpharefspeczoffsaddtranslucencyfresnel_mirfresnel_mir_ifresnel_trafresnel_tra_iray_depthray_depth_traharseed1seed2mode2flarecstarclinecringchasizeflaresizesubsizeflareboostrgbselpr_typeseptexpr_backpr_lampdiff_shaderspec_shaderroughnessrefracparam[4]*ramp_col*ramp_specrampin_colrampin_specrampblend_colrampblend_specramp_showpad3rampfac_colrampfac_spec*renfrictionfhreflectfhdistxyfrictdynamodepad2name[256]scale*bbi1j1k1i2j2k2selcolexpxexpyexpzradrad2smaxrad2*mat*imatelemsdisp**mattotcolloc[3]rot[3]wiresizerendersizethreshvec[3][3]alfas[3][2]h1h2f1f2f3hidevec[4]s[2]mat_nrpntsupntsvresoluresolvorderuordervflaguflagv*knotsu*knotsv*bp*beztnurb*bevobj*taperobj*textoncurve*path*keybev*orcopathlenbevresolwidthext1ext2spacemodespacinglinedistshearfsizexofyof*strfamily[24]*vfontmaxrcttotrctadrcodevartypetotvertipoextrapbitmask*tpageuv[4][2]col[4]transptileunwrapeffect*mface*dface*tface*mvert*medge*dvert*mcol*msticky*texcomesh*oc*sumohandletotedgetotfacesmoothreshsubdivsubdivrsubdivdonesubsurftypecubemapsizev1v2v3v4punoedcodecreasedef_nrweight*dwtotweightco[3]no[3]co[2]pntswtypeutypevtypew*defdvec[3]max**obdeflectforcefieldpdef_damppdef_rdamppdef_permf_strengthf_powerpartypepar1par2par3parsubstr[32]*pardata*parent*track*action*pose*activeconconstraintChannelsnetworkdefbasedloc[3]orig[3]dsize[3]drot[3]quat[4]dquat[4]obmat[4][4]parentinv[4][4]colbitstransflagipoflagtrackflagupflagipowinscaflagscavisflagboundtypedupondupoffdupstadupendsfctimemassdampinginertiaformfactorspringfrdampingsizefacdtdtxactcolpropsensorscontrollersactuatorsbbsize[3]dfrasactdefgameflaggameflag2softflaganisotropicFriction[3]constraintsnlastripshooks*pd*soft*lifelbufporttoonedgemat[4][4]cent[3]falloff*indexartotindexcurindexactiveforcemistypehorrhorghorbhorkzenrzengzenbzenkambkfastcolexposureexprangelinfaclogfacgravityactivityBoxRadiusskytypemisimiststamistdistmisthistarrstargstarbstarkstarsizestarmindiststardiststarcolnoisedofstadofenddofmindofmaxaodistaodistfacaoenergyaobiasaomodeaosampaomixaocolorphysicsEnginehemiresmaxiterdrawtypesubshootpsubshootenodelimmaxsublamppamapamielmaelmimaxnodeconvergenceradfacgammasxsy*lpFormat*lpParmscbFormatcbParmsfccTypefccHandlerdwKeyFrameEverydwQualitydwBytesPerSeconddwFlagsdwInterleaveEveryavicodecname[128]*cdParms*padcdSizeqtcodecname[128]mixratemainpad[3]*avicodecdata*qtcodecdatacfraefraimagesframaptoframelenblurfacedgeRedgeGedgeBfullscreenxplayyplayfreqplayattribrt1rt2stereomodemaximsizexschyschxaspyaspxpartsypartssafetyborderwinposplanesimtypebufflagqualityscemoderendererocresrpad[2]alphamodedogammaosafrs_secedgeintsame_mat_reduxgausspostmulpostgammapostaddpostigammadither_intensitypad_ditherGIqualityGIcacheGImethodGIphotonsGIdirectYF_AAYFexportxmlyfpad1[3]GIdepthGIcausdepthGIpixelspersampleGIphotoncountGImixphotonsGIphotonradiusYF_numprocsYF_raydepthYF_AApassesYF_AAsamplesGIshadowqualityGIrefinementGIpowerGIindirpowerYF_gammaYF_exposureYF_raybiasYF_AApixelsizeYF_AAthresholdbackbuf[160]pic[160]ftype[160]col[3]*camera*world*setbase*basact*groupcursor[3]selectmode*ed*radioframingaudiozoomblendximyim*rectspacetypeblockscale*areablockhandler[8]viewmat[4][4]viewinv[4][4]persmat[4][4]persinv[4][4]winmat1[4][4]viewmat1[4][4]viewquat[4]perspview*bgpic*localvdlocalviewlayactscenelockaroundcamzoomgridnearfarmxmymxomyogridlinesviewbutgridflagmodeselectmenunrtexnrverthormaskmin[2]max[2]minzoommaxzoomscrollkeeptotkeepaspectkeepzoomoldwinxoldwinyrowbutv2d*editipoipokeytotipopinbutofschannellockmedian[3]cursenscuractaligntabomainbmainbo*lockpointexfromshowgrouprectxrectycurymodeltypescriptblockre_aligntab[7]*filelisttotfiletitle[24]dir[160]file[80]ofssortmaxnamelencollums*libfiledataretvalmenuact(*returnfunc)()*menupoopsvisiflagtree*treestoreoutlinevisstoreflag*imageimanrcurtile*texttopviewlinesfont_idlheightleftshowlinenrstabnumbercurrtab_setpix_per_linetxtscrolltxtbar*scripttitle[28]fasesubfasemouse_move_redrawimafasedirslidirsli_linesdirsli_sxdirsli_eydirsli_exdirsli_himaslifileselmenuitemimasli_sximasli_eyimasli_eximasli_hdssxdssydsexdseydesxdesydeexdeeyfssxfssyfsexfseydsdhfsdhfesxfesyfeexfeeyinfsxinfsyinfexinfeydnsxdnsydnwdnhfnsxfnsyfnwfnhfole[128]dor[128]file[128]dir[128]*firstdir*firstfiletopdirtotaldirshilitetopfiletotalfilesimage_sliderslider_heightslider_spacetopimatotalimacurimaxcurimay*first_sel_ima*hilite_imatotal_selectedima_redraw*cmap*arg1outline[4]neutral[4]action[4]setting[4]setting1[4]setting2[4]num[4]textfield[4]popup[4]text[4]text_hi[4]menu_back[4]menu_item[4]menu_hilite[4]menu_text[4]menu_text_hi[4]but_drawtypeback[4]header[4]panel[4]shade1[4]shade2[4]hilite[4]grid[4]wire[4]select[4]active[4]transform[4]vertex[4]vertex_select[4]edge[4]edge_select[4]edge_seam[4]edge_facesel[4]face[4]face_select[4]face_dot[4]normal[4]vertex_sizefacedot_sizepad1[2]tuitbutstv3dtfiletipotinfotsndtacttnlatseqtimatimaseltexttoopsspec[4]dupflagsavetimetempdir[160]fontdir[160]renderdir[160]textudir[160]plugtexdir[160]plugseqdir[160]pythondir[160]sounddir[160]yfexportdir[160]versionsvrmlflaggameflagswheellinescrolluiflaglanguageuserprefviewzoomconsole_bufferconsole_outmixbufsizefontsizeencodingtransoptsmenuthreshold1menuthreshold2fontname[256]themesundostepscurssizetb_leftmousetb_rightmouselight[3]vertbaseedgebaseareabase*scenestartxendxstartyendysizexsizeyscenenrscreennrfullmainwinwinakt*newvvec*v1*v2panelname[64]tabname[64]ofsxofsycontrolold_ofsxold_ofsysortcounter*paneltab*v3*v4*fullwinmat[4][4]headrctwinrctheadwinwinheadertypebutspacetypewinxwinyhead_swaphead_equalwin_swapwin_equalheadbutlenheadbutofscursorspacedatauiblockspanels*curscreen*curscenedisplaymodefileflagsglobalfname[40]*se1*se2*se3nrdone*stripdatadir[80]orxoryname[80]*newseqstartstartofsendofsstartstillendstillmachinestartdispenddispmulhandsize*strip*curelemfacf0facf1*seq1*seq2*seq3seqbase*soundlevelpancurpos*effectdata*oldbasep*parseq*seqbasepmetastackedgeWidthangleforwardwipetypefMinifClampfBoostdDistdQualitybNoCompbuttypestaendlifetimetotpartseednormfacobfacrandfactexfacrandlifeforce[3]dampvectsizedefvec[3]mult[4]life[4]child[4]mat[4]texmapcurmultstaticstep*keysheightnarrowspeedminfactimeoffs*obpremat[4][4]postmat[4][4]vec[3]faclenoalphaoeff[2]iterlastfralimbbaseeff[3]effg[3]effn[3]memslowtotytotxxyconstrainttotdefdef_scrolllimb_scrollused*idusedelemdxdylinkotypedataold*poin*oldpoinresetdistlastval*makeyqualqual2targetName[32]toggleName[32]value[32]maxvalue[32]materialName[32]damptimeraxisdelaypropname[32]matname[32]axisflag*fromObjectsubject[32]body[32]pulsefreqtotlinks**linksinvertfreq2str[128]*mynewinputstotslinks**slinksvalvalopad5time*actblendinprioritystridelengthstrideaxisreserved2reserved3sndnrmakecopycopymadepad[1]trackvolume*melinVelocity[3]localflagforceloc[3]forcerot[3]linearvelocity[3]angularvelocity[3]addedlinearvelocity[3]anotherpad[4]butstabutendminvisifacminloc[3]maxloc[3]minrot[3]maxrot[3]distributionint_arg_1int_arg_2float_arg_1float_arg_2toPropName[32]*toObjectbodyTypefilename[64]loadaniname[64]goaccellerationmaxspeedmaxrotspeedmaxtiltspeedrotdamptiltdampspeeddamp*sample*stream*newpackedfile*snd_soundpanningattenuationpitchmin_gainmax_gaindistancestreamlenloopstartloopendchannelshighpriopad[10]gaindopplerfactordopplervelocitynumsoundsblendernumsoundsgameengine*gkeypadfokeygobjectgkey*activechildbaserollhead[3]tail[3]parmat[4][4]defmat[4][4]irestmat[4][4]posemat[4][4]boneclassfiller1filler2filler3bonebasechainbaseres1res2res3chanbase*achan*pchanactnrname[30]enforceoffset[3]orient[3]roll[3]*tartoleranceiterationssubtarget[32]cacheeff[3]cachemat[4][4]lockflagfollowflagzminzmaxvolmodeplaneorglengthbulgeactstartactendstridelenrepeatblendoutTYPEcharucharshortushortintlongulongfloatdoublevoidLinkListBasevec2svec2ivec2fvec2dvec3ivec3fvec3dvec4ivec4fvec4drctirctfIDLibraryFileDataIpoKeyBlockKeyScriptLinkTextLineTextPackedFileCameraImageanimImBufMTexObjectTexPluginTexCBDataColorBandEnvMapLampWaveMaterialVFontVFontDataMetaElemBoundBoxMetaBallBezTripleBPointNurbCurvePathIpoCurveTFaceMeshMVertMEdgeMDeformVertMColMStickyOcInfoMFaceMDeformWeightBoneLatticebDeformGroupLBufPartDeflectbActionbPosebConstraintChannelSoftBodyLifeObHookWorldRadioBaseAviCodecDataQuicktimeCodecDataAudioDataRenderDataGameFramingSceneGroupBGpicView3DSpaceLinkScrAreaView2DSpaceInfoSpaceIpoSpaceButsSpaceSeqSpaceFiledirentryBlendHandleSpaceOopsTreeStoreSpaceImageSpaceNlaSpaceTextSpaceScriptScriptSpaceImaSelImaDirOneSelectableImaThemeUIThemeSpacebThemeSolidLightUserDefbScreenScrVertScrEdgePanelFileGlobalStripElemStripPluginSeqSequencebSoundMetaStackEditingWipeVarsGlowVarsEffectBuildEffPartEffParticleWaveEffDeformLimbIkaTreeStoreElemOopsbPropertybNearSensorbMouseSensorbTouchSensorbKeyboardSensorbPropertySensorbCollisionSensorbRadarSensorbRandomSensorbRaySensorbMessageSensorbSensorbControllerbExpressionContbPythonContbActuatorbAddObjectActuatorbActionActuatorbSoundActuatorbCDActuatorbEditObjectActuatorbSceneActuatorbPropertyActuatorbObjectActuatorbIpoActuatorbCameraActuatorbConstraintActuatorbGroupActuatorbRandomActuatorbMessageActuatorbGameActuatorbVisibilityActuatorFreeCamerabSamplebSoundListenerSpaceSoundGroupKeyObjectKeyGroupObjectbArmaturebPoseChannelbActionChannelSpaceActionbConstraintbKinematicConstraintbTrackToConstraintbRotateLikeConstraintbLocateLikeConstraintbActionConstraintbLockTrackConstraintbFollowPathConstraintbDistanceLimitConstraintbRotationConstraintbStretchToConstraintbActionStripTLEN  0PDtdl(XP4`@d<0P<  L, @,(D$$x$T p H`P8d@l  8( ,@0,HhH,(lDLP< <@Lx0848l(@0pP<`|,,,8,<48STRC                    !" #$%& '()*+,-./0123 456 789:;<=> ?!@A5 "BCDEFGHIJKL(M#$N%O%PQRSTUVWXYZ[!\]^_&`abc'd(efghijklmnopqrstuvw) xyz{| }~ *nop+*,'d##DEQR(52t(#)+,--mnopqDE&(M.(/Omnop     `a++&(/ !M0"#1!\2223$%&'()*+,-./01223443$ 5 6(/78l9k:;<=5 >?@ABCDEF6G?HCF777IFJKLMNOPQRS6T5U8'3$ V 6'W'X'Y(9Z[/7 \]9k:l^8_`abLM27cdefghijk0l:::6T5Umnopqrs  t&; uvwxyz<#3$ { 6([/7 | } ~=>?@A<]B ql9k:8_CI>DE?D_=I@nopAF JK6([GGG BkH'I'X ''(9Z3$JK L  {  6 /79k:8M     w   IMNHHOOO' P7m      !"(&MQ#$%&'()*+,-./01RRR+23'dS 4 56789:;<=>?T @ AB!CUDEFVXSGTHIJKLMNOPQRSTUVWXYF@Z[\]^_`abcdefghijklmnopq1rstuvwxyz{|}~W!X'PX# RY Q WVnUMZ #(ehi@[+\\]'Z[%Fj^\\\]_\\]`\\]!^  ()a\\]^ !b \\]^!c\\]de f\\]^   gh\\]^#!hii \\]^j\\]   5  k \\]l!5mQ\\] !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHnInJKLMNOPQRSTUVoWoXYZ!%[  \p]^_`abcdefghijklm!qnfgopqrstuvwxyz{|}~rrr pqqqqqqqqqqqqqswGt# su   Xvvvv wwwvvbxxxx]]]vvvvun   y  cz%OzzzQ{ {{2z|  xy| } }&}}} 2{z(X$N|}} }  ~    }   2 !"#*$%&'()*+,-./0123456`789+:" ';<=>? 2@?ABCD EFGHIJKLM('NOPQRgS FTUR V  WXY Z [ \]! /^_`abc de fg+! h $i jkl'mnoWpqr! s';tuv wxrWy! sz{|}~'; J ! _  !~  ! ~';< X' d'; 9:  ! ';6?h+J  ! Y$j'n!o ! wW '; ~ !\! (5\\]^~ hi!JI ''(9k:'; Y  EEEE  9k    9k _K (  _J \\]^J_!LLL( (W_~'_'_'_'_'!J'''!   '_(J!ENDBbuildbot-0.8.8/docs/manual/_images/master.ai000066400000000000000000010244101222546025000207520ustar00rootroot00000000000000%PDF-1.5 % 1 0 obj <>/OCGs[7 0 R 366 0 R]>>/Pages 3 0 R/Type/Catalog>> endobj 2 0 obj <>stream application/pdf BuildMaster Architecture Georgi Valkov Adobe Illustrator CS4 2010-01-28T16:24:34+02:00 2010-01-28T16:25:29+02:00 2010-01-28T16:25:29+02:00 256 232 JPEG /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgA6AEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FUj81+brDy7bQ+p G93qN4xj03TIKGa4kUAtxrRVRAau7Gij3oCqxGdvOmsVk1TV30qB9103SKJxFagSXcitM7U6lPTH tgtNKLeW5AAYtc1mKRekg1G4c/SsrSIfpXG1pExeafOHl4+rqjf4i0YEme4jiSLUIF/nMcQWK4Re /BUb2bDaCGe6Xqdhqun2+o6fOtzZXSCSCZDVWVtwcVRWKuxV2KuxV2KuxV2KuxV2KuxV2KuxV2Ku xV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxVD319FZw+o+7GvBKgEkCvU9AO57Yq8t0S7/SAvPO+qbS3 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 uuid:9E3E5C9A8C81DB118734DB58FDDE4BA7 xmp.did:08FC8385150CDF1198A8D064EBA738F3 uuid:53696637-b28c-4dc8-8338-84d9d8d92e67 proof:pdf converted from application/pdf to <unknown> saved xmp.iid:D07F11740720681191099C3B601C4548 2008-04-17T14:19:10+05:30 Adobe Illustrator CS4 / converted from application/pdf to <unknown> converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FC7F117407206811B628E3BF27C8C41B 2008-05-22T14:51:08-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FD7F117407206811B628E3BF27C8C41B 2008-05-22T15:15:38-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:0CC3BD25102DDD1181B594070CEB88D9 2008-05-28T17:07:17-07:00 Adobe Illustrator CS4 / saved xmp.iid:34001D5FB161DE119286837643AC861D 2009-06-25T23:53:30+03:00 Adobe Illustrator CS4 / saved xmp.iid:35001D5FB161DE119286837643AC861D 2009-06-25T23:56:39+03:00 Adobe Illustrator CS4 / saved xmp.iid:36001D5FB161DE119286837643AC861D 2009-06-25T23:56:54+03:00 Adobe Illustrator CS4 / saved xmp.iid:33F582E93563DE11BB48ECB7764A1480 2009-06-27T21:11:20+03:00 Adobe Illustrator CS4 / saved xmp.iid:520E91AC4863DE11954883E494157F9B 2009-06-27T21:32:35+03:00 Adobe Illustrator CS4 / saved xmp.iid:08FC8385150CDF1198A8D064EBA738F3 2010-01-28T16:24:35+02:00 Adobe Illustrator CS4 / uuid:90c2448e-df43-4a47-b473-2a9e47187516 xmp.did:520E91AC4863DE11954883E494157F9B uuid:9E3E5C9A8C81DB118734DB58FDDE4BA7 proof:pdf Basic RGB Document 1 True False 800.000000 600.000000 Pixels MyriadPro-Bold Myriad Pro Bold Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-Bold.otf MyriadPro-Cond Myriad Pro Condensed Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-Cond.otf Cyan Magenta Yellow Black Default Swatch Group 0 White RGB PROCESS 255 255 255 Black RGB PROCESS 0 0 0 RGB Red RGB PROCESS 255 0 0 RGB Yellow RGB PROCESS 255 255 0 RGB Green RGB PROCESS 0 255 0 RGB Cyan RGB PROCESS 0 255 255 RGB Blue RGB PROCESS 0 0 255 RGB Magenta RGB PROCESS 255 0 255 R=193 G=39 B=45 RGB PROCESS 193 39 45 R=237 G=28 B=36 RGB PROCESS 237 28 36 R=241 G=90 B=36 RGB PROCESS 241 90 36 R=247 G=147 B=30 RGB PROCESS 247 147 30 R=251 G=176 B=59 RGB PROCESS 251 176 59 R=252 G=238 B=33 RGB PROCESS 252 238 33 R=217 G=224 B=33 RGB PROCESS 217 224 33 R=140 G=198 B=63 RGB PROCESS 140 198 63 R=57 G=181 B=74 RGB PROCESS 57 181 74 R=0 G=146 B=69 RGB PROCESS 0 146 69 R=0 G=104 B=55 RGB PROCESS 0 104 55 R=34 G=181 B=115 RGB PROCESS 34 181 115 R=0 G=169 B=157 RGB PROCESS 0 169 157 R=41 G=171 B=226 RGB PROCESS 41 171 226 R=0 G=113 B=188 RGB PROCESS 0 113 188 R=46 G=49 B=146 RGB PROCESS 46 49 146 R=27 G=20 B=100 RGB PROCESS 27 20 100 R=102 G=45 B=145 RGB PROCESS 102 45 145 R=147 G=39 B=143 RGB PROCESS 147 39 143 R=158 G=0 B=93 RGB PROCESS 158 0 93 R=212 G=20 B=90 RGB PROCESS 212 20 90 R=237 G=30 B=121 RGB PROCESS 237 30 121 R=199 G=178 B=153 RGB PROCESS 199 178 153 R=153 G=134 B=117 RGB PROCESS 153 134 117 R=115 G=99 B=87 RGB PROCESS 115 99 87 R=83 G=71 B=65 RGB PROCESS 83 71 65 R=198 G=156 B=109 RGB PROCESS 198 156 109 R=166 G=124 B=82 RGB PROCESS 166 124 82 R=140 G=98 B=57 RGB PROCESS 140 98 57 R=117 G=76 B=36 RGB PROCESS 117 76 36 R=96 G=56 B=19 RGB PROCESS 96 56 19 R=66 G=33 B=11 RGB PROCESS 66 33 11 Grays 1 R=0 G=0 B=0 RGB PROCESS 0 0 0 R=26 G=26 B=26 RGB PROCESS 26 26 26 R=51 G=51 B=51 RGB PROCESS 51 51 51 R=77 G=77 B=77 RGB PROCESS 77 77 77 R=102 G=102 B=102 RGB PROCESS 102 102 102 R=128 G=128 B=128 RGB PROCESS 128 128 128 R=153 G=153 B=153 RGB PROCESS 153 153 153 R=179 G=179 B=179 RGB PROCESS 179 179 179 R=204 G=204 B=204 RGB PROCESS 204 204 204 R=230 G=230 B=230 RGB PROCESS 230 230 230 R=242 G=242 B=242 RGB PROCESS 242 242 242 Splash 1 R=214 G=149 B=68 RGB PROCESS 214 149 68 R=71 G=152 B=237 RGB PROCESS 71 152 237 R=42 G=81 B=224 RGB PROCESS 42 81 224 R=180 G=58 B=228 RGB PROCESS 180 58 228 Adobe PDF library 9.00 endstream endobj 3 0 obj <> endobj 368 0 obj <>/Resources<>/ExtGState<>/Font<>/ProcSet[/PDF/Text]/Properties<>/XObject<>>>/Thumb 421 0 R/TrimBox[0.0 0.0 800.0 600.0]/Type/Page>> endobj 369 0 obj <>stream HWr߯ciQLY\Y(U)ʩD~NᐢW`dz\t~ŕ.:kwe%q]wuu|CbC%|wGu"cL,OMXicƥTk)X#eruxsI)y6$Lؑ#ddm6[1r8qc6(jv;1ᄼ ;h_tPf[x&GL8#ԝ3Չ2 ##X 9a@F;~HN3E( ;~i;UO??8_NK gd>uwr^.;`!g.|9p7Cn:܁ug;}aD|D+DWNn *t Lܭ;ۙsbDkD4QJ~ʆ&|L`GH!ϡB%)KDe&ƳLטɔ3,jqNwDciB0kT@V@Z5g(h\ n۸|z͓Ǖmn !"md+1y@27Gf=GK ?]"x"~Q\iizmٙN.x|3"dO{R\֚OhawDsl8!6sI⼅( z ,=)NS<m8{<Ql=%Eԗ -%* f+ -%oIt&~$ZEKtT;#9hrƒ*pHWPxy.a3D|oJ:%S_қ#(aĠSȆ2r9m`7DŽOnk+2,0{ _̻lp>c9bH{rLڧ/[cc4P-AIyB烒l%Ǩ4؝5(w#y4AcF40)N^4Ul7 vwkƺ\Y$z"S~Sl* OhǑ4t„XIݓ'WO% E˚$8gJ߅Gs8|\"<lB]4#dx1|F%F6/k te<ۦnW#,$@ BKSf1˛ 1POj)NQr+ ўU/ [v dkN<A)&{H ipA}ҹr7ʝxkC4V o [yMh|o/n/*$c%EdC`0xwjصK`DղGYY(ZGbx~RV 3߿~qb4C$hITm4rƝi^p4o߿[;{"4_~ {Kz{Mj|Z6EnCMPZ<PAP;RPn냊kuMB -03^(u- LZ . s0UT.Dz*֡~:Yv\niٲЇ$9)DSxăP܏9G4c!ZX^(bCP;DT 4~ݏ/oX45sa.L4fc~3J=&\:"ԁ6zkD:wbն i$ A؜[47X䝱@x!Hi*3&.өvv(ؠ9%!Px+%euȖS"8"@&mӂ6nmE/ m{uf; sšAdUhHa)qMi@n=r!r෵g%#bj (erD-SW6+ƈ.V{4*֍{cn@Q(Ehd+:};;ql IZ{$1||AY[YدtWx4O^E a'd)^؀zG`hVi7ڝǡO?<> [ :Y,Km']IFNqk"<9" ssy,d.΋q0ɝY 'r|W75g)Y>+->{uGy^G,!AL폁mg#{G c˿̡75}e';r,3"96}gϲ3MTƄ/;FQ=S+X(>b?zؒ%xy0vPzm=涥џư͹_JHkd\r X ,ùҩhyz/H" f}cf3u76 endstream endobj 370 0 obj <> endobj 421 0 obj <>stream 8;Z\7c&q<7$mm?Z&Z8](qUm4bBfhu>4XD-0Zta6-"B[!1CP+eAAg_'_("S-cMu`Co &MTa_@0=uH+FrE4`9^6Op]+>=VIVPa*X;-7k8pEnP.<"2!14=l2lm*o$,`JbJlm9@ bl^og59Pb@0\OUrg0M"L_aDW_>nPXG(''AiYT=W&<'M;,^u,(A0Er+mZANKOpE2W? 'i]\'C&Z@OWa-V,B=+JfZ5 W&A&5a=+tEVQ'nOC5n/Rf[\1Z%T#KWFWY-'a'd-b81i3T@1X2P:8htL-sSgH`nb'% 9X76epDS\%arnXIh[/I@X*(uA6"5[YiioPj2fKq(=.][,fX^&LD&\VJ=T,bMD7,F8 &'T?ip4#Vd58(ju@4_7/\?/0=QbS)TG(n'96&[;!6b6d0n[gJ7H7PDuoXcg8+2g;9 QcFBrhb/)?MWoG0f9gVD@s3X-&-H[+fZ#2!F^H?(8c:h9Em07^EnNt=n'kiSqEH,I PuF[]^Ti[YoM-:CGWJ2Gr>/K%JQTsk-EQ\q5ZD1O.3QkK]!'gaK!f00.VoC8"!DQk "!h#/7eF[*.mCt/0@@N:Uss6k-rWJ$L$i$7QU^uiANNAN^J=MPV0Qk=Wt)GS1LKP* GlJuVd-*BH&u$<^bkl3Pi1f^Ie*$R1"Y=,/EGB@AK-nbLE'D.$%rf(69W5uc]boka [LqOP,/POA";V%4U.6(/ji+`.%s+Gj0!r@Jk&o!r#X endstream endobj 423 0 obj [/Indexed/DeviceRGB 255 424 0 R] endobj 424 0 obj <>stream 8;X]O>EqN@%''O_@%e@?J;%+8(9e>X=MR6S?i^YgA3=].HDXF.R$lIL@"pJ+EP(%0 b]6ajmNZn*!='OQZeQ^Y*,=]?C.B+\Ulg9dhD*"iC[;*=3`oP1[!S^)?1)IZ4dup` E1r!/,*0[*9.aFIR2&b-C#soRZ7Dl%MLY\.?d>Mn 6%Q2oYfNRF$$+ON<+]RUJmC0InDZ4OTs0S!saG>GGKUlQ*Q?45:CI&4J'_2j$XKrcYp0n+Xl_nU*O( l[$6Nn+Z_Nq0]s7hs]`XX1nZ8&94a\~> endstream endobj 379 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 34 0 0 33 203 226 cm /Im0 Do Q endstream endobj 380 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 34 0 0 33 253 232 cm /Im0 Do Q endstream endobj 381 0 obj <>/ExtGState<>/XObject<>>>/Subtype/Form>>stream q 0.5 w 4 M 0 j 0 J []0 d /GS0 gs 0 Tc 0 Tw 0 Ts 100 Tz 0 Tr /Fm0 Do Q /CS0 cs 0.851 0.835 0.824 scn /GS1 gs q 1 0 0 1 134.9146 529.1733 cm 0 0 m 0 -21.302 -17.268 -38.57 -38.568 -38.57 c -59.87 -38.57 -77.138 -21.302 -77.138 0 c -77.138 21.301 -59.87 38.569 -38.568 38.569 c -17.268 38.569 0 21.301 0 0 c f Q /CS0 CS 0.188 0.208 0.224 SCN 2 w 4 M 0 j 0 J []0 d q 1 0 0 1 134.9146 529.1733 cm 0 0 m 0 -21.302 -17.268 -38.57 -38.568 -38.57 c -59.87 -38.57 -77.138 -21.302 -77.138 0 c -77.138 21.301 -59.87 38.569 -38.568 38.569 c -17.268 38.569 0 21.301 0 0 c h S Q endstream endobj 382 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 86 0 0 86 68 499 cm /Im0 Do Q endstream endobj 383 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 47 0 0 33 165 526 cm /Im0 Do Q endstream endobj 384 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 47 0 0 33 211 526 cm /Im0 Do Q endstream endobj 385 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 47 0 0 33 258 526 cm /Im0 Do Q endstream endobj 386 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 129 0 0 36 179 450 cm /Im0 Do Q endstream endobj 387 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 126 0 0 32 180 453 cm /Im0 Do Q endstream endobj 388 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 129 0 0 36 321 450 cm /Im0 Do Q endstream endobj 389 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 127 0 0 32 322 453 cm /Im0 Do Q endstream endobj 390 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 34 0 0 33 463 224 cm /Im0 Do Q endstream endobj 391 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.188 0.208 0.224 SCN 2 w 4 M 0 j 0 J []0 d /GS0 gs q 1 0 0 1 153 330.5 cm 0 0 m 56 0 l S Q endstream endobj 392 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.188 0.208 0.224 SCN 2 w 4 M 0 j 0 J []0 d /GS0 gs q 1 0 0 1 153 334.5 cm 0 0 m 56 0 l S Q endstream endobj 393 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 129 0 0 36 248 288 cm /Im0 Do Q endstream endobj 394 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 127 0 0 32 249 291 cm /Im0 Do Q endstream endobj 395 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.188 0.208 0.224 SCN 2 w 4 M 0 j 0 J []0 d /GS0 gs q 1 0 0 1 283 326.5 cm 0 0 m 56 0 l S Q endstream endobj 396 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.188 0.208 0.224 SCN 2 w 4 M 0 j 0 J []0 d /GS0 gs q 1 0 0 1 283 330.5 cm 0 0 m 56 0 l S Q endstream endobj 397 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 129 0 0 36 378 288 cm /Im0 Do Q endstream endobj 398 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 127 0 0 32 379 291 cm /Im0 Do Q endstream endobj 399 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.188 0.208 0.224 SCN 2 w 4 M 0 j 0 J []0 d /GS0 gs q 1 0 0 1 413 330.5 cm 0 0 m 56 0 l S Q endstream endobj 400 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.188 0.208 0.224 SCN 2 w 4 M 0 j 0 J []0 d /GS0 gs q 1 0 0 1 413 334.5 cm 0 0 m 56 0 l S Q endstream endobj 401 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 34 0 0 33 390 237 cm /Im0 Do Q endstream endobj 402 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 47 0 0 33 289 498 cm /Im0 Do Q endstream endobj 403 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 34 0 0 33 131 237 cm /Im0 Do Q endstream endobj 404 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 103 0 0 57 80 145 cm /Im0 Do Q endstream endobj 405 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 101 0 0 53 81 148 cm /Im0 Do Q endstream endobj 406 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 103 0 0 57 182 145 cm /Im0 Do Q endstream endobj 407 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 101 0 0 53 183 148 cm /Im0 Do Q endstream endobj 408 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 103 0 0 57 341 145 cm /Im0 Do Q endstream endobj 409 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 101 0 0 53 342 148 cm /Im0 Do Q endstream endobj 410 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 103 0 0 57 443 145 cm /Im0 Do Q endstream endobj 411 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 101 0 0 53 444 148 cm /Im0 Do Q endstream endobj 412 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 48 0 0 44 395 392 cm /Im0 Do Q endstream endobj 413 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 34 0 0 33 505 229 cm /Im0 Do Q endstream endobj 414 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 129 0 0 36 118 288 cm /Im0 Do Q endstream endobj 415 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 127 0 0 32 119 291 cm /Im0 Do Q endstream endobj 416 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 48 0 0 44 182 387 cm /Im0 Do Q endstream endobj 417 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 48 0 0 44 135 372 cm /Im0 Do Q endstream endobj 418 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 48 0 0 44 264 385 cm /Im0 Do Q endstream endobj 419 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 47 0 0 44 312 365 cm /Im0 Do Q endstream endobj 420 0 obj <>/ExtGState<>/XObject<>>>/Subtype/Form>>stream q 0.5 w 4 M 0 j 0 J []0 d /GS0 gs 0 Tc 0 Tw 0 Ts 100 Tz 0 Tr /Fm0 Do Q /CS0 cs 0.851 0.835 0.824 scn /GS1 gs q 1 0 0 1 121.0327 515.2915 cm 0 0 m 0 -21.302 -17.268 -38.57 -38.568 -38.57 c -59.87 -38.57 -77.138 -21.302 -77.138 0 c -77.138 21.301 -59.87 38.569 -38.568 38.569 c -17.268 38.569 0 21.301 0 0 c f Q /CS0 CS 0.188 0.208 0.224 SCN 2 w 4 M 0 j 0 J []0 d q 1 0 0 1 121.0327 515.2915 cm 0 0 m 0 -21.302 -17.268 -38.57 -38.568 -38.57 c -59.87 -38.57 -77.138 -21.302 -77.138 0 c -77.138 21.301 -59.87 38.569 -38.568 38.569 c -17.268 38.569 0 21.301 0 0 c h S Q endstream endobj 519 0 obj <> endobj 520 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 87 0 0 86 40 471 cm /Im0 Do Q endstream endobj 521 0 obj <> endobj 523 0 obj <>stream H;A Н_:EePX2_k. EL[3*(( *R( *ԳBnT uԥT]MSWN\pEBzXu=XXv.v4l6`k=blbkf1[tViX{ǣX;QTM 5.ՔQV 5 K(̢1r]Y2ϗx endstream endobj 426 0 obj [/Indexed 372 0 R 1 525 0 R] endobj 524 0 obj <>/Filter/FlateDecode/Height 86/Intent/RelativeColorimetric/Length 1122/Name/X/Subtype/Image/Type/XObject/Width 87>>stream H;oXva -1nOE Q Ħ4GlIA,(&"F4V.FZsldy{=z>|p 7!'?pD!5Äo6ae2Ild G P@LdGECfXć8Ld׊YH)` 01ȼAk@K$R;T5Zy1pm 5hk(7ՖFuf jR )o\ ]΃.NRMnc k+i8IEdsGUح lq0l}q"3msHvH"sQ@Ξ6JHPLR1_\3LȃlY{ݢw9 0 H endstream endobj 372 0 obj [/ICCBased 526 0 R] endobj 525 0 obj <>stream endstream endobj 526 0 obj <>stream HyTSwoɞc [5laQIBHADED2mtFOE.c}08׎8GNg9w߽'0 ֠Jb  2y.-;!KZ ^i"L0- @8(r;q7Ly&Qq4j|9 V)gB0iW8#8wթ8_٥ʨQQj@&A)/g>'Kt;\ ӥ$պFZUn(4T%)뫔0C&Zi8bxEB;Pӓ̹A om?W= x-[0}y)7ta>jT7@tܛ`q2ʀ&6ZLĄ?_yxg)˔zçLU*uSkSeO4?׸c. R ߁-25 S>ӣVd`rn~Y&+`;A4 A9=-tl`;~p Gp| [`L`< "A YA+Cb(R,*T2B- ꇆnQt}MA0alSx k&^>0|>_',G!"F$H:R!zFQd?r 9\A&G rQ hE]a4zBgE#H *B=0HIpp0MxJ$D1D, VĭKĻYdE"EI2EBGt4MzNr!YK ?%_&#(0J:EAiQ(()ӔWT6U@P+!~mD eԴ!hӦh/']B/ҏӿ?a0nhF!X8܌kc&5S6lIa2cKMA!E#ƒdV(kel }}Cq9 N')].uJr  wG xR^[oƜchg`>b$*~ :Eb~,m,-ݖ,Y¬*6X[ݱF=3뭷Y~dó ti zf6~`{v.Ng#{}}jc1X6fm;'_9 r:8q:˜O:ϸ8uJqnv=MmR 4 n3ܣkGݯz=[==<=GTB(/S,]6*-W:#7*e^YDY}UjAyT`#D="b{ų+ʯ:!kJ4Gmt}uC%K7YVfFY .=b?SƕƩȺy چ k5%4m7lqlioZlG+Zz͹mzy]?uuw|"űNwW&e֥ﺱ*|j5kyݭǯg^ykEklD_p߶7Dmo꿻1ml{Mś nLl<9O[$h՛BdҞ@iءG&vVǥ8nRĩ7u\ЭD-u`ֲK³8%yhYѹJº;.! zpg_XQKFAǿ=ȼ:ɹ8ʷ6˶5̵5͵6ζ7ϸ9к<Ѿ?DINU\dlvۀ܊ݖޢ)߯6DScs 2F[p(@Xr4Pm8Ww)Km endstream endobj 522 0 obj <> endobj 527 0 obj <> endobj 528 0 obj [0.0 0.0 0.0] endobj 529 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 87 0 0 86 40 471 cm /Im0 Do Q endstream endobj 530 0 obj <> endobj 532 0 obj <>/Filter/FlateDecode/Height 86/Intent/RelativeColorimetric/Length 1122/Name/X/Subtype/Image/Type/XObject/Width 87>>stream H;oXva -1nOE Q Ħ4GlIA,(&"F4V.FZsldy{=z>|p 7!'?pD!5Äo6ae2Ild G P@LdGECfXć8Ld׊YH)` 01ȼAk@K$R;T5Zy1pm 5hk(7ՖFuf jR )o\ ]΃.NRMnc k+i8IEdsGUح lq0l}q"3msHvH"sQ@Ξ6JHPLR1_\3LȃlY{ݢw9 0 H endstream endobj 531 0 obj <> endobj 374 0 obj <> endobj 373 0 obj <> endobj 516 0 obj <> endobj 518 0 obj <>stream H P6fAfDlFҨq`PH)v_m|A%|QqBO)}st{_eZv$spvlrh\1(`Cz endstream endobj 533 0 obj <>/Filter/FlateDecode/Height 44/Intent/RelativeColorimetric/Length 452/Name/X/Subtype/Image/Type/XObject/Width 47>>stream H̕@33E,b,F,oEA-PSXk!o0.(XVhNDE/;4^ 쩿sg_*DmaYD.8)ʖJL>(4`lk@ܞjfFKiw=8(ϱ0\'5)'lU&p.JM`b endstream endobj 517 0 obj <> endobj 534 0 obj <> endobj 535 0 obj [0.0 0.0 0.0] endobj 536 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 47 0 0 44 312 365 cm /Im0 Do Q endstream endobj 537 0 obj <> endobj 538 0 obj <>/Filter/FlateDecode/Height 44/Intent/RelativeColorimetric/Length 452/Name/X/Subtype/Image/Type/XObject/Width 47>>stream H̕@33E,b,F,oEA-PSXk!o0.(XVhNDE/;4^ 쩿sg_*DmaYD.8)ʖJL>(4`lk@ܞjfFKiw=8(ϱ0\'5)'lU&p.JM`b endstream endobj 513 0 obj <> endobj 515 0 obj <>stream H1 QN0ESX+m'Z%6E,:o? r^g_<<s/>E>-Z'q} 0 endstream endobj 539 0 obj <>/Filter/FlateDecode/Height 44/Intent/RelativeColorimetric/Length 433/Name/X/Subtype/Image/Type/XObject/Width 48>>stream HV=o@GNҡ?ĠM3riN ] :J*}Vy?}9*~-1fDpRv}@6ՊH.)HFdž C4t*U "kFSdXsjTgGu'hɤ@ߛ?z'ow9\kD6s3IDei_I*ˉZw0 gH6\`yфZ ϚAW"A|,J~yt)q:5 R{^t47+ QnF)&˷N'=\\-ZO{u^1V㠤 ʁ?CI0n |~x^o ~/~yWyy}$@ާt_o_ endstream endobj 514 0 obj <> endobj 540 0 obj <> endobj 541 0 obj [0.0 0.0 0.0] endobj 542 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 48 0 0 44 264 385 cm /Im0 Do Q endstream endobj 543 0 obj <> endobj 544 0 obj <>/Filter/FlateDecode/Height 44/Intent/RelativeColorimetric/Length 433/Name/X/Subtype/Image/Type/XObject/Width 48>>stream HV=o@GNҡ?ĠM3riN ] :J*}Vy?}9*~-1fDpRv}@6ՊH.)HFdž C4t*U "kFSdXsjTgGu'hɤ@ߛ?z'ow9\kD6s3IDei_I*ˉZw0 gH6\`yфZ ϚAW"A|,J~yt)q:5 R{^t47+ QnF)&˷N'=\\-ZO{u^1V㠤 ʁ?CI0n |~x^o ~/~yWyy}$@ާt_o_ endstream endobj 511 0 obj <> endobj 501 0 obj <>stream H1 Qe-R8o;1wm$ӺPZϊoACsL'7r r r {Ư?~{G< C>/Filter/FlateDecode/Height 44/Intent/RelativeColorimetric/Length 436/Name/X/Subtype/Image/Type/XObject/Width 48>>stream HnPƹ0.;J>.ԉA73wiN P "g;;9 !|ByAr&*  WF“+ xiV:ixKb# 38 _P$¿={WߔvWDbd(h+:X61#B5f%xeօexvf@h{ K=3X _3ubέdH|bదjts2wS!yO:xӮ9L{;ܝzws}g{G{hCps#4CHB_ endstream endobj 512 0 obj <> endobj 546 0 obj <> endobj 547 0 obj [0.0 0.0 0.0] endobj 548 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 48 0 0 44 135 372 cm /Im0 Do Q endstream endobj 549 0 obj <> endobj 550 0 obj <>/Filter/FlateDecode/Height 44/Intent/RelativeColorimetric/Length 436/Name/X/Subtype/Image/Type/XObject/Width 48>>stream HnPƹ0.;J>.ԉA73wiN P "g;;9 !|ByAr&*  WF“+ xiV:ixKb# 38 _P$¿={WߔvWDbd(h+:X61#B5f%xeօexvf@h{ K=3X _3ubέdH|bదjts2wS!yO:xӮ9L{;ܝzws}g{G{hCps#4CHB_ endstream endobj 509 0 obj <> endobj 510 0 obj <> endobj 551 0 obj <> endobj 552 0 obj [0.0 0.0 0.0] endobj 553 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 48 0 0 44 182 387 cm /Im0 Do Q endstream endobj 554 0 obj <> endobj 507 0 obj <> endobj 457 0 obj <>stream HA İ4 Ok`٩eu/tq 0 endstream endobj 446 0 obj [/Indexed 372 0 R 1 556 0 R] endobj 555 0 obj <>/Filter/FlateDecode/Height 32/Intent/RelativeColorimetric/Length 141/Name/X/Subtype/Image/Type/XObject/Width 127>>stream H1 WPR04Q9= !ܝ57):5 趴精͑>wm<%sA$߹|ic~7G+ooםC[f(,  |v>k endstream endobj 556 0 obj <>stream endstream endobj 508 0 obj <> endobj 557 0 obj <> endobj 558 0 obj [0.0 0.0 0.0] endobj 559 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 127 0 0 32 119 291 cm /Im0 Do Q endstream endobj 560 0 obj <> endobj 561 0 obj <>/Filter/FlateDecode/Height 32/Intent/RelativeColorimetric/Length 141/Name/X/Subtype/Image/Type/XObject/Width 127>>stream H1 WPR04Q9= !ܝ57):5 趴精͑>wm<%sA$߹|ic~7G+ooםC[f(,  |v>k endstream endobj 505 0 obj <> endobj 464 0 obj <>stream HA @_MC @ @\_ endstream endobj 562 0 obj <>/Filter/FlateDecode/Height 36/Intent/RelativeColorimetric/Length 177/Name/X/Subtype/Image/Type/XObject/Width 129>>stream Hױ a  @ P@.$sPeH]?Q񈈘38ygd޷1R^)WfZ?-qg[G^HBݯ&@(@ P(_o9z|ؘr?Л ̼O,} 0: endstream endobj 506 0 obj <> endobj 563 0 obj <> endobj 564 0 obj [0.0 0.0 0.0] endobj 565 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 129 0 0 36 118 288 cm /Im0 Do Q endstream endobj 566 0 obj <> endobj 567 0 obj <>/Filter/FlateDecode/Height 36/Intent/RelativeColorimetric/Length 177/Name/X/Subtype/Image/Type/XObject/Width 129>>stream Hױ a  @ P@.$sPeH]?Q񈈘38ygd޷1R^)WfZ?-qg[G^HBݯ&@(@ P(_o9z|ؘr?Л ̼O,} 0: endstream endobj 502 0 obj <> endobj 504 0 obj <>stream HA Զcsn=}`~'0$d`Lr`.|Q. Os0KJ۷o~ Fb endstream endobj 568 0 obj <>/Filter/FlateDecode/Height 33/Intent/RelativeColorimetric/Length 398/Name/X/Subtype/Image/Type/XObject/Width 34>>stream H=o@ƽÙ8_>]&n ~81ɏP_6 mǃKDCTΖrҐā<ÙYa| %bR2 ?հ๛ZaJ|\+]n^B#TP~N > endstream endobj 503 0 obj <> endobj 569 0 obj <> endobj 570 0 obj [0.0 0.0 0.0] endobj 571 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 34 0 0 33 505 229 cm /Im0 Do Q endstream endobj 572 0 obj <> endobj 573 0 obj <>/Filter/FlateDecode/Height 33/Intent/RelativeColorimetric/Length 398/Name/X/Subtype/Image/Type/XObject/Width 34>>stream H=o@ƽÙ8_>]&n ~81ɏP_6 mǃKDCTΖrҐā<ÙYa| %bR2 ?հ๛ZaJ|\+]n^B#TP~N > endstream endobj 499 0 obj <> endobj 500 0 obj <> endobj 574 0 obj <> endobj 575 0 obj [0.0 0.0 0.0] endobj 576 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 48 0 0 44 395 392 cm /Im0 Do Q endstream endobj 577 0 obj <> endobj 497 0 obj <> endobj 486 0 obj <>stream H1 ǿi0\b uRWL endstream endobj 578 0 obj <>/Filter/FlateDecode/Height 53/Intent/RelativeColorimetric/Length 172/Name/X/Subtype/Image/Type/XObject/Width 101>>stream H1  #aA `9E>8Ddr5hBLJ))hcR>)ws>RޙUM4DM4DM4DM4O\~}#em£EhBLJ))@:/YCH ` endstream endobj 498 0 obj <> endobj 579 0 obj <> endobj 580 0 obj [0.0 0.0 0.0] endobj 581 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 101 0 0 53 444 148 cm /Im0 Do Q endstream endobj 582 0 obj <> endobj 583 0 obj <>/Filter/FlateDecode/Height 53/Intent/RelativeColorimetric/Length 172/Name/X/Subtype/Image/Type/XObject/Width 101>>stream H1  #aA `9E>8Ddr5hBLJ))hcR>)ws>RޙUM4DM4DM4DM4O\~}#em£EhBLJ))@:/YCH ` endstream endobj 495 0 obj <> endobj 483 0 obj <>stream HG  Cb IϗTh4Fh4Fh46'i>/Filter/FlateDecode/Height 57/Intent/RelativeColorimetric/Length 216/Name/X/Subtype/Image/Type/XObject/Width 103>>stream Hб 0@ѻ+pӤ)R ӰKvI b"8p) <"qߨU͗ъ|qWvM#Nl? l:3kgkK0}C fFaFaFaFa+&2` iRu紲fkTVcʦn`ٮ) r2Z "1|@ endstream endobj 496 0 obj <> endobj 585 0 obj <> endobj 586 0 obj [0.0 0.0 0.0] endobj 587 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 103 0 0 57 443 145 cm /Im0 Do Q endstream endobj 588 0 obj <> endobj 589 0 obj <>/Filter/FlateDecode/Height 57/Intent/RelativeColorimetric/Length 216/Name/X/Subtype/Image/Type/XObject/Width 103>>stream Hб 0@ѻ+pӤ)R ӰKvI b"8p) <"qߨU͗ъ|qWvM#Nl? l:3kgkK0}C fFaFaFaFa+&2` iRu紲fkTVcʦn`ٮ) r2Z "1|@ endstream endobj 493 0 obj <> endobj 494 0 obj <> endobj 590 0 obj <> endobj 591 0 obj [0.0 0.0 0.0] endobj 592 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 101 0 0 53 342 148 cm /Im0 Do Q endstream endobj 593 0 obj <> endobj 491 0 obj <> endobj 492 0 obj <> endobj 594 0 obj <> endobj 595 0 obj [0.0 0.0 0.0] endobj 596 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 103 0 0 57 341 145 cm /Im0 Do Q endstream endobj 597 0 obj <> endobj 489 0 obj <> endobj 490 0 obj <> endobj 598 0 obj <> endobj 599 0 obj [0.0 0.0 0.0] endobj 600 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 101 0 0 53 183 148 cm /Im0 Do Q endstream endobj 601 0 obj <> endobj 487 0 obj <> endobj 488 0 obj <> endobj 602 0 obj <> endobj 603 0 obj [0.0 0.0 0.0] endobj 604 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 103 0 0 57 182 145 cm /Im0 Do Q endstream endobj 605 0 obj <> endobj 484 0 obj <> endobj 485 0 obj <> endobj 606 0 obj <> endobj 607 0 obj [0.0 0.0 0.0] endobj 608 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 101 0 0 53 81 148 cm /Im0 Do Q endstream endobj 609 0 obj <> endobj 481 0 obj <> endobj 482 0 obj <> endobj 610 0 obj <> endobj 611 0 obj [0.0 0.0 0.0] endobj 612 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 103 0 0 57 80 145 cm /Im0 Do Q endstream endobj 613 0 obj <> endobj 479 0 obj <> endobj 428 0 obj <>stream H1 A5Pp5^)S!'b5Fmh& Nv@4,~s@.hK (%U{{^Ycd endstream endobj 614 0 obj <>/Filter/FlateDecode/Height 33/Intent/RelativeColorimetric/Length 490/Name/X/Subtype/Image/Type/XObject/Width 34>>stream H?k@ƥ4(pc {If]An!vR8 "yjNcCήkRg{ BQ"D@ 臇[+f9Y44,m1Pnm~{xc6Ep=>==Ft A8I34Mh%p=%YR4hy 0Od6^#} 0%Էuqy?+yzG X?fn"JMg0-[p #qVA516qR)P;Nx ?NyݯfQjbGQ :>u6vi$]?m*B"jXW}.>hxCs%]d)a{2ɔex;\yu'˛A8ualaIy'~6 ՛y91fVQ\5_R^K#~ )> endstream endobj 480 0 obj <> endobj 615 0 obj <> endobj 616 0 obj [0.0 0.0 0.0] endobj 617 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 34 0 0 33 131 237 cm /Im0 Do Q endstream endobj 618 0 obj <> endobj 619 0 obj <>/Filter/FlateDecode/Height 33/Intent/RelativeColorimetric/Length 490/Name/X/Subtype/Image/Type/XObject/Width 34>>stream H?k@ƥ4(pc {If]An!vR8 "yjNcCήkRg{ BQ"D@ 臇[+f9Y44,m1Pnm~{xc6Ep=>==Ft A8I34Mh%p=%YR4hy 0Od6^#} 0%Էuqy?+yzG X?fn"JMg0-[p #qVA516qR)P;Nx ?NyݯfQjbGQ :>u6vi$]?m*B"jXW}.>hxCs%]d)a{2ɔex;\yu'˛A8ualaIy'~6 ՛y91fVQ\5_R^K#~ )> endstream endobj 477 0 obj <> endobj 444 0 obj <>stream HI0 CFH j*CDMքHAx)LtS\ۻ>OO8۩ {vNX+XzKڿhok`  endstream endobj 620 0 obj <>/Filter/FlateDecode/Height 33/Intent/RelativeColorimetric/Length 518/Name/X/Subtype/Image/Type/XObject/Width 47>>stream HT=O@CHp$7H 8 &@&ƄIq lMʀt xm) T޻L8 TQ,9SzJ>rY1:[ q+\>& -,JXyxF޸ p:j]|Y mu?R"ֱL:pWJ\4òi*0̀ye" v=3H:1s.&᧣{U?z̿*$XUb64,!\ՎmC^z9ycev9o+Ya,g,*w.SMF!wVzsy[1DR[I6[;hxMSfv6ؠnS+׏[i.RbnWֳ٥=vU2m 95k2J]gdby0JA-jȦz z;? ÂV endstream endobj 478 0 obj <> endobj 621 0 obj <> endobj 622 0 obj [0.0 0.0 0.0] endobj 623 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 47 0 0 33 289 498 cm /Im0 Do Q endstream endobj 624 0 obj <> endobj 625 0 obj <>/Filter/FlateDecode/Height 33/Intent/RelativeColorimetric/Length 518/Name/X/Subtype/Image/Type/XObject/Width 47>>stream HT=O@CHp$7H 8 &@&ƄIq lMʀt xm) T޻L8 TQ,9SzJ>rY1:[ q+\>& -,JXyxF޸ p:j]|Y mu?R"ֱL:pWJ\4òi*0̀ye" v=3H:1s.&᧣{U?z̿*$XUb64,!\ՎmC^z9ycev9o+Ya,g,*w.SMF!wVzsy[1DR[I6[;hxMSfv6ؠnS+׏[i.RbnWֳ٥=vU2m 95k2J]gdby0JA-jȦz z;? ÂV endstream endobj 475 0 obj <> endobj 476 0 obj <> endobj 626 0 obj <> endobj 627 0 obj [0.0 0.0 0.0] endobj 628 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 34 0 0 33 390 237 cm /Im0 Do Q endstream endobj 629 0 obj <> endobj 474 0 obj <> endobj 473 0 obj <> endobj 471 0 obj <> endobj 472 0 obj <> endobj 630 0 obj <> endobj 631 0 obj [0.0 0.0 0.0] endobj 632 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 127 0 0 32 379 291 cm /Im0 Do Q endstream endobj 633 0 obj <> endobj 469 0 obj <> endobj 470 0 obj <> endobj 634 0 obj <> endobj 635 0 obj [0.0 0.0 0.0] endobj 636 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 129 0 0 36 378 288 cm /Im0 Do Q endstream endobj 637 0 obj <> endobj 468 0 obj <> endobj 467 0 obj <> endobj 465 0 obj <> endobj 466 0 obj <> endobj 638 0 obj <> endobj 639 0 obj [0.0 0.0 0.0] endobj 640 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 127 0 0 32 249 291 cm /Im0 Do Q endstream endobj 641 0 obj <> endobj 462 0 obj <> endobj 463 0 obj <> endobj 642 0 obj <> endobj 643 0 obj [0.0 0.0 0.0] endobj 644 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 129 0 0 36 248 288 cm /Im0 Do Q endstream endobj 645 0 obj <> endobj 461 0 obj <> endobj 460 0 obj <> endobj 458 0 obj <> endobj 459 0 obj <> endobj 646 0 obj <> endobj 647 0 obj [0.0 0.0 0.0] endobj 648 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 34 0 0 33 463 224 cm /Im0 Do Q endstream endobj 649 0 obj <> endobj 455 0 obj <> endobj 456 0 obj <> endobj 650 0 obj <> endobj 651 0 obj [0.0 0.0 0.0] endobj 652 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 127 0 0 32 322 453 cm /Im0 Do Q endstream endobj 653 0 obj <> endobj 452 0 obj <> endobj 454 0 obj <>stream H1 @_WC8`iqO` @ @\xo endstream endobj 654 0 obj <>/Filter/FlateDecode/Height 36/Intent/RelativeColorimetric/Length 213/Name/X/Subtype/Image/Type/XObject/Width 129>>stream HױDP ̼*4 D/zP)D 0Ω[~"ܑ@t{qMb024u"iZ6WaOeCf\Q @xjݼr> endobj 655 0 obj <> endobj 656 0 obj [0.0 0.0 0.0] endobj 657 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 129 0 0 36 321 450 cm /Im0 Do Q endstream endobj 658 0 obj <> endobj 659 0 obj <>/Filter/FlateDecode/Height 36/Intent/RelativeColorimetric/Length 213/Name/X/Subtype/Image/Type/XObject/Width 129>>stream HױDP ̼*4 D/zP)D 0Ω[~"ܑ@t{qMb024u"iZ6WaOeCf\Q @xjݼr> endobj 451 0 obj <>stream H1 ð4Xړ:352E.'O endstream endobj 660 0 obj <>/Filter/FlateDecode/Height 32/Intent/RelativeColorimetric/Length 145/Name/X/Subtype/Image/Type/XObject/Width 126>>stream Hױ a J DI!i` dz/+D_{GJ!*c[ҚxyM|ī<8lՇx:s5G/m̟xsb馛n馛nT]ԺoBIH",/S$ endstream endobj 450 0 obj <> endobj 661 0 obj <> endobj 662 0 obj [0.0 0.0 0.0] endobj 663 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 126 0 0 32 180 453 cm /Im0 Do Q endstream endobj 664 0 obj <> endobj 665 0 obj <>/Filter/FlateDecode/Height 32/Intent/RelativeColorimetric/Length 145/Name/X/Subtype/Image/Type/XObject/Width 126>>stream Hױ a J DI!i` dz/+D_{GJ!*c[ҚxyM|ī<8lՇx:s5G/m̟xsb馛n馛nT]ԺoBIH",/S$ endstream endobj 445 0 obj <> endobj 448 0 obj <>stream HA @_MCpxy/0+p# @ @t,W`W endstream endobj 666 0 obj <>/Filter/FlateDecode/Height 36/Intent/RelativeColorimetric/Length 175/Name/X/Subtype/Image/Type/XObject/Width 129>>stream Hױ a  @ P@. 9 1D|WH,!Š!k}CLR ^?TQjccShrPnk9懡9q @/泜1a؟S CLR ^<21dZo+c: endstream endobj 447 0 obj <> endobj 667 0 obj <> endobj 668 0 obj [0.0 0.0 0.0] endobj 669 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 129 0 0 36 179 450 cm /Im0 Do Q endstream endobj 670 0 obj <> endobj 671 0 obj <>/Filter/FlateDecode/Height 36/Intent/RelativeColorimetric/Length 175/Name/X/Subtype/Image/Type/XObject/Width 129>>stream Hױ a  @ P@. 9 1D|WH,!Š!k}CLR ^?TQjccShrPnk9懡9q @/泜1a؟S CLR ^<21dZo+c: endstream endobj 442 0 obj <> endobj 443 0 obj <> endobj 672 0 obj <> endobj 673 0 obj [0.0 0.0 0.0] endobj 674 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 47 0 0 33 258 526 cm /Im0 Do Q endstream endobj 675 0 obj <> endobj 439 0 obj <> endobj 441 0 obj <>stream H90DQ(', wM=ǪxbBB_^?/O4,gN]`ziARSڤQX!C֌ endstream endobj 676 0 obj <>/Filter/FlateDecode/Height 33/Intent/RelativeColorimetric/Length 457/Name/X/Subtype/Image/Type/XObject/Width 47>>stream H?k@s!BFHWt7h\tԡ~spBZn~$]U&zh3y8X@Wp /JJE!5esrY-qaT&F{W̮ oOi/i%KB*G=v޾s;kTکoc_;X endstream endobj 440 0 obj <> endobj 677 0 obj <> endobj 678 0 obj [0.0 0.0 0.0] endobj 679 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 47 0 0 33 211 526 cm /Im0 Do Q endstream endobj 680 0 obj <> endobj 681 0 obj <>/Filter/FlateDecode/Height 33/Intent/RelativeColorimetric/Length 457/Name/X/Subtype/Image/Type/XObject/Width 47>>stream H?k@s!BFHWt7h\tԡ~spBZn~$]U&zh3y8X@Wp /JJE!5esrY-qaT&F{W̮ oOi/i%KB*G=v޾s;kTکoc_;X endstream endobj 436 0 obj <> endobj 438 0 obj <>stream HI@DQ1Ơ Ӻ֯6݀YXOClQhpR͏:}yM^Շ׵3/ߝ*1::gv4.h4V&` endstream endobj 682 0 obj <>/Filter/FlateDecode/Height 33/Intent/RelativeColorimetric/Length 563/Name/X/Subtype/Image/Type/XObject/Width 47>>stream Hk@}wpn6(N8Ђ \ C5c{{(ȡZ?>}DbSP$ @ċX E,al2˕*e9SGjh4|.g1S9]ϰ@FRspFBFQ e&?mJF@BF;_ixzQ  %k:M%So.pջ4 J;\)ҵ40u50)4a kR,^,f9|7kDeb%B\,/^T y}:Aa b>٩\1n(/Ͻ途$:`|t0;ZfutWgf}sH v.։>o2S3QFRYt6ݶ6+- ۓ4ˤ˥F4JnL$[^*rrN$j{uE4c;VV endstream endobj 437 0 obj <> endobj 683 0 obj <> endobj 684 0 obj [0.0 0.0 0.0] endobj 685 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 47 0 0 33 165 526 cm /Im0 Do Q endstream endobj 686 0 obj <> endobj 687 0 obj <>/Filter/FlateDecode/Height 33/Intent/RelativeColorimetric/Length 563/Name/X/Subtype/Image/Type/XObject/Width 47>>stream Hk@}wpn6(N8Ђ \ C5c{{(ȡZ?>}DbSP$ @ċX E,al2˕*e9SGjh4|.g1S9]ϰ@FRspFBFQ e&?mJF@BF;_ixzQ  %k:M%So.pջ4 J;\)ҵ40u50)4a kR,^,f9|7kDeb%B\,/^T y}:Aa b>٩\1n(/Ͻ途$:`|t0;ZfutWgf}sH v.։>o2S3QFRYt6ݶ6+- ۓ4ˤ˥F4JnL$[^*rrN$j{uE4c;VV endstream endobj 433 0 obj <> endobj 435 0 obj <>stream H;! CQMJyŜ !že'Y=2 ͸AWGu7dlzdS͢.Gnc-+}-3ۥB!(B!(j WTU,J}˵7,i2jڀ";0jDZfhݡ%vXM>dOh= VBF܀)1SM\#g\00>v endstream endobj 688 0 obj <>/Filter/FlateDecode/Height 86/Intent/RelativeColorimetric/Length 1086/Name/X/Subtype/Image/Type/XObject/Width 86>>stream H=oHS؅GyH|RR@){-&WL c#-ET`$Z.x)@mn6^`̸@?yfŅ(E93>^ǂmju:m-Ir6$Ij@`; "S 枚HQQ|Bv%B0Nt)aD>vLN.4]׵Tva=ɥ>)CD'& ad`XDNp)j"-n nW l ԷWX 7Aź,_6,%q)p/i\qܣIVqrgXdfNڥ/v?\\à븳bB5 OY\&Ķl\6[+&usAm3fIzJSbbOIGWrX/b#喻IK0̓Wݤ֥)rvgEE=X WaIDEaխD%u',ńID KSO 6lY? Da0姭YE+jC>ܒi:`N%j΋\$}Y5% \%' 泍`UTTV,JTc& ڛU{WUOT/lX/.S绀{7[ƨ=YmMӛ[G7xo6<2+Σ/N6 XCm*zsG_lnܳL\Y}ޫw.лcM4 fPwT!t4La1vؚ,xb:tl[u POP\}_yZoݢ>r%ֿ,CfZ>z( Յ2&'>23_]?>v:E2*eGM7D3eEAb|*|Ҁ6$驉Q|$ SM -^r\.wM"tqb/BH ;|Q!;$q ,F endstream endobj 434 0 obj <> endobj 689 0 obj <> endobj 690 0 obj [0.0 0.0 0.0] endobj 691 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 86 0 0 86 68 499 cm /Im0 Do Q endstream endobj 692 0 obj <> endobj 693 0 obj <>/Filter/FlateDecode/Height 86/Intent/RelativeColorimetric/Length 1086/Name/X/Subtype/Image/Type/XObject/Width 86>>stream H=oHS؅GyH|RR@){-&WL c#-ET`$Z.x)@mn6^`̸@?yfŅ(E93>^ǂmju:m-Ir6$Ij@`; "S 枚HQQ|Bv%B0Nt)aD>vLN.4]׵Tva=ɥ>)CD'& ad`XDNp)j"-n nW l ԷWX 7Aź,_6,%q)p/i\qܣIVqrgXdfNڥ/v?\\à븳bB5 OY\&Ķl\6[+&usAm3fIzJSbbOIGWrX/b#喻IK0̓Wݤ֥)rvgEE=X WaIDEaխD%u',ńID KSO 6lY? Da0姭YE+jC>ܒi:`N%j΋\$}Y5% \%' 泍`UTTV,JTc& ڛU{WUOT/lX/.S绀{7[ƨ=YmMӛ[G7xo6<2+Σ/N6 XCm*zsG_lnܳL\Y}ޫw.лcM4 fPwT!t4La1vؚ,xb:tl[u POP\}_yZoݢ>r%ֿ,CfZ>z( Յ2&'>23_]?>v:E2*eGM7D3eEAb|*|Ҁ6$驉Q|$ SM -^r\.wM"tqb/BH ;|Q!;$q ,F endstream endobj 431 0 obj <> endobj 432 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 86 0 0 86 54 485 cm /Im0 Do Q endstream endobj 694 0 obj <> endobj 696 0 obj <>stream H91@OO; bWXZ+'>I$A7p̱q4̨F#,,dz,,lUUu˖ۀ~6$U(胥荥菥(IڎTQG= u_'[ci=4GtAf6>v1P; -{jhk0k 뛀CslL4&͐ :\\ '&u endstream endobj 697 0 obj <>/Filter/FlateDecode/Height 86/Intent/RelativeColorimetric/Length 1062/Name/X/Subtype/Image/Type/XObject/Width 86>>stream H=oHS؅ [Ro`TtH eurHD,+m*H\p-dF:CB3Tw:gdd]=(<8fVe,׹Tre1 9^Q&T rByg>~CvGf/Fy>5L iLulwŁ#PY"r^So3fA;}~6vQoܱzNcUNTϸQQƠz K'* ȺY:Qq>LYFDEaЪ6} Q1t*~P~L#*vU*)f60+` *,~*5r"Ԫtt @hͼҭcZsjEN .Vhպ.6vQV-+.j?dms|gu9/y~L < 7mKlӺ㨛λRXoT* X's p>\Y!Og,@BuSk+IRZk¬.v7w~Ykޫt $4AO`msvuٙy̹Z;.ٚ-|KC(f%4AJ&|l8U pAI{qd[m8h}6v`2 }" YǓ`q]3dAݸ> endobj 698 0 obj <> endobj 699 0 obj [0.0 0.0 0.0] endobj 700 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 86 0 0 86 54 485 cm /Im0 Do Q endstream endobj 701 0 obj <> endobj 702 0 obj <>/Filter/FlateDecode/Height 86/Intent/RelativeColorimetric/Length 1062/Name/X/Subtype/Image/Type/XObject/Width 86>>stream H=oHS؅ [Ro`TtH eurHD,+m*H\p-dF:CB3Tw:gdd]=(<8fVe,׹Tre1 9^Q&T rByg>~CvGf/Fy>5L iLulwŁ#PY"r^So3fA;}~6vQoܱzNcUNTϸQQƠz K'* ȺY:Qq>LYFDEaЪ6} Q1t*~P~L#*vU*)f60+` *,~*5r"Ԫtt @hͼҭcZsjEN .Vhպ.6vQV-+.j?dms|gu9/y~L < 7mKlӺ㨛λRXoT* X's p>\Y!Og,@BuSk+IRZk¬.v7w~Ykޫt $4AO`msvuٙy̹Z;.ٚ-|KC(f%4AJ&|l8U pAI{qd[m8h}6v`2 }" YǓ`q]3dAݸ> endobj 430 0 obj <> endobj 703 0 obj <> endobj 704 0 obj [0.0 0.0 0.0] endobj 705 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 34 0 0 33 253 232 cm /Im0 Do Q endstream endobj 706 0 obj <> endobj 425 0 obj <> endobj 427 0 obj <> endobj 707 0 obj <> endobj 708 0 obj [0.0 0.0 0.0] endobj 709 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 34 0 0 33 203 226 cm /Im0 Do Q endstream endobj 710 0 obj <> endobj 366 0 obj <> endobj 711 0 obj [/View/Design] endobj 712 0 obj <>>> endobj 364 0 obj <> endobj 365 0 obj <> endobj 714 0 obj <> endobj 715 0 obj <>stream H|SmlSe;wc>]٘) d8\0vC,t+YCFQ  deH $ַ-?'9yssrHB!#HL_vqYҬzb[qe/pڤ r3,B{nS XK23a1Ə7n 5$\lsm ^}WX괺Ɣ8JMD`  3.!(T3*Q,JMYy (,LU2g>k;BY#94u2J @: YX== h}i uY5l:kQ1XgaOuV:ÙOmq $@.?p0W/~zX-dH׿|{/س="СwC0o o>`Ahkl5c|494$4S-x" 1Z>2bQcFG"*݈c_^̨&O ! E>*HisK1c4;G.P_0v-yT#Wu$TWokG7tN\>+Z獵xwSP8}snKj|~E/y40H\.z?Ab眪vw}8+ u̅nxġLRVkѮq,[Zu]@_'}!V=|6H#}| ו!Y'NRѝAizc$gC@:#U>V2>x&՟cEpz2* +3FPe2Ãmqo+%.{-oZP=IW!7<$Z OҝɺoNSOAj?ؿ {. endstream endobj 713 0 obj <> endobj 716 0 obj <>stream H|RkLSg)xzh-&ˀ EK[c:QZ)nZ Ɩ[4Ѽ~uW̒ڟ7{y χCD@JJBLTFD*of%cgLo.)39>11Jff˖ymظ]Ą0S.]$;ruZNSW*FQAŠV<^$qWkqMtⰠKCe{Ԍ&Qk Yj=25x zBy3q32xxD0 *OэoQrF# ^`YʨL,c' qzI!uDqgO\>|w<oyWE7vv7p{`D( f>AbRu&ӹpҨ($u٦;6Q bN]_.:Iښ\dk}hg2e .n[N r#Q;JvЖ{pBb}>Q&Z+`ʜ<&cÐ>,P,m*$Xٴ!W[g 3͔$ ~CSa80Ꝝ`{zc㲫Һ.{_`KCG4!6E\V-/H;(Ox$| 4Qh͕K LDg<pB)rY#kTv,ٮ)uGOW;[Y)ֵ_WXZR#VII+9q\M'c:}<3oߤc:Nŝgi:ZdNؽkU9uxbstvzM]T8ϖǾ&DW0e9WRh'z]b[nFdnz6WlHZTQW5[{-ny J@1e?7XGg05Xwkk8i2Nc@Y endstream endobj 375 0 obj <> endobj 376 0 obj <> endobj 377 0 obj <> endobj 378 0 obj <> endobj 371 0 obj <> endobj 717 0 obj <> endobj 718 0 obj <>stream %!PS-Adobe-3.0 %%Creator: Adobe Illustrator(R) 14.0 %%AI8_CreatorVersion: 14.0.0 %%For: (GV) () %%Title: (master.ai) %%CreationDate: 1/28/2010 4:25 PM %%Canvassize: 16383 %%BoundingBox: 40 119 558 585 %%HiResBoundingBox: 40 119.8428 557.2539 585 %%DocumentProcessColors: Cyan Magenta Yellow Black %AI5_FileFormat 10.0 %AI12_BuildNumber: 367 %AI3_ColorUsage: Color %AI7_ImageSettings: 0 %%RGBProcessColor: 0 0 0 ([Registration]) %AI3_Cropmarks: 0 0 800 600 %AI3_TemplateBox: 400.5 299.5 400.5 299.5 %AI3_TileBox: 4 -6 796 606 %AI3_DocumentPreview: None %AI5_ArtSize: 14400 14400 %AI5_RulerUnits: 6 %AI9_ColorModel: 1 %AI5_ArtFlags: 0 0 0 1 0 0 1 0 0 %AI5_TargetResolution: 800 %AI5_NumLayers: 1 %AI9_OpenToView: -203 649 1 1166 654 18 1 0 69 109 0 0 0 1 1 0 1 1 0 %AI5_OpenViewLayers: 7 %%PageOrigin:0 0 %AI7_GridSettings: 72 8 72 8 1 0 0.8 0.8 0.8 0.9 0.9 0.9 %AI9_Flatten: 1 %AI12_CMSettings: 00.MS %%EndComments endstream endobj 719 0 obj <>stream %%BoundingBox: 40 119 558 585 %%HiResBoundingBox: 40 119.8428 557.2539 585 %AI7_Thumbnail: 128 116 8 %%BeginData: 14796 Hex Bytes %0000330000660000990000CC0033000033330033660033990033CC0033FF %0066000066330066660066990066CC0066FF009900009933009966009999 %0099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF3300FF66 %00FF9900FFCC3300003300333300663300993300CC3300FF333300333333 %3333663333993333CC3333FF3366003366333366663366993366CC3366FF %3399003399333399663399993399CC3399FF33CC0033CC3333CC6633CC99 %33CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF660000660033 %6600666600996600CC6600FF6633006633336633666633996633CC6633FF %6666006666336666666666996666CC6666FF669900669933669966669999 %6699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066FF33 %66FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF %9933009933339933669933999933CC9933FF996600996633996666996699 %9966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC33 %99CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFF %CC0000CC0033CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399 %CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933 %CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFF %CCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0033FF0066FF0099FF00CC %FF3300FF3333FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699 %FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33 %FFCC66FFCC99FFCCCCFFCCFFFFFF33FFFF66FFFF99FFFFCC110000001100 %000011111111220000002200000022222222440000004400000044444444 %550000005500000055555555770000007700000077777777880000008800 %000088888888AA000000AA000000AAAAAAAABB000000BB000000BBBBBBBB %DD000000DD000000DDDDDDDDEE000000EE000000EEEEEEEE0000000000FF %00FF0000FFFFFF0000FF00FFFFFF00FFFFFF %524C45FD0FFF7DA87D7E7DFD78FF7D7D527D7DA87D7D527DA8FD74FF597D %A1FFA8FFA8FFA8FF7D537DFD72FF527DFD0BA85952FD70FF537DCACFA8FF %A8CFA8FFA8CFA8FFA87D7DFD6EFF7D52A8CAA8A8A8CAA8A8A8CAA8A8A8CA %A852A8FD6CFFA87DA8FFA8CAA8CAA8FFA8A8A8FFA8A8A8FF7D7DFD6BFFA8 %A852CFA87D28CF53537D52287D532852A8A8A852FD05FFA8FFA8FFA8FFA8 %FFA8FFFFFFA8FFA8FFA8FFA8FD05FFA8FFA8FFA8FFA8FD46FFA8FF7D7DA8 %FFA82EA85353537DCAA82853A8A8FFA87DA8FD05FFCACFFD04FFCAFFFFFF %AFCACAFD05FFA8FFFFFFA9CACAFD05FFA8FD44FFA8A8FF7D7DCFA8A8287D %2E7D28CFA8CA7D2852A8A8FF7D277DFD04A8C9A199A0BC9AC3CFA87EFFA1 %CA99BB99C29ACAA8A8A8FFA0C392C299C2A0FF84A8A8A8FD41FFA8FF7DA8 %A8FFA87D2753A8287DCAA8A87D28A8FFA87DF87D7D7D7EFFA1C292C299BB %99FF7D7DAFCAA0BC99BB99BBC9FF53A8FFC99ABBA0BC99C2FFA87D7D59FD %3EFFA8FFA8FFCF7D7DA8A8CA7D2853FF7D28287D522852A8A8CA527DFD04 %FFA8FFCAFFFFFFCAFFA8FFFFFFCAFFCAFFFFFFCAFFA8FFA8FFFFFFCFFFCF %FFCAFFA8FFA87DFD41FFCAFF7D7DA8FFA8FFA8FFA8FFA8A8A8CAA8FFA8CF %A87DA8FD05FFA8FD05FFA8FD05FFA9FD05FFA8FD0BFFA8FFFFFF7DFD3EFF %A8FFA8FFA8A852FD0AA8CAFD06A852FD07FFA8FFA8A8A8FD07FFA8FFA8A8 %A8FD07FFA8FFA8FFA8FFFFFFA87DFD44FF7D7DFFA8FFA8CAA8FFA8CAA8FF %A8CAA8FF7D7DFD24FFAFFFAFFFFFFFAFFD3AFFA8FFFFFFA8FFA8FF7D52FD %0EA87D52A8FD23FFA8CAA1FD05FFA8FD3EFFA8FFCFFF7D7DA8FFA8CFA8FF %A8CFA8FFA8FFA8527EFD25FFC3CAC2C392C2C2C9FD39FFA8FFFFFFA8A8CA %FFA8FF5252A8FFFD07A8FFA8527DFD25FFA8CAA0C29A9299BCA0FFA8FD38 %FFA8FFFFFFA8FFFFFFCFFF7D527DCAA8FFA8FFA8A87D527EFD2FFFA8FD3A %FFA8FFFFFFA8FFA8FFA8FFA87D527D527D527D5253A8FD28FFA884FFA8FF %FFA87DFD3AFFA8FD05FFA8FD07FFA8A87DA8A8A8FD2AFF52A8A8FFA8FFA8 %7D7DFD3AFFA8FD05FFA8FFA8FFA8FFA8FFFFFFA8A8A8FD27FFA8A852A8FD %07FF7D7DA8FD3FFFA8A8A8FFCAFFA8FFA8A8FD29FFA8F87DFD09FF5227FD %3AFFA8FFFFFFA8FFFD07A8FD2BFFA87DA8FD09FF7D7DFD71FFA8FFFFFFA8 %FD44FFA8FFA8FFA8FFFFFFA8FFA8FFA8FD12FFFD1DA87DFD05FF7DA883FD %1BA87DFD24FFA8FFFFFFA8FD15FFA8FFA8A852A8FD04FFCFFD07FFA8FD0A %FFA8A8FFFFFFA8A8FFFFA87D7DFD15FFA8FFA8A8FD25FFA8FD17FFFD04A8 %F87DA8A8527DA8A8527D7D7D52A87DA87D7DA8A852A8527DA8FFA8A8FD04 %FF7DFFA82752A8A85252A8A87D527D7D527DA87D7D7DA8A8527D7D52A8A8 %A87DFD3EFFA8FFA87D27A827A852522752277D277D5252A85227FF52277D %2727FFFFA8A8FFFFFFA8A8FFFF7D527D527DA8275252275252527D527D52 %527DA8F87D27277DFFA8A8FD3DFFA8A8A8CF7D2752527D7D275227277D52 %52522E5227277D52007D0027A8FFA8A8FD04FF7DFFA8A85227277D7D2827 %52277D5252527D2752275252277D5227A8A8A87DFD3EFFA8FFA87D7DFFA8 %7D7DA8A87D52A87D7DA8A852A8FD057DA87DFFAEFFA8FD04FFA8FFFF7D7D %A8FF52837DA87D7D7DA852A8A87DA8A8527D7D837DA8A8FFA8A8FD3DFFA8 %A87DFD10A8CFFD07A8FFA8A87DA8FD04FF7DA8A8CAA8A87DA8A8A8A7A8A8 %CFFD09A8FFFD04A8A1A77DFD3EFFA8A87DA87D2E52A87DA87DA87DA87DA8 %7DA87DA87DA87DA87D52277D7DA8FD05FFA87D5228FD08A87D2752FD07A8 %522752FD04A87DFD44FF277DFD12FF5352FD0AFF277DFD09FF287DFD07FF %7D27A8FD49FFA8A8FD13FF7DFD0AFFA8A8FD09FF7DA8FD08FF52FD3EFFA8 %FD0B7D52FD137D5252FD0A7D5252FD097D52A8FD07FFA87DA8FD3DFFA852 %FFFD09A87D7DFFFD11A87E52FFFD09A87D7DFFFD09A8FD09FF52FD3EFFA8 %7DFD0AFF7DA8FD12FFA87DFD0AFF7DA8FD12FFA8A8A8FD3DFFA87DFD09FF %AFA8A8FD12FFA87DFD0AFFA8A8FD11FFA0FD40FF7D7DFD08FFA8FFFFFFA8 %A9A8FD0DFFA8FFFD05A8FD07FF7DA8FD0EFFA8FFCA99A0CAA8C9CAFFA8FD %39FFA87DFD07FFCAC2C3FD12FFCAC3FD0CFFA8A8FD11FF99BB92C299FD3C %FF7D7DFD06FFA8FF99BBA0C299CAFD0CFFA8FFA098A7CACACAFD08FF7DA8 %FD0EFFA8FFA0C9A7C9C9C9FFFFA8FD39FFA9A8FFAFFFFFFFA8FFA0BBC2C3 %99C2CAFFA8FD0CFFC392C299C299FFA9FD06FFA87DFD0FFFCABAFD05CAC9 %CAFD38FFA1C9FD04FFA8FFFFFFA8999AC9A1CAA1C3CAFFA8FD09FFAFC992 %C3A1C3A0C9CAFFA8FD05FF7DA8FD0EFFA8CA92BB99BC92C29ACFA8FD37FF %C392CAC9CACAFFA8FFFFFF99C292BB9AC399FFA9FD0BFFC2BBC3C29AC3C3 %C3FD06FFA8FFA8FFA8FD0DFFA8C9C3C9A0CAC9CFA8FD36FFA8FFA1BA92BB %99C2FFFFA8FFA8CAA1C3A1C3A1CAA8FD0BFFAFC999C2999999C2C9FFA8FF %FFFF99C2FFFFCAFFA8FD0DFFA9FD05FFA8FD38FFCAC2A1CACAC9CAFD05FF %AFFD13FFA8FD07FFA8FD04FFC292C999C2CAFFA8FD0DFFA8FFA8FFA8FD38 %FFA8C299CAA1CAA7CAA1FFA8FFFFFFA8FFA8FFA8A9A8FD0DFFA8FFFFFFAF %FFA8FFFFFFA8FFA0999ABB99CAFFFFA8FD0DFFA87DA8FD3AFFC2BB99BB92 %BB9AC9FD07FF7DA8FD11FFA8A87EFFA8FFFFFFA8FF99C9FD04FFCFFFA8FD %0FFF7DFD3AFFA8CAC3C9A0C3A1C9A8FD07FF7DA8FD12FF7D7DFD06FFA8BB %99C39AC399C2A1FFA8FD0DFFA87DFD3BFFA8FD07FFCAFD06FF7DA8FD12FF %A87DFD06FFCA9AC2A0C299C2C3CAA8FD0FFF59FD3CFFA8FFA8A8A8FFA8FD %07FF7DA8FD12FFA87DFD06FFA8FFCAFFCAFFCAFFA8FD0FFFA87DA8FD3DFF %A87DFD0AFF7DA8FD12FFA87DFD07FFA8FD05FFA8FD11FF7DFD3EFFA87DFD %0AFF7DA8FD12FFA87DFD08FFA8FF7DA8A8FD12FF7DFFFFCAFD3BFFA87DFD %0AFF7EA8FD12FFA87DFD0AFFA87EFD12FFA853FFCAC2CAFFCFFD38FF7D7D %FFC39AFFFFFFCAFFFFFF7DA8FD12FF7D7DFD0AFF7DA8FD12FF52277DC9A1 %C2999999C2A1FD35FF5227A8C3C99AC299C299FFA85252FD12FFA87DFD0A %FFA8A8FD13FF7DFFCAC2C2C2BCC2C2CFFD35FF7D7DFFC399C29AC299C3FF %FF52A8FD12FF7D7DFFC999FD07FF7DA8FD12FFA8A9A8A87DA7A8A8A1FD06 %A8FD31FFA8A8AFA8A1A1FD08A8FD12FF5227FFC9C99ABB99C399FFA82827 %FD12FFA852FD0B7D59FD32FF597D527D597D527D597D527D527DA8FD11FF %7D7DFFCA99C292C29AC2FFFF5384FD12FF7D7D597D7D7D597D7D7D597D7D %7DA8FD31FFFD0E7DFD13FFA8FFA8A1A7FD06A8FFA8FD12FFA8FD0D7DFD32 %FF7D7E7DA87D847DA87D847DA87DA8FD12FF7D7D527D597D537D597D527D %527DA8FD11FFA87D7DA87D847DA87D847DA87D7DA8FD31FFA8A8A87EA8A8 %A87EA8A8A87EA8A8FD12FFA87DA87DA87DA87DA87DA87DA87DFD12FFA8A8 %A87EA8A8A87EA8A8A87EA8A8FD32FFFD0C7D5384FD12FF7DA87DA87DA87D %A87DA87DA87DA8FD12FFFD0C7D597DA8FD30FFFD04A87EA8A8A87EA8A8A8 %7EA8A8FD12FFFD0EA8FD12FFA884A87EA8A8A87EA8A8A87EA8A8FD2AFF7D %A8A8A87DFD13A87DA883A87DA8A8FF7DA8A8A87DFD13A87DA8A8A87DA8A8 %FF7DA8A8A87DFD13A87DA8A8A87DA8A8FD21FFA8AEFFCFFFFFA852A8FD06 %FFCFFD09FFA8FFCFFFA8A8FFA8CFFFCFFFFFA8527DA8FD05FFCFFD09FFA8 %FFCFFFA8A8FFA8CFFFFFFFCFA8527DA8FD0FFFA8FFCFFFA8A8FD21FFA8FF %A8FFA8FF522727A87DA8527D7DFF7D527DA8527D527DA8FFA8FFA8A87DFF %7DFFA8FFA8FF5227277D7DA87D7D7DFF7D527DA852A8527DA8FFA8FFA8A8 %7DFF7DFFA8FFA8FF5227277D7DA87D7D7DA8A8527DA8527D7D7DA8FFA8FF %A8A87DFD21FFA8A8FFA8FFFF7D2752277D7D275252FF527D5252FD0427FF %A8FFA8FFA8A8FFA8AEFFA8FFFF7D27527D527D522752FF7D527D52277D27 %27FFA8FFA8FFA8A8FFA8AEFFA8FFFF7D275252527D5252527D83527D5227 %525227A8FFFFA8FFA8A8FD21FFA8FFA8FFA8FF5252522752522752277D27 %7D525227522752A8FFA8FFA8A87DFF7DFFA8FFA8FF5252277D2752525227 %7DFD0452277DF852A8FFA8FFA8A87DFF7DFFA8FFA8FFFD045227FD095227 %7D27277DFFA8FFA8A87DFD20FFA8A8FFFFA8FFFFA85283AE7D7DA87D7D52 %7D52AEA77D7DA87DFFFFFFA8FFA8A8A9A8FFFFA8FFFFA8527DFFA852FF7D %7D52A852A8A87D7DA87DFFFFFFA8FFA8A8FFA8FFFFA8FFFFA8527DA8A852 %FF7DA8527D7DA8A87D7DA87DA8FFFFA8FFA8A8FD21FF7DA87DA87DA8A8A8 %7DA8A8A87DA8A8A883A87DA8A8A8A7A8A8A87DA87D7D7DFF7DA87DA8A7A8 %7DA87DA8A7A87DA8A8A87DA87DFD05A87DA8A8A87DA77DFF7DA87DA87DA8 %A8A87DA8A7A87DA8A7A87DA8A7A8A8A8A7A8A8A87DA87DA87DFD21FFA87D %A87DA8522752A87DA87DA87DA87DA87DA87DA87D7D27527DA87DA87DFFFF %A87DA8522852A87DA87DA87DA87DA87DA87DA87DA87DA87DA8522752A87D %FFFFA87DA87DA8522752A87DA87DA87DA87DA87DA87DA87DA8522852A87D %A87DA8FD26FFA805A8FD0FFF5227FD0AFFA800A8FD14FF277DFD09FFA805 %A8FD0FFFA800A8FD2CFF7DFD10FFA87DFD0BFF7DFD15FF7DFD0BFF7DFD11 %FF7DFD2DFF52FD10FFA87DFD0BFF53FD15FF52FD047D7E7D7D7D7E7D7D52 %FD047D7E7D7D7D7E7D7D7D7EFD047D527D7D7E7D7D7D7E7D7D7DA8FD22FF %7DFD10FFA87DFD0BFF7DFD15FFA87EA87EA8A8A87EA8A8A87D7D84A87EA8 %A8A87EA8A8A87EA8A8A87EA87D597DA8A8A87EFD04A87D7DFD22FF52FD10 %FFA87DFD0BFF59FD21FF52FD11FF7DFD09FFA87DFD21FFA87EA8FD0FFFA8 %7DFD0AFFA87DFD20FFA87EA8FD0FFFA87DFD09FFA87DFD20FFA9FFFFFFA8 %FD0EFFA87DFD0BFF52FD1FFFA8FFFFFFA8FD0FFF52FD09FFA87DFD1FFFA7 %C9FD04FFA8FD0DFFA87DFD0AFFA9FD1FFFA8C9FD04FFA8FD0EFF7DFD09FF %A87DFD1FFFA192C39AC399CAFD0CFFA8A884FFA8FD06FFCFA0FFFFFFCAFD %1CFFA892C39AC399CAFD0EFF52FD07FFA9FFFFFFA8FD1EFFA1BBA0C2A0C2 %A8FD0BFFA8FD0BFFA8BBA0BBA1C2A1FD1BFFA8BBA0C29AC2A8FD0FFFA8FD %06FFC9C9FFFFFFA8FD1DFFCAA8FFCFFFA8A9FD0BFFA099CAA7CAA1FD05FF %AFA899C299C299A7AFFD1BFFA8FFFFFFA8AFFD0BFFA8A0FFFFFFA8FD05FF %999999C2A1A0A8FD1EFFA8FFA9FD0DFFA0BB99BB9AC2A8FD05FFA8FD05FF %A8FD1DFFA8FFAFFD0DFFA8BBC2BBC3C2A8FD04FFC39AC2A0C2A0FD20FF52 %FD0EFFA7A1C9A1C9A1FD07FFA8FFFFFFA8FD1FFF52FD0EFFA899C299C299 %CAFD04FFA8FFFFFFCACFA8FD1FFF7DA8FD13FFA8FD07FFA87DA8FD20FF7D %FD0EFFA8FD05FFA8FD05FFA8FFA8FFAFFD20FF52FD0FFFA8A8A8FFA8FD09 %FF52FD21FF52FD0FFFA8FFFFFFA8FD07FF7D7DFD22FF7DFD10FFA87DFD0B %FF7DFD21FF7DFD10FFA87DA8FD08FFA87DFD22FF59FD10FFA87DFD0BFF52 %FD21FF59FD11FF52FD09FFA87DFD22FF7DFD10FFA87DFD0BFF7DFD20FFA8 %7DFD11FF7DFD09FFA87DFD22FF52FD10FFA87DFD0BFF59FD21FF52FD11FF %59FD09FFA87DFD0FFFCACACACFCACFCAFFCAFFCAFFCAFFCAFFFFFFA87DA8 %FD0FFF7D7DA8FD09FF7D53A8FD04FFCAFD09FFCACACAFFFD0BCAFFFF7D7D %A8FD0FFF7E53A8FD08FF8452FD04FFCACACFCAFD06FFCAC3CACAA1CACACA %A7CACACAA7CACACAA8FFFFA8F87DFD0FFF5227FD0AFF84057DFD04FF9ACA %FD07FFC9A1FFA1FFA1FFA1CFA1CAA1FFA1CAA1FFFF7DF8A8FD0FFF7D007D %FD08FF5227A8FFFFFFA1CAA1C9FD06FFCAC9FD0FFFA8FFFFA8A8FFFFFFA8 %FD07FFA8FFFFFFA8A8FFFFA8FFFFFFA8FD04FFA8FD06FFA1FD07FFA1FD07 %FFAFFFFFFFAFFFFFFFA8FFFFA8FD04FFA8FD07FFA8FFFFFFA8A8FFFFA8FF %FFFFA8FFFFFFA8FFFFFFCBFFFFFFA0FD06FFCACAFFFFA8A87DA87DA884A8 %84A87DA87DA87DA8A8A87DA87DA87DA8A8FF7DA87DA87DA8A8A87DA87DA8 %7DA87DA87DA87DA87DA87DA8FFFFCACAFD07FFCACAFFFFA87DA87DA87DA8 %A8A87DA87DA87DA8A8A87DA87DA87DA87DFFA8A87DA87DFD05A87DA87DA8 %7DA87DA884A87DA87DA8A8FFFFCAFD06FFCAC3FFFFA8A8FD04FFA852A8FD %0CFFA8FFA8A8A8FFFFFFA8FFA87D7DFD0BFFA8FFFFFF7DFFFFFFA1FD07FF %A1FFFFFF83FD05FF7D7DFFFFAEFD09FFA8FFFFA8A8A8FFFFA8FFA87D52FD %0BFFA8FFFFFFA8A8FFFFA0FD06FFCACAFFFFA8A8A8FFA8A8F87DA87DA8FF %527D7DA87D7D52FFA8FFA8CFA8A8A8FFA8FFA82752A87DFFA87D7D7DA87D %587DA8FFA8FFA8A8A8FFCACAFD07FFCACFFFA8A8A8FFA8FF5227A8A87DFF %7D7D7DA87D7D52A8A8FFA8FFA8A8A8FFA8FFA87D277DA87DA8A852A87DA8 %527D7DFFA8FFA8A87DFFFFCAFD06FFCAC9FFFFFFA8FFA8FFCF7D27A827FF %7D275252527D2752A8FFA8FFA8A8A8FFA8FFA8FF7D525852A8FF27FD0552 %27A8FFFFA8FF7DFFFFFFC3FD07FFC3FFFFFFA8FFA8FFFFA8277D7D7DFF52 %277D287D27277DFFA8FFFD05A8FFA8FFA85252A827FF7DF8FD0452F87DFF %FFA8FFA8A8FFFFC3FD06FFA8CAFFFFA8A8A8FFA8FF7D2727287D52275227 %27A82752A8A8FFA8A8A8FFA8FFA8FFA8A85227277D52272752F8A82752A8 %FFA8FFA8A8A8FFA8CAFD06FFCACAFFFFA8A8A8FFA8FFA87D277D277D2727 %7D27277DF87DA8FFA8FF7DFFA8CFA8FFA8A87D2752277D52275227287D27 %7DFFA8FFA8A87DFFFFCAFD06FFCAC9FFFFFFA8FFAEFFFF7D7DFF7D7D7DFF %7D837DFF7D7DA8FFA8FFA8FFA8FFA8FFFFFF7D7DA87D527DA8A87D7DA87D %52A8FFFFA8FF7DFFFFFFC3FD07FFC3FFFFFFA8FFA8FFFFA852A8A87D52A8 %A8A87DA8A87D7DFFA8FFFD05A8FFFFFFA87D7DFF527D7DFF7DA87DA852A8 %AEFFA8FFA8A8FFFFC3FD06FFA8CAFFFFA8AEA8A8527DA8FFA8FFFFFFA8FF %FFFFA8FFFFFFA8FFA8A8A8FFA8FFA87D52CFFD06FFCFFD05FFCFFFA8FFA8 %A8A8FFCACAFD06FFCACAFFFFA8A8A8FF527D7DFFCFFD07FFA8FFFFFFAEFF %A8FF83FFA8FFA8A8527DA8FFA8FFFFFFA8FD07FFAEFFA8A87DFFFFCAFD06 %FFCAA1FFFFA8A8FF5227277D7DA87DA87DFF7D7D7DA852A87D7DA8FFA8A8 %A8FFFFA827277DA8A87D7D7DA8A8527DA87D7D7D52A8FFFF7DFFFFCFA1FD %07FFA1FFFFFF7DFFA82752527DA87DA87D83A87D52A8FD057DFFCFFD04A8 %FF52522752A8A87DA87DFFA759A8A852A87DA8A8FFA8A8FFFFA0FD06FFCA %CAFFFFA8A8A87D27FD05522727A852525227275227277DFD05A8FFA82727 %5227A82752277D7D525252F852272752FFA8A8A8FFCACAFD07FFCACAFFFD %04A827FD075227FF277D2727277DF852A8FFA8A883FFFF522752277D5227 %5252A87D27522700582727FFA8A87DFFFFCAFD06FFCAC9FFFFFFA8FF5252 %527DFD045227A8527D5252277D27287DFFA8A8A8FFFFAE277D27FD06527D %527D52285252277DFFFF7DFFFFFFC3FD07FFC3FFFFFFA8FFA827FD05527D %52527D5252522752522752FFFFFD04A8FFFD05527D277D27A8FD0452277D %2752A8FFA8A8FFFFA0FD06FFCACAFFFFFD04A8527DA8A852A87D7D527D52 %A8A8597DA87DA8A8FFA8A8A8FFA87D52A8A8527DA87D52587D7DAE527D7D %A87DFFA8A8A8FFCACAFD07FFCACAFFA8A8A8FF7D527DFF52A87DA8527D52 %7DA87D527D7D7DA8FFA8A8A8FFA8A8527DA87D7DA87D7D527D52A87D597D %7D7DAEA8A87DFFFFCAFD06FFCFCAFFFFA8A8FFAEFFFFFFCFFD07FFCFFD07 %FFA8A8A8FFA8FFFFFFA8FFFFFFCFFD05FFCFFD05FFA8FF7DFFFFFFC3FD07 %FFC3FFFFFFA8FFA8FFFFFFA8FD0FFFFD05A8FD07FFCFFD0BFFA8FFA8A8FF %FFC3FD06FFA8CAFFFF84FD187DFFFD167D527DFFFFA8CAFD06FFCACAFFFF %A8FD177D52FFFD187DA8FFFFCAFD06FFCFCAFFFFFFA8FFA8FFA8FFA8FFA8 %FFA8FFAFFFA8FFA8FFA8FFA8FFA8FFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FD05FFC3FD07FFC3FD04FFA8A8FFA8FFA8FFA8FFA8FF %AFFFA8FFA8FFA8FFA8FFA8FFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFFFFFC3FD06FFA8CAFD0EFFA1C3CAFD0BFFCAC3CAFD17 %FFCACAFD06FFCACAFD0EFFCAC3C9FD0BFFCAC3A1FD18FFCAFD06FFCAC9FD %0EFFBB9AC2C9FFC9CAC3CAFFC9C3FFFFBBC3FFC3FFCFC9CACACAC9A0CAFD %0EFFA1FD07FFA1FD0EFFC292C2CACACAC9C9CAFFCAC2CFFFBBA0CFC3FFFF %CAC9CAFFC9A1C9FD0EFFA1FD06FFA8CAFD0EFF92BC99BBCABC9A99A0FF99 %C2A0FFA0BCA199FFC992C399C29992A0FD0DFFCACAFD06FFCFC9CAFD0DFF %A09999BCA1C992C299FF99C999FFA1BBA099A8FF929999C29A9992FD0EFF %CAFD06FFCAC9FD0EFFBBA1C29AC3A0C999C2CAC2A0C9FFCF99C299CA99BB %A0C299C992CAFD0EFFC3FD07FFC9FD0EFFC29AC299C2A0C2A0C2C9C2C3C2 %FFCFC2BB99CAC9BB99C392CF99C2CFFD0DFFA1FD06FFCACAFD0EFFA1C3CA %FFA0FFA8C9A0C9A0C9FFFFA0C9CAC3A0C9FFCAA7CACAC3A1FD0DFFCACAFD %07FFC9FD0EFFCAC2A1FFA1C9A8CAA1C3A1C3CAFFA1C3CAC9A0C9CACAA7C9 %FFC9A0FD0EFFCAFD06FFCAC3C9CAC3CAC3CAC9CAC9CAC9CAC9CACACAC9FD %18CAC9CACACAA1CAC9CAA1CAC9CAC9C3C3FD07FFC3C3CAC9CAC9CAC9CAC9 %CACACAC3CAC9FFC9CACAFFC9CACAFFC9FFC9CAC9FFC9FFC9FD05CAC9CACA %CAC9CAC9FD09CAC9C3FD08FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFCAFFA8FFCAFFA8FFCAFFA8FFCACFA8FFCACFA8FFCACFA8FFCACAA8FF %CACAA8FFCACAA8FD09FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFCAFF %A8FFCAFFA8FF %%EndData endstream endobj 720 0 obj <>stream %AI12_CompressedDataxmsGVd{"HyO9,{{ $A@ h~YYMRLjc&Ow~=v{g~M/WLo}qx5_M >3=Hg43t^>쏝^yiKmyzs>]r!׵M3MS~avfAY& 3^J&$H[$_2j9+p|.QwlryLķق8YL_]HMr%qjquɬ֧d?GLyȳ7qWMԞ1FĴz$bC4VІ;Zg/ڽ[d'4RN tmo˳91’xdzU~o՛Gg.rvA-.f_&z51]bhB 7s1M_b7tyt^-_юώE ӔKQ/g˳96l {Py5?[#51!|_L/LOi:?Zgӷ%^2[VJg+JB\'W!?!MOlR6>asSr !}ѣ|L|r@v`h>4u:p@dC }srprH^!M)ϟȟğ?~Iω|}yB|74\zrK'RSlᕎ[BZy-\s9yysP"!)N">RN2Ipңz$qm<*_ef[Y˔LÄ:.>NAg{ϓg+8,|\ G-ޔxu"qvsi8gp28Y ;.: ΅$;a)_[Z0p cm#'@ܗ玉Xj3qQ{LsQ>[HyHIJ# #{RGGOI' iu(X&pɓ'ONc{L?&u?!n>$݉<;zch9i7Дޠii'Fyb䉍xb@H3:!)טK:ܼso|s=5] $ntG~tG~tG~tG~tG}tFybCAn݄pHoW٥PJb1>0c|`v <1w`aM7ݣ~A"h l0uG0›zAs^hRGfBƃ&!K䧷?!o?vn&g@{~>:d91=y̐SUsZ!JL$=16˃J&'چ3XU&jK ׆ӛknN.Ӂl%~#O^He\J~x|O+-Y6eک|d*}7=2OD N,u3֞.{M{hJ;mRTZ$L* Қ5s6дۍ:T%4d7iu(v:=ڲH+F'C9|q,ݐC2!ʝ8>L "yiBE&ںeK8X%&Ӏk b6`/$ IB(ⱐčd,Iyh$&v< dAezHaZ(HR}B;L=l5ڦa[RvXR \K@*5&SnXHOtlb6|'cb@HDQfFj05'1QhSQ4fMe}H8ѵ2;DI]`)~6nC9C;$,Vh-N1G(yk1&Tb~o̲m2c&ҡi.FI߁֍j\C$ly+;5`Zlbh#Ӽd B2PZZfR!1/Q;)$J V#y#,9L+1X^[lr Xo'a$5ʞcni;/+: -FS~¾fD(MN&m1V%M/DNdG&NߝXkGeMȱhJtA,{IΗ-x,93РΒ:2mqhV dc* | J&xLEh -XZKmp>)9`b4}ih1N5R<_xKTAvP(tpZ2<|rt΄m!h;WDte5Ga ҃ CK%D},`|`s| N%B4vc 0(l|^LZ0\[/ G!cb US Q4Ea@x8$+Yvk,5A~8kؾaA>PaH+Gk |-NV¦z Rb l`醴C)&5Um`r 0VoGV`9/`(oND˖)`3[ 27e`F^FeD)4䁳[ũN JZ:cefKނ0c[IDvVLas2J|<a$X '=1FٔlpP2Y2eNX8Wj>mpMFp eO,7f0M]˭85z01xoD "t2MhŰWa pXGe=CY(mײ2:]<#,0:u-i5z' q%i2HVQ4lB% :BMyHiw}i1qHFiK>J 5=Ցvn iSq.ԀG(Q@ɟEm!+d3d`ITwZ,^@əj+n)SBe+T&qu}ts~e+uTP4\y\-XYv~:BDdc $$@>pNbSg?[I:s=W©QWK hEӂI$8.6I H/GQEbUsR~H*4 Hb0fya/԰ظܱ&F,H:<#yE%ȂeffqAH2֧&ɛ <uhh ~}le#T!MHaCIcS<"';N[GɆҌ<\qBD܏ܢw&đEXLnBh5ʾ̘$0b"_wtt<rmѷX :d Iiti[K9M_Ҥ`'[ G2o}(F\T1(TU7xb^Ȇh]OaqcHH(em(L;=Bl!j>U I] l"0,x<#ٍY:}lo:d8)H2ˆGʹ0Át2Ⱥ1by *DfقEM0$sR7Y'(aH 5_rhBl\l eMa:bFbֈ[. OꞬq~`f8wKdX!8H- '#;uxpYQhzyYoNY1(LpAE&unp#Sm 7ڱLE/MdmN(P7X\gWF1,sj+.RY J WX "`$DC͗W_Ҁ<M+ׇkX"eu\?-?sA Ҫ8 BHԩŦ|G*$QIJN ͥ|ţN!hx׵'B18/^S:UZ &@ $~\DĉNn!:\I#hlkyLb'^x»`#/ް%-6zZrxKt%3Bk5"kw\þL,V4ml(,2$Ok&i\j (&%5D4卽B )FDAab's Ux8QP>D#+"trLTpɒ;)GNF-D-Qo ~̺|m'ZUTnp0(B>w8j;+11O,ZrHidpt-iXx#LKKV*R Bv ]& ɠn1,#l</4REk\hi8"8ʉbukR FXpT;̃[(KDlFAzrE>IMgB2.2 X8.J%6rL-s߹,AõAu+0&/0`kq'\kjD,g;/D,P!6s45.M]]ł[hˌF̤b+Q\dm1ن=XرFuH" 8LaocVoevjHD˛ (Kg|q[PpZPgm!+ȹbQ`ig&%&D10h9A_|4e^C/JP5,F"D8x&G 4#(hh _lo612MWl N";1m؀J6aUe V~Iv ~ O6gwjzpnfEhC1׻5klP4K%!Ս[s4 T85GڜEJ6CKXY\a /QG/9W#aCL[oV<ڇ%d9ŏQ)%FdP6t$&k%i\f*2U5hdXEf5q0sbRCsA8Ԓ0i|zWۤcXƐj; )b+h >REc2 0IA ,sҞqZ*ہ$E| IUZ,KW߁NBBeIs91pQϩk؄A {@UW^*yP(,ΔZDfcY^b!CqPdȶՓ OV"H?o@z*6hwF?e!󕦇p1oǚ' t4.|K#*KP(ShO v4pm0I;؟:D\-@I><sl`ޠFC6@@A:S60jl7Y;8!ahc=b &8h%l` p\g<<NQl] ]m\9kA}\/xlrYZ\)?껃 L܀l]EWQ"'ئ;ڀk{G `%B=kK |Mm 8Wwp.;e87(%ag@ك5]pm>5GU' P ocgJ2C})6M A-fG6`HYxa>W zuĵtxd}%P<*o!T435\=Qmj{Ċ/(d^3@kW]$E,_WAxQ`H`|#l"ꑊnEqqEX)X9Y;XMsfgW+h+": RҳW@_Аk@zfmoL,Q=ELmyDe#ST = enNa`'@~ZjHa5ֹpĮwX zyxI#=썯y nzY6S (h^&f^98VZq} !BE97C[D)cGiׄ~a IڀC(yK?H>c$5`YbCZ3@o [HL"y.ғR:ZFObU oށ mx4vy\+= gn\x*!gy(ڝX RB0!=C[sr@݁\BwtGmC\@wy`>U;UlgC;$/lqKw yRxtvAa+=0mvzXĜ0+,(!6P):`QYC$ɸf7|z%B!fμ ]Q>ij(4r}L!_v%*gSS4&*+hvU]a/G2nڊY0Hu~}X݀p ImI$AZ "Uݾ%0v6hc Q(vNtœ0*DtBis_NyI0c0`-ÎBŰí3bstvS00g}c*ݱC ; 4,Q \UdCV;]>w5xTH \RnmzݡĞ-,#d| ~^2Θ\ғ>tݱqf*tA8 ]w0^< q($ "w~M:%nn::uH 0l'G7 I!\-u"WvrO=564}U:bկ}R`1}:l_>M0Q@@r7ĩ0umͼԲ RsŨFVO!Ru԰B:<-#PW7PF P`Ic+>u0q ?NE$phLݘLF4]Ilo6?3Nq[V\a~&Q) J3 et~It]vo$vpGGNQ{(,pʘ}ŢcE2HtFJx$PGÀká;$_q@Ѓ7(=I`M:<D CIĠ L YrMbheR/ ~C'=z'_ͧ(FZwPsb4C9W9H q@؅!$|w |%ڡϡ$`sTFps$gQ9 |7a> i=GŞsm]CeFR9j?+Y0`m|Jw="`\s˽à >'bBu+ vBLC9+8Ǩ9- >7|59?4Tyi=GM9 jLE0yX(gsW YHW9{ C$R8@#aryEsy#L[Xo@`qeM[ӤPqTTI/V'(T   R&1 ;}858@qb'c `SQ8$Mx,G!ⱑ3 Bocy#c[mL2dg%VJ rM C3<%'(j=;% {x MUs(oIP3B&yq(ArgL e.%g!5W%ƅ9b& "tW:ÀxS4)UH:E`NZu֪S4@)UH1:EHN"ZuѪS4U<ΑYunY59j\`Y5P׼dոȪ͊cM8VlބzX *`,Ǣ0V/{ +bEaF((VS Vn'C+H + aE<>ÊDڊaE(]|U9 _|W53j>W5U ૚QsvU9]tbW53j:GQ䪦sUM(pU9 \t霂[ՔVkJZՔNAjFGQV5U(hU3:̪CA R(R Y󜇐U+US[T3'-xU1WS %)etz}GYy}{+.^׶qH~G5~{o߳.oY2`d17ptK8X:wBJZX7.3P rb?`a*N0.2Ls#H_<)Ji#c+!ƑbChLsǎ$Guʷ<^_W&kWWZ3}ɔ$D-:zFsBFޞ:BYE̕oAwqoſʌw[_ FC#76K:?M2dn&O~}7lRKZF,BROxZ;?knӶR~.BCi!]&n#r&Н "I DxYd \M2ˍ#[HD+*BnMi<5uDBGr\ [Z؉\Ȟ="5t+AD| -֔[!{ ݣQƍSӞ*=ȇ$5([k4d]!+-]eНNqAG!Bl=bs] Ȯ\BDڣ4G \f!v3J!/ x}omYKcҹV&!+Dv ofy:PZr,){#pBڷ,^Wf 6NqH K\LVlP!3d.AiڮXa_ pE q DNw* 9v"v&n^J -ʭuo@y8_z1簊*-g&m $>̨sm6 UyBn d8[ 1H<9xNq䪽6n]O>w: D6e 7V[CO9H+S1S[Nհ1ue6B1^EQ\ĝQu]BH;!WQ&d~R3*6Ǚ҂U-ÆtfJAQjAl< b}a -"@\)D][g%]߄O(ƕtLj8iM!vڟO)_{Kz 9O -4Jm:~oPmT>ԙnBc g7BVC׆Ccµ*`҂1Gmfi$SY Yd"X^#,j(-њ}Ps9覦_Qp􁳷nX۲3!sEp@[}v@n5QUEԵ@~P+]њjbȐq`!‘fzDEJ] \:^QkȡS!=V ay vDYKfG,V!;ÈKZЋWC񬣀#+B: W}B?Uw*KX æ3Em ٮagnHj$kE"RgV .ɴKyCOr7QYޚjD}2%qk ӭh>zz!o-.a"ñ;.D%@h.+5L*\-0h"W-E2rH?JEjjTB 1ǠZЛ5%XG?5J\ ҹh1iSuc4ZTmCevbg] +*A>0-/`  ],i :xTu5MKQKL)>1ˀ^L@|M'hmAFOP꫔,HjX-3[5œ/F*QcX"";荫0h \ڇ;]/z*-രtExtjB9( ^/.'O7mfƛs1@ծk܊ڪrU) T5pԄdž)FWT3Pm V\X4(&|g. C$d;\;V7]9>981B!#~ݐ 8֐Bs }(^M'V^Y({\]ś ƧF 6̧ g*$vGT; 3!XЀ \hmuR:ujCv` .jnst͚ ȍ/ͼn2.v L2ܖ'䦴%L%&"yIQmpGkJF2 d\¦b* %φk!n\ݵ<.#\j4` vwϸP%mdBotz7\[i.& TՔkXFX뺧=UrE`rbAHڔy{(2LIۍu*Qkm5Aj !fm(]D % DX^N*}d/9޶+J>\)я {( 7ָUrDtpWo!F}SZh]fR]hSBW$$b jR-DN]g ƫJVE'kT!e@Fb%#Wu-ÖgBR"EUt1{s䤦7 5/Ӟ~^R\ 5;۶Y|1rc4Ky-]:u0~ o>d ^8TBAU/4jL %Jw^+*IGЪ-ⴠFi_{J,7N7[qɒIߙ:7]S[Pn3Fw]+d4镛:9IӊH,vWh80[Zteڇ|<^ |N[YȋMM-4څ/X5PVE{PNP3%Bf𸠤VvjrrLoJA}Nc|Jw١~}]^; T֘Zbz WرL^ق>:g5Ob!Էz!HL +g{OܙmI^o,PGzi ЍVc,ܨ4ZҾ)$>JiSL")0UlnՖs3rPo5J3iZtY͢n&HX*!?Zk߸01)H4CFLt 1Z 4.9/i4OsLBn:J̵kgiqRBWV4QǖJ?L;ɱѵ}լ&%j;̣OZZ[7sc-3HsU9ʮ@8JD'jT0e|\z3%~4K%wwh_bki햳4jrmxKԠ5u\Hm t~\Q/tm!"pymQhb95 P@1.(ԒZ+ȹZjeu!oM/_K ÕMihkq_XIxl5@k:􄦦3PLN4oI3'е (Հ8Q5HHD1ۮF -tU%תdjL_5l]EtWIv1zupWqVe):+Wjrׇժz'!ILz _mBs#urBh ]-tq6*Gj& K)Y^s AHr(4px Yw}3Q(̶PhK^IjkWH+린]^mӄʑoj/7]r:feW*m;X7f51W;t'8Mմ-B.Z>c%u\gͷ}keV=g{9SckQuhM׀ MI ;L ?ZشnWrᮼQٝ$(=+Zj"(E2X!r]((&` H>jnEMЛ K NKlT5Pҗ ?.zji=dyhyp}yzdM-h2[pZ)ajC0VRו& [h|*J',č.&WbǪp[U:&\ŧvqS5"BystH)=k=/B;t($%#O =ւ5$ r@AWf w?$ÄƋ"=ڪq!&)?q1Vf;ŔDQJ SJ;PWY1kcS '+[Z8W2'SdrЖ]x$5BNm.G7m%APXt!b$gŗ|Eݦ!sV)2䀯'eʊM;ZJEh=W;pHnypvTld+bCbQŞZ:TTs]Zp֟%ͯdv/jtr\:@<E|c딪KT5V}{p}lcx&o-#aG%yh;`w WRREδ^Psym6ULrZFܝ%,fŁYL 9Q%8#9kz_`Tȗ?.~V엽y^g '@,QӹT)(AߌmtzMQs*Ś&hBOv*ORt5v~8W@@bDľo 5k!-3\Mck!ՂmUrnUzjBiZT*oJS9\ˬKZ~[ʻL/Z5]Ĺ91j dՎۨ5YΜ9n|V_I@#WaK<V 5Ghi}hӛX^)T+STP ZĠ-!fgni5Ջ :A 5nQwů™ڻbZĉBLvDqQQb(T<ڦP3pzP;VS9Ѝv.5Z`n5`Jju`ZJ ډk/ ڱ do ZЕXWZP*vZA+,vGS]XCPj@C(.EQh>hutU69=c"j4h8j)Cy5x ul ٶ?\,i"俞z0;csLM#?>'5/ouu6㿟O/t=?}5%:|u9rotz=~l=[v|f?~|dϩᕿNW8/DGo|@o7ۻioכ\C{=z}7/o^O_8?K7.\tWcZ?xzH %K@b%o ˫Wt\͛NR}ԝ oねgW-^W[_/O#P{/f[p঻x˫g_b;3 ^Nֳp Eֻc~o<+ĝ=ʝ,tzts|{pT jf^֛wb~H`2{9jCF?tC?ԏ~'쇾\O_u5=u\L'yHptD^t舎'>jH;n3tFg_OV}.gbc,Wѭ;m\\=:[dzzuq1.eEwϔ|ζgwܷuVbWdˋ$ RF/pt=|}>܂+GenD?@~;[a^wZ< P-PMen m7븎Vz6)WpEfoӳ߭k0#ͻv>/[Tjjzv', #"`G#",H~xoK.͇4VًfC튌ܔuc't@U{@:r=e]% nVۙR7gQ՛iT9ܣrx>[NfܼFI;X=_YLOg7;Χ ]֚~&74?7>|վ7ҎVEImnW͇k?K_nWrXlUŵ1:nWw4l/׫-ܘ^maJnDoӷ3 z;*7ܙ~{w5j<^_?=w5|9n):.N]m1zƁ͗l~9uw77\>Dzhݩw4]ϧw{yꑶҎnzfC튌xxH[ iRP=UnAܝRpU뵝H:}xI[ iהXthNJ{>$U$m2vE?-a]Hچve=-k佬Hڅ.VVm56w}b6Ej/Ճo^(1898Q ~bzeħ{+{oָޱcn1}bK}.* $l^ *k65[j+ڶ|v#-sxήc&+LL7W7+a`K˳rEZi={3^>fKn~&]Is<5օS64>#6}~1[ jPcedvkGƇ&v搄YϮ#vXFrHr'!_S_\{gGT`D# h -=@-m{O9nԨw3a =N?crc$<>Ge<_;&{6☜xy|4sꘐ39hLȏ ]xF4oLZnLx ˗W[1iWƶ5ѱmBvtXVh$G{2޸vX~xחtSmxwg~j܈-wx׆)G|8㈻Gիl W_z|v`z|m1x_ 7ns~~uIo+noŬzqɜ}§؂wZDԧEx7S J?=,qY8oj&v;J;} ci :A1$T"P{Nci :A14ƠSǓJe cs}$+! I0_lqNx:юy7;r:~ֵ;wX 2jx<+㭅wMGxh۞>wكv<ӳoie9c#s$~m"A ?-FvGŮ˗Kl;1/HĻxğ#/H:iYi o̯[1whl5- ;co6omjBw64^_?^WB21$3d>LC2;d.$ňƐC2zi X1$ !8mʌFṌ/⯫rTv's>V뇫W ݌n*oQee~[ hG[vȯ3^l1k{\|6/+t@sO#]]SWtshtE+ Gga8;c}ѻ/:/I̓FҎ m\^[}x~vgA벹?6rcՎnN-n4Z+A >kAJc,|oJٱvM`lmMeVˋޣ:\mX A7qt=|}>E2bqTvDoF;껢?uwYb ~|n\؝Ixu}X}7̥?{LO8O]ػΧ槌^0|nR6;vwHwZxc]p/6$i6?lonOy6VLNl<fwv'nB~C!W?`9A+?́Կ-pU7O/gw_Nbw)jk''IxeXvpK;)~.I3X OMiq;_8tyrWAvӮNڥP2{`̹u`}?teڋoӼ8^71-⯫w 1g/>iˏߞZHp6u_士G5g6Z׸I\?Ǚi[L~N--?Dm'N=m&јM}F_lIKy?yJӼ`'~/EG71ߜn=1D׻O!K._<h^½xُjV*;ڊx YْPѿoV>an^=;Իt*OrY0U+}?s~MW'NWx}?Nףb1%YIܛ,_-fgbgKkr{OWkW0%e?;®_f_NiZcNcK}R_=m*=/9?q vV' &.Xb?kYNCٮ!]/Mctz}ڴG2Y~?ǫM.HIoK9%.')f{)Vf%'EQp_Iq4rzVN/槃04aųu߮h4 g+#g\Xf-&4`r&#m,!6RMj=T6O~:x{m+:CG'd|T4 wuZ]/7o $n75Si Nj峳wOl31u^Ϧg_{f鞙n/ri3 =ٔxQʹMQŔ2S!=fs-_Bw-=^/򣾣!H 0>fbnM7v.se9}w黥栽E L9sX)Af:/trlw)4;mj#yڔ+Oy.N[A^OïznW ojЭX6iSӂFZKmЕgp&nw4GdtJUdI>X l? Gz9[ݶͯ.~MFL?9Φqφc%NFZ@-fqݱEe*:#7&z]ѷ*.z_0D?ydPM džԥyet'S1(G> ?Df7cce1z$4]k,' =yǛm)|0|;?nTAB h5=d?h5Gmq/谌ܥ$ 7쒃_$^!{W\R}}&-Xt/Xv<+uF0:&c2sA8A81?ͣ|ˆc Ҩ0F1*Qac^_T.ŃC |x}?,vbO}ɿ!}z [-ѻL:!˳?Kbvy9!ϛw/yA'&uãϾ;v8Zo|#̜L|{>)MnHkXXr)f*in|.O݇"ݑxκMe?=tߐW 5-F-ۖZv&{$ btp/}L=77ZՀC41r =+ܽgwL(z?ߵ,rF"d`rp~nI( a93rWQ"Bs&OvvT%T/4ׁc<@@TB80ppςv 'NsAӉ1)RډHd{h rkDt>h qB6>]o{Bn\8NB<'G~gw90a;Ep.VR=NkÏ"%Ɵo;aw ]}^nquUN <RʚȰqxD0]4%"4\f@\Hqum .8^)A1 \?O1*MU>Y \|!y8H>Z?@ǯD}\1]~J}^ KJjW`hE/$pop@XE} ghk>k (da9+^$JR:]}JÑPEً74ۄӅDQ͔$D Jf##r5Uq+P иqzݔ] iΫ 7ᾖ>8a$7/L}N?#B%!n,VM; 7/r Vh~e=4)\k@7g@{,dIׅ#>F1s2^M5_~N$=^H"H܈4@G3!=F`˜0xYW| FEF!1p}2`P9G@ 3O1"eb>hǃ5NA4EUFaCq@8RUFbb@?g^&7=nY!7U)׋40 ׋^*#nz<0L= Biہ4(OV@>J=L5WeN)IdDA@q/3?2+jX (:tv;a: 9PsQ=Fz1~8 ;q$pèP,< )|\ OV2`EލJ3;rZ`Nj}5RrPX_+0i+\}@JɌXzQFpApEAk <+C U(`;l^ƃwPE|pW>G5 ).Og^b=70Rzν` L ڍ/`S]sᯂ < D߄ ̋H]X#%zev`mO Ľde`0|pwu6'ǓjJ]ǣz7^M' G,pN %Fq:;W9oN =I][u0sL p^0ӓQOl"㌦UN[_+x ޯ7]D߫iz~}79&ֳnPNY8n^2edu_]Lx=~9?g*frsYsfxϣ8yw |ZӃ % Ou.jt:S,kޘ==YzyaBq_{u<q%֥t&vz̥N\v[܍kx#0 `YFtY寢 -Wﳫ2^ng@iF.X]sef(ݚS~B};̮5=a$PhU !S2 _ p;{0߀W`7k`S5EŽ 8PNԠ@=tb=zFEWʀХޛ9R/PT"A (@9fe͑PqVɗ[nQIcP@LOFKw}uW`p46H *_%. $F!' w fXr/ ˊK9sr@r_ $P ٌ:PicqٌzXE><\L;PWk1ݾ6ܾs5'3.HZe{oH߻w`JD)ps% ~u ~A6~GټemS(ƂmYAh藧vhSр$H4Brv-sPpmpJ| eM+Y(d:rYZy鼄ב ն3235&vhO¢n^qO^?'jϞX2Ar|zVt_P;;SW&bfo ,!d6X-[Pkd.n-~7|?h*QGݷ9Ԛ|#ul`II ԶT ښq.Ð̟G|#(h@T9!CP;]'- H$mq0 'oxAF%))5uDj xԯ95PIxFZ(Djƥx,jg\D1/[W,4v2ݥfuM Фpudx\4]R;v^=`MsbRt.RQ A4Asb Sk"#/)@h 6Ϊx#RS39bo e\HˇJt{crL)5=-^gBQJb&>BlzvW?l wD&gyNF29t&هLb Ys16a$0i%YiW-ڻ Ǎ'9W~sU$;fiAUuչ,40B&+7Jy ɴhBf<ݻRdR t_c0 AY)5 뽉;6Uwy?&[ЭoFg b ,τtY\3 $90b S Z)pgy8_7٥-q"ј#a=-mrM08*V2vSiKob:~}E&W5{1nƝ:Lv5I",t"OG(a1$׿EoԐIM^t0C-AmM}84I]q%2 BaĮӏ\DCthNhp5P \)f8-H[Ac B*IaE2D |/S(KMj0E?u![TLJNd_BY /GjX2Lfu >>`'ОX)e,Bx,;dxw҂H#Fgܢ<&w\EA-*6ο4)Ofswlh?JG5OlT_Wd Bv`R/MǭqSXb?%_+B0ѕ,Mj=g gGθOrƬ+q|h{ɩ@Z FfMW#Q!u4$@(rN莆l >NmuK&s*o0<5G< g]cP_[sni@k U}eB`Vܖq#P]ȟorz6e4P)JhCj۫=9L6;2;r2y:Und.wi,K Ed ӒK+ \P#r>|A7+'NvX2RI]5UUbI k7.nq[*oVgMz13edAr=YI]tvlΕt_?J@00^a t k9| R~|Dlf[X0z=53Ak Fjpsѯq_͹ *<Ug1  ?-{[vO2z, ;M >X 8e "0?c1:_< ;~ `p,FM΄Z7'-Nކ~z |(1U8=Hg0‘9K+5 o"ffO?h6gV9O5G@ <@_σ %3X[T'v^4Z=s6"~~?&Q]"mWЌ ۜiݮc$z*0eQ, 7CnsC "s0Şـ9]*05d\c_ Н^FCtI|nzd * 3˻"SԷzלk_S2 hN߇Xz`Ri,'VX9b3("w`j#aSMj͟LUM}f,w)7!fv Їxֵ8ws|dL0ԖT8#LR W`Z f0cL 3[l#ϗa TkqN1sBycQc^bp|,hbXp<ۄum/+Xc-aS K~XZw`dò}UjVjX9j6bU'F%Vϕ\X4Wuv49u^컶a? >ؤ`I-v3Yl]Ŗ;[Y^T}%:ai(?*4 DiШܿWT!U=.T~n*UeRr>ֵyn5T]*ՠ T @= eب}վѼozԪQֹ q9juOVM8>[4}I ԱOuuT۫ګԍ߰UURãz:oԿ/@~$.>ר*љFoI5B 7hA)1M2E5Ygܪ)nִ͇mgoU5ÀK3+M/:F딋ZEWO^$o/ {eQ<]~S U, > c=Cw6`P:u^o86La=s2ӱў4Wb1b~l!u2VmO?;`\FMl&O?u0E)LM5nhߦz3/y72 jdN3sْZۥ>0P',2W/KD.-ImNǙlFUmVmlqk|h[ r֟^d]>_%G7Zb=Dc+ m[؆fֶovU[?>FvD'/o{%pv?o;`GPC<;uI<'!QxFkswu^ST.tcw&iMٽL|`ٷ,~JwL UBkE3,Ӳ[ HW4N%oS@SXd RSv^<v7ݡXw.ƹ3ϟ5M#1\.AKSngĒ)#Ԩ)LY _uQ5F~/VCҒC^cqSXlYww;YW?\@x.Da߉:?0ƲMdQ$s>9eZ)M2Iy+S*_R"i.L'|&ci%/dyjpm}gr2Dkǥl5Ǩ]1gnP0Tl4[fh~c\aUZDJ.6&.ϚOV>"'T̆`g*7G lXMX"f'W9|"=cxg7GXkr`o~w}/l9; ѕ$<jr0kj}xUkG:jMZ4gX3N<6ƏM8uqLL̙=gv2[bι+u`: .Mk^KW\֚\鯧:{~ dJŽ~kuBL7&ͷo~؎wgb xE?4O}h熪#M1[uQ;;`.nͽ@p{}ep r7 9]rA+Tl 9)AGtX! q9mR<I)٨cK|cuຉZGD *l4ڦV#X=jMu "4q%<+{.5c%Xi" ai_@7A {BEoKcZ +GuzԞzqբ{+G^JEwyDLGx$@bɸ/#^LOX ^a2Y0cѠ$r/OHaM-}'Z>M|m$fƞYKa-୽3b}C,⨱bmGG&IAܖڃ7ޯ4-W濝T$$nf]Ŋ1j8JxXӿVV_2^U _xKo/> >44֕q:D[>bڎy^ ZoEц`X>v~ qԐɊ?|45gvl';t{VYGD>bO!T95B.>?-H< ʱEOMd@aV x[Vѧ'a4DVWnZEuG~#Ӑ cOL=ED+f-z)NN4H%v~7ΓAMd氳>~EM_W&%bׇGXeO2yVUD7] X9*x jX`OmO\DQI`s47 j?EVPOE 4^m* ̒hs:x0 hawnS!,j{ɷՃhsAaT3]9 ]/##T铯j+£ʡW z"OcCߓƪ"XQ$ `(4`>k[ksKcqOsؠ`ͬXCXUp)\}pNt:5ʄQ(<.) 2 X]ݮ4V9yELX=jŰC x@lt\ IhJQ؂~p9`k p"~ތe`*JS0xA34/buй!0Ľ~\?xi:,\6D'1Hva }1Mik<+E$%DZ&YoFU݁R1ʆ4wk^,5̑3ˡ!f ꅺ;;-9ٗHJ%gx D~_': e.VyeZ,PDWd |y5zJm26&o9rX7}tWCN{u`#L^ps';#y_B-5ո^>"m=_O^m%A!xڽDk=DxDˈW' ͑t6 E&Ns~gtQ"_W\ͳb@޷ψ<_idv7Qj3Fȁ$PQoXMԃ(*(P0F~2􁒙x"hk8rEmV3p=5ZOgPTDuճZ)n͇tz^5_t $b܄DyI<ߴ .gN oh4SГу09bԌ)P\i-h\AsMhI?TP!&PnfKfRf. s *(uxURRƑu-"GUN{>Lڢ1b 1%mB{ US Z Ykݿ޿L}@주q+}3 ݂#MMͣNwƑw*(bK;$طdʡ&# K5兜4E wOʆL/} K\^n LDTWLCKZILݖ t%2&5HtV^ꠑٛ ^VH4%yhSCNl}h~Gf8a5'l 41b]!obo1:4i{MKhOqo{CyA;ZR|yO .O +&ֺv>^7 0϶Ic]'{n8x|yOߦiҝn{'^)vP:'qUwW+4mq' o:GĀTGeN zRI%s[fRؔfVo@ăRop$*a\QmnY讽?γƒHNOJ@=ggVzid{9N7iI݈CiHK(^=)$ݖ%؄'40'%uGAJ8אJx t6Tz&' YkyLN{̜n2%'AƩ} 7ӈ |V6 MX7’peoޤZ?#mzfo(I+W@yopc&x^Gz_xIdIȗ ܉Ci vq*]1#dqaCsf_PT q-xm]⧸bM5w%ǯ[ '~-C++z,Aދ*Sptdg ;⾢3O^ٙbX%yạ|WwqB]/b^֙&.vWu luyњĹLMog#ťyJJ/o /OwS>EbHb+OC}'r[S{E%"NF65$%`3o˂/{1jJNtXh8Yͫ e‘ qɣ 2k#q|{da7'ν׹Ѡ_= WhaI&M d{4*Z vY$=$ U ֔tTv]sk%Ȁȼ % ˍ< w6CYӡ+!UMyYSR+_cMyo2o~aS(g{)O)O&ʦ<|X;JM2W oӺ/{%l|3 Jx6ʊO&d C΀d!`!7'®k0*b.tQJOy{g_3olcs>~MZɄ=Nz*j7»|TP}ȉCQ(ㄲĜ<4w^9$ymwVvZ"WW#w. mw$գx?}+5X3Ϧ$V}nduQen++qW+@ג~.L$d!TFSuS?Rmw;GkzFmeYJapF7tozb s&q~P+ii֘Uw]FEllRn}s H6OR9P\;ޘ.V%=U3A9q{ݽh.Jz?nLiUt~nڲio֓\ ~e޿]AiWN+J%sރrw=Xi[K(ni4uO7e%}Eu{{Sٙ8}>1¼ o{e&4TBO>f0O`>̛Ȼl_)k4uQY6'FuO楍}b{xz]cR}bޔXcݎ4hi&K#>e^O7{1 pG_'v"QyLcXWxTcXʓ}b]}>1rf՟n=O,t t5 i~PШJLFJ`\'r?.2RݽǪN^ey e|:ֈ7viIRFMK5d$+P:'Am}e#CR17C&Mo!D sf7%D.Q?t=e?kVz뙞{5J+,xӗ)OB>u0 )~ ׅX>6jG׾h!~X\ SΣxq ;NOߝsi#xAO\1[sQ2Dq{3 Ǜd8C߹o>? 󶥩|o2o:0}svSԪRKKJKTRW74b>IAPz.`"cBa5l1Mn_faD7BKܩ0hEc&IߩpMzqFa6Yjԯ=^HY@hj!mZ(4bMI;|)n0}i.}_ss䃗 EO'3ݴPc_n< SӞﮫ{:ۘO3kbWm]lv w%;^Li *"g`|eV/&kjpd11&K%B͎>Pw)ώF =a穂8=zᖴV1^pZ [yIY4IۢcyZGn YMlLgڝIhU 3ܼYdO^ɼu}]wccݲbɁU !6MQBvSdLc񬧑O&ld2a/z1D5ht.Jp5]st桽/NW T 3~@¤ a;I7"T.'+jB?rY)`>q9WgQ J\M2H/? C͇Vu%ׅq3F]!UW.&RXo+-4t+#Ia޽BZ ' :7+*2Vq,zDUDZ/̟\uyKwT֧əGW#IẺ|!3JlDhCzMXՖXVAީ>npZnݼHqlͭ"&ݝz6ޜWTC%JQ)QT˶[]nC2Vp z7s7DK#;c" il[DQQKci*+ޮ ݍj 40E4A)ȱ^9TWXٌEiGrȤXU(uIKM^ ^EB!8N Jɘڐ\ίyӞr`NR7TU \unk֗// 4-8eOAOMWݛ"KOR9_J4$.f( ڣ-J7?$2y9#cK~DSK~ěbZ#ݏ$*Gڥ?^4%ZoEMK"h&~%Qi?X$Mf] /6O\V(~xeL=R=yYU#_V+U¼ɾRy ̅#@ 7=wum{ 'xCKvxV| Ř트@CydžQO^xxm-X7P>F o;| Wt<7.q![/]"UJ ̧CZ%n;&*±/;M/ͧ ˝XߞVm觋S/դ8~ʋ´{CN \73ҥ5 Al+98ۛ?c磶nN Mwv-Yq7Ы'Mh1b~998vpFp&zW3x\F"?-\:Fw~FisWۂ<<1y^+SQD jyΩO2^$ aQ8|K?]HMzX\0 YǦ1鴝\'z&.1_UVVoQ xLTdiqٷÎ`j*G`%b]2Ǡ vq5b)vzJV~6~ȞH;1R>6k6lQү$yuՑk#)B9!߁Pr9p흕1N,}xBEVu`Bû3Tax(Tn ہ_fm8ĬԯԂ>9590~wl #y`N:x!@307 =`2P}X1|q/1AgӱO2#u&Ws%_ZG𷢝r  t ~m: pPg? 4!K%9+E0tNڡ:9fD n$d֭gYr!+O4]ydgAz@2 oaoķm(2}#^rǝ%K 畗~<*2lۀ} ֶ#ij!TG/Ogڱ.`P}w4w!V#մ˶s>tHjP Y 錨e]dQ|tp5>[pcbz,O^0ⷃYZiRjw0c\3_Nd ̸\G0cgEYL]Cl5h]ӣvAQup]YpV7505 ϡ_G2%iuYP sQ)ͥ%`FSL8%ŖASk("(oa3oCUnDcJU[|{W=#V6O vKNj>ђ4 >y zقT2 PCyʇU)c@8Öl~a}ɑN]+W1bld [)h9;^|d2Q@`V̏2820^qQB8R/+B',a|sϟs`qpJъbL'Y0'78g Qcs~I7@ՠq3C㰸 *&tL4uvϯ젽_BD~B1aL1lok<.ABcQM ΉӅj8cy4Kh*yjiΦFٝYP-$454ޭwɂuMVY&aU  kLfZvc\p{q ;/gt_=7{ '<яF*z~w1lU'~wH,-a|.'nkLc9:2g/{BbWa!}aic~iQ{EDI?68O&~WC2:4$,d#DJeO$ϕ8#ɿ5n6 L#i 7GNa<2p}ב?$C׼8N_]NRI-/ ݸNFZcs#]S}/by$SL319#0Eld_^!V—a׆F8D+V*|pҟ9VxҨpA)LSδ6 )QH#@?2aJMiuMWD}_ơ=ce?70'n  SцVx&+n4{0wؘŕQK؃ZpdCpŻw,mKfCbt}bd)$"I Ӌ{C% pCwh`#u-G_f+$vY7qDZjXO4>SAaƄU 3DÈ4DT eu>9U啴3fx!a İį C !\Du>D^P?WX|ZcBIE 4fFuf#y aIȈ}ѤCѹ uSF43ٰTd?Fgxhfj#'iꆕ`J7ހ/Rqr ݓ]IN@gG˪,`UCy wG) dBa`9<>XKKBYHVNgrرro<}qKw[؛̋M SAnj{ .r{Ґ~>IzHCs WK z >Z&=n52U- hQ£٬ h~B.}Dz DY9 ڄx,#rOrd)k`p'A`bm2b$VD߄PXGdaS,V}.gjaKt*&pN$UO^A}Ad 'flazޘ_XX~4TX PO*HIKh)Uȑug4=#Wwװ ctM)~grpaY2*"ux\`1U5MDxid@cLJmˇw|Mm>DF{Iʌr̸% 5hHֶ|ERSD_K7nF')oU~dbYHL;#݈&g$4[%3%@[ iILۋvipV?],V|DйXf5Y`+΢p;t4 nQѠ8:XO ZiO%?(G#2^|AFJF#ʝ;嗩ۢ .K O|RVo@ KppXؐ^܈[GR}{NQ[@sYtv`:~RMd+M#= ݓHa?uP7βFY]_>JOZ Q[= rǭj8) 빨9hu+BagJCNly2/rTCh 7/)+W0 ߓl"/[=qC︻6W3+.W7v􃇳UܷYww?؇ؐq7x.{PqTw0lL(OU%lawwc̰aͥ=#8ᓇE NūJhc5kE9p\(+ZZC/t+A P.S6׽$rR{~RJ3"6:X6BG@x&4Z+pf .fQ%,÷^WBrˉ= 6?u=@?Hji~X'>0Ww0wYE]WROr'h:ڙs lծz}) |ߨr8J* MEcRg{u'nEcS[KE3f |z"y8/Ғ`hɎ-¸^&ʍf ;7lʈļlk@ݰZ*l|rh8jk@!]|k#bl xH^74N޶4,w.qH˺wUߘU{*:~O@#v匕SU? ILKG_D10N욬9q}bQ7M0&>$kgf=Y4i`;Q8M(~h+Hcn&UH|2 K@Kb44 c>?H=M aS9 7U%~d? stACG|qH6 FWj~) mdo> BL9 ߁A=t\Pn"y{CQ҈2 6ԏ?q>9 QhUw<뎿4GDuP=F-ٖ('`/s7Q|l24 V!O8RK&|ph|<O G1ztmu-B|` xnO=˙2w?E)N@8_%nįe5vlrx@ftXzTJ@+^^G1|}S3=) Nf 1xnG}wG;'I^>sz48N5:Q0$!l)Sz?/%ro4^UzGhS?inٔTBd Zsq >ʌ$:i<ꊦZ˩@= +UW;vYl6^UWVU1 yU]z=ލXo&ѡYSf,`O#?| ǂ\*-QG~YE+ >ֈw9 A7[a4L.Qz,t{$D~:`˄欛ɄЍjAL\$u Ɏ)9;b a{ 7l0lO|=Nk*ZCˡq(C+s| &#g+C*ڷG ?@gZBg$x~G+Mnw- ~k .1A;kSFa]Ya?P0WFAFF!aUFaŵqac7=!Eyw75^+@Y׌wr"@9# }h^0 Ujx­'gUadL{ieaB(X/J* zTDT}m oRO7GJRà/wi,ĻkFY%~pbЗ+Z-?e;ah жCeHD*Bi$E-V)MH#\!zc2n>D KB.aZ)tyr&#"˃BygihA-XT zI'IXo"S;31x <:r=I<#G"T b_EO#xDQq2G(xVtvϏ$;+k焔jLBZz\gS3!L1[}4hgf)l oUNXGDʣlHp֖C̆S_ꭤB Py[:\;7AnzrM{Ϝ͆Glgc0Vz#kIc!Xfdm6z`a4_ IAuu9Jkf#*eT;gdyZaϪr#햼{8[AX:2Mݿ(Xx}#`}"a rm@ f"$~ {lg}Oq[}XNJr@ 4ЦB{8*}1DS} FaǬ8=]<~AWуY6ilBDoBcYqti|t΢R~ FS6Y@{~Q7 !YSFbFP/ aMfsM @c91F|M prL7Uy~x[OƄ%ô䉘SVZւiJ^4^P—o78~"joDKp$F㧯 &ҕOv{OvvXnDb>Lzͽ(p -99_J*`l4ȉ m+>NekED + 2bwK{ ucD j!V~L|HAc*A0H~LŽJb7W+?f7EbӬJg-1CIG>ꢄ۠.n$A=|g +suՅ{НWnoUcr P>o-bԂfi?_+kU[=2L=iUM|fis$GbYݯ=?7fuWӰ]0> Mݕ: &F!b`>ӓx<m䦶_ヰoyAaraheTSOCB"z,i=;gU֓/B uC(.+J$v(O?70poPX&H$-5{@/ z$ZOF}ͦB)Sg2mZ%yøP)+\w&+s8`/B7p> ŴJPCu:˅r% e ܳWŪR}|u i|v+sCH3\*g|!S2]6v5bDQ(t/{妶Ŧn ̍ף"WyϬ+" !k$bS6"sD#:GS1- v҈w˱g""K^J1W1 "l K1EO"@¼@@ʻiGqJA f 6GJCF,&6$&iyET9}f/"@7vj.]`eaR_+hX h&}ShRä_ 2E! _]?Nԑ4(铼>W0T嫯b?x1@?)ǏU(vK LǏ!nkIsX8~h 'P Ǐm`nn 񣢆(l3Ʒ㇘(lSB):~O?͝-MoѢG1 S=>Տ b6eV >ɰ)3B܎S)1mISeVfaqRa1u&26Ӱ{)W=L^h&Q&rAHDMYΊD$K+$>_H|tA=WQDIN86#hۙ8ˀvHZ-V l1dkJٚlt(v=VƔ.aOk _Ip'-F\ī$`TDLP88;Ci&&`6ո{d `/"*G *+JK0ꆶ="x!h!<f5s48#Nv*auR%4ff>y1)*[ӁuwAyD'c"7CҧKC|1\F1/ڢyڿ$̙5CΛ`CSKOy;^$ 2B4+ arDܪL y}d ÖNR$+cI=ħ68B)VIfTV9Ӕd#ӑQMM4b]y*e>"ms8CA rӱL:hQJ *3JCB_p . x^rSP+8xpY|Y+2hB0) ikiM5!t( nd4aFp>ud ̠YMa)ќlޓ1'4`grCcpQ, :%@TY(6> /H캴d5X(eiVu dFa}97X5p3mQ/#Ghr'*p*3V:<>#m}6גȝ0^NRex8 " fQ15iI+GxH[1^b`uz0( 7 nApS\^g%6ZED>j}e]HDX'~V²`z&a9bݫl%,n1՚z巁ߪf:!ūZZogIo&FHUgތ-L1+H64>d](:tsc||qPxlC_ ӓXQ5V/. V.O}R/|+/);rH #7WU&Тܚ枎g8FM9"9 {O-YvzFsI-GW|-=!Gƫtb$U^Cs"jWo4!HE( xKa?O6 B4a꿵!a@|@1 du 7 En^(hJ!73 >A&B03_2-%jE`f. EOh$^%Fi8AD򤨄&95[b1lQ'59Y/BROKD5Sm3SNzH22LHW wߐP?!;BsICvlH'!Y!r[.)lTV'' E`poA 0phk%'qHp嫯':c5k}&Ki`z5#IS[';>OJäR۩ӻqQjp6EuEl cElu$[VW)-b+*[VƷ鞴[,7{Z%ʥ}ZVV321|3?{CJaR}9a'k|u84A hYlgj<1}*YH6u]9ʉFyħ8sgr}zuq@Ğ/7<ƏpHX&\qLDIKM["\xF$a6vO=J)gwQWW'*\K|JD$ZUnABAUć3CHy 2VBr~Cp%)"~\d/O8NÎ5$J v2a?#I]{Ebn'gUdOSF >[#5 3$ƕ S-F|Ѵ/ 'M4 aV! .N!Y`}@(}у);iᒓP UZ\{L/M4 7j)*mu.!phG3zD;9.V#V/y}MI+X['u=ay^F໸12aԐ^B =1&#ZO9YxG,WDu֤tBL qu48HXе┖pTGxnU[7q}_cȄ'`V0IzdׅuYL v!<֚&^4҇5nXT6&( .`9+Dд܀-Vlq tcc #A[ [i H6dBXyv 'eqwH^gb=u^٘bVLvVTt> !ÖEaURfB_h{7?aP"t.ҥ"]!C/bfE]_0{'FN{Ln^ Mٞ8Z^mNInz aAi8N2Hnj{mՕjطf&ߋ]x&! Vc)oDMĔǗeyESdƺWwV4;eMABd7Y(ZHHlgˈʼ췑&AN><ݦw5 g 5iOia2z#|TЋݲwWK+ϠN `'8YQQadbuhᦲ!qB%4 ߃S"1aj8]} O[WliaO{V!\څ Q?{ q|&eu퇛,;Vxn1#t $q46Z)BUښ! o?;w": 40#J[gwY)#vGGa8zw;ee0{MqdCћEH>g/B=hJs,Ծk-3cce}/ .c u+~%֐'ٹIṶQ{vQUnl?8l?*~DKUxfu?[{|l?ga7V9<~ GU"J\ut?[Og^ `e t?[ug)OYXU  PgAt?[+~r*~St?[O{*~MU(V(=l?[[kL%k%b&_̨ ο~Gft zb=cD#|֚@^E{SU{;"R"Hj£?8ye\z{@lQdW@ܫ+'͸WM*6d'4e<\Ui{DF=G9=]x胮ӯ={{H;qryR&&]OiHȼ; J x:v. G Nh4|khRL X5H/79xT*ރ+e@ ް٨0U> d{˓ƃ qV7GqOAHFH~g-?QjĖTqY@¬rĖ'E3rhfƝ~CFq R.A,͸LάM'PcYR dxM2+^u>z!TfNg4_1r&4*Z 8+ ٯ91E@;śS"`K=3틴ϧ=,} : 2có)}i7I2K{#BE-=!d>ާOoV֯ԩ{/x´Ye4 s+¾|+xOK?LI c/23;-&jOo6yf^.>>Q΁UX])\C}$kSf)Mq.e9d:t_KK4 r˭*Mn෿U zJ9أTrVEab<|3\%9 @M³)p8bi(,hu; $Fk4n>stream K9X6em;O]8+@ps l4hhr'3K-Qq7\&^=*^8?̫ M-纊%n}w\acK^BBBz)錤t\uLrڤ}Br\Pb{|Hb|wH[[,Mf/?~aZ4@ERt  8ڔ_/xQqg͏o_Hh=ZʇpMX @eW"n7A^Pv~M{p{$utf1٭^$ZrY3zV)N&PHPlC ^lU&ʖ~dsi|(Ŭ29$O_krl Im?P?J .&ӈh>\*??\Կ9]b=*k^ CTwı]bq  5ȶzu2g.LSfNm<fNm]pp7sjsC;'eLcYnCxⳐۻVg\/s=ei>eqI%,icq*clFh[8 ":}w yAUa CW# `|pv Cd}~kYP0jI+eq]C7@*NɎC\x&w*C^#vcJU1)^`_' ,=C= _w/g)z7gw&vx#a] hc9.;|_Y30^ک7P."&&|<#J K@pdTO;ws@1[(36^֞ؼ ӐT9˨>z~G Z˦Is38ahc@pfS>kr oD'Ĵ6L3M y46[Ln]чKF45CQ/y{&ED^N:8BuЄ)w{?G{.f+E >M} n @)* {F^b!PͿT]:-3b83߫TM>RrZisRC䏼ġ5TT-/ΰ#)*#W,}J - Nj,(M4g6Suj6y8kp oSp7+/?Ueۺ3yBl ,m{FxT`??8E\Bˡ9َOwcFv|s~x]nd<! 8*x@)RJQgo\xQe b1_䡆%Yo$e􇐃g|N(J|2n5m4z%vmukMzuFз7e\NBF#̡J*yψ"UOx(vD޾Ș38L^, 0\A*p!h"<#prD@"ɡQ99S䋈YΠ'xB^!k-xqgB1$%r|(t%E\ q%\^ƕB&/MOjt";Ɵ)B科k.zU]x~z:rC|A8x+666-x<Sφۡ٘(]26SLDž>/d{َ̡^ ⹠WA40M-tɨ 7LY W2o@PDXuY6Z%$_D/J3Ed6 E|&d#u"/T~P,"~.fPWRvdx{;iDWS)BDjFTNF /taNR4X5v?vc]ˈ5tEŮ']Pgg /tJQ P:GOɨjy%Uet }5cGwѰ#p.,; S/ua5tta/a/a/Kkv햁vS,,};ǎ#.U:>{w-a(7Pyöܸq֛țn޴|ƭ7qz ޿~7޼U7o ,y4?=s-3޹3~ޏ~o?{o4<[}_~`;G~W|~_{&/_|/>wzMBwbO4/o~n]ToA#79߫Fn}O>}|/z%7;ꛀFoo _Y<oܼ=?|aڿ_EO;YuOTy><Ѩ67{~U#*޸u[5{Rn;z>SƛwVw}FM6 o<|%i/a~=OeX ?Bs>\Gw~Gh-_3.&߉.=E禇MZo}u֭6{vLb0B5hV116r`tO!ML]$fD/`ˢV)QeN\XweBF8YK!<xNi-i5j;2eD!jK<L44ҵF~9bL>aoa:k%G~O=-<:5zqgO7BsWltdqcnlnL}{#.|ȹ _:bɾ\u#w؂BD6FmTЌEݡW ^֌yH׌yݑWMS9puwV+wFFRqu&FV1Tv#/隺rm6d {^H /eHcţQ 4 2>Tѭa$|C׌$chpQFRaWRn HvtYN ;t~3}ym_tQ]4ȯ.: ˎU6VrZkg{ { {a5;Lv@;j)PG羿ucG]KM yh;&a:'BEE0#m.%Hf+Eޥj) <v5X]jX ûT>q`yZjwzeݵr5;,1}_2Rhh˨e~X2yFHA)֑Ism2Bt5vEK9[}2fЅ۾邱hؑ_]8toer洦Kkv햁vS,,};ǎ#.U:>{w-(t)O.#et-\F|A1p$աUJb|V.񊁞)FuflbzWĢZ畖C;2ûR^VlZj :44ՖG˨32]C=^y% #Hp/匜ћ3BArS*%))|&/zGĕS,HRGC4,3q"T<ٳ$MhzRۄ%!Bߒ"eċ/E2<9l+``2E x9Yi`,'hx^G| zGxi9jz&]7%_틷^߇K}(*($߉i@Rĸ"50JbzbH*bڐz6FeE.ht$JHs q.!=%ǹycWVkz\ZzꉹZi=I i%IĴ$WMb"3%AP 1P/% и[E3@x/CM LχKCe| @2ybHmRr<f`R$Ӛj3YMk@pfԚd"91mjS1/,j2g [Я25x yP*% >'sb=@ g|=Au8#v3Z~=A@1sI߇F}(*($߉i@Rĸ"50JbzbH*bڐz60Fea0.s$JHs q.!=%ǹycWVkz\ZzꉹZi=I i%IĴ$WMb"3%AP 1P/% и[ C3bCLM 1LχFCLci|1,3KM\Py5a.cOoJ)H|gHLgj+8t hjSȂ4v1f5.~-W^~5Xh'LP3cћF 2d!ᅼ!$e ƨZ%(c$"j7=Um Qec$I3EJ I%) JMgr+$J E|sDiQ !' O{oGesIYkԿnKP;U~/l=+Ч~,1IHdHBO(HRCHybl U,0 o9s8shÈo"BO|  O>C#f >ftA.G;~|_nv>^/nףv?q_]﷮s 99# %qh! 2W,d"jB,ʒ+5; WDM5FV$!S;j*MZ>)Ȃ@\vfP5nҳ-W^~5qɵRLC ip9&@ĥ(QxRDJx^I)D"M?UML zQbC.E(-x^I)K#ÓrY?VB.}^rAX$:kYXybuhG$֭VVI7)-VGEm9^]T ,:0Xc0~'pX]8"3 ;% $FzZF =CL)qJ( )ii/ eڍ&!,a KXsiNv#iOiɒ+ǁLI+a5S4I( ϔKRdBdx&mNh<3iX! 9"4(OK? KetĮt$ǿWT7M ]_Z7V0 /JC{ 45/a߸I}j{tD};}~W@o' 'S#abl!$5<#cJ.SAc1'sN%u.crbn'r1.m.p0emKΛ^Zuް끵7eo<(\·|K!9)r]bBϲZHX^XyوJ:H=KQQ+ },XE SAaRg4rv}95qq ;@3YpЕ$<)p|"ݺּ)yܧXGCAyLrÕVׇ'r#_nlɊ+~[>]BrVBh%)!H 9p2A-!H 9@JRB\LPKRB&\׫*!. }%de;22d.!H p2 t KRB&\L%de\B&\L%de\B&\L%de\B&\L%dehRkL r\A\5EWPB&\K;tB&\PNK3u+| E>+l>%y /?YA" ѿT6N3;1VR?Ct]S) -;7 5&2Ia2 6;}{Z1..5oA{wl`\inː{ȱ|?\3~ϐɩ|ˆ&㦈$R}놿*@?Ě)| ۣNDX,EKy) cK Ra,cj2/ì=Ö $jၚ7ُmoZ?3slͪ9[Qs>BOff[ҬL[ yS[mctgd,!gY@'o@rB@R ` @ 7.|_(za}4ΖqCi^*B'7)k?F{QwJVSlh DMJ V,Y6'ѤA[vMMlx'ݺVY*h.v(zK22hjUŒLabf#f{ q jtggw`CP[`՟!"%ݛL/g]+nAk/NW44Ct' w͹EeէV]fŐ۱kr5;4饸͋9]yAe!W <H+z\cY^3(]9>|'U+"d-]<'f伔dȢ]!#%I؉3|IbPrz;jV'3PR7yU_iiPԆopws=[vɭ o1DW]}Fd;+tJ!+t*+5 ǒ/ WkQI xF[]w2Ȋ~-К'+dÄV:yt'Ǯ>)>|R:|tI 9pvJ>)>ːtI 9'%@>ː_%@ȁeȁ9r @ȁ!B9r @ȁ@V!:,@skrDt8,QLp8 Ch8מ x]>גZ2^'&xkÄf0_k&5k`Bog&fmˇ}pUcK_+6)`0  l: Lw6=q]_|ًE)S!P(Z%'g 2y991UgD)/;\^3v 7+.e$6ˣo$A* JVS6T?w ɋXEWcVφ7vlxc+ˮ)>|R:|xtI_v|"RB\Cÿ 9K2.>ː_%@2@ȁ!p 9r @ȁ!B9r @s ԌB-΁_zBt8*WMTKKsrń; ΁`W- aB3 55eÿvLb׈ Z0!3Z3eBn|zz䱛J'.;pұw.3RJs"dG㼓>dG6ĤW#Sّ5lG#{ oGֳ#Cj~d=;݆$ ##'pYIz0; D ,?x$"0=r`|}׌8юcl|tIkÇOJ>)>|RB\v| R:|t/;>")!.Ô!p_Çre\Bt/CrÇr @ȁ9r @ȁ!B9r9jF!B@/f!:_[&9`9bBsa„@Lp0ueῖL׉ ކ0Lh~ך ~ 2_;&1kĄ-ۙ {2o7>\ہW=ttMNe؁;(a0acSac\¤Ǎ Iäa[ II_Väa0q8Lz&=~#Lzy&=k͆IäDŽ acn8Lz&=nޅI_"[csV*$yx!eong~IZ_8G|E\3nr#&nO;/Q_~f1U o} J~i ( RJp5Dcwcq֙ih)̗;߸A0 ƛw~A#rt4w/~_:L:`%QßxoJ/l} B]Nh]\k^f%qӯ>_]S)R}L:M;*'۰_7vjɶ'gryȊ [G |P O=|AV|vj 8,|&L~zKZNqBQui-BOidjCu׹aXl%wz6#/]ͲWs޽jm[+𼣷V>ֶ^aj_H $J+ZC =ęSbGUQRORBJPrf\",a vHeiÃi}=;JZO3%NF$y_|eZxkM?h5wxkMBykTNncop1Xq_1xk~r9[oc._Ӈ8 ZԂ_=OӢ_=CuH$p9ne\|7BoČyQT=`$eDyQ8^4xA@zG{MmJ<[zҚ\dZ/M'L{L#^knl;[+.y~K^~S+6WpjwփU͓ Uᙤ@)ad0JJi=%v\%\-1mf l3mP5.մ&ϩV"M{vgq4$e$$8z<U͓#Is=chu3-l^1B* cuuo3pu8Fb0z؅ }*aeg W>7"++[ UVFUy*2s2"*w;GmumuoVw=_[N 9XL% 1'ʄlI(Y6?H3z*,ca,濊9ܡ9X44cXHj%mϙ[fnix=*= % v({`Gg{#uD/"~A 8%pJ9(sPA 8%pJ9(sPA 8%pJ9(sPA 8%pJ---2᱑ߨܞ<6!qX;e[SG7n:`c}Q/9~{k[ֽ<Xb*@ ~ r_L5sshW\`X񐅑) 'B$S5Qc~$+ۡy‚$TAeso)ݦAm*设 Ko 10Bw*xڪt[..RzgdC ϙ)櫖<{ `kH}MPEgkET}M7Q_cC} Lu /"H;@~Z yΝޭԞLYk3ٞhMg]ܫ6l:ڍߩyc-gwng6>ۛ 3mWwʦ;;;;;TonIj$DM0 Z[=G"O䓄B0m (rObʼ X*H(KiTFX^k4vp0h^CM圫:O֫ZFZ"x'GQ6H(ta9 ȐxuD/"~A 8%pJ9(sPA 8%pJ9(sPA 8%pJ9(sPA 8%pJ--ߚ bJNōT[?䝜sqI5utq}cw]߼xt5Z5wvn&ݹ{{[sD{ܦ %:ynFD W=@ @`?F+Wp%Ǖ rMR5\t e.g$[\`XI;Q0H;$Q Sj E"/#P)'A$,Am͂m8S; Rj祑3^6-7 擐io1"<Oa sRzq+y[ h{[)H"@%="* #zU 2Һ|42]%yƏ}Pɉ6WyN0W:qL'cǍ^MIZMUF-oʺF n&7C@^PyF5]*"O}^nt9/ٍ}nLtvkz}|Qn?jk/Yb:= I( $ 0`Ș$D>IH(ӖR %1RoPzayӨ$X^k4vpP-hhDM>D,O MyzQSx!TSJr+|F~"ܖ6&mVDaIs"N2#(PFsFJcRc{Gyjz)?kfV]Aϵ3M:g.\q ^5R]Y%SScltqgv(g(9#}Cc s%~d~ 塯%rRn3$ML 3S.ۗI[d0S y\H윫+: snظϹm4g<; I[ƜF8k1k*̦h_ 0_4Ciua W.EbR .GjŵJRZ U^ӍAML5/6ŧ.ׁ k?TvEvW7BwB';.nL'ZGY7[LZxƦ7oŝV{;nܾst=}7fnMą{{{{Fݻd9 J#$Dk]0 Zق”F4$!L[JE(qЏ˩B8oDDDhFs&Ջ0΍N0W;q%~,֮(;^oqMo_ͮ\)O &fIvF0s8a&LyZA'ن;j̉Xoi_lM(^.QmR<^ͮ~XȜZCH'Q6He?tapS#@EG  ?8%pJ9(sPA 8%pJ9(sPA 8%pJ9(sPA 8%pJ9(sjFT[a%iOu.n1v!䜃{Nzdn1xt5Z:vwnc6̹M' lxD@ 8Xfο:z~2h QL1k*0Ƞ$QVI&`է$aj\~ P?eW _Bڦ~$icveXtl[aGa ErFVrAEVR Ǘ^1}YYnCIGh晩K dweWv\Z$OkTV^SIQ^kQ0`<Ŧ:dQsMWʮFNdgō`3ӝDkH/H>ΏSvОK>_WńW_$8u_~K˿~wO~7ǵKo~֝eG=Jueo拝'ZgQ޾5u]]tz@;IS폿Rz.+J@޹ֲ_\{{@F~ҭ<vn]:6juN_ӝV;WO\vs4* |`aq*$qiP4AXSiK#8ʮHanPVEEO}Gxa%y_v&1eHicfI:͓0Xgd#aW bS#34Џ3 |zF NG NHUAJ#>Xeg<ϘЫPDVmX.!NεqwD04>1y!)" K{elY1ʖIq:Aӿӷ?|w_S_S8-)lNh sH$&zôxF{,bٞSa~s^$mqNk=F6f*棪䕞ʦ޳Nńӓ&9] *a⋄R*baTgA{4~"x|@(*cn5-8i42Zqp’;ƈGWb_nQa͊Ћ4O4!eԞvYRZTQH"xoLdc]&kės6aIJc:YT?gc2=uhvʹM0qDwqX:d$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ܑ=,L}hc&jע*&jOvgт`#lͨY;~ZxX_<:՚hO~g{1nH73 6ƽ -䷷H(v4@` [VNغTsjViNy': Um| ZC"H\9eZ]Gz"$PJ=POEmw- ?L)V"J5^bIڌq&\8|mIڸ6iH坛ܖPu/YT{,(lKѥǒ{6,jwFD*L_dnb<zxr|Ś7_Z߁(NWYÂzPta"qc7ׁ<7 W :8xpDCͺa-dq%dakqհg kX\i7,j. d#[(Xƻ2r71ᅌIBV%a@>.qV2N|P-V8أ!(JU &ByÖ?2ysZĢe1k=̕>\+&鑟83K黽+!Nu[\vӛ{+{kz"I Ry~1´-¸OζlF zqDcC1Q`ן2<7 cv iv i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i ia`°k&hc&j獋w;SZ*a٧!a GZ ql^?Ցn{xokc۞hX&Ýs3psnnnnnnnn{fsc6ݹ{@:oo5>֚:Dwr|Stm| ZCsO%VNҹڸoŃ'j [Ow&ۍ[G&[ k[oX;\?;nXLa!T NNNNNNNN''XF25A/nLJz. r q"O䓄B0m (uq"[%GCQ(M(1~e FcR&4N0W:q7~,֮(;^oqMo_ͮ\)O &f¯6i1´-¸OӶljF qDcC1R`ן3<7 cv iv i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i i ia`°&hc&j2 w;SZ.a駹!aGZ Krl^?Ցn{xokc۞hX*Ýs3psnnnnnnnn{fsc6ݹ{@:oo5>֚:Dwr|Stm| ZCuO%VNҺgPUAV(V()7WYx>ꭕ.\/ uZyPB^A ]@7Y "7=p3Y\Q|qsr'O8ַLWLֶ޼wZ +[o߹~vݰͅvBL r@^r$J2cuOy¨uDGJ'L= e+J\bCø +ܘ?T%> t״{iQ 2uDym)=2[yy߆E]ؒ'۪2jXzZi5l}vú7f 1:Lp'p'p'p'p'p'p'p<9Lj8V2J4񮱌܍e rxqcPsIKaUčAx"$$iK@U7(a DPZ64ΛD11̛?Lm&TV/l87;\ĵl3[[{ŕj7y~7r ,PW/RXd5 5Nh40Ä WZ1s^dqȨELj@QOfshϫt|80YuF,%ϩ$Y^6e$ ij~LTpc6_Hgc_2<ƙ .zj\=}WSj?]\WG/N!1}]._WiT9!F[XGb@G⩎F Fb/GMx!K.%u///{5Ohbb$qwaˢ} P? 4^?P.vLݦu`lյǺe?tl k|F/OWxW_޺qI]uaww?*^y_}z@k_|kxlren૗n<؝Jr~z-?O:إ[_u!߽CIsڗ5]Nk[jݿϿ_^;i=i_}{}ZΏ.:㲔k?Ak\|{7X {>IC2xL-T-F,^ȄQP z1s"3h@> XTvTrwjrid+{ Pn j"UxS]z~D&Y_OU e4e䞖9RCɖYr>K44fe,%1.$QB ru25,TJ˽Ļx8 O^P1KI0'q0RuzLqֿm> ;CGju0A$Jix=*/$""LXiُԜ2%&-z;m= X$Ox7oQg4DB ?8%pJ9(sPA 8%pJ9(sPA 8%pJ9(sPA 8%pJ9(sjFTްEWR3bt;}CɹtrН;RG7~J9~{1'u{TG YllkL T0Rhl4:@ [?b/οpiInyWVV%{IO=?0W endstream endobj 422 0 obj [/ICCBased 526 0 R] endobj 7 0 obj <> endobj 352 0 obj [/View/Design] endobj 353 0 obj <>>> endobj 367 0 obj [366 0 R] endobj 722 0 obj <> endobj xref 0 723 0000000004 65535 f 0000000016 00000 n 0000000162 00000 n 0000068065 00000 n 0000000009 00000 f 0000000000 00000 f 0000000000 00000 f 0000257600 00000 n 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000257672 00000 n 0000257704 00000 n 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000146312 00000 n 0000146542 00000 n 0000146120 00000 n 0000257790 00000 n 0000068118 00000 n 0000069173 00000 n 0000072783 00000 n 0000150823 00000 n 0000090434 00000 n 0000095338 00000 n 0000095213 00000 n 0000150327 00000 n 0000150451 00000 n 0000150575 00000 n 0000150699 00000 n 0000074449 00000 n 0000074749 00000 n 0000075049 00000 n 0000075863 00000 n 0000076161 00000 n 0000076461 00000 n 0000076761 00000 n 0000077061 00000 n 0000077362 00000 n 0000077663 00000 n 0000077964 00000 n 0000078265 00000 n 0000078565 00000 n 0000078875 00000 n 0000079185 00000 n 0000079486 00000 n 0000079787 00000 n 0000080097 00000 n 0000080407 00000 n 0000080708 00000 n 0000081009 00000 n 0000081319 00000 n 0000081629 00000 n 0000081929 00000 n 0000082229 00000 n 0000082529 00000 n 0000082828 00000 n 0000083127 00000 n 0000083428 00000 n 0000083729 00000 n 0000084030 00000 n 0000084331 00000 n 0000084632 00000 n 0000084933 00000 n 0000085233 00000 n 0000085533 00000 n 0000085834 00000 n 0000086135 00000 n 0000086435 00000 n 0000086735 00000 n 0000087035 00000 n 0000087335 00000 n 0000072847 00000 n 0000257563 00000 n 0000073884 00000 n 0000073934 00000 n 0000145502 00000 n 0000089017 00000 n 0000145566 00000 n 0000116778 00000 n 0000144884 00000 n 0000144948 00000 n 0000140837 00000 n 0000140901 00000 n 0000137099 00000 n 0000138950 00000 n 0000137163 00000 n 0000134537 00000 n 0000135734 00000 n 0000134601 00000 n 0000132185 00000 n 0000133278 00000 n 0000132249 00000 n 0000131567 00000 n 0000131631 00000 n 0000119183 00000 n 0000129811 00000 n 0000103401 00000 n 0000130587 00000 n 0000129875 00000 n 0000128134 00000 n 0000128861 00000 n 0000128198 00000 n 0000126304 00000 n 0000127116 00000 n 0000126368 00000 n 0000125685 00000 n 0000125749 00000 n 0000103133 00000 n 0000125067 00000 n 0000125131 00000 n 0000125003 00000 n 0000124939 00000 n 0000124320 00000 n 0000124384 00000 n 0000104904 00000 n 0000123701 00000 n 0000123765 00000 n 0000123637 00000 n 0000123573 00000 n 0000122954 00000 n 0000123018 00000 n 0000122335 00000 n 0000122399 00000 n 0000122271 00000 n 0000122207 00000 n 0000121589 00000 n 0000121653 00000 n 0000119119 00000 n 0000120269 00000 n 0000116714 00000 n 0000117827 00000 n 0000116097 00000 n 0000116161 00000 n 0000111229 00000 n 0000115480 00000 n 0000115544 00000 n 0000109498 00000 n 0000114861 00000 n 0000114925 00000 n 0000114242 00000 n 0000114306 00000 n 0000113623 00000 n 0000113687 00000 n 0000113004 00000 n 0000113068 00000 n 0000111165 00000 n 0000111983 00000 n 0000109434 00000 n 0000110188 00000 n 0000108816 00000 n 0000108880 00000 n 0000100188 00000 n 0000106600 00000 n 0000107616 00000 n 0000106664 00000 n 0000104840 00000 n 0000105618 00000 n 0000103069 00000 n 0000103894 00000 n 0000102451 00000 n 0000102515 00000 n 0000100124 00000 n 0000101213 00000 n 0000097804 00000 n 0000098889 00000 n 0000097868 00000 n 0000095452 00000 n 0000096550 00000 n 0000095516 00000 n 0000088149 00000 n 0000088213 00000 n 0000088511 00000 n 0000093177 00000 n 0000088575 00000 n 0000089063 00000 n 0000090471 00000 n 0000090527 00000 n 0000093293 00000 n 0000093359 00000 n 0000093390 00000 n 0000093654 00000 n 0000095100 00000 n 0000093729 00000 n 0000095850 00000 n 0000096666 00000 n 0000096732 00000 n 0000096763 00000 n 0000097029 00000 n 0000097104 00000 n 0000098208 00000 n 0000099005 00000 n 0000099071 00000 n 0000099102 00000 n 0000099368 00000 n 0000099443 00000 n 0000100529 00000 n 0000101329 00000 n 0000101395 00000 n 0000101426 00000 n 0000101692 00000 n 0000101767 00000 n 0000102631 00000 n 0000102697 00000 n 0000102728 00000 n 0000102994 00000 n 0000103447 00000 n 0000103838 00000 n 0000104010 00000 n 0000104076 00000 n 0000104107 00000 n 0000104374 00000 n 0000104449 00000 n 0000105191 00000 n 0000105734 00000 n 0000105800 00000 n 0000105831 00000 n 0000106098 00000 n 0000106173 00000 n 0000106970 00000 n 0000107732 00000 n 0000107798 00000 n 0000107829 00000 n 0000108095 00000 n 0000108170 00000 n 0000108996 00000 n 0000109062 00000 n 0000109093 00000 n 0000109359 00000 n 0000109766 00000 n 0000110304 00000 n 0000110370 00000 n 0000110401 00000 n 0000110668 00000 n 0000110743 00000 n 0000111517 00000 n 0000112099 00000 n 0000112165 00000 n 0000112196 00000 n 0000112463 00000 n 0000112538 00000 n 0000113184 00000 n 0000113250 00000 n 0000113281 00000 n 0000113548 00000 n 0000113803 00000 n 0000113869 00000 n 0000113900 00000 n 0000114167 00000 n 0000114422 00000 n 0000114488 00000 n 0000114519 00000 n 0000114786 00000 n 0000115041 00000 n 0000115107 00000 n 0000115138 00000 n 0000115405 00000 n 0000115660 00000 n 0000115726 00000 n 0000115757 00000 n 0000116022 00000 n 0000116277 00000 n 0000116343 00000 n 0000116374 00000 n 0000116639 00000 n 0000117089 00000 n 0000117943 00000 n 0000118009 00000 n 0000118040 00000 n 0000118306 00000 n 0000118381 00000 n 0000119503 00000 n 0000120385 00000 n 0000120451 00000 n 0000120482 00000 n 0000120748 00000 n 0000120823 00000 n 0000121769 00000 n 0000121835 00000 n 0000121866 00000 n 0000122132 00000 n 0000122515 00000 n 0000122581 00000 n 0000122612 00000 n 0000122879 00000 n 0000123134 00000 n 0000123200 00000 n 0000123231 00000 n 0000123498 00000 n 0000123881 00000 n 0000123947 00000 n 0000123978 00000 n 0000124245 00000 n 0000124500 00000 n 0000124566 00000 n 0000124597 00000 n 0000124864 00000 n 0000125247 00000 n 0000125313 00000 n 0000125344 00000 n 0000125610 00000 n 0000125865 00000 n 0000125931 00000 n 0000125962 00000 n 0000126229 00000 n 0000126653 00000 n 0000127232 00000 n 0000127298 00000 n 0000127329 00000 n 0000127596 00000 n 0000127671 00000 n 0000128466 00000 n 0000128977 00000 n 0000129043 00000 n 0000129074 00000 n 0000129341 00000 n 0000129416 00000 n 0000130162 00000 n 0000130703 00000 n 0000130769 00000 n 0000130800 00000 n 0000131067 00000 n 0000131142 00000 n 0000131747 00000 n 0000131813 00000 n 0000131844 00000 n 0000132110 00000 n 0000132573 00000 n 0000133394 00000 n 0000133460 00000 n 0000133491 00000 n 0000133757 00000 n 0000133832 00000 n 0000134923 00000 n 0000135850 00000 n 0000135916 00000 n 0000135947 00000 n 0000136213 00000 n 0000136288 00000 n 0000137615 00000 n 0000139066 00000 n 0000139132 00000 n 0000139163 00000 n 0000139427 00000 n 0000139502 00000 n 0000141199 00000 n 0000143021 00000 n 0000141263 00000 n 0000141710 00000 n 0000143137 00000 n 0000143203 00000 n 0000143234 00000 n 0000143498 00000 n 0000143573 00000 n 0000145064 00000 n 0000145130 00000 n 0000145161 00000 n 0000145427 00000 n 0000145682 00000 n 0000145748 00000 n 0000145779 00000 n 0000146045 00000 n 0000146194 00000 n 0000146226 00000 n 0000148488 00000 n 0000146836 00000 n 0000147136 00000 n 0000148794 00000 n 0000150899 00000 n 0000151100 00000 n 0000152099 00000 n 0000167094 00000 n 0000232684 00000 n 0000257817 00000 n trailer <<0BAAE37BEFEA884895BDAA8760939CD3>]>> startxref 258028 %%EOF buildbot-0.8.8/docs/manual/_images/master.svg000066400000000000000000011611441222546025000211660ustar00rootroot00000000000000 ]> image/svg+xml BuildMaster Architecture Georgi Valkov Adobe Illustrator CS4 2010-01-28T18:08:16+02:00 2010-01-28T18:08:19+02:00 2010-01-28T18:08:19+02:00 256 232 JPEG /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgA6AEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FUj81+brDy7bQ+p G93qN4xj03TIKGa4kUAtxrRVRAau7Gij3oCqxGdvOmsVk1TV30qB9103SKJxFagSXcitM7U6lPTH tgtNKLeW5AAYtc1mKRekg1G4c/SsrSIfpXG1pExeafOHl4+rqjf4i0YEme4jiSLUIF/nMcQWK4Re /BUb2bDaCGe6Xqdhqun2+o6fOtzZXSCSCZDVWVtwcVRWKuxV2KuxV2KuxV2KuxV2KuxV2KuxV2Ku xV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxVD319FZw+o+7GvBKgEkCvU9AO57Yq8t0S7/SAvPO+qbS3 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 uuid:9E3E5C9A8C81DB118734DB58FDDE4BA7 xmp.did:0C9190091A0CDF1198A8D064EBA738F3 xmp.iid:0C9190091A0CDF1198A8D064EBA738F3 proof:pdf converted from application/pdf to <unknown> saved xmp.iid:D07F11740720681191099C3B601C4548 2008-04-17T14:19:10+05:30 Adobe Illustrator CS4 / converted from application/pdf to <unknown> converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FC7F117407206811B628E3BF27C8C41B 2008-05-22T14:51:08-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FD7F117407206811B628E3BF27C8C41B 2008-05-22T15:15:38-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:0CC3BD25102DDD1181B594070CEB88D9 2008-05-28T17:07:17-07:00 Adobe Illustrator CS4 / saved xmp.iid:34001D5FB161DE119286837643AC861D 2009-06-25T23:53:30+03:00 Adobe Illustrator CS4 / saved xmp.iid:35001D5FB161DE119286837643AC861D 2009-06-25T23:56:39+03:00 Adobe Illustrator CS4 / saved xmp.iid:36001D5FB161DE119286837643AC861D 2009-06-25T23:56:54+03:00 Adobe Illustrator CS4 / saved xmp.iid:33F582E93563DE11BB48ECB7764A1480 2009-06-27T21:11:20+03:00 Adobe Illustrator CS4 / saved xmp.iid:520E91AC4863DE11954883E494157F9B 2009-06-27T21:32:35+03:00 Adobe Illustrator CS4 / saved xmp.iid:08FC8385150CDF1198A8D064EBA738F3 2010-01-28T16:24:35+02:00 Adobe Illustrator CS4 / saved xmp.iid:0A9190091A0CDF1198A8D064EBA738F3 2010-01-28T18:01:57+02:00 Adobe Illustrator CS4 / saved xmp.iid:0B9190091A0CDF1198A8D064EBA738F3 2010-01-28T18:02:34+02:00 Adobe Illustrator CS4 / saved xmp.iid:0C9190091A0CDF1198A8D064EBA738F3 2010-01-28T18:08:19+02:00 Adobe Illustrator CS4 / xmp.iid:0B9190091A0CDF1198A8D064EBA738F3 xmp.did:0B9190091A0CDF1198A8D064EBA738F3 uuid:9E3E5C9A8C81DB118734DB58FDDE4BA7 proof:pdf Basic RGB 1 True False 800.000000 600.000000 Pixels MyriadPro-Bold Myriad Pro Bold Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-Bold.otf MyriadPro-Cond Myriad Pro Condensed Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-Cond.otf Cyan Magenta Yellow Black Default Swatch Group 0 White RGB PROCESS 255 255 255 Black RGB PROCESS 0 0 0 RGB Red RGB PROCESS 255 0 0 RGB Yellow RGB PROCESS 255 255 0 RGB Green RGB PROCESS 0 255 0 RGB Cyan RGB PROCESS 0 255 255 RGB Blue RGB PROCESS 0 0 255 RGB Magenta RGB PROCESS 255 0 255 R=193 G=39 B=45 RGB PROCESS 193 39 45 R=237 G=28 B=36 RGB PROCESS 237 28 36 R=241 G=90 B=36 RGB PROCESS 241 90 36 R=247 G=147 B=30 RGB PROCESS 247 147 30 R=251 G=176 B=59 RGB PROCESS 251 176 59 R=252 G=238 B=33 RGB PROCESS 252 238 33 R=217 G=224 B=33 RGB PROCESS 217 224 33 R=140 G=198 B=63 RGB PROCESS 140 198 63 R=57 G=181 B=74 RGB PROCESS 57 181 74 R=0 G=146 B=69 RGB PROCESS 0 146 69 R=0 G=104 B=55 RGB PROCESS 0 104 55 R=34 G=181 B=115 RGB PROCESS 34 181 115 R=0 G=169 B=157 RGB PROCESS 0 169 157 R=41 G=171 B=226 RGB PROCESS 41 171 226 R=0 G=113 B=188 RGB PROCESS 0 113 188 R=46 G=49 B=146 RGB PROCESS 46 49 146 R=27 G=20 B=100 RGB PROCESS 27 20 100 R=102 G=45 B=145 RGB PROCESS 102 45 145 R=147 G=39 B=143 RGB PROCESS 147 39 143 R=158 G=0 B=93 RGB PROCESS 158 0 93 R=212 G=20 B=90 RGB PROCESS 212 20 90 R=237 G=30 B=121 RGB PROCESS 237 30 121 R=199 G=178 B=153 RGB PROCESS 199 178 153 R=153 G=134 B=117 RGB PROCESS 153 134 117 R=115 G=99 B=87 RGB PROCESS 115 99 87 R=83 G=71 B=65 RGB PROCESS 83 71 65 R=198 G=156 B=109 RGB PROCESS 198 156 109 R=166 G=124 B=82 RGB PROCESS 166 124 82 R=140 G=98 B=57 RGB PROCESS 140 98 57 R=117 G=76 B=36 RGB PROCESS 117 76 36 R=96 G=56 B=19 RGB PROCESS 96 56 19 R=66 G=33 B=11 RGB PROCESS 66 33 11 Grays 1 R=0 G=0 B=0 RGB PROCESS 0 0 0 R=26 G=26 B=26 RGB PROCESS 26 26 26 R=51 G=51 B=51 RGB PROCESS 51 51 51 R=77 G=77 B=77 RGB PROCESS 77 77 77 R=102 G=102 B=102 RGB PROCESS 102 102 102 R=128 G=128 B=128 RGB PROCESS 128 128 128 R=153 G=153 B=153 RGB PROCESS 153 153 153 R=179 G=179 B=179 RGB PROCESS 179 179 179 R=204 G=204 B=204 RGB PROCESS 204 204 204 R=230 G=230 B=230 RGB PROCESS 230 230 230 R=242 G=242 B=242 RGB PROCESS 242 242 242 Splash 1 R=214 G=149 B=68 RGB PROCESS 214 149 68 R=71 G=152 B=237 RGB PROCESS 71 152 237 R=42 G=81 B=224 RGB PROCESS 42 81 224 R=180 G=58 B=228 RGB PROCESS 180 58 228 Adobe PDF library 9.00 eJzsvW1zG0eSBvjdEfoPuA+OsONWZNd71/j2IkiK9HlPHjkse9Z7ExsKmIQkrEFAC5L2aH795ZNZ Wd1NUhagsUyCamPGJhLd1fWSle9P9ef/x3fPHx+crX6ePXZ7zeTRZ59/frSeTS9X679MmDz5ZrG4 urhcg/TF919OjN9rcNXBN+2LcuXfZuuL+Wr5F/5Nfj3B/V98/bcvJ198ie8/zC8XM6KcTy8uZ+u9 i19ffVkfRbc+mV7Sr2bftvu2Mc0k/oX+9d23fMl0+ev04mL+T1wQXetAPFxdLc/my1eHq3/8ZeKb iTF5EkI7CW3Az//P/PvZxS3X7LXe0kUh7dngsl79ZHV6dT5bXn63Xp3OLi6OVovV+uIvk6O30+Xk 2+kr+mU6+a/ZYrH6bXK4mJ7+0r/nZLW8pGu/fbueT8+ogcdHq+UZLvg/e7TD1eKsf9NfZ7Oz2dk2 tx58E16czBczmtbz6eXEyCwffGPsi8Or+eLsr1fnP89oxl1MTHcveBQ/XlD3aST4m+npxTfnRHo+ u7ykmaGn82J9//Vhf+xE5c8Xf/9+9mrOS09L9N9fasvr1Zvz6fqXC7mubWi5mqb8+MPs/M2CFrNM erMXJjZn+nfvb72UxiOXTR7HScqRmonlt25JZr/OZ7/9ZfLX1XJW5uFgfflcuMFTo/Lv8tP3V4vZ +sflHPMqTWWZiG9XZ7MF3dE1cbKYvrrQkZru3+WKH6brV7NL4qLV4uqSebutT6HJfjp9OwOPmPKQ Z29myx9Wf+OuPjZtmkTnqD1jIo0q+IlpufWYaelyfajRf5eG0Qra0Naxlp9/R+v1bD1/NV/+RbuX Xny9np91i5jspJV/8RD22t7/s/6/9JTGfXk5W2rXiYGOvu2xQ7P37XM89Xh5drQ6xxJc8H4jRlgS jyxWr8qv3Rf+jZq4evPos78/+szl/f+9Wl3OLqjFxWyS4/6r9fTX2cTQ3j6+Wq8mxjW9K36eXsz2 X9ISzZdCPftZaLTh5m8u5vT4/bPpq1ezdfkP/b5/Ol8Tf7xczP6x/2a2vny9urqYLs/2n59O16vl /ivaEdz2Yvbycv/ZMbFIkLZBoPb4b5rS15f1kXypfpGffr4i6uX+bHk2vXi9Pzvn/1zSZpvt05Y4 m2EPUGMX1x4qN69m+2crEnkkuCYm5P3/OpvP1jTii8n+xZvpKU1H9PunV+v1bHn6lr7E/Z/Xq19m y5+ntFFNbPf1+v3T1Zu3pc312cvZ+Xw5X9Ltye7T/M9Pp4vl6nL/9ds3r2fL/TXvV7rxbP98eopu 0bTSIu2/IRFOd15d7F/+trq4okmbr9b7l6/Xs1n9Nj29upztn18Rs7p9pp2d0vpza6ezs/liMaWW aB/WO6hDJM9Prxbco7bFj/97NV3TPfjz9XTxUp5RiBcTk+3+AfMDNXUgTzzoreaBzO9BHf0BBrB/ cLx/VLqwf8y37x/zzdTKce/243rfN3LVN/KIb3rXfFOvOb58vf9XfiA180xueCY3POvd8Ez69Kze d361uJy/Wbzdf3axAE/8qAP6UW7+sXfzj/Wu/5Iff3i9WhO3zEiOL4nVLvan8uCpjmfau3sqj57W RqY8HdPZ/qlOx0xun0nrs+5mampW75vLVXO5at57xLxeM6PpWMoDV3L5Svu06t2wKpfU+87mv85B kMm4kluv5ElXg/5c1Xveys+XPBlvlfzosx+ORRSG/3jxwwVpgJ7gdy9Y0BwvT1fQ6n+ZvBhqzhuK 9O/7167Yv3aDCOEf/r9H3DrJNG1bDJ8f3r6ZfVBvoLNvqPBBb0Dav3bDxr2RJ7+grnxH3HDJG/yv b/i39sV3iyv68ev16urNN8uXq0effSFG3A9r4itq69nP/zM7vSSrrBC6v55fzS9ne9P5my/f0xRp kPVsIr/S7fyV/vsS/93k/iezl2TFdA0I9Xj562yxetNruFJoo0z+c7p+s0nj3y2my+l6wj/Utp/O Sf98N6XJ6lrvaBs1O718TcYf6YKL2qp8vdaiEDdp8vnb859Xi/nFeW2wT6l/b9g7kq7LZ0vp5vrq 4vXkh9VqUVsuF5SfuplfX8o99+Yh9eLbHkA/3t/Gj6Zkr5Dse/N6fnpb+7f8Xh/0jns34qJTnvXb njj8qT7s5h334zmY6Jfz5RndwqKom7nV+Ru4cpPnr6dvQMaVJ70ru3YD5GZfMj5+/Psy008Ol/0r vl5D1y4vyVM9IROPuj35z9foDHVwSCBpbSeHZ2zykjdG/zjzvr/q//zk8xeH68G9k9/5Qz6W/h8n gbQEeT10P+z0Fx94++HFdk9u5NYPe269GU89fNKtlM72O1bgKZmVLMuFgjW4Tuqvwv9FQ5J/TGMb 1/gm0Cc2qWmbTJ+D5rA5ap40J6YxxljjjDfBRJNMa7I5MIfmiD5PzLE5sY011ln/6DMbbLTJZntg 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 H9x0V0O1G49xeXX+7PRy+us2Q+zfc2eGwV4TNh7kz9OL2cl69r9XtHG3MBOu3bahyHrXeMzvjed2 RdGXuLMf+qLnPV3//VW5kwV7vrpan86+Xk/fvJ6fbq5Dl5sv13y5I4JzvnzP/hyOyt7dsN43/b1B rd7M1tPL1XrzkXV33PHOOlqdv1ldzC8321gfqRdsHL2/A/tPZi8nX41+6P0d0OiHjn7on+CH+tEP /YT90JfrKVngi7+u5hejJzp6oreO8l54opuLqd31RDcPCY6O6N2LztERHR3R0RH9RBzRx/bBuaJb DWlHnFH/cJ3RbYa2g87o4ezX2eL56+nZ6rePlBrdRfdMbAx21B+chfHz4uo9unWHPbPN3ZWLy7Mn s1/nU3RrG5+sf9cdmxtfT68uLubT5aGs6O6Z8puv1tkWuv3sDpX7FiPaQrWf3aFu30pc7IoMXL18 eTG7PPyTJOFdyohnPNKdlA4LGEco9DxdLVbrv/z2WhyrTcX728UWwdJy+bjJ/sBBXbyZnT67es+W 2WFbY/P6lYur9cvp6ez56XQrnhzcdWcOULP5MGnBrxbT9fE/3qyWs+UWS3nzzjsb7tajPVotLy6n HzLa7s4ddZMeh6bZnD12JDxjmm0GtRsBmsd2q0H9c/NB/fPOLZzvVvPl5dMSeLmzqOvsednUT4vV spMm17Yqe1dMkQ+L5+xKcuWhG1pj8uj+S47N7aZf3OaLg2vvius2V5e/vCfqMxiR3wXb9pctRN8v dyj5tlij9wx+MCJz15rqoVY3bG9f7IoOXswvv5vO3+cF7rASnq7nl6/PZ5dbcOWojHfTrf+A6PgH CKC7XN5vZ+tXM8zrTtpaW8uaB706H7UXYyHXHxYq/PyhBQq3qQzajTChMWMd17/tZh3X0Wq1OFzP Zv/cPEU64ooeHq7I7G1+dMB6eja/2mJ+9Pq7dY0fZlHeFiM622JEd7h5Nx/RrnnCZ/PFdItKlB32 gr9drd+8Xi1Wr97upJ/0kIXhwxOBW8CedkQEPlwA7CgCd0QEPn6ABckPT/JtCbzZBdG3+ZDGwuo/ e3Ee/OET22yoXZERm5uyOyMjtrDOd8w++oQOCNm8Cn53DwjZnFHHA0LuXr29p76sv+u2BGTcLRLj E9dqO1MK9PPmDLgzqnrzIe2aqj7YPId79Hq6XM4Wz2eL2elW0Yybd97VaL/fPAn/waO9eecd67Mn 84s3i+np7Hy2vPx2+mYnldr5lJrbPAG6S95aM9HP5MafZvDnxsPnP7cwM/X6HZCvO6PatzzgdBe0 4OZD2tGg1hGQ9t+qqNk9KbkN0+3KPtrcwd6ZfbT5kHbNmtwCPv7nnT9033bpy+3q1F7OF4utqrgW d7f+4T1GRz/f9r7y+kHC7eoOnYbNmfrlenW+xVLx1Xe2VJsb06stTOnV3Y1ouvht+nZzBiRZeTld bydc5YY78xQ2H9zPeO/jFgFYufyuBrZcLTeXh9PT06vzq/cXYPTH17/nrga5mC9n080hRafTxem3 q7MtBtndcVdDXM840LD5Up6dzS/nv26zkPWOuxrj5oZLf6Dah7/yWDYe7fC2O/WOpsv5+fRuj3be /RMfH7djPdK9dzlPH1490hZD2tHQzViPdH8j3NtsqF2REQ+vHmmLIe1aWOoTqkfa4rzK3S1IGt+d u1MKbnOjd7cqkj5xvbYzFUmnD68iaYsh7ZqyHiuSbh/tWJF0r5Tap1SRZD65iqRtVMauqPaHV5G0 xZB2NKy16xVJ2zDdruyjh1eRtMWQds2avJcVSbuQH9vFyqpttub20uYuV3a3T+zcpohzXJcHcorO 7izLR+3FXfbgQR2hevDNiyd87s6LLcN5G1peu6jwtogC7tgBXw/7RQDjAVKjIPwDBGEcBWEZ3+Yz MQrCURCOgvChCMLjNVFGg3A0CHdcDs7Ax6MYHMXgvyIGR3NwNAdHMTiKwU9WDPbzXy+2LHd4wNJw 85n4FHOg497afm+lcW+V8W0+E+PeGvfWO/bWd/N/zBbfLaZvX2wJPb3PRUWhmYTNC0RlTb/fpka0 d8tdjXF8RdvWU7aena/ed+LDjp5+ZLY4oGV3jj8aj9XZ1WN1zMR8ZZuJCfTvZkL//4r+pv9+RT9M Hmzp/nx5Nns5X863SCutZ29m08sn22zJ3i13NdBP4tSkizc4N2njYY6nJt26kHd+atKG5t2ne2TS fTPTHvLbZn9evE/S7yCGY6tB7RqKY4yy7JLs2G5/7YjQ+NDE284ckrA6f7O6IKv52dV7BMMOy5H3 MdvDSC4e6VLupPTYPAT6y3uqnfqLg2vvLMCw+Yjek6objMjfYWRh8xFtIf9+uUPxt8UavWfwgxGZ HRPou6WNt3VPdlEX/yknd95NYHk9v3x9PrvcwpwalfJuJn0+jKFH0OmH9mJMzn+M5PyWZ77e52jR mJwfk/O3TNmYnOdrx+R8z0Yck/MfTwIjPW+br7aTxmNC/nZOHRPyf8Ygx9cYjQn5MSG/KxGv3Yjm jQn5XYvdjQn5XZIdDzIhfzZ/+fJqizcm7Yrc2HZcuyY6tk1oPN/unSCDm+7/GMtqH62W5Ggvt1jG Gzfe1VjfzhaL1W8bD3gxf/X6ki54fIqjrTcf7/X77sxv31zzXa1fkhuxJf8O79pQ8903hfOQ44gP 8h3nYxxxV+OIIn6/erWezZZfkaE3+2q+PJu/Wn3163y1mF1+tZ6dfbVaT5evNt+OY4DxdhYeA4xj gPEPUo8PP8C4jZ03Rhnvi+Fm2s3zUNN/zs+vLt/zUtXBIukNd8WTcfPBzRb0bbuYVe+WOw5YPZmz T/gUW/BOi5GeiJP6tMiCndwSDzJ4dvFmdkp6fv2QwR5k+X4yUQnzp4cl7v0wC4cf/+MNWc3bRNZu 3nlnw91cX2mntw8k3rxzDDqNQacx6DQGnX434oIQkwSdSgSKY09j0GkMOo1BpzHoNAadxqDTGHTq ePLBVamMobR3bK6P1ZHnxU3b6Vjah4SddiWk9rAh6Z9CwHA8Heb+S5AtzukYT4e5/yPakdNhtjjv ZkdOh3nY7xHaujZ8Z/TwYn753XT+vvj+Divh8VSY3VHG41Ft918Zj0e13X9lvLVQ3xU9/GH+/q7p 4vGItlEZ7/wRbdsz83g824f24i578NCOZ3v+enq2+u0hvThtPLfsA6MZ4+EYO2dgbH6k4niOxN1v s81fJHz2HqE1qEH8x93x3xYjervFiN7uiLjYFRm4evnyYnaJfbOenW0n5v8FiXiXsuIZj/hTdET+ hbV+0B7JfVunPy2DtDtLNDqNu+A0Pjbh8435dQs75g7NmK2GtIUhc4d2jPHN5kP6bX62TU1oufzO hua2GNrr2fvrR/tj0+vvanBdSOYBHZc/hmTGkMynEpKJY0hmh6zw9sGFZLYY0RiSGUMyY0jmXq/1 g/b379s6jSGZG0s0hmR2ISQDp/Fyuk2Z0egyPjyX8eV6eno5Xfx1Nd+idF9u33Sa6sPuZICnH7W4 6m6GtGWUZwegzVsMadd8++Xq+eX88vQ9odKBi4/Lf5gvtjmaZXDTXQ11i7Ozllfnz0jw/LrNGPv3 3NUQm73Nj0P7eXoxO1nP/vdqtjzdwm2+dtuOKpYHfDLYgzwYbBvGfrlenW+xWHz13Q== jWvz9yZdrrawalZ3N6YHf4jbeNDXbUMcD/p6xyDHg77+lJD65ubMp3vO18eKff1wtf75akEzv5tR 0m1c1h3xwh/22UPjuTx/2t5+UFHlLaqsdqPMb4sB7UiRX/Nwi/y2GdoOFvl1RsCL95ztMWZtfi92 tPNZm8u+NfjwcjYY3pgP2P18wOZlZbubDmjuaT5gdET/ddEyuqKjKzq6oqMrOrqi98BfG13Re+qK bm4Aja7ow3NFH3wB4eiMPgxndPNjZ3bXGd18jKMveveic/RFR1909EU/CV/0sXlw3uhWQ9oRfzQ8 XH90m6HtoD/6n6vV2av1dAv5PDqjD88Zfdhv2NnuTIMdQX496HNdNvfHxnNd7l56jEftjue63OFZ Hw9OtI/H1eyQ8Hu5IAta3uH+l58X09NfvpoIafVmejq/fPuXbaKLF5dvF1sE+Mvld8Wn2724fld2 33aj2tENdwIW3cn9tiXT7ZQm+8BTq3YlCHzBZ60ePUipcTc6e4zl35NY/n2Tkg/S3N/6Tck7Iju2 HdeuCYxt3zn5/HS6jRU8uOn+j7Gs9tFqeXE5fd97VAfhg+s33tVYf3u9zQETC+Q46ILHG1hu/eFe v++uRrt5md7F1frl9HS2JfsO79pQ6903fWPc5tM0/ef8/GqbFGC94a5YgFnx4R0eFzc/dmy2oG/b xdh7t9yxKfdkzgLzaUnO3lllBnWEJfjTItlGy/K+WJYf8u7vXbPCRq39UbX2fT84Uln8+B9vVsvZ NnbnzTvvv5Gtfd7eyr5552iS3VeTbLTIRovsX+vI87Lbd9ok+xDrZVcss4ddFv8p2J1juuD+S5DN 1c4v7zlAqb84uPauuG6LEb2nPGMwIr8TI9pC/P1yh9Jvc3P+l/dcOhiRuWtt9VDrqLfOv+2MHl7M L7+bzt/nJu6wEp6u55evz2fbvLVoVMb3XyyOyvj+r9GOKOMt1mhHlPHWQn1X9PCH+fujLh518S7r 4l2MwY8vo90Nk+vPUBW7szoftRc7yR8P/rCuDwuz74a59MP2h37vmknxCR1H9gm8K3M8G3u3tMP5 lJrb/JSRXdILZtKUz21/VcrGY+c/t9iOev0O6Y5dUYtf090X7y96232sKwMOv9U9unvi5SHbZp8E YHI8zWaXttvjh3eczePN7cldOc/mz4qp3cngno0HwOyOvNjejvoE2HB30k3IT2CIPzzk4+rHGsz7 L0c+iBF3RZT0B7c5kHbXtpnZ2wJ2Npr6d77lHq7qxn776eVsfTJf35v4zn1b/cvpz1us/C5Fbe1k 8yQJz8LftgzLDm66Yyl0crU8/X4nxc/DZcC9NGkmnxYLfj2y4P1iQfOpCcHDOy3uYecLxwP8sJ4u L15u8gqae7gfPshu2hWj8EPRSbsUypEx/llm7xjMGYM5t64RlzoeLBaj7/WHbtWx7PZDe3GXPdjy jXSfH3xjmhfHy7P6ZjqmBZBe/HW1/I5a4SOmHhf64ezVfNn/5dFnf30j7Xj58fnb859XC0kbzia0 7qvfvnx09eizZvKM/rXXRusaN2n2kmtj8vRHQ/840/uD/jRta12YmMlPU9ypZVA/vcW3/6C//odo v0385NvJ3/+7mZw9ot++p41g91of0+QxGjM5uMn5o8/oi83ZmTjJeyk57ydPaZr3WhvsxO+l6OgG ojzmm9PttMcghuh6t/ZJ3SMe955xszdEfEmT9YK2w+Vw+3/+4ovHMgNN01u+w8OD09Or8+9Xl10Y 8vMXWLgXjz778V1NNZP9w9VqMfmCVsV9R1txtl4eL2FCf301Pytb8vMXKjb6V/3jzWp9+YPy2+cv zM1rvqdVvrhcc4d6V5ZO5ckXX05++s8+axWG+EC+auW37xZX9O9n/L4xYq6Ds9XPs8nh+uri9eTb 6XL6araePFufEe+/78eJ/Ho0XSzmr8Q+K5f+QHO2PwmTN5d7k4Plq8XsbL9cTHP8bEnDf02O9mOw du/6o9fT9elqurh5bf+qH6ak7GdnE1rYlbTxy2z/y8leGSJNy3CAH2Gy3jFizEl/zF9yKWKgT2rw v8D/K58/t8e3TSW6p1P+Jcff9ic/LiHazspVdMP+RD8BwsQlG/gPa0LM/EfwmX4ty2lImJg929Vb /v4wP6TL/fX/ZXa947bpeoxOUM+4T5v26EMmPstv1OHneLXJBSnp/3e5+m3J3yBHvjhYcrrkr9RF 0eEkZX6d1d/32Wy8Wp51rzTFysyXE7nkpOgi9H+/XCMK7m/zizkJI270lkaeX05Pf9mqkcPpxfx0 0AJmeZsmLP+0eLYut/7bNQONpmF1+f3sdEVi5Ix/lQvLbFDT30O7Jd+EBqwWXW7bRH+E1reNxR8+ RJ863UYqo0mthyq09N3myU8H79J1di/cou1w5U9n6CiU+YTMgy96naqankYBVT/Z/266vrx1uo5W y7Or+eVmM/X7TWESt1q6359kNNdN8aZmQHNzYmpDPC1VQf54MTv+dbZ8dnb27un6/QGXbX92JqbO 69n0jPSr+b0xfzG8em86f0PbTDY13TPTDffF9Vb3f5hfbjJtpqGnfz+b0gAv6qGZtimq/HI6X7yY sl0GeipkPKNH3oxzDhez5dkfuN7c3vYL3lvoroXfXVG65/gfs9Mr9EV+1Hc03yIESQHGURDeB0HY jIIQU8ye1A2/6cYf8tnOhcKVf67sfLJevZk85/cVvU9w9i59t9QctLexyFSBKWdu9xzxnxfLMyXI wN9M5+WSTs6eTde/fPmulXI+hQZ2p01tZD5vXZtyf8Ui/km3/PEvr97F6UJ6S/3Q3uJ9ebqYPMjT i/Xpl4N5+HW2vhxSXq/W/xxVw03VkPqq4fn8/M2iUw26VbP3Fgvf5mCjnfAeNW1qWtCIN/Bf67I1 kX8JsY0fuvIMrt+k3+1O9jsZn0ZdfB908eiUjE7Jv+qUjD7JByseEoTtRxeEtzPW92h3crRaL2fr 9/LV4OJ3s9W1Nre229bTs/nVxe+wySjTN5DptzHyJyfR2fQxrvXZcKzYusTTSXMXYt9oVwuoxawi ldWW+R09rjv2uMjfsuxWpRxbx3801mJ35MZErysz+lb3XcXl0dbfVC/8C4boJyDPm0yyA8qwCb5h 9diQunR2IM9z27jMQiKQ7mSKN6YdBfoo0EeB/ocI9NCMAv0+GPpj8GYM3nysjHIzRm82kYTm/fF3 2vzesLZrjeWkkDcx4I+2IXcUvifveDP4o402hY8ehg92R/MHwY0q6F6ooFEBjbGmB+CajNn9B62m /agt7oW2GB2W0WEZHZa7lITh/QZ/m11ysFvgi0TYK2TPtB72imtCioO9D6LPJBk+usUfd9VVGUud 7oXyGcuOx7Lj0TEZHZN7pY4/fvXXqBtGx2R0TMYy2HsuCMcaofshCEcxOMbzR7N5NJtv0Ra0A8K9 URhxrEEaFcaoMEaFMSqMe6ww3N3ri8plo7J4cAiEjwKPHKPv910R+IcFUvDh/TLf7pl4Q+z3ib8v +bdQ+A8/5PR3ok+vFpf/vUFCeaud+v0W/LMlevedSWc+BPB4edY7AvD9Zwl+N13MLi9nIsS/+3nz bn/x9/7Bof/9JS/oT//EpA5/ePTZd6fbTuAX//kax1frrZtvROIQ0rQ3n5kzXJ3J4GH1nu+/Ppx8 zydllruiye0ASZZjML4d9rR383/NFgsci1vujzZFnweP6g7DHdz49Xo2W9b7gknZpf5zjQ0u5+Ht prv96O20u7ttXYzw2FLMJt460bJGejcO9K1325gze4AtTSgf6HtzdQZ3fzt9NVteTmsDJmaaNsxV anNCA9km6xmiF5PnPxKqLBxHD8jn4QHSTCVt9d9NdpOv/93lyeG/+9BbRLo78iIabz0/wllXvKns +Se0h+4b8qoaXGO8oca1ZXLDqGXbUssu3mSPBKQg81Nsky6dEQ7zxVFztc/Dlon29b/n5kbLtBpw qJsmRu5zG+LQA/z/uXvP7mR6JVH0fj5r+T8Yg8mhAzQ5N020TbSNEyYbE014Z/b5cH/7ldS56UTY 5+45M+v19mNElVSqrCoJ4KJQF0CIJQKAjMghQoZzxsFPABqTgiZwVIETBFE7Ks4J4yTbCymAhvU6 cAwFBCRKoVIXnAS7woMOwUnDVeSSoagIOhjCSfkcSTwSCcpBc4mBKAn/DzEpor0AmoCUJhGpSZE9 IpEoxUkQGQ0jQAQp4zMhIuY7HxSAcbSFgAFkgEMY2HKWTdEKIZtgZEQmRqEgy9thntCAtFgoKrBd EIPUiMIpUyLkcJDCQzI5ikbDGE+MKCHmRMBYnu0IluAc5BDawQgOAIeDoqRSZCSE7q0Nh8BaWf1A AnJEUaQUCqPrbGH8CftWeVhojkG4Y5S4Y2BdSOhIGLQido2EEbsRFEaFUI7OHxQyOGKVEw8Pg9QM ibIWBjIckoW6QBzCEdk+4fzGC4tmRYSDSwaFNeO4BHIkGEQ6NYpF0EyDoXAoxG0TbHDBUBRNhJGm oI6nSkG1AEuoBDUbDSOJB2Ig221xohRcOWsuICGhjhR6aQDYICsEcKYEIcpuJBwiEQ1DBBUMaShS fzAIJQpSgb2OWDpVnIRTjUTEbYoCCWX1SyjIyhMZjAZZsobYRmYcNiujBUXBRoVFRuBnSwHQQUSE ID9Z1rYQFBgXRmwdIhBQnG94FrqISBYoL0tIlKDOAr6whOFDBJoK0FU4J6xRlt8JIgK5IiiqbDA9 Vn/xQoRBuQebB+cnbnswGgqhmQASRFHvExRTzsAAyY3K9B+vYkPszgnyGebNAh4U5RPoPwgRrS5M YIhvcCyCIy0L+JiCtX7wQwAdE3gJD0UAMLj0qEQ74YA7WWIGowSrSiNhgs3PcWpFJCU7S0EtETwt o5iK4xGGlgTuDRZUSJZos1i1D3CHIb/JTBaJNonARUNNRNDl10BQCGRhgIMQlDkrYZ6RKL7/XVw8 oDdk+QgSJQkpgcFAVZ1BAJ1keR+sEoGlIhiyMhT7V6SZgpC9xP0JQbONk0Ek8mGJamadlxAgIfJM wI6wMgvUZhQZEcG48laFlBlXoECgcYU7H5HADYdY/4zCgjibxw4DjQrBAQ8C+WmkoEK5TSNCQmkq gBuBE0aCT4UklKXCrDKNkFG0HxSw4azuw6FCkxgQCs4SYSSkewZtCKBEiEKSJappEg/B9wIgd+ER 1mCQwLQiRotGoijbSHGkCLI6Cq4BXW/Og6ag/OPI/EUIETJFkKzIhIMYghwJYiH0EANYD46uR+fN H8l7BYSYahbMH7J+Eu1KAicS7R4VxNkLFgBpg3gYCUAIUYPjAmj0OEbDFbsHORg5GRLXCGwZib4O wLNkBjLCOoqAOqEQcm0EQ82JMZA+CZmjkBQskUUaB8NhVqOGgS2kWNEN40iYKaAUEEkJfsY4L3sY q8Y5wIjGJNLevMTlBjCV5qzNVuP+Fjjs/dEM+L0uLubg4wj+Z24KI6lcDkBTfkMEhw== cV/O/SDQTTAAxO0ngFZ+QzZTpj8a37bXt2wIZQxMPl5zlq2f/nZzi8Kr2+fZdAWjRhPQVb/GInG2 NrNtf7FzyWCw//8uAfh5u5EM5g0f+wItzDzVpyfGiKyVhopVYvuIECvQFEUhfQ0ivijOqlMQTbFG FTAjrvILr7Ah8xCQKyXOBBUNsqoKONLwf4BMI7EPgWgNMaD4H+8zQq8kBHVTSNT6VIgMIysUAqEK MsXA+oaRpgMTRtMj+Vkpf+HghpEwwmAiLFGlQB8jfySEQ50Af8HCYQSPAIYaWcCg8J/M1MOfUMuJ ugg4DUGk3qGaiYakv2DhEOc08JNT/sLDJpAGRZEa+ClKNwGUMVpvMBxh1SMwKyHWVAN/JcxZKEpm 7ljDFCKV5g5EpGwDAIgnkREFAQEVksU5YX5+yl942GHWlCKvJCyqIWCBKDRRHPp16OwvFCLlYX1E +I9nHSyInAio3MFP0Y2IBEMofMPAHiNuBF59mJKHaPz8lL8IfgSGAjLI74Q0jgRuGBvqEiHWwou/ CLA5yyH/XyE+RZ5PELIB+ClKZmuz6O9+RNEEpA+hsBL4zARyM0D4EyVYpwXmBJCu5loxQoKREk/U kJuFwgrk/1IRqVzApAE0ltGwRhgg+ACAlBiSmagssAyjMCCElkGKogHsNEGw+x7BiZAqaJx1LaGB 51w4LnjlfXZIoQgbYIi7GgQaBdEDGCyMUn0zyR/mPXiCdTBkdxdB9ovAXQ1FEGieHvWcmPITEnun vRtSm+32ssSoeG52nGFkB5/7wBQmfWAKgvp/buSvTCnfHOLG6ODjFkOvh4clMIx0f99Hjyrxf4D5 XPhPxQNL0nfcVD7WfgcNvdfEvYT2fwrMlbBoPVgmviOl+mTZfc9pX+16//S3u7jGu2Ti+2VgMPxg Z/SAmTCbnfyf/+NJtVqvtJ6Pk1FpsR7OxyNTZOKHev8jFjiYrUZgwriZRQKeaY33j2g1JhYqHX5N pricKLg+UcyRYgbfbzMlGWZ2+v93RjhdKcT+Ma8W4Nj/DIaH6xwedvv18j9A/f1bWTS268NzUGhD gRya59T/E3IDJvSfNp//u0R5N/mv/wD+/h+gyXeL2fD/HjXuC4ejQgWh9qab2/D/EJWN41F/JEhE DJf1L1PL+td/yrIoMkIarum/ZqP9j6l1cSP/I9YWpEJ+PBQmDJf3M55Nf0yZAmHof8QChZo0zZUN 1nvgY9TGk/3TdjZFj3cbr/H4S/8x7gTSk+yz5TlYp/mf4U8AM/cfMY/leN8fAUfrCpOJXjwZ64jL lpjiOclo9uvvD+PR7LC8bY5368WBKw4TAGXLOH4rpGOA33TY3DIL+BL1ary9rW/Hu/H+VhL0Hckm KdQ5bvojPg4W6lmX/d1cKWO7zXqvHNdfzHgJCPOyOtrM/LwC4osu11xpJlpqtnybPezXt83+bj/e zv73WAk1W47ebuDLwLe72fKwkL7xzfMIHNLf7gfr/nZ0C8CveficPucG8RTK91f/9HctGS529bnF eDyCov4s2Tu+WDS/XW+y23GfrW9XEXWneNSCc8+U3/Lvld9SmPBrRPzVxX7v6FFznjoCToiFQ0mF QmRIc+W3+O1ANnfhlXP8lueq2/lqPZyvD/vb6XZ92OiPnQGO6+/HAOoYpSV40oYEqsFJPIx3P8Ie oj2S8Cr3lRCBPAatiRO3WyEHYzh0ior82MEkJpvK02G/AUszmAwhgb7f9le7TR/I+/BfAPJsBFhN YA7DqUjIbbg10mkbDhbJgcu2Bgn09p/xbXv83/vbwmi27w9mi9n+XwqGVVMLtf5qeuhPx7f19Ua6 8xL+zyGDJ5WAY5dabuBa40WpvweYANrxtkzvpOyqNba2HvYXUCnJRktHtGEUCFHzO8FPs7hYD/qL 5nhzWOzEHZWvor3eKIRY8mETui2KjwUCY7cTQXcCGVnMVuPbPaA0r4vxoB8IrET9zrNl5rBY8JR+ Hm/hrMCnKrunBnyH+jp2BqM3/K6v/xlvNzBFv1MoVshEnNqH82oc+pApbmvjf8YLXi+LDCcDvkXC 4vtnPNwD1gPE7a+GR36V4jvDxWwDeBXmM/4b8OoUrFmMRQPSInUFBx0VsctxELfAJjxxtGnJaINp DWuL+yMaBW6IklxyPVeotwygcGOUYIyWxEthQHQPA7/rgR9IN9SnCjzKUQhVf8Hvm8hsRwOBMAP1 NTUCuJvPNgOwrXOX/vS2gFu2uzFcyNbkHHnFxVPGCWSjs5oNgVujNWv+K9KZxwX1BovwK+tBeTVZ 30rkH5e6BKu1KAi3sxVixPVuJhDW2GVC3KHnJh0xZR5yfJ7j+KaM46WChcay5ifwzEpUTi5RUtvJ DufLTm77qxFnTJUWy4wTCDlVb0kyU8lihiU1t9ktixlZlCPM+JHE6FDieLC6LEspxo4zTzJ2vAma ydfLfs3Egs+R7c1m62eT2DpiAweN2FNg3gvAhYhcOVCaPQhHNYfJgnAnPZ6A9Y1uB/+6pbfAT93q CyMEIIk9MDUkQ+VktEfJ5qJGJTQK+VvmlYV8fscgF1v/Enxdd3LbkX+9nfr1V8AN4s9FJC1tymHo WiIBmuHAf/Smj0ZtFsN/6fANO2i42unSFgzaA0dQ8NFV2XS6nPt3QreXzqAB9JSMBg1BgKIz78lq 7x8t5LujOmh3GAhrI9WQ7fwLiR+jOp+dfzCDIqs/ZjWe9iUBnMao4Xq1R+fMeoMWOLQ6/f2xslEM 3P30gTmVuniqw6BvtRrvjjSkdNh/b/xH7pnaIOid6cwejFhvhmujETs9CqARo4O+edKRbfB9A9EG DgKgymE1NKcs0PD+aiWkJtTdJjTMUFUPl1Kt4uz4W/7bl/EAWD3gvY1uP5ytl6f6h+v2H0J/UgDO ZruezASBI9SRAT9mPJNkfCRdlHrQgTc5RrZK7rgcYdjtF7zlQT6T4N0Z0BR+j/uCZLPMfGkzgvRb rMzPazMyD57dP/Er6qIHjM1sCTxkoDwme3Mj94JC0zHN7NCtxNjpGGh2MJtHNraz68lkN9adK2tp leNUXfTReDebrvoqeQ9VZ37AZZGN3X4pQ2shZ+OD3WC2X/b1dBEcyw7aKh0xLXsP1DNs04ZZyJ3B UCFKGKAuVo4KYS0Dut5Cl7JvNAcwcgI0rNAWjrITqsNQVKKSpThCDOa56G9MuCjcQD2nAtnm8Qom UEzKFGvNkVBJttbMl8D27eHFAvx0/ERIVRLgWCDpMBRQOJPq04eR7QyEgEbjtpLOaEN3BabWBv3t TlcXCN4PkDFjzSEbLWoPE4O3cmfZaLhMg1Ca45f97XynmLmJ0RK9ZzxYOnMTw2UzV42LZAs97Mb0 eogydXrSzTqXm+1kvdLVAsidW0LlsjNgEeCpjfcKLwx5dWq+z/ZIW6EgVW3o1JRig14bq9f2x4ko 9YFs6tDIg5IrAlXvk/W0uMzBTs8EIH+GTUUOl//SSydJRq73P2PZoY+Q58ny46WZnpMz2Ib5RdWd geDrNNPLQ61Ar/9rxVZnqeU/NL/7sF6thz/b9XIsAqjOViMp9xyT2/w5hR5u2K2TFzmbS/jqiSX/ zSfkPDBg2tnt/r/W23nOSELN5q7VvkhAlHXWCS6vhosDnG19vZgNz5tsW1BVQVM7I6GRZGsMbBs/ b3ab4MnGas+twcDV5ifAn9NkQZA86O9r/X+NBcNDEGr+gnRjRXYqQ/8xO1j/YwYp4mVNptBHq5CD I7yn7VJTYiP05U++3OPE5ykbBa+7Qv5WeQQ2bDaZCXqHOoFqEjYxIQw6JyFB3YVDFtWh9+khNQ9Y PAZic8DGHEtIyABNy5GISjKwSk1m5vD3NN6pia6L3nw1ZFM2b9UsygnHX+r2X0/9q2Tuywug9KCP CsBfObBXahsJKng0DADn+xv2gHgmKGq1FJsoCzCnNVM9UlYb397Olg/Q0zOxYa19fzXqb09Uwdzd R/961M8WGZ/danAwuw7J4TevLUOGOqMNWE5eSaKzQSiLAknVllQGmyMBPd6BreyfwA+8NstuDSRJ RQWfayUF5WuGHEDZlSXhnUbYqOSyF+kJg6EhVF2S3qwAq0l5WW//Fd6XsckQuaw2k57DGrBk0+BI RdD5QIm9jAfPs/F/mZBECUPJ1KXxfNq6GX+BOlAG+/tx++ewHKz6s4Vw4NP+AYrqtr8FkvkzvuVy f7dA/0Ndtbv9r5/x6nbX/weyWn91K9WcUL/f9nfwz+x1iQCRUKjkv+3sEEjwUw7sX+vD7Qao9Ftg l8asSkSoWXBTeKHlTIbIewuQCV9dgTXf7tcQxHB8O0Onzf3bRf9fsICqv9kA4rE2b3cY/sDplVc0 SruJYFhsK0BWENrerici+tnu9rCawwv0/KYVwXA72xgLGRoNSILUTlt6LKS3bUKZgxmGY1VtW1I9 ZeJbSC5zYgpGX8lCITvWsQYcKvUedK136zCAfsd6tW/CLdSnTxCRn8tLmKA9oMtmI4wEUS/4I+uN 5QXbzsuEyM7sCJhUW6NN3imLFirrwe0T+5H0ZD8cjkqiZvkwCfECuRkfJGdb+XI5EqLHkOXQp8H0 k/3dk3hJOuL9V2+FtD/5cpltcfkTm64sFcbidTrys75/Z6M6pQJljWU6xdRDMB2rfTgeMtvDMMwU iIfIPR4MWjFsR//SUy9my8S//O5MwrvZZXZVIgDQZOI1y5YfVdnnpqVGLZMIjlv5WTI1pP1+x/QI V23UBQjDNHMfC78V9/TvZy745vNml+vaLltu7X88Kcp6YOig7SX3u3C8ADT0BKsMVKHZwtFJ+Lnx /pFt5/3P2lil42KfmcSc+czEdv6lh/beHxhncTQBaBC9mG9gAenJ50s4t8gsXmOT3M8+/xN+w2UU +bbTQ7z2l0mkHS8sIDDpXf5r+rUGv9n/6PKobMn5Ir+2bMtnXbGTeO2PDgBN9NfpGRaGoYYz/xPs xRPZe9LuyT16vz2ZvKPD5McHd+q5Yv2JD4f9Ofxt5ilMaj8sahwL9MPbme07NvuqjHKL+7TDt/V8 HLK1lv0PLsCViVd+SICGij9/ZrKroWPpST7EA+HlR3IWDgd2EzK7HZZxzzyGCyCHdGX3DCgXdozD LyQ2is3ygT7YZPwh6fR5x7lFuL5k19Ct3Wfy5YT1peCNhuAVmnT5nbKmwvn1lyfxPHqPEQPrJ4Kb Wt2DJaUotxVuyzv1QjVWkFSp3NxF+TgGfR7VMPzT+kAH+gk7Y/G8bSEaCn7whaCgIQANNrgrB9E/ PCkmwf2WeClU2fF5b+GbhUZ0iTLg4FfMk0oVvASdniY5QC/JRHz0+/iFdlOYMYD3lAvxaMCoXEWY wqc4BdyZbMJR4yD6W8iSo3uI3ECLp4PUG/U7zLbpXw89CVT/Cv2+w5ajBp1GokT3Utn2z3CfrduG D9k2QUIWyIY/u1bwpdFb4fU7fRCoxDKvjFu/5iK0yMK/LfGb1l3Tk1ZhhEgK4PZdng== lDX+wu4ShAzQFHq45zkXfK0wme32pxOMPbyk0S5FQrMtBXbQ7fXk1tEvJTXlS5eSnicVu7kQFEAT 9yQPToZe4DmsEoqBH0VsxgJKUYN1Jt7e32Xblf3hmJqK3ZSQnt/9160F/q0Nddqff5ZVkurQjI4Z p33jyv9QzbfCAEu46fF268XG9WRcmAlLEYEctWK2V8ERzyV83wwU2aqfLv+G+5wWYHc10vpbVrNP X7kHJj+pRDC8Ohgx+dGyizSpyj4Ucwsq+SICp/a15Fuu1LYmFZMAaMA8xk90cX4/BrgaCahsSGwS e1kfz1c5bgh+8+6Lm7tBNOqJBx8VNImXd+tJfrabUUh1+t7rhMtaLonrio9j9iWQ6aYLMtmDJ1l5 q/BYpx9Ap5Hg0/uau7jvjZbZ1neFDsySToYFMHGUqGz7YT3NPLfLfaYQabwCNAkPVvgQKLIpeNeP BGOnYq9g4w/RwnD+Z+cxIEFZ9Fk1bX931ICuTDqyzcXGLR3X6JeAKnQEw80ftz9b7bZWAI1Eo/MD epl22P6Ur/21cZkJcdvpSdbxJTMJTYbpuSw/aCFAAa/GQLRCA8WnPAvAAXOZ5ZF83Q3ManFd8LaH SUDIxgsQ8VebJ5WkSPRptuXNrPKzWWMEF2eBQ9p0tVryMYlCyIU+RauZulfs+PZD5j63qGB+uF/v oqCGI+mZO5h5HgTC9dJDEx98dbKk3WIrEC7bVx4v5msZ+FsKcDqexMaDWBr3OvNp4W8pgEb+JXYo +hv8Zw5qyDz6Jvon1aoRDfhpEn1dwJCHf8uxoLIJHxMO+egm0XvbFOCQBNTQcDz8Cy3gysFRjyIg Fg1EKIeREaafEr4RR0PgdOpoTmgScDUAUJadDFwpu2Y4LUigNvxbHEET0SAYSirBSathRf/kiQZB tuQERV+Kw0/RQtIiNdnBcHYsIVfTeEMgQguRVECTkK5G3MGU7v6a3gzFTgA0is1gvwTpJQJHC0Gk klMkidYl/hOBElAXxUkANPINTakxnu62sJNGvwnrYrGK/+Q4TZU9TPAGHFwT1iXyi5JzudVo0ivJ rhVJEE+RpLhqOPhJja4KkQVoBDImFSsVyBKTw0jLp5MXv4s+VWDl90YUQAnfyLY2JTIvGgyJkVUR mbgwxbqgRlhW5VbDIFoiuGbJLZmELtMgqkMWgLQUgKfUtCFanCCjpzEj2hOOaAkBA5plk4rgrif0 G7sFwlrD1LN/AtT5L8kZwmh2lW1OplXgcFY3CvuRbT9XV0wGx+cATfG+2ANkCTkpicdyaNwnmN6+ 7c7EsbrFU3h+LfG2zWeT+BgST0EZRknHQYaGcQPr1ACr7JEEb3gUBDTVHOUAPqHrXTPcCsffakyW nNsqKEqQL8mDwgeAJrKuYOGCzzsJ0tX0X1CKJtcbZ5urVifb2peXBU8t4JR/umAy0fsNH3M0XWJ4 xmIov7f/sk/5XB15NpzXtZTRBoSp0DuTr1XigYTjw04zG/6oNOnK1v2tBiAbKn2Ws9WXBvShubl1 7ufF3dN4y/mENSoHTPmHXwgt/PqhhX5cgVwOPrSA0MLhzue4Sk8au1ZgVn8HLnXhL8864yRx964f LWmGSgCNNFoSGRUujg3EX7NPDP370svPfqlAfLK8nwLX140DsqS+w+HmaA1n5xPdJs7DbS0+RVCQ oTlo/3cFHpRbSByk6KnPlWYxNMjUN3Dgnhz5H083H5ilkjFuv+SkkoUqc8D9fkaMlqFOk+5DvYS2 AGAlrTDLUGN5n+NRyUy0Yg57imV3UQ7qpQUMPMTwQB4bSKYanY+rebCkkYf57qWidLEbnoCovUBg RDH4oITbsLg+FDEMx2kFHvF3g550PEWgtRmacI0KrCiicBaahCirxL4S9rmWfIWZr0+3lSmsDt/E fXQZ4Vkg4bUF3iPhYa2Sa9QzYDXd5SOXCDh077ONx9YzQ7tG/Aciz6/tWHh+GNSxWnWwESNpZXKC eJvDvcn/fFhinlSx1+P569MPttFfzc0fPMDxr09E4Y3BrMxXbk4kLeIHQsIAi42WCwzalEdI8Dcx LwKDQhXs10bNOVCPUiHe+Bue9HP/j8fQDmfi1e4dCDF/LHLgT9lWcfQL9ia5JTIdSxTtA9qEpPNA jCHjTdAHUKelf3YHtNPRBlCxAM1XH8Bo7wq9DT7IxDsOvySpRoQWd/HJof6bbXf6XYAhtsCYjwwJ plPyCbpameqA+bQFs55o8Q0uDG3zYiyFZlKMSVkSUl+SMYLK2ulBcNwyFGPJTKAYTyFDM676Is7Q oTfCk+p+uo9BmsgGSODWJGKcq/V+EEnh3qSoyoH2N0e+TOzxdy7YrDZ1x6zyXDYRsKBv87ee0YHX eIYnqWMc+Bl/fUOS7rjZFekkp05fkiFo+Ync8ikFzZrMHXkM5p4O5Bsgd3NX8LipicSEidwHGYqy Wf8imdjL3yw+KQ2mmdgrIwXF6m82xI8OYB4aWvGEt0uVEHCJPeeGYk4Qzu+HYA/f4vR481RDPgZl rQWt8G8EXW0/3In4w8+LWhtYvrSt0MNWn/SkTcMEccG7fjhkQ5nse2FYX7gUXhSXzZzez4F2jd6F d4HH52y7loHyGPUdTz/hzGx90UO23gx8xmZfkQnY9Rc33BuJ18Pap2QA8E3NDdyWjw7Ti/8ElFiJ 1N8S7PTUkvlLD8fiB3C/DtnIYDP146VnL/c3sM3QT2vb/nyMcx11KT7DB7UyIcIQTC6c9KMvt1hi E8A08zcqFXvqZuKbKH68OG4c2hswNBxprN2ao9CQ2Gg0+Dse8uajseibg8mPqXm2Tffx3CJijWCE 92+WiT01FmjTpMrmiJfQ6UZy+PoOhId+yDbbmb9jBrHu8rPkwy4TY+77VJp6LeYf889pwW2keHsT TsyG73R52Kzngi/E/mj3v6wWYEywBuOspGvR0gez5xxkbiP9gN2qVLxqy07oqfUzGI5t/HngJM3c IijWHYTQJuM15L33hMt234NMnigMAm8buVvOzi3StttaTM9qb4EVej4KX7/7IUpbHpO0uC8Mcm4n QBN78JDv2XbbbpGxTLoswK1xSS2ON2qsSub/+aHBJSUrk89+At8xU4bZQYb2tkNCmpGdb3SeLbSz 4WhzGg1+55ZgmTlMcwi0C30YS7l8K29bcNtAnNZ+Ts8zidQGHhPlFivMrgJjnJs79pVcsHvXKSU/ gg5Ta+X8PxkUp/8DoZmXH0F4UlhLBZDKrKF9rMAEKVhwiLSCCOItKsKI4RHLI+Pq1vfZahcfSbAm q48jerSMemUnHmBuTqD0n+ZTURuzKnn9/ROSiKxw9sGqX8/j+peeRCsLeuCe3Udr39sEk12s7lAO VcE0kAXggWDsMR/4oKvVRID14YhGy8cU8ImTiWWbd/yuvjiBIWgHqCSTTeWo+02ILvcX90eqaFF3 Aep8NqFi9dPV8h1MdCkkKeE91LtMBtv3wMavBozF7cVBnNuBX4oSQILIbyCP6YgELul2utgANNwO f/HHX5l7xREeR4c44ywvs9Gfj0crQ9cfWsz9Jgj0zMt4miFzb0FRKDnvCPwm+l3oIDAbblnzWfBf KZPw+V3qaOAoexEmmT/AqAJ5LGzWeTbozHUKzt3gE+oUXEkvbOd9Q5sAJNjX1UbTquUI16StCgN3 VEYwsK5m1z9PbmlopSeyiPuFoFBXAIS8/Ts6QYD7RdD9VvGjeF/8jACmmR8kg/c15hOwQMaG/BN0 1Il8aCrzQPuffhIS5k1R3jXjdnkp4B0F70GkkVmgM8v4ODp85s7xqEpHulZpriJNMHHvnRjnH3Oa 1JRvRhPGWS1GpTuNWLbxYYUfLLKtwWbCBpQEdTfNLe8ivxIHKlPa/AniKdobhOaFTPTD8cZTPds6 dIhjAxPa0uXlbAfsWG4JprigmO+H+6i2V/AUS8BEV/5hVLOq7znvaESi68CwMFiXfAbj2vaXz0L/ u3yXW3rsEpstcJrEJ0k/voLtnknqABTQJAJ4tFZoYCY9sJEl9x9wx8uPgpFGyvR363hl7IfNOBc8 5EAoBAJySeiOhiydEaBO/YVF7ve3nqHLo8aerjidDDxPLbMnT9lFf4k8psLeXrbwqCvweFXFr62p Obech7u1jiD3ebMNdw/YzOESlyrnymGPld+LCYVY8KsBbsuwHXv8GhyY7y9iCTmNFM7m36QMlRzn JNYASjxLiXts/g5sxauTntg3Vmpf3SeZ3sIfkKOZfXmnYEd27nDsy9OCJ0oe5d6QrhUJnIRgPxt0 ONZR/KUayEaKuz2sQwCOXrn/mtm5Xu/oauXzjx4nvu7ETWMjgu8gMNuTDdAkHwEqNniym/06/G4U RF+lEtPbf/qYjGPclp/oUUheOGXDxw11YK0zvp8IQOgtFt8i7Y/CoJ19oach+0yhyQQlxusvwXCz KovblgLaDlj9AI8y8z/viT8mW89sYg+7w1qeTaSoPzzapIf5/px2zwLDcIIsjAtvk3JBSGmiIS0g +y0G2qp+NuLz38GsSRFpMJZo9bdw+7sUYOhDFh1vNoAb4vDRzCr7ACxq25WbxxM+zm1RgJwCZdc6 tEdiMQirGTmgjbtcyMXAOpvCKEZPJaqTG9C2cpTmyNfJNte1byr155pLA2tI5pwjUSt4P3MW8F1i KaZ0Eb2ANf6YQaKl/twVevA6bgKxS5WK3d7mGylMQYw0qC9sEJdZTAJ17mOs1LZRSgOHKA0UdvUv h+YkpFSJhnMPuSpBdeqjABDZjpe5x50gum40vXTFMsGka2AHb3Dq+W+SRRaCua+FYwrgwmDIaYz9 bbqiJ+tPD0o0qSa+Oc2fi8Cscrt48G++stU3V0RkAVhS4CgMD3dfkacS2Y6S3kqc6XmfY8rVCIDa hbXClMsBfeenP4wDnqFHZJMWAGyoYnb9twvAkpEn+nPWtcqOvkVVEKv0nx9EEsiC7QbwjR8YR/Ge KS2FuAlEUC82P13xWENS1Iu9rTAceEeoLOGFSHN/Xt7Z4e73oCOXkOXIP8OZ+JfvkG3nwz0QmY5A IB7w9bPr7JgRZ8dCIZ3ZeCb+2cmFV+U2Tn9OPwmYF2gtMgEeGlAd9REQxFkCrFnmpRYYe7G2EUud /BZ79JmiKrMp1fZ134iCxZcL/KxGfVt0+rIFky3BW8FyeH5meXdAufG4h380ZvOtkMkPWfJuHIQM yzsQDHQGRZhuvcfJ/zd1IzSzycvtaHTpJF8fyF+G0+r/M344LPazzWKcVdyYdM59M7PVfLHb+4fr g1CsyXfWlVfzW/iuh+LypDJ/SZiio27TXwkXawi397Sei/XpRFHmCP5It2mVoa/9o1sEwV/h5TpP q3pf2usr+XC5kVyEFRRRoGdQJCXyhPhRYTVcSy4hJMVP6tvxcLY77puDX1oOxiO2PeX4Q1j8Ca+i U7vuCHwM76asjrfK7nnui5JJSpeGqjnR1Xab/tE9HdxX2dLTvbiWuMq+Sx6gVjy/Ag== 95l9lwUSmf/w5n+h0lbpn9QrPT2p0HfYk34eBLCA58EDzOyehL8RwUQjRgofNITf0AdxMt3e50CY WJyXrM1kH4h4NyV8SniSTerH4iJLSYsv4IB+KLAb84TF9fgWtXh/Zj8Wp6/vsnh7o7KfrEXBh8nN fXBH2hoIPxC27xRW+q6k4ewiqD6JHm9zh4LnofYCQkJLi/+UnvsDu1Ap+t0uwsPwwktu3MwEdtNE 7LP+EKXf6PQPRTo3zvxn+bAB4ytL8M16HqEB4ux6YueLFoe5bBFYfrTNT4ofluzTV7sDvM/gL7/0 2m67JXZLuBrfqjGweAkma/G8OT1wka+W+zFetXiG/rzFV4m+wgVXLlkcuzfc+kKLQeGPfmNCpWwp FLun056f99z3I9EKVzNpL/jn0KeyuA23m8lukF1mbr17lCwYLgmg2W6D9AwLvM7DylFwG7cf/fcg WEjk4EkVLfbgjtg98BOLFIlD93sA/llcYCN7l5aSKr573X666AcsEGw52YhHWH1vnAdf9rwF74iY G3wTX0k2A2H97FC0JtZQg5xv1bCi1cR339vPv30HIT7Gmh9Qzb6PUMW6i7zgWlhLweh9do6wwjMQ 5XITd/adw7beqGHdHr79NufA1vxQw+pJEDXVtQI0ADFltT6/PYbUiRzsvmFMjnpSXesd8xuxrch4 XQ0rxkyGJYQVcZpyuZTVTgS7BS2sI6xY3HbVKcz8dYh8ewBJFFyLWCEadmsdyxi33LrDodhacvsc +EFYcXd2UJAz1Pv2k36uQ6yu430Nfgb3/o6P3RspYg7rplTRxEo9YnGrFtbh9iuCvyqwQjQs4seC 2/pHHR7UsO6smxyjhbWUJlb4mzrWoKvriS86qFxJZbmAoZIud+bF3VfDijHRQkoDK2W1tZ63cQVW hIbb2k+M+Sw1VYl8x+zi9/PAc0sVa9F7eNTE6hhPM0+sTlNZbsIS2O42dRvE6j7a2gbjToczFkjh 8EaJtRbOdTmsXZ8TYZWoTspKtWrzZxZr4WPOyIj8lsFq74WQKta70t8uPLc2KFWsD+4tjHgUiEWs mXkvn9XA+u7CWrHxTh1r1fdRHY29exErrCAQEbd66bgm1sePUiWnhTWPPTteI+pYa1aoBVrTwSii utznGr7WxNopBooLLaxV7HlDZkSsN6i5REScdj1/vXxnVbG+pAduTay9Xnb/ooH1IwgZ+vOp7VJf 7uPT9PeVTrhVsX7u/Q1NrMu6x/4qYkVmTYqYwXqNv4Q61qeMa/ue3eVVsRZ+fZQG1kgJuhwvj1iN Rdy/2xflwpPeHl7egxCr50h4nqxx19dh2QNYk1sl1m9308ZhnUfh2aEMcejHjj1wlodwpJwlOVb/ djetWCFW37GiqPktH1aKAVgzOwVWaKR3H+soizjtLHgVRLZkmScHi/VzH6vI9WLHEy8lKxBr4Fgp dnBfuGb/BVgZyNBKG7CdpV0c1ljDL19rqZO7tycQVjLdqdVka7W+70KDd2R5MAVWgAbYgKj97nXf KAPExJGu3mYHq47nnkyof5qLOLqd2kdF9dND35LAyh+ePWvW1AY4Unh/V7eqfQr2gfFa7nM2Gn6q ouIGKyrM2HD4qUdNPEs/G2rloEhxgOzT1Tb89FkKaXy630fw1FtY7VNItLuy3ZJ5eik01L9e3ser 5Yp1p/5pFfuseTyhg8anVO8xkzo4OKKpDKhO6rHws0/108jjG+5ypt8/4afeY12J2YTd9KnqNMre 6b8XUuIA2acJ53M+8ZLR+DTnfrFtZzm1TxHRaiVvL0+1P9S//pBlfpMu0qX+6WPz53fXK3g0Pn39 XZK7AMERTWXA8m/tm48j6p92P+u8X63y6af7XWByVaL1/p555ajy9f4rkbzzhhn1T8evj8z+7mms SbQJ9vnj+C3fqX79/qUxenZaHtNqn263yV6DzDScTvip//hTIld+auSWHNGOVNI221vuLJ8OWvw0 tXElNpIYDWqcVLRk+0WqiI3g8j53E6inWlgRgLN+mizKdlfjuMVLN18s3uevFoyp2xbnu+sAf6vD KBTEo9VeSBLfpZLrOZhOK4MQiqgDD5GVA0SLrwcURiUsTqArJwlhCtbALDlwAd/trrCL1ANyRbi9 IxzJuo/1emEcJNG9KSvpQOEpsikwDprL/DQRMVTwH9pY75gvnyZWFAepYeXDKBgK9USlL8Pa/dLB WrSEtLHCOEjqp5Gy5UZKMDb44LEWF1KsCcunFGuwZZNSuJFpSrCO7HYrwsqHUSg8EJZLKogMY4ON OtZgt6uN9Y6ZYnLxFBBzRAaxgQZWygpjg4EW1r4aVoCGJzIWU1suF5cDR0MTK3Q0OloU9iKsop+m XG7Zrtha3AucCIQf/cZtxuNhZGIc8gWerGMzIO+ekhYT47aH3vxeojc4hpZlkaRCDL7udUpSOOC3 PHTuyog2PHEF2S8+NQGZH7zcjzRWFmN6zoFqBHdBb1MqWHX7Ehs53vPcJPrNHJhqbJ3aOEdteZ4Q 4c8Ffsb0PfxhFTC4BQXYkHmdYEbvNO7O/TJwFClLVgEdmEoV7iU/gJoUvekGl2MTB9ftf/yQVoZX NuKksUro/h79gBwhiyXk2UGwBtqTGpAlCQ0lpC926uCfdpgPOKD6O8Np7cRpqc0pMLPGvOgHS1I2 OSNLIKL0A0v42sGY8OhHUxp7q6wwjVdrihXyRJMuEv3ov9KKbVTsIZl+bj8Y7aH3gWUahAaFPYos KbvCuFufXmb3kE10tVan0UubI4rPqjwP0RizvYJe2MTx92qCv0TmEsXziL/WeN/mKJmlvi7pAxKG voz6gF6DnSbpARojeim0UNe7Umqhwkd+I8UgrF/YDg6N8Y58FfDC564owCDVaVis2jlnTV08Cx9P e90ZIVXvhT/epbnWI/IVYKBcVShxI/F0aS6OtMWrNbOLQxpahT0KHx9WfXLDH3ZWlrRmgo37vQ6f VT+2T+K6XHa0LnWe73o3cquhvSSOoZVbxi+J9svYXZBHGbtj47rfLnfkpVuVKn78ATSX02YSc75p Ge4G4hzoQMH/FRjaowMt73/Xh6YJCrGAQgAHxFopgP272p/xqrWlT8LQfUYYpbGh3oKX/cHtF3vE ccwbA/JOsaWiAyXfVfiDU4kog63CIP27B1yTQQI/a09SnJhEdcb+NOZG/BG5t2hFfZnenFPFO/Mo lKI80ZWaRzaKbQFf72x1jZ4ZLcSZtSKkDaNhqXS8yWOOHBXlKpaQhrinbvI8cjjJqRIPvY43GUx/ YNGnF1xhwHBO0Ts96ynOSN/X4+f0Y9WaU4bXAiaNSVHN15Nq3N7BoR8RyLbRyNczv4dbpOK5Q68r QBs5+gVNUIgFToBmFJZogBKtpxyaCu+fuUzk7V2NaHK1eynR5NrtdKJxmTCO3Yjca9cnj25LUGQK J3nO4mQl5zdwRvuUgRKR+LrqOm1aUnO9FTpN9L7VHYiSMmC/RDynJeLzkKueEE6zJ7vHu7lPs0PY XOfZBDIbGSIHSpNAxfrSbL5BYzWpHVIP+nJjYqs0okDFTAAa/ckY6QMzMznyOs8ii74CkGgBIX4c kC5Vm5V77QVPCx7Zs00+N66oGvqOoUPzpjQp7qT3p2FQSX9wnk0ZBsLFEzSJhqCAQO1ObU58UGh2 WtycTlUFPNGOZe+3fCVVAFZo1/NsTlthOtZ40FwhIpppwtN7IfY1l/ng3XwFp0Hm/dz94FdaYarY 3Qhep7kEqVbo/lvGJvhdV59L2eIkU/QyIexq9FIc4XH0Cl1Ar41M0pN7UdIF65nGKwcTwblxLqqi lHTNZIpe0gH40Db96ag59xrJFNIWgxkFo9DZRCK3gpx7vtTq3IwKgOE0QWu4N3q5kMqRpT4jEbKs IDS7+cuFS6oc9JN6iGh6eT1uq6J/QRO00SQMZ28gbXrbU9IlWrmdZUUtt6PG0BKlIEtJyN1sWN2N yd3sqtLNFugPXQ5jT1u6D7GG9XTyybMc7Iy0j7V0vORj8m2qol0UTnHPNY2xxjXkpqq0imewe6xh YV0OiUE8LY3Nsnu6c0/qLwmiMUyLVkUzeKYqYDltU1VawPNoo5leFLWAwvhpuLmQQOHT7BJ0c6V+ mtz4uZweeVEIWNdrS1MA9aVPLf0AVPIlpxvsusb9nk95vGrKAmpBC5iUG+MsLYSGXUFD5wFVM4cL rRfaOXUbeHRSaAxIHmyam47yhB0BukwUeSgfknS/PJI+EZB5Q8ih0bKFCJr8oPEks8ofcghl1/DE WBl2wr9d53iCP5OWR2Znmx80WZVTI6OTQk1qtk1Sk3VMhQJ/eRwv125uFe3WOUm7sRkorfoKICNX 0G67+dHR9wXabTfXPjUSrKfJMygIjbiCA5UHEUTr7lIt0FFot7O1QOck7aad5UCALtdunSud4iJA Pc3jtww23ncg0TheUs+EyTctjmnG2bITyCPHhScaxxTcZBwgTE65FTVjJmopTFd0vT6rxdsnnvCD LVVoXEXy/qRzDgjNpMaVaAFNpdvdnFRQoSbVUG4mjssCS37naI9JZWMIyHfmdATryQO6uKoCQVG6 iFoH+YaAtCVIzV+Hx6ua8gignZgn1DKObK7T5VWxjy9nev/HrIp02uX2EYRg2haNd9VN20cAzSDE 1DSOx5lbCO0KYtS/G9uuYNZeTsqA6Zi1lzO9/2Mo3c0VzNqLwvvXqbYzBmRkH/WNo1zZ9O8eiDPt o5pxhFXoXmQcZWiOampOLXUS1v+qMI6y41VFaZZY2qGpj8DEvjRDVgk1efHUz23AWMLA19QXcrmG BtBMSaYJJxiAChmmH3Q1r5xoKxMSIZzi6oZMryYlXvsYlg0Ke1u9nLdRxd7xnI6kVcrQJuRMEcb5 VcxUV81MGeShNS3VYKdjpo5L44zyAl1lCf95Qsanhojc61w/eDJb2gtB/dl09uak4x8IbW+Gl00S zUweWutUQcHQg51Y7GsMTcPvyPsDmpW+bFmChDMC+tNi56STTVYXMoWR5jJGohfBFqKrIJT27Tmb trDF+/Pbgj16NYsv4uspevkAmuu08+n38t3ApqVrtPMdYZX18nGnuJe38+n38t2g+2yu0M7HspZW Lx9vby5u59Pv5btBrYtXaOdTwyr28t2otS6e086n38t3w98Ac2k7n8ZauV4+0eW4sJ1Pv5ePq7m9 vJ3vqIxD1svH2ZvL2/kU+W1OqvhePpmfZtzOJy+A1u502yg8cROV99rNSJk/s3PSTxDX7VujWvHk wGmiaUvqDl5YeFu3a5eenpogBqSqn1K/rnNSWHeolrKdTiqVNp/jUja3yaY0j4l8i+KkUAea95IV Spxb4yY+8yv0G8qNacIrToN053Rj0IhpkDbTmZNM7bBozujfM6d2ut6tslD5vDYuc/UlUi2gVWLy Vbj4gE8sIu/6lA1NZyxOy69XlPMYtt2dWV8itzcXZJql+3V87KMeRhm23Z1aX6KmOg== AW10johPilIgKNhHIU1CnghNXuAFAtuiR+5WMGLofqPViGlSf/XvXvWLJqzS1kX94JhR63vVPcpW Vs5KNHSfMdFRaTLRxigOxo8SxLKMikaiTZ5r9BxnVEZFRQ/TJYde84j2xQOKZjQhQazTj/a+1/cx Tuop1L9dwXQp36io5pAp+m8AB4s5Lu0eudQ+XdGYk7qbrZ28B9PSrrwXts/UHkZthpX3J/QUrrTb RbjkvdksVtG4J0cVlJRocmgGFzWcMrHntVFjzCnQ5FbmUqJ9GEQ1pxFN+yTl5GUOdpcRTZ4vDm+P iqj2qZ1hQ5k5N3daMmynRA1lp7fuqeoNLQDSlKoWjDbO/jCQ833api/ksqBQPx48ygjrxINaN1kQ ue6d/UwYQjOYMqo5DnHF7dYgC9e1d+Yusc7ttKQ8WNEOirSb7UxoAWOKaJ9UmiAH8mxMNOzJ7J2W P1PSvJ9FWqVqNethEp+7gV/uYZb1O3Vv+KYlrXYjCeU+d6tTEhzSWEruQGn16mkpIO22LMUmXOCn gTmZaa3jtYARqfZOs3KrXeKEVuMsOK5DKuUpkMAHYuxpmg+MevTU5nSj1icNG+tOyt7ozIk80azp NfsZZm9u5L24OtMyaM/TmdPRLSNpvLJTZG9IW3RjEPaZyN4gTsOrFyc4KvrZG07ZmIwqlpUzszcq eQGwOMfli5Nnb5QlI+b74U7K3txotS5G/86oRz7ar+Mm2bN6Bc1mb2643ihN2hgV+JvrDgJoIIGo 0xuENBzpzOGoCZ7t9DIq0zHjSG+qV+mQTHdw/RIEgzhEmrarXpwBEhenqNJVM9JmFpd0nVQ9p2pv NlVTxRCGbWxiMcSNbuuiUYuf6YrJG+17OaomagINJRj29wGjJ6/oOq/shO3z075fS8bQ5soBAWqr Q3EgC/7m1K/3YgvHTBxdXNiZx0driuY8dX1wfmeeiVPca3Tm6XDaNTvzrlAMa6Yzz1Qx7OWdeawP fdScd+3OPGlQaLJL/ZzOPPVSK7544WqdeTdad6letzMP9XhoH0BcqzPvaG+kZYPX68zTjNY0zoVg 0uWMU74btb41Rp+NTJdeivcnG3mdZkovjwovztQCnYu7/9mCy+f15T4GC0WjDvmkw2IISBFim5yO wk9DgC69AoCFIhNGvcp7Y+3W0b3gWFl5b6rEWuVAEPbS6XcC8cIopoa05BFsxuVtWfAU92q3HMNu qmdVCdLVaZryWPjontbgquqqA5pffBsHNOvXueoYATJ3F45uiIsAnSePCijIrF0uj3A6esbxRu0q MM3CbghNfieO0XVuHKgb9asn4Qo9ipQX/Juhm60Rdt8om2RfrtEk+/F3zSZZAO2KTbIff9dpksXv QhdrLdgl5tWEcnNCkywAdK4SvVH2rZm5AcV4Osr4SitaMwRk8qJrae2gZhsZEBSdq2BPqF/iy3mg rvAeyWPMabAZ5tJgr69cJaSWPF6rKY8za2ZqheTLPK0p70bntusrNuVx8Y0Z8bykKe+c2POMpjy1 or5/Q1OeTvrhmk15+vk0kxEcsKgGSuFG7X5one4rec2F6dvv1f207tWuhIONb85C1UQS0kziGUKT X7h+iWcz2MmvUDZ9MsEnU+QbGjB1BqRTDAE7DqVJPTnRTHZdK+Zkgi2UKVUVc8GlixrsU3qqXC17 Rj48eZ+E6TfmuZUJ7HO1wvYr2QNokm16judzgcoLbR1XWnTa02on198eCvxWrIOhjjzz+sGMCEfq jmaNE8oSS/LQ3G+yJ/Qe05KOMPZAUtZ2Z33u1qXZLlkrWiqWf+9qtd296jX7bQ/fMVxKAkXbHeZ+ 0MBKWW3hputT6wk9o2a/DamNtWh962hidZQH/qlaR9gN+06hS6ftrh4iJFjlDXA7R9guPomrfEIP PgG60Gz2C7p0mv3umL5fEyvGVBN1Naw33DuF98FH+lur7a6n13Zno7SxFhvWN6nXqextvH+aPfS1 sDZ1KPxYfVbDesM1+2GFQoeRb62NvVOB/4174+/gTgZMjSPzmLyCWGso9u2OZU2A9MTW+4JoTMGq u0GFiyoNoxwqNjavV+pvWN975NwC0oZc8lMm2uidA9PNRUnl9R+XvIGmd2O6WkWX9rTyBpWrmudn ihD3Gs/r8XOSvq13I3t79dRclOrzemel7Y5ubTurORNxWt2+xgzuS5fXDuq87aZera2btjv5ZT3N FWqe38C38Iwrg82t0Ph1BbNdsWszb6yYnZbOo3onys3a6HkAtdJaw1f52ATxVRv7LsvZmG7sU4sS lGm7KzT2qXX1HfvQFzf2qWURNeXm/MY+iaISuvpuLm3ENHcmzp2tXbOxT40wN8qu78sb+y64zOSU xj6dSshrNvapnfMcneJe3tjHD5Z29akerFzW2KeWkrlRudvuwsY+tSy9kEy5XmOf4R1d12nsU+vq Y7McV23sUzNOvOq8YmOfdp3NVRv71PZQqOi6XmOf2h6yGvqqjX1qoG7MvMB8WmOfYcfKdRr7ziDa OY192kS7amPfZUQz3dhnrtPr4sY+tXYvPpK+YmOfGgBJ+ei1GvvUDls0Ku8vaexT6+pTD3EvauxT a2NTO1i5sLFPratP3d5c1NinRo7jrPrFjX1qXX2qB/mXNfapRV+sTtPyV+G0Lo4R0056i9DIHyn+ 3E0N/A4TjWrQqbQow6izu6/M6g3e5bjiK37aLsdVX/Ez4XKYI5XhG8AyVoV0kpb2yh/LM/I2zPEB vUetETf67dhH09KYk5FSUJ5G6U3LrDVSmZO0ZKRswiswOydFbYaxstEh1disGMsbyuQhk8pB9rIi txAqhd3qObOj2FP1/b/TEgEqj/8d19mY9ddPevzPSNlU9M+azT7+J6nouqSnz6jmwkw9NNh4kwWP uvXQxu//Xfz4n/kk5EWP/ymTkOrv/x0R6NTH/25kXXgmy471U0hkuuOxKlZ9c/odXZvq9Xo80rGG qYZc4/InsLiYU6ecx/TiTNR3sEGhQU+fwQsNxlxa5fsIrtDTp1dpLBbAGPc7aleGmG6Pg7S5pCFX 3vF4lCxUZ2iD98a48xOgMI9bmupLNct3o3OrldaBEeyb85+2m+qlVq3rlVq1rlpq1TJbamVQSV1f mqqyMtGI6br4JAVBUbv1XMECJgGdbANVCscQoAtEUYQiavIb3QZm48IxBE2329fw8Xplty+8gVnZ 7TuyJ/UN9wnXG0Joef3eGYMKA1kROYBGmKpGlxgzTWr+jt0iNfX3RhJs6ngW0ItzKUu2wd88yoD9 3Krtjlm/3qAx5nl9unuhWWQBoV2rhfR5rdZCcboW2M1NPbdkohEz7r7Ux8gr7mA+++gbATLdYHqj 01x+fAPzudM5ekPyxMeIFIKi2UJx1mEx9MncRy0Ujj/jxhiT8njuu3+K9MPx039nyqPi3T/TOu2y d/8MXfXrvPvH1tnoPP1nmm91b2M2H+Je9O6fzKypPf133rqUF5gf52zMdqec9O6fkLPR6904690/ 09e3w9qXq/SXobLrK3X79u962v25XGrIfLdv/26gzC2YCFSVyRRA8yt0+378KVrvJTVQJwM6Ifml WdEFAV2h2xdCwe+6ZqI1Y0Dad9txJTucD22il+rFbPe9mmhzRFPIo09FHl8vSYPJlQ1YvfZNODLX wEQjVW8rBmNqZk2vl0qFmq8monvuxMNEgP96SanXkXi+mspvm2mk6m3FEP/S2BN2npqI88XYU6uX ypF81lbTZj1GIf0Ap2Wyxc6cx5j3+0SPUUST9xvYFpMeY1fNY5TUcpzad5t7/dG8kEXZdCtW3ut0 phnETSZOfKREM9XIbuJWLQBKmc8837M5uupZ72TixvjVxQsfw+RvIr9e321X826sG5V2bL2+W1GM sQDT86kh5NvdxvntNo3fo25BgIZpp8Nd+o3ptum3wjaTLVHtSj7nH+bzuUAV1ou2Nrwhul/Iicbl ohRP03U2i3dFZx7aG+7htHftB/EijVhdylCyzjxPfPik1Q8Y+nHfJyxrmYaW941pNyLu7phRQBMr xrRzDQVWVgvwT9NJu9WUWL/1Hv/zRSVY5T1y213Ms5NmOZQ9csHXn1pc42m6O6dmj9z28I0r+wFZ 68kTORFfNjWwUlbx1cEjrMHuu14X4pKQZ26VjYjrdlsTq702/hlpYR0rsN6wVxoJLYGNV22shcf3 oiaFZe86KrFCX+Boa4GMcgtHv3HsHjM5Lq42jtNpsqHBj5opkEFXnR3HGc4apeKOPgrKJsUcFsoM jF5+OaNm/vSqMz0rbjWyE6W6fW22Pk3PXL/TR7ePGlcjabdF/ZmYk7T5QmdaJ5XYqJyVcYaAvk7h FpqTWtWWWubWRN6p7rCctH3SUivltE4r3NLpQzu6SPP89jj9SlCx5tYUV63lB2emV3iU7qZPqQ01 mpMyWruA8KZqwCR5aL2+RO0rek6VG50ysAxLpeM4SK66/OsjvdX1bgwbysyprq+CiWNQw8ztdW6+ 44hWuFpH1FfhGsdEYHEfV8infRWucAkl2HjF+bdaiaKplsTTMtLqVaqFa9yuC/sRTfetGUY1EJp2 DZipfJqiFGVArJUPdPbvavqrNq1sBsThapG0fhr5lBwyo1aVJz2KOO2iLniJmvZFXYocn4Roeu/q oPsrZBUJReObISROCucOavfZ6ZjLExrakOqcR/bXuYrh6P60Czu9jJw6WQ4XzEmr/8aRHGtexWDC VZfO6bgG6ry+RLSHcvWvfr+A6b7Ec18bVGmMcSSffVfjiGe/0he4CFpAf2I3Ki2/OtAMX0o+iWiG jxyZnxhxVaKRVyVaUBPaURezzG1UEO3UlkSzbuMN93rcmS2JvHga9SPyZwRntiSa7UcU3cGzWhLN 9iMeFcOe1pJoth9Rlu4+vSXRbD+iVs2t1i6d+dCglGhntCQeb4t6P+KNeqeXJkXOfGjwKJI+rSXR bD+iWiTNzehoXQqn6pTHCm9Of6TunMcKb46bZP8djxVqpx+u+lghXzVkRKoLHysUxPPf+1ih5EDy 3/lYoXba7qqPFeqVKJ5GKt3HCm9Oet/z7McKlekHOK1a8EPLr+6Y6lg+fu3wBj3rdoUHD/VfOzRf bWfw4OFpt1qd/eChyuIkcnh2aa/ywcPzaqBOfvBQqwHwyIc+sznyGrdamX/wUKevChDmRvdWqxNK YfRfO5Qx9CUPHupnj7gDycsfPDTdUHbZg4fCYNXXDhX25vwHD/UXp5HlOP3BQ7WZXOXxE/mDh/pL 4sza5Q8e6r92aFQMa/rBQ30JFqynvkds/OChvhiLXRFCC9R5Dx7qWz4xjLrwwUOh8Uu1YF0R35z/ 4KF+PIzE8xoPHuq/dng5p3EPHl7ekW/qwUN9KDfiO4WXPXjIQ9Gt6Lr8wUP9Zt4brSbZUx881DmP 4R4QPOoOPufBQ41CO+61wxvJnSkXPXho9kGnCx881D7ngW6FNIy66MFD/RLv48ztmQ== Dx4q2xrkrx1K9+aclgv5pdaaNBS8Tv2WC+MHD01pgcsfPPTovnZ4c+o7hefdN6I4kz7/wcMjKDIP /9xWkqMHD9VaLsTzYaPSXtMPHuq7+WLm9sIHD/VvHoGrucqDh/o1H5zcXP7gof7ZtXpZwhkPHqrs q+S1wwtcdfmDh/pQbgzfKbz0/h+P8E7hNR481I+4JWbt1AcPTT5RqNEke+aDh+qNHvxrh/I+ggse PNR/7fBcnXb04KG+88OiucKDh/rOD1s4doUHD/VfOzSfHTR48PCUXtwLHjyUQzH7qvzJDx6eU9Gl 8uDh+a36UEpgPu0qDx5y8qjx2qEyeX+uPL7qv3bIF45d/OChfhGVRgbq9AcPtaN7+NqhIdHMN17p vXZoKtdp5sHDc2LPMx48VOuQFC3v5SWK3IOHZq/Wv/DBQ32PkS+y0HcaTTx4qN+kqzTSZz94qEJN yWuHmn7aqQ8emkhCXuPBw9M8m7MfPFTZTclrhzfGLb/mHjzUYwvpg04XPniobytkR3jwyNN/ZC5q QZ1Js2biuIxEkQ5mzz3nAUVGWPcs1KDeX95EqdRpQW9TnvKyyxSAcylNkSE1LbyFgvoT+D6siDRL flScBDvTLM5318HiCzge/GQtauNH1XbbLbHLWtzTbSPgu4/ZyZeHUDZEeXY/5cD60HeUxpGoO/1u e7uzlPcuS5ZpBu66X1TM1nqx0vfzVasG0DjGPxsf1ar99cLzUW2amX8//lTHrWj08aP099zCN9VJ 66cxW3SKgYfDc6/ocvZ6eY/rtxv6fVrWPfHJxvOW2W/vW07HdkvaLHfr8Tpwj9l+4q632vDZE/U8 oKsn/2zLGja6X9HbbTpWt7g/iw8WIve08MSHwQzGYOkUxrRfGKxoXT9ixafHn+12lvZtDz8px87h fRzApVu4zs/UX8GTjDy+w22xoAY8rPAchi9ibnfTihULPI1VlRO3N6jlNbXb029MtsYks8mh+EAm +8KiI/fXVKOXY7zAoZFu/XRsf9vMinq0vDxWPYrlStZ6+Pbb3Pb717otEl/k7uutYsXx3Song1H7 A+UR2lXBVn0UfOGa/RfwhofZ3TFl6KdZtrPPAOwobQJT87SVC1ZeKkbf9wtZRhb6GFyWNicuTrRG IiUg0bbpsCNmXxPBwkvu7zkT2LtpTzQ4COQKRCkD/vZQyUw67cdsifp+9ERDyTQTtTVH+c+KtYTW SuS6ngLL2uh9wFTxxQmPZ5we2nt/YJylMszZ4IWvWCI/6wdwuEGrwnD+F8UCr3Mf0SuNvFig/+eD dt8Giyx88OvAhCbXczLdufOjwx4+8LI6UKiEBUJBF/onIFpmDf4Z87CGwAptS/MP/CXjY//5uRt7 0W+kLZH4Znqu9yo2+cpMM/GaZQtmXnV6Cs+vpWz15eUTGzmdMW6eqZg7u1zXdugDcQEoA5XKeMXP gB2rpfgvMX7JB5+Hrwz/QQ0TPwCmY5PjPijG0EJcUtTFDFyNMP5LSr4i4xM/AGvtbMHfagHWqQsU nzC4Dx6g+5Z78M8OAYG7gTrdRzkA9bwHDvGCH2E2eV8v+3imOLg5pVev+dEogOF1B/7ZxGBBGg5+ jOA/X1m4ZLq98rM757L5XSigwQLdMiI4QJPH4yLRuk9+hIbMRchIfPzlmuR/KOYpt4hYI5I8ndB+ ykJTur6iXu42Awp4AM2FIDuYCJLqJGP5bMce/87/hKubbDu7eoFaO4rCqHf3F+IlwjV8nfG81CYF XvqWbvKgJvJLH3Cwfwn+1kSs7YX9Py72t/7dM2JaSHXUIcn+I/dGlqAd6wKGSpTRb9z+DqYhOMTH deTPO3742BYOdi6CDkl9AHjWyf7Wt+WT3HTmfUKYzoANCjtp/rNpkCkQDxGwmrcQt5pXzCqxj3Ln FgAHHMVbtJiaRRMOvZBFc38yB4v35/fV4n3+Kli8Dy+vFtfjmxNauYrFcwAWAjiRJYvXlW5bXLNF yuJbNT4snqE/KrOBwOiiLCbL+MmuE/EhUjbcmt2c2xRqbbhRQM+Af3ZBBFEPOrHA3gld9d6eX3DJ Acs3/+A1kzDrW3LBBsQlzM+hf4If3TWrj1BlSsbyyamTvN+LmmDQBgVmr3s/m9KceWMe5IGQtnih ohbGAS55rLJsKfyAH3RQyTirbMIuiSKCUgv+lvRI/ta/K2/A3/KIeZxQUOe5xd2Kwb1f4bGg5zBP Kvxcyn/PsyOFAuJc9Qf0JjVgzwiOaIkXO84I2LzpE/j6oxepZLw4DUKiPUINvZFdlQ8dLclVEOhT 3hCRtliyKJaMKGwRrH6wW6KVwCxVZkh7/D2GVeZfGXzoW5a40gru+dsg8uzkKWiUOXaQ98OlL/Do bWZ0MrfiUUCZzH43GLBpcwossxlDZhUQ9yOLTDOMFxrSg0CU8vv24gTte/cEHotfWVNoKri31k/i xa9pGZAAj2CVfRksrpikTSHsbX0o9iy+lmE4SxHO6TSHl8KJqGocIJE9VzyOD2qvftJORhk+UV9Z IoIDko5ohSqEq0F/mcR+sxCxF3y97DJCQ3hDuGeeSeLAWYiTdre7AHDtQ/jw7b2vhvA4Oxh4aN9n r71CqAVUFon+CYSiD0IhazugeeIgZUsyUYRiDExCwJfBxoNyHGwfHkfRHxJPI868w709H0MU3I0H yG7RwKxey5D23m5oFjWMb1Swm0BNFCxYhbQnHltgwY84gIYniEJ8qYpaluvUX7ipVTu/MtjkwVYC q65gQG76BYIuW/AbjUtOpVxlp+DWgknMPmNAM2BAM4TySTL78NXTZ2OOpSZ3IaRsrG3S1CZb3KXA bDBiCLqUpE7SPdzZ2h1exDNOpBfM6J5c4LuIpAVyRBAflm00PvjaMJpKQXF+09s6EFYyu63S6ol6 yTnLb5/4dvvyhCvjZgBxXTggbofBB9MphkwNN8Vkt3XDN5crjgJS9ZmPtH+/PcGLU+yE2xJliG+L jzkTNRRPFezXRg2NNJ3up4neiHyA2AnyfmQpAhgvySuilhwWi9iXb9h4HouCnZ48QdRhwJ6ZHO7N L4OmUBfbXsiRVazy9HFPFBI5qGxI+/a7brjwy1AjFjgAQ9cbuSvAlfggyaw78URmH6sRY7nFoGeV BRonRwH+TgMNMujTeImqKeVWsDcK0X30ZmLE9+PwEYYWQYL2W2qXoIacpoJdXWu0PREBNQX+Vi6a Rc2uxtTC1VBjkx4GnDW8RkNO82uuWqUx5mQLrLSGg2knB8Kd9SNg9xcMprkTnL0ZX2IaTVgITjwT 4EuZgoj92qhvlFe28vYJH2XBN2NJMt1xEkSv9JvHqrTbeLvVCQ45zSTNgX3yxwI/63uKcB1GYayK d1KE6972YEK00aHXdFoEGtUHNaqdBDPf0Fg1vyRMKZbutws4Wr8VwGkE1IeOEvBJv3BTnBZETjag PlUx4VJya/0MZoBf/0TjQyqP48DzL4IF9/Oa1d2y5V62VmvCB7wtZG/Al8IEsOz5FEFnLEUTlD4d 9c3/kmNvs9ghapzMvgDrcRXU4lGEtvUYUlCW0mSWfiXORI3QnE7z+tAvoCaxqvMpo49a0kdw6XbX 18HArDutEr1FDex1Hfi6rqavYorTPkDMYclAJidRTtIcfxXshOs9WkHN9VBrQ3tD9JYYqXamgALh o4N8fhJ44SPllsTPKFdiK3yFsECwuYfp/oZ42Rtbq86eAvjAnyMHdrx9GKlKtBb6m2Xv3Akw6rIL 4yCAoLvoD5aySy5Bn1y7Fa0kKGWe8LHFVzBlXSKc0S4p6mV0NZ/lPmejeRhD2XkIApALSADgzGsR F5MDbAEMzIklu04w6mEFFEYHZpXra6TlUQqH/9vzH/+37jYwe1p5xLwuSqaIaXFu1UHXBzpB4DNQ 6GZD/vzABa+DjFq504VF51GaS0dpQzaXnyTElL078+Luo2w9e4sinCd7eOBMvy9kh15sUhztAxl+ mnMX8hEHHGK9E2+zhGnxOzidrvxGP5dItFfLOlBg727c5rrvrNw8rBBi58DW/MD7d8Gi8l7H0je+ EejwJTlJSRDThkCENykRZJdr3rB3pmx4OmRIj+ymzW+eDsGASAc4nbmklTn32vXpEwEpm9h8xdPB rbhU0SfQgcPK3WGJtsUEEZJ7RAR04kEcLCIdLMHdu90MM6Rj9T3H3zlXVSBC6dN/99vhieD3ISIA NCI/HL01wxIyhmswFAdDFQD7OAbPkTfi7aNGTHlURXfCJACaIxio8ueShaA7m2WixRPN/EI6sYDZ HdEST290776EtUFUde9RAwDQmCUGzHv5jBbSTzyKAMj3BR6RPSmOezd5THUhJqmJ+2xlHAHY3RVL OY9w56r7fUv+yYlWzP25uKHMLy4OvQ8+0t9Sdu/3HJdy2vh37LyM01DVgQyAKqfpwvjKYBdzmuPH eRmnYSuXFqeZhlHzeA25RApApITIaV9hJUFPoibw8ZIBTQDs+Y0hjGLtzlhudQBANMWO8zRFeDSJ L786NYVJQHujvyPFadBQ9vUnsYn5kQN1wUJKtkzABG+yLKCxkJKPOUNGpACoGquAWKKdJSOldJMw mMQ2FcClRroU3Ps7CQmMwdf9ZSZhMPV4xElIjbTqPNQnscENWEsxCYBGOY+hLey7iC2GvqRfQU2e aCZMEweDymsLu77qRFVWIdxJ5Lo4W1KoVsQCCJkZJhLe1m+0EC59QBvU0Cl74cMtiOuP3RvxVnvP CpZ1OdjDT/4M3cWfIoNg5OHjLwxi2nfaIzlUTiU9/AHyowt5rCiqgTrKS9C+JTwpJFy2vo8tycm9 vvPlPEQtBG+68kgKdoRyGv2Zs/hhdYB4HH7D3WQBPLxaivhOFOC2PP+xWRnM6Q+w6wKGqCfUK/jZ r2MuWwTDA9ZdwtOJb2PTlaPDJArBNesiOzYdOzyxb1tsdATeEm+xB8uExZ46FDWLLEAg/pWD2TkK uEibIqAm4YBnizVJIgDgx1UqDHKY5GlkRf6NEANbthgAnkmLRGMbw1PFhp20P748AEBeHxerh3p7 fr+CTuHs/Ju1N2Po8HyDHaRclUgjGiky2Ub+na6WLS0Q2ydLgMmSAzrtabXptHP0RrzVG0HCus59 yDbIyT2TyyL0vhAonxiYWYPoITTga7qwSp3JoyylwAJRoZbkTcxQQEBeSSUiqk3gieBGYsTynDyx cHzCDs11kpMlmMcQt5uo33VhDZYHcQn/DUQEwgsEz+9gTb1KfggxtFqShp1qawPDXvHyqjh8vrtx jOY5gmrAjj+gCb6iK/DQvgth43mTQfkRsBkfPoIukyVY5+IRqsbvsPE+EwVRBbzGC0lr98UP3LZN CZ2ooQoaSa3Q57JXEXUEKoB57bIlZ2IxF0tV6eprdcBByRyQ5Q0hKR06mn7Ix/EBmok1ARTK2plE qaFDMxz4mdxHYHYsGJgVmQJWxT3viGjwwtw3NF9ULULaLY4iGBxjCPqtHMEqg2YS+A== LKMcbHDFgVQ9xXGvswNzVvYglDmYSM/7+NJeMCrgwD3zWJQouAthQNCwGy/izTSZTeTCSArY0h2J Amj6ErIFT5+xSjcDa3MDYsNTjm/BYlsIHotMnLR/f9YB2397iAL59uRJ1RuA+vh7AM7kFd0lCmub gPof/B2gp0DIlummYXlyhDt8lS0Tnd+ELSWsGts8KFYKgX+gIgswSyYGWIDJgKkWMeATjTKw8CKL GBSsOp+GpysegIssAJLicaDE799EqboR7ujia4qRvodsjCO2gLGMU17nUhSKnkLYyNnssqVAIMR3 SJkxVHKLzMiKZ9UpL46RVhyzIFU+UElbIkNMkU7LMFKKF/rU7P2NQYwkO8hXfEk0nDvLp4MWpYAr 32RNXWXJ6UqwKNZq9O+yblg+kELRqrQ+zYkXvkbvaPUwMqSEgiE3zEN7YfY3x7aaImvE8hyn4nwy Fff8pzZtXqcdzRyVefb/Iny9EdilkZ3wBWahGoPiYVgSjyoRA/y6qpKyVMKVzsQF9QSTkKF93TYu fr4NfrkCwte5FSZN+xZvb9QGP4ZvFvvvMmixv/ZJmHXdy/gF+ifovL538LHHOSlvwU1mH9NBwHN3 cVR4cSPp9AKa5K1lQcWwknoBSWuu3OhJk7b0TFTdsoRny4l0FJuHduNiaa1QS+uUFoPKajKFgsdV Jo492xAvISYLLQYjO3oYyBMNpqzY96uDwD3JlIVlAY61MP5hanTaZwUWOPdA2qI/AbyUIYuAGGmY LS/FZQWXr7jcjvFe0TzqlieIgQAGuZ0eEEAy84DbRZspbxzgKeL1CSLbkVujJ6oxtqU474E3Dujc E9Ah/NhPJ9mvn1IowlVco3rZ/t+L1NR+r4vHaOK55vMDqjBU2KdoqXuAvX9Zl0x/J72c/i6kQ2oG BloXVLjnRIYFHXqxtkVhn7pRBI2nEryZlk28tzacYwTdMUWz4UPrjwRb2igDqfrwiNaALR+VTj/8 6yil1E3zfuaB56600j470u4ijGShP/WxY2sYJrWvJHAg1+iEHeY6Cef0PQ0io2ngyGbsm1Cjd5Jc d2Mo6FI4DuBHxQMdhyKsjgzIq+YRRzC8LyCuGdb6JcEsd3Jrj+QcNTJtgWhNwjThatQIMLtRibQT HRIWeGW5C4Re58+Al/cHGNDh0HuhUB8Yq9O4lcpNOSFbpq8CHOmnAjwnTiqXCVz6N/gNHBo4+AGs hbP44MF4OAW9TRIZ6XkzAag/yiLTSLh8X3GCTk/zbB+BKFWSyk3gyKOmAxQ0sEZSMFgc48EmL1aC QYAG0AgVtC45P2LofgPB0fHB6MNlxt7JPmjIMlDXif00Az9UO3i12E8z8EOruVrspxn4QRa4Xuyn GfihDNTVYj/NwI8/wrtO7KcZ+AE0V4z9NAM/aR/B5bGfMvDz2coZxFxcleqJsR86DzBdlsD1sF8v 9tMM/KB4Xi/20wz8ZJ7NxbGfZuDHE+06sZ9m4IfS3VeL/TQDP7Saq8V+moEfF+JeKfbTDPzQaq4W +2kGfkI99FViP83AT/RsrhH7aQZ+LENfK/bTDPyQTrta7KcZ+MFc5/ViP83ATyhOukrspxn4sR0r 14r9NAM/VKh8tdhPM/CTEU0l9sNPiv3kgR8uMt4Nf53uVWI/zcCPDwqvE/tpBn5Ibq4W+2kGfnxX xHViP83AD8U3V4v9NAM/QUNfJfbTDPyUIe5lsZ9m4HfDXzRzldhPM/C74e62u07spxn4ATRs7Idh UU8Ys2ED8OfiJP5WHeyuEw+iYJDLdUrjwUcgRIChv8om40EfGOLPQOcjznbKiv2TkK/QKadY2quj +cP8xRL2JeedwXZ7bmIPK24mT2MHYGPgoE8eHGFkMLgQMH8PXXWuBb8Yc8k6P0PQ0Jcz/JGr0mYH TrLZfPmobr7WD8U5AdtcHGfabIDm35evFW02OvT69+Rr75O4t+egkM3mVOe/J18rcwevn69VCKok eW+UuIg1E8Bpf095zjuxBmjOTFyclLVgV3NO4sJE1gJEOlNkodDjJ+clLk7KWrA67ZzEhVbWwq+W teDO1kwnLmC1XVqwN6TSQvFX+TgVgcSNoguPF0r4G2mL5atCyEad6g661N1BRxZQiSnBEi4KeCyj JJn9rpbR9Yr/HnewDBtiF2neHewUBQkWEyEpwrVstIVECPAjXE+fmkcAEqKZ9wTxoW2a5tSToSfI d3pJ57af+YAjmyjLJqiWA7GlgVD20f0KgMKxDeDIP0cFoI5FYNNUknBbGk3U4ogPal85lOWofQVk aRCRaOj8myWay5wT2GewifMdU2T/b8THtrZeEHFmCjAuDsCOZZrMJigSYGVSwND/Bs3lQGwMsIFB mii4+glxmZAF4EqBjwFTGH++krhSYZl8DqTIwG/uavC1qjAgM0mT9kQJuc84bKkvYRP8Nwg7E6vA RX3K8rfFlhxSl+OK59/8XnM5kAAKCl1PAXUnkDUcYv7P74AtrMA3ni7zHvbmDehczopN4GbPbJAZ nBhYSL+g2DS+LEHydWGWlwiKtI8ASAmfttMRlLYzDMide+TSJeYERS4lXjaSbj8dCwrgKoGDiO+X xTPMfJRhAyLgA9pWIHojC6yRGYA4oBtOEvQSQ/3XIG7yfWWxyuCLFKUEys1lgvIF4qD3RFjrmIyV khv2vk4MAK8BBsz/EiqCUgC62hlk46YSVQuDCRZxRdzELyldgUuiyKybgk12uyAMD4vIgVo7U9CO gNjz2QFc4Nhrhm0N5eOmAFxwnnA1MSgUiScYtwH93fsFnuggHxFlibS/5MDibNOSImi6YR9xMB83 oasMNeMmCWujoIll7SrLaf+eapHuNp7GvMO49bcFHSgQPFGj+GGUedZPJF6QRYSFylD9pInvh01I P5HIZRHrLw68FHaUOMr1/7wy7yGzU3O5bqRX6MkvDXJ6VC4Nkt4YZK06YQKtBG8UJIU4xCPc6/TF UoJzOUAElXSDED4Jr6mgQqoVnmm86rvklE+qoa/kL6s5yyjXeV1/Wc1Z5uo6r+kvqznLN5L7067k L6s5y6jI4mx/2asmqObT3ZeHzv/z093S0Pl/Zrob2AXv5eluFybxzjTuYCO52yHFsnN5wC4pu4bi yRxEs3L6ATLbnqd1LwnSaYqrSa5bPIySwDBnQy9LTXQ9LHuG/P34GFZm0HnjG+Yy6ND4nnJ2fIPe KdSvG8aBoNab+Glnx/zEkjDSzKL4ZhMjJBNUS55Xip4kUSALNUjhEHk/BNFH6Z6KHkUatU0WNqvT 3D2/fOZcRjQ2ec4SzdyxMe67dzAKr1d6bJzKpKCUVNgsx6cbCkoVoCmC36hfmnvswDB5Lg8A5ct0 56CXwQB4MXRGoFip4AQSvdI0R2a3kyRwAqMPgMj5HFRdKeBh1hLEd6FUQbe2wTuMioBLX7OamXO0 mkuT51pOoAiFE0+JhyemCaAWcAFPO5nDKv0fFwzxXPJQzCsEEnLJRJdfS777wlepXkdQICNh5oos QHTbB3HIy0vsXEH5WSfzBL3ohMTZseLpLhIuX/CN56DfAbw8qwSCp8/2cRhhg1fSdRis8rsilTGE ICgKKYFoThEUANdWNSEowAn+duPDsAUmEeoIjVJQuoc7eAErDga0gKDMVzto8jF5loFdEvwgrIgR 4ZV4IF6qTctijIgiaS5MFLIM4TBRsBQe4JM+JLyUCsT7g04BnV6BSTvjvCzlHqGeicN+vG+PbqUF W22nnWUon5Rl0Ewx3Px/7L1LbFzHlTAsUoBlOG4ZMAUknlVTFik+L+/7QZGi+CZFSqQl03rQEiWR lCxLpN5fnH/xL2Y5RoAoCy+TceDABpHYH5AAAexsnG0wixiDjD2Ad2M7/qWFEwNjI5bkv07VfdR9 dHfdV9+muo4Nqru6bp1Tp+pW1XnUOU8xSFA1TE0Mdqbw/Zv4piYGOxM+DqY0NTHYmVwxKoWpicHO RJiWztTEYGcieoFMz8tRh2WEJuvzctRh2daqZ3lejjosO3roDM/LUYdlvKalNDUx2JnwWSAH0Tkg N4MYle/lyOgYXRmJzgG5mbyeeV6OxHIz3m9SmpoY7ExYee+ZmubV4fimJgY7E95vkpiaBgLK1ep2 JkdaS2NqYrAz0cbitBr0KnYm3+WLhKYmBjsTsd+kMzUx2JlspqUyNTHYmbAGKqWpicHOZKu7U5ma GOxMJXLTK5WpicHOhNe0lKYmBjtTwO6ZyNTEYGfCGqiUpiYGOxOWb1KamhjsTPa2lsrUxGBnAqal NTUx2Jls21oqUxODnan0VHpTE4Odyad+SGhqYrAzlSpd+Y1hamKwM5GZls7UxGBnsu/fpDI1MdiZ sM4mpamJwc5UasILZbHOy1GH5VKDXChLKDoH5GbbGTZz0TkgN5fseDZZi84BubnkxlLNVnQOyM0I TS6ic0Buxr1JaWqqamcSvEtLKU1NDHYm6A2LqQmiSTKHwA/bmegjR1JTE4OdCXs/pDQ1MdiZiCSd ztTEYGfCaFKamhjsTHi/SWlqYrAzwTktramJwc5EjurpTE0Mdia8dKY0NTHYmfAUSGlqYrAzEa+h dKYmBjuTo4dOY2pisDPBFEhramKwM3mmiOSmJgY7E9bcpjQ1MdiZsCSd0tTEYGey1XYptQw17Uwl OjJsQlMTw7ne8VJNY2pisDMRDVQ6UxODnQmfbFKamhjsTKU4wbOSX2lCaLI+L0cdlvEUyPa8HHVY pu4RZHVejjosgxiV8Xk56rDsrAJpTE0MdiYsEeQgOgfk5hLJ5pO56ByQm7HsmYPoHJCbYQrkIToH 5GbaASapqYm2Mw12HYiyMwVMeIlMTQx2Juf19JuaIqKbpbvS5EOT0NTEYGeyr5KkMjUx2JlKLIHN 0l9pwrciUpqaGOxMJfveWhpTE4OdCWvVU5qaGOxMJXxVPp2picHOBJt0WlMTg50JT+iUpiYGOxNl yE9samKwM8FVkrSmJgY7E/FSTWdqYrAzlZ7qSG1qYrAzYTQpTU0MdiZgWlpTE4OdCc+0lKYmBjtT yQ5GP3Ri+vD6gdOzxlJFi1Iac1IJRxwb7R24fHBqEge0Z7u5dLFPWp0dWkQnmytTEYJHb4aCBz71 dWoS4vDyJA78n6fgoVcVPDrRgJ+2BY+Jq0j2Gp4UD5+88BKDzIHYfJteg73kaOf3TvcFjqj4KOUe oKh5O/3yDTJ5SApd6nyweIo6r/tOTJP2/EbPBnP1dp6YwZv06mw/yeEt9V3fGJPPKS++6JJqRR10 4AR0NHyPIfKCwxzeEsh7M9INR9UxktIHbQkK5BIdg+THEqWzOXKm1wCF9qQzGc1edFibnLfD4ASO aJfvzEBTluerjjX+OJf8ySvniRxCuEr3Hm3S80sjkPR2nEwjp8ODfvKn/CtjJ1oPpxxdpyatHus5 LK0e2jcJy78qXTi7MUsry16YV9FsvaL6lsRXz0MktykkuVyFLb9bRPv90kH53MSrQw== aH6dw+nb52A3UMmtiLVDaJNoXyBbCKSBk88dffEo4tz1Bbl7ZsokOUIvdp2f7R3pNkcgws8Y2lHw WqnMoFmqjiidN3vWYRy6XAvsy7CTdPl6gzcTyPUHoeluaZQSjk35Uym1KE7mdxsiKrd39Z+70L53 fHQvpRoJeqWxmZ4r2J3HOrDa7pACOsFFkcn0vLR8SD43dGKhgnV88AZtxHFWppLvkiyrnyIWAQ4u XhUQQqyyPziQYxKHuZVqwuMY5Jcf0gYu713swDPtlZ5pN+JXdRfF3oRyY8BFcf8NK2H+BpIurlI6 7hK5guVLBp71FSy0RmH5ZnxjyjGNrYuwTB0hW657RIRQWEPSjNHjxPD0LxRVdf54cyjZwRqrqv1f gL5OSNPjx/piGMfs9RjWTxA8lI415bA8MXJL8lYBTygC+5GGNeNorT4/KQ7c7joFK84gOs9ZR9FJ 7KSEznMjs5AjdVTpfGkDLWc3bx5Fk2xsBh/NQDYaImvaq5O+5ezEcchuOgnCloUOhq+ixXnp+hxa Om8Mo6/n0Uq68eISTnMuri8ODuNQpfg4HNBme7wmEgHIMi67M05PgI9mCE1+pzPvaIY36bxOZ74Q E/mdzryjGW2Tzvx05h3NsOCR1+ksQtF1cPqla8lPZy9dC53Ozp2eQa/KLdBsLuLr2BAEGy0xM7NZ n868pQCvaXmdzryjGbZ45HU6845m+L2JfTpbXRZBy9MPdrxOdLxaVNGKszE3iBaqiyfQQrU0Cd71 BpL9Zy1ptaN9nOjT3NiFVZfE0aGZaWX08upL9oEbLYneegg5ITdsYw+2j1K8XuwjZ4Hjgs3uk6cE V95nWsmoPNXh4woczU6077/+4jI+px17/mSu57QRLHtOj58dYz2niRfHT46Jh28vDFc4p43fzu6c Ziv5z/TASnoYNng1x3Pa0XOsSv5SgmRbXfH945wjB5j8usHUMotzdGZ9tQxmmrO2G/ldLSP2G2k2 4QLvrO7HN6uv7rbvYKUF/vhmcIEfGzg3jdaqHni7r6hopu2bQGvU9anqqzscOZIs8P7UFVgdC+FL Sc5kb3V34suSCY1f2IEuO8Qs9paousCPdE3cZJC9YSdDy/S5E/N4prEv8B1e6GAIKDsFsrLpxX/3 FJP2bkSOqAdIhrKZ62PS9NlXJX84WVkZ6zyDxkHRD+NxCOURYQ197ubxcE/adJ8B4fxhSDyJ7aTH ws5BL/aKcwfaQW49hc5TfVMT8M71hcLuXli/gZfOS6osn7l1Q8auK74T+YGDiEsLo1LfK/u1UF/N O0fEiyviQlRf7eP4iYuLsMvh5I7n5LMHAsHrwTVuUO4W9h2OitFPiyWupIfE3l48l5WRpfY7RIJz FAvjjj80HjLbfTOwDTIFPa6xB5b8wehrboMJ98ASFdKIZRtMuAd6Vly2bTDhHliyI1mwboMJ90CY aXG2wYR7YHz5JtEemFi+ibcHgu9gnG0w4R7oP0AdnF7cSC7kLG74F0VIcXEMiTSXXzyKVwHIcsG4 DSbcA0skhB7zNphwDyzh2A/s22DCPZAYixm3QbkTjHkL4/LEWr8cCqm+uIDG4ea543iJD+wL2EUx xjaYcA8kNgL2bbDKHoj6CsqseSS3jeiRqcMWDqDH16aiuou3QXAZgaQIl2Yh+r0VTOCyOnJrEnH9 6FzFPdDzUmXbBhPugdjuGWMbTLgH2gJ7PqKgtwe6x8E8REF/SKPcREFvD8S9yUsU9PZA0NzmJgp6 eyD2Vc9LFPT2wFKeUUa8PdD1h44vCo4MviCw2i0oicA2XQBHBmgnHrlTB28kcOg4A3cgxcMDl7Xx YdC7rdhERJoiwEbhbETuVRLssRbpytYzN+peZvAW8T5nEb9VeZfBkxzvLtjB395gTvk3qWOkNadf /UShjSZIrzstTgLDVarraEPuBg/maSSWCf3ellAiMSFp8rHzf/T+vNgvzRyaWQTH/T56kyZu/Zt3 gNhlctqY61o6gLp+ftLNwOzLqaWhzRRtIesXpvoicmox53/0u9UN04sN6vOV1X7027UX/Fs+5e+k jPa0T8nnlBkTzBnD6NPIVETusSn0Upro7Vt9fi86LnUI2D/Nuu7v7tohuXtZHCf3E3x9NWB/nMXG nnBfV25j1x15YqNnUp6Y2RjzktWU8G0inMUFXNOenzpErneEktUI+8P2EFjOFtb7yNIJCyu1a9mz 79IMdWMlaK9myvodx1hdIre+I+zVt26RbbBr5OpSe8/+vRvwaTWhTrREQhrBXgh3EZ4XDHqDIw5p QTONrzdwJqxwH5EiG6GpcNWT7CPdiF+HulKLbHDksLerkeVq29UIeo0mJ3U0tdQJMA71gUWRvJ4M e5Uj4iYySclS1gk2tGtTlb3RqR5Ktw4jlh7oUjpPHD3KnGDjcDt9YvFennPSddhlrtKbGb7vGpoM vtursfOCgZ+kTzKDhQIdeM9eIp5B6P2eHUQn3OEJevfMUDITwEYyTOz6SJaCG5onJfze0HfUvFr4 dkwPrlV7N9pv37qpYEX3jurT88exd6LcfUjU49wy9QjrQ2vU+BB49wyDI++UZ3HCnin64LJjatpY RgcYZUq8uLQxjZdkqI+kistHkciiiFNoVnUdEufGDRNtdYcMtBFY077Lc8aQ1LeE1nRbIljsB3Wd jG0EFfYieiOaPtElXXil/Ujly3NHXuw10Zp+aQJRd0BFZ03jEJJcBBn1Rpy1JQKyx0oXLvUOoImy 1yLOoKv6uAK+oZrfGfRlPI0gFMVcuMNz86jDvaNK54HlQa/DzuHWcQbtg0g8x4Zh+zHRn6F5NAWW ZuFeoib1d4yg0+nKvjns/YH6MDiKJL01lUExidFUEMrcw3WEUBY9BBUvz5XCUUaY4jTeuq0fHTt5 whCfP7s0NNT38oYh7lNWK16ZCyu6oqMzwp0zxM0e6ipctEAl071xNxbH8z6Z00D/9lqhE4V0J/5e 05epu2EITeqw3eL6XvVOxbthuIfEfnOgC7xrQFoa6Zcnjik6U9huR75hiKVQsiNZhM96g7eS6glB geaPefAyOXUuH5SmV/YddZwmjw3ab3dmvl14TSNhD+gXG83gCwP4xfYtjl4VrMzQeqDKuXC7gSsJ 3XaOFQbxaBIca+bHwC9KojVmNWQjmzAyBSYgdezguNJ55JLlbpf2NRfw9nM0Zt0WeNkfBAd7Pbiw 9nUdA1f75TG442IhAQk8Iva9OuvXDl48++ooqn9p1NYOXlmFmAOL/Qy3EkAoHRIvrihHK4pFJRLh Eon67ZaqdJ6bmUB0HJtFk/KMQEVymNl3SAXJSEH70s12cJBVwXENHbivXLnpUxFG7yR4kyabSc8Y ml/n5/BV7UCfbRWhf5OeG7w+Jp/rOboIimwwtZ2ZRcTOWIil/aPA+lG48nG+N1VuojlKgx+4yh3B f+iNbwgy0A52n1i5CcLQRfTnyq2Q0auyeBI/zSOakYcG0brVr2KRpmQnEKx2J1oeQCL5+CG4oqxX vrZdVaoqBaKP1gqgo3SsbvTHvwuM0OQXdta7C+xZCiuGnSXxbBLdBUbvrdlr3/HIJeJs4BpwyQ5G n1kEreGbUYIX8eVglr163Xc0nuBF0Kihixmj516A4C9XdGl66dhgakMYbZD0JK4eUPmMwsm9FzsA D1wevtAdefonb6a7zlZyWXbEKNaQPnBvbRxN1MlYEfF7iN1Twsl/BTAggoTaIWGhxIl688oNVw2G uHliZBTJEpP4huYgGA8mkWC7MQeuEoNKx1r/vNQ3CFmGFeswMOFEL3XTq8fB1deFNoIjfaw7GiwK I/K5E5dHKl2yw7fvLHyyOfuqDKv3uDyx3D8HGtku2k9xZgrLUmaEp4effLjtcQFu991ZGwIfwzkk xL86g3cjQIOY6vfzOH0M7q1NwXKqSn3S8kHEqpk5xCXjkDxx+tZBuXtNB8m0T5Un+l8VU4pReIAq iFHuTMMbF8y0qt7apac6Ku9eLGEbz5xcOjAiCC/2VvXb9sSoaNftPjRBZvb7/bFPR206/ivtwU2n FCvgeWJVHjkLVLQ8ZaXKK5GbxVlYnqqp8twjR4IdJYag6IhRAVmx2o7iNz6yqfLsKeDtKD0JdxQb TQVVHrYRhLV5WavysKUwrM3LRJUneJITTIEqwtNYP1zSnYANri++8ASSEyLx1KtYbXeu+6ThF54m 7f2GkiXWppXRm1dHQsKTtDYKK+lUlPDkndyJ4JFIeLrYNTWqjE7OzFcUnjzJCdD0tFvTEANjOiA8 YWkJDe2EjIUnfKUbaz48m5K/N0huOjo0K60uT4IvYJ8JNiW48DMD21pY7CFmpZehuxPyytr+w2El 4MwCmha9w+LhW7fVsNYzZFMquYmea5iVXp11b6sFbUrKaM/+SYRrto9cgMRK2yq5iRLeF3IiBA9d NMT21emonadEJw1KcWkIwkJJB+H6fn/UjSFs9wxcGppe7ZMnDrWPOWFYgtao/oqXhiqu6QhNVYcC sqyndiTARq/kYaRYHQnsKZA0jFRAZESCrbF/G2f2I+Gj0mb2E5LflzrVt+k7p+FwhNJq/6UxfF7F /M360idsa85RlUTzQVN7RcB3c6KWWuI3cWH+pFB7W+tj3taGILTq0qQ8KV/SYmxrriVHutMv9b2i EM1tYPnfJ5zqdYM7StPjUyNy92wPhEDtk+FK6LQ0fUM0Q5LD+KVD9m4YPs27aNhcBA+jt7DjIIjz ZgU3+SGQ/tAOMSf1DqClu39Gml5p1/Cahk7Evhg+OJAjCU2i+CKwVBN8IByPtGqIAcEHx+7BB6jR Eydm5XNHXwAjwinVL04OAxHDaG/rkf3iZLywPSUnBnH1DBHeTMPOEDDTYvlB+La1Sn4QF8D74dX2 7p7RlxO6QARd4pldIOIlysJLZ2ZGp4qCBEKTodGpoiBB/KGzMjploYRkMDpVtDjVVkJmEMC7d9Od 0Al2FFBG+41OFS1O7lWSTHO6hixO9v2bTIxOVeJglfy5iS4OHpAnNoYO25p5xpQrlO1h+nS30nnz DDjknRIc6qJzE4H95PL0yTkSLtpvP/FJCSFrfsWTO1FCsmw142itPjkLka2Viio6GPND8sTMsAJb zbi8sqxgZ03BXqGjwsWpkY4PVWSeycWByxc2jsK9aysg85RISgrs+CAjEsTD4vrt80pIxLwhTkp9 XUsjoDTWQiJmzRBYtrcdcxSskMPO/huiLeJX235K/itYLGFjbt2A7eci+cO485R8mS9yu4yFDZI1 HNEP3Up9GcvRqjuO6GRZ78v6Hhbl1Jd9zNHUTn22yNjLKjKWkl7BiicyluJewdp/sGsm/RUsMNcl C5uD9mzfSbT7wAG8nMFhdco2rzpn7ppO58we5/Y5dYx4nFMSQeRSKwAR3c7JOauUey926GhvwRLv Zj/7tuY6HLjmHFegI2s/EdhPTngmGbxTSasd/UbIJOOTHDx7DMNpPqDuDiaM2DhpGw== wnydow8Ox2+gXe4K2iHO3HoFvbza7BiIRbYE6cSpXMZjg0PhY6MT7U4+W1XwGYLUEWDMExWf4INV fkiWnDHa8R3rKbyTwWJDi5Ori+g1EkZhH1F84mRCJ/K16WpO5O5M6weEA9jumTDcPXXrO+CxN75m 9t2+vjTYubF6vqLCL5a2z/Uaiq3wi6Xts31uhfgKv6pLN3ghzU/h2DLNnGlpeAzyoknbONMSyXmU SlCz31ZnQscU1GBC+TMiQeS3SWn12AwIap0KRHqYEOfGN2RqFchQUKMvYpKMSBOzOEQ47OK9/uDA zk0rO3CxfV2WIS0S3OmM8qHw2QichC/HJuRz7boaLy2Sc5FJd2/lyu5tpmWHaSCyLfvCfOuT6EkI 5/vCuBG4Feu/LxS8FRt1gYeo7cJ3eKpsdWtLznWvw93RKV+mpw4onefOLJKULzPo7SLKFK0LCWD9 lEM+9loH4U1Hk/fC9YCeEPqK9rGDC6P2DSdfX5XOy0ePoi38+oTX1xLOG0W6+3LtK1hwY3tqHMyw vRHXzSprDEv4cnllF8GZyhpDlgvczqbnHNUT2rt8WTJPT1Xc/sK7Z9b2rqiMmDnYuzDZxMmCaQfM 2uh18rzU3nn14HyGudmdKZDKB95NmIF2nhNHYDfqDwiveL9JG0fk+WtDknz22pXOBjd69YnJXTvQ s6G0BxBDHYSMtSk40urokH/2EFrMN2bcWK6pcqgD00K5LbwlHOe2gPV7ckSrtMFIdkJAZ2+JkuqI 532SBBdoSF+YRS/Uy70MUp0t4vq25tuXe8GrYSK4P0ckBhQvzp8dRm/QtRe8C7HObdieCciNZOJB wBtB8EIscyCjqsH7l4fhsrbsvDcRSkt0rrxoTEB4QyBwbQbiVyih+BlgF6uUFRDuHR8AfeIh3M0S iTLi9rQf7YqnFiZBph0OdtMJ2S+haTEOP4BuIRBq5MqxIcT6NezAKXqxN2ynvsQh+6tnBXSvbWO1 XYWb2/Flv7Dgd+u2NWmMQIKNoRPTi5fQtD5z3ct8Vv3eFvOlrZWZtT58aQu87arc24qf2RmMc9qg FTDOlewEgmnscwzGOc/BPxvX8QrGOcdXPY19jmE3LOFIFunscwzGOehNWvscw85XisjsV+uSAFGf wnIyyHrGQWhqS/lyVS1th9tD12nVGJf6Lg2f8ZlXs8kP59eSO3dBnMMMmFdTxEVjdcsleugEl+zj JQAjYlQoBxi4UQxG5QCjORcjARjxUmXPARZxTuoLbJwQ6R72oEHsWnIIp0wg0pp0wLvem+acFDgk eaZJ52Tj+BXSOm/KgAn7OXVfPHbOZNv0HQwLDyklZ9AJV4mlHKh8m93ZpG2HIfo2+8ugwT4kTypz R8M2TpCkK9g4o65SE7fr0G3qjG6zH7xwsQcRMT5MrivYF9qdwGI37kBgMcW+Neiz4qJF5NI0hBl/ IdzDMdBhD06TQ3hlK64ir8z2HME+siEr7is9E9L0/KVJ4qOdmRW32sEolhWXHEwx02x/b/CbOAGp QE/1B6a9b86HMn6hw+XwcLSjsuNIUHJDgWFXaDxA1ePuSYdvseR9QfMenY5n+yddrTpj3D2qc5TH tUcdCYZw5TbtLd11bAiJSieOk5k2aKEJuHBQWl2emQuZ9HuUCbRCHRqGc/UF5gHya8qC2sGTL2EX dvBflyv507k3nA7qcFN4FO0fkuLYb2y7EHotXrmJozogUWXTwmMTuk/r703PFI7Cj98HNLVFSHhz 5xiJej49Pz+EpvbGYd9lWmVu4PLBY2NS/77ZQTT750muiFd6jpD7tJ0nxiZB0gDlYreCDnDGQSR9 vKolDDtL7iTBNukunWmjO1SXF6IOUD6RIRt5wdHcVhQZspEXSv6QRmGRIRt5oaITeWb+fFheAKe+ fP35sLxQigo3lak/XwwzUWp5obqZKEmC4Uh5oZQ+rRuLvIDfm2oiQ+b5pKNFhmzkhRCabIJyBeUF 2rWXOWcwqIGsWPJCpNdQ4pzBEfJCb4BpFUSGbOSFEp1MOEpkyEZewKaIaiJDNvJC6SnmAFhp5AXs DMsWACuNvFCyg9FXFBmykRewKaKayJCNvFCKyCGZTpEaeRx17DcVRYZs5AX6qB4pMmQjL5R817Ej RIZs5IWSFz8tWmTIRl4A1VBVkSEbeQGjqSYyZCMvOOqHiiJDNvJCqVoYA2enLDIaXCx5oURnXYxv YmCVF+izQAITA6u84Ng9edC5xyroXBx5gZ5pCUwMrPJCGE0sEwOrvFBiDTeVTl7ALiO5Rv7wez8k MzFEywviphmQF/wu8bFNDKzyQgln9osUGXoYTAys8kIYTSwTA6u8QAz5iU0MrPKCYyxOHTC3urxg 9yapiYFVXig5oVmSmRhY5QW8SSc3MbDKC1ilmtzEwCov2KaIpCYGVnnBndDJTAys8oK3QicyMbDK C3DqTGFiYJUXsOk7uYmBVV7w7DeJTAys8gK+SpLcxMAqLwQMK3FNDKzyQsm+8puLS5InL4BzUm4u Sd6OXiJ5PPJxSfLkBUc7mItLkicvYK16Xi5JnrwAK3RuLkmevICPHHm5JHnygu+9ydolyZvupSoB z1O7JDHFGkrvkpR5rKFa8kLJy02UvUuSJy/AFMjNJcmTF3xaDnhhjQxdkjx5gVhxE5gYumPJC45N OheXJE9eKHnX47J3SfLkBeeORy4uSVUSbGTpkpRvgo0IecFTEOfgkuTJC8C03FySPHkBvzd5uSR5 8gKeaXm5JHnyArat5eWS5MkLtJYjc5ckT17AR468XJKCYdpyckny5AX83uTlkuTJC9iEl5dLkicv IDT5uSRFHaCIyHBo1RMQ0skG6vH95AzdM2ruJ8fB593rfOhof+TlGwbi4fIEOazZwUwcMeKgdrQb TyjMETRKe/vQ/rGB3sd95/vtuz4nl28G7ic7i41dwTnmMlAuRgW5vyjNH5TPDU0eJTKHG+Y+6+sK vkj3EdcV0kW6T3pdofb9vSzDUGcVfKYUGYa6L70yenrshumFX3FtBIwRWODx7sD1dbyFy92H+qei rq/32sGzcr6+ju+u2/tNBtfXmY+DiyQoQve+63Ky6+sGRIw0ldGJk6rvimF0xLH2/dNwJpqSJ07P msGkzr5rbyzX131JUdk0ZpdeEg+fOjQeiK4WfX19+lwvOrueXsDZP2EV8CUADYQdk33dpG8jBrs5 Y7TPQLC1IxHdfNmeApCZfQptebenDpG07L6b6wrc7huR+qT5eDfXvWvreE0L3VyvEusyzs117yX3 XEYS3lx/+aB44PTc4Sva1fPXVzMI3AwpA452+y+oR6YMgF6bgU3Me29i7mPxlB4ITS07Kb5rfmfi SJq75tvB9G22i5PD+88wbEnYASZJSLS+WPHQXFNEtg7Q7e2dveOqp8KyjV5Zpt6OUmFRFvacrN74 lIHQBAIFKB3a7f1pQuREKSZt1RBzoICqUQIEd2UMRglAaGprm4Swtsmah871S32Da4eoq/LbxyYN S2I3gzgtdx9rHwEdyGFPnLbHJvMbPuLFpeuH0PZvzeHkQljXKS2NI3EahzrrtkDbNQe7+DCOsecm kagcwbS3dvhSe5OOFcFURmeX+Qk4MWnVJOlLVOfwkWOlfxIitC2BmGz5OtengnQ9K60K+9VA52LJ vo5TXyhLUMqkguiV3T8l97SPzIZuelVQ71AaO5Lw8ZzLpVO+uLXWEQgMhDjxwhKcGE8ehLi1c3C8 wvq07v6zE0rn0JjOwBY4KovVVQJYrRHgSSlxuhB5cmhsQj53dPUEW7oQ6cKlpQMwRyeDuRZhykCU Xy2UmUru09ExYHYa1JZ6gEHK6AkLvVrnTh91QvXPu1PA0buMTINudhaCJmm+OTcAUoJgQawfOFks DzmjFHnq7E976qw5+2rHVWdJqdi17/Rqe9fawPn2Lv2i2N59+coVtD+/uOplWIyZp5DKsAjrspOQ z0mbGK04mX7pWlS02ihXK9aT2OK5nowuLbGfxKofw/C2ViE4bZLItBVyVNmH2/QnserHMCx7xj2J kSi0cYyJJTfNTqLMYxXjNQWOYfjyRRYnsep5LR0xqrq0hHb2tYMM0lJFUQmhyVBaqigqEaZFp7EU Y+Sw7CYHdFiFpZ6xtQnvHR1cO43j2bhz35doZHb/AVhTe0OxheF8AId2YmpjDCwc0KcxxBamV+g+ d4Xuy8R+A0siSxyqo8+PD6G1p30qEJUwwn00dtz8kAqrZwwMl0tj6FTWPoL3Vuw1ZG+vsjJ6c24O /bn8kre9BlKNJTw44ONgjFRjkD9hDCvLAjGTqyiueiYXyI2VNXQMuLxvJHB2QKsAOtJNrOmHQ52L 67tYInFu/bbItLfKrnaK8sTG3Jw3DxxDfqUcc4zZuZ2z9vQr++FQKc5gI614+OQFCZrCkSzGBi4u MLHl9sEbtVw6wcYcwRPi4B+bLXc0FZx9jtrBbmtawvE5Le5lO9+p28eg/o7+cXFu/NYcibs9vXRs 1LWwO3bwiudVVTw8jWhbv700wHKaP3PrghQ4zZPdkzUVamIDXykyu0Iln0DawBfLAxDMRBWdAJGw 2VPz0tDUnahjZuCMWfLdwsvumBk4Y8Lrmcfd+MCpxPPoSpxwm+VUUiKhWTK/Gx9wXMMOMGx3XeQz twcPx894QKetlr2Q0/EzHoB03ec/lWiQVX4Y7SSTB8AnYJJYcW9rJEtl7SNKwvOJ7RLPfERJ6F/i OcPmeoXVO0DVcjEJ5Aj1e2QEEoSG/EtKJFhjRi7pFf1LiItiWpf0oOsU7V+CXaewzsbxnop0MfGn QPL7lwRTIFXUsREfqKxc0ituP7STRTUXE/u9VT2VhJfKRULCxgF7wZ5acS0Yi9cc7w9i+u67dOlg r+d7MXBlr0CC5++zNm7jUwz+inU79qdX13vsCQXhlyHLcC/JT4y/DnbB+z3Tb3+dV11fDrTADt52 ODIruiLuGXHmwuxJEk+6T9w855A61+MtxKBgueD+0Ef/sCStUXqBOYH+7dLwRfchkfoBzYhXnB+O ko6AA8x5t6yXqjx9Vlh1f8ABM6jfrg9C4OCjbrTpo0ThCAHPZfhKglRLF852uI2/QFS1ECMWWPVC v7uI7iP6A1QmODMN3hFo6AWxd6RrAr0yMwv4q90uersle+ROTfUOHDl+RQFxto9s5t364Fqk6Uq+ erxjss/qPkuphiopf3C/3D8+qXkgqsmo9jyJoFaTYpRaRz+4pB4dNc6sdQW2RNTrZdnTlcAmvW9o 6ByeS+BZd8Xp/QlqyuBMOFBmu1FMnJ61E+ZOrC5o9qerS7L96c7yWaKYnexGsjh8gsUGcoRCGxpp Y+X0IWjytIANUfLK1XmRPO4Ead4Hq6Y7507LFDnnDqy96v6guhlVLtC9wYZsI+g9VTnjFpmMrtPX +Rsw007dRFMFrfNoiQViV27TF8r2Y+EF3u5e++2G5YR6u09dw6bf/fL+Q+1niCL34g== uNCH91u07A3NDlw+eRsvHp3ge92LV1Ise7qRyDExxJwCv8EyTSav+wdb5UQSpPqg0e2tEfZejM6/ VNn5vbPXUdl4Pz6igoAAZ+ixq3s3p6S+s8Y69e4fNF6aGT93ZXQNYb0+Zq+L04PdMEePYOaipdO0 dY3TS12mG9K+z14eLqnAtKOC503w/x4sPWUYllQ2VfRn4Nidq+s3F25evnR5s9xXegodGQdGZyVp aXPt2tTN9fUX11+7PXFt9c7G+ubt8mB5YPT4+OysqU2sr15bWy/32VcGvDHu9R82lCg9tacwm7ho TV+Zef7Y8PmJi+Kpg0E1eLcyM9zeP7AftpZ2tNsOtXesS3OgGj0OX6dAAmin/CRC6lp8TOt5fmL9 5tidyd4j8yew+jHg3oHO+5M30HkfH/5HZ7RBMA6/sjx27qh83Jg7NNKHvq7CTMT5eO1wBLTEUjkB HU5Zd7uzvXvoqAodeQnIBhUAaHYH2vu6Dx2C4sPtvS/sPdreuyochR9G2vtHlbX2viMnZ9p7bjx/ s2Lis+q7ZcnJc5LvbomPZGjDrLVbepJY4BXx+hB4T/aNDzs/jMMO5v0mj51eGnF+mx3wfkBv7qVR 54cFyf3hrEiS7kwP93hlNOrp8T46QO9ZGvv0rADnqB47qPy557vRS/sqOklNH5PwV/tgtXjIa3wF PaFeQ2VTOK9Dj6MQn7gCZ0rTNj+bRO0ysLgwgJ2BEIYb+9DXJdxuDxmggcVlxd0je+G4+jz8CgpW Ae15p+b7K+jdTwPTJgbOD3W6FoZXo+W6c50d9B9bH0MMJIjEzXGvyaj2orxDqjXpaBV81oFD+9df nJhdG24Pysuo13Ig8NvVleednfS86vb+PD3JLizg9bXPXoIvLA3Ys//CSVG6MD/bjT4tS/ZOc+Gs fNCaldF4XTiP9Sb2lzUVt2GP75VjuMl+xPhBJIJcOTlgP35lWXQ+nfXm3AXP1RH9sKZQP5xtV1Zs h9Q5+dyRGx1BUdk9mjvLGW1drJY4MrTi2EvnBCwsQ/DpMPwZIctO343rC9ErjmcyJCsOOCei3crZ o49s2h1eWO/BLz4xB5BUK31Y0QqJ5fphVou4DWXkxOR525J6fgU2pZkusluv37rSZyuf8eoi7R2A vAa92GPEvQqCFpu+I9fwTNPGBTTJFk7a57LpuW47kYE2NWCLqr6Jh5ciZ1p2d9p/8A9IgMSuqeJa V9egs24M9thu6eCSNzjkFB/qIxs2sXqgZUfULsyNhE7v18fFicND18TwAkQnCwX5+yBZodGxnXJo Pyv04xO7vX9fHxygzVheJlb3TOX3X6RUJmG7sGtkojV/wwOv4vcW74Xi+l5twFNF4TKsCBTXOjYn UOWbvfQuixsYGjnea4fpJm3Mvtwn+Nt45eDSSg+SktVjt0HttIQ27otDvil763lQO90h+37nafkw 5ewHZeLk0AJpoETuXL4QbuOOr42Zzhn6lVGP73e2cLhu0HdUBpEWZtVML3wS3LIBXGZrttHk7Tt+ Aw6fN8mKQ/TDvhMt7mFPx1D7OTTd907evHNuWMbvCP6h68K+Yy+jRVSdRj9Igu+Q4sgYwBa1+yw5 zwD5vUMTt47Dpt4OP5yCH54nPxy4unSU4g0Rn9HY3No7tTaAe4iw6s/vO7Yhn/Vd58ASPOHl1Ji+ gE7gJ++gxk8t+xiJHd+dLi3fVC44XRqUfJSrN6LIhgk9JF96gSYQzud48tw5d0hxCCT8uuYRCMdg we46asChrvsMNL7X7vrqwhFYcfbiKUDYQlEuVKAcY7UHo7trZPlMhcE4dCfQpZKdN5h9MLB50G7j ZXo0D2wccxs4GWig5AsCe3D61PV0M2L65RtRROCxqUaHr42Vm/E7ghvwFpvpC7dSduSV4Ih4RASY tvhyBaYFJuPiSuS8xU366q3f8hYb9dRpr6o4NTe06Kt6rb1ik2xv8M291CpwaoPCNXPk1Cpd9dTL 7vw69TJN05kZW+1GtlWsH4dLyf6FCpvRbh8y8VvobU3U7nV+38bL5FSyf/iU5s+u7N7yefmWSKcq AyHc/tSFtxiM5taVHv+m3mdru/HXcQGfAAT3yQEvJyst69JOJWfCmzQlLfh3akpUoA5hB6cE6ocz d84ecn6Yp0SVgKxtnwW6aezTh3opMYJCPT3lHcbPIh4uoddoet45J04viHAg6rXP9dNLMj7mB9zS kYSJhQH75Lo42+/stnd67M1scV4g8gKxNSweE7GdEf1Zg68nSbtIxN4kkp7cvU/oxudfPNNOzeIB 7aUFIJ957MD62e6L46/oUwu2vaiCn4qnG6H8Tb1Q0McGMmwStbckBix4MAWWOg+cG3/FmLs++uLo 5gm/a/2c3L168rKrTFTcSXaOHuQL832UDEGkqgvHSFp1MMk7U6AP/BSIVOGddbE/ByiATmF1Ev5k j++FSxqRF4jseWVJACOXhC8+4h9c3Vc/Fje9KXDlvExJC5Swe+WS6pyIT2u+2CdHNj1RIXDJMnDD 0tVpgaHMecXP3iJ6gct7FztdvcAtR5IndrN+W6bV1G6cgxGRf+gavkWGT7Pk/XZfY+zrcvIlkbzn SFA5hNq9IeDTNxxzT5PX3rF5dbh/IP30jOhaVzvdP8eInRO7K7gqd1uBTtaZoMKdlC1JsNjMDXjq NJ/+YF50D/ISrST35UjGaGxlIvT+5VHnLD/eR8qIWg90BbaS6BZo2sAEC/q0lcnNNVqXhoo7UNHx 9dt3ruMq2srY+qXLm/Pnf7KO+iSVyX8i+g/+GlZZks2yrGnoiwal8xdKT3Xh2mWpuzwP7pIrA6M3 b09cXr19+drm+Zs/KQ/ispNH5pdmJ8qDZVJ7BdU+UO5CNIkrqDr6qRur8VaAUiSUiuVR+HPyx0DE 6HX4jDZbUZBESUYUiIJpmrKqoA+WpVsSlIiSaqjwi6pIioE+yBrUQR8kTbZUqXwSN0l6g5r+CXw7 jD69WlbKPy6r5SPlZbV8RiyvldCvCJtsiYKhaWpZFk3BkC2tvAGFGipUqcJ5qlCSJUFREYGoULeC X+lHJAURqCj+do6XnrqD/xdJnwVDFTURMVvQFcs0oVOaqZqiDB9UTVehREK9VKCOLBomZomMvssW 7q8saKhvy7hXimYIlmQhOgxDQDVwf7xCU7dJEMsL6XGfh4H7McKpC4Zp6bh5QKcaaDhEq6yIkmAZ mlS+ShcifqmiruBCIEwU1bIiIRI03cCFuinoqhWo6RbSbWoWmhGSjBHj7xQh6PvF0lNLznSQ3OmA /l1DQ1Xu6i6fPIFrRE/nrn5DMDXEFzSNB47fvnl581K5a2xsdBW9Vseu3T4PlYMT2j/xApg6ViJH hy4m49OxcjQ7oiT0Bm5e2ywbElpMMR13vBl4LLsJWJIlU0DjqKHZjoZBVvBc8ApN9PogGnKYf7KE phY85OIAxKKBmkCfYcIYqo4nhFeIppZkGHi+AYmyJeE5iN5jGZfJaGYh2vwV3UK6SclCk1SyPOS4 MEgRmY2ypAqibqHVVZIETZMNP4sQdYokkxZkxFSVrukrRDUtjbwE1OOKgFCZpNBB5KsZxN6I70jk NKKLqYmUx4tCvyOIVEGxd0YgF2+eU5evrg/SG+mx87duw06KuoCqTZeeGphY/z+XV9ePTY+VTyIC l93dFQ0R2lit8pky2hQUaPnkK9G/w3dSh/w10f6FdmkDV1XRf6gCbOyYgInzt88PoloGKjo5+9T3 zLBFAftTcVuuAnm3nwZXAhRxcaVBwYgoPYqaiLJCUQVRtigiEeWBIoAlJxQBRI8BFoIobxQcC8fC sTQmlvqsMHVbLXNFVOddLCdEkft+togqocgQUXUUmSBiQZESVywUCRAlaJ8dHWMLO3zQgmBHRWhp ad25q1TatbO1NbJay85du9t+1Gmanc/tadu9a2eoTuuuto7h+RMb//ZvGycW54Y72p4M1Nn59N7x y3ff+d2f/vM///T79965e3mis21Xq69C1+m773/8xf2//+///v3+vc8//uCNy+N7n95JVeg++8uP 7n3z4OEjBA8fPvjm/ifv3z3d9XQrVeHNv/7jwSOXBY8efHPvo1+c3ruL0NL6dBeq8PVDH5sePfjH R3fH2zCell3tp38ZrIDgwb33L3c+CY20PjP807/8I1Th+0fffPLGJG5k5w+P/+aL7yLG48H99y93 IEpanty/8eFX4SagkY/vDu9GNXYP3/34m0cRNb5/8Pk7c22tO1rb5t75/EHksD+89+7iHlRjz+K7 96KQQI337BrvZVCjFpaalNbubcuujsvv349qxOHYjp1tk298EtUIDB1wHbG98/L798KNPPSGf2fb +N2P6PlDKnz91186U6hl197Tv/jo3rd0l9EE+uubZ91p2Arz+I+ffv3dQ9LOo4cwCX95ttubyvAu bL71ly++QnMZ5vFXf/sYT2RvrqP3aU//8k9/8+HHn9+7h96FD3971/8y4Hfy6fahYxt333nvPfQ+ bRw/2OF/oUgzz/xw//DcwuLC3PD+Hz4T8eLuQG/+k7vb9uxB7/WTO1srrBFofQAIrA3Y7AK6mdJT J2dshREqcdRFR20VE7F8YHWoiEABpVPFD+Q/0Hz6FWmyIgmmJUllWVUERZVMrFl0CxUdFWpGWZY1 wTLB3KGIgm6ooAs0BUMUDa9kFTSFkqBKOlVL1ARNAu2U25Jb4iCE59xCTRQUBcwhTkuajrpg0Pic EnjOJcut5pLuthTq4SrWXmalxBaJErs5+XgL63gtSt8LivUo7WwUf+jimBwCxW4yHqEnE3IJPZmU Tx0rla2fozdvnneUz6Oz5dE7t6+Vybt++f9Z95TQROVc7hpdu3ZhfWV01lpZuHBr/eb/WV9bmVv/ yQqpdauiDccgI4FvToCVFxCK5QG4QAEXKeAx/PX8xvrs5tr6a3bB8dvXbv7ELUDNRmv5rajPdwIW y2ytRnqU1Uivg9VIj7IaaVFWIy3KaqRHWI2MKKuREWU10qOsRiGKbKuRHmU10qOsRkaU1ciIshrp UVYjPcpqFMLekFajqGlEF29nq5GGljhdq2w1cn/nVqNM2k+DKwGKuLjSoGBElB5FTURZoaiCKFsU kYjyQLFVL8XhVr3sOfXBsvXYacE5Fo6FY2HHUp8Vpm6rZa6I6ryL5YQoct/PFlElFBkiqo4iE0Qs KFLiioUiAaIE7bOjY2whqPflViNuNbJrcKsRRQW3Gm0Lq5Hp6rgtz9rhFcq2/t1wdfm6raPXXV2+ 7lk7dEeT79TSXL2905JXYnnWDrdQ023du9OSLtvqeRefUwLPuWS51VzS3ZZCPczFatScfGS3GkXx x1ccj0Og2E3GI/RkQi6hJ5PyaXtajaQGthqpRoS63yvMz2qkGoYgGZbhsxqpOjYQ+Sw8bhllCgIC gzYj1QTzkL+aXUS3ZmB7keozDYVpIZYZ1cAWG8VnL6I45tmLVDPCXuQVUlYg6nHPXg== 5CHy1Qxib0R7UeQEoou3sb1I1WFxMyrai7zfub0ok/bT4EqAIi6uNCgYEaVHURNRViiqIMoWRSSi PFBs1UtluFUvS059sGw9dvpvjoVj4VjYsdRnhanbapkrojrvYjkhitz3s0VUCUWGiKqjyAQRC4qU uGKhSIAoQfvs6BhbCGp8ub2I24vsGtxeRFHB7UXbwV6kWqDdFvWyrEquHt8rVFSieVdNR1Eum0Q7 D3o1Q4RIXU7JKmgKbb27W0vXBE0EzaPbklviIITnvEKTaN3dljSVKOY9fE4JPOeS5Vbz+uO0FOph HvaiJuUjs70okj90cUwOgWI3GY/Qkwm5hJ5MyqftaS+SG9leJGoR9iK3MEd7kaiG7UWKpYZvGXmF tCkIkRiyGEl6yGJkF9EWI9TRsMUoRI1tMUIPhi1GHs8oi5FoRViM3ELaDuQ9TlmMXES+mkHsDWkx ippCdPE2thgplliWDbGixcj7nVuMMmk/Da4EKOLiSoOCEVF6FDURZYWiCqJsUUQiygPFVr2Uhlv1 suXUB8vWY6cB51g4Fo6FHUt9Vpi6rZa5IqrzLpYTosh9P1tElVBkiKg6ikwQsaBIiSsWigSIErTP jo6xhaDOl1uMuMXIrsEtRhQV3GK0LSxGkmnrtzXVs3S4haph694l2VaVq5Ktnxc1W6fulKyCjtQk mnenTLFkRzlvt+SVOAjxc06hLhG9u9uSbtiqeRefUwKWDpcst5pLutNSuIe5WIyak4/sFqMo/tDF MTnUsZKUR/BkMi4BuQn5tD0tRkoWFqNczEWSIRiaggZOswTZVGTyttmFqqoLlmXlYi6SdEGXFQ/F BrHkWKaqllUdzQ5Ddc07pNCUBYSZWFeAQgUKDQnSaeHMWaqM5p6pBGq6hXSbMLNURKiL3TYt+Siy LUaSImiSjN4BXRUkCVWgOQRsMw2JtCpL6EHEQremvxCxR7LtQN7juiAb6DW46kNE1wxib0iLUdQs oorpebT9LEZaWVX08pkyrKBqhMXI+R2+kzrkL4PFSFfUuBYjF+op7GVCRt6EpaEkW6qyoiQTkjIn Jg1V+RGTgKS8iYlFUn2IYSSpnsTUJKn+xFQhqShiGpCeMEnFEtOA9GzV0V7LCI1Gz1a9rE3s0FDE bHF6agGnpzpweqoDp6c6NCA9DUUSp6cKNOx5rBHoafDzfLH0hIkplqRK9BRCUhVi6k9STWLqSRIj MfUhKRYxeZOUgJicSEpMSR5UZUJMJlRlS0liwhI0XskhI8orI8J7w3HxwFDNz4P4lThOIxgqe47Y tZ9s6zh4nLihYKjki4KhdVdb58Tlu78lji0YPO+WMArsNfbGBx//jbjKYLD9ZZb794RQEB+yT+6D j5jHPOKB89Zm0IHG8ygL+Q09evD1p38MuORE+JdR8PBbcPLxnNEqeJvRKP7h+RXtIK5FUb5nFArP U2mH46xUpTrttlbZ/clHkuvEVsWhyofgvoOgsotWNILWZw5WcvoKIsDeW+BV97dazVMedBV97III bA+zyj5ofnC86yp7tQXr1/KTq1S/kuddVvXj0ROzv3H5GXe84s6H2PMt7nyO+77Efh/jvu+x15O4 61Xs9TD2eht7PY+9X8Tej2Lvdzvi7qf4iVj7NXmk+nmgrp6aiiFYkgUOZsTtyP0uqoKuWXIZnIaw 55ligX+OrpY9DxenZJW4JumSIXuFimUImmlSTXklCBl2LHS+y5qgKBZ4yNmtqIpCrlt7fjNOySpx ZMI0edVcut2m6J7l4pzZNKxj98cMsIQuickUuGMfny3ghpmQMQhhAtZsTxdMNb4LZo65gNFg2g5z 4NZnSTiNglOoqkZebpcQokKHKi4OhFiyFHCxtCBetusj6RWaiutNiUkkfpey63cpSxq4WAZquoVU m5DH2lQ12cOOC4Mk2cmARXCDBM9JzRQs1ZR9PALGGaJht2DaPpZOTX8hmseaJLn0k8clxHeJ0O8h omsGsTei42XkPKKK6Zm03RwvJROWL6mi46X3O3e8jAuNQ0m2VGVFSSYkZU5MGqryIyYBSXkTE4uk +hDDSFI9ialJUv2JqUJSUcQ0ID1hkoolpgHp2Wo8R8dGo2erwRxFthrS0ahoEnzA6akOnJ7qwOmp Dpye6tCA+wWnpxI07HmsEehp8PN8sfSEiSmWpEr0FEJSFWLqT1JNYupJEiMx9SEpFjF5k5SAmJxI SkxJHlRlQkwmVGVLSWLCEjQe19EijSMHd7zkjpfc8bICcMdLpvrc8bJi89zxkjtePtaOl7KsOo5k Inbmcb4rlkV828BryHaUk4j/m+fh4pSsEt8k203OLpRMlbjAuU25JYAMnnG/yyLxa3NbASc47Pvm +c04JavEk4m4yLnV3H44Tfl6lofjZfOwjtnxMsgSqiQuUzpWkrAFPZWUMYjWBKzZno6XWiM5XqIu hB3m3EJVRZzX5DwcLyVNCXsz+qhxvBklXQp7M1KFno8i9bjnzUghomsGsTeiN2Pk4FDF9PDk4c2I Zrhq5OrSCF6uUuVYkt7v3KUxLjQOJdlSlRUlmZCUOTFpqMqPmAQk5U1MLJLqQwwjSfUkpiZJ9Sem CklFEdOA9IRJKpaYBqRnq/FcCBuNnq0Gc8HYakgXnqJJ8AGnpzpweqoDp6c6cHqqQwPuF5yeStCw 57FGoKfBz/PF0hMmpliSKtFTCElViKk/STWJqSdJjMTUh6RYxORNUgJiciIpMSV5UJUJMZlQlS0l iQlL0HhcF4Y0LhLcpZG7NHKXxgrAXRqZ6nOXxorNc5dG7tL4WLs0SobhuJyRUGrud9MO1yfpTuw/ ww7p57m5GF7sP0l1Iv+51RQ7qp/XlFti4UB87ndIskzcz5xWIG8s9irznGckL+6fS5NXzXBjFjpN 0T3Lw6WxeVjH7NIYZAldEpMp6NEEbEFPJWUMejQBa7anS6PeSC6NsmkIMoRiVNBA4WnjluQZSNLU BUnWfXEk0SQTVNNQ/DEf3UI6OiRQqOimP46khZoy9UBNt5Bu0zQFRTUMfxzJIEV2GEnUmChpOnaS VGQ71KbHM0MQTVMlqCRBteiavkIVUo5Llks+eRxRaskk4KSLyFcziL0RHS/DU4gq284xJGUItYtm bSWHS+937nAZFxqHksypyoqY9FRlTkkakvIjJgFJeRMTi6T6EMNIUj2JqUlS/YmpTlJR9ESSVCAx DUjPVoMZ1Lcaj56tBnPI2OL0MACnpzpweqoDp6c6cHqqA6enCjTaftGY540GoaeRz6uF0xM+zxdL UkPRE0lMUSRVIab+JNUkpp4kMRJTH5JiEZM3SQmIyY+qNMRkS1J6SrKiKltKEhOWoPG4DhZpHDhi Ooi07op2QHmygr8KuMREOLhMdEa5rLTu2tO/fPf9sAPNB29cruBvs/kWuPQEHXTuf/J+BX+eP376 dZQD0DfgL9TtewA8kn750b1vI12SwB/pTd8DxOOpgj8Sgodfowcoj6SaHlXoAcrjicFjy+dRxeIR Rnls4eZreZzFdTiL69CGHeYOPoNYxOrB9+Bvvzn+Q0QQq4fgw68+3NiPeszqgeh48DF7ONoegjE8 It+tk8dlPvR4/Y3Hz5jjFXc+xJ1vcedz7Pcl7vsY+32Pu57EXq/iroex19vY63ns/SL2frQj7n63 o/kcLhXREFRZUsuqaIKjivsdkiCTmH+WKSgWakIxLTvmH3Zl0SyvZJX4JeEYY1413RBECzXrNeWU ADJ4xv0ugQOSqJTdVlRZt8P4OcjcEnjQockt9PrhNOXrWR4Ol83DOmaHyyBLqJK4TOlYScIW9FRS xqBHE7BmezpcGo3kcKkoqqCJqCHXW84tydHhUlEUQTFEyedxqcgKuBqqPu9Ir5DyowQSVVPyOVwq qibICpBNV3QL6SYVTdAsyfQ5V4YpIr6NioKI1yzT53FJMc3zuFQUE1FqGT7vSK+Q8qOkHvc8Lj1E vppB7I3ocRmeQ1RZPT0uM341DNPugmKRUJ0w9G5hjm+HYUS8HboR8Xa4hfSkRySG3g5YxUNvh1tI N2lYEW9HiKKGnIdRA0YX5zwVYUMQ8/QAViQZbX3YA9iI8gD2fscewLgO+cviASyLZiIP4AZRLMbU cOZFUmIysiUpEzIyISlzSpLRkxMZCUiqAyWM9NSNEhZ6GoeYOlNShZ5CKImkp0BKwvQUTUtj+ZFs NZjfT6MRUzQJHDhw4MCBQxAaZ3tqtF270YhpBHoa8wxcOD2NIzpFUlIIPVUoqTMx1SmpJz0slNSH HnZK8iYpASU50ZOYkmxJSk9GJiRlS0ZikuI2Xs3Zpqb3bcvOXZ4vz5623dU9fyCcm+sqBL5CVR2L nsTR4lxPpPfewX5L0Q+Q0HJvfOA5Ot37HNyiIh2dWuzIdTjWnedIdf8T24+qJdg0eF1BZdqz6xFx 01ru9yNo2fUvI1ffioq7h73A3ro68i9UVL8dO9tG/vUPUT5j35M4fX/41xEvaOCOnaX+q3/4n28q eb09/OZ//nC1v+TUb326e+XXn1Zxwnv0zae/Xum2fd7AQe7fq4YwBB+5f3dc6hgiJFIee9hBsUYA RuIQiP0N2QJIuu6MjPEpbW9J1vCXtjMmqy+m7evJHLyTuJLGiA0KnqoxHWEbrXqsrsZjZMxhijkJ Yk6xuBM45usR8+VjfLU9b9maC0fAWbbWsvR1wFe2+qIX8qytsaSGHGurL9hhv9pa20G6zWZH3K1s R8yNkjwQYxvGD1Tf5OvqUaupgq5rWlkxiVuo+92wnRbBJUxXIJKkbjs2Ylcl7+sqcTpTdEWh6kiq oCh0M06BiT0R3a+W46roNKGKjjsjRuN+XSXeaZgUr45LrtsO3aFcHGkfd46x+88GOEGXxOEFei42 N9AzSfiBHkvAke3pNms2lNusJJc1VSV5zzXBFCUTf88j17rbvCyYuqVjnIolmLKOaFAUQVZIWnRZ FCAQLV2kSIKkqqhMVQTLFDVfPUjTTrKsu615RR5WTRAtKVhokxLDC7C2957HUkiT7mcqdtejJpDq DnDWPnaqiNhglM+U0YCZesjFzv0ZvpIq9t/aHnayrGqJg2ymgcJ1DoVAfKXL48OTTPq+TbmRU9+3 Czfq1v3GZEUh3W8oVhTNABuavPs0cA4Q4Bwg0OTdd4FzwAXOAQKcAwQ4B7Y4ExzgHCDAOUCAc4AA 58AWZ4IDnAMEOBO2Gu/qa1HAmbDVSF7ZxQJnwlYjXRYoFjgTtjgTMHAOEOBM2OJMwMCZsNVI1x4L BM4EApwJW5wJGDgTtjgTMHAmbHEmYOBM2OJMwMCZsMWZgIEzYYszAQNnwhZnAgbOhC2uUuDqNQyc CVucCRi+90PR5BQA34egaIoKAM6ELW6d504aGDgTKnGgqfjAmbDFXRm5Z28jRS4tENiZ8LjyIRYH Hks+JOAAZ8Ljx4fEHHhs+JCSA48BHzLhwLbmQ4Yc2KZ8yJwD244POXFgG/EhVw5sCw== PtSBA43Minp2vzH5UAgHGocVxXbfBc4BF5q8+zQ0c9/D0OTdD0PTdnwbQfXo9+Eg+LXy3DACtLjz STcUf9vuJ3embruFtPjcfichwMLc8P7nSNtJW4Y2n/khanHxxKabluCdu5snFlHbP3wmWcstO3eh Ng8e30At/v5PbnKEzz/+0+9R2xvHD6KWayYmCDf6ZFsHavPnv/0QtXj/726Khgff/P0+avvD3/4c tVwjPUIISAaHu6jNv30FLT7yEkU8egRtf/U31PLdihkdKpBK8kiQNiOTW5CWI9MMVSZ1j5PNIrJN r2WSjmgPE8FA6mZkTo1Qyzht0WZkIowQA0rdp+/+sUJmj3DDX3/6x4h0HFEMWPn3j+59y5I7BuDh tzgpSKkqgzEDfv3XfzCR6hD8j7++ea6/2oxofboLM4CVVJvgr//r14G8UH6+7tp7+hcxGOC1G8gf FWDBnom7H8VhgNuuP89UoNVS/+YHNXPZRALkozrXHTkfdj7dfe7Xn34bn1ZM79f/Raer8Q3X2Tf/ K+Zo0e1SSXP8w/XL6ul2arT7j4/uTuwJsoEl6U91eHDvg83+ABtanuxkSD1UFR59++lbywE27Gyb fOMThnRM1eDh124GI5rY+6mI/Z7kUep8siVbYmH2fvLGJEVuNsQicu/7yM2G2CC5OANVBsQScjuc ycCWB4sF3FxZmAds2bhYwMnYhQeMLScYCzh5w6BZxsxkLGBnL8OsbZtny4/GAiSHGmbtcyd+dz8b HjiJ3whrN//092x4gJq9//sTz+3MmLWIuX//0yYeM+bUc2zNOmPGnACPDZwxY07DxwYk+14Ozb7H m+XN1qXZLN8y93XIdE1wX96MVzBnqcl2vXUXxox3h/u/I8t45nvZPNnLctp5czon5HSqgTPYz3M4 g2XHBd+JMTMuPPr2v+njeFYnUTRgm/u90zhb5tba8N0X3oC5wl4KEdIm9uu//HT4GUrgQ6Lp8ltJ xWgHsLzX7pP3Wp9OLPS7ECWdYhVFKjaAEBmSpVOzIUrkzYANUSxw2ZBY9I9WJ+xIqaiooPwgbOg6 +2aydgOZhQNseLo7WbuhDMRR7cbVhEVkKo5o95cx9Xa28rKqUnSnrWVkJphR1eoohRkJBlKZFMNE hc1GMCGVTY0NCvcJBi02KMeB1AlWpTuLzv3Rg2+/+tvH7Bp3h2DKQhBKZ421+P8Nhgd2+4BNsGPP +IIykXiGki8+/uCNTTCTsFszbIKJ9eXuO7/7E5Xt2jbr/A7SWCcx6ji2ouF5yvzkGaHmcbrrZMYt Yi370X4ve7ZjMvsRQ1rsqi230KY9ysCX3nboy8adoTkyCcRP/S0ppmpBTldJlhUDZ8hVNFnT6Rzg piZaJvwLiXJV+OClzPWnmZVkSRAV2ShrkibIlqTh5LlOoWpBVlzLKEuiIhi6qpdVA5IoS0bZlAVV V1WvYLX0lC4Jko4IcMtURTAtVfPacQpcbOgpt0zRBc2SzbLTjKahX3TRdHG5BegplyK30OuK01C4 c9nmB5ch+3DTMTCcLlwERmn0rIrOHx7JK6o4Jrc6VpLxq2MlGccQpUl5VnxCccvNB02yfWedFVpD vDIkyAqtR2WFdn/GWaF16n+WrNCSJReRFfp7Bu+j+pOUKyT2ttruPMmk49uRFTl1fFvwoT59b0xW FNL3xmFCsd13oZn7TkOTd59Ak3efQDP33YUm7z6BJu8+gSbvPoEm7z4BzoGtdEwomvbMoMm7T6DJ u0+gybu/xTmAocm7T4BzoMm7T4BzoMm7T4BzoMm7v8U5gIFzoMm7v8U5gIFzgHOgybu/xTnAOYCB c4BzgHOAc4BzgHOAc4BzgHOAc4BzgHOAc6DJOcClY64l4xzgHGhyDnCrGedAk3Pg+ygomqi6QpNz ILL7TcWEJudAle43CROanAM1u//YM4GRA48rE9i7/1hyIFb3H0smJODA48SEZN1/bJiQpvuPARPS d39bMyGr7m9TJmTb/W3HhDy6v42YkF/3twUT8u5+IzOhPn1vWD7Uv/uNw4Si+k5Dk3ffhWbuOw3N 3PcANG3Hw9Ccvd5GwBb/241FnmFKZC9wOsmInLJtJxC7kxKZJEROlRHZTYi84GVExvmQF3BC5ESB 4+l8yP/XTYgM6ZD/L8mHnCDKvT8dspcPGWdDxumQ7ZD8ceL8B7Mhe5kJIBmyk7N489hQO3tSArZk yPf/+8Pf/HS5fw9j9mbGXMgPvv3qi7+8tTnJlryZORXyo4ffff3pH0nu5pq9j5MJ+eG3kLq5ZnqO uImQcebm2rlEkuVBrtps0jTIVZtNngW5WrPJkyBXaTZFDuTKzaZMgRydVyeXDMj5JEDOJf9xPumP c8l+nE/y41xyH2eZ+pjKDJdZ5mM6jV12iY/9OfcyyjyYT4JAxIKf2yzIMpuhx4IsUy96LMgyT6SX 0DHTpJZuYs9sMx7byU2zTRfq5FHOON+xnS0020SsznBlnDXWHq58cijnk+GWt9rUreYxs7y3II83 NpfVJZ+VMJ9VO58dJqfdMJ+dO59TRrYnIvdQmN3pjT7AZnbS9B+2szoVA6meYJDRCT4gF2RErJ/U TIS4MKnZ5GAOiluZpGAOiYY7MsjAHJ3SOF3m4UoZjVMqCColNE6Rd7haPuPEaYerpzPOKZtxsmZr 6vSS5EhmyGUcO0UyWyrjmBmSWTMZM6uKv4+VyJhSa1dv91GsPMaOCr56GmPQwn8SK40xMRdUVO3j LMbYYhAvizGYNvxmiFASY2zdYNPq+9ulTCbhHMa2JSauhYc270SkME5qNaJNUeEMxsktXJ7ZLPME xqH0xcXlLy48e7GiCpYE+VhlS5AMRcHJd91C0RBMQ9LLkmQIuqpDWlhR0EVFK1u6oKi67BWslp4y NEFUVcsr0wzBMHTVa8ctcLChp9wyTRRUA2F1mtF09IsqGy4utwCS7zoUuYUe1U5D4c7lkL246RgY nb1YYUheHMUqujgeszpWkrGrYyUZwxClSVlWePJiXSSDMDA6K0kvrr92GxCK5YGpm+vrS5tr1+Ax /PX8xvrs5tr6a1BgAfJrN3/iFqBmg+tHaJhzS4+sm2XN1CqmR3Z/3mbpkb+v6ohUCD25Qhqvq23N k/Qd36asyKPj24UV9el7Y/Kh/n1vKD4U230XmrnvLjR5911o8u4TaOa+u9Dk3SfQ5N0n0OTdJ9Dk 3SfAObDV9PEqCDR59wk0efe3mj52EQHOgS2eHplzAEOTd3+LcwBDk3d/i3MAQ5N3f4tzAAPnQJN3 f4tzAAPnQJN3f4tzgHOAc4BzAAPnAOcA5wDnAOcA5wDnAOcA5wDnAOdAk3OAS0acA5wDXFPa5Bzg 9gJuNeMcaHIOfB8FRRNVV2hyDkR2v6mY0OQcqNL9JmFCk3OgZvcfeyY0OQcYu/+4MiFW9x9LJiTg wOPEhGTdf2yYkKb7jwET0nd/WzMhq+5vUyZk2/1tx4Q8ur+NmJBf97cFE/LufiMzoT59b1gm1Ln7 jcaHorrfCHwouuseNHPfA9DMfaehaTseCU3b8erQOD1ljDBuBzvPKsI5ac4OzJ5JdmQvzjvJj5xB dmRfTHo7PzKVHTlZw774+U5+ZCo7cpIA+sFY/3Z+ZC878s8TBPsPpUd28iN72ZHtxATs7dZMj+wl UWBPeMCUHpkkfGBPzsCeHpk9kUSs9Mh20oua2Tnip0dmyCSSID1y7awnidIj18rQ0kTpkSMT6qRN jxyZ/CdteuTIREWp0yNHJlVKnR45KgFUBpm1IpJVZZEFLJRYK5OMZaEkYLkkLMsnuVouieDySVqX T4K9XJIB5pO4EDHg59knWcyOAVRCyCwZQKVHzjZ9J8mKmnGq0Yf33nsBt5ppWlQ36XKmKVyppMtZ tsqTLm+v5MDbqdV8Exk3/FuQT5r0fFK655N+3t0LstxhvETGme6G9mBlvHPf/x0erIxPGZ+/M9/W msOJiLCVp0fO7QSfZ3rkzCWjfKS4nCTOfKTjnCT5dHmMK6dHTpHHmKdHZkqPHK9ZxvTI8bIuP2RL j4yzLrOkMXZ6/2um9Mhdp0H9zKKDxFrdf19hyTpMVOV//PTr7yprtZ02QQN9urvEotgGtf7k5lt/ +eKrbyvzwactr93mDmyC2NO//NPffPjfWA0fbjmJZh+3+3T70LHNNz74+AvbqoEbxzaIpFYIwoZn frh/4vLdd373J8dk8t132F6S3GKyg1iM2jqG54m1CJt3iG3nveTWHdLwzl27237kmqLefhvsUIup LFGkXZ/ZjNjMUlrNvJaJie/ZZ4l9L8v8yNA4e2uFp0dWTcGwIFWsqgiipsk4u69bKEuCoSlqWVIk QZNNpayJqqCaGiqRIFOw6RWslp4yTcGSdarMkARdM2WvHbfAwQY5gZ0yXRUU1A+3FRP9IFOY7O+Q 2tclx6njUmw3Eu5XDpmRm4l34aTIlZIgR3GFLo7Hl46VZJzpWEnAG8jVnIg7hec/1pQE+Y8lsUIC 5HDKY571ODY0le9QSmeq7cuT9B3fpqzIo+PbhRX16Xtj8qH+fW8oPhTbfReaue8uNHn3XWjy7hNo 5r670OTdJ9Dk3SfQ5N0n0OTdJ8A5sNX0YSgINHn3CTR597eaPiQRAc6BLZ7zl3MAQ5N3f4tzAEOT d3+LcwBDk3d/i3MAA+dAk3d/i3MAA+dAk3d/i3OAc4BzgHMAA+cA5wDnAOcA5wDnAOcA5wDnAOcA 50CTc4BLRpwDnANcU9rkHOD2Am414xxocg58HwVFE1VXaHIORHa/qZjQ5Byo0v0mYUKTc6Bm9x97 JjQ5Bxi7/7gyIVb3H0smJODA48SEZN1/bJiQpvuPARPSd39bMyGr7m9TJmTb/W3HhDy6v42YkF/3 twUT8u5+IzOhPn1vWCbUufuNxoeiut8IfCi66x40c98D0Mx9p6FpOx4JTdvx6tA4PY0RZpxnPeZZ j7/nWY8DDfOsxxh41uNazfKsxzzrMc96zLMep2+VZz3mWY+/51mPedbj73nW4+3WKs96zLMe86zH POsxz3qcr7TBsx7v4FmPMQ941mOe9dhtlmc95lmPCRt41mPcLs96zLMe86zHDcI7nvX4Mcx6LLFl PaYHXrTXooxeI0FDs0iR0ChJsqBLBp4t+LuqCaieWp4vPXW8Em+7+tFoGSIaDo+PXWNjo6urdzaO Xbt9Hir7OXbHXlD9qZthKqdN3yzpWlnTrPKZMuq2ooTyN3u/w3dSh/xlSOCs6pqRKH9zI7g0pXS0 yoSw9DRkQlLmZCQmKVdKYtFTB0oYSaonJdXpqT8llegpipIwPcVSQhNTNCE2cGI4cODQbNBQ60zj ENM421PDHiGKJaZxTnqVjp31p6cKJXUmiYWSOtDDTkbeJCWjJFuSUtKQCVWZ01A3iLZ72FDDRuEa XwgQs0uFR+jLSBjA9vOjtt2R9iTfLSMb4OrS/HCUUat1l+/6EIHPP/7T7965G2Gtww== hkn6XpBtivv7/S8+Dl+wIdbR8IWfR8TGGLg545poIy8H2Vdi2n/Q6tWuZieGuy4f/OyldmK/Z/Av ePjtvf/4GXGiaNnVXtvJ4dF3jidH6zPDP/1LTU8L150EXJS++K5GbddTiNVPynZXYvZXI65ozE5z xHmxdc/Cu/8fi8WfuM/FbJ2Z9u8+fxvRjt2nGPxmHn79l58O7W4hDjE1/RWw1wvMmpZde5ff+rQW OZ47S+vT/Vf/8D/fVGv/0QPKT2Vn28i//qHafTBwlviF64DSsutfRq5WvJT26OF3theE8zqh97rC zThsjf/iL4H7Zf7reQ+pdxvM7L/5aeiSm3dH0Fs3HPv5saH20E0756IitSpRhvEIpwCyjnlrXk2L d2BFZTBl+9br9Dbq+LZm16Bc8QP5L2xXlkVD0HQFG8ksUVbArOOWKToqQx8kyxR0TVJRiUSMPZJp 2EY7pwQsloYu6GA9cWvpBjH8eS25JQ4+eM4t1ETBtAyt7Lak6YgS06TwOSXwnEuWU+hS7jQU6l62 1mURm8WakIfMVuYo3lClMbnTsZKUP+jJhBxCTybjUfG2ZiOJrVmOb2smxlpicQ6abNOaa2VJqmqu 9X7n5tr6S0zpaciEpMzJSExSrpTEIqk+lLDQU09KqtNTf0oq0VMUJWF6iqWEQONQstVgxGw1kuWC AwcOGUJDvdqcmEhowI2yEehpnCNN4xz2KlFSf3qqU1JPelgoqQM97GTkTVIySrIlKSUNmVCVOQ11 g6rq/8fCXBt4oIa5NmDfrWGuDVwJrWWuxfdS3Vu5tc2138DVYNt+xWCuhRu69vVsJnMtviOOzbts 5lp8UR8iNTCaax37K2tYC2J/ZTZ52vbXZ+fe/rw2La79NV71mMTE7GpMRsYdppiTIOYUY7iXTu62 uwbYqhfOPXPtTv/Lx2yu3cHNtdvLXKspgiHqkt/W6BY6JjBZVQXDUilzmqwogilKis+cJssyKjQl qpqEqlnYvuY05ZZQpi2v0DWAuU25NjIXI2VM8whzq7nEu02F+piL0bZZOcluuo3iEF0ck0fo0aRc Qo8m5RN6NCmntqkBV2kwA65mVjfgur83lQE3gcSUB1XpyciEqszJSExSrpSw01MHMhjpqScl1emp PyWV6CmKkkh6iiVmq8E05FsNZsBtHEo4cODwGEPjLDWNtgI3CD2Ns2s31JGmcQ57lU6e9aenCiX1 pKcmGXUjKRYl+dGTgIw8qEpPRnqqMqehblDVIFDdHNASacCtVJmKuFrTlNFCh30NGkqiDLidVOxZ 2gwDUWf9lhX3Up4bANcf+tZvEvIlw/MZcMkT7/sMTq0/aK+c5Q6e8IVqbtnV/tLPPqhyXdEfL3pn 2/jP/qN6ZjYqEjaJgP5ddcujF46bLQy7Y+pjjbJvGxJZjaC2mZL5iqt935Y19YVz3zZe9XjExOxq TEayDpMTup4pDP6jb9yY9CS+fdV43g+/+Z8/XLWDzddMFggXxv/wryNuwi47Y2F0BG5yHf3qyL+4 4eFJ0si/fBFObulddqffbvyy/vQ3H0YacCNyVZJg2AHHD7JyRF3Ut6NcRxpwo2Nbh5xWnFWvYtDq pjLgKqIoKKJk+oxqXqFrBbMkQbFE2lJmojqW4bc66pagigplZpN1UVAtWaZacktoq6Nb6BrA3KZc I5mDkDY6umQ5hR7pTkvhHuZhvm1SPjIbbyP5QxfH41DHSmIewaPJuISeTMqnbWq6VRvLdCsZVlk1 9fKZsoRePkUPx0p2K+DvpJL9D4P1VlIsRfebb9Ot5BziQfhEUjRFzQic/40AnP+NAHwICgc+BIUD H4LCgQ9B4cCHoHDgQ1A48CEoHPgQFA58CAoHPgSFAx+CwoEPQeHAh6Bw4ENQOPAhKBz4EBQOfAgK Bz4EhQMfgsKBD0HhwIegcOBDUDjwISgc+BAUDnwICgc+BIUDH4LCgQ9B4cCHoHDgztXFAr9iUCzw izbFQoDzgQvEdQPnpnLrLt+F57pB225yv7v1B+UD1AXtusHC3IEyZF5v2dW+9Prb7xUAb7++hLNy 7x56/c+f3SsAPvvz65BFvLVt7u3P/vmwAPjnZ5D1fAdzTIjMgQSZAALeK4qA9zgBnABOACeAE8AJ 4ARwAjgBnIBGIaDgY3nRgknholnhwmnh4nnxCoqiVTTxQ8JJiqlaEIBJkmXFwFHMFE3WdDo2nKmJ lgn/QjAzFT7YYc0iwsTJgm5ZRllFLWz4vmuCqolyGUpk1dKgBH2R3J8h/JVketXLV8l3SbUs/F0y pUBbVIlOHne+o+dERCJpgyrUTGjHadRUSC0PrVOyCqRLgcKrpD+EeqfQ66HdvK/Aa4ziy9VM48px lrOxPByCLjL8HMUNHHmOZkF53l/iEDTvf7RCsfN8wRHeVNMNwJZT9DVTRDzQcPQ1vazI4ehrbgUS fQ1Xsv9hib4mS6JIRV8rQC3fxOCeu4ompBmBs50DBw4cOHDgwIEDBw4cOHDgwIEDBw4cOHDgwIED Bw4cOHDgwIEDBw4cOHDgwMEF7lpZCHB34kLA4Xmht5Van9j9bBGXtZ7d/UTrjpYnnjNmi7irNms8 90RLy27jtV+9W/+beu/+6jVjd0vrs4ff/OTL+l9U/PKTNw8/29q6Z+HdL7+r/z3N7758d2FPa1E3 Zck9WY6dY+fYOXaOnWPn2Dl2jj077IWeKgs9URcrTRQrSRUrRRYrQceP9iFakoijTYiaKuLYEqIp ynBHn4r2YZmiYqEPlqiZpoxLVEkyI8N9SIJlSHJZ1QzBgMc3SAQJRdcMr3CeLoTAD6KkQ6H3eGQh 9fhFtuANEdRAvIYIeuhiCjndSIViXyNFx3KwSN8HRmcl6cX1124DQrE8MHVzfX1pc+0aPIa/nt9Y n91cW38NCiQNsF+7+RO3BLUbwVCa4WKmk0iSLMEwTams6ZIgKYoCAzWFZ+ud7ONRKLKEBlPH8Sis sqKH4lF4FUg8ClzJ/oclHoViKToVj+J7rsysL4SPBEVT1IzA+d8IwPnfCMCHoHDgQ1A48CEoHPgQ FA58CAoHPgSFAx+CwoEPQeHAh6Bw4ENQOPAhKBz4EBQOfAgKBz4EhQMfgsKBD0HhwIegcOBDUDjw ISgc+BAUDnwICgc+BIUDH4LCgQ9B4cCHoHDgQ1A48CEoHPgQFA58CAoH7lxdLPArBsVC/Lu5HPKC HUWnmt+xo3XX7rYibt627d7VivH/oHxgroiLx3MHyj9AFLTsal96/e36X7t+7723X19q39Wyo2X3 0Ot//qz+t87v3fvsz68P7W7Z0do29/Zn/6z/pfuHD//52dtzba07WvcsvltEzAGIOvDu4h5MQCFB D5ywB5wATgAngBPACeAEcAI4AZwATkAjEFBMMC4nHNeOouJxORG5dhQVkssJylVYfGM3Lldhgbnc 0FyFKUg8FUmxED8+mKSYqgUBmCRZVgwI7SQpmqzpvtBOmmiZ8C/6xVLhgwyfIsKDqaoqmJasl1VN F0TJ1CHOE1WoCapummUoUVVZxSW4mqoqXh1cslp6SpHhQUSCW3iVFCqqoniFiqx41XD7VIlOteYU mrIgaopBWqMKdVHRyl77pkKqeWQ4JavQKYdgp/Aq6Snpl1Po9d1p31fitRbk21UcAk0sH4NBMlRR w/G3dMUyTRgkzVRNEYJsaegJHJFLMmFU0AdZNExVgQ/2KI3yccl0XG6xhKbzMdfheMeKjye4eN5f 7BA472+kQrGvkaJD05mk73ec4HHZh3WDGH0aDusGox8R1s2pQMK64Ur2Pyxh3WTJosO6Fb2YNx24 h8qiCWlS4JznwIEDBw4cOHDgwIEDBw4cOHDgwIEDBw4cOHDgwIEDBw4cOHDgwIEDBw4cOHDIFbi7 ZlHAHZWLAofthV30wve8Crvohu+5FXbRj9zzK+yiI7nnWNhFT3LPs7CLruSea2EXfck9X46eo+fo OXqOnqPn6Dl6jj5X9MUeNYs9aBcsZhQsZBUsYhYsYMcPYyJakogjZIiaKuJ4GKIpyhAjgApjYpmi YqEPlqiZpoxLVEkyK8QxkXQVwjQYgiFLBkRvgHgTmmZRhfN0IQSSkEUJCr3HIwupxy+yxpIIUQNB IyLooYsp5HQjFYp9jRQdS8IifR8YnZWkF9dfuw0IxfLA1M319aXNtWvwGP56fmN9dnNt/TUokHTA fu3mT9wS1G4EQ2mG36HiVWQVbEUWtPKPS7JpoPmll1ULfZcUMoMkWZBEtaxJusvr4+4cTof4PPAP 0JoCQoPQmoIl6yag9cpkVGbhkCiypQqKaBL6LEW0CyXB0FUoFAXF1EmhqaFCK6IMP62jQkUhhQHU V535TV4uyX65ogYkerJ19cuCoumSpXoTq2tsbHR19c7GsWu3z0Nl/xTyv8YR71LUsKB3JmJgOlaO ZkeXN7U10z/zspxziqLjbtidk8mck3CH7a7JOcw5RbYE3bLxoglG8CqagGMKOWVXnTLDniGaScpM QYbIN3gmSYEyPOXsZ2VUpqp2oWaHCQpizmDOGYKom5qpZDbnooYF5lx4YKrOubh0hedcZrMNDbqk KWVF1tG8gxVGtOwvzuxCmyvFFFxZEanK+ItXWQ9VVunKKlVZClU16aqmWxX/qMrUj/gL/SNNvuqR H2/bMSK3HXgxQnPhjn1yyTYwkqxCaC0VB0ZCHdFDgZG8CiQwEq5k/8MSGEmxFDowEoGiTUHNBWEh sWiKmhE4/xsBOP8bAfgQFA58CAoHPgSFAx+CwoEPQeHAh6Bw4ENQOPAhKBz4EBQOfAgKBz4EhQMf gsKBD0HhwIegcOBDUDjwISgc+BAUDnwICgc+BIUDH4LCgQ9B4cCHoHDgQ1A48CEoHPgQFA58CAoH PgSFAx+CwoEPQeHAnauLBX7FoFjgF22KBR/bi4pCgKP8IWjdtbutiCAMbbt3tWL8PygfmCsgCsXC 3IHyDxAFLbval15/u4AgHO+9/fpS+66WHS27h17/82cFBCG599mfXx/a3bKjtW3u7c/+WUAQlof/ /OztubbWHa17Ft8tJAjN9w/vvbu4BxNQTBQcOwwOJ4ATwAngBHACOAGcAE4AJ4AT0BAEFHwsL1ow KVw0K1w4LVw8L15BUbCKJn6gSEkxVQsCMEmyrBg40pWiyZruCxSpiZYJ/0LAKxU+ePHO/PGnFEMW JMuSy1Bvw/ddEVRNlMuKIQmGakEB/uz8Wl4tPSVruLaBv0MAPU0SdNUileGL8yNpiipRyeP2d0VG xCMKnTbcQs3Uym6jiiyRWi5at2QVKLdpcwuvkkIg3i1z++e07ivw2qK4QgKyZRV5jDOcheG3WKKK 0qwjEeloDpBodFTnbILmfY9WKHafLzqMqBdrkYT4zD7yGoyVgiOvAZMiIq85FUjkNVzJ/ocl8pos WXTktQL08c0N7qmraEKaFDjnOXDgwIEDBw4cOHDgwIEDBw4cOHDgwIEDBw4cOHDgwA== gQMHDhw4cODAgQMHDhxyBe6uWRRwR+WiwGF7URehyE2o1id2P1vERbA9z+5+onVHyxPPGbMFXIRb XJg1nnuipWW38dqv3i3gIuC7v3rN2N3S+uzhNz/5soCLkF9+8ubhZ1tb9yy8++V3BVwE/e7Ldxf2 tBZ2FZfcxOXoOXqOnqPn6Dl6jp6j5+hzRV/sUbPYg3bBYkbBQlbBImbBAnb8SCOiJYk41oWoqSKO bCGaogwxAqhII5YpKhb6YImaacq4RJUkMzLUiCSohgjBGTTBgMc3SPAJU9eownmqEEJE6KKkQ6H7 eGQh/fhFtuAREdSQeBEheqhiGjnVSIVifyNFx5KwSN8HRmcl6cX1124DQrE8MHVzfX1pc+0aPIa/ nt9Yn91cW38NCtBIIuzXbv7ELUHtRjCUZrhoT6JsAqaIMKeoaSSXf1ySTQWxXBc0HEFFsewviMvH yQM6/QBUVkSqMv7iVJZCVVW6qupWJT+a9I+m+2M8plqRTJVQz0KMvWO/l9mG/VAME/FMxWE/UEf0 UNgPrwIJ+4Er2f+whP1QLIUO+0GgaEVnc0H4CFQ0Rc0InP+NAJz/jQB8CAoHPgSFAx+CwoEPQeHA h6Bw4ENQOPAhKBz4EBQOfAgKBz4EhQMfgsKBD0HhwIegcOBDUDjwISgc+BAUDnwICgc+BIUDH4LC gQ9B4cCHoHDgQ1A48CEoHPgQFA58CAoHPgSFAx+CwoEPQeHAh6Bw4M7VxQK/YlAs8Is2xYKP7UXd scUxrBC07trdVsQV47bdu1ox/h+UD8wVcMd6Ye5A+QeIgpZd7Uuvv13AFfP33n59qX1Xy46W3UOv //mzAq7Y3/vsz68P7W7Z0do29/Zn/ywgxMDDf3729lxb647WPYvvFhJi4fuH995d3IMJKCbGgx3k gRPACeAEcAI4AZwATgAngBPACWgIAgo+lhctmBQumhUunBYunhevoChYRRM/DJqkmKoFAZgkWVYM HK9K0WRN94VB00TLhH8hbJUKH+wAVqEoaJooC5JlyWWot+H7rggq+lbWREkwVAsK8Gfn1/Jq6SnF xLUN/P0qfJcEXbVIZfji/EiaokpU8rj9HeKViYhCpw23UDO1stuoIkuklovWLVkFym3a3MKrpBCI d8vc/jmt+wq8tiiuXMUh3LKKH8YZzsLwWywx82jWkXB5NAfs2Hde52yC5n2PVih2ny86SJ7phl0j Yeyyj7wGY6XgyGvApIjIa04FEnkNV7L/YYm8JksWHXmtAH18c4N76iqakCYFznkOHDhw4MCBAwcO HDhw4MCBAwcOHDhw4MCBAwcOHDhw4MCBAwcOHDhw4MCBA4dcgbtrFgXcUbkocNhe1EUochOq9Ynd zxZxEWzPs7ufaN3R8sRzxmwBF+EWF2aN555oadltvPardwu4CPjur14zdre0Pnv4zU++LOAi5Jef vHn42dbWPQvvfvldARdBv/vy3YU9rYVdxSU3cTl6jp6j5+g5eo6eo+foOfpc0Rd71Cz2oF2wmFGw kFWwiFmwgB0/0ohoSSKOdSFqqogjW4imKEOMACrSiGWKioU+WKJmmjIuUSXJjAw1IgmqIUJwBk0w 4PENEnzC1DWqcJ4qhBARuijpUOg+HllIP36RLXhEBDUkXkSIHqqYRk41UqHY30jRsSQs0veB0VlJ enH9tduAUCwPTN1cX1/aXLsGj+Gv5zfWZzfX1l+DAlkE7Ndu/sQtQe1GMJRmuGhPomwCpsjlH5dU SUEs1gUNBknVLfsL4upxTItgUvMMV1ZEqjL+4lXWQ5VVurJKVZZCVU26qulWxT+qMvUj/kL/SJOv 6v4fNZpczSM33lhJkWMlIQaGxuuO/bpnG01ENq2yhqb7mTIaV0UJBRPxfofvpA75yxBKREXvHxVJ hB224kAeR49YBORHVXoyMqEqczISk5QrJez01IEMRnrqSUl1eupPSSV6iqIkkp5iiaHpKZoQGxqN mKJJ4MCBw+MPjbPUNNoK3CD0NM6u3VBHmsY57FU6edafniqU1JOemmTUjaRYlORHTwIy8qAqPRnp qcqchrpB0NnNp4N3sqRFA6q680kqOnnb7id3tlZ6omXnrt1tP9o/7EZTX5gb3v/DZ6KfaNn5ZFvH 8PyJzbvvuCaSd+5uHD+Inti1M/hA6662zonLd9/53Z8+/tw16Hz+8Ye//Tl6oqPNiWPuNP303vHL b3zw8Rf3//7NA9f89OCbr/6Gnrh7eXzv0zvppvf0L999/5P7qOqjRxTbHpEn3r97usur3/qD9vHN tz66982DR2FGwxP3Pvrl2W6nfsuu9pd+9sGnX0dVJk88+Mdf33Tr72wb/9l/3Pu2mmnw4deoftfT 2JTyZOfl9+99V6lpt/4vT++FOPc72ybf+OSb6rVR/X98dHe8bSeivOPy+/cf1Kj9/fcP7r1/uWNX S8vu4bsf12wc9febj+8O725pbZt75/PajaPmP39nrq2VOU8DSasQt3o8YmJ2NSYjWYfpazJM9iSo 0fyjbz59axlPAphidz/6R8X5iNv+5n/+cLUfTzE0Dfae/sVHVabkowdff/qHfx1psyd869Ndp+/+ 8dOvv3sYheERvB5vXR35l13O+w1v6uZbf/niK3j5/HXRu3T/k/fvLvfTbzd+WX/6mw8//ttX1Jtt v9sfvIFfbXrtQEtB+9CxjZ//9kNq3SArx93LE52BhQMjeOaH+w8e36BWJXtd6mh7MrQsIQRofURP DFMZJMiqF17DqCd8+R6qrqn4iZaQJTMNJLCCOqbOih/If2GDp6JIgiJKZlmTNMESZRyk3isUTVSo g2FLFhRLlFCJIsiKppYVCdWxDK8AQveLoqCKiuIVypYkqJYsey15JQ5C9KBXiPCYlqGVvaZkU9B0 0/QQOgWA0CXLreWS7rYU6uFqpskSwBD241KT8jGcAyHSktexEskfujgeh9CTSXnUsZKUSwhpUj4V bljXjCSGdTm+Yf2O83/GFnb8lkmqISgSakgWdcGQFR1mkVdoaoKBuupaxhfSoz5PZrSk6qgSesjF AYgVHTUhwfSBhCK6DllBvEJZEiTDMHAhIhHNOjSr0DyTTZyFRNJMQUS0+Su6hXSTqolmp2R5yHFh kCKSA0ZSFUHULbMsoxmsabLhZ5EIH2TSgoYqqHRNXyGqaWma5JBvP64ICJVJCh1EvppB7FcdXxey 70j2vhM1h6Lfj65+QzA1NHaS9y50jY2Nrq7e2Th27fZ5qOyf9f4dLmIxipxGdDE1kTpWjmZHmf0+ 5uneIKHNXjbE8pmyoka5N3i/w3dSh/xlcG9QFEOM6d6Qn4Set1qgDiqIBCji4kqDghFRehQ1EWWF ogqibFFEIsoDxVYd1fiPE5atetmnOBaOhWNpQCz1WWHqtlrmiqjOu1hOiCL3/WwRVUKRIaLqKDJB xIIiJa5YKBIgStA+OzrGFoJa4mqKYTDk7SqVdu2soD8mtrtO0+x8DvICh5XXrbuwuW7j3/5t48Ti 3HBYG46NbmCh+8///NPv33snrF7fCbaB98Eo97//+/f79z539PVUhe6zvwTT2sNHCB7aBoDTxNrl VHjzr7T1AlsUfkEMXDuw+QFV+NpvrXj0wLZp7cAGudO/DFb4nlhfOp/EMTieGf7pX/4Rtnc8+uaT NyZxIzt/ePw3X3wXMR4P7v//7Z3PbxRHn8Y9g2SjEOaVbKRX3JpISchKaepnd7UgK5Hsi2RsvUQJ vmSVrCyYZJGMvTJkY/4Gaw9cctwILo5Wu+bOnnLmtDlE4Q8wuxY5RHkPSIDDVvXv9rRh5ot7Gofn s6vXM19X11P91ExsVzFPJVs4ncPvXv3h17otk3RPaOI5e0jJJtPEczalsrPD997levH56qO0eJHK C0f64rvde5Msc2zvfbFs42zPrbCdYvrrd7+KPdL6Da9kkzZ/Ge7e4xrY9a1ua+3UbSOXdrIe5PtR 1X3pfPMq3opKt6Dq96vc7tPeu075htPzNprSPaZd/20Y51YR15FvIs49oZUvFTfxymJedItnSoce 19KPjFuPV9wPQmU8t1QZMvdRs6xyya2TRr7iQamVlL7mTJZ6yiuZYHxdVgy4L2UQeXlPQWhvISzr ZRV3XT6svFk+9LyngTtsYqvoNfVx6K2iWn/K5REdspcSPXJX0lxywyX61PJWkWYBZadIjr5T1ODH MZMt2dC6K+zcKsV8+71kz0YHvo6UKIrJZlH9Yrx7hb7sgrxhnrBj/9xzLyM7mt0L8vn346dJm/TL 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 nDv6xv8D5d1SPg== buildbot-0.8.8/docs/manual/_images/master.txt000066400000000000000000000032021222546025000211730ustar00rootroot00000000000000 +---------------+ | Change Source |----->----+ +---------------+ | Changes | +---------------+ v | Change Source |----->----+ +---------------+ v +-----+-------+ | | v v +-----------+ +-----------+ | Scheduler | | Scheduler | +-----------+ +-----------+ | | | +------+---------+ +---+ +-----+ | | | | v | | Build : : : v v : Request : : : : | : ---- : : : | : ---- : : ---- : | +======+ +======+ : v : | | : : v v : : +---------+ +---------+ :queue : | Builder | | Builder | +======+ +---------+ +---------+ | | | v | | +---------+ Build Build | Builder | | | +---------+ | | Build v v v +---------+ | +---------+ +---------+ | Slave | | | Slave | | Slave | | Builder | | | Builder | | Builder | +---------+ | +---------+ +---------+ | Build Slave | Build Slave buildbot-0.8.8/docs/manual/_images/overview.ai000066400000000000000000005337071222546025000213420ustar00rootroot00000000000000%PDF-1.5 % 1 0 obj <>/OCGs[8 0 R 119 0 R]>>/Pages 3 0 R/Type/Catalog>> endobj 2 0 obj <>stream application/pdf Overview Diagram Adobe Illustrator CS4 2010-01-28T16:06:05+03:00 2010-01-28T16:17:20+02:00 2010-01-28T16:17:20+02:00 256 116 JPEG /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgAdAEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A635981f4X0aTV5BJcmTU fqgWa/ntIUE1y0Yd5FEoRIxuaJ0wIYtbfnl5XazuJrq01a2msrS0vLuFryMELeiD0wgku4pGX/S0 q7IqjuRhVNn/ADV8rJAl1w1g2fC3M9yGmIhlvIvWt7eSP1vVMkilacEYVYb74FQMf5z+WZrSS6tr LWblIltpHWC6gkIS8na2iNVvin98gVl5cl5Co60Ko3UPzV8tafFcTXkOrxQW/wBZj9b1WZXubJOd zbJxuT+8iAapNEJU8WOBURof5j6DrWoWFhaQauLjUoxcW3OV+BtwZFkn9Rbhk9ON4eBIJqWTjyVg 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 irsVdirsVf/Z uuid:9E3E5C9A8C81DB118734DB58FDDE4BA7 xmp.did:03FC8385150CDF1198A8D064EBA738F3 uuid:15cd64bc-c4e3-417a-b6b2-355ff7b02729 proof:pdf converted from application/pdf to <unknown> saved xmp.iid:D07F11740720681191099C3B601C4548 2008-04-17T14:19:10+05:30 Adobe Illustrator CS4 / converted from application/pdf to <unknown> converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FC7F117407206811B628E3BF27C8C41B 2008-05-22T14:51:08-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FD7F117407206811B628E3BF27C8C41B 2008-05-22T15:15:38-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:0CC3BD25102DDD1181B594070CEB88D9 2008-05-28T17:07:17-07:00 Adobe Illustrator CS4 / saved xmp.iid:34001D5FB161DE119286837643AC861D 2009-06-25T23:53:30+03:00 Adobe Illustrator CS4 / saved xmp.iid:35001D5FB161DE119286837643AC861D 2009-06-25T23:56:39+03:00 Adobe Illustrator CS4 / saved xmp.iid:01FC8385150CDF1198A8D064EBA738F3 2010-01-28T16:00:41+02:00 Adobe Illustrator CS4 / saved xmp.iid:02FC8385150CDF1198A8D064EBA738F3 2010-01-28T16:00:52+02:00 Adobe Illustrator CS4 / saved xmp.iid:03FC8385150CDF1198A8D064EBA738F3 2010-01-28T16:05:57+02:00 Adobe Illustrator CS4 / xmp.iid:02FC8385150CDF1198A8D064EBA738F3 xmp.did:02FC8385150CDF1198A8D064EBA738F3 uuid:9E3E5C9A8C81DB118734DB58FDDE4BA7 proof:pdf Basic RGB Document 1 True False 800.000000 600.000000 Pixels MyriadPro-Bold Myriad Pro Bold Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-Bold.otf MyriadPro-BoldCond Myriad Pro Bold Condensed Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-BoldCond.otf Cyan Magenta Yellow Black Default Swatch Group 0 White RGB PROCESS 255 255 255 Black RGB PROCESS 0 0 0 RGB Red RGB PROCESS 255 0 0 RGB Yellow RGB PROCESS 255 255 0 RGB Green RGB PROCESS 0 255 0 RGB Cyan RGB PROCESS 0 255 255 RGB Blue RGB PROCESS 0 0 255 RGB Magenta RGB PROCESS 255 0 255 R=193 G=39 B=45 RGB PROCESS 193 39 45 R=237 G=28 B=36 RGB PROCESS 237 28 36 R=241 G=90 B=36 RGB PROCESS 241 90 36 R=247 G=147 B=30 RGB PROCESS 247 147 30 R=251 G=176 B=59 RGB PROCESS 251 176 59 R=252 G=238 B=33 RGB PROCESS 252 238 33 R=217 G=224 B=33 RGB PROCESS 217 224 33 R=140 G=198 B=63 RGB PROCESS 140 198 63 R=57 G=181 B=74 RGB PROCESS 57 181 74 R=0 G=146 B=69 RGB PROCESS 0 146 69 R=0 G=104 B=55 RGB PROCESS 0 104 55 R=34 G=181 B=115 RGB PROCESS 34 181 115 R=0 G=169 B=157 RGB PROCESS 0 169 157 R=41 G=171 B=226 RGB PROCESS 41 171 226 R=0 G=113 B=188 RGB PROCESS 0 113 188 R=46 G=49 B=146 RGB PROCESS 46 49 146 R=27 G=20 B=100 RGB PROCESS 27 20 100 R=102 G=45 B=145 RGB PROCESS 102 45 145 R=147 G=39 B=143 RGB PROCESS 147 39 143 R=158 G=0 B=93 RGB PROCESS 158 0 93 R=212 G=20 B=90 RGB PROCESS 212 20 90 R=237 G=30 B=121 RGB PROCESS 237 30 121 R=199 G=178 B=153 RGB PROCESS 199 178 153 R=153 G=134 B=117 RGB PROCESS 153 134 117 R=115 G=99 B=87 RGB PROCESS 115 99 87 R=83 G=71 B=65 RGB PROCESS 83 71 65 R=198 G=156 B=109 RGB PROCESS 198 156 109 R=166 G=124 B=82 RGB PROCESS 166 124 82 R=140 G=98 B=57 RGB PROCESS 140 98 57 R=117 G=76 B=36 RGB PROCESS 117 76 36 R=96 G=56 B=19 RGB PROCESS 96 56 19 R=66 G=33 B=11 RGB PROCESS 66 33 11 Grays 1 R=0 G=0 B=0 RGB PROCESS 0 0 0 R=26 G=26 B=26 RGB PROCESS 26 26 26 R=51 G=51 B=51 RGB PROCESS 51 51 51 R=77 G=77 B=77 RGB PROCESS 77 77 77 R=102 G=102 B=102 RGB PROCESS 102 102 102 R=128 G=128 B=128 RGB PROCESS 128 128 128 R=153 G=153 B=153 RGB PROCESS 153 153 153 R=179 G=179 B=179 RGB PROCESS 179 179 179 R=204 G=204 B=204 RGB PROCESS 204 204 204 R=230 G=230 B=230 RGB PROCESS 230 230 230 R=242 G=242 B=242 RGB PROCESS 242 242 242 Splash 1 R=214 G=149 B=68 RGB PROCESS 214 149 68 R=71 G=152 B=237 RGB PROCESS 71 152 237 R=42 G=81 B=224 RGB PROCESS 42 81 224 R=180 G=58 B=228 RGB PROCESS 180 58 228 Adobe PDF library 9.00 endstream endobj 3 0 obj <> endobj 121 0 obj <>/Resources<>/ExtGState<>/Font<>/ProcSet[/PDF/Text]/Properties<>/XObject<>>>/Thumb 135 0 R/TrimBox[0.0 0.0 800.0 600.0]/Type/Page>> endobj 122 0 obj <>stream HWn7}߯T`ir8[uZiv4R]Iv4p8䜙3b 4`;mլGϓuk>``9~tY}(5(Vrtvk#ߘ{-[H݂ zwO աW(ڗ%k'/`oǵ?qM\F⫋3z?AM;N0N$;=tLk].>K% endstream endobj 123 0 obj <> endobj 135 0 obj <>stream 8;Z\6h/U=T%&Qf/,Ne5pf?+dGU=gYb@@[aV'Pp"8*>idgX3J:3EP]atVQ4'cbou#c?5_tNRhIqX3!/MF\]cZY&GEo<)(-`EA &VXLYMu=UYDI:0N*:Dn(-EkCgAj`aAc:6ea*#m:mO1r?i\sh.Kch%GnfW@@tX&79\ IBN<7QSIGU1%rXCTNgqW#F>>$6I>V)\-9-Q&K\<%`rhKj*@#J&/E/b[H_ZTZ 09S:J$4i>G@am\BRC4M:16hWl\L>cmUQ0W5-UU,"Fd2]]4>W433+:pp-\S3fOlhTn o70TEe9H+BXEF/X'jp,u3Yq;eeC3e"-&_V,nMV\Ol+GqkM!_t+=l/+V3#iE`j8\(R !8iXk@f~> endstream endobj 137 0 obj [/Indexed/DeviceRGB 255 138 0 R] endobj 138 0 obj <>stream 8;X]O>EqN@%''O_@%e@?J;%+8(9e>X=MR6S?i^YgA3=].HDXF.R$lIL@"pJ+EP(%0 b]6ajmNZn*!='OQZeQ^Y*,=]?C.B+\Ulg9dhD*"iC[;*=3`oP1[!S^)?1)IZ4dup` E1r!/,*0[*9.aFIR2&b-C#soRZ7Dl%MLY\.?d>Mn 6%Q2oYfNRF$$+ON<+]RUJmC0InDZ4OTs0S!saG>GGKUlQ*Q?45:CI&4J'_2j$XKrcYp0n+Xl_nU*O( l[$6Nn+Z_Nq0]s7hs]`XX1nZ8&94a\~> endstream endobj 128 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 118 0 0 118 340 319 cm /Im0 Do Q endstream endobj 129 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 129 0 0 174 80 291 cm /Im0 Do Q endstream endobj 130 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 126 0 0 32 82 432 cm /Im0 Do Q endstream endobj 131 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 129 0 0 174 588 291 cm /Im0 Do Q endstream endobj 132 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 127 0 0 32 589 432 cm /Im0 Do Q endstream endobj 133 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 118 0 0 118 215 180 cm /Im0 Do Q endstream endobj 134 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 118 0 0 118 467 179 cm /Im0 Do Q endstream endobj 159 0 obj <> endobj 161 0 obj <>stream HK1 /B c'ﭷ,rDyE`-&ʵcF]q#ۍ6n:XjuQšY,dѨţ .Y`Y%C/X&:eh:f#D@lYhzy7[fv#5}mOV[uh_i%ZmUJMg 4USY)VYG`PSZ&Q:FLS^<e .Q@ EJ˪Q$k@q EFMB׌Q .4HR)ȅ`B*5 endstream endobj 140 0 obj [/Indexed 125 0 R 1 163 0 R] endobj 162 0 obj <>/Filter/FlateDecode/Height 118/Intent/RelativeColorimetric/Length 1426/Name/X/Subtype/Image/Type/XObject/Width 118>>stream H׿Ojpe@IN$.@ ?qxXsq. $zB67d}CQPQ lpiCFxzwM7uu2 ݚ^: S NZ/i^Zw5te\q)&Ej&\G5RG+DuY$\G!֭Y:>;IBxgz`֫e _j3c*+Չ"K),~v9g ҽL,*a)vQ*,J>:JXeI", >Ѩa.W~&U<hNFgA-rwcQ 턦1>K=Q!l*ffgY37O >`i^b T184W{`ie w'C]a1WO _mun4ZZa^uu^יu^שZ^9Wަu_m͟o| ?<\/Iam. > Ѻb ^h.{v`X,+ɇ'xZ0>1/*recaQW'|ypX`†W7)*\PqH49*\jf-B`J'y-DeTJ*UT|g3H$u&eQ;Py1šAcuܹ" r&koӆh`o endstream endobj 125 0 obj [/ICCBased 164 0 R] endobj 163 0 obj <>stream endstream endobj 164 0 obj <>stream HyTSwoɞc [5laQIBHADED2mtFOE.c}08׎8GNg9w߽'0 ֠Jb  2y.-;!KZ ^i"L0- @8(r;q7Ly&Qq4j|9 V)gB0iW8#8wթ8_٥ʨQQj@&A)/g>'Kt;\ ӥ$պFZUn(4T%)뫔0C&Zi8bxEB;Pӓ̹A om?W= x-[0}y)7ta>jT7@tܛ`q2ʀ&6ZLĄ?_yxg)˔zçLU*uSkSeO4?׸c. R ߁-25 S>ӣVd`rn~Y&+`;A4 A9=-tl`;~p Gp| [`L`< "A YA+Cb(R,*T2B- ꇆnQt}MA0alSx k&^>0|>_',G!"F$H:R!zFQd?r 9\A&G rQ hE]a4zBgE#H *B=0HIpp0MxJ$D1D, VĭKĻYdE"EI2EBGt4MzNr!YK ?%_&#(0J:EAiQ(()ӔWT6U@P+!~mD eԴ!hӦh/']B/ҏӿ?a0nhF!X8܌kc&5S6lIa2cKMA!E#ƒdV(kel }}Cq9 N')].uJr  wG xR^[oƜchg`>b$*~ :Eb~,m,-ݖ,Y¬*6X[ݱF=3뭷Y~dó ti zf6~`{v.Ng#{}}jc1X6fm;'_9 r:8q:˜O:ϸ8uJqnv=MmR 4 n3ܣkGݯz=[==<=GTB(/S,]6*-W:#7*e^YDY}UjAyT`#D="b{ų+ʯ:!kJ4Gmt}uC%K7YVfFY .=b?SƕƩȺy چ k5%4m7lqlioZlG+Zz͹mzy]?uuw|"űNwW&e֥ﺱ*|j5kyݭǯg^ykEklD_p߶7Dmo꿻1ml{Mś nLl<9O[$h՛BdҞ@iءG&vVǥ8nRĩ7u\ЭD-u`ֲK³8%yhYѹJº;.! zpg_XQKFAǿ=ȼ:ɹ8ʷ6˶5̵5͵6ζ7ϸ9к<Ѿ?DINU\dlvۀ܊ݖޢ)߯6DScs 2F[p(@Xr4Pm8Ww)Km endstream endobj 160 0 obj <> endobj 165 0 obj <> endobj 166 0 obj [0.0 0.0 0.0] endobj 167 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 118 0 0 118 467 179 cm /Im0 Do Q endstream endobj 168 0 obj <> endobj 170 0 obj <>/Filter/FlateDecode/Height 118/Intent/RelativeColorimetric/Length 1426/Name/X/Subtype/Image/Type/XObject/Width 118>>stream H׿Ojpe@IN$.@ ?qxXsq. $zB67d}CQPQ lpiCFxzwM7uu2 ݚ^: S NZ/i^Zw5te\q)&Ej&\G5RG+DuY$\G!֭Y:>;IBxgz`֫e _j3c*+Չ"K),~v9g ҽL,*a)vQ*,J>:JXeI", >Ѩa.W~&U<hNFgA-rwcQ 턦1>K=Q!l*ffgY37O >`i^b T184W{`ie w'C]a1WO _mun4ZZa^uu^יu^שZ^9Wަu_m͟o| ?<\/Iam. > Ѻb ^h.{v`X,+ɇ'xZ0>1/*recaQW'|ypX`†W7)*\PqH49*\jf-B`J'y-DeTJ*UT|g3H$u&eQ;Py1šAcuܹ" r&koӆh`o endstream endobj 169 0 obj <> endobj 156 0 obj <> endobj 158 0 obj <>stream HKnC1 CQgnQmgCRA;Δ#y{N/-$v mܐtfMA6i\g!>Y(jeY cI蚥+.ޱdt)[ [J++B/l*C؃T!-Q{*FVɪb[}Kd[mV[mwQOMՓ~T5;SUVuWUxU^"UG -R%w(G -RY%;QkA źfzPCAE3М63lI3L/"=.4q#E` A*P endstream endobj 171 0 obj <>/Filter/FlateDecode/Height 118/Intent/RelativeColorimetric/Length 1436/Name/X/Subtype/Image/Type/XObject/Width 118>>stream H=kPo!QlW:) (]w\^vW VH_^cN\ˏ"a/gb!F0AϖF\V͆P N':U5oLq< dD,u^⒌\F̾-rKEBgA`3VtIq%%Puc:Wx{ܑ2Y`"l"#cRwŅb OYcRҭM-ӷ)SLXK׺6MtXr&b 5\5EГ5N 5%Pf<@WjJOН iWHr_fL|[m~~bL>Xlo#]*:PRlC:T`dLRbtR\ܟ_*:u/vwL2QE¥ d>=(0B ~;M ~Sf;") lȒ޸6%t'ĽR*=d}kl2gbSwF.˩՞-a|V=9%x{rdKT¿%x`O%cO'H(QI6:0xȰX2הD,5sx80x(uXM;UVlZ+䮟DgjŊOИ999՜jNi9'0? ^CPxyӝy`v/|y~]闤sŮjՊ-uND;%VP/0r"byZA޸#À^RtؒRAl.k t@]KUgdUQuBvW =e">YYܣ eBwm̺BbG`'cu1*x3Q)cYlQ܅НXv$qZeVeUo2f"wuT0EXҖ>űCK\T+dUZ*]qaCKZf̄%+Z Ou; LsѻJwQ6^t厜\.4⦾Fʨ\˺lu,(fYLW:Xn.-tuMB\UC$5_n/e,XUoY]&vo4^Vxk .Lλ&|jG VE ]gy !M"kĉ4lD|m6fOfj\ 29^#jD>AM&S!!bأƅ9}uyWi-enG\#V_dO endstream endobj 157 0 obj <> endobj 172 0 obj <> endobj 173 0 obj [0.0 0.0 0.0] endobj 174 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 118 0 0 118 215 180 cm /Im0 Do Q endstream endobj 175 0 obj <> endobj 176 0 obj <>/Filter/FlateDecode/Height 118/Intent/RelativeColorimetric/Length 1436/Name/X/Subtype/Image/Type/XObject/Width 118>>stream H=kPo!QlW:) (]w\^vW VH_^cN\ˏ"a/gb!F0AϖF\V͆P N':U5oLq< dD,u^⒌\F̾-rKEBgA`3VtIq%%Puc:Wx{ܑ2Y`"l"#cRwŅb OYcRҭM-ӷ)SLXK׺6MtXr&b 5\5EГ5N 5%Pf<@WjJOН iWHr_fL|[m~~bL>Xlo#]*:PRlC:T`dLRbtR\ܟ_*:u/vwL2QE¥ d>=(0B ~;M ~Sf;") lȒ޸6%t'ĽR*=d}kl2gbSwF.˩՞-a|V=9%x{rdKT¿%x`O%cO'H(QI6:0xȰX2הD,5sx80x(uXM;UVlZ+䮟DgjŊOИ999՜jNi9'0? ^CPxyӝy`v/|y~]闤sŮjՊ-uND;%VP/0r"byZA޸#À^RtؒRAl.k t@]KUgdUQuBvW =e">YYܣ eBwm̺BbG`'cu1*x3Q)cYlQ܅НXv$qZeVeUo2f"wuT0EXҖ>űCK\T+dUZ*]qaCKZf̄%+Z Ou; LsѻJwQ6^t厜\.4⦾Fʨ\˺lu,(fYLW:Xn.-tuMB\UC$5_n/e,XUoY]&vo4^Vxk .Lλ&|jG VE ]gy !M"kĉ4lD|m6fOfj\ 29^#jD>AM&S!!bأƅ9}uyWi-enG\#V_dO endstream endobj 153 0 obj <> endobj 155 0 obj <>stream HA İ4 Ok`٩eu/tq 0 endstream endobj 144 0 obj [/Indexed 125 0 R 1 178 0 R] endobj 177 0 obj <>/Filter/FlateDecode/Height 32/Intent/RelativeColorimetric/Length 141/Name/X/Subtype/Image/Type/XObject/Width 127>>stream H1 WPR04Q9= !ܝ57):5 趴精͑>wm<%sA$߹|ic~7G+ooםC[f(,  |v>k endstream endobj 178 0 obj <>stream endstream endobj 154 0 obj <> endobj 179 0 obj <> endobj 180 0 obj [0.0 0.0 0.0] endobj 181 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 127 0 0 32 589 432 cm /Im0 Do Q endstream endobj 182 0 obj <> endobj 183 0 obj <>/Filter/FlateDecode/Height 32/Intent/RelativeColorimetric/Length 141/Name/X/Subtype/Image/Type/XObject/Width 127>>stream H1 WPR04Q9= !ܝ57):5 趴精͑>wm<%sA$߹|ic~7G+ooםC[f(,  |v>k endstream endobj 150 0 obj <> endobj 152 0 obj <>stream HA ð4&I 4[_ Ղł`Vo endstream endobj 184 0 obj <>/Filter/FlateDecode/Height 174/Intent/RelativeColorimetric/Length 290/Name/X/Subtype/Image/Type/XObject/Width 129>>stream Hϱ@7 L `IB+t;'6|5eeQ3]7CN6'A~}cg\Ӳb{Jg20P6Z zO1$cg\$@t`P_k/fwhB©f 4eeQ3uk"jI endstream endobj 151 0 obj <> endobj 185 0 obj <> endobj 186 0 obj [0.0 0.0 0.0] endobj 187 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 129 0 0 174 588 291 cm /Im0 Do Q endstream endobj 188 0 obj <> endobj 189 0 obj <>/Filter/FlateDecode/Height 174/Intent/RelativeColorimetric/Length 290/Name/X/Subtype/Image/Type/XObject/Width 129>>stream Hϱ@7 L `IB+t;'6|5eeQ3]7CN6'A~}cg\Ӳb{Jg20P6Z zO1$cg\$@t`P_k/fwhB©f 4eeQ3uk"jI endstream endobj 147 0 obj <> endobj 149 0 obj <>stream H1 ð4Xړ:352E.'O endstream endobj 190 0 obj <>/Filter/FlateDecode/Height 32/Intent/RelativeColorimetric/Length 145/Name/X/Subtype/Image/Type/XObject/Width 126>>stream Hױ a J DI!i` dz/+D_{GJ!*c[ҚxyM|ī<8lՇx:s5G/m̟xsb馛n馛nT]ԺoBIH",/S$ endstream endobj 148 0 obj <> endobj 191 0 obj <> endobj 192 0 obj [0.0 0.0 0.0] endobj 193 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 126 0 0 32 82 432 cm /Im0 Do Q endstream endobj 194 0 obj <> endobj 195 0 obj <>/Filter/FlateDecode/Height 32/Intent/RelativeColorimetric/Length 145/Name/X/Subtype/Image/Type/XObject/Width 126>>stream Hױ a J DI!i` dz/+D_{GJ!*c[ҚxyM|ī<8lՇx:s5G/m̟xsb馛n馛nT]ԺoBIH",/S$ endstream endobj 143 0 obj <> endobj 146 0 obj <>stream HA i5Qm}'A V7 .Vo endstream endobj 196 0 obj <>/Filter/FlateDecode/Height 174/Intent/RelativeColorimetric/Length 292/Name/X/Subtype/Image/Type/XObject/Width 129>>stream HбPF p@bUhJpIBK!V` 0E}9 ߮g߲PT.!l[bPvyh (~|-ƾ)M48;4>cP|sϋa1Y> endobj 197 0 obj <> endobj 198 0 obj [0.0 0.0 0.0] endobj 199 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 129 0 0 174 80 291 cm /Im0 Do Q endstream endobj 200 0 obj <> endobj 201 0 obj <>/Filter/FlateDecode/Height 174/Intent/RelativeColorimetric/Length 292/Name/X/Subtype/Image/Type/XObject/Width 129>>stream HбPF p@bUhJpIBK!V` 0E}9 ߮g߲PT.!l[bPvyh (~|-ƾ)M48;4>cP|sϋa1Y> endobj 142 0 obj <>stream HI@ C/͂Mhm-::dzNR` $֚b]+qF׏Xe1š Y,*dѨţ.Y`Y3Y*:Re}6E6)j zfS(MQO6EDFj,b7R'[G ZjRK-)ZjWs6?OgSԶ̶T5MQ[ȶt5m?4lECNP"NQ@9eϊP+E**Q FtFM3mԻSM)&0Gۆ[*V endstream endobj 202 0 obj <>/Filter/FlateDecode/Height 118/Intent/RelativeColorimetric/Length 1423/Name/X/Subtype/Image/Type/XObject/Width 118>>stream HԗnJva  [ETY)("DR2)(EY) Y8m](g?` $f: Μ 'ka0xԏRAX Q72||T;l|fHIGEa Y@q݈xTvtWr-%zcb?K3ƽ'(`KB1-+ ^Qei,ZqȢi`]eJE0T=[rO%"[1/TjKqLbOQ4U%OZcBJmR@՞K>XTҥ2yԩPHl8oS@˿9A} ["`#aXwj.i?b(bjX,|:^XϜ|X`{Ž1=/݈b]<=AD\'5ZF4 v#*SQ݌2%8fcͨpBFslL_KrYQ ;ծ (QDicc(q\n֘>k;NUo%.~-B{Q֒ۡƾϖ(_O]ߊ:ԭO^?[{pQe<3)c*gKZ8byet )P2-|-9rtMY^f$N=ar* sw koj߸C^,TSQgpM}*M.m:mg.djRVȵz ݡRo\1Qp?,$=qC6?ƒ/ 910]n> endobj 203 0 obj <> endobj 204 0 obj [0.0 0.0 0.0] endobj 205 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 118 0 0 118 340 319 cm /Im0 Do Q endstream endobj 206 0 obj <> endobj 207 0 obj <>/Filter/FlateDecode/Height 118/Intent/RelativeColorimetric/Length 1423/Name/X/Subtype/Image/Type/XObject/Width 118>>stream HԗnJva  [ETY)("DR2)(EY) Y8m](g?` $f: Μ 'ka0xԏRAX Q72||T;l|fHIGEa Y@q݈xTvtWr-%zcb?K3ƽ'(`KB1-+ ^Qei,ZqȢi`]eJE0T=[rO%"[1/TjKqLbOQ4U%OZcBJmR@՞K>XTҥ2yԩPHl8oS@˿9A} ["`#aXwj.i?b(bjX,|:^XϜ|X`{Ž1=/݈b]<=AD\'5ZF4 v#*SQ݌2%8fcͨpBFslL_KrYQ ;ծ (QDicc(q\n֘>k;NUo%.~-B{Q֒ۡƾϖ(_O]ߊ:ԭO^?[{pQe<3)c*gKZ8byet )P2-|-9rtMY^f$N=ar* sw koj߸C^,TSQgpM}*M.m:mg.djRVȵz ݡRo\1Qp?,$=qC6?ƒ/ 910]n> endobj 208 0 obj [/View/Design] endobj 209 0 obj <>>> endobj 117 0 obj <> endobj 116 0 obj <> endobj 118 0 obj <> endobj 213 0 obj <> endobj 214 0 obj <>stream H|NMHauts(pCPKAeםQuFfFc/!9x҈ȓD$(IlGoft=<<4ȣh.tZ""߸+AYm yitفX\g}g C.Qy$E1>8ꆆJ9U jrM/HBՄQKqY&~42r*PUP&&yG 叐Ls6PY[_ Q#"=MhL#%@@$* bϑ9w%.K" kt&Pxa"UM0/v~TghW3)ɆQ ;Z(h6ör޵ >ç@',+!)3hW6}~)({ w\ge=9D! =$/ۆ=' K C.A Mm܌opd`rǸ7{2ڂ ўGKv|3p0^X tЭZ_]*4u˸τִ"tO%[,[<^rbws0% endstream endobj 212 0 obj <> endobj 215 0 obj <>stream H|TkPSGcMnbCG EHGC4@RNJ" b%"RVN2Ukdtv_;|gs;g/yEIr2-Y(74,1lHFXNJqBn/gcT?E-xd* b,!׬⢔UYg+٨HϪbu=]n7ٴbTb4i,z]8\Tz(̬Io֛NRE%8[Pg.&12&6*/5YVob5`0sFߠ1gn?fYV8ͬXAX,ZcidЛ#e/+/ѳN_#Lx3<^-x~n2x͔D}Eݢ\~/X!*  +}l%KYC. j\3U38(,+v<1Ǹwmѽ2HjMTd!\T*5/"z Y颀Q ]^uAۘۨ=AˀA06hc:VݕBs{.O<"pCmf՗ܥ¥ ۷kxklxdyf]<aJ؇Ɔn,H]\2ocyM]~|%;g絪)u+>&|,} 3Ҥ9;eAymQ)}/H9=@?6(.>~j. S"1&t `"Fr"ю2:;K5(j1@_B vpXhtq .;+NGk;-,QNى*FJM V;дў>םkƫ3y ¤i;*WbQwB:]1? RQ𧄷 7mVڬn=`Hg'/j;n>stream H\j0~ C C|B>#SC- 9+)T`Ç430CengXۙ8ܜ&vkg4cM;k><i(X/i ZEkuʞ7k'3%k^jZM-,p =44Z͕"dɟ2"aV.> endobj 217 0 obj <> endobj 218 0 obj <> endobj 219 0 obj <>stream Hj0 endstream endobj 220 0 obj <>stream H|T{PS$7dA\{Hx! * [wuCA!EAE ‚"h1BVtЭ KYjΎS~7=8Ν9s;HB@$2zSRԇ1F&-h2d=+(wV3I],(,3/[2gouֻ +plBN'972͐[2M+ ٘~pѢ }UMJ>(00pjUS9&ݮ~m`65&]?OQF]Θg?5ϧ[ FgaL*D N:ik0v=a4.17=0ux}-Yzo‡9&+-h Y&^*>!?[LJi)3Ibb@;$@"u#"a"DvF* 2&mn|DfS*qPGzN٤GwGdz}O|y~QX8j;~ 0D=(\(n;'Id("gy! @o|58w}Yİˢ@RwrG⮄.C\[nɣT0 xp%H.1 :^-D+jØ J\p=`)eL%0~m  BNo7UpD"qL2}O'G[EB߹椊E #S7=x0N74 .DsW1f _[yFTPHjQJASM=/Φ}]IAJJ+p5[z|jsL妠ӏPOrr=Q >PuP]+;͋CuD=a!ь{E)Rp(}YEe]rUսfT8#%Rs,AR*s4>z䅸(6Ez-joN]);ۨn ;Ծ=ClmtHo7 <M*.}~hCxˉN匍qq|M ` 0n+wi4og@2a9]/7x[/}- j;%X6>EA TH} ܸJIZn}SV!wpJlnz(rh{_ݪm$-Gj*}!;My;>5/Kh1Orh/ahnDcecz$ΉQ,#Nl[} xb({*J K;RE|Wp tz={Da߁;.O6OJ|Ɂޗ޹H.3 1~WHN[>><=;,EE4G-X.BI>WzK/=s`!EfQ1n=<y 2H7 ⇒t eJJ3Pt߱;JjSͼ[,A9XP~dlMW V>@9TTWV+\{=vY6i]kwWzKEbLpx;;=*~})ugA6#3- ,jB$/s`5|  :,u|x9(CD(椁9YV0O6Ψ,}a"~7ڈVc_jdK"-lsyG{7vcPP :悐$@2 g+ ~|J3b~I ;8Vr~J u ?Ega {|=>|r3x&OBGced%P ?fkx0Ǡ,%CFltrddZ]Pdrђķ~Lj)6zJ7w 9Uekb6ea ;Ox9͆#tG\Hf)v!]W endstream endobj 126 0 obj <> endobj 127 0 obj <> endobj 124 0 obj <> endobj 221 0 obj <> endobj 222 0 obj <>stream %!PS-Adobe-3.0 %%Creator: Adobe Illustrator(R) 14.0 %%AI8_CreatorVersion: 14.0.0 %%For: (GV) () %%Title: (overview.ai) %%CreationDate: 1/28/2010 4:17 PM %%Canvassize: 16383 %%BoundingBox: 80 179 717 465 %%HiResBoundingBox: 80 179 717 465 %%DocumentProcessColors: Cyan Magenta Yellow Black %AI5_FileFormat 10.0 %AI12_BuildNumber: 367 %AI3_ColorUsage: Color %AI7_ImageSettings: 0 %%RGBProcessColor: 0 0 0 ([Registration]) %AI3_Cropmarks: 0 0 800 600 %AI3_TemplateBox: 400.5 299.5 400.5 299.5 %AI3_TileBox: 4 -6 796 606 %AI3_DocumentPreview: None %AI5_ArtSize: 14400 14400 %AI5_RulerUnits: 6 %AI9_ColorModel: 1 %AI5_ArtFlags: 0 0 0 1 0 0 1 0 0 %AI5_TargetResolution: 800 %AI5_NumLayers: 1 %AI9_OpenToView: -145 688 1 1166 654 18 0 0 69 109 0 0 0 0 1 0 1 1 0 %AI5_OpenViewLayers: 7 %%PageOrigin:0 0 %AI7_GridSettings: 72 8 72 8 1 0 0.8 0.8 0.8 0.9 0.9 0.9 %AI9_Flatten: 1 %AI12_CMSettings: 00.MS %%EndComments endstream endobj 223 0 obj <>stream %%BoundingBox: 80 179 717 465 %%HiResBoundingBox: 80 179 717 465 %AI7_Thumbnail: 128 60 8 %%BeginData: 8831 Hex Bytes %0000330000660000990000CC0033000033330033660033990033CC0033FF %0066000066330066660066990066CC0066FF009900009933009966009999 %0099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF3300FF66 %00FF9900FFCC3300003300333300663300993300CC3300FF333300333333 %3333663333993333CC3333FF3366003366333366663366993366CC3366FF %3399003399333399663399993399CC3399FF33CC0033CC3333CC6633CC99 %33CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF660000660033 %6600666600996600CC6600FF6633006633336633666633996633CC6633FF %6666006666336666666666996666CC6666FF669900669933669966669999 %6699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066FF33 %66FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF %9933009933339933669933999933CC9933FF996600996633996666996699 %9966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC33 %99CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFF %CC0000CC0033CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399 %CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933 %CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFF %CCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0033FF0066FF0099FF00CC %FF3300FF3333FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699 %FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33 %FFCC66FFCC99FFCCCCFFCCFFFFFF33FFFF66FFFF99FFFFCC110000001100 %000011111111220000002200000022222222440000004400000044444444 %550000005500000055555555770000007700000077777777880000008800 %000088888888AA000000AA000000AAAAAAAABB000000BB000000BBBBBBBB %DD000000DD000000DDDDDDDDEE000000EE000000EEEEEEEE0000000000FF %00FF0000FFFFFF0000FF00FFFFFF00FFFFFF %524C45FFA8AEA8FFA8A8A8FFA8A8A8FFA8A8A8FFA8A8A8FFFD04A8FD4EFF %A8FFA8FFA8FFA8A8A8FFA8A8A8FFA8A8A8FFA8A8A8FFFD08A8FFA8FFA8FF %A8CFA8FFA8FFA8FFA8FFA8FFA8CF7DA8FD4CFFA8A8A8AEA8A8A8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8AEA8FF7DA8FFA8A8277D7DA87DA8A87DA87D7DA8 %7DA87DA87D7D7DFD04A8FD4DFFA8FFA852527DA8FD047DA8FD087DA8FFFD %04A8FF7D27272752275252522727275252527D5227275227AEA8A8FD4CFF %A8A8A8FF2727275252522752FD052752F85227A8A8FF83A8FFA8A8275227 %7D52FF527D525227837DA8527D52527D7DFFA8A8FD4DFFA8FFA87D525252 %FD047D52527D27FD05527DFFFD09A87DA8A8A87DA87DFD0AA8FFA8A8FD4C %FFA8A8A8AEA8AEA8A87DFFA8A8A8FFA8A87DA8A8A87DFFA8FF83A8A8A8CF %A8FFFFFFA8FFA8FFA8FFA8CFA8FFA8CFA8FFA8CFA8A8A8FD23FFA8A87DA8 %7DA8A8FD22FFA8A8FFA8CFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8CFA8 %A8A87DFD04A87D7DFFA7FD04A8CFA8CA7DFD07A87DA8FD20FFA87D7DA87D %A87DA87D7D7DA9FD20FF7DFD17A87DA8A8A8FFA8A8277D527C2776527D52 %527652527D5252A8FFA8A8A8FD1FFF7D7DA8FFA8FFA8FFA8FFA8A87DA8FD %1FFFA8A8FFA8CFA8FFA8CFA8FFA8FFA8FFA8CFA8FFA8CFA8FFA8A8A87DA8 %A87D5252FD0727F82752F852272727A8A8A87DA8FD1EFF52FD0CA8CF7D7D %FD1EFFA8A8A8CFA87D7DFFA8CAA8A87DFD0AA8CA7DA8A8A8FFA8A87DA87D %A17DA87DA87CA8A77D7DA87DA8A8FFA8A8A8FD1DFF7DA8A8FFA8CAA8FFA8 %CAA8FFA8CAA8FF7D84FD1DFFA8A8CFA8A8277D5252767D5252A8CFA8FFA8 %CAA8FFA8CAA8A8A884A8A8CAFD05A8FFA8FFA8FFA8A8A8CAA8CFA8A8A8CA %7DA8FD1CFF7D7DA8A8A8CAA8A8A8CFA8CAA8CAA8CAA8CF7DA8FD1CFF7DA8 %A87D522752FD0627FD0BA87DA8A8A8FFA8A8275252A7527D7D7D527D527D %527DCACFA8FFA8A8A8FD1BFFA87DA8FF7D7D7DA87DA27D7E7DFF7D537DFF %A8CA7DFD1CFFA8A8FFA8CF7DA87DA87D7D7DA8A8FFA8FFA8CFA8FFA8CFFD %06A87D5227F85227F8277D52F82752F82752FFA8A8A8CA7DA8FD0CFF53A8 %52FD0CFF847DCFA8532753527D52535252A87D525353FFA87D7DFD1BFF7D %FD04A8CAA8A8A8FFA8FFA8A8A8FFA8CAA8FFFD04A87DA8A8A8CFA8A8FD04 %7D76A87DA852FD047DA8A8FFA8CAFD05A8FFA8FFA8FFA8FFA8FFA9A8597D %A8FFA8FFA8FFA8FFA8FFA8FF7DA8A8FF5328527DFD045352FF537E7D7DA8 %FFA87EFD1BFFA8A8FFA8A82727527D5252A85252527D7D277D527DA8FFA8 %A8A87DFD06A8FFA8FFA8FFA8FFA8CAA8FFFD06A87DA8FFFFA8FFA8FFA8FF %FFFFA8FFFFFFAFFFAFFFA8FFA8FFA8FFFFFFA87DA8A8A87D28537D52537D %5252525328537DA8A8A87DFD1BFF7DA8A87D7D52F8F82752F8A752272727 %F827F82727A8A8A87DA8A8A8FFA8A8277D7DA8FD067DA8FFA8CFA8FFA8CF %A8A87DFD07FFCAC9FD12FF7DFFA8FFA8A8A8FFA8FFA8FFA8A8A8FFA8FFA8 %FFA87DA8FD08FFCAFD11FFA8A8FFA8CFFD067DA87D7D7D527D7D7D527DA8 %FFA8A8A87DA8A87D52FD0427522727F82727CAFD08A87D2752A87DA87DA8 %A8C3A0BB93C2929992BA92C9A8A87DA87DA852527DA8A87E7DA87DA853A8 %A8FD057D537E7D7D7DFF7D277DA87DA87DA884FF99C2999999BC99C9A8A8 %7DA87DA87D7D27FD04A8FFA87D7DA87D7DA8CAA8CAA8FFA8CFA8FFA8A8A8 %CA7DA8A8A8FFA8A8527D527D527D525252A8A8CAA8FFA8CAA8FFA8A8277D %FD05A8FFCAC3C3C3A1C2C3C39AC2CAAFFD05A87D7DA87DFF535252527D28 %7D7E287E525353527D522853A87D27FD07A8FFC9C9CAC2C9A0C9CAAFFD07 %A852A8A8A8FF7D7D27272752A8CAA8FFA8CAA8FFA8CAA8FFA8CAA8A8A884 %A8A8CAA8CAA8FFA8FFA8FFCACFFD09A8CA7DA8FD18FFA9FFA87DA87D2728 %52532753A153537D537E287D285252CF7DFD1BFF7DA8A87D52522752277D %FD0EA87DA8A8A8FFA8A8277D7DA77DA87DA7A8FFA8CFA8FFA8CFA8FFA8A8 %A8FD1BFF7DFF53535253287D52A8527D537D7D2E53537D53A8A8FD1BFFA8 %A8CFA8FFFD04A8A7FFA8FFA8CFA8FFA8FFA8FFA8FFFD06A87DFD0452F827 %4B5227FD0AA8CA7DA8FD1BFF7DFD04A87DA8A8A87DA8A8A8A1A87DA87DA8 %A2A87DFD1BFF7DFD04A8A77DA8A8A87DCAA8CAA87D7D7DA8CAA8A87DA87D %A8A8A8FF7DA8527D7D277D7D527DA8CFA8FFA8CAA8FFA8CAA8A8A8FD1BFF %7DA8A8FFA8FFA8FFA8CFA8FFA8CFA8FFA8FFA8FF7DA8FD1BFFA8A8FFA8A8 %277D277D5252527D52A8527DF87DFD0452A8A8A87DA8A8CAA8A87DA87DA1 %FD0EA87DA8FD1CFF52FD11A8A17DFD1CFF7DA8A87D76522752F8FD042752 %7D52522727F8F82727A87DA8A8A8FFA8A85276525227CFA8FFA8CFA8FFA8 %CFA8FFA8CFA8A8A8FD1CFFA87DFFA8CFA8FFA8CFA8FFA8CFA8FFA8CFA87D %A8FD1CFFA8A8FFA8CF7DA87D7D7DA87DA87DFF7DA17DA87DA87DFD04A87D %A8A87D52522727527DFD0EA87DA8FD1DFF7D7DFFFD0BA8CAA87D7DFD1DFF %FD05A8CFA8FFA8CAA8FFA8CAA8FFA8CFA8FFA8CAA8CF7DA8A8A8FFA8CF7D %7D7DA8A1FFA8CAA8FFA8CAA8FFA8CAA8FFA8A8A8FD1EFF7D7DFFA8CAA8FF %A8CAA8FFA8FFA8A17DFD1EFFA8A8CAA8FFA8CAA8FFA8CAA8FFA8CAA8FFA8 %CAA8FFA8CAA8A8A884FD05A87DA8A8A87DFD0CA8CA7DA8FD1EFFA85252FD %04A8CAA8A8A8CA7D7D52FD1FFF7DFD17A87DA8A8A8FFA8A8527D525227A8 %A8CFA8FFA8CFA8FFA8CFA8FFA8A8A8FD1EFF7DA8FF7D7D7DA8A8A87DA87D %A8A8FF52FD1EFFA8A8FFA8FFA8CFA8FFA8CFA8FFA8CFA8FFA8CFA8FFA8CF %FD06A87D5252A152275252FFFD0BA8CA7DA8FD1CFFA853A8FFFFFFA8A87D %A87DA8A8FD04FFA852FD1DFF7DFD17A87DA8A8A8CFA8CFFD057DA1A8FFA8 %CAA8FFA8CAA8FFA8CAA8A8A8FD15FFCAFD06FF7DA8FD10FFAF59FD1CFFA8 %A8FFA8CAA8FFA8CAA8FFA8CAA8FFA8CAA8FFA8CAA8FFA8A8A87DFD04A8FF %A8CAA8FFFD0EA87DA8FD14FFA1C3A7C9A1C9A1CAA1C9A0C9A1CAFD0AFF9A %CAC9A1A1C9A1CAA1C3A0C9A1FD13FF7DFD17A87DA8A8A8FFA8FFA8CFA8FF %A8CFA8FFA8CFA8FFA8CFA8FFA8CFA8A8A8FD14FFC9C3C999B499BB99BC92 %BB99BBC3FD0AFFC2C9C299BB99BABBBB92BB99BCFD13FFA8A8FFA8CFA8FF %A8CFA8FFA8CFA8FFA8CFA8FFA8CFA8FFA8A8A87DFD04A8CAA8A8A8CAA8A8 %A8CAA8A8A8CAA8A8A8CAA8CA7DA8FD15FFCACAFFA8A8CAFFCAFFCAFFCAFD %0BFFCACFCAFFCAFFA1FFCFFFCAFFCAFD13FFA8A8A8CAA8A8A8CAA8A8A8CA %A8A8A8CAA8A8A8CAA8A8A8CF7DA8A87DA8A8A8A1A8A8A8A1A8A8A8A1A8A8 %A8A1A8A8A8A1A8A8A1A8FD18FF7DA8FD18FFA952FD18FFA87DA8A1A8A8A8 %A1A8A8A8A1A8A8A8A1A8A8A8A1A8A8A8A7FD04A87DA87DA87DA87DA87DA8 %7DA87DA87DA87DA87DA87DA87DFD09FFA8A8FD057DA8A8FD06FF537DFD1A %FFA852FD07FF7DA87D7D7DA8A8FD09FFA8A87DA87DA87DA87DA87DA87DA8 %7DA87DA87DA87DA87DA87DFD22FF7D7D7DA8A8FFA8CA7DA87DA8FFFFA87D %7DFD1CFFA852A8FFFFFFA87DA8A1A8A8A87DA87DA8FD40FFA852A8A8FFFD %05A8CAA8A87D7DFF5252FD1EFF522EFFA87D7DA8A8FFA8A8A8FFA8A87D7D %A8FD3DFFA852CAA8CAA8FFA8CAA8FFA8CAA8FF7D7DA8FD1FFFA8FFA87DA8 %FFA8CAA8FFA8CAA8FFA8FFA77DA8FD3BFFA952FD0EA8CF7DA8FD20FFA87D %FD0EA8A77DA8FD3AFF7DA8A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF7DFD20 %FF7DA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA87DFD39FF7D7DA8A853FD06 %7D537DCF535353CAA8A87DFD1EFF7E7DFFA85352A8FD057D7EA87D52A8A8 %FF7DA8FD38FF7DA8FFA852287D52A8527D287DA8527D537DFFA87DA8FD1D %FF7DCFA8A82852FD045352537DCA287E52A8A8A87DFD37FFAF7DCAA8A828 %5252527D53522853A828A8527DA8CA7D7EFD1CFF7DA1A8A8A12E287D527D %5253527DA8527D52A8A8A87DA8FD36FFA8A8A8FFA853527D7D537D7E2853 %53535253A8FFA8A87DFD1CFFA8A8FFA8CA52537D7D52A8537D287D2E537D %FFA8FF7DA8FD36FFA8A1FD06A8FFFD09A8CAA8CAA87DA8FD1BFF7DFD04A8 %CAA8CFA8A8A8CAFD05A8FFFD04A87DFD36FF7DA8A8CAA8A87D7E7DFFA87D %A8A8A87E7D7DA8CFA8FF7DFD1CFFA8A8FFA8A87DA87DA8A8A87DA87DA87D %7D53A8A8CFA7A8FD36FF7E7DA8A8A8277D5253A87E007D287D282852FD04 %A87DA8FD1BFF7DA8A8CF7D287D5353FF7D2852535253287DA1A8A8A87DFD %36FF7DA8A8CFA87E525353FF5328537E287D527DA8FFA8CF7DFD1CFFA8A8 %FFA8A8537D537DCF7D287D5253A85253CAA8FFA8A8FD36FFA87DFFA8A853 %535252535353527D287D5228A8A8A87DA8FD1CFF7DA8A8CF7D53527D287D %287D525228A828537DCFA8A87DFD37FF7DA8FFA8A8A8CA7DA8A8FFA8A8A8 %FFA8A8A8FFA8A184FD1CFFA87DFFA8A87DA8A8A8A2FD07A87EA8A8FF7DA8 %FD37FF7DFD06A8CFA8A8A8CAA8A8A8CAFD04A852FD1DFFA87DA8A8A8CFA8 %A8A8CAA8A8A8CAA8A8A8CFA8A8A77DA8FD38FF7DFFA8CFA8FFA8CFA8FFA8 %CFA8FFA8CFA8FF7DA8FD1EFF7DA8A8FFA8CFA8FFA8CFA8FFA8CFA8FFA8CF %A8A884FD39FF7D7DFFFD0EA8A77DFD20FF52FD11A852FD3BFF7D7DFFA8CA %A8FFA8CAA8FFA8CAA8FFA87DA8FD21FF7DCAA8CFA8CAA8FFA8CAA8FFA8CA %A8A87DFD3DFF7D7DCAFD09A8CA7D7DA8FD22FFA852FD0DA852A8FD3EFFA8 %7DA8A8FFA8FFA8FFA8A87D7DAFFD25FF7DA8A8FFA8FFA8FFA8FFA87D59FD %42FF7D7D7DA87DA17D7D7DA8FD28FF7D7D7DA87DA87DA87D7D7DFD46FFFD %05A8FD2CFFA87DA87DA87DA8FDA2FFFF %%EndData endstream endobj 224 0 obj <>stream %AI12_CompressedDataxmsGVd{"HyO9,{{ $A@ h~YYMRLjc&Ow~=v{g~M/WL6>LbR{DUb@ ֱGl=5_E^|u줕}y9[j׉CswPΧW Qzx)(ċv:'V@?8?*]?fjwqy7k_+?y&7<nx&}zV;Z\,?X'~(7ػzɏ?^[f$ǗjSyT3=GOk#SlTc&ϤYw355媹\5=b^t,+|}ZnXK}g_ d\ɭWA=oKJ~" 4@O,h+L^Ԟ*Կr-7H{O#;臷ofܻ*Fhȓ_PW# 7[zuգϾ5^V_ϯ3|Hgnߗ&?$k@_g՛^ÕBgMn1]NtN)MVzGۨk2 I7\VIߞZ/k}J{ޑ]>[J7W'?VrR76~4%dڿwܻOa7D/Ḙ<=}2<]ٵ 7ߗ~r_wyI |FgCIk;9IMd4Q911g &dŹ94Gyb͉mG`M6{h{lO 9]p%׺ݑ{݉o{|ɷ#}YpbH!pcrMECL9xxhO!ŔRr:H(tt6i-}\KmCԶmA{O= >.L1'\s>ȇ9G>O1}NMہ98pԽ5~sD'9!y4|,<"Z9Z<*'s\>'y 83g(oTx3trG=.XpJNW:n9 ;qSQT|A og".S2=LL8k=+G1?Oxq}1s!p Gxp-0C .*_/N|!&O@@δ8pG,1',y?:T!'8x!(\??Eq&o$xS]׉E΁!ㄟdт@.8섥'~mi'ģ]&|Bhq_&;&nsceG1q#I/Go"!ˎDb"6:&8j'6O$rHI=9:&n'i$`Dᓣ'O?99n1uԱɓ#R'Ď-mxN;'9>9>>~r|DjX%~Ocs<9I q#=7=RocRQ;9jHӈh\4ikiaih&h>ZH{>oGǴh4f|Zڙ cڳGwhSi#ͽ$H괴FVzYZ1fKkie=U>KOhi3@"Nd ?!8%$@ϑf4q'$<$g$}'ďO+;3h"c񭱍9!:w'@ޢ@SN{vJ]CojFyb䉑'6≁Or#h$]c/wpνW{t1pѹѹѹѹѹёyb䉑'sgwR¹z~#E_-gRBq+J1>0c|`1>0/8#Oa|@F6>vڦ=nAX@v{tDbt#B|=]\p9f/4)O#3!NLApqUPf[|;7G=~yX0yqרZeRL g2L֐M?ȈnmN~Iˎ+S.جo--Tmm20kZ[vԳ}ֹeM>(L[alأ;qq%)!P4Dn^Ħ="cJ]Dc!hP6a^H ۽6\F3C`i2H.h%C%}Tα=Cpr}hu.=v1s$Ϭ̑ky6 IB46&ptLޣIhu*,4-wZP7MG? ̂3M 1|m~/O㻔-/MPm%Qv}aڧz6t}p.dxdf^$w|i{IK3CNU]ji`+1Ąڈ~/{*h`sWҗB/-\Noʮm];~hOħv?ym#As9(-HYz=%dڼi=XZO<)d; o[DZ{56yto)zijG7IiPimá1<<֬ eƟ˴Lnס/O!I@{Сmі-m ĐGZ18"Α狛f^}T>dɛO(e51 m,GX‰=dLw~/Q5i\#m{'VH*"D!$n%K,D` N=C@=$1yiHt% *C BMDdzd6*豤lMXs8TjLDϱ%ّ$.Ċm NƠŀUM)u&6(=ajNb.ЦD!h&2̚pkev12PS3 m*݆dsP3{هvHnY2[|/rc! Q8! &b,L,L31p-M%`eQdƠ%L0."C-\LfqI!b5~cH6a+Wv^Kk ؤӣ#/'d;$4ͤ6yUD#У@ vX = [l򥛖T~ZR:q$ȩdd,"aაD7 Iғ"Gl8%G< i102a=E!Wր'Z=F3ͰKC{%o֗v^Rw`cx0$$-U2hZOl=]p'Nv[%l̑Vx|P,-6Ki,7ꃓ0e߱`EheɴYڝcNB#`ک]?aߍVoj3"FwF&{|'+|QAKߊ"D'D2#Xf'AINҲS&xX`4VgU ʽU<^vZ dhЃ.qzL[Uvlb63 _#ChI#^"#aBo '64.omp>)9`b4}ih1N5R<_ѐCΈ9bp %CY lj>`:gB6L4ڝ+^ dt#!qC%is"gG0} MX\0L9>'M!@`|6~/-v1 *uA(CTGޢ0 X^i㕬Oz 5֚y`G5Hl߰NrQ 0 w蕣VV'CaS)1KLEqtCZ!ĪX60paAF7f#{+00j޷'"ei }-yp0 #/Ld#2Ma-aT' %DV-g2@%o}x1ux[ܭX";+09B%S0,YĎdlJ|OO^v$$S%x3:K84=7aKSdK\ɐnVv?BV;=68!r4~ls ^\sXа269iQK,P7dVc/ȳZk5'DT-j`pgBY&Vˌ Ab /N_+yxGGC!7}C 4JN**pk`%M ɘ}"0u x$wk5LnLkm_qÈ'6`!ډl葏d^7ƏQb]fy wψU?eÝQTiO1Tf{Was n$ґf{!Sę&HlHAFAynw% EZ0<'E:eS fS-k+3C&.G uO8AuV3K̀;%2$wT@퓑Za:ptFkp,pWa4,7V,͘l&8 T"OSJ : uwY񩶅LXdQr&N6_Cd7X\gWF1,sj+.RY J WX "`$DC͗W_Ҁ<M+ׇkX"eu\?-?sA Ҫ8 BHԩŦ|G*$QIJN ͥ|ţN!hx׵'B18/^S:UZ &@ $~\DĉNn!:\I#hlkyLb'^x F_a Kz `[l!(`BJgXDkDHײ}X 87'i,Iق QXdHƑbMҸhQLJ9.5k@ +>@F͑6g҄35Cx&WKD_Q-KΕH)Ӗ[>a-G}@5~xi 2gNvTaɠTͤ5ݤ,Z2>cIZ"(3j E:qhb0b5VQYM.6v?PLjz:Zf7 ^ st 0X[m8Eu ĠGhc RclC: & 521eNڳ3NKe;5pa>I Z2z);IRHl3)w4' B>9u 7t\` RBA0<= R+xZl6g0D1Usueף0JEC*y'bax^+X%M2di=3 Sf")݄R@HPEZm-Q·`S` s)4vၯ.eg xD9 8{ð ͧq(q!*0#ג{@If/4ƣIA! ? /?W1J`y}^Q@6Nڃo~G-qF'`Mm~ݕXqsz|dJ39/ z䗃CD=Rѭ(.zH+ӣ+'tpz6+VTB]={ j}#mE3BCw|4[@z H+R:~ H֬C=IpR%(z)-ol| 7p|HO1X 08:waPs8π1)}8CU`#W5ᖙujz]0yg?u@+n;TAz cPxD<>.b5UpաUȱ>ILz@-ik&R=W<* F G =Eԍwwqa{,R{|ăQ27EmV;0#ͷ`!+}E -vPW<, E QJ<ĺrhBN;KHm PA=;̧ sg߸ ~rMW0pB6!OCߎ2;(lN0cŶ_1!J0ElW=*+ru$7@OD"l>WbAk# W' @%1FsB<KЮDų#l`&PBe`un+ W\fM[Q>kf)@ٯ0`=!-DV ZW$3d`9d9tsΉr88xF;Sw[BȕN(m. c) /It  eQvuFl2n oLŰ;Vya'C؃"=ʾعl `Gˇuvڜ*~ ^KM-C;TSEuCc뾑@\sbkPz҇;6L#5Yf9+r$5DU:O \!4Ív4[ǖҕZ pf=F1)d+ؓU֡WPD_NNwfVwY VVuCj,滏Tˇ PF8H8uT`-ZvAcc})D]*VȽP{DfS,ilŧ&1"ک$ӱt(+ITGr).uk+,=!*=E_AP pA`!C u 1j=`oT4:%7NXtl^\Hio[@tpTu8t_+HC>YC#UQzfX# ؜ B'Н耂!Q?Rz(ӛ䔩!+# VI L @崁tOrHZGC~^k>ܕ"E#ۈSp]Jv}nVf>G @9 $#0$6NỂϹ`B;9}VsX| 2 >GĶ& `a90Qs~=H=Gc% M{T^ɽιGkzwxr޵?][LC9"wG?JxjV\n9zW!TNi>Gq5\o=燆=o9U @b=GAs&" LZyJYAT8) gW(A@q8qLv ;a’H>D<6rFpAGuimÀCM|88Gq.pC\o[!;׈)4N7hg(2h*2\*#a]#CŅ; `=a*,4y7cjX8*M@)p;攇pStZa׊bE9 0_G;e~ 7|jٹ8@`Pp II2؊ QPl k R),naQA9ӊ[`Xm2NbpNwpY3X8tH#|5cg8 9p 7q$m"] ),Lq|6[`y *&BVLC~ARɐwibpln@!(RH1$y3&<-pTtJ,yJ5ëZĄ J5I 备1:B1:{M8x,/]bdliPL x=J"ap6C3Ia{EͰgǹXaRo=)@z- zFS10nE;3Bq )5`;;O,~s20;Ѹ09'#V ׄBAD4Pα@g0ouV")Uhl)R\NZuV")Uhi)RDN"ZuVg9R8M45`Vk,5Z7Y5YqǪ͛0RO1@B>¿R+yACXƊeϻX`Q(v PrĊ"uc- V1qb@ӂaB!R1'`Xh\[1yڛVx |9u zzk ~9gExb aA&=XG᫚QsUs|U9^|W53j>GQ쪦sUM(tU9\tj:GW5SpQjMQ)U(jU3:تftjFgYu(BVrסD*E7!\` sbp*b X|椿: FqJU8ųl6US~H^~<+Pobo?. /fo> ߴ~{-] l>N( 6CW5+k萇Y B,g#@i_K$De\E-22᭍] ĜpV!9&D2myC/qWi @|sZ&!iBN# },Z>v֖[9oH H#Bd`]p>4ߛfuTfjTvD0ֱea'ŪdI)Ę[qhW #."|C.ҥrk\:0ѧ !N!HINe#GNdm"QUiEU# V7GK/VQC,Є¼mSu еj"\[[ȍS!a#B)g9i"\s҂NFr!fj Vr9)r|qpu1fj˩6nl&@(ƫU;sR;N+Y3^r'*1OQjZ}F58\Zj%[\ZSyONL1h#j_C-pBUAl6ז/L%Q{8H2Uk {9 eݸNuImq;)NI=UWko`RO5" FMCB *':Mh R{hphLBLZp"&m2mׂ$ty*"L{kX Zcot2bۿ}.Ӡv1K: N=>p yk[vQc&cvr."э =5F8* h"rrqsA4+ZSm~W2N,D8,2ZBTHkkT+9t*D~Gj8^!/@!:.h6vXZ*bgqIz1@j(uyZ|{%"TVgO[JNe +9!rTz& $5lMI dBwrR*Aޗ3S1\FDa}ohVxqo# LrDh! bq : ʗR;/QY \Yµ֪rx@dWO?|eé"1!Y4'ck ľ@gK@詰Ib]@!'GavTW=:<~ b=*6T*-LE8p_VY|4 ByDgO%v)oI&*[bS-?݂T7u d6|m݇\O/Du05^%Bd8v'ԅaĺ}X({{t}I @ ]ʵ%HRQGiHm@_ *^(!T z5@һ5@UbKGPɘ+A uق0%j& Ī`fYԄ^ab,{:zk& >0Gq7'fپ_(qdӯrcR`N!ѫ 9e+Kx3HU3Ԇ5a̹RjaF4:" 0Д NJg;Qm\L@MwdTkceS<FiIMXv-l:-7N_ʊA6R 'zG29}?i]x iqe+mP9Rb[VMKWЬJBu޵x¬&tGEHUE˧z\om*'l/gcL-.=Iĵ)iyIGV 5UbԱq6ܕw5*gvÕ^ \MZK:'#K)|iJ<9roH9<J0VV+ԉ#PJD :>߱0$Μ(PX}SQ! >:4\k綮LИ^ EszrfNq:m(n-hN+F,}((eUp!;uTOzj%] +k 9YR$>L ĞƁZ6nLIQvUfzPzqJN6z{ܣMR &MrBdB,ЖAdͻ[\\bԍErIJ4'+eWQhPĤp,-SRvO$4+ĞqTOcT7Y g!';UIg+^Ehӂ͆A%Cj:軖bj Sl5 uZ&ήraY&tזi=CR'įa k@@Q)@DTJ ^T&Il.dTj䜝.hmӒȈu!z25PgfF!yz,ʠЫӖKphMԱuY|I=zş4&ٚ5E(ITqG:f 2Ian,$}ɚ&I$i Hpȡ+p5܃e$PRřPJ}(ӭw:VB."vc΃w!2ߥbVjX.VC\(b,֓qHs ̠ p,P(z(IyeХĶZڇ^ ,\Mm zSyQ }IM]QJ_bJRe@OmY9M,o-OX/0T,iPbkN+%Lmu(f [jҤa |VA;*B`X~JGلtڮ0qFD(o.)gEhuwd}Zೆ#Z](=J,D~xQ=@[w#.$'Η^,S VS˗SJBucιK .U>L5֮^NnwrPgQgH{`CuRup|iT&*\XXϒoy.^m䍽E`t1 R$m쎂aJr`BR ݙ sN8-ئʽrITp¨3Ѱ5٬8#+b9>~$1bM  ϊw>%Z|:w^Ñ*%%ҽQ-n]z ^4}NXMTNV NUq HHs4ƺb 6^2eil4ZP൭vTϭ*R[]OM7MtB<K -OWd>Kr2zu<5.)`ه#^*Z z^bJ=Gku^ok@yE ]]k8w5g F-lڑ"95ҝ<˙7эϪ =iv6lGj-?mzS/jer JAK%lTb-m zqA'hƭ]8uZ8S{Wl\8U.(.64s"JZLs%ZjZjPjqr*ΥFkԭ&SLIBQ.rLKAZ;q]%5A;TbQjr P jSrV+rhk|J hzϖa[ct+ugP0i?ެKy;֣=Z-/.2uٜ=v$\(wD>~Om ѷ?{ql];##w>ټ ~wٜQB^d-j;S 3z!횪><{z\gVьwh< y'7|vf'<KZ3Ɵf?03;ڷޙ}v͚>?tv^w=FG?A͙zuRwTӫ-Lݍhmvs$Yy9]o'\;6xX\-7ӫ`׿/g!E鷫-qWC\8аR/nKڇX6;nvc=ҽw9O^=CXt#l]i!ZXG-HߝS nsw*>q3I"i!횲+nXtڧTd>mTƮWŐv4I0ݮ죇WŐv͚Iʪm.WvOܦs\r,G]Az͋'|΋-yZ^_ER aa31 Q5QFp4w\ǣ"Gsp4G18OV _/,wxps~oqom>;w-o_l =EE DeMߦFw]q|ESwÎ~d8ew?cu|e fB~E?Ll|y6{9_ηH+gof'l-w5OԤ787ia&ݺw~j҆ݧ{d}3f^O cAcn: {ˑ1H.R<{k,ޓadam!~Cg軥uOvQ)'wM`y=|}>œn&}>GbLghјL٘k|FO#=ocBvN  1!+݈ ]ݍ ]2!6j7&v\&:Mh< jh/X7Xox1.x|7o\]_%P7cqW"~z͖_7j6u ;͘!svCm~MMhΆGcjp]H?dƐ̧cHfdC2cH^:!K4dv!$rM2>`tDu2+:+:_]{nnsE|> gtcgv|/zsEG_tE? _ypVC4<\t?٫t <:}oLA~=s]6s]^zGYN{ Z_ifz:|mo[wŧ۽~Wvv w%&Sv%|g=Hq7:{ߓX}M;";׮ m9twE9v׶v6w~l}2_ߛ}[[.Emd$ ߶ nc)tr<~'e4i& ~=bA ;-a ./^n {>nCIʑ1Yf9:,nձC{q=t|c˳f:^uZ#|g}#xW I&߾|tfF7ikcGC84mk]OSܩeP?ŷhM=߾`Z139ٙ8{)9'OiZ< zI#q7D|Ip2M[ÃӫW]X>]M5j1V}G[q^/aB}5?+[*6Wj}/kU\szWN_N~>k@jWg1bϳrj<[ljz4],>+@s? 7{l\LslIMcvjymggZؕl^"Mpa1bI_r)bOj+?ǷM%S%'?.!UtD?%kBG~-iH=[0?evzNPϸOC&>ox)wm G8XrE$e~lZu4̗"\# o9 #nF_NO٪tfy&,x.5autEb lPC%߄]nDַ>D:F*I*Ouv/ܢpOg(̃/zFU?nuV˳f3MaZߟd4Mf@ssbjC<-UAx1;u|vvmv&11zo:CL653 V_n2m?/ꡙ)r:_]VxF.f˳?p] t?fWhENq}A|~f b6{`IkԴ&eKf"~ [ڷm(m"z&Anx 䝇$e-}&kLhkQ$;xp/,Goh^߽)ͯ.~JH>m|#Ƶ> Xx:iB&~k1Ze~?lijzf_ۻ{w;W^hыrX)Af:/t*lw^ ŔcZtq~V~ᵳ|z>r0_g!jQApaT6U  &.loX;6-6.:iGy>Q!<"^XlM XNjY36pQ/Ŏ^<<569umŋ'Ǩk߯fؤ[MꚐ@<3 ϏblF-w/ܘCcvxfCcӿ"zQA X79 ‡,GpAw=ƱN Y38Qrѡ]9X@F)? @qxse}؇e=re_:|D^-.{V;-gK\;|wL˙~޼_ ?1}z84g ~2xX'qGhr;o=_gM7~͖`Ry}<>샶WsP{vcTu8:|E93`8,I+F+慘 z=$N“&`pM7kn^.:`NuIS,Bt1s&N^}Mznz`* I v$ ÊYq<`j%)u e$S1uQLGpnrKR9 b:S;T J'j X& a :ZtBHp]K yYSZJ9NX ,` s@CGѻTbE6%LC.vPdSZUĺcsU O `hvIqtH'P'LARAbE%5JF,9"j6KY΢>SYNHU/:0i` xƊJH䵴<7$y/b%*@.л}0K-ő!_drAb 6t!pD3e eVY 6}H0U< Tp彜~=n>#qAh=@4'#BEs* Я1>`P dGpfPLIYvnx+.K) 'P|^*"9s@gT^&!HylϤIDž.܍N!WZQHqhHANԺX`*=A FF4KD)4 E6hATPHfqx(XC f&P@(u\GL\y81±I+G0f(.ؽF׿9qЛ ;~9@]A?е H{\KȽzo/[5*]5 _듑/N=UQ׈SuHii(X9$I$#(q )Jb$D>\6$J1A\4in!d]mFhlrRt;=H T1о$Q ?z3Bg@#{M@?5Ҁ.OÿPʱ .$ޡp6~@yj/ |]/%ZqS+ZC}n^E 2{'n{kx<$.`/ӽ[(ќ_GvnN1r;&LLcz.((+y^̢(C^¡3PKXm3s|9w?JB P90ʧ^VIHUV/N6&HNߊ&O;^N E[4t9_(RL]$g6ڝQΕrna?ۗcL $+'0W?æ?by8 {z0 1QDs_dyټקB'1r΄dtٿJ^=NO;M'N0ӺVB;wgazyϕW'uMh{r޽ׇg*5o^ޠ{ϣ8yntPbf_Om]F>vwV7-}wg>zُF꽛)a`B(4Hv%gNxcg$ޯTƫ>?.{K@q:E+j JӂYCFZ_q;\G&s%Q8o(yvg0Ngx~}orH!Cp}147,r<[@bb8/sWCqpkV\c;='.J-5JM Bו3ލtYϢ cG 0}ń#~W\^_1ݞSj;NwZﯫa@B">W2D^kexX5JxZ1hu|3~&Ӱ0$\Ö`Ƨ[ikڌq7[)%+xP] Xr<fqG( R͚,q i/SƝ^6nM*bkݍl@tAe!#l@ Wd9!_H{V(?#`iC+U*_1c]U(9;-i =C!du;sjKV{qTk^p;-:VbBJ Ü!xq0 dcd0qX)G^^S0~QQz&6@Pؐ$" t7y=yFQa 2Fo,bXB1\XwhۀewvǹM~tGE h %7|j[nFI "Jvbdxx_2m϶ɚ:N)d[s4l%dY6|N%Z612ni1k6tB mS4=eFZݽ'9 zme;V&,Pv]&쮨oRв5lh=`M{FRgO;Z1%')XS y}iTsE Zm\;Rq{_ZEGNr*3Tu\8rw^Ly;ZL'vmύ<_]}5$TH{K?$-L K=V ##qt4[-oVÎ;8Ti`^EIsG[ݭ Ɉ*4wtu2m홞S:&T 8ɣ8Q<]Xr~vRѲ$Qȃ Vǟm󑅂y!{+7 -C>5(`m!q1EŀSZnXJ2%;ۏN5]+qFQr_$r[mT{ဂޠpO~/A-bcyvRrSkwwY>ZtcZypv}Wf4ddEQ ge(HNO`)UŎw8IDUK2W :q; QHe]E(:%QRҲ *Ⓕ{$wTXg:j/9Ӕ3؁<8%H4BrƤ9(!8J69a B|gC0B2٭C\AV{.(E?.~:./=xGK0,9S-`Pm$LZFwgqf~3I5םBCO]%U1Y[,,}b94d+"h+."bO_/cO:(y˥S79hf-STph<[+Fi# IM?Ut@O2{>3o?Zb]5 sT_I];LrGi<]Ś [[* dۘ%mLf Wh_ 'ȏ.9&h0PA;$sz5U1\߯aIP9,dd;ꄂo&3b_%yf}v!T.'@G\';5^8C&NZ`69ǚ9VՌK I wg'raD/ZWlo޵혽.Zw5RRhj`Lj@qG>=-ԉUGb|BlEU®9t1k"~'/h 4ϪX3bS_WߚPl˘Uo6ĂFxД|Z PaQy\,[%s!-U6B/' I:1t$YL|M:lg` Ǵ̬SƉx٫F>9* pȝv$~UFu.+06FHd%s(BLsڢ)sL6O>i_ i>Kƚ#Ő0KTS2D_FQOCuBiۚ;l$XqتvD}Dᇙ7 N tgJ "  X0!ŠW.|)H&Bv1Hh 2`ٰ+]_ŋ!X FK_lh"U|мد+t_а. 5hJS(y( ظ̋!ҳW++FBa^, 0h fʠ#N}=@ ˀ-m%^"#&2fUFPJ&pUn da%yXL1<15d QI/aO0sm _'}m>Y G.lQX0mf6RӃs U̜jdͪKAFqA~el8o8486K>s#/?%3r.{RV̕,D..`oXۤEtQ7!<*xY6յ:ϕcxV=#A̝bN ZUTiHƁ*&Z为7՟کa_VI:$(cP}{KXBX% _ms`S謬D[u 4]ibjPj0*Mؗᐟ^/>x P-zPqסEK:0PhQH|]"w5t^Fl2tZN'w:ܳx0ڳ08\I,z W2 U e^?|W>`YP!?ٸt E4m@L̑pD y%L~ڄh u@2\SHx3ʴtA+ |ASj'F@Ko*0Tcְ xud:`Ο` ?RD6.7G,X/I m?9k&a8eٕ.1w4WFEG3>d/cbz;!z.j/3\C駐M̖^+=,BaVzE}sm[2C;9Xӆ(xF9rzѬ>ɰҍ>vrt;6[ۙ$[&dtmiMO}:1%WfrH^wX˨R[E ZB`d)VoF>%)Okd$ʉv1ɫdn\]fP&x-mGl/} dz!m d͒sum `$-MlW9{MwFv<?|4fhcEiM_2;bͦL$[Z4m)|ړ:`:ii0#5^*j(k.~3_~Z_L[ ^P)QkX)bfHcuuU;v2vNk(ZW;:d!.'-j7Q3YK> Ljӯd0RnE-<!:iLg::.)i|b.4ԉ68JAhr2zs⭔4CB,h[`Ÿ8kAXbEuXQ?2B;pa#h3}/ Յ\L D5>A[Ȇ&r7LZviBȥFt,S߸MwXEđXTKi\U҆B]iD>Xr(jߨ*d q@R5.-ǭqX|&?%O*0Е(Mj5^>NvYvM]TY qlH, À7s f6 59+3J ߣ#q%\!s\wdcqjK㿿h27y! SsSl= )u+<^kb-mH}X4:s!99㹯LPeGϷc)r *4sx Z|tŇ!9pkfm9m݅IּV嶫wxq N6ðWX7v&핬/jxcVR2;8se}tҽ, 3$1̤Wa!w1U9Z4Gxj|7N3Ԉt mFAG_S?\~ q7_+Tט܃<Vg1Bb[`mNRҴïhn m'J(;jMaqbtD_Hmt~?_*,FLθZ5'm:79 HB^bpq~#!|:+`#sI͗i5 o뚀B%l%4>9O5G <@?y0U}Z^p*$WgAD5"^gl#2g=VDt]B#2os2 tS&#+=tqEV 9(V064\$ cS"c^s.Ne?UJNəOCM/l(tl`<@m餞5ǭԣpkvK!jyx1Ĉ&{EϙJa+"0bjx&_ Kv\x p*z 3?3!bV4Ї߀yȎum Y!c:L1%Y4zB~<"/̘j0S!`^,6f~1je7Fk0Ebn]07!G-91_ڕ- p|Cs,%XoJb/K,]=Dvr+VXUG( k-}Ua;K >Z컶a?M l`I-v3Yl]Ŗ;[Y^T}:aR\e=iTxiP9Qy7mO{P%*]prZUe+ʤR׵un7U]tԋUAFz9T`Uz󪳾EUFsEjrQ[dVM8^[4I :YWV;ګߐUUoRãz:olԿ/ IkTL$wf_km4 &$3fՔn94?UЯJf9cb ̎>EgyՒY,8>kh3B[*چvUzuN~wSg ]:jRtQt*.c06ː킮:KHYˌtC|ͧ/6ٱ.VcG;uzw6}ㅝK4&j/w(gd׬sy@i0m0>ozφT:a(ihn:C7&}Ѱ/tl'UX #'1sHUjb':ƟV0.MxX{&m`6t_jOLke:5SSMiLet Fovxws ?\$NhNZok3Uuc1 q#+[ݗ%uJ/-ImNǙlFUmVml1k|X v֟^d] e񯒍ԍֶb mC.5a۵]wz}dNn{Wgrtn0Ă^0U5ޞ';t[ %N%8l1#7;»QH|w&[16BH7 &PymM!ϻaKp ~\0ƒ$~E*.ʾX ӖdJyhàŢjþs&y3b8«߸v {gql>lr&bb88(Nj>.R>%-,bOTk2Z=,Z6ݴ{J _<]ңPm~gr2kǥl$qD|wpBZ濓?N6/ԕj3>]'3{*l5:N374t67zu<.]=c`Ykrdum̛<ZtɪyVpLռnT&ll6ߎt~7۔mvpp+}1X9xC'7T1mzɜڙ^3gU\{Łଘp$\.nM Lu>ͤHӏEWxle[rO<9AXSN}ϝtOx)r 8| Np@QW"gܩFtz~xsZ%*Uyf~ޘ[`hu*DOOas1G#;n7섟`dԘOAiə/ʪarwS9T'љ aVrէ, ̠2`C#fLrf*+R ٟ|Ӌuc2d!5O0}f?f&ħ4Ւ/,HaoHs=h߀7 ƍ%jhQ8,4TJS_8m06€)̟35S?>9ß{=Jiw6uɈy);B4Ժylܞ^PEM3;'!G--++Zco^ su`ތ5>uN'p%jLeKĖjE&%kՑ*Ms- YZPO!5 Q6nP;5Ǧ14TsQ&g-q":WU׫:U1ӳqVbZpuSRP'x&sc8}h9͑悩7KiU^c۾@P SlױlW!T*^v]g[ˊ@gP2PKAJmd[NRzRi)وcK|A|stU. 혆bP/J51B/N8]WٺT@Pveo7Df,TJBjt#9ُ}Un0\K"(1 =.ZjLlWm jJŋ_)(TUp5RAK㒞-jI@2 $`&S N1${|\ jo? 4棉WjH$2Z joQ=Ŧ[ߝ(Ndd:TVIz._iš+ߏd$ $nfe@MCPj%j<~N Q_+)Je)E Pu'rt\y*ꏩI^L:Dk>lڎy^ ZoEцd@T8QP#Ed,)?ðedCy~+[cup"eHClf[q.:#b7W=BGPЙ!TZ@uclmΠӸWm{yP\Xg/C}j`L$S7ڒxTŞISgOg/OHStXI_ϝ\^}ZE}{ mU^¬OOh|S˭5h5i~Ե_ӠvO㦎渌OH+f-쉿^CFQi= RfiߍdwH9쬫Wi_"O/DHW(t,G9}VUH7}]۩M{z<5gf06<\7"lNX_dAe5HQ\jTpԚmS!@`dЧnb/~B/߅CN#{ɻY/C##Tjys=PJYްکG))p_B+Vr=iU*v00|+4ܒP@WAfoлBͬs.k:Ntj0 $7؋CuuPHϹKˇ| #)C*mjxHP! -1=EɪtN'XZ">D/F2l(NӊzKU%)hw VګLD8/eb O hnex?S4,HԹ\aJЃ`]xo42 ӤQ#:z4+S}% S7 [999$ᔖI֚16qUw`VAyV@ C9rPiUtX'1ٗjPJg@/>џ:a(qI3DI[Cg,ɯ!ЖM7#R3 YC1>/iȴ94O+;d//|?|HK\18Hi_AIzŗ@ u-[J{.!\2LNqf :XgTr3$-7zt |qQ4JNΩ Cɛzj!\(^ pВO켌:4/qZ|!N)i;Ï4. :z*Ůp#nG6#8!_9 ~ySlȴAB&bJ*G-l4TM5/Pk0Ej/:/;&~GQ^9J xH?+GZ&FY?rSIy-鲢BLBYΔm@_⎭){7E]d%/i[iNŕu>Ƽ۰Bppy?Ds+ GPgQS]VM)CKqQy'0& 9+yIij/ꠑYao;ZKy0(- $IQ/ݛݑ0ܤIذc&sɂ.b*] nPx0kkZ>5||q|9|-*.A9|-t||kk.瓯僶KknJX'_J_P'_F=]'_w~OrPOD vqkIׁEsԴ-`X{⡑Ar%|%( HUuӿw zubv[sfRXhNLog(ZrO mwF)5D"czS\y'l&3& v6#{4l(%_BF)L1Nq]ztewH45uύ[v,uFf/&B1 ^͘fER 2+_CՇtTٺW[Fi =LKVT>hK#Q&@UhͷIU(SF^#}GcJ23]CvfSÊאWʨ59nԠd`VDFJid?;;"Mz'/H9bQDF`¿g%˧؍|%WD)6玬>GD\$?xNpĻ*C}b`-%{$ g+Ҏtٟ)p#; =y6jfNRL0k3Pߩջ/˲nl1ݩE9lPʷ'>a_*A 4Hk1k#WXPFbLpURћ7O9牍M┑? 7No/+/PQx:^&P Hp 7o΁p%&R$ ,޼1'ͽ eD>7֐~VzKQbH /QA<P|Ʌ tIgpᆤg Ke1&ǽ)KtwY t@ZlȂ ^LHe;̻Pqޫ+]2O=_G' ^]`2_+..]<`ʼ{V/㤏J։2y-SWɬ++v ]7<)NDSpS/o/(Z&\w/8Z qv$pe{P={ `Fe[K6^.̼Wb-!k6i߮E ,_˲N9]e$?z]%wLu'N`kEr֑Y8 ]\#(`G#V9 b6 7yDzǹQ2M& ghowluqo렞/^Y$ z{al"YB~6ڄ,^.vr7p8KdtA"s%T`Gb(Nݨ5%Bi*){StEyEy򮾧{UQ~uӟ/ʣtFc&[+=,\9&W Ug큌lF'wv&n/]QL28`P+`YPFa+.QWR5ukI~ &j?Njwhj&WD"nwiLO*hO?zCV%)儢Ĝ8t[0gtуݩ*7+E _]bOHBӸ$TkK(q'sU&zc*^ ^Olݜ@q @\;sevfkf=rL]rot> W[aN+J/G[9] -q+)|MaXU_b6w ļݟ Īl?9-T}.#7X7gSR/Mj.ժ4N&uu(JA! kJŽ@9aVSJ{Ao$ Dj_o!i񆰷|패'Dzs(F(3IlNa̩Φ\ ”m`!,ƴwJ tAoܱcK(bLz ]Pѝ7E ߠE^F$`A œ&3ݔP ccIf\{EI{?̽oB̛F"IqqpZc{v^7Q]ؽWavO)>D_aێ6M].]*ՎVER5ޔ^r&)5GFKr`7Û?$*57$޲x==,Ix="D\ED{kJK#J:J%J1E7۫$*GI[,Ԉn%0_V%u/+|:e\Vd)PDO\V O^V] JBAe8'eWjvK{C.vl; CH{CE/HͿT+=5T{*|C[v(AЏ_xց_x?Cv( 'v~OT({!CEɰ_xͶ{Cy6Q\x(|?]xxS퐟sp Ԭ*K~ðmoS`a0vx GC6 }9JCv`J/tӅͳyCy.<L!{>~s o;D|+DӅ.qSԅEJ {m /!o X1tԨ,_kX4ctKN`FǦ1U7kL^ȅ\>]43W:ꥬ 'U:gŎ˾VցFXF?5ܐAGixrÕlJ}=rdR>:k5˱,S6ܡHڧO:w)LgF~Ң \H}NڦƫAvbÃ} Lׁ VHKeC=W9#W ]FBZt~/5jOS ?Ͼh s:>>18~Sl+C8ja=V _mw?`?GLo.L>smoE;e3Ⱦ|m9`& N>jm[Ye5gRą2oEj |:g~P:#ͭZ9fD n)0 "@Vl3uzo, ӕګrS~J&4}n~_e gHBD $7flہ-9啖N~<*2lۀꈴ-H}V*oOOg. `}w4w&V"ղ˶r^Ij ԧ&B@hEtQ||j~s@QWTE f/eY)R|`j3LbuF30Dd90D‹9?J"U ދ!u }'@DG㧜*>@~:DB1B G*4L:`pABa"])7h )h9u&S `&Ɵ_C- aOB K3Cؾt38[;}Ϗ!?r#F0h`0FaWyzn B bauET"XCݿ}a8+a)aIݺGU:vbJ:^j`]| M p:LX_{oƑdKnHgL)J$ QEz@] /vFE_#lזdV0S!dd!dpdV[=/_m&2Ɔs3#ފ5Snֵɼ D0PJ_|VDWȖH,(uC,ǖ՛'[Rס BC1e.gUUgt7{oP^U.+Jڴ>969~r WU>%[c 6bEDVPqf;4=O5!^Ƨe9r*w1c;vnX?byk:M\ _=efȣ=z9`4*{ËJӴ GYen[KLm4j92XXXylym3:ΔuzC7;HW c1&˃ 4tq|Ɓ9:K[u+,7ads!}̒.<*m 4^jSl_Q:>4)=y)=&bԃ:pJR)O6蛰f7,}a5ksq{%,!v0gpCơM65.1΁E6VLxpvFBޮeƨژSƌ 波RA|$eTܘC ]vCte]2F>"ZU ^RMvjwty1qe9X1fZ KkĝDOOǽfu*v%ҍ}^u2mޣ+lgpa\:'5XM-YK7Y9&-:3!-i.X=FξApRr-}8U'חZ1Shh^}`kqeAR̽b4LL:WNAb2| 64/Ì/͍H_7r:Խǂ]-|Kwj[vF]h붇}quiAxw\ʼnXغF0ɏ G'n[XJ; NLv*kō=¬J$K+vm,/l{Y.Jc_+2'{ƙ;B=l F.S|£G)hZ/KӝbF3CDfLϵ{fWɬ=y: ԤW״N#j[}TMs{p6"ةމH. A,]cQp lfeԳ(}(}e+46+v8Mw˾9RAss9'e$-Yؐ|`/+VA_o@,fӍ1tAw?C7AӣJc~E^FhҢ'@r7Q\[wF)9,oJa*M74sEK7l'yMho Z{$ hy3CLru A۴v%[ˋ%MK:!*O'0<@Vfѕt{ae88΄jRrS!bTF;yt' 1}EƖqkU֟PŃTpϐ0{-4yR,tUOy2ek4J3$/]/E ʾ&C¢b8CTgJm%[2ʜH&9m!#towd}ϊ*h֪Wi\nw5BCt] AٗoI43İovNCwZG R7D*Ij!e=b/"Maۈ4 5n;F;e6zE lM;%2V'O0խu:K=s|cyZܐ6U]:;H5hCuRX41{4|}i^}mǜ@KVxmKЅ-4m:MCrkɊYm]Dl dQ=ƪv)/K띛htT٦e 2U%v4Bo@%޷}\ooe;ˤ2)5aQw*Mi>8)/:B 2HVoay ]R7:v<8,B0)ÐBJ>4Xs厶EzojqɷM>%i.O !6S.v z//kXžPYc`KmΘ@2l;V A+a}d8r%k#C(KRA_^]Eձtyam+N]~TY%Z fvΕqGyF_y1o&u}"-5Q'bjxp#!MU2'TEttgxjEڣahA:zV6|tI3?W[q*uЈA2,S\_m3(iVIo" 7?,^ o!d۽&/ ~m3+#f;mHk/HxAt> hV |td Nc]|l᪥l/kB-)B4.C< z&n41\޷$_׉ZBҏ}szv(KWH~R&.3oB,c!Ia*:Mt텝8Ai$:&_E@ehiT盎V+0j<ⰟI^+笑^`EknsN.¨@ ~im@_rDIQހ4w'FZҮ.y ,7An2*؋ȱQ8GWehds΁2apdnfkd賧-6'37r7@*̲A,S~[U,95 ̀npZ=z(1VqZ>0Wuڙ$ûTxQʥfpz,l)6W.#m=Zr{t`:.ȱ.&=^fٌZˍ]}0$QqkCC;' =ĎH4}j4DnY}GɃAc(#2)2s{5%edxN|Ԇ^8g#mtY-_i4Km(/龹OwPGjW;<ˡ]$uDU9*׈M`0 Ε%r=粯s%B]}0z%cԗNr%1ەSF|=!V:s<5lgo\Y?X<뺔8w 9/VvXJW#f9NJ1E/PoSi}Y\b0O9^ںgZa#qIsjP ]3tD[I9vIoANz}0nq]xp%"MbG(J,X%6 *nw:2tq }huӁiNn&p WaGv;exNw>W̰֭Z4 KF1w`W i~PFiZ:\-Om]ըb8/*+ߒX :./L̜k=sۻoo0"$dD͆}#KUJZvꉑqgg`gDNUrtY,-qL2.ide<$]B{]z/kpN`,^L7Iit :~-~#@8@DZ{ i(tC͝am'/@rػ/({d\ݯA8ZKJ̃)قFPܳy%:4+P)ĐpR̚awy/yOkn؝3ء~t4Q &Vdٜ9{eccI g|h;= W0o [ӟgԗ5e4K8~Ob2x}0[H4ynT_0B; $+ 7ی 9qSC "LYe`pPpIE;e_%jS"Sj PzښyÂ@k>)ŅM=PC1A̅DWH}W.v'o0 ۝]\kp69K}3L78P lp&/_Zcn_cZY.jlD*3#7Y8VhgYU9ܩO6" ttF)$ڞ4 ݺvm>ۉk{h^Ԉ0uEȏYѱE/\>ӜKY#[L;4,h h5iEkY>ӕt>"OI]IeѢ]Ie+Ź#Ǟb]Ie'ڮ$ݓudKzHlZwIE'6'HsHZxC@{'YJ[V˜ːx k4t(v9Ȍ0i#pqkr _R-|xXdZpͩ5hA:!7t1۩v1b\mYɱ>7&jy\mǶ#_"(+Kdqxq)܍`|;ץۭQ^g 3ruc R`|Xn;z&pz{c2N!(6Ba7h`Ylڷf|PX i90 ϩs_ lܜ}jg2m9z(phI; sO a&3wwny<0HSۘ;עQ&fu4aRye{x.çifa ' xwa{ 6+{]i?KJ\_Pñ],2č o|/}[N:zj7r6KK0_f//(r]C^]Jk+d{ٺ6z#u͚f4lzr:G rbis/c czfDml#r'1};Jʁ csyBYǓMdOQMP-,M=i=Ie`"ty{i2G:C_lI;.'×W{1k\|skbҧ}T!j+d$-T)0 YܫlOo獚s AꩁO)vay4WFGP;vC srFf3ᣓf^v {I9]Cv_ eqdl{:kSrm_9D.zgfx1VwLZ'8DBbD(}kl{:M:5,ܾ\miWiKCKCq&dvy8M;_3-b0%m> օZۆ;J gaҖnaFTg4{h%303ON2ʾYUy3A8"=Ⴛu}&C0#5(U2nn:%x{&Q"/ӑ9<͕*x$җxslϦ a&DۑN4R?sC;ag v#܌J4ňHg'_RwI0͜Ŷ!b٦tޚ Q/46Ze*ffY \ۆw\mEn^3Q\=->; 4{o5_,he<"Ac:;КmʜvRvE"-&T۫{ צj ڲd۵/<&{%jaw{Ȋ s,W+Uֶoo(?4)qlt-1I _c݃Kǵ@yٱfgNEzҏ6Ωl@F HāNzkR]j븶o:滖o4jGf' M쥚ٛa;[Mwt3;6{]1зe/4&2)ߑ ~22m*{4!~`О=PXtMOomnҭg 2&IdjN2u(t \tlLbݍ`9iM حD언ؗٺw5Ƅ/7usMу^0|aùAʶPɔ^: M@`'0Y- _f'o,EaKgJw_ׯBiӥh fra J8o(z 礭[ۯfviCc"Ick1/9&@/ܸl3$^n|B4cQ2 Ρl+-$b2+/=0bJK(ˬ$b*ߊ/=we1*ˬ"JKs]e_"u _hjVCN@W5;s8xMtA͗SF5F:4'lZ-Ǒ#іQ#Υ[똒PFohZG*o=\MmK\%Z77ޙ^XpVr+Kwe{Qv{y{z"aN}AR( hdI<$z$(w%ͤZg=ִB/wP[Ox3щ/N/k72ji䱸WI5mƽzD|pm vP٣5r(xi{ETm=#j<Ų9<5y vɖGޮ#ݦ`lzؿ`汸CnoUmofִ k\KN4Ś꣫Yk8ZW;uGzɗ9= Sco=4[?fJ0v` wUӴmRUyJ0%hl&Hk6]ݎl8|"usB#n@U߻F@oDL?Yez 0ARVJmשHr3IK[`rs+54J'3$YdM#4H\4z݂&0x Z?M% ]4mXLqrw%:qr;.AӸQ"c)@>݇OI 团!#v%E]] I~]#jR;/&L|B' ?H/!r.pjAR$]GC,ׄz԰IbXPB_ j*E4H\rLEZ"B~U$*VU KP!(T\ˡ3QPPgZyaiiOA-r [yӨeT{>a1,<7ǧ!o$b_wN*]2GS~Mjv+*A"/``}N]JD(k_W%uNS#O[bՌSU`PdY%zhÊ=6cb'9Hn$߷HkC-Vy~hA{zoMv[!4P,zrvdPX /[ͫDx۹U;U#8Lt  = cJ(E =bskIsE5_bj7Jb27yj797if]fWN/[ 5[ 5[ 5[ 52\ nvHo Ȱ@zkdm 526vHo dm 526aȰ@26uȰ@26a dm 526a dm nvȰ@2*pN6a dm nv/6aW1?nvϜίȰxڀ l*"'pa$Z@]Ez᪠0Hr8^dB0\6.3$E2*ʠI.eӋ$ dUTH/\UI·*ҋ$g"@]K" ҋ$׀|\cz7y<̗[ ip yu7oFs7%o. 777E7I7s7[7[7s'7Cos7+oF̈́@nk$f@Vk_ /{>uu l8}yΝ=:&މ'z[_e-D 8s=uw>CDRU"v?G|٧;s}U\p|/'÷O|bu 5">}=} |trOkm"Db-ɭk8s!'tM{\w.bD?ȍ^8SVqܡ;s" e//M!wRPd5x|K@OJ~ߎ|X?u3ɵɏ!^nݿWLZn),7.EeobVz<:Z%]&巋˄Vʮ'%B[[UE;ua魪 5zxnǎ~WUT[KNm<\]Dwcq\dG:p FC/Yñ+Fex}>ϸbT|~qè8n8=~QLNl}}r.Co~KCE}bQIag*l 5(jvaN5_TV3[Rgn qKVܟr_y.|kROdլO7j@0 Z져f;(;[ Z{J?7ޗ5!P{aϼ&pUj؆WFR~iTԧĬ6ְ2=ֲ2>z0sVL }Sjͪ0GW8H}!}`k+|`k,x`k+|`k,x`k.}.Ry]a>+S;sx\|M|ƈ0abɚ6=a6a.a~92|0ԡ68M['ÏXGb&)GN98\^zg=e|kWƒ[] u.r\h۵e߂n-0[(vhoŒj83ժGК)BDh {F)Fb`N`(>P|4 JM.%r WD 5jR"gK3$!#Œ:Uz@)VZVkJ(iaFP8[d)mOngWn=9#Owa6 UTQ\F:wr.$N-v8w+[7l178w9$OHl'ۻLٕjnJm O¨92Il0i4y%!ii/)r!J(&kAr!(']:& p!Õl}O~M%A (Ž=QȂ*~) %MV%E])F%uIݦ*YSLE3QuTRd$Z)䋖bVUvo@hH5Q#$tUePJ)ցAr!!]U`4iz[<ư Dk4ӛ9'QslW(\.SDc[[}HOȘ9:XOo6[F6vגX8>v-Hޏ|}፤vSȘ$]M~D!0LΧ6v[cb'9H ݿo%\ ګO4ڬ*=b~*Y%9uG %կ(j:m8g5 Nʜk2+ʡhkwI"!,6ّ;_]awZyä4jЙf(tgf.{.39i.IjS JNWLu褈FE iyEH կJr &$໐hީ~=,YuU3ni riKsqJRsahnhn(TD%dq=J"6BXȠ@2_6]Ԡ&$b)s@?7 )D&m9DhJ0KU&Ƣ{csĉ@ @ @p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w M(xk uy, 5z@~g_>/}WCc30=zźΆ^9_{?|/׿P~?=ဟ~0}yѓf}>m7MkG(ANۖ᭻.z02[m%_ZOΎ Y6 IVD endstream endobj 225 0 obj <>stream =/]P RޫD3Z.r$F6Hvvs kl[H;@ @ @ @ @   's;_<2 ]/}_ 6zźٺof_??jL絛E(tQ:JGՓuTuDw6w&ww] bX Qeآo;$J\EMeJLÂ]%y*Z:mҖ'7H:M+[ސHs<*T`[S9p>AcD$jC' dr> ߢ;ɱFuHA}+䚃ɒTnEala颤Y*JXD8F u\5Qâ#r4)G+*7Ea#!eA ~+hPDRK挪* x\i]_R%-wp0~3{xzPr9ifV`Pqک~]Q哃_ 2 d4 Of% jA"$Hnc%E06 br0HuP_ndRZirT?/]Hj.˸RdQ.ӊkޗtG=G',H!5 $vt!Rmg ~ :%%4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;܁&4w Mh@p;Q3yN <롳D=> ?{M~y<'ޟa * * * * * * * * * * * * * * * * * * * o8OFgTr?9Oj}VmsPj#8LxF=͑1-NRf}O㔧( =O(L~Iհ$ YVB!P4Y @?7KX dC{CD$AZPSDQ"yCaBhc{x%ʺ#%EYnV$5$XC RJYBwYIiDVі[&o$[,)JXH *ɃM Y "h8i ^p^i iq75渍#yґl)8$O3Dc[[}HOȘ9:XOo6[F6vגX8>v-Hޏ|}o$dQeE?:C-D oMv׃ͭ4m#@,zrvdPX ղѾHT !A۹oM/]PRެF3.rvs ,wV92@ @ @ @ @ *0\0PLvսjh..kn,O?'nLelQ rXs3He3HA?wqt*B3~['Hf]_*mG }>Bl6x x)xyxSxWxSU2xV&xV,xTxwxS5&27xRe3pI5ׁHo Ho Ho Ho Ȱ@zk@zkdm 526aȰ@[ na @26aȰ@26aȰ@26a dm nvȰ@26a dm nvȰeWtnvȰ@ tȰ@Ƴ,Wlzv9F|"BQ^dr0\ E2$ECI #ɥTzRQHr(^$HTB/2\ *dI.E Z"9Q]z A/2lK # ы$feԆZ\{z9rk7.7if&52xәxSx2x3TxWxSU2xVxsVxSUxwxS5&2xSx%6m-* P~YN=wpW[{'gff'z[_5!I w=uSי0+L_?/ʄOR5""v{??÷;ޙ#O5TCn]sD>5OM쓿A7;]\:&"H9A7_[ 2Y7frO1?>QdZϙSe̅!`Cn™r[|Gɴ~tyBAS<|TLC鸞%ɬkedroK>O),?<|㓲d⥰L}ߕYPXn8_PeoqbVު]K,a_1bAkX~Lhx}1b,8gj)՝VuRo-]8h8;~]u=ѺjTp֧%>~]c֧5!Q{I?7ޗ P{a^ZL(~W&okA0OvfDJ ;A [~G1+1g׳a*TiueeJWZVtuieLjV:=T*lUbRQ*J=Q*Kϧӫpq]qI5prQ{w oӕ Eu8ԢEMaZfQT n/]2swE'j@zs劵gYi˾ A@"[uO߂ g|U_χՂ!n.[pFaQՉ3@QxFU' W)~ ->U"LrZy [->6b +6*WJiܲk_L,.@S#ⲂT ñrjXy"'DeQ?U?"ˌkDqxC"-?(r jFHYNT"fZ66X `a{Vzl;؆ ŬR`aeڭby7V626ʸ9(- $$g6*GsPj#8LxF=͑1-NRf}O㔧( =O(L~Iհ$ YVB!P4Y @?7KX dC{9D& JEرI$KM ($]5YD9iUٯ:};3Li53-$ePʇNQҬ5K'+R(lkY1nJXׁGMWu"\ZP@Ő*pa! gwXIRO1I|B`x?Om·Nr,|tJ mV['҆ Z(mSh#6u7e\&7RfF2 c׶֓#i疍vI f8$$IEn羃~62RO)DoW?rѤmmJP4cE5T(< b΀>Tמl-5&xsY+y*9+9+y;xY2xә |޼M^@zk@zk@zk@zk@z2\ 5[ naH@kdm 526vȰ@zkdm na dm 526a dm nvȰ@z26a dm nv8vȰ@26a dm nv2*0m nvϜί ϲ[_a lzvF"UAa q"p@]E1"ɕx2Pp(a$$A/2\cI.eӋ$ E rz¨HrNT^$9UI%zdWEk@Lr}>I1\}~HH@2ov -dT ެTY<+y*9+9+y;x |޼xsP [K K ρ:W%6>{|Ν=d&73m \u/;y=uYx8u})7s~,|٧w3Gjܺ皉7gz???|_}7s+ 2[b[DWOz>%>ފT*אdžB!7+kI"ӂ^SiE4垪/g.DKip\>d܏|Yy G<_ gKd2TI,7яOʕ x;^-/!GZ) Ro)jBEТVWhb-(j\ԟV](҅SǺSުP"DZ8n8;~]Յ>{Sbt>~s>{}9MK]~+lsMp.zF5Fes\we#WG[- p~p_>s(Pص>.lݩ&תCֿVUbQ؆Wu[S|jQsЛpӪ֝j mN7P/޳jdPr_yt]q.|NYoԀ`Jŵ"AqvP\w5,Zn/kB0&Q6z5!P6mք`B񷟦akhVaM̬԰`3Ȭİ̜gR߯JA*y\?jI㚖ZՒZՒz~W3-+|WNmsʣW23Z323 43aa +Cz.zMdx=>KNZ>^iNx> |VV8"qZ1:i%Ϫ/nN|"8EipH})҈STU5Ŗ jrb+ ,)D< pD&wJ XѭGn].1`D*&`V'?rY$oa:zmxfzs<'C9jj<e9alkpk7ؿ/ti "Gk~ Ԧ7t Z2 ǧ%7qvׁϸ~9nPbD$jC' dr> ߢ;ɱFuHVn0 y4UUi25T~URtꤧ;Q/X(K¸ 5R)WeEd]RjA]Q+Qdj+OqA2 i0"TH;*%|H$5&J~9,4 FI#iA5G"vOʥLwis9ow뷷S+ZP k&f֒8:$uvLݝݣԆpp;vv7r:9Hs9>UOg0d*Vհ?GH %5$O&s%*{rZ ((IUThC5nU"kEӤe2 (n8n@8HR!GтI,ڽP8hlVQ i%h$_ϒo*#Ie_u!2y"BŜ%%ɹ.ŵ)d~r$4$6W[waʽyx-)$R)(MrIOܓDB |O{b> endstream endobj 136 0 obj [/ICCBased 164 0 R] endobj 8 0 obj <> endobj 97 0 obj [/View/Design] endobj 98 0 obj <>>> endobj 120 0 obj [119 0 R] endobj 226 0 obj <> endobj xref 0 227 0000000004 65535 f 0000000016 00000 n 0000000162 00000 n 0000051583 00000 n 0000000010 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000173025 00000 n 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000173095 00000 n 0000173126 00000 n 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000081641 00000 n 0000081502 00000 n 0000081881 00000 n 0000081310 00000 n 0000173211 00000 n 0000051636 00000 n 0000052187 00000 n 0000053821 00000 n 0000089447 00000 n 0000059628 00000 n 0000089208 00000 n 0000089322 00000 n 0000055198 00000 n 0000055500 00000 n 0000055800 00000 n 0000056099 00000 n 0000056401 00000 n 0000056702 00000 n 0000057004 00000 n 0000053885 00000 n 0000172988 00000 n 0000054633 00000 n 0000054683 00000 n 0000076806 00000 n 0000057904 00000 n 0000079079 00000 n 0000076870 00000 n 0000074749 00000 n 0000069579 00000 n 0000075709 00000 n 0000074813 00000 n 0000073074 00000 n 0000073801 00000 n 0000073138 00000 n 0000071018 00000 n 0000071977 00000 n 0000071082 00000 n 0000069247 00000 n 0000070072 00000 n 0000069311 00000 n 0000064718 00000 n 0000067003 00000 n 0000064782 00000 n 0000057306 00000 n 0000062371 00000 n 0000057370 00000 n 0000057950 00000 n 0000059665 00000 n 0000059721 00000 n 0000062487 00000 n 0000062553 00000 n 0000062584 00000 n 0000062852 00000 n 0000064605 00000 n 0000062927 00000 n 0000065315 00000 n 0000067119 00000 n 0000067185 00000 n 0000067216 00000 n 0000067484 00000 n 0000067559 00000 n 0000069625 00000 n 0000070016 00000 n 0000070188 00000 n 0000070254 00000 n 0000070285 00000 n 0000070552 00000 n 0000070627 00000 n 0000071436 00000 n 0000072093 00000 n 0000072159 00000 n 0000072190 00000 n 0000072458 00000 n 0000072533 00000 n 0000073406 00000 n 0000073917 00000 n 0000073983 00000 n 0000074014 00000 n 0000074279 00000 n 0000074354 00000 n 0000075166 00000 n 0000075825 00000 n 0000075891 00000 n 0000075922 00000 n 0000076188 00000 n 0000076263 00000 n 0000077404 00000 n 0000079195 00000 n 0000079261 00000 n 0000079292 00000 n 0000079560 00000 n 0000079635 00000 n 0000081384 00000 n 0000081416 00000 n 0000085015 00000 n 0000085042 00000 n 0000083112 00000 n 0000082114 00000 n 0000082401 00000 n 0000083414 00000 n 0000085491 00000 n 0000085803 00000 n 0000085873 00000 n 0000086147 00000 n 0000086228 00000 n 0000089523 00000 n 0000089724 00000 n 0000090715 00000 n 0000099732 00000 n 0000165322 00000 n 0000173238 00000 n trailer <<8D220F579F017D4EBF93816C83D3172A>]>> startxref 173419 %%EOF buildbot-0.8.8/docs/manual/_images/overview.svg000066400000000000000000003517301222546025000215420ustar00rootroot00000000000000 image/svg+xml Overview Diagram Adobe Illustrator CS4 2010-01-28T18:16:02+02:00 2010-01-28T18:16:03+02:00 2010-01-28T18:16:03+02:00 256 116 JPEG /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgAdAEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A635981f4X0aTV5BJcmTU fqgWa/ntIUE1y0Yd5FEoRIxuaJ0wIYtbfnl5XazuJrq01a2msrS0vLuFryMELeiD0wgku4pGX/S0 q7IqjuRhVNn/ADV8rJAl1w1g2fC3M9yGmIhlvIvWt7eSP1vVMkilacEYVYb74FQMf5z+WZrSS6tr LWblIltpHWC6gkIS8na2iNVvin98gVl5cl5Co60Ko3UPzV8tafFcTXkOrxQW/wBZj9b1WZXubJOd zbJxuT+8iAapNEJU8WOBURof5j6DrWoWFhaQauLjUoxcW3OV+BtwZFkn9Rbhk9ON4eBIJqWTjyVg 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 irsVdirsVf/Z uuid:9E3E5C9A8C81DB118734DB58FDDE4BA7 xmp.did:0D9190091A0CDF1198A8D064EBA738F3 xmp.iid:0D9190091A0CDF1198A8D064EBA738F3 proof:pdf converted from application/pdf to <unknown> saved xmp.iid:D07F11740720681191099C3B601C4548 2008-04-17T14:19:10+05:30 Adobe Illustrator CS4 / converted from application/pdf to <unknown> converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FC7F117407206811B628E3BF27C8C41B 2008-05-22T14:51:08-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FD7F117407206811B628E3BF27C8C41B 2008-05-22T15:15:38-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:0CC3BD25102DDD1181B594070CEB88D9 2008-05-28T17:07:17-07:00 Adobe Illustrator CS4 / saved xmp.iid:34001D5FB161DE119286837643AC861D 2009-06-25T23:53:30+03:00 Adobe Illustrator CS4 / saved xmp.iid:35001D5FB161DE119286837643AC861D 2009-06-25T23:56:39+03:00 Adobe Illustrator CS4 / saved xmp.iid:01FC8385150CDF1198A8D064EBA738F3 2010-01-28T16:00:41+02:00 Adobe Illustrator CS4 / saved xmp.iid:02FC8385150CDF1198A8D064EBA738F3 2010-01-28T16:00:52+02:00 Adobe Illustrator CS4 / saved xmp.iid:03FC8385150CDF1198A8D064EBA738F3 2010-01-28T16:05:57+02:00 Adobe Illustrator CS4 / saved xmp.iid:0D9190091A0CDF1198A8D064EBA738F3 2010-01-28T18:16:03+02:00 Adobe Illustrator CS4 / uuid:15cd64bc-c4e3-417a-b6b2-355ff7b02729 xmp.did:03FC8385150CDF1198A8D064EBA738F3 uuid:9E3E5C9A8C81DB118734DB58FDDE4BA7 proof:pdf Basic RGB 1 True False 800.000000 600.000000 Pixels MyriadPro-Bold Myriad Pro Bold Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-Bold.otf MyriadPro-BoldCond Myriad Pro Bold Condensed Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-BoldCond.otf Cyan Magenta Yellow Black Default Swatch Group 0 White RGB PROCESS 255 255 255 Black RGB PROCESS 0 0 0 RGB Red RGB PROCESS 255 0 0 RGB Yellow RGB PROCESS 255 255 0 RGB Green RGB PROCESS 0 255 0 RGB Cyan RGB PROCESS 0 255 255 RGB Blue RGB PROCESS 0 0 255 RGB Magenta RGB PROCESS 255 0 255 R=193 G=39 B=45 RGB PROCESS 193 39 45 R=237 G=28 B=36 RGB PROCESS 237 28 36 R=241 G=90 B=36 RGB PROCESS 241 90 36 R=247 G=147 B=30 RGB PROCESS 247 147 30 R=251 G=176 B=59 RGB PROCESS 251 176 59 R=252 G=238 B=33 RGB PROCESS 252 238 33 R=217 G=224 B=33 RGB PROCESS 217 224 33 R=140 G=198 B=63 RGB PROCESS 140 198 63 R=57 G=181 B=74 RGB PROCESS 57 181 74 R=0 G=146 B=69 RGB PROCESS 0 146 69 R=0 G=104 B=55 RGB PROCESS 0 104 55 R=34 G=181 B=115 RGB PROCESS 34 181 115 R=0 G=169 B=157 RGB PROCESS 0 169 157 R=41 G=171 B=226 RGB PROCESS 41 171 226 R=0 G=113 B=188 RGB PROCESS 0 113 188 R=46 G=49 B=146 RGB PROCESS 46 49 146 R=27 G=20 B=100 RGB PROCESS 27 20 100 R=102 G=45 B=145 RGB PROCESS 102 45 145 R=147 G=39 B=143 RGB PROCESS 147 39 143 R=158 G=0 B=93 RGB PROCESS 158 0 93 R=212 G=20 B=90 RGB PROCESS 212 20 90 R=237 G=30 B=121 RGB PROCESS 237 30 121 R=199 G=178 B=153 RGB PROCESS 199 178 153 R=153 G=134 B=117 RGB PROCESS 153 134 117 R=115 G=99 B=87 RGB PROCESS 115 99 87 R=83 G=71 B=65 RGB PROCESS 83 71 65 R=198 G=156 B=109 RGB PROCESS 198 156 109 R=166 G=124 B=82 RGB PROCESS 166 124 82 R=140 G=98 B=57 RGB PROCESS 140 98 57 R=117 G=76 B=36 RGB PROCESS 117 76 36 R=96 G=56 B=19 RGB PROCESS 96 56 19 R=66 G=33 B=11 RGB PROCESS 66 33 11 Grays 1 R=0 G=0 B=0 RGB PROCESS 0 0 0 R=26 G=26 B=26 RGB PROCESS 26 26 26 R=51 G=51 B=51 RGB PROCESS 51 51 51 R=77 G=77 B=77 RGB PROCESS 77 77 77 R=102 G=102 B=102 RGB PROCESS 102 102 102 R=128 G=128 B=128 RGB PROCESS 128 128 128 R=153 G=153 B=153 RGB PROCESS 153 153 153 R=179 G=179 B=179 RGB PROCESS 179 179 179 R=204 G=204 B=204 RGB PROCESS 204 204 204 R=230 G=230 B=230 RGB PROCESS 230 230 230 R=242 G=242 B=242 RGB PROCESS 242 242 242 Splash 1 R=214 G=149 B=68 RGB PROCESS 214 149 68 R=71 G=152 B=237 RGB PROCESS 71 152 237 R=42 G=81 B=224 RGB PROCESS 42 81 224 R=180 G=58 B=228 RGB PROCESS 180 58 228 Adobe PDF library 9.00 buildbot-0.8.8/docs/manual/_images/overview.txt000066400000000000000000000022321222546025000215500ustar00rootroot00000000000000 +------------------+ +-----------+ | |---------->| Browser | | BuildMaster | +-----------+ Changes | |--------------->+--------+ +----------->| | Build Status | email | | | |------------+ +--------+ | | |-------+ | +---------------+ | +------------------+ | +---->| Status Client | +----------+ | ^ | ^ | +---------------+ | Change | | | C| | | +-----+ | Sources | | | o| | +------------>| IRC | | | | | m| |R +-----+ | CVS | v | m| |e | SVN | +---------+ a| |s | Darcs | | Build | n| |u | .. etc | | Slave | d| |l | | +---------+ s| |t | | v |s +----------+ +---------+ | Build | | Slave | +---------+ buildbot-0.8.8/docs/manual/_images/slaves.ai000066400000000000000000044064361222546025000207730ustar00rootroot00000000000000%PDF-1.5 % 1 0 obj <>/OCGs[8 0 R 94 0 R]>>/Pages 3 0 R/Type/Catalog>> endobj 2 0 obj <>stream application/pdf BuildSlave Connections Georgi Valkov 2010-01-28T16:16:25+02:00 2010-01-28T16:16:25+02:00 2010-01-28T16:15:18+02:00 Adobe Illustrator CS4 256 68 JPEG /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgARAEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FXYq7FXYq7FXYq7 FXYq7FXYq7FXYq7pucVSn9IXeraf6uhyLCGl9P61dQycTGBVpIozwL77KTRevXvRxmcbh8z+hyvC jinWUXtyBHPuJ6feiG0ez+vSajEvDUpIzF9Zq5FKbApy4kAitP6nJnEL4h9TWM8uEQP0XdKH6TuN Ms4TrbK8jSGNrq2ikMQH7DyD4/T5dDuRXvkPEMB6/mPxs2eCMkj4fdyJF/DvTXL3FdirsVdirsVd irsVdirsVeZebvzLXyvp+n32pTX0v6SWSRYbGK0YRrDAbmUn1+B4pGpp8TMfc4qh5Pzl8nxRvJL5 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 /wAqz+t2/wBX9L6x6qehX1/7zkOH2tq8qUxV/9k= uuid:fd772761-a3ec-4632-8af9-c0442bd7dba6 xmp.did:05FC8385150CDF1198A8D064EBA738F3 uuid:5D20892493BFDB11914A8590D31508C8 proof:pdf uuid:966e1fd0-a1c0-44cd-b901-9f7f640f0ff7 xmp.did:530E91AC4863DE11954883E494157F9B uuid:5D20892493BFDB11914A8590D31508C8 proof:pdf converted from application/pdf to <unknown> saved xmp.iid:D27F11740720681191099C3B601C4548 2008-04-17T14:19:15+05:30 Adobe Illustrator CS4 / converted from application/pdf to <unknown> converted from application/pdf to <unknown> saved xmp.iid:F97F1174072068118D4ED246B3ADB1C6 2008-05-15T16:23:06-07:00 Adobe Illustrator CS4 / saved xmp.iid:FA7F1174072068118D4ED246B3ADB1C6 2008-05-15T17:10:45-07:00 Adobe Illustrator CS4 / saved xmp.iid:EF7F117407206811A46CA4519D24356B 2008-05-15T22:53:33-07:00 Adobe Illustrator CS4 / saved xmp.iid:F07F117407206811A46CA4519D24356B 2008-05-15T23:07:07-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F117407206811BDDDFD38D0CF24DD 2008-05-16T10:35:43-07:00 Adobe Illustrator CS4 / converted from application/pdf to <unknown> saved xmp.iid:F97F117407206811BDDDFD38D0CF24DD 2008-05-16T10:40:59-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to <unknown> saved xmp.iid:FA7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:26:55-07:00 Adobe Illustrator CS4 / saved xmp.iid:FB7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:29:01-07:00 Adobe Illustrator CS4 / saved xmp.iid:FC7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:29:20-07:00 Adobe Illustrator CS4 / saved xmp.iid:FD7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:30:54-07:00 Adobe Illustrator CS4 / saved xmp.iid:FE7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:31:22-07:00 Adobe Illustrator CS4 / saved xmp.iid:B233668C16206811BDDDFD38D0CF24DD 2008-05-16T12:23:46-07:00 Adobe Illustrator CS4 / saved xmp.iid:B333668C16206811BDDDFD38D0CF24DD 2008-05-16T13:27:54-07:00 Adobe Illustrator CS4 / saved xmp.iid:B433668C16206811BDDDFD38D0CF24DD 2008-05-16T13:46:13-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F11740720681197C1BF14D1759E83 2008-05-16T15:47:57-07:00 Adobe Illustrator CS4 / saved xmp.iid:F87F11740720681197C1BF14D1759E83 2008-05-16T15:51:06-07:00 Adobe Illustrator CS4 / saved xmp.iid:F97F11740720681197C1BF14D1759E83 2008-05-16T15:52:22-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FA7F117407206811B628E3BF27C8C41B 2008-05-22T13:28:01-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FF7F117407206811B628E3BF27C8C41B 2008-05-22T16:23:53-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:07C3BD25102DDD1181B594070CEB88D9 2008-05-28T16:45:26-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:F87F1174072068119098B097FDA39BEF 2008-06-02T13:25:25-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F117407206811BB1DBF8F242B6F84 2008-06-09T14:58:36-07:00 Adobe Illustrator CS4 / saved xmp.iid:F97F117407206811ACAFB8DA80854E76 2008-06-11T14:31:27-07:00 Adobe Illustrator CS4 / saved xmp.iid:0180117407206811834383CD3A8D2303 2008-06-11T22:37:35-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F117407206811818C85DF6A1A75C3 2008-06-27T14:40:42-07:00 Adobe Illustrator CS4 / saved xmp.iid:32F582E93563DE11BB48ECB7764A1480 2009-06-27T20:06:51+03:00 Adobe Illustrator CS4 / saved xmp.iid:530E91AC4863DE11954883E494157F9B 2009-06-27T21:32:58+03:00 Adobe Illustrator CS4 / saved xmp.iid:05FC8385150CDF1198A8D064EBA738F3 2010-01-28T16:15:18+02:00 Adobe Illustrator CS4 / Document Print False True 1 792.000000 612.000000 Points MyriadPro-Bold Myriad Pro Bold Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-Bold.otf MyriadPro-BoldCond Myriad Pro Bold Condensed Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-BoldCond.otf Cyan Magenta Yellow Black Default Swatch Group 0 White CMYK PROCESS 0.000000 0.000000 0.000000 0.000000 Black CMYK PROCESS 0.000000 0.000000 0.000000 100.000000 CMYK Red CMYK PROCESS 0.000000 100.000000 100.000000 0.000000 CMYK Yellow CMYK PROCESS 0.000000 0.000000 100.000000 0.000000 CMYK Green CMYK PROCESS 100.000000 0.000000 100.000000 0.000000 CMYK Cyan CMYK PROCESS 100.000000 0.000000 0.000000 0.000000 CMYK Blue CMYK PROCESS 100.000000 100.000000 0.000000 0.000000 CMYK Magenta CMYK PROCESS 0.000000 100.000000 0.000000 0.000000 C=15 M=100 Y=90 K=10 CMYK PROCESS 14.999998 100.000000 90.000004 10.000002 C=0 M=90 Y=85 K=0 CMYK PROCESS 0.000000 90.000004 84.999996 0.000000 C=0 M=80 Y=95 K=0 CMYK PROCESS 0.000000 80.000001 94.999999 0.000000 C=0 M=50 Y=100 K=0 CMYK PROCESS 0.000000 50.000000 100.000000 0.000000 C=0 M=35 Y=85 K=0 CMYK PROCESS 0.000000 35.000002 84.999996 0.000000 C=5 M=0 Y=90 K=0 CMYK PROCESS 5.000001 0.000000 90.000004 0.000000 C=20 M=0 Y=100 K=0 CMYK PROCESS 19.999999 0.000000 100.000000 0.000000 C=50 M=0 Y=100 K=0 CMYK PROCESS 50.000000 0.000000 100.000000 0.000000 C=75 M=0 Y=100 K=0 CMYK PROCESS 75.000000 0.000000 100.000000 0.000000 C=85 M=10 Y=100 K=10 CMYK PROCESS 84.999996 10.000002 100.000000 10.000002 C=90 M=30 Y=95 K=30 CMYK PROCESS 90.000004 30.000001 94.999999 30.000001 C=75 M=0 Y=75 K=0 CMYK PROCESS 75.000000 0.000000 75.000000 0.000000 C=80 M=10 Y=45 K=0 CMYK PROCESS 80.000001 10.000002 44.999999 0.000000 C=70 M=15 Y=0 K=0 CMYK PROCESS 69.999999 14.999998 0.000000 0.000000 C=85 M=50 Y=0 K=0 CMYK PROCESS 84.999996 50.000000 0.000000 0.000000 C=100 M=95 Y=5 K=0 CMYK PROCESS 100.000000 94.999999 5.000001 0.000000 C=100 M=100 Y=25 K=25 CMYK PROCESS 100.000000 100.000000 25.000000 25.000000 C=75 M=100 Y=0 K=0 CMYK PROCESS 75.000000 100.000000 0.000000 0.000000 C=50 M=100 Y=0 K=0 CMYK PROCESS 50.000000 100.000000 0.000000 0.000000 C=35 M=100 Y=35 K=10 CMYK PROCESS 35.000002 100.000000 35.000002 10.000002 C=10 M=100 Y=50 K=0 CMYK PROCESS 10.000002 100.000000 50.000000 0.000000 C=0 M=95 Y=20 K=0 CMYK PROCESS 0.000000 94.999999 19.999999 0.000000 C=25 M=25 Y=40 K=0 CMYK PROCESS 25.000000 25.000000 39.999998 0.000000 C=40 M=45 Y=50 K=5 CMYK PROCESS 39.999998 44.999999 50.000000 5.000001 C=50 M=50 Y=60 K=25 CMYK PROCESS 50.000000 50.000000 60.000002 25.000000 C=55 M=60 Y=65 K=40 CMYK PROCESS 55.000001 60.000002 64.999998 39.999998 C=25 M=40 Y=65 K=0 CMYK PROCESS 25.000000 39.999998 64.999998 0.000000 C=30 M=50 Y=75 K=10 CMYK PROCESS 30.000001 50.000000 75.000000 10.000002 C=35 M=60 Y=80 K=25 CMYK PROCESS 35.000002 60.000002 80.000001 25.000000 C=40 M=65 Y=90 K=35 CMYK PROCESS 39.999998 64.999998 90.000004 35.000002 C=40 M=70 Y=100 K=50 CMYK PROCESS 39.999998 69.999999 100.000000 50.000000 C=50 M=70 Y=80 K=70 CMYK PROCESS 50.000000 69.999999 80.000001 69.999999 Grays 1 C=0 M=0 Y=0 K=100 CMYK PROCESS 0.000000 0.000000 0.000000 100.000000 C=0 M=0 Y=0 K=90 CMYK PROCESS 0.000000 0.000000 0.000000 89.999402 C=0 M=0 Y=0 K=80 CMYK PROCESS 0.000000 0.000000 0.000000 79.998797 C=0 M=0 Y=0 K=70 CMYK PROCESS 0.000000 0.000000 0.000000 69.999701 C=0 M=0 Y=0 K=60 CMYK PROCESS 0.000000 0.000000 0.000000 59.999102 C=0 M=0 Y=0 K=50 CMYK PROCESS 0.000000 0.000000 0.000000 50.000000 C=0 M=0 Y=0 K=40 CMYK PROCESS 0.000000 0.000000 0.000000 39.999402 C=0 M=0 Y=0 K=30 CMYK PROCESS 0.000000 0.000000 0.000000 29.998803 C=0 M=0 Y=0 K=20 CMYK PROCESS 0.000000 0.000000 0.000000 19.999701 C=0 M=0 Y=0 K=10 CMYK PROCESS 0.000000 0.000000 0.000000 9.999102 C=0 M=0 Y=0 K=5 CMYK PROCESS 0.000000 0.000000 0.000000 4.998803 Brights 1 C=0 M=100 Y=100 K=0 CMYK PROCESS 0.000000 100.000000 100.000000 0.000000 C=0 M=75 Y=100 K=0 CMYK PROCESS 0.000000 75.000000 100.000000 0.000000 C=0 M=10 Y=95 K=0 CMYK PROCESS 0.000000 10.000002 94.999999 0.000000 C=85 M=10 Y=100 K=0 CMYK PROCESS 84.999996 10.000002 100.000000 0.000000 C=100 M=90 Y=0 K=0 CMYK PROCESS 100.000000 90.000004 0.000000 0.000000 C=60 M=90 Y=0 K=0 CMYK PROCESS 60.000002 90.000004 0.003099 0.003099 Adobe PDF library 9.00 endstream endobj 3 0 obj <> endobj 96 0 obj <>/Resources<>/ExtGState<>/Font<>/ProcSet[/PDF/Text]/Properties<>/XObject<>>>/Thumb 107 0 R/TrimBox[0.0 0.0 792.0 612.0]/Type/Page>> endobj 97 0 obj <>stream HWn6}WQ[@\rWX ݤi8- #$(m=CwH)Dq8˙3r[v[Ue5ה^YoԅN/zOOßUlg霓W7wLU 1궊Iw[f[UcmrXqh V5A:T?b)O(x;'=0>:cl潺o79klV89rY'sJ$V{Ҡ<0'%怌,3EJ${f¡0pNN@p4>M6X` d\<ꐢGf>xviNbڰ9MèI6ugm%%*K,8$حDp_Řuj)9E,Gquޫ_+[/Ppu?\quaڷ3"qW|2 S#>35 _2= 4LP}( Sg.$.ь?MtdH]X%e]{Bw+-9E>+ovBZ/uzA{GREhV.BWG#Mʆ*qG "l#Vޕo{R:׬kYZwF(h\IBk{iuLIl9>QܵLS4`{zX20"%R ">+۫ L'mwD+H>9/qm r9zJ=zq#9;AMPe }WOlb~G ˎG!ljІ=㧈0~u`aSG}DK4~n3S3B™u}yqѳLSkF1cAA8?eH222y/_W\p6 AΧU[hR@uh/ 6q!v:Lc9)'(aXծ,e8@fgsI\:8@+8d<ݖ""3ؤ_Ps;i^ixqp[u c303#`?q\ <@AJ AJ4 ! Ci sorqO>JXioy6f_7KBRvE|zkƖ: $CKw 1.b|0&㵏qǩxv73[q&cZG튎bCŸT}dG? #xV M#s,r_lUu endstream endobj 98 0 obj <> endobj 107 0 obj <>stream 8;Z\54-kLQ%"sMUa;$Z+<]L2`EW]n1NGP.c:D_N?*cm0\HbOVW1A%mn;a>c2KuEX\UJEURg7bYo38QN1F?> 3XJmh>a,IM2@^jK=so.3-`tRdPlQc!,5Og)O.8'Q$8>P4F48YPSAub'm7 CaDnD(eNOj;[aOCII,t($\bG*PY7@:pK9KD@823d"hM%B]B,fR==E'\fS`)(7/8OW IA5cKI@imY/OuKQ9b\fg/#GY*/hbt'U[H^C(Jjc/e2?<6,/0^-A'g(%%PHlML:uOB ,#gV;lWHQ^LYg1@G)B(G7>fIO.\.TR@FPSOWM923E";gm;q%nR!(jJ+q%15*WSP ?G)$[^6o\K7#R*O"9O->+:ne]!l9*M2ZS`>?).~> endstream endobj 109 0 obj [/Indexed/DeviceRGB 255 110 0 R] endobj 110 0 obj <>stream 8;X]O>EqN@%''O_@%e@?J;%+8(9e>X=MR6S?i^YgA3=].HDXF.R$lIL@"pJ+EP(%0 b]6ajmNZn*!='OQZeQ^Y*,=]?C.B+\Ulg9dhD*"iC[;*=3`oP1[!S^)?1)IZ4dup` E1r!/,*0[*9.aFIR2&b-C#soRZ7Dl%MLY\.?d>Mn 6%Q2oYfNRF$$+ON<+]RUJmC0InDZ4OTs0S!saG>GGKUlQ*Q?45:CI&4J'_2j$XKrcYp0n+Xl_nU*O( l[$6Nn+Z_Nq0]s7hs]`XX1nZ8&94a\~> endstream endobj 103 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 117.1199951 0 0 117.1199951 319.7880859 271.0333252 cm /Im0 Do Q endstream endobj 104 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 117.1199951 0 0 117.1199951 48.5 273.7589111 cm /Im0 Do Q endstream endobj 105 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 128.1600037 0 0 173.0399933 604.5800781 243.1123505 cm /Im0 Do Q endstream endobj 106 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 125.7599945 0 0 30.9600067 605.7460938 384.2587433 cm /Im0 Do Q endstream endobj 121 0 obj <> endobj 123 0 obj <>stream Hҁ 01~^b3x8|* l*@\ *@\ . l. q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ #q  \ #Xfd`eF :)׀ýV endstream endobj 118 0 obj [/Indexed 100 0 R 1 125 0 R] endobj 124 0 obj <>/Filter/FlateDecode/Height 129/Intent/RelativeColorimetric/Length 1963/Name/X/Subtype/Image/Type/XObject/Width 524>>stream HK$N^u(:ZE[ \hv u!Mŝ d+A,q 7E(.$ T4YHHS|I~2A3PTjZSvVN[ JJju:eeTH8N7d` ekT Rp:o1+`(rAiĐNAg犅%RYk`]yTR\(f1S0&H7[ms}]MUT$ï1dTszTPZYkilnmz,hkmnV=oAn{^{d LNʸ? biuCr\&eۑa`fmi. wCK-48'n힞s߶+ryZPiFQmߍNxf >#-{f=֦ZI4'4VXmfYw耭RQcȼk2W5ZΉ_@^]X[ XUlnZH]17y}gw/ e{;^XwsMI{yTZ R]mp|vQ^<8<:#p u]^IIJɹN{$Vbѓj`nrLoA5sʻ{gڒwi0G҂굥wxz~yc4~~q 8m,OvZ^27^X E/DX,{uy~= n,G~o[DZU2y J&.-v9_c?/J 6)1$.¡myO_BJ HaJ ?¡/{GH{n!ZH]'S-|C Z hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ-R-$ˮBO-[CB10: 'ZpX*J&.-s ]}#Q*H$EʲWgѣʂ{,{'K`ypocy~zQ,7w؝Sޥ݃H,?;FvזSN{\4jVz&;h,XEO"߃;ɡ2QЪ[ȗZm㳋q86GN+ixwvPHYv?9w7הM /2T\pN`ڪL8ƪbSJi)F?xK+ X%+KĖ[0ǐyׂFo&gv;?~¼wn31nmD^C-(J>>v=.{zjr|9`b,o7GCXZb9#.=Ivd94Y[Kl]? o*Mn[,غMjqs0SAie R[YZ`ǂ҂JX$UT՛- 6B*MӨZAg犅%RYk`]yTR\(f)Ơ3S^(։b~)tRHǠpZ!#O,^iԏRHŐAqZVSVLM)(1drPj% `Bo)  A<>stream endstream endobj 126 0 obj <>stream HuTKtKKJI,t(݋4K%ҹH4J#Ғ(H wqyy~3̙g<3Y9El @ ]!O-@\+BVKK :OX~WCaiHKL0qY `5ck X]x= 8 XĿ׽>.f#aPn D^{y8  dp H st:Y׬cxc IV?S!:_9[YbQP~+rA ShHht^ '0߅™kYXY9Yqqpl'WzEE$%D>,^|t*K)%/`\ҫ:&D [7dplDa5|mb4,yy{e5 3⚅,t+whlA   m k xYUH&%Ȥ qO'Mz3KT@v[NUnn^\o]abTrtlmE]e~U+jאZ:zaqi5};CS[\_ۆwCaQ1;>L$Lz}4:%8M7l̎Χ/}XT^]X>\Ym[n!ycskkƶʷ;v{pIs0Xݯ3s󝋒&$WWW*)!$$%!e$cHNOAKIMEq ƕ;KLw@YX;ؚ8^+DspfKOTCPpJ%D=++O%$*8IZ\Z^UK_wL"dx]}>9=;s_G8/̹N!Gz[<=2|B}PQzlH0Wc(Een|Pds::5&89yFT"od䳔i/ZK^&gd:fgQl kJХeJ*+篍kj5U[ZUh0|em6]B@`PpH?QM1Msψ*iϛ.Z [JYZ)X-]R޸Ѻپw?@?5 ǖ'vNg W3gLC#u!MMMEvAms˔FVNA̝GLwA̬,llؿsݛnͽ+!B²" 'R&k?3?4+:6oT\ұڿ6VʝoF?LT;:>::>:;eqvx^sawݥʕ'_EFO\DKLtAnFF)F|ԭ6\`@z?m+F;LwiAhy͖)Mgw~_ @ZH_XA,"F)%/*9aZ:Q,\B^_AU񡒀2 *'[j o5[uR1uh`fm$1xJgBdrltlyyEe$feg-g#`dGbwj0TOC9; ܨݿxz6zx8IP=A!.aAxۑϊ}bG-ޒēx`G/Ԝq_O?0"۬խЮ˯ǰı²µŶȷ͸ӹۺ 0@RfzƏǦȾ *GcЀџҿ'LsٛFsM6+1MZ:{T?~ò~i~L}~cbA~Dad~ty~W~O>~\/~|~`Cx}%H}1X}%z}K} {N}׋<_~7A~-ψ||Dz|+E|[s|z} ^}wO@}-~ċ {Gu{Dz{]Ĭ{f{Zx|[]|ϕM?}R<}Ǝz]YzHħz|z={LNw{\|=>|v|ېI8z/r z;bz'sMzd6zɬqv{D[{0> |;|yyaIy?yazYvzݮ[{^=c{ФI{R*y߄yfUy`VyyuKzZi{ <{z%zȎ~+~}͇}W0}3}HtЄ}Zk}=~zɇ}!~Єd*s}Y<9wpSwuuVrUW؈|;,뇔{RsѲ;:8q)PCV:4.8Ȅ2񡂡?Up Vu9S c bփR.ՁNn U388A/ͬδz6߆өn1T\e7݀tXT)$̯̕6;eCʷˆ imw3SƀV7M \lGNػځNāa5tNzlߴS<H6*-N}o2ن N%է>w֣A}⇤\fXMݘ2, KԐ3g°[} 0e6M _1 ? 1ӣǾI^I|B̯dܪwLe1$: rW] 1S{z|diL g0\ U{[G{!{ ޔ`{&yE{xbie{Jr|/c5}~ ~:f#MKx+Ca|uI~.yW ώәߎ%¡唘[w!^T`^H*- 5GȨ瘎=Π4rv_ҍRGf,ދ̋|,ƕ{ Ҙtٕ^1Fő,;',#h%T,Qۥ{[s:9󅼓&^!Փa@!" y .Jl6mHju,bU6+s hܸd-ʥ}wi-sun=0Ľi-_*)U_ˈb$na+;ϧT;ppA7C4.*Iߥa8Mm.ACi7\j|fiԫ)]ޭjʄU]3(í whJch-4x7h׿*P0H됎L랇ڡuÂ,{Bz}8vggҲd[!XTZZ.vlAg {;Sm`vؿ`~?ga. 3Ì{L^WYe4]L7ok!wI~Ira^=C#Zh`Wu}p)"z7ff&3$FJ8Ҷ5m uR_,^VS&aR~PfLL_Dw*`\-9]q  TI6)>u6 D`e͢/xqY%9ʜ;åOd\˾P&eRz;].R<oΡ]P{?: r̨\ʻb Ҥ3|m s؟W9oZt]RnÅ\cW#+nI&gyAjsN06HiD'@J+a5V~cRI̫vwtUc[3+?F|l(iU^+O?Rs1Hqil$Wþh=(RE 1BvџnF/ BsGMY9>ܖ3ȗqI ڣ5V_1ȣβiJiX0WVH[8g_/ n3 ` 38A.|f|ј0I6bv%& ;Y㿜҄#dD.).p'3J12K[Duɥ$s8IƊ.z^48e!R6}vcMiozo0'=~i,3:?-?oS,9w#ROa; ?pB ֞IO ݟe#}ԯN$\l?], y,>&Рq]yh0AqK)ĝBFҍcH:-h-ǟcf)K9T127]qEjL<>h;|U dpG ƫ`&!8al`83>.qɂnA9 ; `HByg KB*k㰗2fF=#OM eT? mTm_OBۊV<ɆF('n3uG~Ȯ#7Њ9[١`Ns.P..콤 'KnpF\? B>-`NWOOWBlfxW^b-_x&*/(j_=߆󑊢zF`LdE:SNʔ@S 03|TOKokto}bFz$4-,.m'j*J|)J6BP ^3ewܫpX.*,07xPڳ:2XOT21|"7=0ߴy}ĸB)H[Fs V+̯+Y(I(x&9JAI'tXmyG=X[8TK)2<TSRvxlȓGO|g/{>4/gRFȶ&A52 uЯ*B幃AuFǞѧuD)B,*?n` 'qQIzK֗4{B_g68#ʉ2.A$69!̒ub1&D3Qx" >ɏnνxVG&TۨÓ)sxd-5KxߣD&1±jdGjJ|J{Z ޲f6/vTp̄ub PmBU#gBg˷)-*E ar>>Ƶrn[ɭF-IByѸP=ĶKUC wG D}"vN.p]]Q8uY{#qCv}sax_oyiNr( d8aw2CQ}V8UWO\g \yk@dcZt9$u p-1z(=f) vě92 w u煼ת#{P6+Dq3HIi%BCb!kc5&U ):X$܎[b2*@PkcӘdoTB_L1Uwi")=2#pI9,RO>T@>;bnDPuCfk^^\G~ oLRcHqܮ=-8^5Ońy*9:-\g8:T<?*C;[yX+I;lRL߭$DvYTQ6DyVmfy%/sIsmXP1Lռȭvow)QBb_LVwupeėO*|+](uHװ4WU.{ 4\m.QwR~MAiRz+%BKz?'{ k҉aa{H]sX}da~3_auQz VM\ĵv5I0LM)DŽp1:5,&4 %!$}ocޤA]R^xT◬M&/B:DwA24?cd&g]5b4a?iǐ Ĉ.OA 6vfvsd(5yTH/P=(a;zUs bWxDa)Eʼ $sgPJreY3w`cFo0|U[j5k.5J&eTor È´}I lpjC8c5J=g%Uo|L58E" ِ[Ak]J͆VBM"{NrQihЦ@Y?6^߫ZWٯ]ذc؋hKSLj:>O ɲ.ݰQ{5mm<ٷ?^v"}ъw9O&vX7km[ ,70nΒ7|eP\I;-wgFN cIP#qWI ;NٶA)H~7i thl~~dzY Cx2>*c&mb{9f1X*L #> V@g蒼]7n249=MK% ;,F\j 1klZi؊ΐ.|Q9а$_.!;̿lE,ɥDi}D3^a`Y5g{J=mɳy3CM'jM-iЦm n5? SJE+U~ ;q.tXd~~p*QeS%.Ћ"ưBsZ6-6[\d;^z4`;64藸ͱw;|+&AfLU3XTm)lF'l VɺgcGObbɜ9;v \CL, >B?KGCe"z -@EHILp<5'҉$>8#gL2m c1 c Fw)P+rkC qp/u8#!*g°Pa`vu@oH`"Ž:z_Q<,D>'ӅWP .`xW3|!6 5 El[",0 e[Oz0~lUO+&xkPc|u$k.?{Qp""kr6isVa=~@W_ .<7 2#h?c~m'rE_xs6aG+K 14L^kUp^^_mS^dШ'>}5$:τ!E[bJx&n t(m;ZsF5uqX.ՂBqKP *l%{ٓ{'f';,TT,bhUq2Z3;}T9vwRR;GD K*/@hUv$j!@ vyבm,W|-͢ ^ ~D_􆭍"ĉ#c禘*X/Ϝe>|XH;:)d9gƖ4aBQ4Ew,C ۯBU#>SV$L-5gV ϯ*B#} npþtdU$Db&$^\^&Z"/˺+-}%Z:}9AYu rTlP0"~! ͚*@5K?߫Z-P=j>܈[O?)a5 ?WUsy5^(ge${Cm> "Gգ+$踿ϫ& Xw8?g,'ō="/xNM)'EFqrf CįQ9ZY$r!6m)4 V9kJ$# FьX٥Cp[ģ)CS;rFP#ImKGɺzj>>X9,ZL-jIbkȉ8˚?vtxPIO}_ay@:|Ve6ubd/e3<֭ztea'cLaM lz&,f^_!?l2x2Xyń3D)\?ye ~4O+9$  EVDTSؓ7X?MM!ԼuOtP Cbt;iްa@gW#@4c9.Do z2>M5i~u0 qswQ9ǸLt삟Mz)>kɝI;io"U)]$YL >$$T:gUo$UK,C`sCMAJMÄKC(g]ٮ9sUG0?L5QM%0Ol5&`Ƒ1,x'{k+mY}-Js#\d:i/NK\8HstQ#-ND).s*Zymnf\1l{(E=VGW9s:?wǟQZsC6A1ƃ6K@8OUY^`7j6@9?,yt4&}"T- \Y&kVx녣391ٵqQ=beMq\`/nņ|2͌JkzDmͫIR4\~5NlօKɁZ]TC3l̅D3jSS)tWw$IX[wV WTUw^PeUhWE^ؓ~Wchs sIg`wgs (5mr] B`7JfAaA3ƓG?{O[ ?xj/Z*7exXz Ά})C?`KcMՌ&)Y5J]q':]$؞]Yv x(ıH1eU>_0b?*񸨎b¤،D;Wxm]|N7U13*;.=>SÜj)CM>.eI1/QvН6Tkk+Ɯn\\FFV#Xde&~WE7"bju^I@j@bQ Wk8w_D ^z xZKA _`T}] x}ЁM0S,rV+ KO&ƈ`;E{irf0F] w86f fm_8c3V<)r1p +hs|p!QP'Ղʛ2rӤej4Y r, r?4! Uq]f(*&umM+;1 -c8CjL=L1TDJ7>)BH*cHY}~xI,{7WjWާʇhg_YovMKiN> QRǧ}AQj^G syJG"?txt,L>֍p_>Po$^<%}KDS4 *S<ܖyd;éIJ~JMn>ȸcI6uɖژ䩊i77_5W2' 9t^}/8%wd0k)ͦF9kih3ShPBULzs'0$Y/L3ol|f ɪ\AW#siS-O^I+36xas @M A hm45V-' ѵ1S+ ~*%~k˝ʉl * lك=3_2~OgPs Ccd[aے{<ХjA {! ߲ۓ;O'9+wEHE&JV?fiӺ j05瀶bhWZxo=ƺ 0zhK5mov (YOut;e=R*yMVn,$v:QڳE.yVl;svn,Wi.[@34SD_!MF>J柣ND @$Y~-CMu (+lBpБ^#$~2è /@̣6 3nh ;۪.3Fq3\َvZnZ"/vNFNJ2V{#ΚVse_쑮Ta8C¢!Η>FL\M{5eH~7;F AB?VY=۩Q i9J.sӿc%FVbdեiL`a)kD=W \ne>NX7Ƒ†2IYf-to7/~Uas[`W*v3_`~:kjR("E * e)DDIss,f_n6":hmh+]AqñQqSa9{~8|~bh6GZĠםN\h+(E30~kTMGβ1:zka'LG2>,gt X&@?e% =@Ihs)HUOeX^m7R7~,, \jJԌfͬ8!*]JR:WR]Mɚ PZ;JN.8ɦ,[r*Α]MM"waX)Lbjd`>:?|:?u>^G$fa. ʥ_S%ED8 J=ĕK{6r zGG Ui<Kg"^ q I6vPWy^,uc/5@:ǹ+[N+li{P#^yv,ñ-NѳH⺣<֡gxV</nb6󴳜Ρ +nhB˾PoT(W##ĉTwZU} w-vT-9O᭺HIz) z9R'dI5aZGS˟agW=.P1ٜ y?2X)r4VaGXBe`9Q1͚@85$W?D}z2* pt +;Br\ܕ'> -vCNeʔL-ʌqKHr 7I d<BgNelB^փRγF2AqCR&t7߄{" D9u)Cw1t}?"'[7o̩~1{>Ru* ʖdClutqf2[l~{S4>J$.nQnlP#x])By`r+wLH?VD:|iUG~ժ+&+Rb gP>}WԹkQǖ]WSkqwZ DQdVd24KGMvU35KJ~4&jwJ*y;X߉˔O@5hw)񘴕o-9E:_̂o&6#V(ѽS-te$ פp}4%4mrnzhe4KX*KÃ29ʩ~'Ǥl|O5ÍB ;^j㛑Q`exH;J\*`l˴Khk &tF|(8VǡܷR:ϳoG*UjSKknRgl ޅ-6&Nŗ7O4rGmO[du_TvY{ ̏Iy\aRKy&P7ݪJ)l"W5{K S_j0WSW;wixF1^lО伴^'1b%OAXhq)L7j}=9PX=n`ɗKX#CùA *7{ jWܴTByufכd=Af]F=_u*`q+_i݋\^`BaE|S&%Z a8+QgQ[IK-jIKr2Tcju=A ʧQ"7{ٮם*X|,Yzѽ}ƈf:jCo[>]x^hlhNrϳEDkcCǪ ת9c Ht<)}z!hE~DBӳ2S͆i{;ouIp??砃46ٺ^"1R<-65sjpCSjqi6dzھİ紈 41.$5EG9:=ob쾄 v#[xﯦAF+T(C@RQF772I$^a$Eq>.AEbiO0]ТK5ΫPÛG ZdJ*$d ^}E*֤>?Ƅ$dO _tl%$^7[KSECqz"$]*B]}W zT[Rk"n]EUYvFUW\B6-RB^Me2B4/wͺh4Ek5˖<1U[tD>Q!.kR涧7uJc>c l/i^3;iڐ0sĀZnS qW7Np:([568ViAFޜ~h9Pldüj2dO +61--1Ewv =JCHW34܏&x8,&#Rc3Dvz6RSyu_N/nmكvT֥Y˼?RFװKzn9Q4gC^5l`P\ܲG&ޫ` 9PҞٲXr6 V4,{a؄\tcY`]lǿԾar鴯؏=b!&Yb ^[\aYt$w [R)i[{$7f"o Xp zBz'hO|Ō4ǐ|-j :}̴a%Tv5Y9QK d0 ?$ćH|#uD3 phrd@,@XmVKY@ou([8#!OM~.7SoJn%OG" Ü3N|/'O-R_1Vh&׺ NPz8de 勊ZTH;XQ6}+'h_|ȋCcuHjBA,NOS{3 L`]1> A rxӴ*E^.ؐ`Q5 v{`=W6뼟\9avGOXc& v1w~0W:ʎ~f: 0/˵%m KRKAcR% P#CSߥfmD5oEx17B0<&Yd8"1wܡ5 TaaJ3p57A>+yIMcu Zd?Bk1x-rsV9sH6p]DGgO| y5S$aE`$Ls [Ym ~u8p`6*I ߕ`S88sn9O3nXOE /7f^lbN[PBFO.9Z_.5>F S̉R'}ΪѬ`_dX|{dHXԾ3QlZe7PRqشO5OkZrx5u`aǂ:*`T), DPQʮdߓJRk=H+ *#u)h) )B6s9߹瞏HZGzGT"93hDͺ sr|b4y $TK "$I~$v(B#].qi?CN ~ޱ|ܷLcOnT~vxj̦5<.f\K<2p:CpSy,66>|zC E T)f/:X1}J+>_~Q;^ㆪvs&۸>.k7yZS:˩㜍rݖۜaKa!l.g57Kv0!;ڗfe %]"XT J3aժlwVj=v姠αe=bI/gH& :g,(y 27>aba88fVVqɌT0NɉB`( _"fo! t}Wg_0}HX 9,Qx=~Jٹx>ӱe9M2mFS)Vk-eZFF٥btg0O?Dǐ%7eyښ6WSCyeUS}l`a8i g"1лJ"|PKڝc,$+&PvꖴGBoj_t4I vqf熚(eC!b׼^SbYi1¨;2W`/7uh?4 !z@#(T 6 ^!R S#>E/Sq9z_ /G%ӈ0C9[ۼ@(٩P ,}XTOkpQȫUG6 x2e,> -?ϭQެYz/T5FL^`tީ3\#̬D:,vw[mDW)TBZ`0Ֆ`3tBQ˟kks41y `\޸cV#z`XHhwA0چFTyqӵܫ*F˪%*/>9 gS'"b'zL=N)cs*bR)W<#S 癛)K &L\9WtW!Y17i*%wJ_ 閥nWJ!p-0T`:K6B+SzlL,~J#ZLHBEe߈Eq1 ڸTD}bB;*OTCnՍl$OYQ0mz7o9NŻ|hDV[Ve֩b7YZÖHl~I)ܻJ5oOݑ%(,hZGҼmRd!/NEWutV57z;jjs^^lDǾ0-a_aL؁w44簍b^ppi&nX uƻ-݂ -cY4_g ?jGIfH %J҂[%ϩC6OzvWzoZtA$?z;ؼFT2/+0@@S<@>0bSuqw;j4S'/4sEթ(P[V^5ƊHkg/ۄw 0*֭ ajyB5TC J(_F4!m, RN ?S9 :״OfOV"յڇ1,V)S@._ #Q`K|ͨ%cj/&\: [Ft^Z"q٤Jm뙊jMarח`VCg w"~>< 8i}XT8dzQVY<p%HG/Û`rq;Nm~Ms\/Zh:(MXа^F.꜋.Ys}5`a((X0T+JS 4&~|iB!! !)$)ʰ WFY]E븎3x,˽}|dc |i-0Ws Q_GpRjy0׿tjT̎ԍD1څڍ›N:ka? 7ek_%]a;זF=9-b= &Mm0-vD'^j+/5(er^+EL F1$1KWE|fOFMKm::1`ڥfXЩM*i9 l?+Lw?-Nx͈wɳ\C0瑃f sM;iđ`$O0z*RٹB9@"k5v~.lB?ug]ed8JAj͹um.DO^^v:y;ske+,L¶vŝҼخd_5Z;q#k> MU\J{l*͟ґ3Doy"UDcu#H)BPit/ v`_Sʝ{e5mpPpy=-2[m+v6*.WۿSǔ] ^DMk,2.#ɲ\!{^I4Ԉ.~çlDcBU\b"c jvJG|H`_2rHѥ tHHBaG :Bf{'9 [jaЧe &hz6Fdy?>gۑx&l$^:^nx-'-]O 5@S Uڏy]Tu _,zWPT|BJ,ɕ}`8ߴy?p7gˢu\JO(_vOUue4+Qbi?A.jCxyRJ駥Pt㸲rTfdd$ֺFR>PaL'v2M*׵T]`W*cD*hAe#"ɆKO9JKL2J( KgK3jԉfZnL5oM(_>FOӹGi}<@w#Ndhoo4Y ̾Fٸ2YAz$W֜5Copli\ 32l;a<;S?B>zprjsm1tZc̥{s/J{c*#3ހfϡneh->Bc9SJ"չO8'8ހ `yHϤu-*` x[c')Oy\x!QS9q*;$;d'=NY ,|ܶ34qT=ka%hs䬺UX7Fl[ o1apuxf9QGk4;e ˸7荇5xB:yZdͫ,`2?_a[0~9iY Fs3g Ë9u<,yx87 1Ja,O@/gO㔛94 |.]16'^@1'p:XtwL,jVQv@wl{έ̱\?R^UV\GI+9D03oyd[R<""" .2}"!<4tH~(-r25DH@l"K濣,/S}"+~wF}V dRz,:w&?C~FqJ}JݢJirjzEgU#p]ZF%+[PjewVjlW7wR/*C%%jGx @EFH)&0_Օ|Xu DRNXA\0JSH307͛73 CWc+U#r# aQOL4Eљ?s~{sIy?y>ҒLָKd-ޣJ1v*fH 6hz+~BO:IQqZUՍP[UD#BM >$ z|?^!J0W8N WzXfщ@'h< %sdR۔e[$z,Z2H5[&Ht L UO 췯+52j&P6uRɮ! a+rk!o4 `ܗP)f%VQTF(Z]s,TR|O)O?ho# ]6yл)OU,F٠E})gsٴGyҘp/kw~˖I'Y;TdgYU'I8@F* 8 $I+A2((+y8OϋWȗE {բbW"@}@C׌teYgvֈHofE`eagbN_4!/e%O;mhtWv6[iyFy4ʔat V] au #QYm3rM/q{~tjD 7fiɷ  . =[n`4qShBrx_5wԐ %nQ~x'G[ `+qb]Q2Ըi=UGn~ڋJ(Aݪd E7Kz +M]!} jnh-Cզ_魺a٭Dfrj6$-4nUZF)Zpux'@]U/ٳۿ3Ug`iU}ڰULWu+SU[;uXJPvOŀ{$KF,qQruH.}imfZh~atMBb0*iWC䶧jZmn[nKfi c+.&oV.&ʭ{5_s9dmIA. *s5: 1Ů m!|fl'6#N Z>\oMkCZ8)*bEE@(27{I" $!0a=+vUZŁ`-xEJUǺ ~~7TSsV6i1=2J眆Jh@ Uu;7!0 ߽\醮%-;=.e/T7D$v{.ʫ|ZѮmcDֲ+-Cu_{>1H1]"D^nR ٺ:E3[h9 7TJOW+3 vœLimc @6'[c`Ǧ8v!bR{1_ӵuoPE2\@;4"mO m{ ߺE1dA}C=WB}[3']\PJG5VmnYG Xyahd'J[U~ vWۅWo]WnGnR9H7ѨAu 1vZm]lUrTVA sj6lhm,My4A*0vJR? Ĵ>2C!*#q0MJ!:ŏCR|dFa?2݂ch3dBzSIt?%LmF[AxYGҏ0m;GY1űh%[sጒ@9 q_8G>r Wn)jodEzC.qJviN&If8bg v|sd%:uTf&L0~p.(RU ; _)w%$/ t# ~#u`u[w.qsY_-*'̳ɩk/)2* i9$7fUzflc9}],툏WYCIkS-ty7>T! 26Kݲ m&cӣh' ..+upC6&@j5tdP0=I˂Ė C{޶$tR:(ϭuOR4$=jluq1?פ9Si|cqF!_z^SK}`d%DT wV>;<'V=(5H%jWMV#9YD2֓p~~J }D]gNSsjJmn->,vg&SLl#>^i8ʞ%4'RJDhRN0hBA0(r0K+aMY|"EGE_R^v4/?m[˨yN`K/5[71[Gؒ' '铯RGhqꭁ]>iIX 5'\GB ćd^ux+[^%e ֪pxE  6%!Itި@Ҿ#% :*h$r7שׁ55׈Ց'I+6*ЮwȰ%U#zD+Jt BaUؕ 6}uOr7dP Cu}FEua7RV"KST20 EN{^lkƕ$vW(,F7b ˢÞOy<"_).kh[n 9W?gڈ7yș*ӼuA@ OpIRrP($e[iVYR n#(aFq&mq3%\g?%ӆM5XD3b$ʁW ƿ5&͔D4®KcᏊ . 1Zo ^`~¿`6z q aXǰ)Ӽ܄'84 n"Db.yC<K d},{*h ڸh>wMv^ c8Iƻ(~j? eoyl/Dl5Żרpy1ܣܵ^004{ .%CA22dWuQ>okL<5.ſȠiffh7S-|^TjX[wCY*sG^1Ve֗+˃L3 /2y{+.;CtJ } ->٫y6q< WxA_PZ? Q y1>yK\.!OqM 0Cl];Sk)=RZ@[ɷ5JBeǐ$Ni"0 -úR4H~9.☫|Dϸah-)r~"eoMK%4 _7"‘e QD~0T.>"x*O>酧.Ey+HVy55RWsEk*PxEGB;(J X(8hiqmh^ 0`}_APWDLZ‹]<4zG֦`oyZR|u^gCF#nr)Va5ƪw9njyIt xI1bIy>}-AگOShKFx6xqqQ 3SU\ka椚̩Di~ ?{>J3mtߐZt]YNju]ɒQYlZZsNѴѷW>Sݥ0Bj+7q҄fU7m :8^;#eտ+*,_CY3MSU*LX.jQȖg_IWJ5a"9R'C\y׳qH)VU-Z.\+Ѥ/aen/|F[?SPkr" ^Y>VH9 &yaIxQfd}+] U.o.=q-y][viRgk*`/pLBu+A@[)&PYQ?im/K,Y*gu(i2`؀V"fJSs=RU@7+>dْsmY)w=U?ο3D qjv83׽} 1r@vy:{Eͩԡ.޸,珈~CH{ksv_l毁@"lOR."0Fl]]C˧Mfi nq˶Q{56ef e l[IuY_(i&;to 5kZ/ jjp~Ch⨿䦿iRs!G-֠5 &wa7WAƫXUr8+}E)oVӃIÌ}qZlh<gw A?=$6-ޡ|,)!<*ǘ*z!8߀ϸuPpD|Ŝe=sm4'ҢؽYaPOZ(vj?VGgxI=V-̹uMCJH_-C]B~2A\8*E8PTΔTo 9/whaߣby\'F,Ռo%wU/ժnM*T Ƌ{5NJԢT9L;y _fXD\uַA:x")V%V/*]1# )ԋ@X"SVӅ4u.f?Uչk%Nj;c~?]Pۺ˄WҌ=V듍1 E ֻqd{q׉; NYHdfttc #&vPtQjd1o ­R)ʽ@}<7 &8wyybH04͂@>o` ~M`Oi#T2"-!NSn\ z$SC%Q%;OzcT)!M.wf.Po1U=Bl1F#F0HD\u̞rڜ*ujQO5u8E$7:"І(UuANgulWYE*Z"cT\kTxlx)$8(YBIY`[}.Bb T$=U8Oŧ yP-x$]0_ j(sOH|/=wKR` ptl>f*ӡuU<=Ts(&zpKA?sLo`N0Mq+~*m-~F7^5惬H]${|-Ҷ9Y&=X'Vu+^ϖEm Y/0X cAdPc_X VRx6b|C6^FeC]o-F?f7Q3V>͝yFsy]ݯMF͊k^NնI#FZ.7ƆQfeϫCJn;AjB JFw mԗ6t(I5beElXQ͌ i,)6QS 1zJezVBf ۹ʹ/ HQ89SnE%o-4NJ``,)~utyQN]vحp+e"xN6y*,7$'x\CQL[8.d@}CɏE)1D?@晹b$?7 YM N| _Td'wa}0Z<9|3閗3~o=Y>l0Wb=P1jmE XR[louv:.C=;.a.BřS[nWJ3ǟN1='\Xr8۲:KXj6e g΀ap%z"K1.c1ɇzɭGTRiVBe-)K@iͬ!u@_`&2q up%P SЧ|NWP !o-t_ nyV|ؤ賐e`HʏE=>\Tǀ|cҎkIST!%Gu,%[IR'+#T}m3\/df)`n2#\M(CQd6flqGv첵).Z&wITe{JQܕQE\m`p`Ҵ\z[v7OVo9ݜQ}$SSFMWdnyuя: *o[3 O FRJ0ոl+L+&oE+d- @?^fEkoo\fyJ8zΰXmi  -Nw}OYpz&@>gݪHc. ]7Mz#fe"g\a@\qyºJc\3ܔ r'WQVE D|PLs\h_h#9Z-TdL>˼!WS/bniA3.1Fx@Ǡ3UNN^nPOZdtvWO&-8ךshveSȉ`wPU_cař=շ}m`<<$+UV66do88{ηzkG}ڻ<<7\jvg!5M!w&GmpfSgO3x? wZsLRq/~lK]QV:om<Q' R]AMXyu ^ȩ $}! 9LHaH8hʡrTtD-*fY]]wuu[bgg޼ߛ"ȹ I7HR7HBHudt *Ჲ=eJtj| #TI/W?{ΝO^'`v'$^E=7ITF2˵7-^'Z"[x ;[U7,QyWrr9E6cy'I gIRm2ZQ {0K,^H/>>G@l`T=FZnZH ѳ$m¯鵩KA3D;w7ŏw^J<`i$M_x8wU-,/h!pbP1|*k _U;N45jX_:]$ %ͫX+é Miwzz{7`fOE5FohX}fL}k%Jq_b_A54WK'h?:lTHmm. m&"X7rV7l̨b]r+ OpK[{0EuwrfӵFajCCPktMݻVw[FR(Y-VE8 P?)p>͛5 #TtF%3 qhk ;`LVOpZۓ. j&\Cʡ <*g!r)J;ȁ&xK0N\B&Գ$bԍ7fpt(0H23ӲG1d?ź bVֆ|\[w+tjj?b7hwJCmm#b.^VBDRb8E]4J 7LGc.Xd/a&ڎ @顢zQuֈ4Tqi˽èb˕ 43~,ymoθ[0 l} TCuLBt 2ZW>Eh@+[Řy0= sU"r];û](̏{e E=ma^2'FKv~.Оm0Oj(esߺ Pk*!3IBЦs4{^|{6k\* }XYǠD=A %$hǹWǂORV UBꯪr+Ca6 Kԣe :Zڿu6&?W&k).]%],lb7MX][H"}WL)RIrfr?AƁY&I~_IB${XlZXE&|w#؆`_vߢfu3fm89?9 ̟NՎ`jz1*.@爎܋`oْJ_+-4α6@/DWEjE}HRDl;Y+ z/1Dѓ(z)oι&;.4aZ#gsbZ+XWi;<~n"( M'b6!G lP<^\nM8--aG+dyXP^s:0q \p3bWu.,R&rm#қs)lej(^ ,=/FV6fj;ex%Dk%!FW@ao2QTvs 5h0B{UHiGCOzL'pbIq+'_1Lv QA%$[H~}{1fKٲ:HmWS ëd}2w7 j< O7i2G;SWݒ!@YsZ~*PƐ6xQܡ/9i7cGHVf3R>K2jZxH"Z")vHD} @} YJ64T(P_(*C]miSJqOZgA(ny8}wν37;?߇*x"D6HaeZ 5K e tE=H\ƒW8 72ym]Ly 1N<8͍@:> >6pӹ$.7$C$pA)hJewT*FmKg-lm*{{v\ܲsJa>3_*ݑہ>V5|WG_>RR_YL!RFjz S5fځO2< `}I\:XiZkRH*4[(xX$u|I9̺TkVzl_׼gC%*wXR nY)N.9+wZ[E9ľWJ%wp`Nj[.b|JOsdW,R~#* ĽyFdwCp*L(8OelL˞)A vfFʹ.Knd~A򥾺]Di(i]YʯJߟ?>w[侾7KK6w"!eDp5V* 3VEa{:KoEDcɾJ#oOU44lTjFk,>{S?ýSk>Su=|j}T SU.nk.mcŮ)RxbT<TV*yÙ<+`RC;S^0-itp<ȗ2IZ_0ȡVVKHWol9=fd jb%}DCy{sI*{ZL1r`n}+D_*Uz3}i779_kjxL+u ;FxL.mmQ`sKzK#>&ޗxiBV^\s3_XX_رC+ҭj|S kϽ|j|[X ΆBL.?\DCqߢ7nO(M&JOiݖw0IJLM,NCOYPoQRSTUVX Y#Z:[Q\f]x^_`abcdfgh#i3jBkRl^mgnqozpqrstuvwxyz{|}~ˀɁǂф{pdXL@3& ֜ȝ|jWE3 תū}kYG6$ڷȸ~kYG5"ŵƣǑ~lYD.оѧҐyaI1ڲۘ}bG,{W3qHvU3sIa)\ Z,      !"#$%&'()*+,-./0123456789:;~<|=|>|?}@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`acdeefghijklmnopqrstuvwxyz{|z}o~dXMA5)ۈʉq`N=, ٖɗmZH6%ؤʥwog`ZTOLIFEDEFHJNRW]cjr{ĄŊƐǖȝɥʭ˶̿*7DQ^kyކߔ ,8CNYcjnoldVD/h 2 R e r xzzzyuph^RE7)4=@?:4 ,!#"#$$%&'()*+,-./|0p1d2Y3M4A566+7!89 ::;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{||}v~oiaZQH>5+! ؎͏Ðxpjc^YURPOOPRUY_fnx̰߱ 8Ql»!Ceª9^ɂʦ2TtҔӲ6Lat݇ޘߧoX\[VL=*b/fMq T p_L7! }tfUA, !"#$%z&d'N(9)%**+,-./01y2g3U4D526"7889:;<=>?@}AoBbCUDIE~% ہ‚rW; ϊ}bG-ޒēx`G/Ԝq_O?0"۬խЮ˯ǰı²µŶȷ͸ӹۺ 0@RfzƏǦȾ *GcЀџҿ'LsٛFsM6+1MZ:{OX͙~ʹ~y~eL~j~Qc=9~|4~cl@~]̳~nf~C~لOiZ/gP8v}6q}0}>ϲ:}i^},~ ׉_LpK-~~,*~&E()D9vyowy=TS3wI!D)J%OBvwN64;>FVWm S^Di*bPkpة?%"1#!ϼK`L<n-e2*+) X䥂C@v2l Q?(=0q MzǃIz7MEY; Y@K (-\U&>rI^2IMe;Ya"VN,S;o_%sD;fƎ.R?l ;0Dq>8zDKG)3o+&<4@n͗0EO94#ҐnW9 b_7}B2yːv/ąJH삻Ȧp$ȫވy;Æǘfo虔F¨LsI,KhW2!AjHE^τ _wdlXggΩr!jU)[%B\DCfp <_\?k,.wȲirJRݐ=>0+cvZ{HllLVAc۠ ^{6oCҏSمbȏ:sz 7jP@Q;[wg|z30Uq`!P-~|X3+z2lIђ:_p-FOJ*Yr(".O'qäfrCRJ'dc~h!€?`}WzBd;hѲGϲmT SAij9< ߨ%@`8xLTqė=,Mk $hJdx_r̰gʱhtG,KytomVK0X?R=Џ ]ٛa`sʠ7g&Grŀ?>r&z`b>&z%sxbw&{~څ]"WR%c"zD zA rs!֝=jcf]rmANJl$ے#ؑ >wTfGFF699<׵.'SZ*˺#-Jl.ZZx%m*| o 2ӝ_TWK4eRsu33'jRFBWl| Fgml0L1, y+Hu2f;[T0BE{:qntoT]okI, LgV_R:Kϋ0dP?= vE̷փ(M4m\Tk׉o,H=Zw/EI-LQ[ 8F/g֖'$?[u~fghXjݚ- VImKՀ,%ibQ*e97WKMYiHtXTBUDw-49#iԗ/r]hGވ/ lD2 h‘%TTT*Fdw">GY?"[f r5ʊ4`TAo4H5rWS8Xy;$Yr'q vUPV&4m/5LJE:S7Hvy.. kPXAl` ,e: E$@BKr.!{A$A,CY[EA;| TJkU>41aƜdcT.Us R&BchR) Pd;ʟHbl?1;_:i^mMh9Ӝ+,x+(‡j3=P6u>a}&b (0=.À<2&m%u9_~zL!S`(6͟>թVlW䨸m5ypg!2< PR%wC>ubvbF.0UK$K;؂P,!rA5%\v" [2gwdxJ:_'Eښ_+^Cژ I! v,V72UJLNITUKɎIy/R+=+(֨v6!M @PB%R--3|4-)#ͯ w.ܘ<;b#;*>$eG >3"و~AZ$xOUx f𜓜x;٥Q h X(Zx=`dš 8b†id, ϐ!enZ b /޲І2P0~ +1baktT ?g)˧9 С`.ޓ`>'4\DRdPaxԗ?i|9,t Ĵq]"m-9OD'Ex>#Bz6Nk%tm6BDzVQGq,2O: y{iHcy[]vaZT5 ȨR 345N@qG!fYXr{3^M7HX1ey87ҙ;NP9tn/D=}*I:2s̋%G{7abTBm6ۺ4JZmI׶Fהz\FD*rEyք ̣V-8ˉi#7XmZLW:2 $Iⷱd`U+z3 8"}Y\E^\Qܵ)<&uZ!FM)V"ڟ}&à/ ď 5 O546PW눤0 fGlEbdc 'ƪrӬ[{K("M/y%0=zFBx}{w6{Y50%,40R}ԓvTp>K@fR$7HU( /10f<,1BS>٨RI3#&&pa5j19#yTH9cI[էjU̟~? +7NzM`k|-kqJ}(Ҙ2SaӼGi ; b:`uǤayU}T 2Ftm̔%OpuDU0m~L-_:qWg0~huw-] NVrP =<]x;Y1iw@8,n\(zqb !$zB&5dn61Q& & CuЎy#c%$7]w'z\0Lk{8 ;fGS Fx¬P~Km%t3MccM(bCB$ _ J,@՜ %ӸZ;.6B)PT~~:_tHNITScΤ5_3bO6-[o 7$cn:zNqnE2~7\NT' "[fTT^2F&+c5r~ԕ(jl 48mWDC]X#<n_ T 45 C0 V~ m&AGA7w@w;Q8Q ?d9#1yʕq_eS]y|d*&6Q30J(WG>HN vAg+[o:y1ډGmUV'pJ{"M@3X|*oƙޞ%sfJ<ߔ[-0R'G i++qNPF\&XT~ykPx>–~u2LX'P MOW rة Z?qU\+w>-q}y/sRQQJ@737Ka[t̷E8X,Tp!PVK$`Κ׵bu~*LlBz-f{i8DbMp/ŲF_<`w[Uq. Y!'i7L' Rz$v]c-ީ%HY~ٕ 鞀ws{)Wa˹ԑ`{[z ϡZ& z - U@uBP.8jz B{GtϤ1ޕq# ^o2N*`DZm錞c@QY@Oy`ŕ^ )H??s %J@f-H%{#}řPKn@u5w:=YX9(5#p 9#Av(~-"]Qb'䠡ya '£ +vO@%7_*Z-r*~z Ց4!wBpG-q.a+c"wmqk=WfB +k^0>npu5㞃= m]0o-1:ǒ~%ui;pVO/a3;0oKܼL6Ed@ZU%{ ^ ͰyOVNHLmu?uMBEQ1\IُOui@L7Nk\dd[i|lRܰ3"rW^  19~(VZQjsfb5~Nl, $LAE \Yv3k"*Ie.gj4uDk"*T~~g^ ~<|1cPx7kF84K(/AI\%HG;'6`kK ZJAFqKq$5GT#.a;1 p't.t-SSUn;QY(sў*M8= BHZ# GcDS{d',Utl=,}*vcr+](_1rØ@?A[KDlv'”o>=ԏ[?Q ôn!ܘeoiB]u3PzP'ߧ%44Qw L7@?;gSVjgohop7syR\7V%xL| 3n|2Q|-GotuV֘Gk}fd'̐yQ/;^+b#&~ي2(ɚpTֆ)$Dru:5zj,|~0T\~>*,6Y ]7E9!7;au*8Y?Ң#WfiA~\mB\$OwDhE16:_JqBR%*X3 !O:`Iok2+}Y'1%Y GPMJ{rK w_ L&N NyA'ճmﺾo4gz"v;L je %Ɯ{NS6U'*@djNcvo^=Bi 795l€Aⶫ627ICkyV_}B.I=YR2U^c~o\Ƙa3Ƹ2@eU*Tlmcӱ~ xnNU)o`Iχa]PFŚVTC&ϣ࿋Y=d]/..FBXs+$=}buM>RWm6Ŗ6ᢐFX 5x{v*j;zv<_~AVUJϐ^IjQxシuQo=lK_ՑEkZ\4sqU7vOa J?Q)4C^\k[{3y~M|J'g4Ay,$0( jHl:Q"V҉1X&e s)MZ(W |Ϲ\88&tcpҔa͔ CC GU$^fb|8u̸&A֍9ke7;㥦koAvՏ0o5y'M3q"y$[Y@SgÓ=ݎP1)L \!B;U!)/C$N$A³ueuU},3Y'/Jc .8_[ON-<"NawGm_+yj~P]ſ^\y X,r-|㒒ܳ<L^T},^eDR,nkqց%|r,!gJx=~p{"\eeEN;Þ=${q@Q_\?/иLe>u#Mp'Yn_e<q㼅Ra8pLB=(YK[l`BKB#4;c;HS^OA>Ʉx\+0lkOԼ`Fcfup.wlCnKJIi]&fXPAn1کFTKBoI!ӮZ f)~Xhy9 ݨOC5&|T2ӲnSLB5eD0:yP;(w9mΪnWhKu{`wk kH>*ڲ1 wp5Q݌$;LvvJ1f3n*Tg@oO#9|}?V0M5.ۀz{" NK?C_$ P&B̆e>(qIu`|ob|_0l2WꂝsCܴLTIa?f(/+PIwB WhgšH EiŮ(G6 "  "(H2̙dfr $xZEP>ţC~EF:}< \{ % rH6N$(߫Nᷘ_%1]2:$o-8ȥ I-qt;'kTjJW^}kfQUr\ulNkHn᫂H*Wd6M2 *{`V%VRoJJ`+"yO|s86Vy8 :+;9ɨ=.qqѝ=ɥ^ӏwldG;fH^2`zBȳ ŞO*{M2MoR0i:T~%$9ED~cj<}${.-+P]c=Vzpwz\S;!?C:GFIױqYŞ ݇>;]mS)yrEz_n˕aI"l|sGvmߵ_7e]֭>ГU)i:D΂G}V W5*{f? ($p\)9D$ZYr|(4D܁OHʳ ;ܫv۱jxLr_r ;Wi nV|Rudܦ;@YNl-QnJȲc/14C:'K&̕BOJ{ߴzfsW|F-q2 ?}Y[pXdY<\v+M{ir8~LJޯ vlL: ?@o[g`}>?UrǛI2Lk.}GpI8QRV%܂L0/PUE ?ɹTcۼfHs^QMC!)$ ; ej uIy W6#LMi9ĦͱP*HʘFg]mߝn+|X$Z6K'OQJq m(B~ljSuZ ťbhWP"z@UVJ΂\,<\HA 5Oaf΍C75O Uݮx7F>QL~:ʥ#][eTS2%c Æ~EWg9i%3W4ފ:}޼0_X|-ƣµVu8H{YF"qĔ-F95E!L/3zLw@"FRmOQ&[#ZO/xˤr~9T00bܬ 4Pߋb>_nMFY%MOaN$ʡ˖~ &($~>tBM%^i3ϐEf8UB '`-icIaͨ+ دR=ZȾŁ=5U#5HR>njky/s6H؃E oLyCG/?QE%FvMMz)=ZB.ϡƋ/•3O85&YKլ(ST eҝZVx'xaV4Ë*H]z~h~ i0d,K8CZy{jCF')b|xNJ>V{0e#|SE1b狛*_R"37Boξ(p3_<ݥ%-tɫBetƓpx HuRuɵ)H?mf@Iz͂qrgM_D|Ce ӯ_wCՄYK/Ԩ 佨/Y0y̸7.]*ѳa !d[m9#{-;W[ U$mb?ci3ؘsq6ĂT t֠} dlv{Fyt/ټt̰KQ8 N"4ʻc'׸Ns6I ][#?wsb,4U_ f)Eď* uä6Go76ɵ{'CGa+RUA=@5_rgs1OUG*ʚO&Q͡4%nlc=%Z vY Zeਝ4? eC` _wvĦ10KB/*Brv4όwM 0r `$CܝGa6;g-N_&ɰ.` `0M/s\PMf`p3 $A7 i c(y jӍ 5!UiMSD-rBFL&^:OF-T4w T3c q]2Rd/3U\;?Up=@b TYRJ3O)*+sWu.[L6ǼA. 귒hoN_=C|HW Gz}w\2h{?Ur_ס,[<4DmD〷C/Fl Mr_򑹾g"P\TMIiDw$=` IӐ }6.jYx^h}]"]l 8"ӽ΃ǐL"Hڝk:^֖Tm.^@1~qxTlU#U75:LE|4&W25exz*̖̆;M0do^lpmaIS7kD#'͊$"lL?bADINmEh 8Ԍ*"vұE݌5Z5 `z~x[MN&a|b(ǁ$ch |cq)M_Ɔw>bSО$  Dpz!G@o3a]PnN2);K4 U"p+q 7bLay$04iCc9(6>E3a{ R䏡0`?s07y9'`Lq`ScLr&MP.ڽ,_ru/F=܏=1ltŜ 9>1lם KX_t+ =#ثL uuWK̹ u)F@jR_$YuBśGbQl+$,o8qlg!) n2QήU>Ytw(^'Y! %GU9, &>YcwU Mj"Zo6VWF9=al mynqA/2AI̐i qAN?!9NxlbO{eiYQ̶>SZ .&sbj?1_ǡPkٟx`дY!n6fVJ?ffon06l)7BuyMAѢ&m>>Nj#4J%&|E]ۊ:i2g0io*6zXh +҂3;1"2ҍ+O?KjaY|nMHpA/LsI5cu*ΐDx!W {|mpq%qehrYbBt M7uA- w%5,x+ z!Ί}|%wpȩxeXx|Yy$M}yAz5{+=}5"6~{άq~p^Q~Md~*XŸ~,LU~S@~5 ~+f2T"P{pUIpf P[AE;Z1ٓ0U)Fj"0΂op~7f ![BPY_EE;T\1撠C)k"djpmfr=[M,1P\ǑES;`Ћ1')}"Ρmfni=pkqr^mtolVurX wtDyw'0|Yz>̾jqźjlr`ntpu0rnvgkbtgwWIv~yCtxz0b{x|bh|~j|l|^n|~pp|j\s}AVtu[}Bw}0z~l;fׇ i 9kDmh5})oviNqꂿUtXBEv=/yVǧeP{qgi卞|l{nohLp(TsuSAv@Z/ryX_dִ2f}}hƖMk/zmtLgdojT3rxAKuI/8xσ[c&5e[}gܞrQj.xylfoDSr d@u/x\ębp vdܫg%iwy3kyenbSq@to.wUad`RfWh-xkkemn)Rq\@?t@.wZtf4uhvjxxm0xyosekz.qR|{itP?|w-~zK'rp{sqԜu#svFtgwwtudxw*Qz%x?E{zb-}|Xpzr'zssj{@(t{vxv|cwy|Qy }>z}-R|~H(oYpq݃^s=uPt;bvSPPx <>Ay-|0m{opzrt?s^auQOw+T=y>,{¹luSmoou{psGrlatqOvk?=txj,{ @k mܖnlprxqؔM`WsNuȌ=&x,zj׫4lgmomqq0_s*9N uI_|2so|u]}@vLO}xT;"~z-*|Ly(x*yyr z$y gWTaˢĮkTd@D\dPPp-HG&]30;sCg( 1DE*n6ܵaz*&>P3ĸg| ,X񦁓`S$>BG DǕu#i#܌-`xJ!wم:(`[HWeQ2UFD`|:Cd2~TvkdEeUb2̽p ʠ~[@QdF!7H$ #dLt!BOK*G-iCrB.UlmO> ,B2W<+367ߛ@ )۠&KO 0ޏO igm82=D 4FB[!AIb4~Z *fz\OtF&ӝN&3xF[Hjz&3n14bM zB! |+ /hw{V\lsTjg?қ۟u 깮D}û.5ʺ(wM ұ=Ljeo(u\ yPXƢ8p2232"uh0 ;(3-ybݷ3WdsF@w ,8#!H*9)iF^ P7Dg3I33D_)JQNdOm2ta':=J.۱ s`d+uu- ǵiȵ\L kw/i&G1|91:H^gW@-Eif?QF?/KvřMkz݈uN0:ӎ3BJ]PU@׊VVzDPC9>RTl{=EY^ScyjN96b~mwj[ Zl'd}[YގM:tU9WI-#d=sѣS IKuƷ6i/JO{s{c@6oPU,'9cV~M6IQ1WwoT+mlF0\Od?oi4M4MC%HfM[r0p[p|R’/Ld/_c8]׍ YpFKM(Ewo@jjI0/kad[H>|/ѓL |00SVRׂV2Cæav4x,'L82'7&n&CĿf]9-f]i{Ta4EeNٟή"V_ǔ3tf65ҷ, jP6Ex)ͻUSu@6M6dFVSˬGŦwƠuy@>.TȆVOdj?#驺sycA)w,zl<ـB*7ij,\P#;}}~r4fxO"ZhNMBe@(78,iA#FaN}qǖ*lf Zۋ M2HB-7߅,yY#p9|qeےNYƐ*M}"A튘6؈U,ۅ#||(qW,esY!MANJje6Ç,}#5tPcjOf=_`rhTkHm=op2s(Hv "zbtu5k#jl_-$nnSjpDHrB=tytn2ݑOv)yL |triIs ^ٟtSuSHt#v=_.x02y)B{! }~st(o.w]^`cCcHlVf+;t)i0aldOȯ>tsw[-wnw\-_AMb0ke#SsShA!a7kO|o>#r -v0[Dn^aaShdL%rg{`j哟On-=rfv-vm3Zp]­ `܄cr f؝C` jRNnb=q-vBw~o`^q&ccrfBti quk_wInN1yq<{u8,-}pymjynlpptnRrp/qsr_;utMwv2>@?nC)HKс#Eu$%`^>[ (?`~^x0_+OËv&"YD>s5x']~-if~>NF" P^OG# ǖ0<7ӆ7 :sXL!kݱrx{6Rt"+@q*7k1U誘Y}(~\H`J䞂\ 52[{F;Onݦ *C{2Hpuw0D(MHOB$vKѻX{'V' 5c sh]T4I DGãTD(2BNlz9eB_ ݫ.#JUbGɰ Pc36߅!3?o/˼ 4Ta1l-vKWZApɾ<>\Щހka8Z5$GdW#{{ߢ! e8l&Vlu4ʚ@ԸQWJ"쎛)9(6gf y'1?JL)b쭢l]4LkۘPpuﲹ)nCA Ŷ+2dEH'Hm&Y3uѷkѽӭ1n]_Z<ڮRvӛpjm9G݂#j}dA-uڠ 0\C"dhK>مٸ:IFq\BVhF'$[I&3BtK\ D'`;I ["%#N\I |?a8+ş3"-Aש_ZZKO%u6`X{cͯw1 $+OM{'E],jz6+~ Qk a=_/E qbVk&S7fg\"&]KOÑ: %ijeB>%j:l=T1e~/ߪg I0^YV)<^ϑ% զՏQS-WGpaθD8ߠ9D֑ՃXM' UJ]I"mteuuE)-3`Ҍ SoO6Ju@$ZZǚ;oam>݄92)@m{>-V|WU>r$Ӳ]qّ¸zEYuɔ>GT@蚩\'}њG9mp.d.@L4c&,r;b ӂdlt3ݦ]Q<b-w Nk k bK%H@ j"W4sf|Aa{8c%J@bW\E':Ehsř=}9fǹTW !3ߔ% פԘ]YzĀ&XIkWdPيb]9gbIi $ O1wu_)xS$P)m/UI .mpsf5Uwl}oyh 4;=DUIKSDSjj:?2*w0P4o+G4O6jeu HW)ϛ=ݮȆs51 okaIӽ֒Wo0%>#}?V5N_r}%7 Լ{!`D}K_4 !Q\HҽzȔHN>uA-^Ჰbg%+k58W #wi+q0khcuTT[`5Z[`J &-v**cs0:-7o3G(Z!d  z Q}vx'E}aQ#*'viƷ|'in˵Y;eR{E1vikYT24o/;K |O c Rr_T'UtKyγzaL= zs#k)|OĀ܇:axim&&^cŽoIѓ` W82K/ױϬ˽^ipuO:JD:WtG<8YJ] ՄyiZP-|xm4rQe`dZH ;4SX1̚`wpu>7 H2%Cd>zES?+&e{\Q>+) ^T9ZPFV+@l@ A B r3L2$$x *,^-ڷ[]<**RInpdk ŻΫ :C>KXi<_TTՖqcs.JmZEŒ:^΄hsVIbm8tSX&^ a*Ɋn^m=A2s^mICca|k`K{"Y١:nf,ڱW x_n~ !f睥# Aɧo(u gįVg攷E)?n/ؠbdSu3QQIB`\C!d P,2QC[Pһn`RXYU^',|Y5G4-},V{:T5zGFdx|4Zٲ u'ʦ"Ww[f^'0Xcx2rKJJDJmB|CÁ=55oc/hNL9'0jI. =$!_3s^>pX0]ScԹ`gi9Q?+,O|ekkC)6bf!),MjQZF_Y[-ۈfiv&mH!`5oIxudP#F P&h_2nnmMsC?wOt[Pk+jnA ǐHځY*zל`L﵋TL01|w:44o(%j̨5YJ_|fyl00DO+/.5T"$8[g)T`MH?Ɠ\fިÕyL/\Zj@Ν(Wڢud>P"Yd'$$ʗVJ+W>pG[^Gڻ2|M 5kci{ZJbILFPCR7<]'wKÍQXb* $f»~ ^̈́:)]}pA(+RXzE;b1t!9ݠBj` d> !L7gh%7nׅ _Qg1R2Ǽĸ:@n\KX)'WIC0hݤ!XL}4l5 Vh2,?bLb#(sÀytk]:ibP_"2S&F ߆*:/~5l6fݻ Ӡv(l1u;8qi7mL[@Wxlg Y<#nMDyYZOEX;/C<_IfGuROM++c7S 4ƊaZԃu Mߊ]>]o/m^&=Nh̕.g*>d_$ ]koj-]wz`g`@XRSZ^6uV^og~XQ 濮a%{s Tp4{HLydW)YU&R?FD/'gH7yOG S0᪄g :po)-.XF:e*diG{.㯙nwn.tY<"`7dsSC!x$g:SX9Y%r_']4K . q cYv.㏢Mrm*ADbW냊M1Dqby9mT'buq7Or }yXK8`微.;~1K}wҭrB;ҏޒ &6 Rr*?j䆑lugICkM|vhZYHn8VzQ3N??֫zGP5|No(RGJ[5&Hs)qq}^&2n:zǰkFmP03;7Nsi+ZiӍ ^zs7Tm , zb@p22{96ʄ/= 4)c x t&83B-(;^SedSy7yG^H@Es7<AQ|h[\jeZҎy1|i-M']|k!3h{&m5&[KiK%}UEk̀u hT[*FkkOZ e ev]G ؼ;GLW[d;oo3xY{OEk[@|l2섐^򒼗F6a 9uUQ[Em'*uWAw:^WfAw:Rc$DZ9-N7~c ?;A34VfO 5*DvUe_Rqr_pMv]{қ[;f4( c5ڑGdxEjO-n | g8 KٶŲ]{r3J(?ұqlu;S7qWA}ǰ=o nxg|GCTpTaH͗O0U`llڤClt0jh~pڱY_,x',IUjn\[M zDBb<Ô]T7S0Co}2%sF͘MQ ś!7fSѕ&.!mFk(+O Oȏ@ W1fG 0JZ-#=qb>@@gIxFz|޴\E=Yg6atҺ*SY5T9vh  %2{}n}I90v zRf8kOʼjVo:*xH3_ 6WWx4\;5juK::i7rʶYAd~X:J1<;e (;MsrlڪU[y5vw(k -OlHWeG㐣݆L9sŠFp6i&xИp0C2}TxmCH#ѽZyڇm{+EAaWdVSy%ې8bש"SLL14$Bs&Bj&d@Y?O+82}-D^ݒD(PR{Ѭ.s!$4Pڣo\i(#u"D8 :]C>6ڒ׶*m@1GQm lìOrusg# tk-ۤ^G) yۂ2b+PgDWB;T+4Qv{9輵;!f6~/ė|@r~EM$,<`2+oMҿ$ȵk뤆)<$\nnu|LX+z-]:r"Xꗺ.KW;–YFC :Aǔ+IU u+U>.+͋;SN@] LUXKx6 ͑8=*U4^qݗۥ>S韒+Ż eLsf v?m!'粈Yv0zْ2GwT1e{BHM, &fr(y)% P Ehl% $EVDĶt o \~6-s//E 2<뤪t :mbpVn(Q7:ziZNl*3miИ` snX U\Пbi0^Kc=!!{pwpyKH&Ș/UDg#M@1&yf_sIrŔ\ Bc7HexXltbu!hI &) ֩ršbps;Cu GFq~~c6RbO'l"<͖z [T0}5y V|EWrф\2aAA0 /ɷW&aA AK]מ q\kPU"Jѻ?W{j#'rG^$U)~VHDTup7eÊ⚊R"I^w0^+mOXiMi-T5ȝ'N]~{e r5Ճ-wA-VYF~UgBOJt8y0.{KO(vlJ uS0փyk^?6Wc+ Cl]Eko%ݼ脦g}h0[[tVۃw,U^|}X?4:a<X s%هU)<@ZQ/[6 . 0A=fxIҗQl3\PBoJ]Դ\>[3?,ЛMOyIOi> '|2kxo6oy*Zo9XYifNP?1k𾠣 *_BupֲB[ 4Xφ}P73d"dٮ&<ăT>x4Y"GXF%Ngt2S 8.hpq܏#~2HleҢ(j =~n$ Y9PKC‰/q䢘&lrS1|8+ۺp5q Z(QӸAX!\$$$CsrL2$L%,*OQuOłBuUX뵊]xV~n,[|nC -bY@X?(e92"կ)fm6@>_|Xȼ L N+VJ2v&ǂga:y*=>C,꽅zqwΣaVbP$Ԇ3H* |tc^7CvfCUʆN\A X)MȊQrK{Fۏe"j%hCi24.$ҲɹDӮ?2]HMtaPZ+C9J*_r%QNH4r{W) |em}^e ٻ .v_.e'T)V4(FoUgzf0=rƣ[(hGjKҢy}%]ʟ%(y쭬0L1sR1w^NJO7 نyoxõO`i0)¿6T@JJL#״C[!)9!w+@,&TQ0GU5a 5\1(-9]s41y3yʍ/ G䇫~IĴ41_35g%@.1N§ N̡Pi'74@rz8Z? i;f cENOri@Du{A6.ѱ>1_:, Jf?/LCNN*E]٭!mq=p)ݍ cFMH?b;t% 7r~L&3>ﰞ~6slD'9?6T­ϙ^ 5; k[}gX0^hq$WKJm3qV/f̔&|}31sO[9"6ε6 9K+|dj8a&kɐ=9wUͩ?|0,lugzeU,}* e-^uGSoy77bC#Qşn[,( l^ 6!ʌ>":jbiq2$V1\$ǕwkGԣQ%[`ѐJ Ή `]+Y)u!*5(HIdaoElw17hYxЈrMyA39ScLYgBل*dlQ P/Džml)IR`i?ĞAY訌:et/ ysn琸M>dSG&HPe*p:vFӫ}9|%*CdڌTm ؍θSVkq~VQ< f CB'LH? 6ǍZWzjxA|+cshi#a43 KZr?'H:m2AĽ eЭdcM^k^Cj#,@DL2I~tHGǫJ̀e W`_qZb "pp߄CH I&d2L)xʪ*jXEtJJ]EZ_=@XY#>(UT#tgE UO4E]cDix`Ffw0b(U Y]sAvjfhw@A,bx#iu+E_Xx˼U-EW'_@ce2b1( h^EN `V[@-kbn_Pe:60lu-'\j|Dme;tHGD˪&աD!ߪ@M?B=rΕtSwo2Y!;DLž]򮆁˶Rf;˷-r0ۏ첸R}"?5#mk+3((.RxP{K$ ~?uX m(U$C[KIl9vL"F]C2q.OI61Qx 1iQZxle_)O&uZCj7$6} A~8zXmb|n^i>]fQBchJDj^ k]rou#Ih 8ЂTc1)üW+-*kxueI~PE:LR] &t-¬^*$M4-bB c鎳A9ZuKDۄT}pp;dzx0w 7 ? rlJU/3BK3hf@jm1RזD*p֓2O(Vv ndmMAO;1S`M-a6)N˛,_ l[c.Hі%Ŗش+#]lcٶ$ s~&b~In^Y6-쪸ʟ/FRa` Ei|o$Գh:)=kZv6g|V'E;R^t\"ZW YnN'⢒LiK[!6bjnf$=+ *.ӃKvIchP*%zډ,1-pGsD8DC7x&X8e!j5kL4Y &XqYLA)$]s_g^.[fx́{sHq  o݌ KFaa)1$PoגיDO̐Ńwq?0$װޮxYZN8$8 _ُ$`lcZ6ݐ?ȇY+0H5zቔkQ}Ö!~QQ2&P{BcH|7gz9^sylu^A ;RckU>)vQ 8:oVcsK68#7>^nNk_<w*>mڹ3"ΨŢl` D#ޣ7W-#hD:G"DxA4 >X( 6b-X>*'qkxOOX+{5| fP|~NEzEy?|S-2<3}=`[~#ltGPj_ _߷,cn$kaM=UlMQ"gɆ 5iЉ5M%7R%qvLSG[]]M vKsw>q| 7pL=#.[CjϨ^wUOlTvCe]j20uuFfձʪ:AƆ"E*S'_ !Z:Qpt47rv윽Ys9{<Fr׃d+G1 F~ /bm1&&x, ^ LtZnDz4g?x7o߽06m3fB|=ksΛ 4|K5~Xp%&(*,.0<664^?|X@`PsB#b$ PX<1A͹O3l.O IrOS#?UBP' BPT;} *~>22 EOL_~[ g ,v,cy]zFl(}FVύPq㫪J6A$*H$Ρ`v0;f×9zL2ٞQC|QM5xzAR+Ԕ k*xGjsH%Ť^Vaݼr~Lȡ3h5$؋#2'$ ,FP].V!foDc&2`* _'ǹ{# ݰw%{2>aQ*X SV*5r1V/\2dL9x~dE ]0 ^z[AKmILŤSK``;m\ojc{.]w{]}A][UT5䄚T9"#֑$-QJ֙ (R;7n^윆a:VVTST@e& PkLlvw6ԷU8{`>5#8-Eʦhc5Ij ɱUx(EUu=XU=ux}{tjG 4a(=Gr(nËqZTivU肝 F7 :&|ؾĮȬ8CLNlG\nt{Bvx~T2?]ъ?:B': nAS+w."nG%PBRBz^MLpz&*T@ mHh؇Dc΢&ZT_Wj 5yI5LOї5m һE/`v0;fˡp;ϙ־A}UlK8SQC#kדtYFUVErAF̾!b7E|{e wY쓌E8T@V4U4<7IIiA(R@: j:8vug*tE@EQ*r 럄B; !rIC@V@]_ӇQ5UW/)aY/-Ry%F2"  InK/i"tY{p8d|Q\Đxi'6ĩ/UUi5gԧyebLY(ke&\1q(h-Ev;wΛ6 !5kC(xH@m՝N&וy UFeaf5n\+#$,۾.wAڐ&T%_}ؗY6"s 9G&j ơR9aWLt~-m ANv$&! 2p0t{z$?5Z uTj]Ġ`9t& f,h؈!%gS$&T<6ncK /'z&bp`F*8b(@H3x!}': yo8IP&\P{C@Rt(ɓʌ*rH1𵐗&dx'McČ`$f>m|S~䃱ؕ$x0mq]Pe& i#eF6AWB~8QChiTɞ <|]z[u*nz!bg9Ԓr3lq Xr3" >4SPh=m@A8 {Ͼ+\Ǖ--F3a@4M6;ҩ'Z8JԐpjj6 DzQ0'չ=;Qv(X N#0-z#}2Ң>ƾ#Ahw8Vw5C/[r:mU5fYH7H)N6S PX'>}<5ӽe~y'NNdtOݗdjM Z̓x3YAdECM&-ڀjG ož>ْm\-u ZTS#%xG;Ѣ8]0^`#Hƺb~ںnA-9*ViTR8 `'yM>aATm#GђZVZ˪ݐETD_l }mϒdo8zPc)VdjGT *:YϪ z*MSqKP}W7K۫Ov*om;Czzqt}JeVl|eryItV2j)kb腳h ?|lIlN^mzQr}\E+ݫl([Xp1ٔZ[m@_Xi䮠pvfy?q)?GZ3=@W =T2lvsdrڰP챢ށzE     q5YTp yOCŻReb &l[Ghmb9M%>]8!p~{gkl’B42?ȩVnI6 e%2G-8o QP6ncN/J/FQ&= }-9>#, +>nƙ,Π z,>3'ЏԍI6Mo$GWdosfܐT:jGyhKڻ)k[Leٓ#ceA>Vl oiEǪ2p˪lMe.{J~IT"Cvnc53}-"ÐhI'ِ,kHM"D[YjsUZCM:fD˂+)U Naa␽Zfk@ 0,"IBLtrAlĐ  N9Vr:#Q1ha x!coDjԀE_dLqi&]8NLSNIS/)WKlƜ5==\[jTv]٨@(WKsm!fwO)iiLڤ?鑓#tɕOL=?ٯ9,o9̳t2UAP@C6-!d!@ BB6BĂQDkop94Mre9*ӍRMd0W:rB5*G1GRBd; ib"P'dh8^`B5yϕJ\ L΄*nW2b߭L)3t*E&' sdr* i@s?/=:Vh,~ߗ;{u15k}6EnA;xobhS$u,N%ɕ8j 'q/qO=`S)г ,Tרs=@o5-z$^˚Fk3(lUA?5(!4v(_uw1ff:w-}hXKvzqAOQ NϜ@:&z$B/ $Gc*8?z0;ߗ]/ZZV#sY]X&qzlKNCd P¶GFޜ=;èj!,z5ϥ+D`C^n"NJf90 2?}ɉ=yΝi*mJnL6M$_e A ($eEU Ȁӏ^9,>IoGs}YEHBWh֯յYTwL3rS1MOeS-)*d`[hh%؝jӣ͓\$|[XRK@-_JoЌ+כŋ8V"]?/&{d_$]B?,kʯ2xF5xun#s [oyDs?{how1,8 fL?CVAyE% K.?)-amU [5[ڜȺMtM0o?s}*Ϝ|-.̩ {JZVu (lIneC6%FQnj̍;\M{w 564q@p${{bKXQVx &\^fA{O򒻭m.B0b @ħ/d?4m/o y0wA6kloz=vVtbd.RC{,DŽ4]@Г zӁ4#L#y,xK|}]XÿC>A𵲇i6pD1|܎,HψP(@c ii@Rq2[eaU^FR6Jz!` {v' fQm)0}^(6Rc$5 (r~P,y9wM:(^։gDHDϡyl"0A4t!5F5bl ”#@ )ۚ+Ou`;\ mqׂZ4++'8bqu2ǬN Gt$ F7 G,)O '6bgSo/+WuQ.mlc`rj($oQM 0rIF?i#@I_S>8Z7gW-[ܫ J?&[1Ck\B"mф;[ 7qD $fØt;Sj͖%qzfg,;-^Q`-}"ҘGHv- 35Sl.J7oÉ@ 5pNgmwٱٙmu*ꊸ/#7H NH  @HB\$77!PxE.ov[O8bD>Π)Q6AY-aWjLGU-oF7k1Fj@3\=ۉ <'#Gޙ?uߎo qxeP IÉh1nzY=Wu Mզgԥ'(e]-gCGi.];^ɹ>~o[?) oOP^M!=aǠtRl69m^rU4\ O%%-,O]TB*s;?Mw+Pmv{ւC)#HܥO)ih\LC.!K'b1 HQs.w{ϟ/2Tp c6#s6"bI)i+˰exVz:;9 sYAnSKG?vOW{$a R*ը1o7l ˯WC^kh+qf7 :B|J+*u}B2#PCѦˋS%e*:g cCh܁li) `Fm5{kï 5!>s^sUXt9UJ厓7YΆ-P7 $*gz0W]yl`\:XA>s97<5'&cE=ffӕDdyix M8ZH6."4Fm Iz9)d1 ź F+)mju@a7gDfFiUcԝRڊXxi>6|XG/@@+$kaQbќ0/nMҋ]%:c!רZTxY jq4Fּ]Xyw?=5a'v:u]㌵u=,"@n9 $$!+E@AHGBBpEA."(hA P뷙ӗ}Їw oPEiԑ9qͩ[ q)Q<\Uh.gY}WS(35QEJYj)zS h/Pk<^~'?aS| A :8}F/R+|cha 4Y^HjZU7 [C1 ?w<}Aw{_Kyē]Pmp\+ؐ- TźˠRVYĐ[tX;-i(i7[9GPq4zg6@0=4kֈ\c-MANTij *A+7V |ZQ4fmld/ 5@ ݽ#]w̋Usri07mN wˌ|!WQRQIc fWlerU:Gg&{ q? n. |f0rg$u͚B869A$Vˊ:bVoi L,EUJ@!Og)Л@v4>4=A[+g $fy4"nv,9r1gJc:5J-AYL :J匞Y*ϗȭy5Zg!W6@@6,GDOMBӆF`+٘^-+*uj/iuUcnC9K)7hsz 5]Nٰ;Td~>TJ4& *ow} u?zXcΑggS+~P2u.3MV&*1Z,_e%I#\iPpYRg/PphmsY}~'kGs4Tj`ޅX~>3en؈24"y 'ʸq~tZh/5kofصOa8s߸F_$@3q˰>'n9;7^^^=1.5?jD'_X,D,Qn?t/J\p &w!ב0؋gTStZ*j| D„=bCB3WYx{ot}5[,w$ 4LBA#oaQQ\xąʈ}IHNK ȇߠ Ke's}*_};v$p;$p\,1~ ?$  ! 9~|?}SRwp^@YH{VDrqQ"Ş'VpoTU$VdDױJtzt *BM"{i1a=~oضR[ Q!q/eUV.yVH[(`IʪYL 1KWiE2c9rg0]DgQ])ܚd]ѯWiMU}:o@:vN?ćѱ@Fq?.[cT(y1oM70œh~8Jh.#lQDҭWF[3j;E#@O<~.;YKhk&qtd=rT}J+zPUX}Ψ9gTz<#8:<1)y/%O$yevUm:>Cn^!R$,@P18Qr .eFҺs&o|<#AD1@q47剜_NJ5yvAT8a@Â*2 hc^3~13JEi颸r!:Aj$U^NMrs!&xt~8ۀ>4@sWѴm)9PV-kQŸiP8SYFR4c4Kl] IC4<Q zás!{2 ЅfNxfKH~JμΟuF^4܊prfJ@г:6BRBd Am-[[ꍏm@Ch[kd+>~r`vS!CkBD+Y]d=a&JD;Dlw؛7c_so` y툈z6tk4 6֗7Z *-Kآ&%ת#qfB׆cʡ2 GMTC?.X [ZH5:Wt6譥dUEFIҬŋ(ZǗkxZ,z0= >=P~?Y9=1y~4tV$aix%A!jLsLdEԶrV!tZQ<s`i ,{߸?xQ#/Ne`%zyx+UnGz)xVY'iNCV`k"|FyT&`y'_z>#n/F\Lz2Cs/)Tb%Ӌ\8yU B+|Ȫ/: {7Ӟ޸ho;A[,8N(V'O7* xUzjޝ;Wd(aCV%l`PPyp<}捑^gՕBkQG5wa…g7pkŭYlhd˿L^b/IİK(9w} ۿy7S[Zh=(L0~l.}-ZYn@."@P gSDFd{W5d˸:n8 \o3K>^=ݻ_%%4$&8 j%| A oմĶ^Ƿî:fԌ& 6-LzH| b?ӑu[}U ^^_b6QYU82Tݘi-434o'iͩZRn ZoH͟sӹ?}W>ߪm7 b#1en ?#s"*aQ{u5k ixtJK} LjH 0}0:[gAM vtv3tљvZuծ]uC;rCDD @ !`BBHHBr;\BZPXnŋu ؇}f~/76ذQ @Bbh\Yuun^R! lQwLs6H-M{#RpRʒKʓ7k׌MrM'?gİkS!" q8@& xw3KsޖG!禼:􊑟 %X~H<齾vmWkaİu~AD (Dh>F,AC~I)o|J"&xŭԤǮ03bgF}PM}3-z[6|ǓoK@C' 룐A PtD`#c{xʢHjl80bÀ!s'<jc/q/Ӄ@ | 8- QMxFeU>iHR|/1{.K<['-<+AIgPW7 K g N H]iD/X"IYEMo( g]Ytd_6]8|pR~ =)L}Uz{@ yf4HsRA:VPRX[CYqDu*ܹr. Y%3XlsZ~=*UN^i\U^,t{gP5y - AEr(ӣAeQq>IY`<<)`?5Y^2]b+0gnϪn]T_\Vc/=˚%>x[@A#I=,-B- g Vm<Ǿ_%߭PfZewJ-۸?{5# %SryUC ݠ>Ф'XʂRlFyCrsTI0%ŭҐǞ݌!Wi KFMvWZfC?]>jqF-VTyl?d^6b#Sl0bYKO̹4KftDuE5spx!DGSvWLv|j'mmcUZգ_E&Ѕmc~0 ֑ܙyWk:nv}þv sv$4y4A֏K磻2nuJUaDG222qwQ؃RpaWPgM/ uLnmXivu:3_0%yN䍡I/ɴQ:8nj %bP,|Tv@^@q;$8ΐBOGhOtP___r:!͆i`=li_(x1ra q#Ь$ $v@mdx8$ F{8 ;("a)^STS 7 Ә>ɟAdL bc!3쨠bUom`kRS2i@1ȏlr>>^@=͚#K+ڴW+lc4`}_81CQ~u6hxF 0l? y;H !?)|$Y"3?iV徊H!fLSI̝Itx#{vMH!!M@0cr?H+e.%fNMcH͐/dLk V-I9wȫ_G 7^P6P%Ȩea-\`XL)jYFX| ך3"紒jro/&ꀣmjv;!NzA1 1+d)VasYV.o*X0N?'Tg<'TZs{ZI=yw)=?S4О\ p|*N{?(ы Q#eMeXqiJѳRSFz9XFRwOMnUzwOqKqOVgKx}E5qcu(:ʢ2 R^P)R @JHC"BE0 A\ gnև}99? ^!HyYz@-F*#1KcH9}b_Rh2/s/gf 97y7 HPa 0WRX3aA *v=A)%(j*5ybf?7 +@\MH@2 P7]APeB<*#q r|h%x\N/bz|VViè- 5(n@ ^$k $ub wkd߁zf0]1>F)\d7KheRUr:[Dx%2Q5I%euaYI+tJ^%(G-il \~NSyU0.FyaM𔋵dCPq d&؜L,QdJ)BJ)dB֋$SC wNyߧ6Ʈ6/> qJhMIlm"Y+q &WQ%+ŕm Tbs@@ӞEoܭ-~b0䤶2'rą >UepKyBBc^3XVVIqUz1 >7O;AtzB;~ICțF-LZ,8GK(^4#J]cz9@YA}O_\;nzGPLh%%lƲ.I*\Y(ؼX%mK$ik ^-!Bs@i ?lu?ov9цwD%HS2{31| n)c!5*!/Q)Hj&I A |sPsp3F>M/Gl|tĺκ>mw3ȭUNӑ98żbt,Bw2IjVs:L&9Z&9&^ MaݕɤvOeq'Ey+_hbh'GDzCȺB(kAzE*f5Ό0"4ӌ)ftPnjXo]+o?سB쨅手e36M$Po(u v02`Ry=0^G/z*TN k㷩a#3 sr%ۿ Ve ˴?si1ߓAԇaqIw3SY*v5(Y51讆to40xQ9rl|Wӆus^Y~mKw|NQ^#Bqsғi1s̈9Zn0/GϷ`{|{cn[:6-2vk-oVZm-FC q4Fcqƴ(c j&Rߕ}L{#}9,Wϼ3 , S!VCfi}ؼþMGNK?z8O.{—`bc?[BD/b>bSPo93){J<#}Yw:W@F4 WAZY۾[hΪ8,v ]#xA7̀}@a zZ`C? O-"ܖ#>65ڷ;2"{+vM%\ -ypI^vq2_gQMg9=ǥ=Gg>(*(Ȏ;Hd%| ,심@EERVOU0l*wo{_;Ci zCg н|_H)Om;ݠ0ʃ]ʬ_Y4("65p`63q' ܭc~3!>G P~؎wr+ ..:rN@uᎅEc *lظ zHMQ xzAԾDkW pN8t8@`s$@fka;PYln "b HQƺoc.᮳cً9 ܹ11?` v뀍5}wG!Bj/YD}鈿S +5wqY.棇xcy/q14o(v7kHx AAn8x|A e=1ı.${5pנq &+0ȋ9 55l eԄJtJ{UK?Mj>"k>G>EOsE7ڙ+2k1`0)쉑KxP{ ]D#؄t J2:xՙ&V"_8Cj71RuӲ 6YPsMҹ>jY,BOz;[Rd:MRhg75V]={__Зsbc kAENBv?k|?0j78H89PE -aoPoꤜYB#k 5*a\pP&k, E|>O<3KbXC㟡m+y~oߛ`b<&Uȥ\59颦lY€VɋTg*uũ 6cdJ3Ft@6cv`^GKq;}^] h;c;H N]/eS  VUfRe $7eMZYWF0W-3|@oΗ l1a ؜um%]V;B=vB\pW-%\gKERSy*ʐU(E_0}&79 @͟ S߮\tncuO:>hp{+!Z#9RM2Ǫ* KH)T*mN6M2յ4\DgB9_2?B p%MumwuL@#pBA^ ST::8iQimlY"YY9}^Pd9(R6 D)LI3 %8)|'r2$E9)yW ro?(}Sӑ) ֩ COǥ]%c7M5Y,iY!iFy-_RM-ϻR?{9,Rl|RRF$5tYqE7 )ɏ<ޑ)  Y4PSF5;/xWg-^f72.ԊU!AyW2*R/}8Bfzc%9gʥAgjĥ:NwJCrgECzu6Wzsmsw~a5eJmN qȈԪkRbWH:&*_V/+w_rDgfIkU[4Pe1vGO}MO@ٛK_omϕY' YwFHNM?x=G_sb:Uݔɬyɮ|ɭRAb/+զtU|J WmR}mNW)6'|cDŽ6%ňw3\Heܩ%w_J{1 GV(d2*uTnVyxիE5.vmyN5ҏ.b< >oDrZc}[-U$rD$j {.TB2/^#.SjПS3gi{ݒ>'Oqb_B]\~gݑ&ft{w t\ ꨎltz9)z68D WoZ?u#ꇗT ,iCzҏNF<,iQL?ЛO`S,W}ueyUL+vS;3$~S' j#*eߩ]o^T,7Y+O;'=#e4@ӑ/rdbO,B&xȏYhuX#wvݗ C3깢L!rL:{NFN&&%ST˴}P<4Mt /fVwWkS%*4ҩǡ; Ra:6p`F~ 0cFnuF##G! E$Ks@9]0D Te8v,`X` N70I>~ r>ę["fȱ2E>ރwf6uw r3W)˕ 0b WS $x9[LkpXBA{c7$;C#@!MO/ X/AbAh)c52 E0"Z+l xj=ir$5w« /Urc3\嬃hD1w!av%8?)b|Jؠs~S6$ o=OQ3MAdpm:f2ɷ@Hq$KˡS YeLT~Sz7I}t _(Âh#t! NuM5exuH،x1bCp = Ȣ{v)Ki5)Zޤw=@0A}N7PF,`Ȅݾr<`&OlX+m$9CiFg#Zd= ̠W5o*oQ+~(F{.0F0Lw$sD% lggEw:v/@2ڿ.bϰ=l.R-:{RUp#V$BB Y$9Y$0Baod(PW+^!,E^y>9/yw}qzP!qO( CT=gd W o#oŸ_F M"#Q/IѯȷP(7b5. 0w~B~`9PXT?9; @X\V?, !tǻ4̡Y%ԴjH#uz:~CCoX}:No\{5MU?ͯO+r3nwfB` 9HY}LpuD(09ZMF5M.t+y&A ?,'L2򤨈2% `uM%;Ěsy~QC| %'bzjb72zjRXMI\I-)'Kb mB\@ḨOH8Ww~rCsk 3s63Q64r6[!¶K&~˙F"D]?L 49.5%Y =7pH`1],Y1W|rTMOweC/0m|L"H Qo\JhKٍU}_6HϵIӹ{n OO?|{e/ʏU{Pu''L٠KT2^fq OhgK ^\RQ?& lLjwxѬw݂{"YMв֞\;Tw}˄ nʦD֤ctB5YN7)S92 C'NEEC,PGI1YR PJ[rY¹}'}K5Uv Y/Ηg1c|I'SCR(NYd*R!Z2_ɞ*!hTAc2px3H]}=@]_Y0^}gwt# cOU EttAVJNSrY&U+UJJE1HaU@5ikwxN|ҹk5zC'KԘ<^-j3$/K5u&-Qp5 J暒Qr4rn,Am@7dK[>Tluٰ}së otxՕ`ߦ*P'B2p5 (\R' G&w5\gZ׻^<|}WwVPr9꘩{.+a%R!(Pq9g83mRa. $rt >SWV:rk>WX}rKEGK 2؀9ZG@$Ub\TDc+شB-h.YK}6(E[%XӸ$.wBly; OU+ڼGr꽳ݳҚ7y(n)(A=Ǯ52:ZVf$+̂J]#EOP)=@/q֯/qxpoӡrΟ}=K+3FNȺ :VMi ӒLC5vDS7<]~QmP.rF/Pm`C߽yݏ:6Žў%GVg  uDЏ fB)7^^Lu)6Z2>u䝆c Ъh](VED$ *d/FI  Œb#ngT-.uGܷ0n B39+r?%RC]9˻RzU.y;w;l`Wqy-g?cS_iy=*| BKZJO6>b)MSXT*4VUj^cu:ZvctWn`>ӳ~˴[9N;W/9'%j:f8#mϲLviTv:^֚ۖǔ.[Wd1uV#eߴj%?Pbv$k4mv!&2yҶ]7tG۝8 /t)]8IWN0׵^bvWrRsLyc?=*˷ /m $KQ TL eP`F80+c_ĴŦXJU$& U% J>=r25j"#C##KnD]=q=ɑgDGw>ѝW!p|!ݲ7=^Jp|Rq^>(9!Q( HaY1!;BG.;QȞX?2n )~c3:Q/H&à r"d(|!/1B?T`GMG b ֶj+}<Aw#` 'p3nI`ǃѴ(ȦG@=# :d Ry=[9}Ʀ߷ V|aStD}Hp GP''C>i>ԓ}<9S|P6%_z=P5uv1 ġP/r. ܙIH@Z^(%Q| DJ/&8X`a:$I!a xa;{K!Ȉra93aӡ@ eqqu1Syn-\Hnlf裆XT?go"aHi9C crY3aaH @FVҖECm<$ 1n&x k&i}V3 #~{Pi کaa5, >.A C+Ĺ!<20DC:oe@Xu QS|pS\(nD{;rPo,'!6@f A c8Lש( _6 hLj] 䛙āh'#NwY3a)X<,a&Fc42Q)mkD,Bg_ ܒZTO.P&6+%_e- / _'E}4pR4Bo`,L\jV[x~IvX%=!+9x-7+__)[T-=YsSn\V/*G5f. 9sPl8PY^X#*EP.r`i^|onI)k-筮EҗvElSecM셦Y֓~G>A^W֯;8"߇UcPwGs-}5bc)pڳS2$kw[4UՇ5wtO7T]Kzuᔦp?VM63rz\?Y Brs9z!p2;ik#|r[a[!g=,Ʈlׂw1XWef ƫVD)tL^Nn?Γ8rFJF7qxg3Pr|UO3& S5`їƽ}/0~_5t<᳷9h[C䙆xO$_TN r0󖻍\g'9YߎAZ-՘MOd%LM59U}v!5J@XĖ1fGyPdвp.O80v9f< smOvcb8fZp(%-$T,,5K34HDuQP"KٗdZN<9\vupdi}{>Q `>7ZNHM$RCÆGda+2ZB'pĂp2SHr] j yhC_K^hyb5b=lО# pQ,[8XG*cE_ODNCVNš)i8GU;ۈ&_HfPHZ!I!q"EmB"~>"pg#!(Ohg3aNQ4NB8kC{-!v,t5J d @T~|p7c1?#HKAo*V"t@' @ P{}dY7` b u: Z34b(@,i!֡%`D(0~N} G69?CL  [(B[= q.Ш=4Bqq%xg`]y=;{5x5,k 2)Blp'0\Wx@c2;U ._ QM;#tp[\6scc~pG{ÜʘT e0} 5alZ(~'gYb.cny8=לOO11v {*D̿D[!އ-L쑾h hśH 1%:K谺8|H!rP6 ca=,(^%~wBx/[bE܋=!9a grܑN6C=ڵQTUnE/?%'bW/wsᓸےRȬCAIɃL;8bXɜ!|n>sZzs~Ē7 ѯ4[؝>sQSYr_?ߓߑǷKWҋayu!CNF ;ڢ0xǡؐ|Ѹ#i{KcmJqkjobMZ:Oo tgw%;y}w,p>zݭB/M6小\!8D߲^7ZՐUPq̸%5:=iszGRUgcefobEf b,g":z_Jמ 獡#NvF:unrsԱLvSQpxWZy}&6K&w*簩2yCgu9Irr{A"rYLtފ#oserɁ`{&^ɛu6LfJSdSy:qMP\Tee`KBE~Cb2isjrqؤϷ&,%!T ; (]@{:!PRB( R'DD H*" qwPagȇ99ߒs9I$(BVK S%> ~"^=7y^as`&ETSYAʨcGq'y3좂s‚nÔ/.w-XOlDde1%PD _*s:bhqИvN~Vqt`~xv>ǵ6Ç_TSq4Һ"މDnW49z)p}8EGדrlD@`VFExߡ³JdT=bH2`#7>"ak{?~л>;0y&6)!3)l09l:`9e̒ (FPyyX햅#`\/X˜pQ<cr9Ut(PZ=/2*PmC|zu;+lrJ'&I̩ZgTn$VlDt_$ X' ڤEmۓJper7ujRzdYgg穾P3Qֵ]SNA&&t.C#I.^hz-;XO#v>c>N6nkRlrk}xg.+98=7Q; pa``4ݣARP.F}CycJO$ ]ㅾjQPpav:MaC/ao,lfʹ%?wHo, ןDY\$o4(^U5"kUfJglYsVXV^ R x_md-;]:fֳ{l`^`h>jd~rgc" t^hXx@@!`CӘJ*䣃t'w9O~[=>*~fnsK;jZ|[=8t#42B/kd@su:pPQD-JSь6t7t䌞[_Ce!S "gf(`*`Tݍ=.ne4.OH"Q(D'P\ЈhCFG t}JaFK!k.:7ict5A=Ș0EƬ_lWXi?M12qJ$ވ:&$*eQyPEY+:긺 (# ~| G E 3N:8ͺ;8Oz@5!8&cǴ |5;Gk :{nq#x9g 8fӸ/<.ou[@1?s!p@3 if o^9-j y;Rf5@nrv' tR/2}e_^S\?zqfLxÞ7$>hp ANAF\2r6hjіI,[t;RZq3~.Ӿg\^3E&$ߑN_%| , @`iRkCٽV@8y5l 9H:ff (wĬMқ\?'?z u:Lw~v{ S?xJ;oe;5CB"/oSlKlYk3)Nd;9ut3{ܟ1N|ʸI/WIs >@e@>AngkJXO]%i2Bӟ֯eǤ鎣2Մ!n 1!ktkk:K7J?(}\[0G}Eb=l AdHQ@[!Mڮ{W{zn4yX)(6~;aj<ⵠ*+6EI>9?nj3qf K10$H 0<_^ ꝉh4 ]\ܒ\w,_!5{omwrqqQ{/3=.iH}!徽jϾ&)id`Oˬc6'vMUE]sz=H٤[ ע/Kj{FܕXRgkܴ?ZWLdUE7pQ=’_DőEQoQ3C:~AW= 1%ޙhFIiV V\-[SOxgWVS{zTg*|$1ZpqXqU_-khbOc/scs^r⦅sx!!n꽫QZM}y6Tvnj Ҁ' ;#=T>)2U>(I*ي.Q$]qWVS4)u߀`_vP@cMjM給`:IkOk[ lZ ϗΉ#j3I%iCibVvr/]$8)NIC5Cǝ/: ;/1n&K `ŏX4jFtM@- aPBzVYaLYㅘk|kObX3ٱ~&6r6ȻOOG6ɠDW9i"ӽQEhƜ ,0b*e9,'aՖS3c3{DQ4H0)ځPqE! <Q=0i` 4LOt=.a.ʰ"aDCE4TQDU8 cPf([ .Rn(ASxX9xG r09ACڗZ1Jj ֨IGբ8hJ*\'8(>M\'ot b`8dLT;YR6*q~uF.J=QrNި?(KGyR$%zQQţGC1 0Vg်Qf@e;b/CxbQި$D*,,  ]彂w9zЧ[0OE-z c LZ` c16\0j #ڭaMzo0|?@uDЧj*[>*/x}P~|ݣ|ݥBY0< }c% \*fS1wM\H tdrtqƽ7jCd n]7{G}^kNtiD/5D/4Dj=|f~Rc5uԙqIDQ⊈ȾCHrsH }; #xZʴiZԱuZ>sx9||񐊵n.5YMAJ"KA 5 *#pL6#-pͶz7ӦJWn]Rc&S٥";H+,%p jHVJbe)Qa^b(,D y)|Z)qn3כ X)a zmVoRG,K)kȫvٕɎ|3LV&V%XU?@Uw(1ſ!1Ő(ZeW0Wi x6}=A{a.'M6eKȞ&!>6!$.ݙ[+tOfUUFW#ȑWy{R"wypьÝs8>Zﵡ7"fi-hgMoKKiIuHl7Iz7QCi n \+k{'B>p6?7{qevCd]@?ߓv> eЛbw8Gv廝xw{S;|)W[E?r/~V迒g9jfjk`s@=aSN3w1_3"ܑН]QM^i@AH ,!!   aȢ ѶNjkkGfܵ"hE .qj3/s{{sfW/=4rl4:&eUԉU'br(PV_}P#>NW8,9u >K~i]ԅ܋/a坟ÝyDUD^Rj NOD{Z\oO#"V7ЊwXN)iQOͿjr˹jʺZ\25/$7'6}&o 7}״Gm:i=ic l:;wP^Ս Ϳ㌊|QMD[}fpNۊ<zǷ1tmk|cm_blԶݜǸv ?6OvwP;;ye*pALdRԩ3vΰOJuvuO*vt/v^^ٳK޳[s.=͐^cHzak=U>GhùwK[w@9(+JcԾ"_L+)qZ;@U=h̦E;ȇ#J$ëpKi נZV7n7ˁp;8]~QBi8 c>H7'""zBJ*'T"}kC]dR!EBXd/48pܑ~p֑ ͎,xx5quoC('u"4c )d $L.9t?$\0Q ‚̷C|n Pݠ}f>g#Ѕf!8w W(|!g5q ̤+$a.9N )Br=H$$(H-@TPiwgpZwl!_t1 b v{ cbh01dU!$Ą Va8*Ĥ@= >re(>/}K _2AR]`O!tZ WR`HR~E$bP ev0CKq'@7' - r\>&@~ aأ+{X>߀8rɀ7(qVH pIj*$&9f̙!vh7z+bMDbGd*FU'9oTת+-Πӧ<S@?IH䓐0)IO0M_=_3[|5略3h5gx/4x57xk}10=c ֟.)~ HEnZ{4:ML5y$҇V'c0l{nj]^An}SwQDMЮ$M|[:A8n@,ҘHB>#/~|qĒ2U<}̷;u 3+ޣ&Op/Bh3Pxtp_t=ᙨ*рK_걺I& (NBQ(e(:\ Ź77ǽ#g={ U[Zm7SH!zʿE-!ƚ+ƛ9ji&"N}} {o7sY Rʳj)s\ΞMoBVkNŲZД!cR֐ȧ̻$VqSmDcYi@~<4VJ' s<0,bK%!dW"fŹbR~]ʀs> *SINf패';Q̨<Ѡs,AeԽ"xBBZuh)MְBXRȶ[ȯ)\.<9q]QMi$((H*"@V,f5@ !LK@(h5x92NGǶsȇ߹~z}c)̓*u96Ϝ e^*3WuZM?YP2r}mob ZfkVPa~RM|%Qz|Ǹ$~(ŵO%n %ZnUSOPj8=G`ߡ_ҥhܟ)<fA%z)U#%ܫefeE䶉ò3. ҼMBZ P+ڰ¦9$P%+2-%&DqlZ`ߗ+ ks9l3k2"Z*?﯊"you@+a{6 }jKKKbA*huE!j iTo5&#YP>e~L`C&ZSXQr5\k޸qM>ʮSkMmeCJ)׻_V& *W"5QXN< @>Bsoh\!B-"y3$0T`½z5:<̶ɖøPFm[ÉZUGJ>EMʪ|oHY8T*Wy-$W6Ec-sFF*"odRJ,48X`f:` -ؼbt̡Bpק{+y~š@~,6<_ɮdUL2d tt[Z?tBɐ!䭐\oJީr1p {@0uָ]r]Ky뀨1dzX]ksTu BV&*)LU*CqP|Ce¬Aȿ!mpLp~Wy z{ô1){˻O9w&)HWԖu㕧4K3!i03"Y3JJTϑ+ r|ȭ^:OuW) {hshF}p|f+iZ@՜#H1\%wc홗.:3.f$p㦥NNzj5y˟>?}?Oq$6nfDgpG p =I=@+B;D7xxK>ؼ04+6g|`rŁfڵCk3eO=IW_zFοC#fwv~Qir os+ k cLV-&۞˲?f`;Dx; ejgA'зhv 7|fkg/] z ٿկ{x`),@ [ߙ@C 8`64f1ƳGm4c5ȵ4W+jv8N Z] _;{ z LpA8"4```"pP, RFC` l¥zb'&jA'^R 4TxPQ HG</chm6F&Vjr l&e #n#D eSNCCC@:*"=S,kP%;LQRBlt$js_%nsFΐـ޻9sG^xWSo-Tj}'润 MuyVMg/hF5DӠDdEa0$L!g*Si=j0DG3t9G.ߌzFZd-tm%mӅZ!?9rNGؠq;EQ=QGNZ (M4LfΙIJz{zX[3ح ټkqyVcW\YgCSǟ"8(s9~P~Tx>좸6xx!IM8JEo`iǒ7g`Yûl;x ʩg[at5#}!UgєPp6i 6-)>$VG7yTE_UF?UcP=LxI ds0<Z@{-ΑR.¸j8]ECF.-D ǣ_:N N&!Ƚ2~"RVws܏^ZqO%(ߓok"!dc@13E4wкXD]c[lظ ]lq|,úՙ3 \+ֹM.}7מEIRN+g^3?*I1ބS8Ä́!9&1<&_b7r2Wi1_ì͍dIUTfgT6k^QIɷ<^3{{j϶:-畅w_u+7nJG騘=C<R}ZVry^).jpdI*/Wy`vs-q-[ 5gdBV.YMY2O(g6yK.omZ>a"^.#NzK\ g8@U+beV%y:Ewn_Bu.Ϩ<PD H)#LQA,"tІFpF RD *1XQp]f%'nf=G}s=WR*x-^nAIܐ84wQSQQ;aQP_B61xCTT0^,p̕_-]Qךnܔm^`UfWH+v)OmRIޒ)ܤ޹oEDBLH$ oA26.98]pfnt.*[;hQ]&8+e6lDzBY[Q+HouSEg|2R>H{-H#BK&E20\ߖpQ )qXt)*+4W֕V"ҭ &ۖg:J $\IN^vNWFv -h[i Q^R"K0T꺭bQ#U+,-}).$)" &{d1pq5k7٨&+46r5 j:^q:(X̝),dEK9wkE5/snAph}OQQQF_,Õ2ڃJwfm4Յlț5{V5d7DbRd+>6)uSu墈&ކ.uCq~hН) Sxgz7.^܃ZZi>5Pt:2e^iRuI*Knm7rKs=M2 JnHC{p OpCpC:=zW? -4 ]@e*{磤ϖ)sg.VY97[pp֮(f):v!;ikw۪n{B.^R=lRMPzA]H-u̕IrbVύ>u4BcuGLBd.XPWvﰢqy7N}7{;s& 9:t}C@HĶQc$:2%@`u#BF6_s*ppqp5~'[-LjL.7h2h1=D[!b܍Y?.b/Qߪr#icؤ#7&s,17]Կ+_6dǁ\DU#c$&3+Y+&lU}'|2爦4SJM&-m):S]{ýqwx+}P2 d.W)6ncmm,m ib191 qBG|KV@E1aɂ:3jQ!9N,vP>'Sߨ־XԤN]O}&gI}D]\wa% R)i~=>BO͂82ٙ!.g.CX~خe6JlQ*\iTS@H.!y`b FdA@A 4 aJ"cD'( (
Pϱ+߱}`:{uI ,c`#ց]{I|OdE?Xc{< 8b¯37*535.ClU4-B 8۰::paQpڱX'v1e| 2F9#a[ lA{̷Sɥ,s0\; "_ h@ |9 fрW2:pb5 a|'&Gq b{̽D|^'Fa7BMhXt'=o) <_YؗڝW(5ܞdZnQcU!-[j!.z5{%-dp_jI:Pw1 d_hwWеL*D:臕fJ>Y)hץ(Sc +e&Ir2j}S_l_W- TC|)3I]':&ͺ(f^zLd/.XBVJ/)y+nd)˼hYh=w2٬ )vQ,yvi%)YaYYWCoadbτd`Π6AfҊ`u ؋M,hSbE nU/*H,X%%.ls>abAǐSN7=p w %!9kųx)-vbE8{`u,= *̃/ŖŔ fL7=[+"|WhW+BwK' ,:}mDss^R(shRX\)wPCTffU*'EL;mV1$bل-mWC_^!S~\[~ uI}q-v P߻`G)@N9@ΡKհSk km N3<:fjm0ormڹ55K֤Uk|YxWWENѨQ?Jw0%wrf@Rɼe%ǘג3Ly)P@w_wOqץu*N:ڕyw[6~_U%1/;{xb ؅>K= c.Ul&׮VsǭX[-uMu^uY~%U4uyqx"*ʡvlC5ިxxŃJs`Vm\clTf3iwNl׶ݴMnc3w<>]لmqN `TRiyFxs.q|r۵yyo띭}w8>9|nrolʖҵ-ˤe=UΧ䋜o)`"<#“QZ2\b$D+ mk ݾKvxr~Kqf(/]p6Q43` (; e /r*x> ].K< ^9e>gx:,fٌ M`tWDL+p`_+ǐ5|U"wxP w`EĄ+͸EQ"\!dAל8#P ܆Vk=!㼽ay4gTh֩ȑtG] ;z6& ,}sQD%IV%~pYJFii~Nu?V,'ZBsS` 9}yt{\T_b޼1zDw5Q]_Z|#x~sKn)$U9 48U*婄\C"⁒RX?"ZB =zOᨮFgyfG*˒V{3f{OBlMz 4eεFO >pZ`JUD/y:Ľr y̿_ # C{-4k-CF(^ԽfjppQ0f|7\^a3d{wUҕiM դ 0ь]}QNbWT.ŪUV^+1\"h:еg=Փp>j -b oЫ*CH׵Gh(MAcj1:QLtwxBOg tZf݈kVߙ^b]jP!SXIsGN/l7O3y|-0?a LYB6b>@p-3(.7RFvsնD7ó2?YWyĪw6vXhŽ]ٖ eɳјJgg]ȳfPQ%L^`}a`aQ PD0÷Q )Fal7Ls:q&3sޜ0e \[%%I8ù s>pٴi ]ʞQK @ ?IoUWp㠻6DC{=7ff:47BsP u~ڪ`v? lo>mnVGA '&:n1ߒBӡC U(| YO"$=3!Q2 @ׄBz=HfX0IF)_u@wPlP ( PC2hM? tB"A kSRsӚEs@a=2`8Ȩl3q}JCHb >$L$)^>8qZt^wK-uD'3Ÿ2q'vABpaRNH^ɛB~ CXHPCnnDOZu T 52^HF"$W셺=W3uЯGnj6{ΆF.f#W'~#{;֫e=֥?:CןdNvå_…VW.D.rah+i 8Jc=a} Xa2bak7lcwݿfwܳmЗH=_2p5YIr4'jqbjQ3o7>xDxG#G퍼y13K~{ԷofÆ_$TT.nTwR7\v43g7p$I42w7y Y<=Aߎ~m1[b~(h-hHh\`%li<?"ﻝAtWΰ)83Aq^aQYpZӀ4ۥWsN)LjK$4%Ygu 煵Dğ |1SPk_yd`ZT[0VFr2zeN K<׭EhJ3y5YxS}k]|tDP%VHEfuIcQؘo1}c%殺9Us0clƐfӧveٷ͙.J[}FG]z%WPt!A|BT*˗$S^X`EаtP7)r>0Oc m@o6Sm9`ߨIvV8ת\+Urg̬-l/VʣBYNxBKdń3_ ܣگ逋"`~ǸCBGGAӢñG%8XrӜ+wcNg3de7 }|aQ$G}%@~HJQbB'Ry"es8Ba+z|tٰ֠iEU9H.%:!_YW˫mks{H=%Qi/dj )Բb-in({HWFG'5ԗ25e;8a\sI}iqp)2t|b ~VA `T;!V.J亻r'?$ K߱!u="!{KsH_[p"$bP[*( b ݜB~xmuSv%2MYY^aS̃$0(8qKQ[Q&']%3ZZ:WtCY?֠ȺYwrpnvC}V}^8vw֕z&Vk}j15,(-aW¨/U V]uTz>+C4-(lA~*h7#};jdEqmim2Gi9%5\y볿_x,?:_/aa ճ`>GSʹ -]=m]]@^7^/dٿA0Xnb>/!W[cv 幷%ޮB:B:㦉fz~t.tV.=Q7![@$oGx(3͉OF"Ʋ9u5ctmim##?r>o<Y, Q}hPv ec@¤b=%F:ފ] gBgb=3) ΙmU?nqxkzq 7/ޜS'Xc@ v>ʵ sH:D&u9_[sc>oχ`|mq2oTh3q6٬܍~Ivl?ᮝn9~Wc2 Ng9ឋ@e.,x p iq6.a]xj_Ǻ%,e%V/YUKʥ#LyFr6#Y,/<爯1E#T{'trcIICm׀759`w ﮃ^ł_9}PV )x=χ_u1>FH}oM+@ lzx> չle D((`W% 1`A,H@Dņ(< C-O1D!ODQDĂg0;=;{9F 9PY0s C Yҿ#DHNb:D X ACFr<(g3J,Z=X=OZ8 `] h%+"6!j&;@:5ͣ1n@hm }k7jGK(]48Zw }0`W.e@~5Gn+jM :kRsG?:=@ON}DoG=b{}`]$7bu)bѽ5t?+f 5(F?C?b>`hUŗ`RwŮ`0o4H%$"H>)k xCxjth(m0k0QLɼLDcOWI%KrW߀|ExNyn@߅BV5ջ ]x)[,<:t6ᑬt7J+&BZ7pC]h2ehTsE9塡|(T7Tj :U3PŸ@7QMVen)wr{q]yMWՌCkp^øZsƝ{=fKm`f9/c)QDy P+Kz?'\z?#qnU듸c>;sC V}ҮBܾUXfLXD%L3lw`φ1H6G[g\qǜqy,wy"y_sW8-q;-v\#,s:Jvv:;9:wlqfLm|N:h{u A!8bnqm* [u_epKؐ**2,m7֛l1l5)0.7TJ6 W\:dk\^V2Yg`(vF#9. % }#cwJFscS[ŋ6-X f%YZ=_ڽXU9 ֥t'+mZ#PM88>(cEV~O8qT oDѺk6+Y"ʐ-ʑg{fzmS,maeye//L:}?>4sЬD}>͟Po ;`k@xry`A1Zѓ٣L2eit,ET-RBg}[~=h(1:ӑ (X2 d 3lj2|/N&I I?Wbиlqr1_5׸S3Xejf<.iSGCp" 80(ٯ[u^ȉ̘AȈҢ}QXqR9Ӥ'S E ]|j)ǻMk"-&1sT?pjPEq췍Ҽ3NZ,ҿqBj;(v<.@0wlpvL8!f)xy\ԨLȵ" uyGEuqwgfd`.誈i*e60 URUZb2XYK(nQ@M\)GO-hknQ999s{}b<31=uO\u]D1D[~:s[<ס='ˍykP0e P0I(HҜy2s&3.N#56CiuXShvNޠGGp>36o_kE QY|7jdYc?4bIQ4I\tl-4 6)1D")!ΐc/T+b۵ \z/NFŋ~>\3T`'ٔuy%&G,5E^rR!+ea򗤚a6IѶE $}LR¤r'Vaܦ 7w 3wY`%Rf5Q|'&`_ԥ;I 2ۭ^8cGbt8Nşi kܭz a5_b[7 W`=.Z ׆]4T[]Mo:`+@. L p? f' iA̓0 8 ׃S {t{Ȁ>-fn)Eϖ:4@ro9tXr0y TO&`R3`Q19*hZ]nusp2Nm U{0C{2OAy vP7A%PJ^uqW}@w&cN7sG80u p>-*ka{l(H/xArA$upup}DwPA;6yDt3=S-iw8O.ձ]#Zr_`HD)PY^K:_KFn )kp9}5O= G; pKŦ@ ؋+p By:xyDM?} :[KWO21 F.~EG+#ɗK q_po-~#nT]:˪^nb 8K!N>C<O}'iǠd[[k;ϯEf\ wNrgũ!p/394L`""}*/@%Spk6\KÍ8†NQp: jp2`9Nű yy9t>`:G}vm(/cH?5'Ip?P;2z4.c: 'i8ڍVW0.bfzWt[=h/ n{h˸_E zyɓTb5 O7?OEOHhq`t Dg)`Cʘ!]Zv{*vkphsѦŭ!CΉׇ7OZ4gI{Y*w}? A/zPg&2S:Qh MP3}:5<@SnT6hZ4uuqUҠ%YVkNq+5WSHOQZ*HyYITꩤމ&8biޡ'H}1 "'b{d86Gji`6D3-vv]m / %^^%^DR[- & +[v\^'_H {BWG7&3ҿ| )-F{lM16ͱhIbƤ2l}C<@,L\5G$jW3NpZzfyմyQG}*.*D=P̎NQ-xyO |JOi:D'mxZѬSQ7uOg x $>[TujZ*W+F^kSRΌUighUjP yj9n/L]ns!I!X2)!K CmJ,S,HNe'e%9ĕىeܬJiybLR^[(TĻ|~$A& \9 4{IF ǪXnZ㻨1b12h![\npI%\BZ_ +/7+)ەN}?&zAI(^s?dN~7_mSJ<ñ TMAeZ$Sa2s [jfKS\q7K]*Ӵ'Ԥ1n},)F??2 J/W袙h!kS.s(N9]Q;yIq#IlĦ3Ein8U(1} $pGn?cUk(b,% J,v-I.. Eu݊#ʘOʘA'GHotE,9g0@X3}9ݓT84ɬOOZqIP/y_,*ʷ8o{PzN-gߑn1>c ӧ#% iJ-,KRĦwIp^4;D!:gk{Re܋$$ӻ0 Lg6)C8cl7FgaTV?x B\,Il|ଥҨղYeY&rM"<'"*WB[+XIYIoR٢M^s=\wD\C5`0D"83ƹBqfL7JHCvKCviH#iȆe!Ԧ.e.I ^ ̦{~F`8[֘B99c@"u(AxI$ %_2JF_Tf!شzS۴Ne*Kv PrQ? _H \ la d5i!݉tOR r+ZJWޕeE9X 0e,sòb 3КB[m(xuQ!b#IY}XLa[8 l5N /xF6#n7LŖ-lڶ* joAe}u͏Dt##s*g16Jҿ< pnPBUNP6t>2 kgBCfQttR@Z| 01O'06 z? 'Q@86!=Õx-~ 0h兠V>xڸ\[ 9/G0+"<5`#Ha 8iAu#y㼖➼ ŜG;/"WX_B_-'{9ȍN2I{F(;޾^S@y\|N u'^5Mw6'݁t$jV; . ={\\ ry =f 0^-z~I8m|E&w͜>ɤDtح;DM"P2$ydIOK exJVғ;؀DW!-tUU񭸆2Gq?"G@ο\!/"o™89iͦ=zГQ3pkMrpqUFjFgъSm$3‘O"%Cpb.đh8!x ܒNuY"o$[ TY:Sf*/G|6Eр&E :=؝~@JDd j|<\5x]7\uK18 Ψ)ؐ<޷=||E>86pcQgvǡJ? `>e TNXI(ћ`Pl67HCNI6ܒCHrCEIίx̲\fimc?p}a2lEa$&4lLY(6COBao/}\)A55J .{]8..]n r[ۊ(%*XԱmSӦi3δv!mӴt2M3Mil/2f9ߞ>f&mJ`OfO-'_Ʌɍˍ "ܱj}6p/{Vp\qz܊5)hF+" ӚjLjIMs"fÙ!v43gNsCܠ"\4wYSe}~@DPCܦ+;t/m"hUc*7'sMέdFNfXa젶 i{~0ק=hgnK.UEg_  yyBrpeCmnNSgcDǐނA}ۘ~#ۥ悺\noӝQ ~+BY_٤+Ш>BIN1@QD,60aL@ش æ4g"dGٌt*tUVG5U~B$Zʜ5!M^Z{&Mpݵ6W&dw*&]g] ] +"\F5uWep2CiJi zE{RpqbS#uEuSnCw}jwςb_c٘B5Y3xwZ. וywy_sjJ`&FOy]7Gif-PO՟ f"1j=d\?_T䴼n"[n~i~-J#0GLQ;;ZPŽ0wn)j2@eE~W9tYV2s܁wyu65WGyu7HJxZ)st~P@1лoA^nhRqp@P>CfdJ U!#:¤zq65qMMKr)=kJu.ӞF D5-ʞ[ d!st#2ƶc8ia=R|+,a_؉pH0ç] M&)|II74eָLZqhcq=dLO ej=N'$$O`fbI"qH+FB3sH\3oFH28O1p#Mύ!Z-v87 dRLL=e\,'`f< )H\8شhlX|s/#~qxqo n=<9) Ch_$uh ПfIH^6]p) D"\ށX"vŌu+XEJʕA|-p~I|gėPG@pS%gi9i%ҿL/וP1M}SQQB_CRJSNhHER !|dB29>eȲZfǚ550żmw\]繟њ@ҚCeSeSE؈xxu`E D994|Cݬ`@c\ 0^_o !(`$' NRp>ٜ69mV<Z[9ɭ u;yr)ɘ+ƫf0jRӢ P676 }@R;Nl_lL:X;:8 u'F7yۀ܋ouQ`= PSy *_6XAEl<sDw' L7;0x0ZSלV/R"ȭN'w97?=G3sǼBOٌ<݋*%_꺑aуBFqd$$o+%9V)6 *5 Gp-'.o Y]> C+"/NyYG(2ꬢ:lΧq&9[<`_Gz)s 1'#`uQ/Z)ʤN`uSJY1ks4r.f~/Ȣ!ӝ7:WGP3ٌV"uC.b,lN%3_gpqoZ躙Y<8I + ᧾dJQϟ0ԊhI>K\P\͜E140M\ts :k42JC斒e.F` J.$A"gѦV84%^:e^.R/NZ*4؉zzu uawc3vE= 2,wwȍ>6^X㴱;MI(M"pX2 ʏqPze6>WNbOr۱t?63/QmvU揱-xN,+? bIaJ.l?=*q|]?o쵘ݖѨ)nyfQ%*W`U56YQ1 ^GXmnzxj3%Gyg{GFhh:!m3;m}PcjQevlMv`7v9Xgka}9VoE{X^nA+'C %bY(zԿ=}bi}z5 56t38zc?*ð1ӱ#9JYXR<,u*BSPۓ C%(Cg( <'bg$LCRimE/R. aǜ|+W㬰@=ծL?2ԡHWO,TcNiB:[H+~vHZENһ\b͡\l{Jza|7[ +Θ_!90IB|B\`/.> \/E(TL : <&JAigV29Rz)d>rIv!cv RC⎙!c1# !a ؐHĄ ѡIBTh0=4W -mSL '-kzaRYA#[]dK3f H0$h\FFhM04ᘮiBdx0%|09_#M273(Bj+7& "0#2L*8y2T2E$\Ct2_ۮfFmo^R=|yߔ-}ԋRR-)>Ϝ"3*{$efEim4%wW^zQM ʶ&fPndXVL#1Z[W,2Z2cI>&6j!<@ۖh!Y>q4M`,e,x 9*#fT{RclO8MIE,%eF+amڭku!u j[5Vm8պʭVU_Z-mߺC[[7䷦A-Vsy\۾HJ1eRl4^kЯBnfs nԢ:D~aj^)K`eổf+]M"ˁ샓4(-wZ^;ir) 㞓nwF[Zi&sMk.:⽏B8jijpkxju-HN~spRb]05g9#э܆AV xE{M\0pvƎ4Gh 1.::6zIù:bQG, r/ֱ>[#>AVG%h8ٜh[mӝihml҉GccPϡ_ONIt=.9_9%tzuR glf13] &;Jw>%}iBPWf2PWIU̫8rf`Db405nt;xZj~yl ҧp>HKo[ȝkrf>7vߐ@a5쇃L  B,$B&'fѿHi5\Buz}M=żtC:~5V)@C(M44sћ 4棱XׯuSDg-:XsE]>c}X+]`F>/jh   M@'_\h(Ac{)ezO=lK59cnE鄩zj>^TPnBHȅ@B.@!B- !"BAQDTRuκεgzvvnݥ]9o|=y2|k1;nmmW׆p%f.Ōb^pe^wqbpGX}qQ,MB!R}[;q+67Dĕ8.n0np̆vr|'p"~b!MX‘8p1Slӛ+,ejwѽW6\ڔsxiXJ$+܂d'wc.#2-޺[b_77 I}O0vG1QݟP{WH{1jm }=]8c,Pxub'k&j~GIF(}Ls1.è( Qv 0 .E!qbz]BgNtW2)ZXKM於C~ʚ%X$|@5敏)pS=e勔Ǡd#$Jr#K*C@ZԂvenx:) -f$sSk48?&"fE9OO5_{Hcq2Kc^2F9)_<Ay)(ѡ]QVE9*(Mp+Фtl ;|Us^lcQQfU=|ƌsdL3NY)GQF@:,xphRRW€ WzPW [jƙEsjN1Ǩ}|H@1jO'Pݛz49D&N7@9z_ЦG t|4j JQU*;,:7:?L>fԏ /1*Go6Gg!=GrO4\Q|̒մoѽ =?eiѬ_> Tx `5\@mj#5uiBuc:NVefZq1^Tr*L#NKT֬`o+&&uh<͔zSy(CC xIe_3LVe(%dtSV@uʹ[hUIѶ X6# D(ЯvB / ?02xmY/sd?q5iݧg&#?E^`!! rB*d lnFf2SH/!-|H }z+NRi2Bz'6@m W7Dd;灼i06@0{]1K%5edX+aXo/m` ƣP;612@:Xvp {KO"ޣbrϯ.˥*4q~d%dԳճ|$$;G؍=g~Iރs{Ecpnk*>'͓|]%5!qw4V BB%}lN:PBp$aIvg9s~DD$<Ɂ' =Q%_BfjDd{=YpN')|FLN3,19%]`aB.(}INPWʤ8xd8Du:1>J;돟Ч[o pp p&t;1({@}>np/p_b?'v5Q5M+[4[Rjcr}Ǯ{GﱷEsS{^ =9כ `.AFhC+qrCH_i!eWk2[EB ;ɳtUvZ+~~vuGMD]쟀F3A#bÎZ̙m e|??[#(FXI 5hHKS?(4HИ9hb4qR<_Zق5b.@dP+^?jFؤsbguC |h4)ڏ$/{;vk.rrkmOqR-Yۤk#ވ ?;@_e.hza}D Bc>رlÎر;ꔏ<-zUv5ZVJ*T)W x+>hv@Iy _bh4ICgV)B^fUllF-n~TTj{OĎ\봷hh{NC-U'5vk}?UV0īܐe-5LbCfaJh*w\*v:"*p~9.ϔbUnܜoGEچ{hsVDh_wTb-pMD(9IQ&1S9DqpŚjdt/ a44ztc-Mh`yн\g̣:0+*"EPEaeXT7( .ǚb&Zq_c5֥1xXҨZMD? \{0t^>|߂3s1TG9y%41W1~PŌV1V$ٍ6es[2͔-WJ3-WAMJ`?fr1 6 k`T78bEqgO9+h`U9Kq&(%a,pFIJHc0'+ ?:cx#%S3=|K!1'tTN쉽/[P%5)J)Iq$[d-`.s\ŧ<+SJM2ZbSI Qg[)#Si)ZdQ5DJH5ʜ4LiK+Rm9-QtzƦoԘТKj0;1Ue v~ۘ7m]Č~2g V|F2-cY1YVEg56@cehKoPxve G r+^eti)̇ ߶LUvyWMP*սj4ʳrUS99~nբf@ pWKXN/`^ @8)a3/ffl^˹-~**uv4{Wnuٕ-thFt2K` Py;Nn{7M.v77\ĵ7TN(WRjgpG˽_&h'?mM^!A918P~!0qGBAlp.|7ݾ\Gx`K:9:A$'1 G f:GMhI ކ* ]{.CvyH8ZZg8 U|J}'|/Fk~Eo#v{n;tk`3?M—Nñ=]|m--M< W8/t?úB9sIm|y=C魇 ЏF{ok:KkOB<u:=K[Dp\џDlAOЕp@F=+1ɤI *!q|@#q8մNjB)odJOXWGta(V2:h䳣:FGqK]k!*WmWxvjgvBm1<{/H.ΐ}"1++YO䱜LYvNATúuLM&آMG2ӤO<JpW0`6``6` $&!IsM4I&kf]zd=Uuӎv6դQҺN:mkUv޷dz{>I}R/xW%^սŋ7Zʥc:\G&dQqXtS gb"㙏5;e2|+ O =.V%?{ewV,Y ,de#l33a*pN79nek4y g((FsP;."7)R.JŎ].%Yˏg m K(dXѢV 2X4Lq턶GIPݦ2=Ke6ҿ7Q׾H_Ny5K/Ib$SCrM6MNJ)&X:@w8]eos[<7C_kҝ6GYyҾLh_Fͱ 3k6Tmqeioi⧣"D{(Uh:D,xlO}fۯ_\DVyFWf/k\2,'XL5v IM[aS4,d +48/QxKEDd'{VwQi> fѩ6n5zqmIޚNuk>VֶJTzx#f(-Q[仗G~C(7_eJ"(YRZ X;TvPљN3eՔ1[(80EQ`#.x O~S U..HgI*1'k*j;ʃ(`KO>=&z(쭥z MIv Y =DFۤ~&~OF'dDwK렴ĔDPKINA? L!w("d U9@pA҆GI#ydGΈ$ ?KŻ$ }*wJkYEHM%ZcUVQ[cȘ06HD:)y$OyZ'$bcxMćOb_O7xG?#~<Ši1"ѡ5UIJQ٘U!}z I$m8Ms`/68e|/Hu^dD~@cL<0""2 * 5"(Ȧ(( (8* +˩₩1n&DQc%i[5ǦMjԨI44>99=Ǚg}T:++Hϖs''- ŏ;q?>Əq)S&ժt"_u~uyzYWz+TXGO~>/~طb-v7R(=zB>C,N)V|^)P+[]G9DFx!Ngu%yab Qh@#`52yi>ZUƏq@Vf*%cDuX;;M,$ǩW5Ġ1 㱟LVUG$oV*V[rcգ_Ks4g [{/^g A' hEc)hdc)E ZV,""[.v._iswr# kG>>wpelwUSVw JhYG%Vu.ZꚢZL-q"|Y܊TVjZ֤y-*s?RwTcxJ1lD%G(1,V aAي /иrF,؈lCuR#~=;iAo m 1 ǽl09C"J (EӸъQ)5UkTtFF[4b0dǼa1|`!vS\7ya&po K#.M ̣>0dQvMvD}}GEepcĠ`Ԉ\Ƹ5qiFkUظ/MjzbNs5MSTk7IOsf`f{{K9YeIPfRIIHJWzrҒR%SJMJM]j%7)MG`A,W}́z@y>9JTqd2јTI)& `ҧ)1ݮEXgޭX`>x7e8نV7m\30*ǔ.SR3(;[9%ʩVdLE(}t jK4l)w)We 7v2l {Emg6k|m~sn0(z8E BװH~Rr_&,K8p.+*.]tqíAaa= Gw1]_5 ͩPFs([\!k\>ZiJɩm*si-䱎jb`;6{[ Vf6SDEVFr{ 6xh$2.c}cc}ǹ}7TGKH1Ia1y5빑oz v^x2 3#jrK y36 Y+0;g6~K8N[ u?E\vih2@o!ނ18I59͌# </W/RK ե e_&*F;Djǒ7pjY`\ U \eN>aFї2gl MVżuؠu <=w'-]U'mu}r uvxa}k}Ӹ_C<ω <74}tWE/JD3|t*Ш-6KANw}eE|y\Y"qyW(29?9<{=;BDzQDJ^Gt<ΐ))y|X5<\i0w|G'X4HG# |4J=ͫ O[;i$Nb''sqbױsqiRM6Z:umU]K+T(L\Mh B6&B Ć m0ډ3??~:w}~{cc/V0]b -|Q_75O op}$1s4WG :kѡ\i5ϫ~j%?L FX0i*\ъvif/hGɋ*ɒ5Q&>d eEi׸?-Ye,-5jԪJ-ЬyC =ij׌!ƔiM5a<NjĮ1]ר鞒ה0F,Yڬ^FzЧ}c~,lZsLf1;5mnДEami21˘F-Jn\U c>nzRqU Zju~?>./8l>Xz{f,3qږ)Q)&iU֦-,xwnm~LъksxUa WާyEit-<3M2s36{f 5dw*nנݧG=9bVr\Ym(TO5wU:koÇgZI"r=I8ce^FːH_mUPY^g8[R&Au׎*X;EuTo͉[=?kh=Rioޡyd,}TckDָTߖۿG.Ljj7T2|[/iW?ճ@su~NB/ ]m|5j RC%w{jc@霑sE՝GT*^eٻހ_p7ά濏YjN~#?yJ ZUVnTTeOLCS-isCO,| $[[&[=>Vy54ИVA:R#Շ}…rn1*bQe\HnUCe٢CFS]C;'Ḵ{Mb?9WY73hzϣ3N Aۄ%n໣RU_*KT>`RـUA6 j`#e )>s2/]_SIǴ0:tf|0^ B-'F)ՃRuPAED6$dLeHKɘJ 'w([*H^T^r*7C%~(cFgJ D H~hObl3ɘ*QIʤ&*Lժ ըT@aتuL.EL%*Pl܎3% Ce{ˇvLJmϳ?ݿ}" JH%:bv̠RѰ$H@phĈ`ּ>5&ym xyX{g(b5 s/w)1WΣ0JWAJc6ԔG1 #uơK?C"<˚eße.o-q3<{>Mzmx_ShB?ʹ |5[By=g®r'oϳ.0gK2{9 2{2r{ 8|oaׄnZr1xvfK04&{CYi>>椏 ~q>J%?A۹B>zƸ%9j]cF2ur9ACa?/~곟B;i8'U9@mcAg|FW(ćW$ ^~Ea{3ظ!'}q=/XRl $Ip.G&& ҝjKt>oKOlH1ӝS{7$ۘ~S M̫2ґZv>Ϫ@VOS;tF=ğI |ݞpOѩye \0]׹ i"'kL>RXf)'Z:%t,ev+-H|';!.'v5LqTa'&3iB/mt9.hXIdn9L?Ev( ,r5^qOCr1/$v9u&q'-[|c!.yds.3: On1.̓ي U2E|$E/"|,||\q7˺LOgTT2CeO8[S6[.R^/i8:4D# <4(GJ31yJ}P\M曓Tp$:`v [6 jV^?!=8-:qHCh(fSwԫMԡAS4>. Y2a ݩЃj =!vA@{ql5[=0fO53\6;ܠICtgUaɚR{Xi Tkh79|uq 5D,P}JEnGBTaT,5VŶDٜ*e/Hy&)7U9]N%}Ik2*#\gsó֣T= W|$^h)Ub{ Fʳ'+מle'I.;FY)LTc|Pr:#x>3zhL9eHc_#yVR!: qq)ˑLS,yJO-QZZRL#}R\ z@IGeǕ6|W<h5 ћȅL|}^d+ W\QhŔX]tȑ_$4(c,J*t=TO\K%7MEF4 gR]AQg]wEЪ(* -, BmăD3iFUi;1&ͤNkNc̴L56i֣c,d?Y罾}FL+`WJQdv|dȕQ Jv\*C ~;+ιOcqX^8V±`>( *id_+;IFYIJdT'[y*u)ڋ'/ыp| <<_h&q;(@1τ;~$J ~dʼnJ**@0 :3"$ * !yURxP JlL_qÿ~Llu1JXbPt|R.Fz#ìCH Njų#aKgpK-/p PH9ĜE̓}O?/Q_µEgKO F+k+:w%KF.(\/Qu`;ϰ-DMT\~vPBsy&1O _?f4`9VAZM.?Ppxs{Ez3r [d!m\@̳p}jΫ)$C7XlaX?X6N`LM6s6U|RMySpw+TQ"͡|ի^3uK a·A? XWY q/O=r, w}qKCM~'q~g<>,O ڙzb/ku?#|agD:a/Caq0&Xku7F4(8!8G䠿&M sA ";`4"hu&x`x?NsfO8)w /:r΄;M6HhD9pɈH#88rpu\,b%% ~O y.!MwAQj@|ν:+OQ8|H❧I~E?"sphBp;C->Un3o>$}|QX5=:7j ~{=Hj=k? Ux3z]W]Rt+pk>\P\fFi3[GP'^uz|:z:~CE0-{/J'i : A ƸE+Zd$,%ض㷋\DKè!A6]Tyxscu9/pޏ#N[f|a Gb]m;V]a;l/nvS<7v#dr EA+|2;17bۊtf.v#ʎ^DZ=B]F yBz}d%,ã%2vb\lQ*'a{:sυ.#U{~=7QBy5df'ީ~.=$8#`; ۓ=beد~ ?:CZEKo rzSL9q,Ǭ`#vpFHo~:b&'2B". 8p@wtұkuԣj .3HxU32_ Vq G-*3VÑG&ȃceTY 1GT5Ii De=G(\jycm+U5qr ?'L84^zJKXk'/SIF-6X3k,!K.l-HWMbHQuOzU&.UUfRqJL/tBEp |'6\p-^~w[62UcJӔjTM3Te|S**7WUV㖫hjͳUk}Eso*!=pm`cmzk.|q⛃SbUeMRŢ MI*NS5[ֹ*ZS;IyW)7urR٩O+fL9p{HC U |w*_ԖTRST:A575Kslʳ*VDEʞT5#}2.5-cD55,! ¿4`$|e}oJx  b I5AI*;œVYKfnVbUQyUۺuն]ﶹ]n9 d'y^z|*|̍W%Yety-Y*R OGrjU(Ek &-V_vl4~PVg~”߬8Ki̥*PfYI(/TzT) jhQjE'Uo@ɾA%;Ċs2T\*>W?a;Rԃ|ǤJ pϊ|THپx&')ʨLiԪP*JnRbuDŚXFwlU|^U կ+|DUݬmo W TP *1Q <|.HF3ńٴF4P(NiO;JN3X3.kᡖ&lAĵ)0(41{$f[3K7E,^mfv)##ψvl/ dx:4z0^oQ&R1&J ȵ Ny=/亭Mԃ>!g}6blS|s>imd7yp.]6E,`c 5YQ>9fq/r9br9c/[yfg0% .mm,o:HCYk7f-Pl,`'&'ߡOQ!zt~"'(~sbϫ5*]Msv,!{_3hl<&Bh-TlDŽ0 ň2=r?F(8 a:tPuVr4%-|4.F&1BJg蓳q\E?OAr3!pFpvR#<+;<au:Qx\(.A]6}fJ#+{^8i=syS~}=*:+G /P]Wiԟ%.~J~B.i\:ops0^/c_>Q\f 4G5t̻jL?~ʹy -JCxЙOEh47jvP}hũ߄3,ji0)(' L5{ #u̼M`pEWhT՟W<~`;۹v0Ŵi%mx} %rǘ as9jj=7{L`e R5:%.Z;}Q`O#6Zm/u؞{݌VlEݥ Te е/iVқbX1\G.t욱k.l{]Z쇰V+#]Lb Y:1~6ktv 5bׄE g?RX a2)snM?ӳٮ:e05&9(Fd}{\,XH.&=Fڍc~t!셱ۦv,n/f:z43UaKH}$A+oX&fp:9/:jQ6LC8JdRruaĉc;ǗN8NvlDZs:M$m״ K֭bBJAVSV1Dm0؀A h*h6&.ZQPG'e=:3Hì1V*f젗 c%Xz>A4lsGX 㰔gKH ;;Ѩ$:u42to>.& zg=;6%ʯc³x/U|8fwcniL".|5ը\nsL]:Yuv0-WxZ(m٣fA,ǔVr vM{RaG^{jSWKVZliDҸZJJ[;lWʺPɲ1%(n۬mjS" ۧ:\G8N 6 CC7]'caVDY]-vJ~%uJأjw)UԱ@ 1E(llVG!~*h<G W^k[KBzNUy9-粼u7 ;\MZL3v@gi%r1O5m ջ+rW]'OWT]HU+ީJ.Uq}\Kryr{oj'荓@.pm4$x#FE[תסץjWU DJ[զ~UT㟐ۿA |EFpJ偋rPOtk#Z!kR]'D~vy*婩&.W0#gGڅ2j<4)Gh/òRYcm݆]h44O#"YePP\u9rWɨUy}4t'[d"kdlUidJ%#DN *\d ԿEسA,$!=P ˀ91B4B6Lֺ"og4t@ mM@mݍ>T𚱮ib8d6cLll&qc|-0'3/<~w4\|tzFaɪ{Yנ6t-#Hb3ı8VjXCc1dOT 3oce}~z.hE75L\=5-Ch,I5$so%{sIFMı817v0&;XTVfH3׆A!s++z ))"ö[/:@ndwt/ ıv?~ޗ}S) kyR{꣯s"!Rt{^sk^nh Ƃz8K!Lt?I!q8feep#TxplCN.a0UXR|e>oH])a0K$SgX'0ٟq%=y2ղ1@ۏk#VR+{ @^y3xޔT'Y{.o?$ %KE&<{ŋsgW ml}y`}ò{ސ͚:Lm`VKs%O,~ccl:W {ś4썓dŧpO/yC/s /d"oGG,~~ͤyIKLWW^/}_%Կ,jg'ހ Ufyw?6sZ) :2qӺ{Esxq~&̳gcۼ8m~v|;׉8iM鑶뵵)F=Cݠ$@cL ILHCC􏩈C$PP}~{<$% ݜ73 0(_fѯ=MgP^ O߰y ކ!$=~7V!Rd cse e:#h$>+xyK+Dgt*sB?Lm* у_u]S25t,v#Wȑq?>2S{R#aCdC/6k*< 3ϋJ\;-[Cw6Н@wY4:0 Gt7)T 2d V9-hm[=c0g!X=GG xl'[p3=ѲЄqQϰǦ![[-&v؉c'vة`;fL$GS\VY<:ށ(Na |ayjiȓ*ʝʕ3ݔ=$[愬epf(Hicc{SP2(:x$!(*n?/UK/w6$gGL*)r F%O9s* rg}-ckl@%!4 AhߌM-]N9K-uma*$MG+],ljj@iCePPo)CН$PnNS!6J@e4U6]?MS'hu>[w4qu:@zJʱ{-hAz<2Lrr®Y~ΚE~A!ah66@A<0ǀfq&m&А ឦ ]` ta/)q ĮQaE{HYaNaV6 3]Qg6{9d7l[ Pb F *e(P*SS J/Pʥj-2 ʴ:ڱj 3Hm-ɞt;oel?V~YpYKbr5 c̉ջc,NY{&Μ&38]p~ᣴX,k:gHL6}?ѯ' v ?mI[-~x;gr!q68wsΕΒmQQ]·˨#rs[ 7c?}&{vdVĻH"8sIKi&xA;.Gd##h^e~WN0?HH3(qe3~VpNEj'[ٜ;nG<$H9X< WU~H<^W^ef\. euqDINۿ^p᳹ϏU6K<`,D$+5>>ɿJKb&>f- | Ol.>IQAaM2z 2zQ{u΢k~8 p ޿z]uq-l$.%~u9Gem~~|?D~bz":'~BiUh ^VXe]SNڟ&hq48Zj%v؝lj~>^n.NC)u}v!~D_v<mv\pǝ;vd`IЈ"v;;eZu&v;#bl/"Vc(p< 4z"%kЙcp_/;muiG:ў؊ @ENA{;ӱ;arXeQÛ rW+b f8S a@䩾";=}ll>B~ *YoaT1v|*8=ط{Lcz\cQlz+۱ݍ>l`o6 ;s:>GNU QuCt~1lEоkپ Tc ~o~;@VdjYdg:YG-e:5c_ ;~σaWuMC,lr2ژT2c^y;u£)TE G7Y.wmkUh9WJ4fy$;B5ur%X| EΊ}ṗs&o/E̻,HK}ܥx#+iժDb񠂉jO˓˝lSMG;lqf܆i I|HbxSGdQh- ϻ|Iy"QX+3SD~ & y24Xr5 9gϢ)K{caq+X³Yφ$/"\Cedj(fsI>'ݲ=&=#U0?;ӼMvū_nF5#\O&~mXflؒ! ||e6;A+h9/)>O&d\25 r73D V:HJW xmǶAlcoC%K"K+>|pN+=`hiy׀)ޅ~F5}faX5 ZZ" nUƱ3h:Z+neJ;=HYB6BIH@P !Ѻ/NT;նK2x:ɇ0p=!?}f^LRpφ`@Vr@G Aw"0<A!\ŜԪX<71 1 '#hGw_C0" 5m ṫ` ",B",BPGbP !BpS/ t3Ϟߧ"$/0` %:BrXa`F6;XApٕVb\r>i:_PK -:G/Ґ9c+.q|h"|X ~5.5uбFl 0a|x=u04.zE4)x C$Hl- yױ;'jn i\ W8tl-бk؎nA pNlEMlaY6{ר` Y;y80_w97=Ecg@Ҁ= бQR$Ή {P1j` B΃Vݕ Yk`Õ(,7U U+'F|` ^EMB@n/+iQ'B/ paT/D;C!XB"0cr>Q88/l0݊M?xy~n07|cǎ0q)SMs^(d^^2l/WYn_zWl۾ܵ{"ވ־o|#G?>3L6ğ=w>1)BY"D-U5ڂ¢CiTSźƦffpvv]|nܼu}ŗ_o~OD%}y1<\'_ gK"0X8d$ D0QPp)#`@L6-F8n#mO@zH(=&c̾dݽz~x FEyy = % G X'$`(,K?W-=C o"[ ;=Qo;p0ȱ4Ï?!Idr -bXwAWM1 0 z޻}_>xo=z;xɓOkMuDT__ba~CٖsJ:CR Z G#e&\WfHKi h0a@À 4 w|kfdKeUh_ݯAųs94HASe *g)AxӀ n_ToO*HSoTb.W]ޠZA Р%4(ײ3n膆>nE$YL!`*_mԝ/QsР 4y"ySIfuaƹgc,i0,5pCu~S9Ѡriȇ۝+]xWY"Z:ӸdM3^Dv 97V0N6CC4N۝#>1tdBG*@C'ie$5hͥotРUrS!\ʖrz$N:Ҡ#{脆ƒn#Hi КʷkJ A˱)sNy6K"cwgI=q:E+6 Zg Uo-/4CTРa;rV(ՕБu9'_4qbf՚ *ʶ̅ڸ|5ǢT۳,8Ȅ#Eƾt^鎗{<6XjwУ-VZzQQYkF}QLVנϋIh4X$&}49߻w?cW{YE˫}?Q ˱lpWDL|rV\`ƉмVmӰi4l6 m{Pdžg0|ǐ0aV]ց灡F!ʺ[Kn۹l{`?)`oh@lǧ"sf\޼-RtɌ)Nm-në= 5e'#1=0htHh#EAg"F Vh•Ibm0;;6 7`2>A :SvIQĢU]1W B% OXoL[n` `Q/c×hޫF'Jcs_+!DtU3(˗vjYy`xN+1™-x[VJf AƻC),ȗfjkۭTkëK/ck$fLGz(6lj;^i<)7m}Uɰw>&t%4aS&Hsĉe!e;l[԰0ݸ/WioƮOW}/>{cI_ᜲks,p!m,g9@Ov.Rgu6A$Ⱥ[5X=ښWǖͯslwrl$&";$&,aqJ'=ʲ[_vwMæaӰi7X?ښc˖9_ 0tJddD'%x:,&rA>'>\0EEh`NӽGWpkz^`x +Wc"R,Bq&<$Lci7_uA[=kV};Ǘ/ b$fǢ* ˱\PW@i.wEfx΁HmjiHW#-]`0(̩ IHL` HwEĴϙqrxsvB@E͌:yn8~ ^I3mfځ6MmҔK IJ qCwI֣yGﻭѾobKl˖%[^ p(t4uU}\?ɩk3Xb?<1{B1 )ʠ)u e;5+jK״4Œ^S5x{z~q_=a8 ie/ŴxXj(Q@ӨʨVf =[rSPԤtuEhx{~ {/ͩ0/!=k[8P&ڪY V $7yMRULMogn`##4n%ubD@tPf*haTIȚ^ʸ,oe>OUq x -"8g3h.PԗMЬ] U,*WPW2M~K(d+\+x{ڍ^o_=NioYz!pg'ئb Z(e^ik{dEDUۆa}B{_k_ӜB3sޔJ(6y %<$iCPMAcqd"mnf:p~0HA\^0K Ì*QJ 82Eg`*)=P3؏6r[h/w`}o羣=\[u᣻nj:|ͶoZp7ȗ|ImKu:mlB%a50as5ޱwDGI^{Ivx\/$ٝh cD,IFIdB#mZ47"TՁ>m3V?1Yiޯ-:B}Ky/eN(^, юd,A#$9Z6mtoJZmio=aqS5ݾ|OӂSacO0.v8hx'#TQ*LIHLʆt ޜYޖ~0˪a аm=ć“!A)# xB1B 3QFg2R!@ R`, }owYr6[iì+auc71'R 9#lD}qNܱqZӝNUzuuk@zWEAAP I\Bx $F$@BȅpAEVԺ9;m-ʶ?*9M8bɢv:jh"(VV@ߠTei4EJtLpavwk}n䅜4~1+=n*(NU <L;sYINiBx6 _sZfFGܰZ)HB':!TUr_JDot$ H\$\VQ"Fa]|VaG ^j2#(Q6"*r*&!i"$]0 k A]0ݺ4!>DZр/rz[IV-9~`qL45z]ECmdULDD](ՀOICVt^DA$"C V[+{$SL:Q 1hG 5M|CF^kʇZx3UAPi/  n҄di=ۊ~i+zd%C6@>k\OX["d>Еq]iB6gx;iذ% gd9 $*MM//uxUakfR2ȕ\o`*X( 0,OƤAq.<1*; O[T{j8lQƒ .3&Ba:A8/ W=hS g4IC΢/}ڐ:=kJ]* *8l]Kh-nH6j &_ciS 3Ҁir`xaؚDy]Mݧ 1M&o Zr-s.j)kjTAAdGO۸7`pHGܤM$Հ!o?f*wm2~\?h2b۩z2lnʯK @1'TYY0FG)2UhӲ4`^2nK֬f{}Vm&pҁ-ZwZܥ5UUz(ԦMjJ m3GrA A%h4 `Z ЭlVy1>g~ |y؟~uG? ӷmHozޯ'|%:WS 8#^87Ѐ`SӏT]=r{L&u~C*gN{i%8 dp?3 x \aheeh jOy`~RMOU!KrUh>Du38lj,J0pzT~ޡ{&`jmյk˦t˸("R(HɼX&QoAqq˓2,ah6EeX=7eNަ ;63e0uOɧ4]jnH"QRĀvߌ帶la,/1 G#Œaܔ>ehi3~1k<ʞ1tȧiPQ'5D^LRMl)l8q(˛G| 0#xeX+)z 9Ys{xJ1?o'ud^H2kq2,9ʄq00|hahe>o옵fϘ+&;jpQNj$ %h>鵐ifqfb5\Éߊys&``1k{ڦ4vTᢁ/AɩYL"2B5=+ v:̂*;\q`r!=\= ycʚqOZO:ᢉ/),duPbM97Fz\Wjz{Be7&H΋ ( un̬uyP>8Z?]'[E(fjY1)QUoh"^jN^l^$oGs4o-Ҁ28>u9Ƚyhlu^sKO3;(jzIyD. As\5KT1E7w>u>3mu *].NQ!iWcZDX ޲=7B^UtpQ+.hD2-hM;[l'Apd:d;,{OHgpj]<5jT:hCjJ ]QI%d@ [[ߎA;.}߆w[|pRB\G;A-٤}SKUT*K0)!D=eRoh`2xo.cxk{wt#;]ds=c?bv> k6`B:EM{MDZ"VE Ӽgwo2oM{ ireӇŢ#3PS }fj;8wym>3tE`uÅzAQlnwG6{xϫkE7]HH ~5_8ɯs뜀gq+>~?>].Lm`=acܜ>"ˑ~RJaiVUXaS/%(\bxa@ @Յ e nH\tzK?Y)ƶX f#fHvuqҨFą^DJ +a]XH:$?y.d_Y«ѶOo~~ZJ^]rrj[Eۛb.A\Ԓwͽ xYbN8ww`{-CplInF'LǬ/F>-/,zTB^O>{.V~1vtnYHI׽{Bc{C: >gώP:}$%_z^US~nˢeϪq%kҔIe?R˒6^L|,Oxri' ޥ^y/ >9}Ǿ+22AnB:@$dPɈğ?Ǐ۠d~u9;3'ܝd}/Ds;d~>O`?T.@WY4v,dG$xPt2\11 ЧO| @<(1>0nN\x??G )eUMuƥ6-k8b#S͢v횮馐J~Ү*`wo2`i(`!8):W@KD|Ъj){g3Wzǫqdq 1>, Ay-"8YhfNS%o_%B)X7oǶ;LyeT;- DA  p[ZT ͷ4zS>KkL7tDa 3fY`l^{j{~8 &x@ ?= R7 EUne2^dQDLr9I[M#D%@P؆~?VN8 o @A$o @ (pM@/6,qkًxդfu㍼*d %vk\Cn\ӂ9Xgh ?)lń(9 R7DkPPqKf9T$Y?. c(w 5A3xی{6gsv` ;llHklԪa *,ђY.I38aOr791fkpoui6ٶ0 ( KJlK-Xo;_*%/K8 P*cK3\iaY< r|^|ǐk2L=>_USI;İ 6mNH OHT$+U=Td웒rl+Z3! 6?9(zI!73`zѯP^e-'ڜ2a@d#LҖ*1:HFמӼ(/J pEHy,pWt:;7 ^)m.3ȷ '=Zs&6qg 6q[ͷOG$$_py"!hgT6! !E f_+Rl.[buũ@36.}"~'>]W6SL 1f񌒢Su<*qOhfuqi6gAm8%h?w=Oe4Ĕ=1a$P[k匭sH_g7)hv!oFVϷ0&96gtdul`5( _YT8PG]s߉5{4;~elH&{aL0Ejm<,P2|sszl e1- ?N٭s׏oPʝ~w8 JW14Gu'C0VЮ#ԫ%JFWV]R-fE`%la*2 & 7Ym((C U5XB~dgr[7h~ }hč87w*A?:Lڞ64^or]҆Xѝ&jL/RiYvCA)Tu6Ae} {48=?pkbPVg(3]BGiK{hnzicgXeTCP T!١} փNt[>59w#;vމ)/)+F $ev+Ӥ(󻒔.RPtSj]Τ eGrJc(D 5f&P}j-~&swl&n.Yh)YQtвE~Nkbr[iWra;=VCjRic.TڄjP E &P)46_.K{OkVW<>D:Ewa>r:lHd(qm6r[uKT[|ks+AutpP.0Vhaf' ,լR:!]: sep1"@L)FK%tەYݑ@ 29!kZb.zۖ7.nޭY["B>ߝ1cEGC z)?"WWc{5: DUՄ/ jDA?iW7lZ7ʷ;[%NJd&Dr'IY\hR60r-ʺ6WC`}UI$P,1oDAÖ/V:eņ-`,oY/ݱ)|! 1iTܽشDιt^73h0!-/]6(֣5~c#턉ӗR05nl:CLy! a1Q_sOq!)%5#03g!0̃T2^6:ע4C_XW L: ip='>sCa@Ci4kP z#T=saTؽ;`fVg  ;`xN@vvG! R\!pJCPy8Otغ.̾߄?m?.N8BpDt=~8+[Z!H[Ck#`X 0- - "dl2.b" >c @gaЫ\BXK&=ה%?}*_Ŗ͐iŢIbhX<" JFA0(&~> C e Cfpc/شLVbJ-?k.A7_"NDˊǣ%cƒ1;;AͲ^bYgT2Cb!,OK= yЫ7DvZC&3O&L%Hq1|4JYqZy->i':OJ|C> 1d#LĐ3ѫorٔTÛcM'M$cؚr]0IU=uf# ȮZT!΢<0ZOsjӞqkuQj-"eA` @XB$d%!@VI %$lj@AA VG;ߙuzݼ٦$DbRfw9WiQ^cUT-U3f5URmJ*0P 5ṗƝK@ޱ C?  ;61|3$-!xUF1x&(bJfX,tf(FނOg5p}o1(f|Sv/%V})$;͚F.MeםuEmvC'hQCݢYаsh],^trx77n97Lw@,Ddu,B %k{=eե:uS.uܥʐt*ڿB۷/7&V,tOmx} o*<^DAxbyލ0>P,8OkĸDT6.HO:{9F#OV{xAW~%=3ϭ/?ulmWۂ%/=J=:U|?HdeP2дpy7g3w{jd8⇃ȀX <&(Gdl1?Ƞʨ13?3vjjqBn8J:j`G'`21| ;7`&oPh1G a}C )ȁedD#O/6 P{]䈪F (䠀Kc.#KqgKhpu?ŀ׊@ؿtAC}"c_zAW;(v@ہ;\BPn  :w#-ya~ C'z6 UC_ B 9t ;{p?*NN& n nlw p?8_QC< Lq;FVk)+>eRƜ%Y8ωgz4Q0kMa?M47q1콌!} Xu;1pC:b`!7Ey!%x„LiRK33oT-"֋2$+Ill2_;$'I$ʻ厐7Fz, \ GN-M"EǚT`R%~BL&6.dN(&pG~H988l' +]mE P7ȌE2&GrpI/9iγ"Szx2*}L|DjP'^81Nh~ʾ}8K ii1U vp9l Z$N0gy4x2L6AT'f=$7< Kl#&s)' /S՗@ A N*1hb d| Q&O%xΗL(Ɠ+jU) QS4w75}M{Ҁ6D6%h'h ĈADA pm|("F-lTže 'Z88kaVmFwII7 i~~~}FY;A2 Πq@PB ^WfΔT! sF.JsѯzJrИk8W\+e^_4 1b ,oB! APw}A"NUqSJxBrR9aC۴s%Ime]+nnYfSV)) !cHɽ_oCP% I/ ֔J zP*5aniԚ>Z*|a98fkz.7q{ʹ=O@dA (F0aDY0H R'uJP ;-ִWSXmzNf+2~D]nt1k%~fo2 0~Py]܊?K ՉLMeQkj\rU[kתKmVHaыLzqWb1CO@s0 &߷uasQOԑLe-ZyUqR+ Ygԕ[j2ZkkU6NQt.bA&b#VgL{BPz7CF7}V3GvHwVeU+mŲ.5[4my6kR-4UN#rH|jx>A2 91PRo<݂x.NW@Ʋ5΅ʃvz!0$lŜ KHH"N_Ԥy=Hzg04Ay,Ey٬,G} "}bg}OXeeK'!vD _0Yǩo"ȋąs^kJ86׍z99`t2~@2ȓCByvK߿靐E?)ԯ&X׺5\L^sv:F"ed? ƿK \⇻)t{]ue5yn4nq2ueI 1@&d tGeɍRR؞Z`nvb, S!O" Hu rK}*e:.װ~vxcOѥ$Z"oieLMoʲ@[ F{^ ؙΜ.zD{@,D۵rZ ?8rD݁A bfL6lL0V;f`Kdp3% d 7 l+Gq@#[8ko G-x ,=j] bOrT!H4dT2-pSbj'tC>ZMISs?Ç k LDFr$j@#H$C!ױAU&46Aw'(vGUNkp+o5SB!JbD}ӃP*CD}qIE3 aQ*qGt7Z#`&gV[VpV0wEJz@٦ }}/DІ.ݐr%`U 0j(6 pUa/S 1f-u%o/&|E@j R|iA ~9_y" -c>CzϐBT0Bh2@EjpB e(;`uzP/R e@SWI-A+vw>o/e<{g@|˚]b={ǖ lMi24kp/70D'^' RʚBka~mg}#|%#3a&ϰ&5==-:+ZQԣuTD+ʅuBf! H,'$!Ҡ("e(U(Lx@e(λO}s7i /l>BG/`X/Ш[ DՄ.3#6'=0] 3ĉjқ:kci!i{JFӚ0#NI@Z ݀xr 9{"=qH{\v[laSBzYF Hz1|`D>e1̦X 5Q5P7y7@?H @O< qzܻ,\>5F})b_d < y`ۣpnapE?tݦ,p89 ٹi$,~'<=E3ch/qǘcӬ*h䥄gx=?1x~M\!_;_[ 8> yȷ/5 Yt Ac|bIo#e\=;0 cÑ͢GV\_͘>؇:Cɹ>q%y?h] zjPo4L A f~ 'J8=leC5Q QI^M|or=񁬊@vܛ|ܛ,`:jp!ul,Ap#@䐏bv/f<#|`l \QރR܎V^N9OJtQ'i= G,`Ow& iנ8 `ڹ} 3 ѻkJ&DD0 GMIT: wc;rjޑnct3:S ])lG en G `2w, oo~g1Ag[$KiPyRT'5kkCWlǷiYjl|(9Uѱrfr% 503o':M,s&[W8nR)UK]^6a֖ 6X~%dgEl|AWIg)E b K1F|q B̳(V=1mxCY0;̂c&εk\,č `rlLjxcWʴ|Yu6NQaK:|a6.ݮX:ҝbMf*7CIC<\:W{}w/<صSS~ՍuquDPQT(bIl$$,D*0:ŒZ;NZ:nǵZP*2)UdK9}m^|^K.7VzaZjBK5}F_\c<\mzGiafӛ0ܻ=|j|4쳨Ǟ$MW?l{I]voqf"k[եm+UnzZh|:^Eh[m[?QIT"bŋxFR.p\T*m?;1te!WrΉDyjx,k#]!ԳQ>ňX&gk *Y>cȎcd%rQ)#5Ңq+QhG3bwF-!?&H#!EjZQq_qY_iRH #ܰ8΋ŊhM\ sp1nq9fG!~%d͠3Y /RLtFkӡ\Ob ICo2 : Ʃ:KayU4c&ϜBp,4? #G2_%dBR+>a.| sxF=qs@ ݄Y0)։AXISQ-~bOqp?;"s;TR4HH6•%t0 `Hp\"b4GvnM-13Vw_,Q1_@? `g]!gCzztPh á.r=3'CM*${yCBEXtY m Rw26MV/z/钼vH?i3 lhS`¨DFf(Ь\_ܜvCrH1D%3O ;r,jߥh@aEvy7;S0 A1lz, 8HA6 MPnK|bH- z9DWUB𘂠z'~٨]BfoU A %@ǰlr2p`^cI<BW(w8 V)%$uWT5!zJ _6+_(ltrH e&f|U7h2}`t06 cP2A J$7?OCj!L0lSAG~DuAYgV\7?QtR6?I:?K 94d0 \`Qr$TOCl6Vh%o eLpq__ӫڣI7?k~"-ցjWuDd !I 2 hQP(ThI ǭ{{̋yy~y$A'b*37EmJO%\OŚx4C  b'iݑ/f F}KF-%:v22vfAi:Oǡs=_H`0Z:*J?,m: 20% qqChmݨ6foT?'j݆49u NU<*А^ _b`406YAP24]f2e\w|D x~j&TxXp%=6s@4j rѐǓ ) [`bc1` i,p<f;/_ |A;sT!5஘I 7X- eI$->CX?\Ij(cO3 4#76N0 Zd{߽\ml׷m#šC.9 !ƶ˜LV]Q[j6,KeDŽ =<Àd0 x9h@ZjKf{p?pjw˓S?+<ڕߡcSX8Z-PKj~!Bl0{R2Y:=,VGr=/mDP\s`z[k sBfjv,t^<{ j]7wZu@E מVET$xb%Rν)S $"B˸D5ŕhڷxHGz,߾ோ;^5YovYcS%]7+Îj~jrXUPPl,S.)Du2qrgH\&餢aH8, DO7"@@*,XSiy}-z.h umǟѨ1yHJ%e+f% b~jږʑ!K餈tXHFy1_d 9i9%FWa`FN֏oU6>\w1ҧ"6TU"Oe!<32%Q*f<%Ii#b|TȖ 8)GjD́dtm-,_tmkŃ]_t_w]|`eDAmLpfV"tnKR%q)yI㲇%dՈznLHK B@ 6X֬6c7WG0}wv]:֋5-a9AZRNV T#$Jđ%"\hrLǟ7J#rn<[%/sڥY-xg ~5=?Xt,S~gZxB/sI$4IŎ gj/C5z*4 F.!gCȚ0 -Em-xlۀl@З}pƁ}U7ܭ>"Ϳ{IŒ81k5Rji`MK vXQdbF0 v<[_o7l@陣UeEmz]~?hn/$%8vC2]$ow/4WԀWKկh!Ab;,å` tYk24cGfMcݬ?Q }#ف!'Gz6⼆pq^o 7}:Y0y!`XNKg j,eUL9or^!p]/?4$BQ.X=㴞0&+Am;2]>0GzbL;Z hk ,A}kPdk-[me{Vg]1f=Ϝt{jx&{9:jo|}{׉ϾGt~;߁pF:0Yc>:̓|ޖy9ӡ7Fy:-p.]gQMy? q .,* l!!!{ I 7kKGwKU#-X+:uA=zL[8 B|潚|w]=hil*5{.]0wp3GN RqU"֘[>asbOn"){>G6bڸ-Gx}HY|HC4ЄaX(AQ> a@TNq Gq2͓$ߡ(2)*%`8z dE!; qL.}6D3e|4|Es262'aqh/Ȣhf3 2* (\GAi,; <As Ru t:3ALd> 1y J ' JCʀÄF KTaP-!DXK/ldAV'ɺ.g Ivg|[xbd=xM4d'ѡ`1IgB'^9pGCI<ے!ٟ tNf@x&v.Ywg!>Y/yB t&xCȀ. &E [D(@/8nBܖ>BE<C!ρ ُQx /(#hPy#o1&BPPCUꓠ4 ʝ =GBH#3 KGR9 &'}HNJ1&QOn=[}KAݝ <Ϡ#4>(:qLT}å A1(Iy -|v{8TgP^RWhʟk4Owyw:?.)4½a#*}P23L}*QhAd$?ҵj}jzoW ˦QӅQ9g0"7x&XśU@|e渱jGʰs)wtuV+neEc88ᑾx_~aKyrpf.l=tГ|{]Ċ:&N'ؐ=ա#1+mWU]GF&K_ n[nZd(0[mmECSC-_zl/yAo"ؔ-Y#zY[|%+p2\+9TcqK?gK:-;,J/Y_8Z4h 8NJ),9yL~#d+ȷ.ͱLlK2ȟ9( vmpo]_JSMk{As_%Q{k7%γfGpYeM>'( dȾWOz4̣a[4;Yp؛=n[m .ѕ++ۗn)ztAGd9׉+eU|Yy+׾ʾݮ~.'0FfQC5&2%?1Ad袻[~mC?h9|{ɉǪ]]mK:j\]Etm_Wly8yƟ8H%CESf_˖889v!5dl!ҴeFiK4L^XYA@3AZ6]MDj+.;fw9&G7%ƞgTF.8M$, %tIIlb樒I^֥N{:+vxof:4 kRe i"anH^lYXVt/#\Ԉ 5=/%z*"9z&,9j649j>$)j%=֓0{"_B4{YS.uEp@ k%Y5_qOfKf|Pw .F &BWLxYN\;.v% #<{+UͤHߴzrLNM~jK ODdg%222YI„)x䇑 ~d7*a:<:~7ǎ.DDaDrxY~nSћjᮽ&ʷmZ_s2P"wZ~ܙ *d 8ᇧOq#Rgy)~[& `A O_B'=q/n&yd,@؆%`mY`Yn`ug=w4{@7|I:H5 ?BHI t`{R"n>|bf/s/m!?삐OV"xF`'!,ɹ 0z}OX ҂Ag,7{Ɇ_g"D.ǃ QvGlYMtBt"s+]*W5Fh+ !:i__#;?=G+b `>7ҁO=3@$fAb"h%[WWGmtp:f}6aי D @+5zq$X?r'j"Du"֕ S g8@> JdHJ[Q+<: D3q,]bk,d;2{!8?Ds3듀UHXAPAK },N&-*unH2 _x+lƴEwÆ؃Q7Q9/9}pŀw3Wq>&!?{ԯZ{d>@V#֊ArArUU=,7J$6^Z^%s^[%*7!q+C;Q 8/DN&A-d_Ɠ|Ň-֑{@w. …lٲt[R["WQT;KRgIO{[7c! qe#C1$WLhb- #G4g _4egy?YH_κs[+▲%kҞ+o.J{IEeW@ܩj$>đ|)֑6UTN-g7G8/yZ\ИNn}%7,ܫQ=V!Jy27ңv[V-@g_Bidg'=6M%sz_e_- ~6K]nt^7 r 9戞;O?O9$w&8|[ٮ]ٖ2h[ͩ㲦ԷƴwI dgQ@zlZhRjwZOkCf>VEuv$ٳ!}*$\KlWv#Ir8}`ZjMk귚}#ꆵVE}Ƹ|{[)!yDmH@6o<l&} ԭmݣFyN$,P}U.+*wWdS6g4e6d\Kٙ٫NQdsqYUDH$[G dΥ‘2VrG6O]m5n6;^.{vW6g?h䷙6 [ +eyU; jks?լ}0RiN0-1VU0.{$mJ l޲T͡ p<߽Vԫ{58xthWflYWf6nIY\#-lTWO0vZn|Z^03 iMqTU?(˷y{)L|28k݃(7x_h {YGՌF6Z Ě*yeNfSkʦԒ4Sb:ST41L a&.&{S͠|>rǔmݭ%"J};uʍbBf\.1M),,ոLZ^ُĀ>ӐX:)(UƔLV&Bٜ3(CU沧iFuh:'ʿ۝j[W[Ģx=rzSS nW&./fkIiViqUX٬5X9SY׺-CuyTe4\ѪuMBXEAaIXE@0qWzZD REAPAܵEܗ#n=3v cNUԞ,gg|~zy}?ѐf͂1=ŧoA4ӵV+ok2?mW{$QRYk+;.b}˶S"{qIyy%w,>{I@m˶\6E~у*!ݮ3FtmuM原Tյh'ly}OqOj# Ǭ;&a)*>K_X?+w᜜}md}=@V^`O2w  Y٧DN6 u1ֳ.3&sՒ"/jT6慮;TnuÛf=,=sӪo2/ UYeCswFRևD"_IUǧ M%S,\RU\,=㰽CQ>wݩy'G,iY5-yc\vSѬc{SkRNo / Æ/?R>*FGRGCo#zTFtb=tG_]ҡkT%^ 1MmDd+/d/>08g6;>'^:1U>>f6#9(TѰ臝Dw]۽j/qTyÈM{\]ۑފ_q3m,k |VS\1s6zڌ1יӣ vyŴ#>3D]!h`?Utr뮈ӖO}[8:>˼&<ت};hVFByCx]DFvAu:yDgD7#jnfʯӖ"kNkzżr =ZkCO]JOxVcz>Fȵ=U͊t2T8w(C@u752ω.4>/N͈V/y/eTFWfɯfOxdחa3/N׷!oc.܂M |{FD7$/!5Z!Dul+Xvv'_=7-)_3{p~jZxY4C UClw~d5IJAlbY?hGXaD|K#Q;#JÎ7n:Z(3 BHc?d`l.ATVK\_0l_Lj*P5˿C)EpVCԿ.4YEjE( "A% #@#r A("HM׫XVG+VWZ]gߝ/g|g]ך$i VcjD0!D hzG[Cq n@=_\r}As}F} ns[x ϫAy9*Φ9|f9DY@DB(KD*׌F!.mz?2a4;Na1vk ZC狰oR # ~H{/px*ٽ_ LJjٰb׻ͷ=o:~y_#!|\qw| $|ÃQ>P@)wusW`Qn2#5hyR/ף5n3Q-߇/5uM  N :!x\$hB6&P(APo8.S3)mOEHd`\iXf6iK'Ed Rtv阽';' :>|$l*@zg!U 4S V, =vS^jR \g [ͨ.Ǭ="w99)xOHKU|%i t D0^y(ewE&:bh F 0$@@)=Į%Ωs?A şS~+[ovlLqɥgr"2.GRIZYEࡄc|;+#vl6Knsc$SA j)0@7b-ǮȳCcSSfz3%쥓a㹱.#->J ;,3*o&e=d}06ߐp]PW%n 8r r`d0q-=-@Ѝ}M>*g./.qL'꒮O+IX") ]E7!=*nFgfONTF*=ERώ\>fP陕}z;D/*'Dˡ9a~5i(akRe -D}/ =˷Duz|o.5-Bg7߿f6x@ wqo]GSI:mu~nG߶a6޲z1hQoge!̩R^[.*KחkUM/+(L U~P^^Z6j`0pXWwT hu:yMt52-&bEKh}]m[UM6]e_Q*P+K+njQ@ɵgCe"y;B;9S w!!tC}fh@nj ՔUOguUMbW]CVm7ϐNԱu/ D{X[~|pL[V)DBwNc=fh rͲ]5gm[Gn˞YʆܠRzBNQH~T Ș Ht@ĖZYpـp{C |i/CC._-+aNn݉[S;mŴݭՌV"0G)js23^;B|3$toL>,u'{RFj+E^O?dr7 N07]X!@*Bw]Ad Bc _ݤt{+k/7ZT_ks76mDna-r[;~cx|D_|J>KˎEћԂEG->v8T)Nв@]n|;)T{s%35q0Ͷm@yW5;dd&GyS-<D6zvc_֍Yco,dYbjmt"\8\ۅHMkD Ds;^ ,4㹼~ocd 8= TxV{ .\;vhH5mL¯.CwC׏ma3>^gsX~G[BQ(e>*  MCraxayFc xGaw$xKp' l`3vog&_$*BM# |Ʉ@CBZ(( *.\,\ xH` X&c ࠇW!fpU3+l?D"\" Hų: Ix C =q?/8T 籎簝'c??g5|M˾Erb(xS(b DZDhĒT /j!8K"f5SdZm$=m2] { -HEbfy"z} ];ҏ|!iү 9ꏨbD2wa1xd] ԠkyXzLVG'zB9 q h( F|?b2 ?ɜgfn3~_r B,#dX,TzGPA}1a4{W#"f2ς友#; @vȨAKH?0q}5HpvE,UO ɯ)cI n e@t 1W͈1Ҵʀ﫧4OmbEۄ?+[+M:VHiPv}>dj3q]3r57`g0o/iK9XߎM9#sdkQ5nBN y\8 <; ?QB+ y#p!uNxʶ [Ÿ] X&wg<%ݫ:0/<8S6|n:9@틼H뉸Axh|KD~F!ZS4.y} |&t3I l}#fr+Ȧ0k4f,9nD$s& J{jUwQ1k n$o<.x:rVȖQF"vIv$5 Jst0k울 NeNEOU{JX( Z0D] (ަi0E&pJהFߍyǷ ʣl2v2&%ݵI ť3ɵD K%)^U / Es !Bh`/ {o. c2{WTKEV}9{[I rU:]M/6 %}_7[͖7[|ĒC_dD[ :U7JHu!ܪ5*5LNe莖˜=jС&K<\YH)ʨ+d nQnz 1!Y*bRSv10x{J.7[$5; לvU< uSTbt<%7GEϒ׳dYa$8̯~Lđd"412D Xp;O눠kXMaщԭq-5ǷUWFRW%TVzeRkYE;')O'̝{/!s[Y)(J"j& pk0hkZ1i8f .ZU*+{H˔Ԥj<|/_|b +.1]$[=gp{W#vVvYB{>bc'ٸQ9jU#'!@jYR.:S%񫚙'+|*'88|"*;R%S"h5[KLqf`34&w3T1Lz-#6-.Y(l5+ȼ&WdC#- n Va#FpV#ZX+*_ͿE{Wp ``#6ფ!ly +@N{Ss\»JC:՞A=q;mAԣ͈zL(Auy{oq`w0@-vвuq1Q -q/xl#GN *v:s9>Վiq\r@ o/"s;ٿ}52GpsgN kdӻ iWRX0o39jUmW;'2w(tێLݒc} 9. ra ut 4|$@MH3v;b=IQ>as7[MΦ[sf fjvg:`Kږ:duȎ1{\E+WwA'@?@ίXΟH m!f[Bਞ_l쫏^'1)i}g6Ky+wVn|8x8]Mh_ο-3'pC"HvY(9yѡY&/J9hZru3W/~,=A}ny;P gD.~gЗL{(m# a!: 5px7?ՙSa20 f`FP"JQ,X"q%Uc jtE=.Y{uƵG"%( !;O}}'~$~0Ofh#v^R+uBW e{; F;m_ x(6Q}اD֍"j)]5GPps`|(|H?-"")bϏ߈5X/v~nH>6J-߳* .C4'DD8?( А:H>0ZArCOY yJLX R`Ev%,M4/q-T{cDAD 38Ӆ㡽.Cw&]mqm{w'♯E^d֬QSzɫly]jyh'P=9]}GK4wV{Ju#qg|&xBSFӉПHD1v( Cjxm#TFtfNLPɮ+( }߆}fDTDDYaVePYM*X&FM0.59Ѵ1ihKs޼[ą3r { ʏ2hnڒ۪1Sb_ǯ*Ҫ=RDna_Y9sMF"",MB0R߯iPQt &VX) wj+\ټwIl徼Tʜl~Yv)(NBQj& S(Xlaᮔ^;4>#80Pk=uL{Ӽ/xE}ZhBg./c$18%#p0U$MK]O=O>d(NGQb. w`1JD}P:}'ih`A=hcZU4u kbMeՉV9iҊ~-FX_r'N>++D8E; QB`4ԃs5ԃz{vH[Cje-ZEM+c-$u))Y$TzU7 Uxm];xs6pk bJsS 5PH3@/*Ʌ.3rev.+k_ٶ0Ӥ{,wdh9(w辩KpBr_:lEX z.,^.Vô6T~GK5=Z)GvMw[n̳>\Q缮kD{xv;a="zNϤB 4MC rfh a]';m$gxF[bFl6_7 o7䴺)AU輺ɡQA5h8AzvV,Ns!eL83 Gx*NgLбB㐱Um kpooȱ>^AwP~1?OH1Łi=3LL{յ3OǨޥzZtnT!ACӷyFsh"D3\p-Ds8I?DMy`%6U" lBgE b eJ2L^U++fMOe?Y-k7g]ew+bG)F)O+a5Xs\3 )ς@x+܊f֟btRk(j/˔? 'ODT up~ `$lF򙔱xV2eы,?xO{*PuAo_t?_#?%7j`X~|0^@0WANx絔Ahieޞ`og?hΓ|9g|Ht7B|{`'  zh%hp440ppX%B0H1Bo FʗRQ>= X=Q[LɅCy+)hEˉH #[!`|E~\BAYpS8RB7(ˉ ro }bL x`B/Hb͇C<hƠ3̕A#z jAM,H`Z&)&5t>2L$U)}~D^ KK0hȠ ]̝ACo l`rI$! 2A%r|INeJvv :2hOZ1[•XB\RJj٨B: Bw,\'u}GEugqSFA"3u DPAd230 ",BK5ZWcM=hbY-b'su߻}9(zy'V&q_ Nq%]ev^Hihde-r8hQA:'hE"[|}mqBLb?ǖ( zŨ-,rw( e}ow?$kxo%7WCgҋ_w?=߷{'+E;oKQܒ(['e8s21E3fNPxpz]8oW.Z ?Y̬ Y 0/2]7\ g'\e /p@w$@/#@oZP/^z~>+]}A&ݙ;U'Eb;w>3_q)0JƧ(:@38]z~@Iw}҆<4{~ެ>;ܛs\Z&Uٳg7'dY>=x5qχ&G<ޚ~f

    #z}b!\ C a ZdC_E yN68=qh~y&sL?ݢ?`xOn>A]gwd-MwN6]V@A`Wal-pM9G2p:ҋ},b>H.p ,ݨ?$Ev/6߹r{Z6A[K:K7]`'QkԱO/&f~e%<疈JGT؃q=ѱ{#4=]7nmtۯ6lM%YK#٪w͡hOPc8O7cq>_'d$8,d_۝P=>Ұ;.AԵ$lSlEGtmMֈ6eY˩1sC9z:N(#5hWұ0e7gRYp" S'g67c{g7'upKJFu=1Ŭ-![ܪYĕ6/Yn"UVѩ6̥2+yy]7Li :Ƣ8н}I ڍ0۔)oS1ņ,؛m ;s䬞l/^g\Pu1$U)&uMCR.־:acE|sejkQ)Wjvţ3q$2 ÍxAe Z!3|gVglnG^[΢ DY f itMuZ<ʾ$ɱHѩII'ܴI7r/Z52ĉȴI0.x82LcTe} AO)tX6eiʟPj=VٵuZaIBC]U(ReS*,˶I+-K5;w01E]#.BdSc PFF 9Pg?\Nay4;ʛfq+ Fuj,ĚqҘFYdNʊmLXŠKhLX9:RXU[<^H}ݍkW J8 (8g6NZ`jNmzN?f`afnMPEESkٺn]6eyZ(*X )JYYػہΞ;0}'MZB׋ǽ2-c$)nJjG%W?ō'=vpUB`J56<ցYki3d^S`gꪉ~E+߷bz + |NXc.tsȥձK,i)X,1$f=baoy-~KU^)5cFi(ޔmJצJGxiqoMnx$p̆; .X$lhIix^IUDnIcDܵZ"sVIdގ5^u+7r~v'l3`Jy*qEX[Qsl$S}Fna)kֹ9[V̭3ʮ؇-%$}0=5P-gťʁi\&TwWQXJ(W wݣwy2df3]/ӪKR\;-] lI6h )wHp8_\ɞ:P;`yVCNdQ7F׍j)3u{&կro7$1T(c1f`6ɝ.`2Wûùf6hXt$ G<gSFcwAUQ˴2-Z-~ˣQ;"ijro`R?PTY@Ƈ& cO!g|&_$#%;`?;}MCO"h-ݰ} `;+BgDi#3~n`k/b݅ F I'3@9=.ak[,m03Lv^NOй^6Am?tuނvU*3N5?evSO Hflo|oa1:w4;pPA7 -`s̟ɹ2;ك?e[V`'` x@7BdNqL9ćᇡ7\.,P.W/{rg̎ X>̽o,v$'ehB| CG{"$(C iJ0~OzJclr}jO][B 9 <9Sb(T/yf(ў:-TDA@'/R'yN[ߛ3?;nD$_}š-&¸P9U^x<~4^.0#;ߟi%G\ )PaI6Re١Ԫ֏ k괶MM6ѮAbN} :F9UrꧠǕiE`_PKufT :kA+i_ !7!q6Tt-? A$b@k"q$>ǫPZ%vٱDX}ب]ti;֨ڹS+D7Lj:##ݢ{-T3$88t%|t$ˉWӵ ki-Η=>wqڹ\wYsssy6%6{6&]jH`T$>5@| q4Ay@+#Wӝt[ZF⋴dډS5?gcb)+ )yLeKgMi4Hm5M'UvSUX*iIXgk{YjveVc5 Sհ|w cemyUWo5+ o" JbZE( K!@k@E(޸junkn۱vvt;ad?=s9s߰NJbMH k) ^ ك{x s%' 0!n%&,%^JR/5|ϹR3qS։ةPG2{4!xW!s΀e$ kg|¾Ct+J\V卵WI*9}V8=0MTL$[ƒۘI=!CCY=2/.H]r³ זDظTuYc繥ΕAt_fMMtfv<gTF0즎Їyj^]w!S[lϩ mn6gu4Caͤ&s>*Ie#YBCDHYCB>9Ήװ{^.p!g 0e b GP5&0z ޝ,}`k~ I_Zȭusf털\;')Yh?P[xJ$  |s×jߢ7 A R7 `LRʢܺKeM "]`Ȭ3VVs͆v~YQaIH?+)/n(|+)1"4#Ucpу. {F[UQyнŜX[W]_]j6BJj9%m|cqP4*Ht+rޠ5~#0t`aB 8Y0O0{Ͳny\VQS (2UXEj/-唞ה^dޗd3MD1AJ^W%fA=X4By#45Zѫ ޥ~E@C]S_kͭif!azSz;\Yu:\YHUITf"P _]AxkC?4 `Cz'f,@w ;kW j0\Ž-nؾ$mˉuY [uMeW/ة)ZxM* u]xpNA{&q38;p;@57h~D@t[ۛ NDn^>pW BCȃz`uP y2cc}8ܻy3itu` cOx>>ޏ;x}~lFຕ@Cq \֥)bJr:ɣP-g< <ܗ\;JܖᦼUp8^E' 霽:'8^vMm -,U)Q٬jifM~/-߿-4˩ŸS۟*p-lQ犓|P:Ma(UOUϰfRn1MPm6MWf7 l0Ԭ7m\keYb׭Vh %? Z+jslgXgzj~:J[EJ,6PnLW . )lڜk\]n^bԼfy\d\h,7W9aSs\ Nq+H eu-??;w WtX1QcJejtȴ* OY4KTh;7h.?~vP}^P}n#~zБ]N-:3.mKvʺ{:+=TFiXCEqYZX, SvfU6zY_L.4W:~Frǜ !{vziBЏdO%⹷7ubM7gjHwP,,ΏL떢u͌lsdvq);|a\NwYo _G=97Y#Y.{{3~,K`E=^&W{^VocvJ4yRp }بR=9$A_ٍCf =s c;eH~kZLtNr"}zpppc-4CJbe6%%ppj\&#}YI %)֘ꌉ!;_3T#R4b JIOde7 1P,,.V:,UHA@*`-k1Xb]QQD#UѱrԊ:k+ڙs@wŤ,F/(GFWύ8;jSxTQWc(a>_# }xk+$|dm8IZ%BN(If4-yYrR"!1ba\eLBUt|M,9"V6:p kv A>0^舶Kgųf] ޹>-)9;r=$eѹ~Ȝ9aّʰ4$Khz: w=}lIV|(fYb.sFx <%!e3˦˂KQ~-'-Vy[M(Yc^IWؒSڎ]*lH!)6=g;ؖm^!I.I}*$BP# `hKWjlҪP3yU UeXxUYRzVnQyTWW+>j a^c{s2|s@鎭WU[` |7q8P3kH̐ Y I{6+1n2w55w1lmxk:VXX\s;}FZ:K+* <moԪYG]׏[\?Mx,i+q1K6HVȆjdCLN2T+䃶^7τ={tW MDofm]2 kPO  3CwǀPosc6.C}$NKE%q\[Hv l#z,za ˞u?0 &5M:0h`<c=F`ӒrXBz\U3X>"$d382;s `. 00(лв]:!e mv0o E2 N?!kvN}'5) i{M'܋HDrA..iT5/Z\/_\JyC2h/`pB/뭐yO33OW:赦;X_*8kx!v7\[cی@77,]N)KOgͣp4x0mځ=jz/ȏI~"r~T<</qC.נ(++7&F,(,ȲܖEvvrY˂+  ".!xCEh&Fmc6If:i:MSM[vڴ}z<_9y>|e >X6e7pmŕOK\@$ dXqu,xFVe *U-])[kkݵMp={aj1drrr_w~ko7CfC $r"CkKGmoWkqKp/4 nRZ.GRZpP9E;}VC)g~֬(b}Bq}Lq==WΑHH둄CHW ׇG17r}G͛`!:)3aNi(-)>)wfi^Qg2z{88w}Hca kl!Mw07ߟWЧ>(U Qϊ귙.=CӞOQ[2 $<%b޿{?@ωlsc9ʅ49Lføv33 @fkזs5ތF~OF-L/jOJ[>})iNؕND"BWO_zp}b0L &tRݱp@Gt>ի/`wg[]6^g@ێ֬@wV?Ӓt3Fݼ^wKZVw#ơ#"$9p7\G߷`=` ci`@J0C1)Q0󊸞<+ߝ[ВrE 9{NÈaޒWޕ m'2H1D>O1wW9K(D}7 A) iN3X&{m.,5V4 ZE5=!8)Ae_HSGD瘃[xqz~\__z_ΒhlViI]lvcI>Yb9Jl5N-,+̃RaYLPZIXn6iH; \>b';(}-ügyQۼxQ}z ?jXxc^.=.vv)jdҐ0@+w(RV, Ư\2ZBm6^V{Nr1糨{{i'҈ߕ>j@k<ɃȣP]S!> kjX?7vy@E}eaOp}P, (q]ՠƂADET,NPXh{,G$1qu]{Xۏ{@xgygΑڛ%_>`Q2l]f(2C/)멷4y赌A.| b38~Z9P rxë;<+"Q1ír\\p4éUp,2!9V3yLYǻH?RO VF*gS݀cju#`WDak261ZCcIڲ*K%\@]+!=bԝC݉Eݸr6ԯ_ȠAVh6#GdeYPV: S^ jO-Pwm.߃k=?CIl3Yw8ߕF6eل\dikbR5љ&+"CV!V`zmDQ7+|; R@.Wtll]> 7 Lb|II}g'&w!h!y6N(F{;Q׋]# DuOrLhv/C?[7lO 1yI#_ҐWhv<xռmּExD3=桍i<,`!Pqk6@kA? $#dYM6RDJvRK!u/+~xI!쨭PpW;H32$t䐍dur .2i.'WG ƙ5H?2|B>N"u9RkC:k%2SVo>~CG7A8RWm! GzjXjMf|tX@Tjds"@# ~I p'4q7F \hK_hZG9&ۇGx}Lԙ0&He%rM8O_ŠS 8tZ%#R9SThgG8A'5qU˲h%|:bN+qJ'98̃UYê 4jpHՀ&|W2cjAxQeNW^/'7~}6}pV7lGX3`?`8nsWu2:AC=84aT9F@YBz7ˈn.yJ\C;N;tQwðԹGT{$aL敃*|Tx{JHfKi IA3!!Z=k, `;孁Pg} lʎcPR(bdl HVJ TX)Iy'e~LY֐՝FRK03Ov@ol=P4[Gas8OgHy!s!) !!9!5!U!쐣eOKC"#TțY?]8iG,=c~3XP7la(<`G`q8AZc"[eLeʳƕLFEq2ݸS^~EX(\(I< ԝn_>|r8nU =+LXcFie%-7e&2Lt\E)EjZL1S-0FlSϏ8gj1=6 ,Pe s :W|j {Kھ>XX? #e&5E\F3+Pydvf>6#hE ()Tm(O|ǧJx bca@OdFwƒ0XiQGdcNR̎LNNγ]mMV71.!Fh*a`+"}ccbuX2qH &̏(͍͎)K#"q4!SR4VhuGEyafav .D&葨((("0 ̌ (qh]\Q0.cMh4rZ=&Ic\kmm&A;8}}yIirjIjI4j{'JxU?3~F[6a>(ѠԦ*CPnH t$=WV^PVOW5MߡYgMYHc֋*^TZRES.qaQؑlZVudD9TfCi*LiPlJN]Y(_Yeƕjq&˸KΘqZqC&#CT ҏ}mf`69x%RuԘTfbK0ʬcQbBA>dbJxyhI%){rs~0AZy(R+‘R9HLT I\E4L6-U]ު^WY>J"r,JB2`y)PK8]LWA߳H^FB@̯}a瞂hĺ0ǝ"ĸ3ndX宖Ns/nO}M><<P{ ~u@7hYGo ڥŠ$;Fc@G8;#\<өG]  <M hw=n];G;65+P`0^ہN``~ jCp(C!EAeaqC1}C"? 6je6րv1.Ao8]@8B{|a#hB>n~psynu󘈿+27ԝXg&Qs459=@{?0# pZM3lF{p3,?gyٟ!a{(pm>/д.d/`=fC70ԧ'J"H5K\~¿ƍ b^?EnD|B]k4RCIX= (z%-BR&kOm?rw޸p0>&?62j4hGLAIþxq1GxPR*Ǎ+GsMԝPS20l<@?F-5Aո޸5ZxWXwq+0"<⢤MT8UKƱs qW\ %uW7hZpYӉQ\ňv ¸C8? ΍3n&<ĉIdGoN~:G ӊx0n11W&%atrF&0- _NI~GH) ^?`ST!|:lG0V#ӝ84ߛш3۱j웹 Cv`p>ݳ10v%%U'8V? _LHjȹ{<3&̬)8>'$r&cp{T`: 5cgP'vö~ak?|^ .l >/څCք|/@܎FcyG92]ұcq6-.Rlw/# û º]bO~qui;X\/=R}F4XLk6c9 b r&G/Ė$W|ٱVW.jձ*]إ[/vI!-;*ޕb$7SjU=c;3Ҙ?ov$/޸ذ, Xn}+Ra%=W.H Ƅ'NQ?RjjWr^\ Ekp4riӊ 2)I~<'yNGWJVRn0͐/BBl4ԉC6 rUe8T.j* w4eߓ(N*;STu˯lU' j^,^h71nFȘyP"\ֹ-B-΂d,NJ`/( bAAdHyղ֯dۆ4fi,5L}2dZU%3_S11׫=W̽H;Xx:O#c㳪EpFIB81(rБ-6!Q"ګ{dwY>ey&& MCMZMZr4;ej\A+XEq 挄2r˲S/dSYlN)ݹINuVR55I4)I%)RCm|GiJ%i"ߋk$UNjr!ۥɕ,]]EBLHuW ]򲪣r|ը纯UUT U+Wg`/*!mMVXE] k#ݳFz}IEDŽdAyZ8Z1~SIOrYSZU!ϸ\R㻤Ž@H55 IFJd$LEb<[ðu ⽉f`ׂX.omBw{P ޻bh'bh*6FU {'Za'|/^@,%#k& jU8"W-EĪdĢn+ºKuG|qXYĖ,$&niTUk_p "$DԒPJ2UcLUjj:Jϕ:Gr#y<꒕Ȭ g*]FhI#tM#44B3`i M7-a *tfpY Sa*gC~mw@^dQbOE*<7Ps#)7Fay 믐  ̟j_v;\y)`jcmAv3yf.fN5`={e!/b򥈥Rpq/R?- T@iڔʿ4A~kS>jmVҾU^#_WOjYQx?Vv&gR\)"K/ʥk%O<Xp1Pom$5qQ cXFTޕe) SM4PIYhx>]B IϕR)51JjIb۶21 ocR P RCk(b Wovm7) ĚzrjE oTK;$]++>v۽ c~ǏZZ}-ͥbbjW#0Gi%oFɺUh$/5?(G ~ŏc0$~b9EQ:_|F^}I;l 5wKa MchJV0E:\:Ǣt%B{ KuL/gds2y4]!T=AOI.?H+XMXPܥq>gA*KczM#c/v?>>~_zNo:ptp0JSjc &C0&51II1/gה_q0ބ10fXP+` -6ПPOls&\wV6= 0a&~j [Z=W^u_:Rtzme.4+k4xƠF )O ίu/`@hȉ+f7r}!>w7%,gҹYn!Kktv> KP_ ٤4*3ZzCǪljjm3S/`R _Z- N!Mhon6\[b6R\wϑc*=Vc=?jCZyF+n{>@NZ5/bF*#r#7i{YQǍԨ+ƌG(HyNd7xg{=Ê6Wvg"7*l.an ZЭ跔=Js'jvLbR53fg̈YaT2c?5b/ScSbM#I̯\%gˌ\}2|))enE1>=*U)@=Da)fn$[IcuhuX&L;by7q3qFzWhD{o͌ qڽ]iVK4+>B =5#>4`%ۆ)6ZSmD{d[1ѶИ`+UXj,m\Fλݳ 1g$b>ã0{ KHe K"I&L2IfLB&$$C!"ITBR, @!(}cVVc] B_Hg3s9|/>X$E ҐcU8E5IsT@U-wdґ G@2#Xa:Ŏ;BGYn;[ycq9.YK$_mqg.j]L,kc acSUU TE<',ݩӣgFsQ3Lw[,q+'+yN+fNj?g8IK+MuJ,Ty]̈Qif3ȝ;KnEFk\mǽvo[Vr_\GF9ƱlMԀxꉧ,'r`fE8;J9SU3GK= qɔϛ\o@^o1Mfg<`9={4cVy+󱕒c}fCV?8+/n-xBRgT7\c_-or}'w*Q?_n0#b&w[I^+Z\xm&}$=o%PF0 7f|>xhA,BeVVQ2#*RzTVj|&)ԥ`- V(!x lc 4o2cؒbی-͸"ۈ+c/bO~o&j`C5o(]k(FӜUʥWj֪"ͬFnԴnM=ɵ(zۚ\16&gJm<h|Pu<شZՃ4>\3 i7ѴxMmthJcuC2Mjӄ6ii|qkzCz[Qk`mD#hl#Yy&-)tS4s!&E:TKXܗ.S p8.jkaR3нAWe4ހRbc‡/L>e~>g|A`fNmh5@8 q P`%:X>qBx_]}%~1%ޅ&V#7B%B70vޯեh>g^}~$%zEs`@}xËWbCaADA z,EL Fe;{v0-[nrt#Lqjh8Чm>GulꖀiEP0'oeX׈?L0?gpjJU^lbPx;w@x#F7b;&"awQ\r㑗#G~-QXM7gQ;O-SQp2"G#q$*q`i9-2 v/kΘV#cak6X.#/a86`Cj~c>11_Cqy,$Ȱ31;VcJlYi$+6%90HAOPKL=PISA&ze?Z#tI %UoW9R2yWP~XaJy;RU496*pz9֧1ڌv d?}ѓݙCM7!Y'KG=2%|'>KL!rl/碗͙s1  ec [Do=9 V8PxWtS9ڕ{QNUy^ g#?¡3m>K,;&Ygɸl`\*cїyŸ_΂j ([ OaZ p6¥jJ4 ꚰ>ªzB$a-@Sf4(cCO# 1.aV-EWQ|řh/΃D*.m^4aS$E0 u3J$a"?JE>Nśi^t!:Q%,r\pVhЬEv6VZk`n&AaԾ& EQH5咸Oͫz4KI='=駛qfܚ%piQ)CSU6UhԕêӣAgF&}F F@NZOIB[%*%qX'{j}񻋿UZLXj`-P:FC#j -1tBo Ag}Bcr:#w#K V5HA 5Їf$&"p|wHdꓰb | .&7P[M`븪oR#$32R$uHYjA\, iD"*cDAtH8MENLm]'{LwXǿ *pʐ%DqEׁ /BEyjMl`֓&8֪16Mn?H}~~=~_Y⦎%( )Dn(/WS:`ʖ@Φ%r2mKʡ|2LhLcx, W<$Rk3`\r2#s͡jgQ[ ٙek,3ƛvsZ+*Pb[嵅OΗg S3`VjeSˌ[ۑCh(u:.:.Xn0g<̙+[F_sa SyH1g`^.@Us$z 4fp'Eg  m=E'{xK4bX94s퉮j#MCd;srÎ]ر;ر;h(|Ful]pwr߇ {)5՜ ݌>4Ap&B4hΏ{Hc_N`G#I#ůKuX4`;1'-cٮqux-tɞ%CR[1Y~ւ}8694.HbU(Mm™&>v ~fتnc8!;ݪu.4@W 9| -Mywt{>Sӆ#I? {YrU nGL_M%݁{ց 0=&&OVۃAcYp drXw@0C̄9P eP`~aY̍;ټ' K==⭇܁uޔ_8 l4r9 scxƎi )>s]u ~˯| | \K68ش +/cHgi? ؂.c*Zkl7ң49Y}]ZZ9flAMOŢ:#WϚdDeo{g)Q~hAN^Z0UiEUUnE&herU|w+Wrm]w?<5nk0I!vßWÕ_n/*}cJ;U *4X;<1*J,{T\,POfӌov?)E]C)!*mU2a.mTAE5k)7Vy~7L9ɚ?^3kz7P2VkJ6Ҥ3J &=UjE7%gaz<+Ŗ7fn^ jzM9X=G(eJSF,BW(5x&k\A yOcB+%FXZ(.EQ6XU<+Ė|l%69i!]552{+w >4)l&MPjX&и /SJ ج%E҈ CE#\ 06C9,{rhaVᮚɑJ쭉}5 5D㢒46jR'ktQ@#bjxFOCcNjH, Q-נ\ZZ#mPGjRRc<5>_b5&.R)qo%jd%ŧix|4,ޤ5^JأNh`'P-?<*?we1 $n "" 2,0QNHAA(MqZ5q4զM6mzĸ&ƚXa9}"ΡlX?m _fK1SU@Y(/ir'+; ,E&C2 2UZJRSdHPR>%RBNJO %$HrT#,g= 3 boeȜ6RpeE+#=ItsjLJ26(\MG'qA;Nw(4bS:F @QU5IVcwU^56=VS5Y!r>><ekzP )iluOy-P0yڇ>+7{4>]5o_pS$l7SO7=ϡ~z&j"9Ff3A(h ܚ4K}i~i<[dZ8ZL-P kygຈmlyh*^/|3Xs"kĚC 7IÚ%%m,1ϵxXE# C N01ҾCP"p8iFjz͚ 5Jc{Jj}?@?6f p뤰;YT':ppzybFcI7xZZ+ow^BmxBP\wFzw>{pbGs֏ ŜC9VM(gU8@xuq?Nx;AEquO/Nj[9WuqN ?%wŗ̺75f/NLN>V 1,vb{%Ө;[|;xR>prWJU}s_DщTMNΨ@Ə7𣋼:~ŏZu[8}D|E Bm'|.85go-/(t"шZ:s_lخ|l6bsll29=قldsۮ"G'#$:D b]Pr\l.`w,$ ϓTVWUD̮r]9 ];A1B9 (hr4*Ѩf,Ry ZDd+G#r"pvrԾѿ+`ܵ^ Gk4:ıT-TiL\Yn0˰/2,3,31 $vcxKʭk7V괪*RRU~V.Q*Jc;R{;G: rq+YȦcn:JFd)ﱰG}VuWj폴~UeZr6f_T=/F|Tg"S8%S[K8]ͱlsrx[}嘽,Lr fÄce.vLg2=&> 1,wvi9Tk%?k5t2Y$Dq"nG9orj8`!E8\IL&1B(iK{SH9#3jJCq'_vDSyʝi%SK,'r %pj6iLU1݌2ьQfL4-0`tݙ2y_ d2LG_d}>'.zi{XdU˜jɌf*3Y&w/4i'p y-0ws_c=om)]Ɲo6t=&ezX✖.kߜ,ȔjٯZ͛ٗs J*fwa=V|En+x O=ūt?*%o^ΒVGaֺ"tQޓDY3%R=V =xz1{GN]a92k=c`~53tRLrH[(m $`H#Ϸ\_!9 }ue1ӿH4)$(~I$ =5XE_Z#_t ^}Wt,RT$k$S @:;I Th$9")Obp/ yvOL\Mb&&+#rrC|ǥĠp!ҮZjBVCq$Y!6BLaCSl aTdo1'"lgqHLݢէ9(Ji+"J_1uBp:ع DSbsMa}aܰnBywx fkf?T#VJ٭aH=Aa+\89JI_4)ҟMDZYXI׃(ORS_US[Ƕ\[U\=%=@vP5,O8"Y=%]6mzI0H_)K0l>.wR )ZL-vj5!/Cp'V54Xք,(z۩g C|D' z "9&5xZpT% -vz'57` BcㆎS}&Tyi0(:5 : HtRwKc)j<)^xrS긭Mz[95YiGcݲ9S OkI7e.5ӍB 2{2ceey(Kk]XXXv]`9DPEEE-}3Ѫ68ƨǚ&5UcըʹMG϶&iLL9l?qg~e}yG㣍 Y&FaV[O?r&4ݑ Cƹߢԩ~?pҪ 'Ki.g]l穋 LhݷS c)+C7`?vj $ur.{gxhV.37kznP7I7M;*D2f;y6U+6S|}.UXzM|;]jsrE5zH]< t9}" v@ Z3a,tS|=t}M>|\sOzO BVȆB}24FQG@Ǘ9 3ЕbA\Ru!u>}p?^0zݣ׽C.RC('~n>_~fb/%||% x6Otk?Sn)qG>H^WBԟqsϨسu8Mhl6uF*ާVc%>V2e e摍ϯilJfSQM49αͷhf x%{Z1p"ơ4-6o,P 4jИTmnq_x y-5+8{wn}W0zh%KdhVWfvWc!޽LӺR8MI~Tc&X[Us<1=/gjjb(Gˋla5øph?YCif5iJ_&U}M (Q]HѸ8C5 TFU[UWo<=ߏ2pFqZ#-93#gjҔ>(BlQmp֘~+~e_UC,dlְڪ!T~q >O ИUEktI"2¡9*PJ4@);҃i Vgd(͑TG9du(9YgdrF wAF2:)SB}ځγ$:P^ ,g3_Yٲ5(ۢt%ggWk̮*\Jp5fuoQeEຬ(]EgS WgђL1P%PR]e`Yr#+sY<9Sg<);S2Ez7+;y*sIaSDn[X,4&rP ^pV,o4 P!2WdU薡PE#Q4NE -ZE'̃>8dY]p9dj FEvǮ"!fRzD1j56$HӚ1ZsL5Gڴ4uSv~LLJ=}}}J,}Zҗe-=/kMي\!iZP[OhgJ(~ԏ0h.CM& lXoӈ`]~񣇋5顸{ ٽ ]k4N>brK $B])f:[`ki8`ogg~rr9H};¥{Ev$9P,z)YJ\BcU?t-=7L0cQq-)8ť|?ct$`]9sMxB@w~DŽ q` a2B5XXQfezE|^&WT_?xNDH x&@QGՠoLNי1]e ?>Ǐ?Y>c2D|oI9d 88>//w@<)3̤NL ?>ď0՝?{wgROyN9%x%cd5^ \{%e3)/&.lخlln应wEVوvϓ:^<@G!.b(?hDcy2ĶuDڄ]?Sm+_qх*?J&v%} (nC D:\fkmt*t3zs7]/Rk3ɰZ嶥jm\Lʤ"iʒUTZM8K[T`T}wj9ME$QnrvJˤ3i ƗR-gE)v8T:Lũ#5-u&0PiєA^MԬI_ ەO_ kx G҂Ҋ. `+ܔ9"T0k#Qi*7eyG(AuY`w]`e]]6xM0xD⠉hhԦ:M4=$ΤvI۴;^37{y{wiJ>SMM,)O"t]-)n~]6pDo}=׿%؃ M|!.oNP9M1#U3&_,UVSSE嶶i] u.XwzHb=xpgيlAS!|(^UEUY\QYM29m^a,-<ٗоY.e|9)-0pvӍ*-M0 &]*pĪ̑,Wi*-5,TRŮ:&5UW27j{/h*u]9rFc3e.KFB|P e;GYli*.RQyVe(S^&OF{f)ӡQ]FV>L+y>FG*^3T͑[|oF׸[SQ5SӨ|3kWo2|ەۯ4 \UZ FoTQ=Fh.& B }/P06Fk[yoHCY2uLRz` XmJ-ocB2)f(= &rkxȧPPɡf%*!BCC݊S\bo+6znڌ5]0Pp]W>mĤX6&*%p¹J[4,p(.ܨ0/6&|f,4b96Dx5ƌ@=|mA{D'Rb45AC[R@pDT#EMaP<0iBSBca<}P{{$7eh6ugrј?v6ʜMncښ 0mx9c8GXfH⽓1[s)V)m)nql( ".ɣ=åM$wc:<_O&(ӧ &⩒iX tSK(kRˆpp [eg%yt2'9drcN/8&s-[ֳji'7UjCm^0}ƛnr ]"W4y&걙ztG7B=V6,Ԣ\1ovaM]QD:Ro ig3tt:~͍[`+<(f"$#I̯e'{5N1bhof=Cc@~ Wad 0*r޸ΞqM&:$fϼɀ$`8dA>ؠAd,-=qB~#M][}wuO|ʯ~g ryAXzEa N n2.SY4yy]C4b9eh'{̻Ja,#tZ\S Z!}5}L>U3 xG;h^ms{V3]8 Je|INS4hTO[}?#ĞIybZg)W*7eƻjArz}}Fwㄧ ShGV4\ԭ~b&Tb n_}ث ѫmM-v,ϵ'`| />g,ƒ8B-^T*G_L|7{٢mӉ9:w [iɨ Fܤ`< W;k,ExNT2yg?fۈ_FtA7 Z9#9NZ֓I:Y' \9yv2È?#KBh&t0UjgBF5׏p6XfƱZtR'e]o;v.p8qNlp9&MNv-mvJWrT+[v h5[@QZXA\1&  !:'}<_+Qˌ7ъFQ4$M,c]OxaYEVW^eN{{J;Q>!ctM:^FݏNtf6R;Iha:fmMEQڣ^C")RQXs< uM!}*FjT tj"W5=dƲ7k 7,jpV7PE,+s_ܴb%4J{JCvJ.Ym\)Weʘ&+W*ߩd~ Z4ZHAD˜*@#S4hтk"6P:MAZ]ƴwRIJfa X<1ٔ0974dnҀ9~sb}kIieF˜, WrFAeY*]YzJ9ZM `<ĉeȖ" X-V(b ֣.:c 7(hߡ} Skj.7-}G\ܓ<:B %CYXc)O/;Qb-SOYrʫYVI6+^UrD~ǤZ[ث&xFNjr;^Ɋphƒ8$:j0.kQmRD5jq*P mZjj9Ffy|Bu|A5WU| qOPٍQ4` z=^0>Eü^P.Z\jvW]/ۧv5GT_?(g\.&9=A6Á23|43 #7964U_.4&ab1۸zsVW6t-ribAL"c+ǶDObIcN9TYPs46LJs;0c`w^@nwUf 5V1!a5&9f6Ԍn&لnތc+Z$_-xIL|1yyuz8c}:`?GgisyǼO& ٷ=0ۃ>9g4OdNi8)<|.[O+q8O^aX\ūÚRZ#u}g\"ӿ30+FHaW MqM7-:uqSu_qp?a`ә>^h^; s͌I.1_^ }C~w=ue|>r,!݆!ytVLX1K/W[e2c̦Z^ً@'~yy_=ͫ O8v?;NDZ%n;7zImvݺ6[E֪] Bҁ( ʠ*kT.T`@`m2sQS>w{y+8!hn X_8wp@7)82}]c^e?̫ <߅o79|NFb-COoc\#Wp9/|^u¹|<~KhM^#80lO1|e.c_ރw&2؉_3/^P'=C%i/pZO~|1!} "zARBLy,|>Osyx4c$csyy0;G{ W=hϳ<*wnY6e6NY]adZyN8K΋eT/·df }?edNaA{)tg"֣=Sqy9 ܯ3 OTnawj~ m+'`6@;vahO0y$>f{;{n2ŵSϰހa<_֜B~c7EE]TK2/{]B?J5hgI8Zrs̜y)u&x"<参 ǃ-hQt1"Eo2KnB>@6X=GRff}6CKc@ ZbKI~hyYE6VhF~ , )Y}E SQiT]ILkiS;,>Dj+>V%%9ib"6N&!ˀXf2ez˪cU99Ty:;QޣeJZV2V˼jQAE-\RrU!u9Eqs mohn0*]7t ԵTiU)CjꔴXSҤZnicƄYEyj0*h|JTgX>E:'?'n݅=XK۳|ý49u_;-s)(WaQV+TѦhEJgάkpnr*[yZsrW^Ru pSngN5pwoc-u@[>C )VB.1WljtU(r+RW:긂 ܫuonkUsJ5_#9ϫ}*s:Ļv|OA?tbqo&FxL y,j+P[ZޠMyu7)6U>(g9|'e="|Ruޜp-ĺV|e:^$Z"s~|KwF@܁rҪ d%"#O Y_%xQgTxSJ}NBw3h&hڡ{Pa\a.UF|rFBt-:'kt,=2GWY!D.5Wi8{ 5سly Cc~;7zUSr cN1d5OQy|Lj78SEc*hct U 9?ހڣس^V>7CPǵIrag+Z eʖʚpȒp1xiE%ɔL%I$1ILY( IpB'Po+h{$qh~\r߅uc H^"sʢ!Sʩ%) S jV `V4NaRRRl֝Iّ]hϠņd6h3//LH/S/9t5Hu i࿏{vw #A<0sqsc,1|rl7mF1f#+>i<>]s 4y%lH*P_rsN0+8t&ܧH()^b)e`.ɐц?:񣛜M'х.;q+\̎ ')'/aS} 0Pc7!g y?QK9d% .5u!}0klVO)^ތ6)`{`5=0XI}ƏuaK}u\kWr@nzcyGY<D^%-ći 5M7a&X90Os6&j>괟d+V곟Xl!y}K؟6Sϟ!s=U|R</hr뭁H(!0 XBqaC*5?+8plI>| L}_cuM;9`ͅ9x7Z}k -Ǹ(dD\,FN?#'')3q˥|xCulb3׻Z>W]yD&Qp$ U\0!Vs%q@ϓs ,QTg~)!ĻeW9IнFsƺFA}pC&k@&0@>r0u-ys% kTmr YWIe{1܏-S׀CX5ȵG6(пјwUH}r:~\eM! s?ΣijOZ{$U1#1J ]v[>_mpmu })'hЗQ* 8fy >=w$) t9Ïa|$љY;6f_)b 5خǶ&b{gbx O)5dt;mG/Q'*;)p XUf{&wѺИPn.NVjd ~E=Kx:\?דDtO`X >-`U2K@ +\gQQH/5?z ORz^Nl߰Ǩc~g;h$i= lYnl WP:l7`{'!n-n_%sŃtAzDwm_P|ت]>`tdJa7#u:IDQR-4,MGZYmbÛV͵ǐy,G1C,E'9 'F;5:KQL䥅L>f2H6lq~+93=\ssAOǾٚ5E^8BpDJ8jnV_D*P#ĸc7|8V3pw1wG6Q)pd‘GnRx੅M؟Ld4HR1è:g(UW^1DwAýxK#>6s13:mԐ`ˀ)𔰓U W\ᚈxC v~nw]Al}`hi`, Y̹ßI)&;U^U'4$yRTRLPij݊.V8uS_T(u)G9%RQ}_ι_m>@xV˻JP=Eՙa2åL2TYrEm5WEA{kߢ^y䶝UrF8Xʸ HjFJޗO4:Vđ,Y**U" 3X|WF\~96>>`b:ɧ20ہZyVJ)7WJI*vf*RRY N@9U*!k]S˵HNZe^+쮳9ALk=YHI;cQ5WR> BK?/M< *ϗ'';A׃AA?L pew^g;n ,$$\ T D8V^Bq2ZN6#2Ң >q;9oyn'[鐫%g+{ ]Il% e VP!d"Z "8<<]LpUq-@ԉhܛ_! c> ) $Uc֥ &Sa (Hw#)<as29C>q-JFP .~1͵~L{ MHi 4 -JG ll+@5c`*o >40E70cۭ +W/pdK,1!a7fF3bll m B&xjG؄;@ |Ro.<)> ?:cm9&'4^6O/3JκsܺbCg\o3@jiy\g6^g8 9@/}o1DNQCbwD&AzkN# -:Ncnx_`='d62!'#FW?r&eRR;㚅ͬU[uv ~&v6IZFPI`%XEnE㮥>yl>7 ,f=F5\3KTh\5!%>擤!s`1kD- ^[csX|0>CMlFY} s$A n–Z5d||X`5?b ߝ%b&&ϰo_`aM<~H{xy71RX:8{WYIň%@#_&A%WL$u8xWXQ>8B>0uUJ &^pKd|G|FwUV3]6KD_PLpoFp*%U~/N )ڇGNvSO8jQv85mjd+~Ɵ9D!Mǧ);Lr+jOU,U*.V_ZUbRmP*Q!y7^'q~&>@^gUcS;Y))*wzT0TjĞb{*tT(Ѭj-JsHOU/Zl( "Tr O%*HV^SSSI++5W0*7BMJ3:eK1VmlxRS4Na\ɑJb1ǐ' A9J@!%7=A9$e(ϗP&#}cr?8ʫ o6$,fwI6l~vIHBH&@J$ $AkJJJRZjŢXZdZQt:0VvږaV;0/۽=s}o |ӷCyd}Ke+{y3݌slP⼫`M2|ey*(!Uh+ت`>9ò7RN-F 6`|KZi |A|lr :)wr(4KP Q~RpBpNˆ/B׏܆hDѲ!|PNc%|a#hpJF0ߕKżbxLq㤸Sn~ǐ Ѐ (%]%\a8g|JHlHOq4di X65|ՆoexhE0W!Д./n{y೎Y|< scZlŏv0 :rEE0&u٦0k@3nIk%e o!ya|HsvB!'KA#KYd>`]*Y Ճr\tuL-1GlkKx_ o8I/9kA!h.\c 2ꄯMS~w9Xeqrđ&fNjn q/X6,ao=puV?&kyGC&g3dL(9!Qjgky?ۇG>-})wžk) !#6ko,c\ɊA(fC~yCv&ړ{OK߹F*JyW=烀% qe#3pH\΀j<9y{@&/|N:gT "bc|'ku4Jg-_-__߃?[mrrz{ҿrGQ -@t%"}v̨N|StLF8$P3\PEM/3y^b}"3ɹ#LV92l+C 3l3ԑ)eX%x?<>j* -tL4&qXsh^x_xONjqJtR{L(P&˽v+p^Z!3Ne8qY Squ sa(C2M~] {mf{Lm5:'`6?)=|w|_APŎL籟.Li$_y=Bz?"kzDZ}p_!B%}'] J)(<5kn tb#BŅ9!:NwpCȎn$|_)nSV"xfO*xlyxH%k7xxSspd;I쯇o9r8+[@ ԁ8cҕhC|E\ ;{Tn6ٻ4wX܍*ɝyyrgEo/(3?do%3$$`BH@ Ud(Q"EED TPM(Ȗ-Z""Kw;=4s3_sg}ߖ6(M@rAk&Ь%vJ^ ; KgM@ úZ|u9I<9v,}l+팸 O8:C_ mk<b$J.!)A-A&.~OHtOHZsѴѪŭ$NVi%M["VxsRܢx:{u>4 gqYBzt}N}.ѽzDEģgq%+ġ~ŢNy}d8/aKbrg*bNE} A3A "M~K[4[<~M [[%VA[rNj?]༆ey|1G@(h`*]S@K!M>b Gs)4 N_(| SC,u%7$ђ4ų `6уv-dwCaآļb1a11NSaȤϋ8!5F -ESw':{U!<\>y0?*>⎤3&C̙",\&57K?GӚ 4JC96g! -dSCI23!rm3A{Z"%I cfqdZ$-*<|6xcw!^"0Å("qkNƤ5!96"M%3 Cso#:24s4/%RLZJkM[f ESjhrٓ\gRGB %xPEWjI],-VI^T0 GFZќ^IRkA#VN,c.'ZϕW:g/\hL1$iRUkkuRG[bNڱEAΚh˺نTIn}Rɻ@S3$( 4[f\" h /\4DSGwΆ݊+yލCF3gDv6gTT a[>u 4UFhF4B@?hI| baX_MbHДt%0>BS .z$f*|ըj@8:FF0'YPyH`pΪ4M LH9:KFf T{|jHh#OS4puddq'qm*i~RRB7 ೛%ljۛ ZoIq@>CRM}jP~D&%x8(D笚|9*Ŭvh֘Ws/wQqN2,AI5F(SB)RV$M׍luxUyd./(ǘn+ДЬB݆j>׍l[ {T38؂r99<cBvOUdN[`(Y9y 6izEqe-Z^Yqխ暶U_WݱSv7٫w8vC 1r17~¤Snco5{gκos~?.x?.|EO<䩥˖xz3ϮZ k^Z~Ɨ7ymn߱s[{o{>Og𑯎~}Ϝ=ϟ.\JJ]IYWQdg+/RKrR+kAݔ7) nƃe-ʅpa2b"NLUV܉w+3~ܘP~< y G+K<Ô+/b:&e+8fMy[sP>| T}<:I?().^J>\ͻ%~__b2lvǣaO G8#1<\|LҚ7?3' t뮶;f[mq&O6}ƽfϝ7 [xe+.j[ȤƟILiLT*Nhj~o'G~83gϞ;w?]pƶehbm֮-;zW_5a3Жj꩛sӲZdOG. 54PCC 54PCC w +u%^]/9 _ԏ-~kwe ܝVOѢ'~HPG VFծ=Ͽ1}C&|ww>/n{y /Mٵzo.Ѳ{_d'װ6Rk]/޳̾{ttp|gvzffgi;fiNд2/AoF)TLP. E["-)'-߶g/yޘᎀN"_ )Uka"a>W~ՓĢ ?J!&vezzc-̣>$CkSFkCe\ )j3J-)|ʉ鱶d(;nZs>w'C71}fDmX4 >J6 3O;F.^hW}F]υ {E"f(/c`4Ѭ7h^!}w>85KhʙHAΡ 5.-F\ |{Gq{im=/ZtUppdK*8+#KD0 3{717@AoPa.57d0~DK2qH9yBCOmHLU1tՔl2"8dX2, K%e( %6@du= םj^A\ˎ*$,!2.KGv[=ZP $A=]+#zw^ XaF*bZ*SRD &^\4h`C͏@v~'x<\վAGi]8^@(WI<}IXA-edYÍDVa( e Zg:8QCHz,I^PQ%xI-02,탡2Ad jk.s[g-HQ$Xi}yJ&E|Ne2@ZFO AF# zw/؄9Ќވ0RYeJ($9J;C`M􂜇}0 P/69&Il됼:y*LYT~?W@lͥt%SYBfaC*Pn;ӑW_ހ}]M*'(bN)%O2qbzȅ)}^KgX $'mo N_u_q y[r=P"fUdVp=!:qܴ.K``ZJ s^9z5-&i *ઑ9u(I#ObDQHnƖP VKE2jVwn '\G&ǼԲ֠m0NJ\ɰuUH Z %4Y@" 5Pog1ip`䉳ߦq/g(MEɱZ,T"V $KM2TjA})Ï9;g/(Ė>Ȝf+Z5b m @92v~ jl̆q'G%7B#~̇ސt& n磛#XB. xю?S]1a^‹w>LpP8:H"Ol@H4@D E> Eߧh x 3{ =p~@bIb)y`o%65~) }OztGr( yĆ_ x {L|@mT+5s7*Nȁ3GR]xڅ)|9x^d\ \dyb.pPdmkkm_"8'q*)c{&B?P|5 b} \hXL35j1|%/h`?b!4У( ^@9 Sa6r%'Pb 5A(=)|FIS|F!'AcIn#V4jBkn    {C.bE aԞ=ag*"tVP*GhHLf)sUQ͚H%PY~5[y6V!zgkhpwK Ly}\DM3 3]WdkijJ$#d=U$yA 7B-P%P_6`ׅ3$}N+5AIL62U`#%yWFSE E[V\Ks2[nh`ԱCbx. 蕽|Đ Cű-NV>ߊ=jVy& ޠe=ή0ۯk@G *h\̰O^LA m(U42fwO'pxa0|YPa47(t%1 Ο;\0^vwvgԟg oB l;e^cdUd ~Pb07(PfG?^;87rzĸ~_|)8Wх5SSrITdC!b@ As/=s߼tD[gnyQAA$LN'3 c8FBm}.)u#=g4b4~noUc[Q8ܾMYBy͇fBHOI8t8:K |H{ȠLdT}ūoݸ\7gMӫ[e`Gp=ϡnO"@EdYs@\4yjlflV/o.=ٴBj:*n%}lNmg[SeJKn/T^P@QQQ@@PA/],YinmyRؽ e[ C>yE@)H;a#ssEfKϖ?,?_V3|~lEfAx8䌃XH?Br_({Jh3[^^0_S 3`p^AkOot7$磐dmÐА P+6/ f^ Gp1~)$T1|RB.玦TMI千.YoA777SCkCɦC ?#4wPO?Nfo0qZbNYLn~(VbOKmkZeu8MB ̵Օ??#Ȏ,kNDs:#:'>V\9Oe t̚R2'L掞8Nhmנ镡1!?mb#~GPqEfV 7UO$K.R+ \f9\cLu%uMXyߪoNhkGWF 5Ak(9ԁZ$)93Ε4RfiNb]6*MZInAӉW2v -&Ġڷ6[ɐiuL Ye>3YK@e6rU/"4 SjbMCb"5b@ ЇIUBEI*w:SW.+Է[i>9Eߔ+UKr7Se@p^*6A3x!Y!,!*y-vANEM&ACӫ cuԆnvC$e^X_/.bm[rĠyb g \)ƴ&} IQRVmz;hgyn46,bhغ ;WB;b>tv/KWt:A`jNmg\}CEŴ6NΠ7Ҥ}4E111t"/,1AaO\4FٴC AJd2ȫ(8Um3R HaD ذ$[n+A? cSΙ# ͺvUD֜Zא )F/ (F;Lj! 14! rfס^w Y]1|6QHUU5gvŹ=utsԚSͳe׈2xRKFEMP81H|ZOgk15zq4`.dzXFPV(3Kd&Z/asbP+KVPBCǎOf{~4]=|RPIZ-` I-|M)z圾ЂxME os?߻IocA?c)ґ]쉗RtIZbAe򺣕EHE)B)2*AW8/1/o\#=s#ǒF#l{t%/NՓ % ʸtM~aB4|}MG 5 qtl^44ra8((>' ) 1tIDIz*5+ @};XpgӻG\^km'#!_ٴ_DTXrR,-s4A8K`qqY9UX[~M6®QUA۠]tw \ԮUGg<6`**9{".#u 7 '8́j');m{k&h ho''DX_A!NW1K)LY[FfZy on: L㆚ P 5ۗ@+@t =  򆶤YIVS~AƷST*e\W;^^'cF:/n΢wt@s[\d FPEt$H3>eOsJ0)/(kh@>Ӭn fvhN1{vgo'h9_ >u6EgZcin=aqWu7h4(N~ ="l'h0SFI721MGMl/ 4d`B mȠ84'<@sgV;5Q9Ts ny*rQ(FRiھ,&#{ZsJgK|ݶ ۷6@|ftgB&'=TeH[H}U{˚6˪Zs}I/S9K 'h߷ T7P1!O I=;scPcƪQm%WY.(IM7ـ Ad|LgGX"nJVx+9J%rS}Zd5LV%THdHm/6%^AfdP] ={}-*[[|SJј*9(#6a"[R)|Υt9Xe*\of{{8 o@eЅ փos{Ȑ選 WtEi2ȕMe*icAfa<&ZOki*QCP:gZd`"/zn߽ʯ'mytSqф̚t^s4{g%wQ5g,lmEڬJgd୛ m?gz݋NP*|3I;! RʼnC䖸 5mkey_goMi#֊NǺ_ъbZԊ"( D K! ـ!!@BB "ua(nXQHU^h?ܿʪ@-g{~o٦w7iaݯ'^ EQG TŶȟEa|1Hx]&]-E"͚`^?eܡaWtLmqu~)~?;I _)OԄi_hY=A.3+ݑq+rnW-n/^2禰gl}'=$qϛ2 &[ftC2.L?+N z*)ShSes s )[+,vU%8Zyps T z;v6ُ.3 -#0l&. . x D ꞏ|ces`"5^Y{JOD'"E5 2p!8 ;Bq(7n\v{>F+,Uyv.5ก .I- vA@Dl 8p}!:.uXb,15(`ռbYȅK2Uw ׇ _ yC7^R@qX;o;w]8|6s\FV[a4,/Aqp[k=;\ȯ f+7@!H26:C6 pm mlO +8{q\ׁD!P@rԣag BdIR&ԉqMYϢhc.ƓޜG{a87Nso5dX݋`J#o;A ccx⇿LNHVvL7(I sU g F3LcL.SITS󞞹's1{.bXܛ|mz^y3-9D^鮒d L4T ~m8d{嘳@_h[;V'(PJIki)%F'\WTonuwWIeU'z+fjol/c}I6K m+ΨU\f\VnFeoÅwE*I> fh:֥ݰ˧֚.n&(袔[1Z(>D$h $k+{*%KGuS[o{d<5mq|셽qj??gpzB9(RF/oeV[׃ZE-M]5rI@.i' 7crk]:_Szp؜q봋sd 2hD Ӌ>n_ҧʫW\[1(\Ax*P& e~Qaўw5rr,` )||dh2e"1!>LpW1CBzmM><\( WyPzѿdf--hxomP1^R#g¦iIIVD6)/D~R:.rЬ,N/TV ֭],[y )u߃'/RBHoƒHgyQ0 L=ED] DI(>݂]+Q[W_M[f1-cC\ÿDa)5:6`5\,+E$Eq$rV7z߂jZ'W ۄpN%4X`E3@D߅P Y &( Nv /Wm FI,ˣF9"/*J~ ?*\8 'C| X d}51RL܁i k|s'Z8B$1"۳ uod8:;r8 8DC+D8CN@;)/S~l ZCDݑA5"*{&#Ud8p@>3Nl ]80 {`ma CVsX@s`5Qd3c&?.n)TC*בֿ$\)/]N`,w[V6/SW1F@g JUTC4$"1 Bg $*AHP ' `jAj9H7Z $d;A, Q^jC>Ak6*8$l(|g<59jCo_j@nEDioK 0W, `{kX3_*yBJZJEZ n $/F.D>y;5?5HoCIH>,1Sd{jC<, 5ujS h5<:y)!j@Ky4@ݿ4$ig>3MM$" q:y;*CaӇ,.1ҽ?'qL.\|Q&$NzEseP ONM%O_P@<Ґֻ<eh@!Z jYu&o5C\D.S^GU$ͅ`̐n1p=pS4\:9x Va dYˤ~Hg/$'gK'd= iN%  t=n?e5&S74!i HCJ9@ї^\Z;=z4C1O#  FI%>aOЀ~9x,&i<4А͛lG68`H7K ANmtaLs)JM .QY2%Y")u"bb] q@$8@50Eڿp0̿bv9YCu7DrV,8dI6Rr<ĵH i(ߴ׵G>trÛV/,أO[C0L]5SI =V E fKt XBkUSjOL~Wi_  鶅 ^9k>=퓝#ۃ͑QqMzZA(VIXb>Yʙ,uIr- RzV]@j 齒B  dW'`#g! ƽßo=UǹC;|{Z#"[o]MIjRE6') oTWV(VX5֝U[++h(\Aj|l>m/^7K>fs,ؐ'NYJdXʮK2_f dzh F<ߪ!zA=P4;A%r@Esw]kЙM? _p8#,-:Ym+jQYņ<YK G*hez}I$jp^ixo\GJ9t[ivO=W{y{7QUmqYd ݄/4s b)R$Ī8V}pKsةUu_8t#Tov4t_6=sꋈw]`X ҊsdU4a|S$$E#LMaT! LdN)σM~kcK_o}vvo8~,x8<⻘4j̒byƉ_Qy"Z4`k0mz{i4hހ&ǁqܵy 9O"n|@F 'gR> ݈6fYM\d2m&Vp73pp,gnWA8vx2 5c/OOp>D962{"1>m"S^gEYQϸĐ҂z6 v|yl@jGr j`bCo+ o=]ޜ3a93WgC3"㧉1ԸkxI!/)uJTm sE7Cj?EXzf?l^s_);xًٳa'fKf`cfKʂCgy!$a~W}g{e(]P;# Q7la 8 ` xd( yBr&gp>;~`}juBR*G!lu))6<קix A;/CԞPH i =H1= bPz\Qoh<-^(eCUM-tZ':-"%,X8.λی-~^m灴\qJw֮ udO~q|2PG( ac>lpNj2[Ke8vGEłH "BH)$${$!H*(( X.l"(3{9W?p.Y{ofF0  dI_}Rb/Wx"xW]^ <|s8ųq0}<Nb8!cVx} sw[1,b]YHLelQuҬ`ZdY-IVQ¾guDY@< ␋ _x:[9pˣ!5Q-e'& 㤞-Pq$FDk (ܚ|9ǻenkxn1P` #|"Ȝ̍j]w{MEP[[L('1F:רgKjJPER+.4ŧ3M7XN-Kt_>kDP"ga^c'qqK>˞^=SgsוGŜ*OJ)*JS9ZSXW tr]:ǐ,ȣ+*,4EC>UuӜХKe"[q`/j, IgMk@76޺|tŦڳ1ѥ|~y M_La˭LQM-àͥXL$}9UBȹn&;t$=)Y-0epD`anj{9vZkgoaGQudC g Kv1[F?!RS zdH4%Y0aN8u)H d9s8g[lIJŴ\u]I㚚G1hhjsP+]ռ\}xn~sDr^G{TAPI =uz:ڬ5>tk7ͤ﾿Mw/uk zDs7u#)y)6YAO9e;푷T֦P{@!AsSwZCRs?U O>Rj&;ߨ}M[Z W}^w76MUK,Ց=8RAbyڰsԁ ֫ uTqZV"D '(`A^Iyݿ20duQ琯UPi5w["I^OjfƵ<ɕawY9*y;zZ]k^wiT4لݔxwg 10dBVI۰ՙ!NG|=xa~iw\p7?W^)m,m*m+w4XƋk/ TA!GRpn .q.qst [q3c>VFn=RRL{ e"Ibw:C/8hΎCBh$3b\PpJx֮CyK2c~q_vdrT]ptInpF"Sp},t66!l"FMl I3Ff9X!AN{H+RƍQgSwy*wlHڨTG= cpsdX"[,G\Z $7@t6KH  /im>MKH{ D6XkBA2]d (["O@~bN{@i 9Z F?x9 ϙaciǩF9ݬۜ{1HH@?!i&Ri#g.Qi@sTnBz!<y1v> eJ ?Vެ#Cbhw/e=(}z,~L:%|HŞcdA@6}GnRŨj=˱j*n_J2}rcf32gҎ$MIٔqQ~7לG᜻ vfOJsE]Y*HPkPNaZnZweCI~T~%c"7*kHNT$Kc͢ܧEw/ sGxvt'g@B6$7ϰ&5oT#G:i)E edY$oH I؄ld)C@+R>}/Ç}07H^0iƟRO~O$,i,K0)QN|BȢ`z@`/A\$%%!5v_k]7t 7S.lt9nF f=ް|܁Uǵ6[B!ڐgiznT[$߮sڀxoʐ8dgWos0f3iApZ@bڢ2Mq? n.a~DX+"UP"  "d&R0 w~T]sJ/)h k0x QbF-&*E jQQDkbaĠ8?R9Q_3kỵ@` ܦO_,Z_t86]aɿe?'#a dëcV[L:@?}b#o$`W{tC 5@; A_m@%kPkj~CfeK0Jjn@A((g4:I?|kXyI*x $%Rtfffjj3Q*jS$bZ\uab'q(\} Tp0zSs( 2A hBmT‰c4탱!]~^qD:M"!#M)Xag 'd&n,D#fa$ȋO0K!G>(B'j m6s+.\I9MD*9N` (7Fx!$fツ^kx]2_K,GT-t@B{,Z_]$$)\Na8XE= Ìzs\2Nl숄^ RMVBd2ePڠfV*`u}P\\cB瀿<JmQۂG0ց~Ӯ^ 7Pz<^O$B°7yi}>Ǎhc'|>B':8<WC𼍑2M0,؀oNXׂ䣮wvkʯHH|;čWH^: 4.x#=h1, -GrR:" `q'yq~=@$= &j ڐw c!o& 0 *r4bY0CFsȒ,!M[t=NU")3`^D`0~폰I𱤡-YI|89H  #D:#eDN3afȎ\E%+!_q U7xw܎ pN}!kH) 'c-uh&W؁/1("B`p7Yke9⬐˶ETxATGLroc?KO~K RSS?{i_c#:u6ᐼJUOjoSp 'r"Rqΐ1QcM#)'XkF/%mG{~B?d%KD.vX5u3Qi`slBE|q볢0H+z/}+K~?Hcd`v1n O ?dh=L-6kq=NƙLR =d,GJfb *`[%ƶh>U^ذl`=>ԧٝG0odg>$nޤ__I\2s)pFn[lu4v?5c5vwֿ) eG+ ˙%5oDH:2p~LξɹΧ}⏰O2t.KC 7:y, TzdVG[ʾ=VF9Pis.)/w"`8P#~}bC:1J"n!ȼ=sSbMsF])c~.SPnrn(W%3ʓNcžQ[=T ,BBxyɮht9e5hp[Ԋ˓vja̭J9jsT}vi.|oC#$ $Z-2dAC.q {'i:&C D,{ږ^;2(+r9gJ' *GUYs[eu-FvAdOse}N6V i(&A;}_;c䰛p­s}Vt*YIVe˯W6嵨~[TsdJiujejekq@H[2YD,%!iAGF>s:jh[i~W#J:Q#gW *J-eٍŻuMj*UgirYB?uz@Q5B];sk:~#-4)A)ԡZ I - *HDP]@?8*~Yѳ}ssv: {:,{~'ŠBzjZƲԨ3Y i%9ՙp0W(D/D)췉y@4!M?a{g6-—KR.~4qj4h^\:Y]_^+(.*N/gd)ŒԌ 񟱒@:mCySn~c+ǯx`q_8ΩZAd ёTaiEfFajŜ¾#E%/KiG $I;8-88 w>\/~e*=3rpAO^?۶[cK8!)LjFU^Y\Q/?//[>_LRZbٛ$8I rԫ⾶oý؁{q{nvh䘽a^k]gOhV0qјĴdL^M0TWõO5Ki1oxU+q@6\ET)I}QFˣ{*޴!ݖ17ݶa LjiO$3#>$Mr,ȅ#q)MU2Cu6d7m\N ;~8I jTDfl\o,jo: {FeDzS{“zbb#; WקjHjNgv̦_ -8$t%diF4;$ݑFSh䑎T^Ŗ86_p& ׄ!q쁊D$߁!c`V761=/{5JqP)^^ >;JBf6gdtmB᱃F՜ACƾ,ǴPOhޝ6wBš(whñԉ9mb%~cPI _}8-ۤН /οQKrk{5.T@%uR=w1щXE_R^K>KC  />/iE%FoZgaAլ HkU-ɫV"WCW9FHՔ}B[Z~Z/9})gOrveNfB82GuRbE| |#5lYwT [`wi} ӣ}x={ɏPcMeC0cy^a[I2ކP_B-7:=P&\hΆ80dWPh' bpN't/}hc{6m@]Ĭ Pet7||ϔA$י T%OX黀 >]4.#`\5ƨ LϬ_9,P-R ,?S@5"Ib-adVp'EBP>0O]QM^[TzYuQ Ɛ9! I@ @ A@(rUZPE *Ȱw;9]笇ظm@X  o#D/a$vla,|Fk~`We0;xۜ{!p .B i+n hkrx[6?nǹQ'q-[FÖ8āh=н0 D Aq1O#pR=%~h@m 3@ ҵ@ez$|e[IZ؀ ws*CfV=zG%v?&W0e 'waO"w6x   Æ9~/ϸnl$;C8 T7[ځ|فa䮛 N!F#{4i!U!_ٔ>oA%W;9-<þ$`ODfhl<%n|TQG wN::>r~u.;Zm`.W&<AȍԄȾD WA3JJb;D|IoKIyO%^H>J3*dS 4p1:?,y7s_pyP]yW~1GyFԦЎ'XGE6&Z,L( %;Hi{I 2+F,,jB&)&:Wn*J.eW{;i4IsȒVIT|(0g-$Z.UíN2 +TU% iC nE>rSiT՟՟y z ߾C7u27ۉZ5/[|ٲ[WjʙN[QM*jmU-M-4cR<7U>42_%di> 35LA,ty ]lrxOgwۂ_;CO=aCAֶ2fS<֨ThX%B}0/D=$קUR:U)?RHӎ1thkuNf Gkf,ںkyWS:s-=hUR*S Y#0deIU+*"0$N%ǘq5@:g6t1f_Km^t?p#`:Ἇ7R4ԲwVW e2raS(hr+F$!ˬeHP<3Sd[X15@ cbfq݉Ř]K0W4t QrFVXY_aS֗pRK qb\&-O+=JA]4_8̗-,qi O 1P= f<^g̍Kv;φڻp;iSe #ݓ &ZJMr`:/2O_^*yNi7.*a^\W<ƴH=;af~.al̽s0W0}Wu8/l_t.ҳWl:j~IH (" ;BBB@aG(Vԩ#.uSw ,0EtVEq=c= bʎϼO;~|}&GPnj'Stbo~~́ՍҴ껥? eUe>6 S9Gg?2ɃVEnvK7rk. ^H /usYq[[ 7sbMFuaӶ9Y k U E┆u9&NMit tIn3 .3^9w^SǰVՑ|פЭ37\X%XQ"L{~:ܮԶʬymdmC9TeZl7$chI-if+ qA3$MScCV{n Э _}#/zq|εIJŕJ*-A#HOr{kJxBrA7좑ܭt~NZn2I##rߒwYQ۞7<{7ú{cDfknNS2KR2LH.$ &zrX艨Y? ^3C \?bY>.Ԉd#hݐky˰qooLlqd)jMlp-:2{-O)zIH8!J"HX>--t?x9g#0 ee:@i5 !e96̰p\C8hՇ" `Bʂ,R)!*Bk ;[s@\#/x7){4<ǃ.fqq!cBE1"ST,B%W"=U^jo3v+w)n࿡) >HmW] f{!և‡ T\cMPCj1de6C!>Q DHhu«)5QʤL @JM 0&jTًH CָCR6 Qrq Zلq鄏 gLQغ|AACil2} fl)2HϠY_8!e+<8!vCKľāIp1\h"G$wH .HpAFڕLw8(XahS-^dltf',rw6&`FQ4%oL::!Az;׈Qɛo%Cҹ7I{-GK+ D4ȝP*yLa5%B )mP1oL6Ɍ=~iޫԆ&7rGQo1IyCWW/dclLg)BCBt!ő,:飔c}!"LPƷ:̚ bϱMr6s_`kcN8MEqkKXܪ {H @ b B-@"D@D A^VPֶ^u9ߞ3)1 ŌyD>$ < {>p}n0 1[ n32+$lw} b:XۯQ)GrU`0kq&LxO1 Q f#@O[ a#O}kTm=0}ډ}Bs"oףX}i$6hWѰV0+ s~1e9XET2K^Q9A~E6fLH@S@V@((C^ d!o5n3aNKǪ]/wt9Q~uqXN-Q1J8yj^NB03S?*?g$z$ {s羚P?llew]O~{GI=VkȉŔe&tir:NfQJ8Y9$Q)JRR^y:m<$HX|\^ԯsv-K.O}ݪ7rV>VANfgf1ӋjMOu^>/NPJԷ$?IIeH\@\k X :G.5yionm>KWg(Kɔ4m#%']ƍϬfeiƴ$:7<-3.#%. lb$ zob0[=\&:7;/j;E W_v_U*є(ɧB-K^P•Djω^H;)ʋ*|˔|J d!"5.7 Eg0ri={vUM-ݥ :gv_cĄr5-4uFxb$[ܒ\A?LQGl2d=iv'poٻǩѕ7w6qiD>YP|]>&Q*4qw mWB-PA;?k#t{h5h0phUw-uHy^/;,1 0idҌ,M6ɍOhoht#$1a-0 pF;0r]m3`fouw)^lw+{/J#E] J ˒B'Q:*(v#-3>xJ÷!a m̝`,߷A ,hrO-i~%s0ɇv9-t9(ax@!p`9 l ,n0aߌ@v;(ݎ[G%];1MοÞOʾ\O%(wƁfdlfGmrޟ~n^BL Ѿ"4 I\dLCpfbc!Ń5RlOh0P¡Ej9Nh8b#MN dBBgCbrDd9CVI;hdFo۸O@p꣞Sc>k ᳐ENCP@^ᆌE gސlB@|<:S!RԨ/Do/G [1|l hf;U:A=*$(j='os背f2N/d~~C]'^OaM)^Rq|m$ y$ rȋACrr]3CS2.TkyP~@ȏA~6dJ |:a9z[ gBƄ>c8i80 :W=79>Эvc4ۂnAPAu,lȗQ!ѿ)^E*T'* d#d@G4LX( aaV4D{%1K튢|O"Ə y%~Gnwv?DsdڐT ߳`F5}E=z&L`dcn= '\y0.+\2lZb憘gMOsN=ɪHӞK{*+y&O\0TAtLi/vNuĂ 7ucżsJ>?.\0s}Իym9,?-wnog\?]}oD|$u0R.`ḁS.͇=/4[/[$ɿ(*n)1wƎKNw=Rv<[7E)F$z".5,uL,Xo?T48˽oҐ17*fhiHRRF酒EMs-3ԧL4'L]c;ڣϳۋd)!J~r}EoY{|_y"űo2ksk._YB|Ʋ.͊c/5K9P|>wϹ%VKedZ n)J{4/#/x b}_`߻FܽR|~vLU k,WTy|zߜ_);qc2i@Vs%dM}Q ỐOsڷYVp3? c> tF}i1\Ci`mrkU{*7iw<狹_(o3~n9h2m:oHXնf>L0?I8XUA)ғ`B(@( =jjA@P((2눸zQ 3{f{vV|>_NnMF*1&8xxot~ |NΌъXQuTY٭9.}|gWF>UVJoO&51/'&Tſ NZ 4D#/C +2TVkrRtLgve뢰%1Gz ;ryr)R~1)ܿ>YƪLa&KEļus->TC"{٘p#W7 ưnA:hO6zIw VzE':J 9U*%IeQ'Nt=h/L@ TP![ ד`]tk 5]Rwҗ]&ok7BۣKq-IM79'LWBȎr0yL, M1e?0Y~rD#CdUV&z 0_@]=hxVr⸁pD0`ƿޟ:esZdjJh*dAC1b)VO(P T{kn~x{oeFgC5='ݼios~)wC,D쏹k5t$9ǐ27zO17ml36E|blՋ6<ȕ~WNP0- 7HP5 #(^C}lgIqLO΅sd?8{ &`V`ǘ9f32g̠촋#:JR%n+Wq gC5(~/r!Z Ɯ% Ygf~,"/|&x6dtmGUnߣCnO6p`sY9P@ -HUY.B )RyLR7*71[hP),SOeNwen6sew,~^p\O;Cde.|-{2!aYb3V]5+ꊟJJMWӌV}(>o;6kb6ە/a+~*p<@k:> }Kې_|4kC:(r:k!T 5C^pZ>}w <H[_Hh \~:L:IvMQ" ְD{P9Jڍrw2Iu|u &9+m8)@ g)kHE vȜ` dYTg;Av5&@ $$$6!)67,E*n8RA[EQ}k=ťӊ֭Uq3_ۙx;}srpig0 bɴA$ ZH2E1ʴ JYec'6PT9I~(“Fp [83ؘǰ' hiCzu%icj&v&ON߃Ѓ`O B Jπ_.xzǂ6p0~b8A\4uxg3O>C|x\-,@0Yï {M;H3)W;=%wÔ/x0ȃ&|<BDd,H:τ(} Xict `)$- ?&^[?i >Th\H>D,Ku YB !2m@½V"i EknٓWB_ a5W\R'H#ݘZXc!F](#,2S֛CMʆd͔dpG#vg&W߉ڏux!px.S lH`8G!ք`gr{Qv4bgمD)t01&*4 _c3fE;v7{^u~%;4 sI\I>{7s~c RV$4ePͦ24/-!E: >&ϵ|3So"j9O=w60G=/xߑI;vM +3 " D[1`@*&D3>+ߤL_$~YT|?V.z}nFs#b{=bwUywTyߥ7 Q> v&c a$7d;3,!7#ўL {g c NrPչ_Khȹ&v߅s1D <}Ip"Xˀ|ăs8Ép'4!ιtl2K %dsobɬ&?$3K/w)|*Ι=G :Pui㟈&LUw( dx CIb̀B R2 {ƛ*WI5GneЌeJѢDNgN˽^w05@rp_Rhhɠj_Ȁ꘴?lww}Kop[b RH~6[EFVQlƯuNV+.Y*Ns:v(@Vo?,r=K%};;RC ˷W$VCؓ/M7&wУ(#Qo̩G06NX'3ZȖgaks&%C>_GT䷫I+&gRVRZfQ6Qmv>-"{9צU[^RZ*^Q$ސA.o9wGxS#VD5\j}\?!m,DPSO;!oQzR[3~:7SohUڻR]\Qo+/]%n.Y'k*ڤXUMYW0Y?U)aDm{gՕAP_&(TLf ֦CnAlMU``|wBmMIs2<;?n-_[])XSY/^],+oWԖ~*鏬(9]VrUQ.rՕT iiQohjl5M]_M:4:-p|澮PѶ[ZT.kzlXпBаliTW}" aQ(B @ؑm( ""PYdY(.Pjg: eLm 8ȢTEgǙ3=a>~s>9 f?pfތ=!)B3&w7- :íT~!3߷1 ײ~\btqjZVWdx,ҬdX]~at,^}w:}.();x{Br%FeTA:']Hx uE:L8˄M|j2$Z%&EŹ;itK+L)(I:Z㟘75Qp|o~ ^BD&}8jh]π4\nAG byэWYoP|&lmM95qU;U.puVnTUy?? Ȫ=us5:r%j8Of.-lHa^ttz/Z}=b]Zfx;:ƞ͒G7(=:}B|BF:V7^:{Nx ۵W:?yO^T]ׇ^+F5);ǃѽAOﺴ4BU\ۘ{>ȔF|rT^^v=#}#mJ6'jEVH]r.;hHO2pl`)0?cWh-KjэcǚD&Zn*Q<ZG+2[2Q)'9O7^F;uߑ.> g_E̟d+?Āp{߆L3Npp~; ơwRL2,X kmb>E !T*ք!>8^LI dlT,q*+N׶>~LW4Ӡg3s'|:Dx ꩻDa5`ɿ | 9_!(_SW"y֑vuc8aڽ԰ye=c\\tm2YYpO'-Ba -|P&(Pq%HђBW=iS `0F3 1 Ә+{XY2kY|uS}.?@O߁2``Aa1 5ZJTP+Л^4#h=up,08XKt0Ļ75LU$;x #0%uj}4d/K P@領F)AWJ) (t" EƖF;R!"Jh`8 AhyǵHsq'*.Zoω7r,ɖXD8(g?E,A660G \ QJ\ Y ?ψrȝ/j:YB(>\E t1QB0MƲ!Y 2Pͺ(EQK  gijoĨ;Kx,(RfBҜ0SD% ݂5%;_t߸Jkx(i ZG>b9 z }z3u< qz:O|I~`V=.h` t "7N qQTM ֆAH$2Ī3l5c ~[>`-c5Sfe~ #Ɯ}Ƃٽ0L ƋBF cJjH}1@LIJQz#r߇zVM#ٳn'AF}xiۻnzЂL0X^W6!``U8ߐ4|3-5.!q1GQkG,7[޸VNa{rq&ՐΗB;_ y4F=֧#TX`̿6M{W"H7Bd]沼5_g.}ʎ{fk"f|*k1vWZ~޴b.#" t?>1>Kg O.Qx,ŀ^ `38xػ,|+>Iq峜DKm%8opks=}2f43J>}pIʡ^~3G 4@x{;W`2@_VBkcڝ"_!y٦9zw,;XýȒp>IdY>--\!BEݔjoBWhN(Ԏ}ɂWD+,z*2m;xBa"4hR~/C);ªțfc?]Y뫊]H[@Y1BVaߩSGB:j&Z4y/#g$H;$l61ױTW*H}$B%M\(/,  nOY xb - ;jp:DUʎ0\2ckY,ͦ&k\wUEbʊ:NFQ _r*(VP~[|(B0l5g>BU t]*щvutc%߱0Nkc,l 3R5n^Ԑb]P+uHd|&3e#30x=KvHU=ki;m_0ptn.>'n_Zm}W >H[4@oƤ`GLspf=1Ze_LRs`6`a(nQ5 S≧3JӾU_zk;qolcoLc9a91 5Rk)C:{PWX\(nvW(hdDzs۵ DQ:8[3.O08ĵ}ݶYٶG1ֻ5]ƶD\"ΡKԼ */= 0"&\Bֿl!:!#ԑufq:': ;#;?:kXݵ&$F$W1h|~z=}?o ? n3b{˜XFӛ̊VsBs>)Kk ~ׅBy yV?Kv@x D? ŸBG0qLd0%c Mc{ xgu7v+{u+}u+5=Jp_A_F9\w`l7@0JttTa4F1ned8 Y8ebIeI 52@cs'?25P@` EԔP\ 1l1 l01LF>ba c/jwJRC,Hn!? ۞ Fz0EYf&\TOal>/r* QD('UDE 尢O\\DEM‘0Qn ƹ28_BX=&P3] ^"°O,ߨ[VeJڤRUUG/Tgs⧪+⇪n}Sq[{jVwF@/eP`}\zi=u\Ԏc|p<˘VtS~U*~QV@do}v5uv^ͥQw5y2FkHWɕ(az{tGh.R0#3{g$inuD;nݽov%n=N7coK;bOIǶˮޖ]}$7,5ƣSk<:OQ0-mH8&dհd'dXdYIgs3e]~õ5NW7HL4\rA׭][ަm}^9U3~fݐ{S"6&d2:HF'6Q7t̓МЏyTbq~t[]͙i{iBέ\ΥM8t2&}w }q_GRJEϺ4tKGtfmL,+zwNq›NxLdnG*/ZN~I[rMuG5}qIK@H &8L 5! !!!!B-D~AumγMzvqwHEӒԫ|90;Fzx5OռQQBgC$kP|sKMgZ;> .=w$e@q<;eb6tU!ŕFQ=.Q#U"tGr VMN|D#2fo+) ;i Dokvpa2v\wz`Ys:P{qG2ˬ6d:jH343!ZHXi\:!$;D0ut [8?^|WA ,9ai;d8У;^rji&F7Q*Vnbh CJdCJ,K!).R.>H_RT DQ!5({AW ie$J/wwT_ZIqae-Q(w=|kXhރk}twKfWvzoUk%ƜyTe7J| EueTam5-hftd֌W RUXz͏eDL5;hϴ`!@[+{} =L۪_9w^ܧHr+pڸVwU9)ECi6P3&4l6m&lbB&fBdk*=[\?ޅ;`@Iv0ʾd Qџ\ЛvPԓ}-bnV>h0-<m[8u{\r#?Z_#3M/eȐoewbp8S-]YrNaUy,"_M|Tn'UԐZ\&MaSހׯT%=Oz{U z9gIO1!E41C׹z. 銌hԯ-iw {=k aC,CRDNئC GgsYg./1n_nߘ7wϰ_d8"i46[4X\3 嵄 37%_A_Kn^ ,Pisc ?\tN B`hDSEoOu̐&ʡ'Q <8H1&FTȅ?G|&^(=7ت.Dv6ltvS!{lOVԆ!aP~`*ZpӄqtL:R͠LCsR|I_EuqӱKO,^&?u-Ñ#w6N/►*♺2jw*vEME4ʈhn ҵПƃL##Z3p'S2'2h,ď,Q ;mu\ݾU{w+igaˣlnsۧ.eqhWD>F` }lAmstl6+Bx4sPzd\yʔŊBb5e Λ]7w+w_cmBKI"KPUvv4 t6L睦y 4,B8,pc7p5aFrD{̔=jG|Ŗ6\!uSiz)kN>*ve]]q+aMLtX*b72):F[\B:qP?Y@PU/bT5?F37Y-gUYv|cVtbYk25X2}ۗiӾ-K;4NҌA^6 ru КHW?w|98/#j8g> ̛fM -KeKr?R|Z9E5.fo/u$F!@n ulJA@5@XYE#.`ǕxZjUlF2Z;sng.8g>>&u)1$~*2~DV]Y$ILLl'bopk9@[D;W"!+H^૫QMmG3ӭ>ȵNSWV:u.NLVH:.K$Mr"91{b7P.8EC`{Jm]inTśXVʳmʲ Y咢LSAFK^z:kQ;kiZ2E]\PUnZV=CU`QJV$YNM.)g')v'(ds8]ٚ&3&I*`Fga4g˨}=@W:Eh[+WV(DVerی"ԒJiRqì5Mh'*o{DZŜb'z|K}jpvAH{WALl:O^UniCan4:vfGɖiY6I55تzv++ETuQOL9DU0DR/i?WS (l BԶ۠rW~>oJn 㬖u1D mRUjNaug}Pw5n|5s U3b7%zCt}v=7[*J5PjfqoEz S:BL6ψiO3ڐk6V*Y%]ֺ)s`7΁-Zòf&^l'~L !߽PvK'^e.=ӑ#Af'OЏl=R-4+Y֕mYdbkufM_Osak%[F~mf6zP(hU((H"y5E/_fm7A|oi=3zj,}{=e{g7>=f>yz LwoTy#@D rH!O.tȇe|5r B<x GhfL{'0yZLA8 c< 0~2$o򏁜a?O@ ([@z0F=7 30z ׋z9cQ- 7ö́1j5򄌑+)?W $ïK(ۍ(0` 2=1 |f Lmo08qLJ8 ƍcGg 0H YG6.Yc \#'+q/?dyTSWK¾!/@ Z*,ʾHHXHԸТH݊8mک^ENw~NrOqw¢)^/  (sHlCbh&M+_<8 xKKO>Nctf' HbGZdAC6=|Gwѽ8}|K ߑgcr?F>c!SMA\=iOcOg#SbH5|'.5ԘhZz%BSE]3QDtNSEWG-M1fO u?Ax}aM>nۓO_)S 'zRC*}I d+d[}̛~?$'7$XW<.z60s;pv*f} τp-#k ,IK`btV`Y.u˻w52U&߽6]n|ѳMF<My~ozIfC޿Ʀ76# uAmaG-Ƽ\%9Zy%FjM0=favo}OOՀ)c>׬>~?l\Ȃ aha߿~}'l_o0'([wb+`50@hlFcYqeF9 3Йۨ(֑p< u}ֽK{BVq nn +ogY^G-=w[k [?0^c_t-ě:aqFbe=-V؝՜vkNo o ;(61'ǖg;͑ض9 |/=7SЅt>LCO!l/5ѓc;)մ31bGں=[n[cm\L_&X'\=* 1X쁭! Bwa0$Ѱѡ 1&T,ҨCh-Mfٚޔ$7'V94%6ҫVovZC,nKCQQ}܈.K]¸c]"0Iiq' AR :Jm(sEM^ğPи(Ƭ Ų\+gjm܂RZW#P BUQVW-Su=#*x!Tc' jLޤQ8' •\CT" RFM.P1ha, 1(6-+Id,[MQPSV ;ej׻ȵe=nRq4ew8],X\iP%28Z2 XNvQSK髂X& Z:\Q-Qg9(J+FuiWIcn(YS üIwypB{a4ܷ 7XFU7(oE42 ÍbrkS,klEV^UM*U.$W4 *\ݢXq04k 3!A4(ADqpU֩Z X+ 8"8KlUk+.D:Zu}_Z?y}r?hL1+'Ǫ$;LZ_3~Pec^:A?iÖ8g~&h;+Wƒ&^I>7AR9{u{d*`}¬4=f15x/j\jѫ;|v G X{EwϔLmn5l%$ ݓWoy?8lJeҁ529ega:__qڋQq=C89NwnXۚ]2xuj8QgCls4و<+al܃?/ b, q=0DGcr504/"~}Ts{re.r,EvܜGD7H}zQI;q-ri9Ѩ }>mmV ⭱5^tWtSYNq̟Xbߢm_6*m管;k莿+gs' v}8 .B- 8Dz 6PF mmmmdۤ}hͮnyc!xP:շn+9 d ;H΢l@@ѺEA0[TV%=вdKƠE4++JDuIU>%Kſ+ T Oﳿ&3{_3[_ wE .R.uCKɗ"ۡrAWY E ] ombj/e?fSXhf? rh^U?mwfpػ>pbP٭P؋!vI/3xG@S` j'hjXU5@¨#񥤆5kxk ^]zB/Hf,d~Љٖ@ˀO&|P:t^;5o @] a\:$dwXNR]% RJ:RpUu~ߜ%Hx]/dϦ{̽j  ~O9^D.Ue纍.O<Otc BTPwk`w%襸MtlVGKf#d<3#Kwa5,Ն!]jr}va2v7Ἰ}[S-.R\ @!nӀ\!Cu~a/ZlEY`<7"{n\$n q͸Ah?J ŀ2EyuŹf)4S6b*B:Ul| 2ۚ0#Zŭ i4UT$wT9Si̴MuڴWUb*PԅJc~ 2W :b\Qq}nE%󖱢2YQjPYl:e^Ɯ>iNs8ar8.hvO820|aj|tmGF8BF"xY;ը&(n1PO|3Bq"zQt8/ǃKGhE2 jĪ}Eb'{c\jl!B!$K@$6Ibر@ 8X$vl'Y&vL=i&I:Mm433{y9^+b?uUL$L(8/~?b.JϤTJ> FI l |,Ki #ޖ_LAYB e"dDG_ŞvQe sIZOKB/yȝK3kFJ0}n3уL̽{T rr\9fW 9eAEEr5 z!֜l=+;ŝ\2S cr0GY)kXP!JEBlkdJ'+RRD.0ԓy 5LG aŸ!5Gh@h@DЯ$a!0\̜/񨻍(#AḦ́˚Ͱy 4iӥr)uQL6WlzUDQs=\,+ {xw/\: >ulG<>G~=<𞍄r98 z1iVIEזƌ3SZPJ\ܞx"Y|RԡZoS'&>Ij){|K !hQ< 0*A-3^d hE0cJ!MaCqG_NU{ʭ.m#۩mv4BfFԤYJh,]KNi~ɦ[ڴ߮%'7؇1wޭF0w]żcX[kN&U&VfG TF4nQZjFn5r]FЮk/'Yt6~5F_EO,g50_.|\}DW1y+Zu/iВFEtUG9*=QYhc5T4sm&Z@bbR&# ӏJ!@xU&K>zpu6͘79o=Flc 61]ASF4[˩ ʪnX;恸Jqʟp|&]ՇȗIz"CG/_p3u8mx 8لǚyRHG"^mQlFY]gTֵL}qq:46ZGHX#*qe_k%xa>}g6ּ::1wvw ҠMζm֖Ljn=LnhG; -Zeijg[nAYè>_b9Qe5^Rs|^b;Gxa}x&ּ+?1s [ܮhhw~{I6W*֕IvFVvǘ:˩ zy{-ns[ [gDdZ$E,,siJg|XHCBr<(ds r뀯hf'07!_R:WƊ2B_}(VM* 6U M Me0?;ϋgnse@@Ӏs%`-TMP^q7W;AT(ĉff>XxkU@c^_ c?\p/0Qz:Ue@+ n:ԤnZpC͐7݀3!/o)ca؉?DZڏCKswُM>0U﵀&Ѓ)yocܤQ}E.>o9G윸x~Q`:ϞWXx}ͼ{~⦆5i`M󞬉"CFQl`.~ <_ @]Q }Fi ͦIٴ66*TL';1E;w<;A&W E8>UQ1=H?y,NxdJ<2uQ-R.iOeEBvWjz/+/ x=K{+~rK NX2Z*L-!Kel%]ϒ%#/X |* })v\UlSl}Mbc#?4esZ 4tU\q/Q]}IEcdOΔﰦ)[+ZW(7[sUͪ #s5oPtU]*60>kt&T Q?wQ=F*Nm %4N)h"/_WfWdkr6hvج o"nYo̠6ABmАc̿B$Q~<)p0EaWHiCxڰܰъ_({NV ^ ]dLk$d>=H(aAha^S}ZO#=vn4ݛjfWpj/s'Ϡ?FJ׀7GbCdr#H91Pf蛤^'Ygi3lz2 h8;8R}J_#6{܎~f췏l:lvژȉ醕1aRVtYFtbaʅ&-jiّ" )+G7Niq4%CrcG ;ғ=FYcP'pFnXoEF|O v"-6Q͠hfLΈIM=ߐe41zWCR[c@a [5{砚}>)8 |`BV `)-,5!Z>ʔULM7]?1nݗbWq\>r{c ;ғm|/#Y.h=?goÌX<5/e GAkТ!#@ Az@TBt]OZa]-3umn~L _|?~i扫t$))2k89ǹ0ՒJT2k7gk[=LڃYSL^&3iH$%QS{ Krٻ>5`:d1UKkR$iAzc~97⚣[XVu'4i^ԛ4#uNpK J?sYIjeC?14LӱظP\!?kԜsr2\ VAZwmꔌ5I^Z Iz-Y/(bkی8(bq1;¬Ay¤c> xc&;b|G:1SYQ1#:As9|ҩw X=|}鄓2v q~ x́GO4=ˠ5½ PBEE(z<(O=޷z]ɸ-w "N! t;< Ji7N}7PHI2$9CԿp;7qBƝIS0"@!tIeKo4pe" WX0/#tpL.#?o05w1cbzx;~~ 3 'MJpT,=/^`Q|9Y0y\t$o>r|O~|F!Dϵg/PdcE]cAnArKĂܑlX Y,?`/G|b‡hEE>{F)[6SDϣ̘.c x6o>&w -C}1<%ă=&YEyCp m49q42,&$ Ud=LZțNr qO?/ z%qx:)$D-"d% d+APg?u1q xk%w~AE?4tN"|G҉Xy8&>y;uvQ ?uR8ۃo>?pnA+r7Fx@qnT\9C41$[1jlf4h:Ӆ/u<;HT}Pem:X5$p 1$$"B"QmCToB ~ZC j]FҊ6\lU~\_qQBYOU"1J F* !zR}/&4w|kuWa\QƗ#.hVs|يs=1|Nw'#k"uqb  $?-2zp۸%Wb7;>ŹU8ӽz4Þ8ٳ'z^m8k:CO`kO]"_ǘ٧1-O$.E&꟏Q8ÑEOg`f_BfOr2lav lpxixm71Fd7w_AB> ' ]8\Q|L|4h9'6{6`ٮf:S-ڙLv.Yݤ]nnbkwrܺ!g5CV_>T-ÈG<&w銿6ZY=[|0,Ga_pҍ6 [tyجs0t%zmu:vMl*[/m[bJ,ѝ.ݒ6x,m!J? O?$[FM|@380J]b^q!ވ,fM\2]WʶUqKiB}YHҤ_%o5OW̸񢔐O%RkL!jy{Io$('ClH&$%IfQmNpM$2BZ P )Ҟs=n#ڌ2tŪh1hNMaf3sRNaC1,36 K5e0j*` G11E٫`Sg~+Ofz^b)K29sG1sӐc 7_k*ԘҐi:A/OL_LoWUPQ*L,Ch>rp:>iBzeE6l r1M4侍>'d[PJYҪ2 ܤW(6uy8ƓuE^W(6ҜN`g!XK- 5?OY=1#?ov` UyvΟ-R%(ZBe“LUQݭqZ>8,;9,?y™'ʝQxɷTd8GڳX@~*P`ڢQ3a6=$fb+ rٲWZPėX}5 + .ka][׫m]NVM_jUTXE gܤ:![G-^]4:u&rDiי; ^Q%k}j_ooUwj,\ub3^wY Gr`C3}Qye1LȸfnowKlE~F/zGn)\)\*ܮ6,x2Js KtNRS*4~$'j+텒x|Q䋃7q2 t7畖Kr!Yw]Q{;TiޣTďQV"_ <3:S P4vNO~%npUFQ9FXҘlRir* J$?IRF*ErVe*IXPT!*E9!{:;)`Tҝui />aB0H1șldBLf(5\ZO N$I2Cp0]<^PU T$ QSo&7h"i4L#UOs: {\?a0G!=p:c 066)a ~nL>\yTƟ3, ʦ0 '-Dk$F5O465"eE 8Hpj%&*.TӨ(1> &y{E 9^٬IθI&9]hBm^]u KY+ǢVwdX'!-'Y00g#YT:Gaf)r /lV&TƜҘXe\*T%R=PC_7f1&yeVr dia=H>}BR8Ο,$}oɽX{c?&ؾc~RĬvywR@Դ`5GQk׋WI%0PCi4K+MA/@t Cc4b嘆HG;rX/usRغv)XHk}/q ;z8x@Mi3_pz"©G3*ViDhe B*"r8*Ǣk$T͆U[U}VRS0\$1θلyY&7Vlc<.=c6$z =08WO] Թԩy$&ߓBwp_F;~v[.vB-ӎxJd"%"SB ԩN 5j{q|˿C?N?D_/b"Od fRg>u p6Q)\s;SU[whWp}+\D ZBӅ9 H^!M?Ө3m&SǎXC56sjnݸX|8%:Uj- @oX ^zXHo2L77Z3X Ȧ 󸮥F5*phf,Nc'Y@*o1zuAS;hvcGbl ^;CQ T6`sQl n?Jp!! "',Y<8}hHBmcj"G:rĦ lZDB4zT픊51n(T{GUHOic{WT^o}kd4hg7Pih2X8 PbxņiuBT#'Ib9/a2a"axFq-ENcEv:Y=k=ן@|U߶^pĦXcBIXcզQ74QZek!0}$-3-rPe*S,1mU,65*N*$Pf)盅"JCơqs5>}{`%v,iȵ2j/e[&IK-Ŗ兖 y%[a)-%yjťeyޙ{D K] qDpFf`fD 5.Kq-5zXTkĜ4mz5m<96ij4Iۓd1w= |zemA6G#ulI1kLslJFɄU&3-X,VUZMI[a(wcm<+1Vl y+6"SH"?7wg:xuH?6#<MXmE%4X2EZ S,7{2 ۼZ[b~^*6o]BeKa?LK^Ze}%s4kahEI٦*t۲mPj+KlbͫqֵRul:lsm/ԬkCzu]˸9Dq-빮l-#QW eʔ$#JLHY"8Xr]+~)W$/U~Q)ʅpEy'<[!܃Yż1t7|ۊQBRu&T@j:\L5IRԀXݭRYVxO^YՐLܢߗuJ@o/K} J#Pdc:9pHG#KPX&.q5َ,Gjo2;uq.,q3l>P/^0GO4l^\NGV G3 w><\$丌X]9bCJLWcqҦ6H&gltҥ^եOuo4gH꣰+y|'{X[rzTB^i$1qO➉ŞdyRaX,|!S$.TFO&ͽNkpoRrFIII>KB^ޠgS@-H zdW BVHX+' ; <)XTeʆUVU(ebNL,n{OKqޫ)ڸʐ6'.S8\>84ʕ] \n ~OFo }HYSs >Ź͚پSX[hbBڟ8tf`5 |?` 4HnX< S1?$0o.f0fi8Ycc 1Qu@fύC PD3I&s[1efƌhLoiqڪ3fL ((@Lс<؈ =x*)`|W ~KwQ{s+=o^[6 Q1LLjۢ0m,&MSX<`*30`1FkZن;aX"FԎG=a֐Qe۩BFT'%`^ v>ۣ0} ڣ1}t'i;w,ǠF ؂ h?[?CAzdݛX'$b_f1G Dqrº+RW,] Lz?]  |d8paD8vs 0CK77[7E. ̹3_oI}^3vi=EWGA a:-Dr:0 3G_l]BG>Z{#=`7ԧ__ DQԾI@!j{r aCя =aOpaȞߓ{G{]E Ybj٬5{#|Dc1=GO>g`|C x/y=dO4 rjbE 20*;o!"\>'ug_KH2kDT} ** EZnnhYDQA@B"2bM01rRV&NRV8ff\*5qܢo~T{=缤O~ld!Hu'3enDٍ^ӉYDdd"d3AvtS"oq?xW?" ~ 1 1tKlF3`'5ڨqssg#>mj O9z<&ȿ?eg7N&qdOT@EꬤF5j8s#5P{8g;V!}i_2:2G;C5ķQĝL%_AԌ3sӨCBjF%5jH-_'QB//} Moq~$7 /DÁc 9}r]*|=c\| urQDUԨF-5>V9wd4o鋫˴wi0Z"6;ٙ0eG'\;kBq5JN&gͣp y]U Dh9YВSG|kwqlCyeÆo$O^17x Ұ,\p9bu,ǙU85|z6S 9G#qGF^Qߠ1] sh!ȓx吻|!+ȍpy~.)DpM1lt-C[ :jtmD6toO$xm}qoc<6WL7OfRߛ70L.Ot%wW􎝆|=^ }`M.Ůk:-ScJ O9Sylv M=D+4xB y4O3 : ]&s6L*gsf2ϴaR4{bW*UY[Q?kP7S+}]s_\uS^})ZO.;v{{bs%}4h1' >VlDoj|P[*BoηUX3P>G\=X6rޏb|Y,yP<\{-]~tS\ `*aRJ=ʔXLAҎ"eBY$,W ˔br+-]--YtI#e?!,CG߈.10vƲ1-Zָ. Uc6C}PUT:(PLy!E_H^X 1cx@k[Hb[fB+:q#1&.헥{Rh2q<3I+s#kvxa>Y=DlvBP&-~,"d%ĞXVjI 5bԴc1ZiCvZ3\o1\r{y{lb>Kz 4&Vq.]#4"!RhX0&>'dӀ~M}̽5G%]3G%>4G%VhdeT>` 38E<gTJ&;iHbR48%LSh@jT6Q}Ҧ+:mҲ+3m),)tUShShdArCc#˰ Jsz2gکOzguStV_ٱ ώS ˙МSPE9kS+c͹,2L/RXݲ|އB}0 f8*]A , V@a Zƨe|,3mIU7"ue<-\GacFgWA+%r:!-;klql}Q3dcMW2UP #[yL@^RE7_W?7Hq؃R)`+5okTg/S |!adg,@PՑXuw\ xº2s/)kS ܍>iޖloaHa1~R=Ci}_CP o,^Ç<OXI-A GhFoz<^ÒsdwT2GvNI8Eag0?:Ǚg hrM@-H| -/:'֣?<ŕldllj֟%hMFg&9GEq\#dG(+t|+e`؛=vEHrsh@:st4CjQNFi-9c֋]DNg:ЙCGaoA:N:K(gJm5b>i-mP՝ U|ǴUl';cWC(NzM=~WO2|u{7W ?w1ԄZY?T}40VEq*  zM f*7h+;8WYEYy!GsC+-)%)a_ڸŵ7+x(0fl#Yik͊P- %,@=# ^+eOiJWZxR#2Q>_ h- ZE%Hy!@$ $BТmN!Zҭ͵{3nu;֞vNZ!~>Ͻ`͊O= S&',V iw$uLs0^5K>[R)G{Z 6g-=Xaڌ pŸQ ?|mX o^:"YDX\f!U<ຒX`d?|lΞH)EkӰ:;9:rLh)GCN9u]Q-\ʰQjIY̡TP/*IT80Tf?گ>8b팣E5yhV-O j Q`e<y [v&*w _4#2]Y&H4cO79rZM;렂Ʊ _39j&c6.N:tpQæ_/'EYQZ' ̆wd%["G+ ?Xu ;i& }60(Ӱ9lT4 a+,ƕ(5`1h(.z^ɊLC2iTVh#HEc[LyB~'Z$[s8ܦIpf bLa5eXX0QRBq*[`4(0zOd:yc/"ɴfIJG=L+s3Y&Pa0JR si:K0PTf̊ʽЗ?]yX}"z\loTBe(me-rA/{"z`-]c,Mb{,KQhO|+UA[YJ*WC][:l1pYUD+~g9 ۀ}M.G}\fN*KqC0TGB_]"hj!&y5*P9P:+ZlW3 tu WH=*gDjUO!wIBFs/QwZǀ'Ɇ5y0(Ȑ_{8CAGL V;V߈Vx2oR/#{Z$y HDGqKU(=C$ s[*e^ Oo*2}QHGZ"țRڔM*,o6`YUHj"ѿK['?m$4CB$!s;ڹ'Zg[#cR3 Hi@R$bi,i]G[Xܪ ;u Fl 11man% ¼"EB̺1q}~ux@s `3]9 ;v#%L-[Jpmcr60%&^JI$"|HL8x˥^ȩI @@ \*""^b2T@W=j>gmt]36v[NvݦsT|?D~;K NH#H3i#ϑm%1|I1G,Cy|G3y~g_2)ѐ,O"ƯgFCldbOajWL#>[_0o69aOƒ #5 &$dP/:jTι_72~w1N.~vp:kߤ0ڍ>$%qαRgrragaoj^ԓ24jZ}\ q>)tvpgp//^_ğYopjG708=]O͙xԓ3I<87+]Jjbf@FRcn)C\vV{k4Wy? C~9wyD)B8%3/ DQU^jM]c:ut='ye&I-`SGch"x^Qy1H}^Y:9?"56qj66LubTZGKB<kW)hVub]X1eG;Kf ?6I:E1g ~s7ڧmFeV 5f4`Up>V.X6!QҌ%!X50<_EugxG|Lw d*g> Iǚl)X>#"BTGP\,SEsajTϭCFTmCyTG@Y̋ Qo O}؂ձ!|u iKd煕Q~X=u1cQl2jPkDe qN̏,Q⟠Hn (D •>.SL >{Hh%kS'F$ $Ơ\*C4 z$d(OB܉(L\|2dp:F87`O9Ia0x'29gIigk譞>'B>e`H![BIFlp&9H*F^r%K))w"KyUȢ(S`n:ظv``볎>VG-}+Ǣ$t(#R#O J٪\Rݰ#+F¤QsDyUd _Ads6x:ْ>(}T"O :X5)hĄLm6KaLAn6tۑ? HcNAF?V'.w/Zd=F.V}0,9ԋa+`ԧ!Ð }Bk\q=LH5|Q4@A4 " F'Aj?xK1#MQ2gLdL3a Yt ZL ԙyH!RU2d"9k^>d{|~y0Bc{?wp$(^ J5ři h!Pۢf"Ֆ UlRHqCSyRHoԾsGBl$va3#{/u+9Tq/𹼜<\z1:EPyCYH΋D# rGd $:m:!qAEk-b]g|A#% 9i?wQ{">9*VK!G%=B$A ;y @TQ `NQ#"7#xŠO!m9B!H@+9Κ~/;9_ 4s]QQXWeueߑE=-fQ(̸ `T 0q8QU bզAlVMM`L6{bCRc4how{ xmcb-fJM`PW`ŘՔhSE(4\ldZR[_yE`oجloCkiYΧ6B}3UXO|)uF(6VvЫ dά<Ȇ3D$ͭlJabWS2mzAWڄf_0'xni]' )vba'luPC!d|R[Yp156v)40wIwvjQ:jXG .@Z.Z}-Kbna14,ttN_tb\KK34@ o/uahj!j6pENc$\bq'-%r?= kgY,zA&Z@q.IX4iÁ=9]lix3o'3#MF{- ~FK.wuNĐv>Q@$M1p2 u1(}\4׸7qț|m‘3}ldIƿ>_{[4Ү)yWP(]%| [6]?>FC#c61qF./l~ `0)`(bJzآ~d|isY;}/\pedZ AwH0Ŵ}k1˰_}- :55u]|gu N|OCx̹7T} c ضa{.0.S0I v͏C8 Zנ"ZIJa/`߈"ih~1/Ƕ \M?Ч<~b*-a8k7刦NƠABc")}gcfcߪ^N*ȎSD2P-T+nKK_ϡ1L4ʓIg#?EhXrc;YvO^Ö}51%;JUhi#:cFg1v՜\; keҧ.]:6k 8qW:Dy{+ePvw9] ƧimZqGiV9hsV8s\ eJ]*TNuA2_T=z6k\FXᷔaw͆SUnZ=̣U9R%3EiI *4,\JhX|~C9>5fSvVfspN_FъpuQ7N :sh@ h;3bʹvK| ]9ʎ UVd21fF ֌=Vi)=MSf)kRb5)\c7+9Zh|qݚy};`sآ#;EXojz\kj|MV*%~&unS5Qr5.at_7W=hTbF&6jdѳm/uT@T@S2 0 ]`H䲨1 `y ^K$Zf*hY)=Zֶɶv:k%ִܓ?>y}}˚ƎL}%q4bb\9\0 -_EUG$+7ª\eGڔYQ5ʌ5EJTjl,5?NܛEra #NJ + q-z )?zrX͎1*#&U,*-ήԸjYR)JNإ2%Șx~)S'FNm[q88GE9^2LTaJ3D)Ր KB JI,Pr\%ͪiV'4˸Q2ː.;?^b911AaaV٦0QG%#dɔ$cRf%*1D ɏȐRfřE[*| w)<@75؇~gא2jSHdd8̓d4*yS~NC@SKoޖt/*zXlȤ,bI&XP,cR4QE *(M+NTV) M-Ճ%4CJɧ䔼K>yC6&35‹JzYQ΅Zz-X҉oya+>J+)5I0=hD{&3SV$_VqOjlyʳ<˳:8e ,\~Zʹ4\SHl2y1!P&JOոJ? HyU%ʳ*UcQe{"n[FVKիUF.wZVmhȠF΅ǩzr@LI1Z(7T:B(GFe遺 : ;лX_mQg?ߎI~%g#=Rb|J cʥUÛйM\\k1>$mIgiໝSQ;vMG'$]0P`C@uQN w+ }|7[ًO FwJ]#y‘PRa#> eԥ8 t4v71qzjiW|?-/҃ ܏WO1xNA^SIAN$'gR,Yhmy׵u/`ͅ35b%Ұ>Z ҅\Opn!p8>c"5ec,ýKKf+ų`ߐoO!|z-Kp\uCѫ 7RnWosܦHv;; PeP hfh(MEyEևb7:󺮢gKp>5HCax$q`\,?Yu !yaMZ`{!`{9)E h̏Qh;:.iofp^'Ѻ7/}J3G~1`9U~ YKm@k6Ӣ?ڵzGOѺѩlS$8AQn<r_ w_pYX;|r"𓈏4-"el ֱc X:V;؎t^*ׅ5h 9$ V,a߆};plՐZ| -]ɳ|kyF;lݘ؀m@an_L b -M&kk^5SWUv6ҤjTiViӤݴnUNC}>}}.Wy%z"Y/_{Ob> ۻ3>wiJ>EOOUE79𓣛}!\+q~F6e;K 0"WҽMyޑ`HRsxx/Yڱ8]c~9Xze TtOҢQB|c29wxz8-RLSγhqyi'Ooi=lff1s c`4!F?јop4Vc:Wy,=|`oŔ>1 D`1*u`6ƎjLza"ΏnhF0pC LÒ{_CI"%M{MlbdK II%LTc,QcI%:0,APڀ!i H;0 EldcdsI^EwUtmDG{+3wLYfV 37C;1"##) /ՆCГք6tw`z?:GϘ2іyYwz ^ω?9B x6`uKlfiq'L) (R‚@v9NgѦUGޜ!4LE4?-xo s@Ïy uQD\, Nݿ{xmtMc[:oCxhdj2q@FV Z' ܨ-ZKg1TΡRav+(7~@N<_&-7p%~X Rud h,LGz}jTpp2Ԣ؄ c;E즣(3@y6uX-/>K%"Y=r`wps:T:&9&*Mp a7Qn.CŅRKlm$iXga]GQ"}opKcm*q-$ RG7u2VP֊&E&.wm  li&IX9㡭BS5uv۠T!ӆ^(Fp Ho"!R䳈=%.p$[;xuwIE덂99r(ݬ«CׂL:|Hu!7 yI$lBr ҖHlI-_Y̷۴?77s, ijڥZ Qn Y8H!ůܟd2pAф;! !3 OqF_|g|AfY㼓#VA}FK=J} io{eD$ B D !L`0!J{e7#bɽ6ɼ0Xa,L|qzJ] PSSm$;8D'!b8 -,FI> d0 1y7ȹf{5"Iq[\9 N98|_%~ / .) ._\Z!,8 ]u'0B(5wN FO3朜>dPg\Ҥ}jCtrt\\ȯkK8D??8{=<<wrx\O &5y vh}q- t=! P Fj0ؔf/TdV [=v]Ku_}K7펝ץc+ XASZQvg+tB-l7?ckncgX>Ntho+|+{n* ^k踂?t\B{lum29wtt"w71pyG\Vx塿ۏa ]x: k %^i optDoq>!;p(cv;i w|-88,~>^rlWaد@Z=ZAXf8Z_m&:-D`kwp~ >szK?"'f)X~vcN^F[4Eт&tl2!'*,42"^q6Q{rר_\1a#bP Gcᨆc2pӜ.E(>;Fvf|¤n 3a#<H#8 -F"t)rkKwմZ)eZTmjKU}*{lVEσF]W=x2undy>уnOun5W&h{j3T힫qCU1Bc=U9^= y.RJxh~Km**qtS>TgYB*ݨ|V {Sy+0w^s6|;:rT ?Oj_J#5ߤb*ꛪa}sTw+?\Co50hv++2*3䞲B#u jv}L$7TA* 2 (F588CB)7P9!_) t21RJq%ڕP xwۜC^_IqD7g C"De(#<[JԈ DNRRl٣Zw}L֘Kp(щCp`<9k6d>FBr<̨~J2(-*F)QJ+):C ׀r%j"ǭٴ]&gzWq2}61dZʧyo":*1CБTCd3F*'k\T%b.TLje΁kJJ%QC|`jK0~a||}XzfXd/,`0oJfJj_5 TE WQ%+@3T.¤Vބ;@_Kڕ|VKH F7D`aLr_hvȣ<ȣ<iFF6wh es-**12eXI3b3Ism2Q"@zcn^NVN$)I&O/\T ypײ ~ h x Nw 8 ^o7h:9ϚQL3xm\|pZ+>V4X9np 9 %pb]79E|Fk.=tqߣp_ ~ @z! 8d %8b&qO, 7G;[s}F7}#8>oDX׏xobE.!}F'W\G8?#} y 7{//x8xB/?xxd!]ʥ?8 Jqq`2ϓ9cʚv鷈uXi<^^G~_['228}@-1/i z]@"b#v91::f)d̲%8 )=`A}`7x#vL*%x[fEA>Nlb=Ӊe2į~xVav];aA-63ڧamFnf:iyZG1cW6!~>gbE,C %F3QVXn8ױwl=>t 3mIB6wh=X)p1b8{V e5YЕ(އq#%Y/>`ݍ.F($ p< G 68jȣZӴ<G UJ\ #J7á{6h^b{?v[!{8v !J$D @2-Dʂ xPW`k,@9GY?[ԟ0G^m8rК.5~a_\0A O:YT W*N.gd m$VM{Mn+rޓ+}GXo|/DA]U9fy;kfTW5-hr,lSNCݚ;d\%X mh#aǕ~Iww[~8:ZڲE7*HFyb=41\ T3f(8NYɲGNWfd2"+Y&YMj:.,/>R+цhkknтҖQ|k9T(ƛm,S/My2̣d3[n\F)%fĬШW#^wh 8ӂ%mhY y>̠NSikTF.+_l# 0 3ΰl (0.D4Dwq;hc9&٬i&VLlkXSi&=iZcܲUt=}kPqQE! 0ԩ,+7lFSNx1WUJ3nUK)rF7r+%.^nrn-d߂Y?=N#_<&0ҧzs+&OQA1#RVg&),3]Y *հI2dPp<(0C9?(8/<39AM֪lTi&Sy?;pMgq$*rkPHnȐP`~˿̖qHA>+#C *B ܼg9G0s%\*(EYX'btTP%b_qq-OI,WWҍ1 %gحNՠ:iV4x i|U<}/!㤀A+ Р2|ˌ)h`yr\rYX 0嘵rCƠSW.jB豉4/Ɩd%ӛ BM 0wE=\.BD."c'1!Mdb61;-s8KpG`O+yɇK*a@ȡ3$x \ \5\t5\@5д!`u+-M_M;"88Lum6{&P\ U jbibh[6ҋP@/DG=lyC2D-\X:` XX G} 35ã9p5XᲈXĢ.$ml||<[\ nm 5ʡ]ֱ@!H]/Y@ & VxҏVx£%RZq.|j&UL+q4+ZOX9HfF$|6K[w+(݋ < d۹xvzю(8r965]@:r;zgAK2>Ab{婋t} A===uh $߽V3u"o%9KɓFbvI9V#= u̐ǜc@E?eb(Ea.^zCU>_Z>QA\%!Կ_p55AGy1~ [/ g?>q&8Ǣ%Yzq]9@"g 57<Ǽ=f/΀w9Q|P5Xr*.S 8yP  q-M\׸p@E~_).: x~B>G"QUmpA0ҷx̯5c=U K+<.$;?1?R>@k?eέx ^?ni 53|5ezqA#_L ^.{8 3w𗿂8#=C=:n$2y?t,Y?8VrEr?أ8G:rXD^]M2m~A馲.= ݠ&救GZq+YȑLLV8DDRKX%_"6cvv'iP6Դl_+u:~G-rE.9ϢB1į DWc Ğ2 YNy: ߰Z_j%yWx=19v-{E'{Cf$Ilq1 BjrԑdkL76`0`n&&`CbH'@B(HB[Fi.K@%Ye (mfi6AZN]5mӺ}m6MӦM۪}ؤjڥ4G.S =z?y99『w f 8$7el{W('ߡܿ$xqÖ8 1Ua#f<ߦg3q;cX5#Df= MSw)h5졅p$v1iL.x 8K)gYBDim` $]v>NK<n'2LY%u )tY='e*\v/q~J M5+ɢmIښQ{rڒ˵9%M)aES՜USj61m"Z~D XR(j ?R/1~ b:m:r"8+GS IږVQQU6`(VBZ7֫޸Qu6Mݪ5Ri3)yNU VUX>T3SKH'bCطEȓ&K"L3TgU\Z_5ZjUm(hک_Qgͪ,mUb{Sg;>S#{Lo&Yg{(C$;I!Qk,ekͭ*[*m媰W^2GJ[SI39OQsWޜw6 %33IcuKZ~vlF9{IW3SNʜ.*8'"WH>涩 Gyv?ʬQ5` Q)-[J&RVj0vƟ^Dw;X҃][K> 'dMSfI,kr@ ʨVz(PRB=J )1N0uR;HfbM ~f/w_ዾKax e"'q!a$|:xĞqbMG#a{i{sp mx AY2`͐ѐb: }0q8k]A(nbL4n"LvLavL"&i0bK4A<&?åC){1ǎJw ցJ9>c;cܘɋ9.?7FҳB_Hx| :;_ U:G;0\|Hv,bb,R(2 $y{8G^~;?oسEi㗩WH*_%p p Ǎ' 67%X,e 2X&8ҫ>_{Ŵna"r܄*_a |n]M>gVcB~PW Iʊt9c/ggTW6\ۏ_ݛ¸oo=^I/G!R6\{tƟ6%inmzK4IIKKEZ.E\1AAȠ ás)`e2q2&sӝYiOs~/<Yz,GG>ۇ;h {mf5*c?,ks51#ꋚ b԰>_8?@}^Gnx7u6v̀/b@2(CAw6ڦq-gҿu7g8?R<7{{BGeER?.jK?wvT=:uч踂89,C%tz gz@{཮kz _>/߈M_p귪һܷtɜGG8qyqa6WqR6K'Hz0v]_p|ܟ>ݛ,::)tGsc88#8Zܬ}d/ _R@m!B#_y \b3e'"֯MzGek=:Bt5JR=pt±nѽ(\Sݰt*O.r?b̘C"f'Q~mmIG<4vPAo ɠy#ynsmEo  8,OUB$P]*,Od_ 2\G{?vX-s^tSsd+\x )c:h_P ~/k$?fOyF>OqmrѺ!.sSc>;\䱧"p᪇pMdptvZf^w@dG\ȝ -a4uAL&cjHָA9ʂ͞*P}LHuª4Z59_'`K0\RE-U$Fp+mw_ղqlI&&cqjHNQ8:CjU`b+4$JUI $5ȗ4VH%oUqOxBwU`BSDEOƮGm%#P1i(bPɨ!Ft94y4T*Rjܤbs *HW5r[)\ʱ\zD $#F#ϯw泥8!7#kȍߚ YJ-*RBK UX):Jn[rm3/T}RcxGi3-»[1nŌsJnFS'R*U`cO۞<{r%r9|I );AY㕙>MNgҝ+*{^2^5MlkH=Sl-~@ Fy24+iSө̌\P 93*=+4L={l]< K7#L_O̔zvx75RxeXՅ|vꤖz P#6(e3Ǣ49\#L*Yek.{LddHO*sLIs#>|o #c`;3 mcHrSCn|Ĥ* Y|vY ke,K)EmJ.+x U\Qy|;rȻ chiCG3#t27^RL%VJe,u) % VRYH 2*ۡXxK^n"/˴2K-pg]9]m jF_-CF2֓b$&@>*-JLx_b}^ SH~gCcc ~cUEp>4q*=NsaXFh11+)`bA MhPi`0MA C?Vj)x6{LzӐأVBV7q7 $K%l\xa0t\x ǸcBHuhcC걓zCON0yy@0"dF\1RkRivHMdM4pġӄ&45GoLLk.Khhh k\ni)![ 9<h#;?;: 6+Xy#tp 30hs1 ; 9tG7&4nrхU]Gy,AUEpܳ:^J<a<2h6ƺ gGI'M/uE賏FG.Y'ṿ; 1pa0p{Lߐ {%W@Ca!WқO c *r1@_RqpfLtLRl`ut^o$6hVӐq -8.sfp>rFqخR+_W.0Y āt0Rοgjs;pH}A#GGs"^@ aG>|Tp!X4T |pƲ~kg88K8G<N]zS'u/ >z:=E;N*ңn<7U#` :._ORܠԍp/h=k!G!^7YJgz\hDt*bn 6^ 489x,؋h2GM>:p6Nv4#ԥY EfUR0we mXu8# teDt2!Ue/Z"\B.j(fmV]O{ jȭ7\~t \χc9)2xYŮC-Z@泳R\ ,F}9(48ĵ5xW:EiU5YJϨ.&j$ n1 BxS(fYjC(i>'{ogG;k}+l$n9C5rxxK;\p%'/\p k4\5hr#{#PN. idgqedY1@3zMaL$?r2C&X5>ȡ1A.%jTPFgiD a!w+'tCV:7)5C)O( 1|!OwDt.Xm)1PANO!ǁ紆*dT^Ur .eGxGRZySbdSd{< =ZQ1]!2YQ,jLN\r,rNi 9~LSeLȄ*W*,qB=9  NUHO]pwL,Xߕ|VLl)f9#'CNPEyLxl2{2yeLJUD0(-U3Ui0v|:ɮL1v ͥB+tr)D]᥊y ݸ~0)\*ָɽû{Xfmհ2V|ߵ=růĔTMT۩jEZWj^vqq*B˄ΆKZ[µo5c[_U`8,G bK^2ٓ:hh5i|1/jZVXA>ך_,N7Ѧ _\[=_iu`xD@yy_2%ʹx>r؏{Թr`jf>+Te$9 `cU: I ~%ٱ/袁/h _s)qqlK3[j ML_>7\;ֲc4QkTT((kx[w ሕKk4U@{.J1P╢4 ŗqE`ƎUn\ɼEi]l'${.yǵ1Ja} !Ϛ:mfG3m4I3]4E35q^'$;i츎[u r@ 1T<ȸѹm a-߉MKvǀz(j-|BL9~3p.Q3 xԭGn߶dN;|ܛ}6'Ѷ$3'qR<%&4S|qJ~DzR>ދx/9f |ʸ'yj= kâٱ ]0!,ڣp~ӳq0rN<Qٗc;ޥ`|<\^\e>PF<?WOcq|xiorM_a{ u| =&RK忚6W$dv}*1?X߶i{#_\Y3Nmc} 6>|d)];__/9Գ 3%OlOI' 3d,mB=E;bW8{; ,g_^U*IltBtl x( $/g :{'iv6l`gv;8hûCQO)͠s'I=. \x)9)#+yJ9ۉxs'5ۆ Tx>)3tSI/ WB)t~-vk~ƻFvNZMsEp]z>Dk;ddI8,ybi|ENbWVf{crVրco5(Xe1/sSG j+GYvꎣ7b%8pTȊ*J3LJY–ٲ_h9 ukTz.?.7i<%oD,!`R8\)`. .jȥHB@H1%폎@TXb/&f:.cK4#1wsb=8|LfҖxxCCxt $2N(mt 5&j0T?CpmG2aEh9K(U/0q&{@AkX = =Y&zfͺ uЭ>HV^iPfPwމЋlxH9,4ٲ5f` ,x808!qM٠)]I l"10BTI##P$Ccba܍2Sc5#&F&G;Τ-gҖH#D >[3F5b( Ab${izз9&l^}p"F;b2!{asE D&x#8j$,byb!p,dLY]ّ!1CHlp\q .U%NLH-Rdch ^@D3Hvgxq|Dp*жhcHu}67jʌ ?R#3I3< PS,> ؞Eq\=-R'6;9IAzɆٜI6|XdA,@W־+Y?[ړC iBF-(ӊ -A[(oq@j ȡ^s8j$,AE$h~?Xhڊ>ǁ-•a0|!St+R)5D@*zmahCFnlV7qm͐pnyQњ+{O#Ok R>5y]Nbs0 ;P^84~EJcil)%dtUY#Wq€rFtGz](9dj_8`]భKJ7HKwsؗ1TT..(rۮѵ}4f>z{ϟࣵLAϻsƌzfzkfL(քC ~h?j}CJ3E%/c_TVJ*pT_xEy\_^Hڨ;Wi YA"ҭ[l!Iv^يR9$Vd2nqy>=/<y;s+Nw $ ӟmWy0\*c<0gלuN@B! +G[Yu?R|^rrH/坑,~$K]Kn`l=Z5[7q|gUnr"~F8ߛ-cY đ\ೖ-K1Es)`[>zyH]PF(볫ܤ;dqFV Lk-zPߔJK{wWy~P'C8d,ߴ. :J@7 dzqF@` V" 6X ##  ZeWŔԃN~a~qfu#E".lйy.?Xϊ ;m HK=`(tu4G!gn_:^!B@zhCLZ8l$@ + @ e!OAx C8~ⷎNs]=/I֣3ѡM*{q6ljK~!}9Ym!!_7Hlް(Qppj`0GXs,D`+/xGF@ҚSШ s=t##URuMT?|zq+[:sMnִ䂹33o\P7.B *OEtO1o,N4GO\ٞ~pc݌)GR0XQAl(f4 M)h@<׹L"]NJYsr,'%hݹv  ݆/U)|JnPW x kFEQ`0|=t[ 1x}fpc3A&ŽpJ ~ 7%1,۰PRND,^HU0uf>7웻ñ]zQZVq6 S d`0XA#GVJ[(9 RWvHo^0x3 bx p`+gQ(^1ױ>9ږ騬*^x#qb ,Y2aHwcVMOb/f=-ȁ/} - `=瀾}k) 4`" C!)p3:mu@XoQv ngn3w:s+*qBV- M$NreO{}v R` 83JyMO4)XZGyQj{DM {_πY ̸Ӻ|)weUefᨈ.A]]dciI~\w<8/t Pg+e >*7E`S# 3\GHpχHn aKS[K 5uk;mɶcVރ iEHD_+߾U\'9GVXJ¬9M<~̨փI+qijL9%A0pcF"((`77Q#'q h[:-H,n#*Z_YXO =Vy!pLYzY*K;x2}{"w7er"Iw:GSy\V[<6'Rչn%:溬'5mDtbZL\&$ ܾ~vן{}߻<%E&gINDHJ"NƄdD] Q!c@ d *>7 8PW% \ h`3^l:93cM|;egA :܂8XJ[7XI|0|N7w[{EkvcJȬi%J-Q#u|FBѵ<~ԠVTw|_JvV{J,͓ɯ)l/` R|Vxfm 96pL1c3Y0ߜ,/NP[@Qt+eKTe9ۏ-p Ȯ|BpW$ %IHO޿y:~0?_(gD,rE}KcШ+)J_*=I,?!4l=Å[Pծ=Ğ [ }g OZO$o!xL=5dbBC) Oմ>RIr\r"#;@V2[kclzi5a#*Xm?;62.#:ĉ֙Li_8L+ endstream endobj 122 0 obj <> endobj 127 0 obj <> endobj 128 0 obj [1.0 1.0 1.0 1.0] endobj 129 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 125.7599945 0 0 30.9600067 605.7460938 384.2587433 cm /Im0 Do Q endstream endobj 130 0 obj <> endobj 132 0 obj <>/Filter/FlateDecode/Height 129/Intent/RelativeColorimetric/Length 1963/Name/X/Subtype/Image/Type/XObject/Width 524>>stream HK$N^u(:ZE[ \hv u!Mŝ d+A,q 7E(.$ T4YHHS|I~2A3PTjZSvVN[ JJju:eeTH8N7d` ekT Rp:o1+`(rAiĐNAg犅%RYk`]yTR\(f1S0&H7[ms}]MUT$ï1dTszTPZYkilnmz,hkmnV=oAn{^{d LNʸ? biuCr\&eۑa`fmi. wCK-48'n힞s߶+ryZPiFQmߍNxf >#-{f=֦ZI4'4VXmfYw耭RQcȼk2W5ZΉ_@^]X[ XUlnZH]17y}gw/ e{;^XwsMI{yTZ R]mp|vQ^<8<:#p u]^IIJɹN{$Vbѓj`nrLoA5sʻ{gڒwi0G҂굥wxz~yc4~~q 8m,OvZ^27^X E/DX,{uy~= n,G~o[DZU2y J&.-v9_c?/J 6)1$.¡myO_BJ HaJ ?¡/{GH{n!ZH]'S-|C Z hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ-R-$ˮBO-[CB10: 'ZpX*J&.-s ]}#Q*H$EʲWgѣʂ{,{'K`ypocy~zQ,7w؝Sޥ݃H,?;FvזSN{\4jVz&;h,XEO"߃;ɡ2QЪ[ȗZm㳋q86GN+ixwvPHYv?9w7הM /2T\pN`ڪL8ƪbSJi)F?xK+ X%+KĖ[0ǐyׂFo&gv;?~¼wn31nmD^C-(J>>v=.{zjr|9`b,o7GCXZb9#.=Ivd94Y[Kl]? o*Mn[,غMjqs0SAie R[YZ`ǂ҂JX$UT՛- 6B*MӨZAg犅%RYk`]yTR\(f)Ơ3S^(։b~)tRHǠpZ!#O,^iԏRHŐAqZVSVLM)(1drPj% `Bo)  A<> endobj 117 0 obj <> endobj 120 0 obj <>stream H1014YXh9<($,R0X % RPVPV0XAYAYAIAYAY`eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeK6t/MQR`O"\Ar +(WP\Ar +(WP\A+قp +-Ws1[plf ;}-7H endstream endobj 133 0 obj <>/Filter/FlateDecode/Height 721/Intent/RelativeColorimetric/Length 3810/Name/X/Subtype/Image/Type/XObject/Width 534>>stream Huq8W90zDfs$9F٦MDuCڤM4`eKs*jZT6 ln 08M9xqs9N$^Z7j/n笳+J361bȂ° 3LQ(PZVV^^^PuYYi0ΰ( MW]UU]]Q]]5b ʪj'34M?nlMUeEYId1E1u&Mn2aJή?,ohvfϙP0gf|~fYŘg7?c.y!ůi.]8nS6|d٨L~E,[bJW_o~_9mYRňڳΘշiiiYK1+Lexw/nʬϝ0fTȓˢ*J+7Ntܺ;7nL۴~~K/?k9U%VQ^Up uw}[nkmmN1+Lx-M~˿<,NRS8cn[ٱ󱶶]o7躥M_,Na#JG8mnÏ>{wPyfv -|j*NoQ6z\ÅZsx};;;P /<~m7.k;mOOH]ZU;izw룻]ߠuwk^lwm\E.hWI?&ϼt }䉎}uΡCo϶g]8_niقY?y ^xOӼ▍[۳;O1+L{//?s˝_6{JUm^fӶ=zEu籭oiΔbʖͭm= Q"WȢho{߶i͊9Sk*TŮB?<'ĉc=۶mjY<_V`PŇ=ݝm[V.m.[8{zU05REV0Z5}ek7l{O:uIǿ?vK4QEq뷾᯿MSK}s}zUK͚vqY$5zHf[-u8#G|EN+m҂ИU1jPtԆzs}~w(\-<V1ʱ9#UaL`l9Z{nsbw;=tLW6=} fT+Y9y%g4Ϳwooխյw[_߹uӿִy5EgXM :lݶ#ةtHyMW\ZY,<;_ؓO߰sY0ᎍU+?**r+bSn^toCVmk_G~owͿ`Ud3'$R:~RM} -^tJg[[pnϵ/*VEϪyEeN:iy 67߮ |ۭt}+UOzkW1&;_\6>6fF}ìJxS_jS*DqvUdQTZQ2uZuk/EŏYEK*'LUMRfMr|EYIa~x(s")*[ZV(3DecK"_Fq&pn^A$RXX QX)  p2C0P(';+hb4^Ca P> endobj 134 0 obj <> endobj 135 0 obj [1.0 1.0 1.0 1.0] endobj 136 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 128.1600037 0 0 173.0399933 604.5800781 243.1123505 cm /Im0 Do Q endstream endobj 137 0 obj <> endobj 138 0 obj <>/Filter/FlateDecode/Height 721/Intent/RelativeColorimetric/Length 3810/Name/X/Subtype/Image/Type/XObject/Width 534>>stream Huq8W90zDfs$9F٦MDuCڤM4`eKs*jZT6 ln 08M9xqs9N$^Z7j/n笳+J361bȂ° 3LQ(PZVV^^^PuYYi0ΰ( MW]UU]]Q]]5b ʪj'34M?nlMUeEYId1E1u&Mn2aJή?,ohvfϙP0gf|~fYŘg7?c.y!ůi.]8nS6|d٨L~E,[bJW_o~_9mYRňڳΘշiiiYK1+Lexw/nʬϝ0fTȓˢ*J+7Ntܺ;7nL۴~~K/?k9U%VQ^Up uw}[nkmmN1+Lx-M~˿<,NRS8cn[ٱ󱶶]o7躥M_,Na#JG8mnÏ>{wPyfv -|j*NoQ6z\ÅZsx};;;P /<~m7.k;mOOH]ZU;izw룻]ߠuwk^lwm\E.hWI?&ϼt }䉎}uΡCo϶g]8_niقY?y ^xOӼ▍[۳;O1+L{//?s˝_6{JUm^fӶ=zEu籭oiΔbʖͭm= Q"WȢho{߶i͊9Sk*TŮB?<'ĉc=۶mjY<_V`PŇ=ݝm[V.m.[8{zU05REV0Z5}ek7l{O:uIǿ?vK4QEq뷾᯿MSK}s}zUK͚vqY$5zHf[-u8#G|EN+m҂ИU1jPtԆzs}~w(\-<V1ʱ9#UaL`l9Z{nsbw;=tLW6=} fT+Y9y%g4Ϳwooխյw[_߹uӿִy5EgXM :lݶ#ةtHyMW\ZY,<;_ؓO߰sY0ᎍU+?**r+bSn^toCVmk_G~owͿ`Ud3'$R:~RM} -^tJg[[pnϵ/*VEϪyEeN:iy 67߮ |ۭt}+UOzkW1&;_\6>6fF}ìJxS_jS*DqvUdQTZQ2uZuk/EŏYEK*'LUMRfMr|EYIa~x(s")*[ZV(3DecK"_Fq&pn^A$RXX QX)  p2C0P(';+hb4^Ca P> endobj 114 0 obj <>stream Hv7k6-[j nI''V|a|g(\\ָ֘k=Ѝo]A_cvB|gtŧ'^E$:!iˡG?:՛Ag:@TӢÜ>tJtKЧNrة1.E; :胧@G>G>E?5~~4o:|tVtsoNNs&˹uB8=:9@':Jo\={ Sn-tVC:~mAt#ϖDG{تn/Z]%Y+۽jmtg?- A:cC3>B :wvB}^_ -E#%{~B+>cgtW썮?}o:?eeD%,Qf2;Yu(D,Qg2KYeD%Pg2K١u(D,Qf2;YeD,Qf2;Yu(C%,Qf:K١evD,Qf:K١evD,Qf:K١u(C%Pf:K١uv(C%Pg:K١u(C,Qf:;YuvDPg2;١uv(C,Qf:;YevC%Pf:;١uv(CPg:;١uvC%Pg2;١uvDPg:;١uvC%Pg:;١evCPg:;١uvCPg:;١uvCPg:;١uvCpBPg:;Vh:;١uv­ uvCnVCPg[Pg[Pg:;YVg:; Pg:;YVg[Pg:+p+B1ssTg:+|\\ ١ 2y?w;z;uVλs7Sg:+|{C\١ e.>Pg3zuv£̅C8swPg:+<뫳s :;Y̅^[\١ d.ZB f.zB G2z=uV8Ы̅^Kg.JNB e.*nB g. pFBO̅]\Yؙ̅ =:+г:stz:̅ =K2y6d.d\Y̅ȕ =k;zg\)\3ѹи!LаQ ׹РИ MݹЈ M2y<"sc2z0*s2z$sA3z8sǠ+ =_ @=:6G~bkt_lN'ma?3-Q?>AB:gB}>}O/)I >iutG- ^C,:DW;lEtCC;m1t7׭nuХD߷:@G:}B'os^twNrM.A::U_'C}Lϝrs+@̣ B D?}6>~8p}:>}>/Filter/FlateDecode/Height 488/Intent/RelativeColorimetric/Length 22748/Name/X/Subtype/Image/Type/XObject/Width 488>>stream Hy<۽Qݱoǒr6d$K"G0Pȩ11DM<%FB-KY9[=j~?<WXXXX߭?룛3њϻ _ y}-~z˗z5##r8k0"k13_e>3:&k)8 N^Ú#F/ l\8rp\l,TqpưW6:1Â)4^/\x?/*ܠ 㞏/F9ÂYXˇ%$7H+*). RxTƺM+_Rs3F5!̓%WAIEU]s3aRlݢICMEYQANFZJ/*,ϋ`GנܰWg55spx%XQYMxm75ikkR`okc>6k)+m aSM^?wO1J,$ #k`dbfiec砯a"0䢿a?߃>^q`gceifbm&hJK6l6)g8F#f  XYMnjamsϡģǃ%",q$۠tsqr2aC2Ȳx9asE17Fy% 7aam{A?!D}xrRrJJU(maԔ>K #8J=}NKScTS(%. æX]Q)CťdT4L-w;#CH"D'\LrZƍ[9y  .ɾu##=-5ńhrɿ#z{:#FzZ*0lĚpS720^r#t Ḽ\=}SQ'^Jy3+'ҲʪꚚڇУwC.TWUVɺq-5bB9rDhH domnbVE q T!Ȝ0dQ i9%u-0qtq:D Kt%-fO%+k=~Ա0b{{[[kӦ'>_~¼IΠ؇]m,wn#hoǨ?Y*:2 YuV=c.>~GHsI7~*(*WQ|wtv? 10𼿯Y;7xmue۹7pR?=6jRxazSf`" dմt Msv=y|ٹW<ۓ.wphxdtl|brr[c##C֧uU(vzg#C.ACiUب?<y=d@V ] 8A8{>19-3 K+jv=Fy_;?7;;33==ޮ'4̴ĸ"Âz9Zl5#6>efVn>!1yv ['7/cߞ"$$efW7ut/ ^~B^Bg)#Cv}]MUyIQ^kɉF;YҨo5g֔yD%e -m}"/^[XR^qcs[Gwo(UxyyI 9 pOa77>~X]q3Ӓ.Dx잣`eF-0^Z^UkbPX ͜;eU`c4a 05ǎ܈6 ./)̽uJb,dPZ(#)*}dPL)*kj8NcJ54wŒGǁ.L|_ nЦaðP몲쌔Sm-iKㅩ^?6ʔxi5^oaR2aMQ8g8M=3 'uШ>N6fښʲQ3alNl}1\p2C#itƿ/MF==bva:v{AMA/˅I/L9ŤTtan>!g-Ϻac/f4O#(6FubiRݶFښJ2p|äҊ[ l).W\:d!~n=3b|Nu=BtxHZw?~'2YFlM:Ɩ脔̜ҪG (8u0^zuYqޏiȡmL jRgYRNEKoǡc$[%kḆ%Ȕ!S]kdUd$D] c , *%F7u75%B/"-$R2FR0$B !];1u}i?N}?{ki9q'繼|oFKzmF!#iF^B#`ֿ*},A\D0l/BߑQ52=v4lA rgb+먇;k+pϳ݋ ru< M.hLp}(5vpi0>RQL uS`'Ą^vcLpc(wt [^qU]+heꁌGlMO tw4^^vA2 ;7X_ SnBcc JۻLUQ&RWyH,C;@g_iOXBVIc𲛏XlRzv! y+SdTDDSjf<4zbڰQQ}!kWoHI+SWD@M2$] ̴›Ť7oa`r?PUշMV&S HoSfgAkVFuh1˫l;`i%?r<0"7R`{P5Z›l26&$&1=W (# ljNN5 Y4Z#AFQ]Jp엕m(e4 b젾&)~.dyӹ3Õ/"IUr`TlaEشJT H?I}vGTdwKXʚb𷂲VZ i\NzbL̉F:j ҢtmiٹDt8vRF^IM3@%+_fݏ =he"'!Lז&YXBNEK/,朢WOeŤ* ){9Zj(ʈѱIfQ5u%> W{tAj1Ζڲ71N75V[K&Y^E YH1M~|R@j!>|suqnˮ'--4?,O^[\? ('Pg&{;*_bS.9tkY8x39s/,FvC(fd~eyL vHuOKl̾щem݃3b/P(ϟ>{=5ڏo.NG,/ 5lvAq-ZEMx$b?P΁i+LvCZZAZNlVN>j:G@3G'e׵ =GlJ#D$@gM Ȋ Av#;{=F%M=k'_;Ɛ_)CAҠAxNח@K}XGm$2h=-$.'(*)wxrF6]"!?=Z0?'k=M%1nVy]tO O=69ہX:*p Lkh6̆;[LFiK2G&+#LDK삖N Ÿ;ݷKf74NgSadasC,hD3S+&$-p+Y݊Ң|4[h5s K"}Oim=D3Ȟ~>}b4ZfP͢Ҋz0G&fl53/"pO[mi0̏:YA5oܢ簭GϊjZ&fWQ ~|cEAf-&4Ff՝{-~Ąŧ=/lifT$KN uT1ANJ2׳IlR=`tVc`ͭV3"X)0jKrRBڙ#%̀CY`R LOI⪉z͌ cc?ީ*'. F6'v3[+!q9%0߬& 4¬OPҺj$5F C.0mC1aw3wH vwkM!,ii(H h µPU]&!Lו"%o3Ƥyi4m[9yD=xn;/ -av{+Ir 42ٸV}YۏA5NadBP/2"- ehuky)Idh;_NjniD'BIW&E{8Zi+ˈqYXJQ m׫7*;{͗to{mNGwl(Jcvʦ!R5ST+~x}:rX/M :cVyqAn6f&jM舣dS\Mk_XS9gsp& !nTYYŀg`hN0ǨZ` u6VŅ:oYR@akyz쒺PWSe~z|UW[3}M)aMgG9eЦ-ZHISU/2cۙlW٨k-[98$葞Y ~{hz卝b,o ho[ iRZARu宅̩_}|;3^[M~ca|2L3 )FI6I24BՖHNQB2g2eҶ}?V{_gu}2UX04˼l\\ 1_ =5>PQA6Y$)>7@Ib0M Aw4T> E$QYU}sL̗сYwUSABx8f>aEvAg&Dzn`LNf`!f9ftCųO;[DuB^am\ ay}@.W}k Ԉ 1I*Z o//H=b(k@̬<vz_Iy0(H'i1vo1W%q[TZ8u'ś3MƇj^^u5SX|B8cU_LNp6ȄJfR@-y}戋jݥR¼4ITV}:>hDi=yjlգ䫧}Mu$x YWE%bހ /$Ei`wsܤ+^; pۢ3u MZ^?:0. wai)/NĺTml\_Zodb L@DLO~TYm*%xf*gVm-C<.iB"*Ɗg#_k*#@3,X[fթI[z0̈3xG.̈;gK7. 0MWASýoK%]9uKĘX_:·Ƥ=) af1cC=-`]ݎbLh8IbK4@;|56 zA=w1!&E4"2=/y-* U 9_mE&E4Bzv{EgiF1N{;m2Z&ǜ_ gWs5cŢNzn[R)2 geFNާSAEQK7Ųn]<ώ9 ÙW gϓQIJj6A.XQt?.ϕь`Rq J*뚁px+EUK`u+}ɒ 44Z.aE]cf _ "IOu2¼ hlRȪڸ+s9(+<{>LWYRQ gc X8F-,'FjJr/[qŘM*+:z]N+kE Q"yf|fmF}_vO@DB˷X8E/3>NssڛvQT=™I#zt4/Ia离w|™YEtk5WmLHtbks+}"m((<*> :0;ڬ"k_T3E4W@6UZ,E*-(T~hR1UxDc;慣ֿiʉqsI2q ~>,n$Eھ@UZ0vaUwxI/tm|׏#O۲z?'?7,a\tͷF%确MA\ؑ]4dExWŠ9D,w 73_T|a{lݫ<-J rx RYnr#~5r"vۘhɋU` %Lvϱ-k@_*>pOseQƍ3;6T1p#$ JOhgoZzG&&BQ_~;3΋8%u̶_sp |mdg}m/2o:ӯaWDV<(aoG'&HLJOSAsS^,EmETzN]K- 7uOϠF`qV17kcM9Q> MTrn 9%*)U,= T谭`v31EͨoUQb񽶦tV?l#֣FUA\Fh54fg!iՕpS=(j% ٙHUlM5'o{h3v>}wmo~{;ߕ&CDBkd繣Yl[)w=&CC;sĩv.zyNjz -$3s=ҏ$8' 쬠cno} 6nCyy"S*$AWRIQJⴏFGDr(RBȐ)Pig/=Z_<p ΘbVi-+NI d\g1hXO*C`:,lJV#d;(w~jl=E 4wj#$n@LJ^3֠mͣ;]99,n8dQE=K]3D'3tE^J B[#hZygtTXD^*mqpi~kZ\J&լQuʦ  x{mI֥7kdaШRеpFbtT8c$*>,[PRxW0<Q=@Ӫ64-tiŨ&mqǑ٣{+|AU4N{3VXRڨq"3ƹ#7i1lbhP}BcTA Kyb'l 61Y󨦭XfAL VUSc![7a +CLܧ ]QQoALljy>#M0-ŽE+la XVtScM0e0 ޖqa( 7醭a>LwU`&v~v10̃pWcEUj7R*FA?]WXALlþ+5d)n5(  8pqOM tԕd]:ՒFk[A?"!qm0wwcEUjR\Wܴ^3z5kX֙UhwōԶJGgoU :hq2Sڶt}RVI]m|qϩnP8➱mPFk[^ڎAPx`vq{"Fj[\#Tr.m0@ѐVqK/n][b#fa]B6qmgPx`sOy*YId1%I9 ]q[ ؛.&-VyT3>{ٛQP.ćm2ז%.YUųTFs;](ij(Inc$F&/RRеt]V9a 6>@{ͳL$;дUedBMɫl' =VSq؎Vi,YlYͬc@m> u֗e'FqUXtYJvzUu LL=9TB 5Z|Y!JTQ-0 *Eqa",ЪPYnˁ`U/151Ё.+'3-٥< /+< IS|[p̲f 3񄬒A}YUݿkrE4 +;>{{ V@/8K? 3'8|Ǥ@y :Ϡ};MX@&(~Y{LK% fbP܍/#pw5<BG^8GwFu腌Gځn/p4U%n(G#,KB2n'dw%p<+HHY9aӱpk(=V;U  3+@ *3=Id1%5?__6>A{WiVWY_c ['4~$WkVYbOٛ^OL3 {IQ-u +r܎̪`VXG{R~?XؐYj̒:0p ,аz^zVby̳TV?`Vᖙau&{?'AGXN5n0 :j:wg 5IYÊzg^=+Wr+zF)Ȱ}t?G;Rԥ3͏AԔ7cq 2:K~}x5}q0&ld2- R:"8/j$X\)Z.drBհ!$1/h:y>=Vzjx1g'.vf̄AoO?Z_YViͺO>?t:%I]t5䰚h,ȸnc@"ⱘs'miǗ^glz6!\`Y d[C氒^ZNEyב{s4P{u.3ҐpVյܲԇ/< hB)&Eyk*pjzV~aiy j"NZ`MEqg:Ã3 ٩Ҝ'lu4Rz$ 3DΣuOrSNܶt;gi @2 G&dV 23 sӣ=O\ d,[%adxb֣!&:yk޹\GEvrg{$ĵ&\x=k,{ppU{猓Q4Nt | h3=a٣_*8SKhPg_yia~Vzjx gZ't9a[*χY?,r&LVd\p1 XΥ p3s03!akHR `1;BgC̡GYyi|DrNim uf!EԤ@/{B \pGM15):XSQW^Kδl WhΝ۔`RCgDy'r餕Bݒs-t^ PҜy?qw>g.KL+EZI6%::ԖkI=t^ \P{u5)*HCtI]Zۉ: 3LQV}vL@80۪ 3";:3Γ"m HD<#%L9؄AoO?fNį^M.u@g.q&j.KsSpG5PgDˇg޲~XW`T}ŝ΀8ϱ/-tT$<Oz833 sc ]Mfʲ8qg92!m9AM(N?s56dӺ5Jo0XܠUc= ߈;BV{?7O `o+mw2V],ܪk׻1C6so1ߴ#$ʝ'pp V 猓U2uܺ?&1@fNc% -ΰކ7τXU/!FC fXldHCri5!^¥V->wF:r1̠,ȸxa&b2JZ&>1! }0O?4DC 1[ A{vzܑ]#CS_&T{_D1̅2BMdhs !P {L!iB6Jө{tخ] <`HBFi}[Pи R#䖪 _y|fzF *uv]oP=[rQǜmN ݛƲ?v0VoḘj!C Gz+IO9DktL-hq%m3SW,k~4Ns_[usAnֆj23kl?r)e7Ny|useNY?'K=IA4-nnW"A=ޕp/+sOm *F^ϮhI_UGMѝ ߺf(/چſHaN䳪čпԘ{7Z*=W 4lO*nI&}U}u%R{69/A=UOU}Ò2K끸D#7U+"alq"7AT}=nKݿFĽ7q4#1sr۪ niȊLUq5ԵhROUjmq1]Wj{LڞB@X֧fPۈyEn,˺mFlrvES77ƃh{Q[rnk{Mwz&fԽč@8&TZ3me5l܃Jp`?|?h"ɀֳ}̵Wo? +lTxohɋqΨm;rnQMe,1 ]a=͕S]6Z*#̈iV7C}Xb ;J3¼vmV Ϭm%Ut#{5vk{|i͸Dy9f6"nN>1y͵z>^0o k,˺yi7+ GXz`a6 tݽrt"~.Vzgv;=O&%L +l'Ę-" cοqiYĤї84ጬ\v4Q$0mӵ@~x1gh[ n۶VS^AmӗmoT%^`^a{ H p0%y=+x#};(.@`͝%P;h̅swSENJԱWC+^b7=S ^& ¹8=!meY0hhI(ko? V_И Sfm!': -1QkE_ ^h8wg$q0ׅ*Y VG’2k:X \blb|\Xaٹku%V61:ε%IގzRBXa8T,Ó2Kj$Sc`ghE0Ahe2Η#yf3 44te4#/Γ@c.0 g4<Xq@c70g4V\X &3Mh  s{MqFRqv/hs}blq {`,~q4>F*JO ;HXbzwxG؇97ܘdo?ͺLL|Y݄P/sݹh]sЄZC>} fUjqVrqWeL2cQY:), :xv!LGEJh8SR1 _ b, :F:޻PcqF[a{}#`*|T_0],)=wQ$W8[ L1VaOq9NVdE3hQYUV}“ҋ)=@&%.;0 r J(._= b/)#?sh#iK',Ȉr2ghN^%z#S{qXkB?98@h+%6`nVa˅GzmT V۾D썜N(0|a}|7Dn}V0u9f0ꡩSHې{so`k~?ByY8+9:>˜3BW(iP?.eF=44xTt>G=A6d6RqӾ[1sQ M1.~19U0akg߈ ڿt$3Gl܂Zkl\Qssgr'- d;¨+,AgXk* 9¨wdno݃c襂=Q(+%&hSm)a0ڡ6&/eOeiX}M$!-R(DB$$]SR=2\\;-.ǾD1u<9Qny8^~?~=ؤl,M  \m)a"J 4r>\cq:(-=}|-aT`EOAh8?N0Y]n}#S =p\;J}%krQ˽uRlFI6 hC-gtR_p޳eR^>WL܊o$f5` AIu1rjㅙvEeUYx+X+6lR>dZARrF/Ģ:ǯEnq sYvb w݆JD+nW\nK,&Ŧ׶R`EZ97W= D&ղbxM*F,l^i57Kۡ`w (稠mkR1ƕFb8ZΔXzjN*ظWٶg KA9[n^b= +V^-.Ǫ&{AI54'h%Mg}f=AU@gSEӈlr/8L *OLVe+B 3p*`NڙbVrm.aɹ AГlQ vbHgwnP}R1p#8nŞU4u@`Z[jZY1)*q0ŢR *醠'+EV2;1gk$\̛J7=Y/ϣLPᘲr\lKGw߇qE5dzB[T/߹fkq”:4[oy̒rwcp]}sPBUȠk'Ac.&.BJj[{(7[T]͕>}x('۔8hQhª 6'ek륌@ `wKu!XT>voFTܩ]LJQM#CfrMQzC_w'ZQ՞̈|Jzvǯ O-k蠂ך\w,*P՞̘Qз8xj`ӼWCo!sOkmIfB-TKNj; |_9" C=]mLE%L$LjO- p]gߏN-j6_f%>xLWmgAlp·VXAe1'=v`4-YX9x$km060`N C0k(ˈMӢ|20#cO0!e3@\cfp|FQUsgwcvy>lISYV{ǠD 6 :p_}i>|RYq&Zl?:62 AihQc:͐9sqJ+"Ƥjh良y AQ̣frm bFQ%/)y^Y}[ 0 tkJ3)fh~1z^gwOsKZ!/ʇFGZj33 VT`btzPDJZr7= Auj~7򺿳(3!1AJ/YcW`xrvI {`ˠnTY9b-;aIY%5-]C#cIMmo(H y쀹>b@Yx]N\81Wh՗`O{ceqVJ$ÖF:d%ghO Wh>( ?q(v7u~YDiGV[ZB?د= rM iB4QHFuꈨ Mn IӉJBݤ4*BJ;h}eXνXZ>yM>jaUuEcIfbvϜ# Ln%t՘EԺi㨼0XAFBd Vӌk2㳛 iɕʨOEz\V]q"20K s;vD[MQZN3m&3H[ &Z]緃ǧCF{|7Veio)C,^-"pNb=4Y%{LHWjÆ8cxeFa,΃pToZٳif٬#{5:&?,od*oƕʮ){-,l&}Cgܳ .^&}'PyӖ7[IӔ?}V67/訯Y.&lģu7 ^imc#.FI+&4H3 vԿNufvMUe6lӸy ҫT4M\d@y5w4[H3+\ ~a_! ^̴HO9/,.^p c1ُK3#kݽqcL Fl#=kYw<,rtBzKj Mlg[3eąyl|95cnN{3k$63M`1(jZf֎q)to߃'$_SlgnIy2U63ĴǠ*m6kK;;Ҭ+{YiK f<|3mlnsΞ!ϕ-M6SX$0*Vs/W38DNzTow֘Ƣ塁^P~I-\RDo|U6{ 7i uL,#b2 ^5C=&} )g}UٌG,o8ӂ"inށ)VB}wzQȌ(7W?/~ək_tt%o*7WWh Yأ t@(WNkU]INZb G!3_hV}Qy` 5o&1ݘڪgsBϸ45eBo<\gC-.% ;-OgPWT64azGa b&まNr}Meyɓĸ _~ۢ,|<3~(ѥѡ]\^IMSƁNLRR^YS C#:7ta &ʺeUEia?c‚<]IkmT]-+%!G{HdHHTTQߢgFgj:6C̿bFX!MPԲbrzۑW}bkd$ Κ5u2G;Pc ^,)-ZuowҳVkhj7N> }~cƒ@]3~s?}|PWSMYaR Hy"G7z(3w@H8:~v^AaI9õ7x8zL; `Ƅz;E2Hߌ wwq<||' (?T9枉Ժ;vtq?xztlBrZfngox@GՉop_aP[Z^RXsoE{!l1١ẻPO_v<۞Y0qh4B.2-+JuKM $˱f(%HDQ)Զ|c~v=ay*j{g n̞L~~QɷUuM-m"&gp_yc}k۫x~nfjbЭ}W;ۚ/ k*JON%F{X KSCʽ&~Fڒrb{F&2 UT5`ؽ7{0>19=;7.,x ;zlNON>stream YGH endstream endobj 116 0 obj <> endobj 141 0 obj <> endobj 142 0 obj [1.0 1.0 1.0 1.0] endobj 143 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 117.1199951 0 0 117.1199951 48.5 273.7589111 cm /Im0 Do Q endstream endobj 144 0 obj <> endobj 145 0 obj <>/Filter/FlateDecode/Height 488/Intent/RelativeColorimetric/Length 22748/Name/X/Subtype/Image/Type/XObject/Width 488>>stream Hy<۽Qݱoǒr6d$K"G0Pȩ11DM<%FB-KY9[=j~?<WXXXX߭?룛3њϻ _ y}-~z˗z5##r8k0"k13_e>3:&k)8 N^Ú#F/ l\8rp\l,TqpưW6:1Â)4^/\x?/*ܠ 㞏/F9ÂYXˇ%$7H+*). RxTƺM+_Rs3F5!̓%WAIEU]s3aRlݢICMEYQANFZJ/*,ϋ`GנܰWg55spx%XQYMxm75ikkR`okc>6k)+m aSM^?wO1J,$ #k`dbfiec砯a"0䢿a?߃>^q`gceifbm&hJK6l6)g8F#f  XYMnjamsϡģǃ%",q$۠tsqr2aC2Ȳx9asE17Fy% 7aam{A?!D}xrRrJJU(maԔ>K #8J=}NKScTS(%. æX]Q)CťdT4L-w;#CH"D'\LrZƍ[9y  .ɾu##=-5ńhrɿ#z{:#FzZ*0lĚpS720^r#t Ḽ\=}SQ'^Jy3+'ҲʪꚚڇУwC.TWUVɺq-5bB9rDhH domnbVE q T!Ȝ0dQ i9%u-0qtq:D Kt%-fO%+k=~Ա0b{{[[kӦ'>_~¼IΠ؇]m,wn#hoǨ?Y*:2 YuV=c.>~GHsI7~*(*WQ|wtv? 10𼿯Y;7xmue۹7pR?=6jRxazSf`" dմt Msv=y|ٹW<ۓ.wphxdtl|brr[c##C֧uU(vzg#C.ACiUب?<y=d@V ] 8A8{>19-3 K+jv=Fy_;?7;;33==ޮ'4̴ĸ"Âz9Zl5#6>efVn>!1yv ['7/cߞ"$$efW7ut/ ^~B^Bg)#Cv}]MUyIQ^kɉF;YҨo5g֔yD%e -m}"/^[XR^qcs[Gwo(UxyyI 9 pOa77>~X]q3Ӓ.Dx잣`eF-0^Z^UkbPX ͜;eU`c4a 05ǎ܈6 ./)̽uJb,dPZ(#)*}dPL)*kj8NcJ54wŒGǁ.L|_ nЦaðP몲쌔Sm-iKㅩ^?6ʔxi5^oaR2aMQ8g8M=3 'uШ>N6fښʲQ3alNl}1\p2C#itƿ/MF==bva:v{AMA/˅I/L9ŤTtan>!g-Ϻac/f4O#(6FubiRݶFښJ2p|äҊ[ l).W\:d!~n=3b|Nu=BtxHZw?~'2YFlM:Ɩ脔̜ҪG (8u0^zuYqޏiȡmL jRgYRNEKoǡc$[%kḆ%Ȕ!S]kdUd$D] c , *%F7u75%B/"-$R2FR0$B !];1u}i?N}?{ki9q'繼|oFKzmF!#iF^B#`ֿ*},A\D0l/BߑQ52=v4lA rgb+먇;k+pϳ݋ ru< M.hLp}(5vpi0>RQL uS`'Ą^vcLpc(wt [^qU]+heꁌGlMO tw4^^vA2 ;7X_ SnBcc JۻLUQ&RWyH,C;@g_iOXBVIc𲛏XlRzv! y+SdTDDSjf<4zbڰQQ}!kWoHI+SWD@M2$] ̴›Ť7oa`r?PUշMV&S HoSfgAkVFuh1˫l;`i%?r<0"7R`{P5Z›l26&$&1=W (# ljNN5 Y4Z#AFQ]Jp엕m(e4 b젾&)~.dyӹ3Õ/"IUr`TlaEشJT H?I}vGTdwKXʚb𷂲VZ i\NzbL̉F:j ҢtmiٹDt8vRF^IM3@%+_fݏ =he"'!Lז&YXBNEK/,朢WOeŤ* ){9Zj(ʈѱIfQ5u%> W{tAj1Ζڲ71N75V[K&Y^E YH1M~|R@j!>|suqnˮ'--4?,O^[\? ('Pg&{;*_bS.9tkY8x39s/,FvC(fd~eyL vHuOKl̾щem݃3b/P(ϟ>{=5ڏo.NG,/ 5lvAq-ZEMx$b?P΁i+LvCZZAZNlVN>j:G@3G'e׵ =GlJ#D$@gM Ȋ Av#;{=F%M=k'_;Ɛ_)CAҠAxNח@K}XGm$2h=-$.'(*)wxrF6]"!?=Z0?'k=M%1nVy]tO O=69ہX:*p Lkh6̆;[LFiK2G&+#LDK삖N Ÿ;ݷKf74NgSadasC,hD3S+&$-p+Y݊Ң|4[h5s K"}Oim=D3Ȟ~>}b4ZfP͢Ҋz0G&fl53/"pO[mi0̏:YA5oܢ簭GϊjZ&fWQ ~|cEAf-&4Ff՝{-~Ąŧ=/lifT$KN uT1ANJ2׳IlR=`tVc`ͭV3"X)0jKrRBڙ#%̀CY`R LOI⪉z͌ cc?ީ*'. F6'v3[+!q9%0߬& 4¬OPҺj$5F C.0mC1aw3wH vwkM!,ii(H h µPU]&!Lו"%o3Ƥyi4m[9yD=xn;/ -av{+Ir 42ٸV}YۏA5NadBP/2"- ehuky)Idh;_NjniD'BIW&E{8Zi+ˈqYXJQ m׫7*;{͗to{mNGwl(Jcvʦ!R5ST+~x}:rX/M :cVyqAn6f&jM舣dS\Mk_XS9gsp& !nTYYŀg`hN0ǨZ` u6VŅ:oYR@akyz쒺PWSe~z|UW[3}M)aMgG9eЦ-ZHISU/2cۙlW٨k-[98$葞Y ~{hz卝b,o ho[ iRZARu宅̩_}|;3^[M~ca|2L3 )FI6I24BՖHNQB2g2eҶ}?V{_gu}2UX04˼l\\ 1_ =5>PQA6Y$)>7@Ib0M Aw4T> E$QYU}sL̗сYwUSABx8f>aEvAg&Dzn`LNf`!f9ftCųO;[DuB^am\ ay}@.W}k Ԉ 1I*Z o//H=b(k@̬<vz_Iy0(H'i1vo1W%q[TZ8u'ś3MƇj^^u5SX|B8cU_LNp6ȄJfR@-y}戋jݥR¼4ITV}:>hDi=yjlգ䫧}Mu$x YWE%bހ /$Ei`wsܤ+^; pۢ3u MZ^?:0. wai)/NĺTml\_Zodb L@DLO~TYm*%xf*gVm-C<.iB"*Ɗg#_k*#@3,X[fթI[z0̈3xG.̈;gK7. 0MWASýoK%]9uKĘX_:·Ƥ=) af1cC=-`]ݎbLh8IbK4@;|56 zA=w1!&E4"2=/y-* U 9_mE&E4Bzv{EgiF1N{;m2Z&ǜ_ gWs5cŢNzn[R)2 geFNާSAEQK7Ųn]<ώ9 ÙW gϓQIJj6A.XQt?.ϕь`Rq J*뚁px+EUK`u+}ɒ 44Z.aE]cf _ "IOu2¼ hlRȪڸ+s9(+<{>LWYRQ gc X8F-,'FjJr/[qŘM*+:z]N+kE Q"yf|fmF}_vO@DB˷X8E/3>NssڛvQT=™I#zt4/Ia离w|™YEtk5WmLHtbks+}"m((<*> :0;ڬ"k_T3E4W@6UZ,E*-(T~hR1UxDc;慣ֿiʉqsI2q ~>,n$Eھ@UZ0vaUwxI/tm|׏#O۲z?'?7,a\tͷF%确MA\ؑ]4dExWŠ9D,w 73_T|a{lݫ<-J rx RYnr#~5r"vۘhɋU` %Lvϱ-k@_*>pOseQƍ3;6T1p#$ JOhgoZzG&&BQ_~;3΋8%u̶_sp |mdg}m/2o:ӯaWDV<(aoG'&HLJOSAsS^,EmETzN]K- 7uOϠF`qV17kcM9Q> MTrn 9%*)U,= T谭`v31EͨoUQb񽶦tV?l#֣FUA\Fh54fg!iՕpS=(j% ٙHUlM5'o{h3v>}wmo~{;ߕ&CDBkd繣Yl[)w=&CC;sĩv.zyNjz -$3s=ҏ$8' 쬠cno} 6nCyy"S*$AWRIQJⴏFGDr(RBȐ)Pig/=Z_<p ΘbVi-+NI d\g1hXO*C`:,lJV#d;(w~jl=E 4wj#$n@LJ^3֠mͣ;]99,n8dQE=K]3D'3tE^J B[#hZygtTXD^*mqpi~kZ\J&լQuʦ  x{mI֥7kdaШRеpFbtT8c$*>,[PRxW0<Q=@Ӫ64-tiŨ&mqǑ٣{+|AU4N{3VXRڨq"3ƹ#7i1lbhP}BcTA Kyb'l 61Y󨦭XfAL VUSc![7a +CLܧ ]QQoALljy>#M0-ŽE+la XVtScM0e0 ޖqa( 7醭a>LwU`&v~v10̃pWcEUj7R*FA?]WXALlþ+5d)n5(  8pqOM tԕd]:ՒFk[A?"!qm0wwcEUjR\Wܴ^3z5kX֙UhwōԶJGgoU :hq2Sڶt}RVI]m|qϩnP8➱mPFk[^ڎAPx`vq{"Fj[\#Tr.m0@ѐVqK/n][b#fa]B6qmgPx`sOy*YId1%I9 ]q[ ؛.&-VyT3>{ٛQP.ćm2ז%.YUųTFs;](ij(Inc$F&/RRеt]V9a 6>@{ͳL$;дUedBMɫl' =VSq؎Vi,YlYͬc@m> u֗e'FqUXtYJvzUu LL=9TB 5Z|Y!JTQ-0 *Eqa",ЪPYnˁ`U/151Ё.+'3-٥< /+< IS|[p̲f 3񄬒A}YUݿkrE4 +;>{{ V@/8K? 3'8|Ǥ@y :Ϡ};MX@&(~Y{LK% fbP܍/#pw5<BG^8GwFu腌Gځn/p4U%n(G#,KB2n'dw%p<+HHY9aӱpk(=V;U  3+@ *3=Id1%5?__6>A{WiVWY_c ['4~$WkVYbOٛ^OL3 {IQ-u +r܎̪`VXG{R~?XؐYj̒:0p ,аz^zVby̳TV?`Vᖙau&{?'AGXN5n0 :j:wg 5IYÊzg^=+Wr+zF)Ȱ}t?G;Rԥ3͏AԔ7cq 2:K~}x5}q0&ld2- R:"8/j$X\)Z.drBհ!$1/h:y>=Vzjx1g'.vf̄AoO?Z_YViͺO>?t:%I]t5䰚h,ȸnc@"ⱘs'miǗ^glz6!\`Y d[C氒^ZNEyב{s4P{u.3ҐpVյܲԇ/< hB)&Eyk*pjzV~aiy j"NZ`MEqg:Ã3 ٩Ҝ'lu4Rz$ 3DΣuOrSNܶt;gi @2 G&dV 23 sӣ=O\ d,[%adxb֣!&:yk޹\GEvrg{$ĵ&\x=k,{ppU{猓Q4Nt | h3=a٣_*8SKhPg_yia~Vzjx gZ't9a[*χY?,r&LVd\p1 XΥ p3s03!akHR `1;BgC̡GYyi|DrNim uf!EԤ@/{B \pGM15):XSQW^Kδl WhΝ۔`RCgDy'r餕Bݒs-t^ PҜy?qw>g.KL+EZI6%::ԖkI=t^ \P{u5)*HCtI]Zۉ: 3LQV}vL@80۪ 3";:3Γ"m HD<#%L9؄AoO?fNį^M.u@g.q&j.KsSpG5PgDˇg޲~XW`T}ŝ΀8ϱ/-tT$<Oz833 sc ]Mfʲ8qg92!m9AM(N?s56dӺ5Jo0XܠUc= ߈;BV{?7O `o+mw2V],ܪk׻1C6so1ߴ#$ʝ'pp V 猓U2uܺ?&1@fNc% -ΰކ7τXU/!FC fXldHCri5!^¥V->wF:r1̠,ȸxa&b2JZ&>1! }0O?4DC 1[ A{vzܑ]#CS_&T{_D1̅2BMdhs !P {L!iB6Jө{tخ] <`HBFi}[Pи R#䖪 _y|fzF *uv]oP=[rQǜmN ݛƲ?v0VoḘj!C Gz+IO9DktL-hq%m3SW,k~4Ns_[usAnֆj23kl?r)e7Ny|useNY?'K=IA4-nnW"A=ޕp/+sOm *F^ϮhI_UGMѝ ߺf(/چſHaN䳪čпԘ{7Z*=W 4lO*nI&}U}u%R{69/A=UOU}Ò2K끸D#7U+"alq"7AT}=nKݿFĽ7q4#1sr۪ niȊLUq5ԵhROUjmq1]Wj{LڞB@X֧fPۈyEn,˺mFlrvES77ƃh{Q[rnk{Mwz&fԽč@8&TZ3me5l܃Jp`?|?h"ɀֳ}̵Wo? +lTxohɋqΨm;rnQMe,1 ]a=͕S]6Z*#̈iV7C}Xb ;J3¼vmV Ϭm%Ut#{5vk{|i͸Dy9f6"nN>1y͵z>^0o k,˺yi7+ GXz`a6 tݽrt"~.Vzgv;=O&%L +l'Ę-" cοqiYĤї84ጬ\v4Q$0mӵ@~x1gh[ n۶VS^AmӗmoT%^`^a{ H p0%y=+x#};(.@`͝%P;h̅swSENJԱWC+^b7=S ^& ¹8=!meY0hhI(ko? V_И Sfm!': -1QkE_ ^h8wg$q0ׅ*Y VG’2k:X \blb|\Xaٹku%V61:ε%IގzRBXa8T,Ó2Kj$Sc`ghE0Ahe2Η#yf3 44te4#/Γ@c.0 g4<Xq@c70g4V\X &3Mh  s{MqFRqv/hs}blq {`,~q4>F*JO ;HXbzwxG؇97ܘdo?ͺLL|Y݄P/sݹh]sЄZC>} fUjqVrqWeL2cQY:), :xv!LGEJh8SR1 _ b, :F:޻PcqF[a{}#`*|T_0],)=wQ$W8[ L1VaOq9NVdE3hQYUV}“ҋ)=@&%.;0 r J(._= b/)#?sh#iK',Ȉr2ghN^%z#S{qXkB?98@h+%6`nVa˅GzmT V۾D썜N(0|a}|7Dn}V0u9f0ꡩSHې{so`k~?ByY8+9:>˜3BW(iP?.eF=44xTt>G=A6d6RqӾ[1sQ M1.~19U0akg߈ ڿt$3Gl܂Zkl\Qssgr'- d;¨+,AgXk* 9¨wdno݃c襂=Q(+%&hSm)a0ڡ6&/eOeiX}M$!-R(DB$$]SR=2\\;-.ǾD1u<9Qny8^~?~=ؤl,M  \m)a"J 4r>\cq:(-=}|-aT`EOAh8?N0Y]n}#S =p\;J}%krQ˽uRlFI6 hC-gtR_p޳eR^>WL܊o$f5` AIu1rjㅙvEeUYx+X+6lR>dZARrF/Ģ:ǯEnq sYvb w݆JD+nW\nK,&Ŧ׶R`EZ97W= D&ղbxM*F,l^i57Kۡ`w (稠mkR1ƕFb8ZΔXzjN*ظWٶg KA9[n^b= +V^-.Ǫ&{AI54'h%Mg}f=AU@gSEӈlr/8L *OLVe+B 3p*`NڙbVrm.aɹ AГlQ vbHgwnP}R1p#8nŞU4u@`Z[jZY1)*q0ŢR *醠'+EV2;1gk$\̛J7=Y/ϣLPᘲr\lKGw߇qE5dzB[T/߹fkq”:4[oy̒rwcp]}sPBUȠk'Ac.&.BJj[{(7[T]͕>}x('۔8hQhª 6'ek륌@ `wKu!XT>voFTܩ]LJQM#CfrMQzC_w'ZQ՞̈|Jzvǯ O-k蠂ך\w,*P՞̘Qз8xj`ӼWCo!sOkmIfB-TKNj; |_9" C=]mLE%L$LjO- p]gߏN-j6_f%>xLWmgAlp·VXAe1'=v`4-YX9x$km060`N C0k(ˈMӢ|20#cO0!e3@\cfp|FQUsgwcvy>lISYV{ǠD 6 :p_}i>|RYq&Zl?:62 AihQc:͐9sqJ+"Ƥjh良y AQ̣frm bFQ%/)y^Y}[ 0 tkJ3)fh~1z^gwOsKZ!/ʇFGZj33 VT`btzPDJZr7= Auj~7򺿳(3!1AJ/YcW`xrvI {`ˠnTY9b-;aIY%5-]C#cIMmo(H y쀹>b@Yx]N\81Wh՗`O{ceqVJ$ÖF:d%ghO Wh>( ?q(v7u~YDiGV[ZB?د= rM iB4QHFuꈨ Mn IӉJBݤ4*BJ;h}eXνXZ>yM>jaUuEcIfbvϜ# Ln%t՘EԺi㨼0XAFBd Vӌk2㳛 iɕʨOEz\V]q"20K s;vD[MQZN3m&3H[ &Z]緃ǧCF{|7Veio)C,^-"pNb=4Y%{LHWjÆ8cxeFa,΃pToZٳif٬#{5:&?,od*oƕʮ){-,l&}Cgܳ .^&}'PyӖ7[IӔ?}V67/訯Y.&lģu7 ^imc#.FI+&4H3 vԿNufvMUe6lӸy ҫT4M\d@y5w4[H3+\ ~a_! ^̴HO9/,.^p c1ُK3#kݽqcL Fl#=kYw<,rtBzKj Mlg[3eąyl|95cnN{3k$63M`1(jZf֎q)to߃'$_SlgnIy2U63ĴǠ*m6kK;;Ҭ+{YiK f<|3mlnsΞ!ϕ-M6SX$0*Vs/W38DNzTow֘Ƣ塁^P~I-\RDo|U6{ 7i uL,#b2 ^5C=&} )g}UٌG,o8ӂ"inށ)VB}wzQȌ(7W?/~ək_tt%o*7WWh Yأ t@(WNkU]INZb G!3_hV}Qy` 5o&1ݘڪgsBϸ45eBo<\gC-.% ;-OgPWT64azGa b&まNr}Meyɓĸ _~ۢ,|<3~(ѥѡ]\^IMSƁNLRR^YS C#:7ta &ʺeUEia?c‚<]IkmT]-+%!G{HdHHTTQߢgFgj:6C̿bFX!MPԲbrzۑW}bkd$ Κ5u2G;Pc ^,)-ZuowҳVkhj7N> }~cƒ@]3~s?}|PWSMYaR Hy"G7z(3w@H8:~v^AaI9õ7x8zL; `Ƅz;E2Hߌ wwq<||' (?T9枉Ժ;vtq?xztlBrZfngox@GՉop_aP[Z^RXsoE{!l1١ẻPO_v<۞Y0qh4B.2-+JuKM $˱f(%HDQ)Զ|c~v=ay*j{g n̞L~~QɷUuM-m"&gp_yc}k۫x~nfjbЭ}W;ۚ/ k*JON%F{X KSCʽ&~Fڒrb{F&2 UT5`ؽ7{0>19=;7.,x ;zlNON> endobj 113 0 obj <> endobj 146 0 obj <> endobj 147 0 obj [1.0 1.0 1.0 1.0] endobj 148 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 117.1199951 0 0 117.1199951 319.7880859 271.0333252 cm /Im0 Do Q endstream endobj 149 0 obj <> endobj 94 0 obj <> endobj 150 0 obj [/View/Design] endobj 151 0 obj <>>> endobj 93 0 obj <> endobj 91 0 obj <> endobj 92 0 obj <> endobj 155 0 obj <> endobj 156 0 obj <>stream H|]HSaqLs[gWȴMe[$O,iHiCݑjZ%!R E]v^EE` o W['w|DZP'lXKÙWw.2;>6^1fOIh:Ȅ||>I2b`L|adܻ—G͵V#JPtʰսƺ{Q4 t  j$,FMآ~Jo$19-+:R'lø)xRdH`8gLh7~3V. tԚj>Od0|Fw endstream endobj 154 0 obj <> endobj 157 0 obj <>stream H|T}XS7}wB#6SkD"?P n"<`H EIHRj b͉|(]Ac--ۄhZWsnu ۞s~w{4%P4M+5:`ʳYeZ+MbT.Vsxv=G O'СVwQB@ʲV_JIIԊV|bBB v~[j )ϨvfmN1Hq$ϗ{If.IMJN-vlqjBpBjwd>h[R.~OE$h UxbW1Zk69'&s9EBSrZ)bhJPeT*E)QR;) !nI%Y"IdTI˲SvOc[ FE&4y|Mcx0́N}tL|$Ls Ao>B YpŢa?s[ٍ.JȞVOns̩6f8-8w XӪmq7h,BA+8J3[2 _ V'(qXh'ϽznQOh#v葳yrRF:$F7oC*@6 Bay09k:Ell fT ="yhzWс%lu?z u=6ĥh(QTzg@@HB̹ <$w;U~A4`</2%AMv4Uxtd<ދw't&Q6%o^ԓ%G D(zh.Y-|>ıe8`y`f 5M8{vsfqD^sl'akȂB @/gčgU_/;9`ss.zQzdk:UܶNJyiZ(RXoe: F~sr_ߟΞ.W$s8\)p?|:eldِbQv Z+TμF.n}BW`6  CsAM,Y~yD{sI90bXЛ‰kuX\5CvCtiyUvvǨf' Br(T; ׋!w+|򿸓Ux}r\6=LjZ_槥@Z-"VҊj%DQQ9ҝiRyǽT-o- i)aX69oD[UtTojFԏ:>g!V2xP{pa7w !x{})R⻧}SBNޱT{g}@-mh;CZY( r`#m endstream endobj 152 0 obj [158 0 R] endobj 153 0 obj <>stream H\]k0{Ź\/n Bb 3Jż -<$缇$:T)|y34\b:3A,Hwj4s_v8vμ 7vB_zCa}QY5ziצg }ٶnL;0jC;|o'{!tWu]F.[ _8_+7 endstream endobj 158 0 obj <> endobj 159 0 obj <> endobj 160 0 obj <> endobj 161 0 obj <>stream Hj0 endstream endobj 162 0 obj <>stream H|TkTWf9iinEA`kEE%"(Gt18 (o1g AD* L'f5ɺM5{q!r:nݪk"Ht \eZlF.\MIDENUfg kQaǢ<ſP`c7WFl#(GЄ%IDDu^Ѧc¢K}?e+_)/L3 aCZVթD/!8%Et .Ӳիp\ؑ-l;t>?_K0A!5]j;5 שթ*]D~&tMiǛ*-h^IM4 /ШtDHT'͘1 \A^O bJaN$E'$ 1i;D{Q$Zkuu P19>F#z(_Y'"'`FYRF3yT:I2 }Zd-+dd%@A4Ǜ M3{7t]{:GO Txaq-1[*%% F ͼ7~ͦ=e^`jp<-S!n߀ygx (vMzDo9-A ڇB&^%`+~6MP 9t@6cW/=V];pq#bEDk܎O?,D|9̜甀VcT n щEG[4>=tG q`3b^^ŸC{g })o$R9,gj/2TQ9h oRrk"}ʉ@ gmW&r$,wNR(^Uz39 OeuF̡ȓ#oiO5x6,~ 4jxfw2ax3s2sP/Gޯ=WzYyL_(~]BtM|=pNi2Ta'(dGq)0\$8ad ae,@NJ Iݜ`)6@)S|߲iO2-sh9 )`Y^ϫ2a˯9]wA ӶB}4fwd'ǓR!?^jSi&"&cE9s-*-2+R uIfO]Ѻl2El 8'0ped4s_Q&zsǵ!A<5mKWFhCJ SBxGJ]m݅ҊJlQ!&)38Lg ;g\wSI J?0wd\7{JE>y@x:# ak:f# 4}e7G*qk6q/}v9=<}]G$s!( -%4Nb.RNJ7'6ɻZ5WZ;zXNb $Q2c""`P pVzJES-=ՆyԈHaު[nKc 4WiSi&$.yn" F"? V ~cL;ߤȈ]7i{WP ZRc-iA9͌TW**ϝƝ2x\̈<825DSȉ6B2g_5tq zΗڦEgU5k:oP~Gդ!|j]˪#eu' YO8Xz8Sfub.eiP"H{yEJ-EwŁ3uUd 67eedSb^ t2½01BFd-P''`wgYHqI\(D77jmj7w~>9n4=mNVg}ɸ 6D9rjg yǣ@ > endobj 102 0 obj <> endobj 99 0 obj <> endobj 163 0 obj <> endobj 164 0 obj <>stream %!PS-Adobe-3.0 %%Creator: Adobe Illustrator(R) 14.0 %%AI8_CreatorVersion: 14.0.0 %%For: (GV) () %%Title: (slaves.ai) %%CreationDate: 1/28/2010 4:16 PM %%Canvassize: 16383 %%BoundingBox: 48 243 733 422 %%HiResBoundingBox: 48.5 243.1123 732.7402 421.2778 %%DocumentProcessColors: Cyan Magenta Yellow Black %AI5_FileFormat 10.0 %AI12_BuildNumber: 367 %AI3_ColorUsage: Color %AI7_ImageSettings: 0 %%CMYKProcessColor: 1 1 1 1 ([Registration]) %AI3_Cropmarks: 0 0 792 612 %AI3_TemplateBox: 396.5 305.5 396.5 305.5 %AI3_TileBox: 0 0 792 612 %AI3_DocumentPreview: None %AI5_ArtSize: 14400 14400 %AI5_RulerUnits: 2 %AI9_ColorModel: 2 %AI5_ArtFlags: 0 0 0 1 0 0 1 0 0 %AI5_TargetResolution: 800 %AI5_NumLayers: 1 %AI9_OpenToView: -47 687 1 1166 654 18 1 0 69 109 0 0 0 1 1 0 1 1 0 %AI5_OpenViewLayers: 7 %%PageOrigin:0 0 %AI7_GridSettings: 72 8 72 8 1 0 0.8 0.8 0.8 0.9 0.9 0.9 %AI9_Flatten: 1 %AI12_CMSettings: 00.MS %%EndComments endstream endobj 165 0 obj <>stream %%BoundingBox: 48 243 733 422 %%HiResBoundingBox: 48.5 243.1123 732.7402 421.2778 %AI7_Thumbnail: 128 36 8 %%BeginData: 7303 Hex Bytes %0000330000660000990000CC0033000033330033660033990033CC0033FF %0066000066330066660066990066CC0066FF009900009933009966009999 %0099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF3300FF66 %00FF9900FFCC3300003300333300663300993300CC3300FF333300333333 %3333663333993333CC3333FF3366003366333366663366993366CC3366FF %3399003399333399663399993399CC3399FF33CC0033CC3333CC6633CC99 %33CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF660000660033 %6600666600996600CC6600FF6633006633336633666633996633CC6633FF %6666006666336666666666996666CC6666FF669900669933669966669999 %6699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066FF33 %66FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF %9933009933339933669933999933CC9933FF996600996633996666996699 %9966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC33 %99CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFF %CC0000CC0033CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399 %CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933 %CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFF %CCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0033FF0066FF0099FF00CC %FF3300FF3333FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699 %FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33 %FFCC66FFCC99FFCCCCFFCCFFFFFF33FFFF66FFFF99FFFFCC110000001100 %000011111111220000002200000022222222440000004400000044444444 %550000005500000055555555770000007700000077777777880000008800 %000088888888AA000000AA000000AAAAAAAABB000000BB000000BBBBBBBB %DD000000DD000000DDDDDDDDEE000000EE000000EEEEEEEE0000000000FF %00FF0000FFFFFF0000FF00FFFFFF00FFFFFF %524C45FD3DFFC2C9FD7BFFCAC3CAFFFFCAA0FD27FFA8A8A8FFFD12A8A1A8 %FD39FFCACAC9FD04FFC9CAFD26FFA8AE7D7DFFA8FFAEFFA8FFA8FFA8FFAE %FFA8FFAFFFA8FF7DFD38FFA0CAFD08FFA0C3FD24FFA8FF27272752275252 %522752525227522752274B277DA8A8FD35FFCACAFD0CFFCAFFC3FD22FFA8 %FF522752275227525227FD0652F8272727A8FFA8FD35FFC9A1FD0EFFC3A7 %FD21FFA8FF527D2752277D7DFD04527D527D2752527D52FFA8A8FD07FFA2 %A8FD057DFD25FFA0CFFD06FFFD05A8FD07FFC9C3FD1FFFA8A8FFA8FFA8FF %A8FFA8A8A8FFA8CFA8CFA8FFA8FFA8A87DFD05FFA8527D7DA8A1A87D7D52 %7DA8FD0EFFA87DFFFD05A8FD09FFA8CACACAFD04FFA87D527D767D7D7D52 %7DA8FD05FFA7FFA7FD09FFFD07A8FFA8FD0BFF7DFD04A8CAFD10A8A7A8FD %04FF7D7DA8A8FFA8FFA8FFA8CF7D7DA8FD0CFFA87DA8FFFFFD05A8FD08FF %C9C9FD05FFA87D7DA8A8FFA8FFA8A87D7DA8FD06FFC9C3FD09FFA8A8A8FF %A8FFA8A8A8FD0AFFA8A8CFA8FFA7FFA87DCFFFA8FFA8FFA8FFCAFFA8FFA8 %A8A1FFFFFF7D7DFFFD09A8CAA17D7DFD09FFA8A87D7D7DFFA8A8A8FF7DA8 %FD06FFCACAFD06FF7D77A1FFFD07A8FFA87D7DFD07FFA7FD09FF7DFFA8A8 %A8FF7DA87DA8A8FD07FF7DCAA87D524B52522776527DFD055276527DA8CA %A8A8FFFF7D7DFFA8FFA8CAA8FFA8CFA8FFA8FFA87DA8FD07FFA8A87DA87D %A8FFFFA8AFA8FD08FFCAC3FD05FF7D7DA8FFA8FFA8CAA8FFA8CAA8FFA87D %7DFD06FFC3CAFD07FFFD05A8FFA8A87DA87DA8A8FD06FFA8A8CF7C7D2752 %2727275252F82752FD042752FFA8A8A8FFA852CAFD0EA87D7DFD04FFA8A8 %7D7E7D7D7DA87DFFA8FFA8A8A8FFA8FFA8FFA8FFA8CAA8FFA8FFA852A8CF %FD05A8CAA8CFA8CFA8CAA852A2FFA8FFA8FFA8FFA8FFA8FFA8FFFFFFA8FF %A8FFFFFF7D7D7DA87D7D7DFD05FFA1A8A8FF7DA8A1A87DA8A1A87DA87DA8 %7DA77DA8A8A87DA8FF7DA8CF7E287D777D537D777DA87D527EA8FF7DA8FF %FFA8A87DA87DA87DA87DA87DA87DA87DA87DA87DA87DA87DA884A87DA87E %7D7DFFA27D7EA8A8A87DA8A2FF7DA8A8FF7D7DA8A87DA8A8A87DA8A8A87D %A8A8A87DA8A8A87DA884A87DA87DA87DA87DA8FFFFFFA8A8FFA8FFA8FFCF %FFA8FFA8FFA8FFFFA8A8FFA8CFFD04A87DA8FF532852535353285352A828 %7D28A8A8A87DFF7D847D7D7DA87D7D7DA87DA87DA87DA87DA87D7D7DA87D %7D7DA8FD047D52CFA877285228A828525277A828287DA8CF52FD047DA87D %7D7DA87D7D7DA87D7D7DA87D7D7DA87D847DA87D847DA87DA8A8FFFD04A8 %765227FD077D52A8277DFD04A8A1A8A87DFFA87D2853527752535253A853 %7753A8CFA87DA8A87DA8A8A87D7D52535253285352532852527DA8A87DA8 %A8A87DA8A8A852A8A8FF525252537D53527753FF527D52FFA8A852A87DA8 %7DA87DA87DA87DA87DA87DA87DA87DA87DA87DA87DA87DA87DA87DA87DA8 %A8A85252F827F82727522727275227217DFFA8FFA8A87D52A8A8CF7D5252 %A8287D537D285352527DFFA8A852FD07A87D53525227282852272E285252 %FD08A8FF7D52A1A8A853275352522852524C5352287DA8A8A87D7DA8FD05 %7DA87DA87DA87DA87DA87D7D7DA87DA87DA87DA87D7D7DA87D7D7DCAA8A8 %FD047D52A87D7D527D7D7D52FD05A8A7A87DA8CAA8FFA8FFA8FFFD04A87D %A87DFF7EA8A8A17DA87DA87DA87DA87D7E7DA8A7A87DA8A8A87DA87DA87D %A87DA87DA8A87D52FFA8FF7E7DA8A853A8A2A853A27DA8A8FFA8FF7DFD05 %A8525352535353527D5353537D525252775353527DFD05A8FF7DA8A8CFA8 %CFA8FFFFFFCFFFFFFFCAFFA8FFA8CFA8FFA8A8A17DCF7D5253527D287D53 %527D28522853525252A87D7D7DA87DA87D7D7DA85228527D527D525353A8 %7DA87D7D7DA87D7D7DA87D7DA8A8A87E53A87DFFA27D7DA87D7D537DA8CA %A87D7DA8A8A87D532828275228282852287D525328522828285228A8A8A8 %7DA8A87D7DFFA87D2727525252765252277DFD07A8CAA8A87DA87E285228 %7D287D5253A87D5353537D4C53A87D52A8FFFD06A87D2828522852522853 %FD0CA87DFFA8FF28537D53A87D2777525328537DCFA8FF7D7D7DA87DA87D %847DA87DA87DA87DA87DA87DA8837E7DA87DA87DA87DA87DA87DA8A8CF7D %522752F8FD0427217DFFA8CAA8FFA8CAA8A8A87DA8532828522852287E27 %7D52772853522828CF527D7DA87DA87DA87DA8FD097DA87DA87DA87DA87D %A87DA87D7DA8A8A87D285328FF52285252287D287DA8A8A8FD047DA87DA8 %7DA87DA87DA87DA87DA87DA87DA87DA87DA87D7D7DA87D7D7DA87DA8A8FF %FD04A87DA87DA87DFD09A87DA8A87DA87D7E537DA87D537DA87E7D7D537D %7D7DA87DFFFF7DA87DA87DA87DA87DA87DA87DA87DA87DA87DA87DA87DA8 %7DA87DA852A8A8FF7D5253535253537752537D5277FFA8A852A87DA87DA8 %7DA87DA87DA87DA87DA87DA87DA87DA87DA87DA87DA87DA87DFFFFA8A8FF %A8A852A8CAFFA8FFA8FFA8FFA8CFA8FFA8CFFD04A87DA8FFA8CFA8CFA8A8 %A8CFA8A8A8FFA8CAA8A87DFFFFFFA87D7DA87D7E7DA87DA87DA87DA87DA8 %7DA87DA87DA87DA87EA87D7D7DA8A8A87DA87D7D7DA87DA8A2A87DA8A8A8 %7D7D7DA87DA87DA87DA87DA87DA87DA87DA87DA87DA87D847DA87D847DA8 %A8FFFFFFA8A8A87D2727275252275252FD0AA8A1A8FF7DA8A8FFA8CAA8FF %A8CAA8FFA8CAA8FFA8FF52A8FD04FFA8A87DA87DA87DA8FD0DFFCFA0FFFF %FFA87DA8CAA8FFA8FFA8FFA8FFA8FFA8FFA8FFA87DA8FD04FFC9CAFD0DFF %A8A87DA87DA87DA8A8FD04FFA8A8A87D7D5252277D525227CAA8CAA8FFA8 %CAA8FFA8A87DFFFF52FD0EA8FF7D7DFD07FFA8FD057DFD0DFFCACAFD04FF %52A1FD0FA8A152FD05FFA1FD0FFF7DA87D7D7DA8FD06FF7DA8A8CAA8FFA8 %FFA8CAFD0CA8A7A8FFFFA852FFA8CFA8FFA8CFA8FFA8CFA8FFA77DFD0AFF %A8A87DA8FD14FF52A8A8FFA8CFA8FFA8CFA8FFA8CFA8CF52FD15FFA8A87D %A8A8FD08FFA8A8CFA8A8527D5252A8FFA8FFA8CFA8FFA8CFA8FFA8A8A1FF %FFFF7D52FD0AA8CA7D7DA8FD0BFFA8A87DFD0DFFCAC2A8FD04FFA852FD0D %A852A8FD06FFA1FD0FFF7DA8A8FD09FF7DCAA85252FD0427CFFD0BA8CAA8 %A8FD04FFA852A8A8FFA8FFA8FFA8A87D7DA8FD0EFFA8FD0EFFCAFFCACFFF %FFFFA852A8A8FFA8CAA8FFA8FFA8A852A8FD07FFC9FD0EFFA8A8FD0BFFA8 %A8FFA8A8527D7DA7A8FFA8CAA8FFA8CAA8FFA8CAA8A8A8FD05FFA852FD04 %7DA87D7D52A8FD21FFCAA0FD04FFA8527D7DFD05A87D7D52A8FD24FFA1A8 %A8FFA8A8A8CFFD0EA87DA8FD07FFA8A87D7D7DA8A8FD26FFC3CAFFFFFFA8 %FD077DA8FD0AFFCAFD1BFFA8A8FF7D7D527D4B5227FFA8CFA8FFA8CFA8FF %A8CFA8A8A8FD34FFC9CACACFFD04FFA8A8A8FFFFFFCACACAFFA1C9C9FFFF %FFA0FD1BFFA8A8A87D52527D275227A8CAFD0AA8A1A8FD36FFCAA0FD0BFF %99C2CABB99C3FD1FFFA8A8CAA8CF7DA87DA87DCAA8FFA8CAA8FFA8CAA8FF %A8A87DFD38FFA8C9A7FD07FFCA9993C9999AA1FFFFFFA8FD1BFF7DFD04A8 %FFA8A8A8CFFD0CA8A7A8FD39FFCACACFFD07FF9AC2C2BB93CFFFFFFFC9FD %1BFFA8A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8A8A1FD3BFFCF %9ACFFFCFFFFFCACAA7C9CAC9A7FFCFCFA8FD1BFF7DA8A8A8A1A8A8A8A1A8 %A8A8A1A8A8A8A1A8A8A8A1A87DA8FD3DFFCFCFC2FFC9C9FFCFA0FFCAC3FF %CFC2FD1CFFA87DA87DA87DA87DA87DA87DA87DA87DA87DA87DA87DA8A8FD %3EFFCAFFFFFFCAFD05FFCFFFCAFD1FFFA8FFFFFFA8FFFFFFA8FFFFFFA8FF %FFFFA8FFFFFFA8FF %%EndData endstream endobj 166 0 obj <>stream "&IPÏ\R\ ϦV:G凇{,;(.k42'!-6+՚YxlB__nOmN1<bB fLO(j҈[taHӃsmܸؿJ_-|хFsAp䒬cjXMRe5$,v6?4ԧ.N'4!zh aHѠ~$^OqݡaP8uqchWnidlml-lK[n= {VmjкIdtߊOцB_4U7\!tn|t{UH[[O/54ţh4e=J?aسįUV_ֆ9}[fS!`p@&Lzy2wD~}y([|'^ِczXʦ&.ȅg+ ƹVH-a,Pa6U')WE\*uwWVɹBby2wUP5sָtVs8V|. [vLaRsZR|96!9?9e+lx}>*;耻k4.L㼀n//8kW }ÌecSɪh<_ܖk=׻eq$9.a]"uќD9T1-ƒʠ=}hhF~Qh*?YZ6o-=JRRJܼ>fh/6/ŗ4)95B=gv981ĸLfѕk&?$zU/+;^JGr}Y5pM3Tm -Q]R|&ızcY҄"!f|-KR=f6Ӏ%~J_:[7+4;Q,wH(A,}S3=Fiv&"ƉHm$~(7^˯/?F^T>B" ))~tq]cq*2l+̙.FL62J9h}Z.R#?ՅO,:h(Aǫ=m 20xG׽0!g}v._bB8%8~fX<+]d=y1ZHcH҈|^mΡȏޠ!PTGke~6co^ۭ~$2pj{cl5E-'!IBEAmĘL;\CHY#p꣸E(ΞEibŦ?7S>jpxXǽfJ4k(|.;|rG4OD ߜ GX˂o05t滔)0&@wJ)qG8vп2nL/Box]K%F.2}p衑`El Pʿh<}A/z=ϻOTjxEGݖ 0yO+-<33XAnX.1=jypexVy/߈,2 qI<ˆV(@P&M8[cof΃z y"E=ѡwpr8pw·/)v\\gX9%٭>S2*s`SDc}gGyA! R,Ƙڀr ?q_c< ?&.Ƌ:-Eor S?s?Ӻ[5ΘQ0њӸ|zXoUZ”HLLJ:H%X]|]Ҁx'cX8 * SDw^sA_쨛n5CTߨ #Nw7 ½h?ƙ8@٘(b&;ۅ (m&1G@ (,%)_}T@ # @20u~ɨo ]6fTmEܒ`Ÿ&RJ)1 L(IvDžn h+_1:bVRDNZ4YYvOx\\8 HZ} 5of#BLv!72n-T\𷕢nqG^z vq;ҟ%\R}?$?  dO5bU-y?^xC 7Q)_gT+=+||O*V4r+ OP=_/K _&~"&fJ0!Ipmu % ~HQSu+ $م>23 <ۢ\L-]qH>bڍ'b"Q' '_e '&$7_׀]B&PaeT@N:PE$1Ԫ0W*@6z$ql~㔹_oowZV"&~|`{VH-PZ<0lF9F;#*Q51E6yLOrBmi[񐇯+)_A}j\Tnا&µ~Wwy1~(\D]87AR5'i$H̛M53.(^[9^5tjL#Sg^&"nYknB-ϥU(} y2mϲ9dT)MQLz j\6獨OUyxhh8rgvM*me cѼ5=Ա,{K3M~'NӞ#?c6[O#uR=cxp~3C9'%f6qH?O jWke0$܃>ڐ!=n=:頓[%rq:jtގ-DPomPdmrm&IXf@֝~v^8܏\$7jY'/ZܮplBOWR[%kƇì Xfաv`&^e؆_l܌~%SDNʚ͠'ol̉iҭrf9J)f=,;|vBX^mh[A auR[Utiw*m+V裻.r/qM3c(B=ϣ?\6?6.&^IqOMo(gp {\S[A%=y2w. ~e^[6(%*ܞuai-L*i="Ǖ#t~^yrہJxb~cy.o܉[1􁚹R]h}y>*: +>ÍC0pVgJ3k6ѼSp>̫}q)_5\-\<4s0Wɪ|sDŌE+Eg5ʤþ9 WƼus}xkۑiA.SCގקҬ4򣂢 @Yl2*o3FJᰭξw@ܔ*TM|*hNj9DϹ^2;SԶRY P=L-5tF}XL:nۖ_@^}*Jmމv"0ЂnM f61 nt(-hͶn5cb5Wm 4J(*MUv[s i{U~y$ 4#e3k=xRxM\,9fs: zp-ׂUtR om;7vvӡ#LHrYRwSϺ%Љh1x^@ =sC{<kzYq,=;{e1'5|m Ugt ֧Zqjw+m^+l7+jR${lMPS| /ozd 5Նds?~ !0m2)m0@pɲ{FYLy;4v ڦQ櫡Yh4"SN̲r:9vYj׊KO#-} T$D;^^]dE? "WޣG'FJTLZ t[,cx4n^Ҭ[%^ZĚ'C>;t xpBa8aY8|SkwHQ3rȗl|¼sI--kM60oV\e<2 ҺQD|or0y#d$$db|gm7É}üϐQ:nsE/4744Nb.du0vڿkX>l?創_,F|JiiԿ2ذgu!&!GN B8hp ׆ϿZ`l] 'A, _I iZ zh2爝zJ*sR6g?d1N+"2ZA#q=HW"]0܍dl~TRP>fr[lކqLZz{(NH J}8W L7 POFZ P=*aP3_ˤ\yF=wkvxsU`32b*͌t%lxy>t9xҩm$\z @w@xaHW]a1 ԱbQh3RÌH_!ۚLٶSsE@e}Ýʑ ``wouO"c1J4-^Фsk=񲀴Z"]UrэK3ߩWߍf{w" BgWi2'|>-+M] R x Řa/hWX9F5Rx`ublQ}^#g0p&;1^Uwg\?縂>K娛BI6 ;eIawD&(>.@,R0 CQ@rB~^ <c;ަd@ ?nD )8 ϛv\L맨-UbҲ^6uHuղ N (Oh@n< 'G) 'mBd-;b,L@fu@f;@NnW$:&Q p>/k4Kl+4&r5sap<#*[9xt:P-/v|ȽlyP 5]Qjt]j\'|']U u@)3-{N4GbL^IBZ݅dI1mfT|Dmg_Kbt1~K1\7i1:G, |$=07u]MBgQvvq.oflewky =`T '![d}/}Ǐo䟺w#Ƌiv}=րlpvF`kQo\63v=<ߜ6c,3cVPbqOQ>,4L?:/41 (S6r8o9q~WF<[ ڠ<z}N2$cvai}4Mƍ'? w9Fw/[uussI8ޜwУ=T;o|ح"!x d^z wFGYs[zD.b-нM:䖣8MMbZ}<]f htbf):bZ]}"'t%uP/9)^x݄S.[}-CvY*2<V2j j ?XmB )sxfLOgBixo09PA?^O^>v(ݙ8iұ}LD!_l&L4$ay]S fPCMqxYnirIj4CcBi㤦h4OɈ/ʽmiqkj4޴~⬰taIo쯪ax:{i .d:Wצjzf{D^6rmT+Ӻ؆hjQfWt಴VYռb>2(Coda| Jd~U~NL4PyVy h(2RUؚ . veU9+۰P.sBR;w}Otf^ ~?+,YVXp+ T2򎸩@!-qo ˪Vlje_N/޼4Pd|]*Qѐ/@*B@+LWyk7q-dKHQ6]U)xc3M MD,ME-Iwe3atBoM_ڭRdkNƧC<_W!O_ܶm&ffns\[rONږu,9i7M163}| \'8Qpu0u/EBhcǙW/3\J<¬ sLTWV'EaަcdNꕇ^#p9zUgR邶oikx#j}LTV r 3ʷDhs JWaףgj 0)yzU!^wfڏ,G9=]UA܏17M~Z} 5fjXPy mK!y:R98H6ϳS$ͣѭ_=G_]ԭ,YًipVqR1o})mOyz7TUBkrwJm=d]Vڒ,)5-a)Z{CtMuw'1͓Vk=AwV8IuP?l:t ފ$Ӗ~$4,_=* Ƃ^$Kp(qp8VYD.8F/"fnUn5!:v1aL3a52lE 8uhF̔t3+N?RW4t%Z吏rGԯOq7|x׵!UaoU4M96-d%eQP/ÈVuWe\u|wFK2/2#6IϽy sԪ-7\\o//okځ`IE-e'~vܺv߰;a];+S7/2xƘ549Msou}u§FE [&Ql iefzςw;^NũlևY9m+L1faUPfjfϩM-Vޑo^>WAf1Nuq" &^88^_41ݾM!QB~W1͠5(3Ч+B83JϴwUYU=Eɮ(0*@g}-h͎s0"ReD -or5^Df]cl)̓F] #y/(^&HސZaQY9pYhBD}o7BNOVpCٜsu OA8x8T<6Tz) ,eż44hBUvVs4GHQDjgk:){n#E|` "s@R[8rʢXslEg%HY$/u["%}~bad;M( ql ѭ\6@'>:*F񈈻 >w>L t[#JT{9,Cᆱ]6pzX)dr K}Xө\hh&.;Yǣ,x H([4N?  IBEK;IlOYӴՙ3[r(qWqU\ru{,;e~<ǕTεF* \_N yx"YS(}= 1ڠ{p}mcFW>N3e*CǓQ@! ߜ|z1.nB6@h &H {%٢-Uv\o?c$kWc 9C~GWM&q49nol -i`\[:p|&(|h 2W/Y AmaHM/j2 ?zIvyݿZUB<&T-:s,A =L'X44Knx1Tǎ<@s0} |Pj @+ PUL_Yj ҏm5rқtW VG)c;aBZ&A}Tgl&t'Qac ce-c<2Jr4}f(JHWjĮv| VM`ݒKD%pMА.dƣy<<$Yh 8"Ȧbt `L6Ƣԋ;:V:xx,:7V'Ax zkuou~^[n?x#?0V HDy|g #S :O@H%6_AJq1Jz|Z1;w:K7$~p{Ñj=w9h*}ؔ$Yc;|i`FLBpcG20@:2 HUDžLrWoˋї8/Y@lp'kНۡ~+w+Yb;v屟6aM~+yD?Rh%x- 7:s:M}@PI;̣e8$J6=OKA.V=M̓=k,l5'_%e x Q0zXzǸp՚`9XrD80`6zDecMM! U5P$C*/qgU8]BN u6 xrb> hp\i&KH竚׋= r-l$I,.y?W@4M%SVD:$1@̊ 9utnnHqWXj}m _9ow"?}p_o=|7S9k%?F"j}RLUkZ;ҞX:[:43Xwķߤ\b-+@ֆ@'p8b|V@!':W\*783IݞOw{;~R{vw IDXo*<{RR"E+CHo ߺHw+CWܡbf=f oUp,"zΟ5OP>}8!sL |Cmxdáx,=~ʙOJz $.СiHrlq\ }S_g?jx(Pp rr_Z_p^OӋOXf?ngbѼH]#b5>n$n+m#~]uwG{wc4}/WD ci_Qdo=8`[#b6ݵWjR^Fߪ1`09s9g5omp [fx խVKj]Rx*ҎOEl 3_pCnW1;ss"uíBBAX:Snttv/aɓ-A#Wġ W.r;"J`PL5k!/S֗!ڥzƲ5W*u] LW(fnjʹzX"Dg4>%;R:RX3DwX;@D82]lSS^t†"I'Y:swN qL#1E '& :ig1kl] ;bg5(S'Ae(}iH D=qfgȂo5tX^:[\IJqumAGf.y=ŀkf|5c{-[Iĭ*,pvGC(MCYF E$ZD niikO3/9)"ٶݭi]iWcdo:{p7JcӄbͤBLl]"f.nPPb MZWƗu;>e;|תw-N(\s /68=uAgq_kUmo5WMqֱriFlZ"E]5 7CW07::k.=uע2F]ͤ/ MSk@ىKmKg뢧&,bӈŪ ym.nâR`~ԝyW 4_U;M̱L3a_E 2SoQ1{OO9պݚ :z"nk[8౑*h BT3&抹 MEnlr[c>{jԕ1Cɳ \VtơKE4@BkckBkKzl6k^.%agB9^4NRm5WwuiRfi9ۣXѱsr֖@4[`vb_ #PWFLOtPb#ZZQTOMXɭ߭p 8㮒и*v9Gϻ1`C;= TF z4X89;j϶VJlH4vBLjʹfZӻO2n%DklStֆ)5'\ݾZSt$ |nwD&3MmZk% k*̷1ev@C <"t,+zZ . N5 8Y(U72\-`Z8Wx:S TXʎ6b9:C!&lUCsZʐ`-?Vǔsv3r(r)mh@pQapNTAmȫ@D!R+4̝seRŕ+, 3lk64sebe0[t  3m>hm"28HQ("VUO~K"{PDeh\ U!9o p:8ʹϬmuYfUV:PGsMsRR뽩rd2d.}m؞,Ghp tp;i"\!@pգ& KzdNBK1gb똙TiPxzwW[8RYsZ6_fطwb^o@d QYtX7#Z2"ˑyűé 5PiL}GY!xDbနp )hzɑRBJ>ೋ,b3gʄ8sF{w78~f-i qiB-xj*PWuMQWXK,@J׆.tk~D#1ZCʜ5nHAʦ )[,LTKBri) = LT'ͩ/ְN&xշi7ZT/+muzS݌Cg%)Q {DxF*<߆]'H&+hSc1T)"/Fj'ʨ~hcz@*3iT.9#)PzJ<zO8 ZcZmJpWrΨF\69Ԇ7w} y6.3=S*`؂ԊN RmH5q 5S=4jRIR=Eq^YtjwBi@bg R sQ .9!u.携A!ujԡFR*GH 9RHLV%H b^Ez}ޙ;eʺLMRLCp}R@1}{! B*%'kAHP0F=&|ԣb-1ň7E@ !zG洫' }e1UH40OW*"jԑ+p2i KD37`,cW[Up6}?ߍycB[gM$!PCЀN=Za j ԣ3Pzy1@qPMLQƵ[g!d'*Y0bP OtxDX=]0XDz:u(NB84$`q a:Ȍ1a&D&1@X̔-kravHPRX:7"sܥ{/tz]kLdv[Pp (e(а@{u!ܻBeL@8ůQ 9ƴBgH٘!MqPHTFYHS| iat4[ݗ!w4֑=vQ)T6X-vKM>j\k)[D6ёD`#vԏE"4X"XzH!|,Z { גnm&;ƚXpMzjˬ&`&C.Qi]6]U=,)9q$A=)&&0hCX|G{nZcL=VH m69QjiG H"xE)g3 S̋0 KU#(b}ܕSL)&~,e W<@مԷ}MAz{l ɉ?phB.] k̔Lǧ٩.\Ļ'l,1K&dK-|<K$ h%.m1UcU)N5+;PC~][d-H#p[ؗovqץ({;hd]Hƃ2O6˦N,vAfHJ[ ᢌoO=(2O/3jVwftݧGݻS2;KQd.:hcr<\Z5nG%@bՠ{,FRD%:jQoX$d2F62cRXm,ueiEL^wN ܍0͙r6C3a[? æStbF#a65WۤR%ޑ^lP,]AYT.撧` vM=5csr }c} tO{Q6[MR8)vÙ"k$ѩmγEda϶+˂%7Y- :0:tk^o[XuVƉjr¨?-Âdéi&O%_SkszUBNιk `200`9͖M;dETy4;zE V^yZq[fz2!if3ۨ١d/o (Q\f$8xiFRdxiF&O3U|]xS#G?2d#G?2d&S1&Clte?2d$=tlD"ʣǙG?2yU&d`N]r3qf׼枳4zF?U^ J:^CW+sxAGqr-uaK`Zohh-?rn#_EƦXMPM[|sPM<Z{W:'l3*McE\-5p]m&tn$]%V~kaUeҝk{)>/ŷ eaC]WY@b׶鯦Yed_@f=![P/+K"ZM[c'%sކ %|"I Dd.݊i/ͪHnH ̛8]S$p_,UtߐFO q EJHa-t4mV3!FYnm?@G2$ٌGx5?2,b~6xAh>!C3bI3ZxTQ&ɬc6(*hR9hD_-bPIq>mQoZE|h Pذ@f<6[Awˏ3BF]xmꂊS-4%2?JoMU>ȝ]ԄmAQ)*OtWQˌk2$:N=b:'fN*f%_Mdbx9Fw2tNrFVQ\s1R]Pw:iU5/Jkpo2P{ĝ*\ߴj9ϼtY7TF: |PDPvhAAo9$teܵX.2&vKh&S^2n,:2*αf3?2b:ݦdV _q`=tZ&lI.G3)uqjR+hi1YmjT%J!5Qst>LCRxTqQ6D~/.u鳥#QSBiJ6@!Ti# A V+M^E:S{%$ ߖ=peG`?InQS"u8| &J{,ʹDRAADB (@4m^0rq( ^(U@fhRN%}͌rQQY]+>7ID6<&{i;u4MPb 2Z\T^QR<V'V{EZO=sa;tm gjNOmG濏5/='w07 S dXyױ L̰$҉΄s"$`3Z źwrz_C"-3!n珦$6P&D}EW5hّWx\X7sݗ+oqLoJWGnѨoJkӚ]B 2ʝGHęhu%w)X}HS%8l%[w~VPs%\bD-X7BUJ"Ov >~n]fƤ8Dz]L#,dێR gLx.DګjM5_:Kv [TH5(p}o1wnts^jƔ@1m2v*yሷjLŀ?$Ŵma m45fe;5ݢ^A t!D%LiK5N|1 AI?j$b>s aq47vpȴ+%eEj>TȌƷG!A>@TڅqՂbvDtl6}3H bˆ_3ZV:#1wO5Do+KD?Z,0$M JpŌ˧mhɲ`\b[j5_.mT&r`dQ9x-J9."ФeHٛ5(i@6F=]o<Dcj6+- i *_7!PX֐b m`-ji(^Po e+x!R`NAb]RaF4@9j7գBL謴FG+ҚU]Uhzr,;\]jŒjR{ vRSpU*\$nUYӒSPc>e6'`\9}C-+6X =C)WJiTI/D7K߱o!c[?5U<5C# Si T2Xke.`F@C9L{_Ǵ}<=''viWZW%_ZcZDfѫPʂn5%TS4=Z(%7KXʹ 3*4Ĕֆ,[C"FТ(^j,P[VE4B]623-yC(Tm4T#[b.̻aGBpC3qU+QPRrJ%@F`M;6%*qЖ&[u+k\^ǎ>P\@Б7_88&+W9xͅ2jI,R(qgixOC2D1NF(ISFaT̸y]1p.n&]șpVQӓM7( zMd=6VQ $Q\ߋǡn Guf(&H)aЮWݼbܓ ;Cg5)q^9$|aѳJsNh 9j`\es``ZWU@eǷt1k'K?KjJDgN4##O;[ @Y7/ ٌ5׻fjQ~N>LǩhJLuxςHiS?G.=\|Em:!_ցxenew})yN B͸wT#S~^Ӻh6]ח;tbU6 N5Ԃh"ܵm{"Di!˚}u{_@f=lEcTXQ Y\fB=]l/0YH[8>sMU-cBƎ;&7sD&3nGy {>A{l 6Ig}lw|c@xbз2p=hWmBfn IO *p| V#6rMi5B늍҈QW鎬i2f[qL~ bH_p;Lx)%X!&z=ĞȘ0lBT{ܭΈHˋ=fÉES k +>DiO 7 IvZ<Π(]"녅>(*x+Üap!Wo bH  DɟrD|olQ!Nʶc]%F&e:\uKOinZN:OW\Qytﶣ~qpW,x$An{~8Iacwe3[~P Q \qV;_"GN{{!0½GKb#حٻ)cZ`P]4N3rSig{xWǗ?Dq[^̕9TYx8A)/"AD,veh0 fGIf3Ãy:Vz $t֦ڐW("؁n{DE@I UKYXvȌ^,(2@ޫV-( r:V+׈ˣA4qȑ9cs0 3@  S_7Wyvieb$b^hwȈodncX夑T6E ֆE/"x ۦbVO'(?P7?ͣ,p-$[Էr/‹HR{cY3B5ZV9,eQB͞"I[G.̔X܄kº˱9,/U1sf<ɀtcM>e"146yHFT2qdzEklmL^&# N실hbL;%_{N554X& ixd|-_ >-\)vRZIdy|ؚV3wa@ʫcUH/\ObĄ$ev[Ŷmvz%8~g֮5 vB.YXቯ`,\2U-]0W`rH9xץZAYFjjaḍRmj#G? 1^d4}sqFldktײ'C#4hÞϪ{bHyrP*ll'aU Дw;شvZ}z=^j¦BPsGOxt+i?6ʄ}U`_/@40ލ.q7]' ? g>A}C踽W/ɚ}bw--'ۮ賻sq2Ǔ1"C/8-s~?zGqG( %L=-^K߆C=38M/]p~(E2`xꈭp3OɆ[#)Zf{ )(ܻrCX逈b z B "zΔvPy*ɤ,=1r}/'}vk1xt-J@EF?Y~9Ԃ_|n}s ݣNⲧOn_C4 rLaN14@[D"C4}: a `uH51 | Aڤ|}pY!܌Z⁸ũ y+KGPig03 NY/ةC2qޢ'@v3^!ؚF]'w` 7&!xӬ0:5/ ۻɠv*ְ(UK:ިrF:ù"B54Z UJĨ١ VOrzuJt,ncn=a+ WY0UsIF֜Xѥ]5BTNGqZa܈Qر!b *gf؇7R#gjMǪ~NS5h㼶p5b쏙;泮 IQ,Ũ]NSWgو>X/!'xQ[CS*7G3]JVhԂ`M3JFU+,U.*E7\sCF;UjA\Z#.Y!d 3 鏧}^ jx.D5>TӶ`Jk -^U*0֦L"Ad[+* Ch v|U/ȒsrҶS b[\Tx2(F5DVCaKi6Vӆ(JL[QMսOBMjRQ^\P涙ʘ0lnA JuuFn;J 0՜; RkPeȰe4Cu87x̀*<9؅u/EsRk ڜ%rhk {/;fIPEZak?Tvwжw&Chav {/+֖V/) 8.|KY=5^ nWI Na>yH*P+Bjc`*[۠iTз 7hg_08aoy* oDpkEFyG[jv{:qȷmn>2MPC\-RyJ%II"-)k/^y֞m0o "lLU]"^oh5E jj"xôt腬3҃0oԋBL֗IoӮ&voUiы\iC*ԍa ;ggEn"ʴ)ٙ#˄BZa\EO+c &rI@os:Hww68^G\Ec"%Ҷck&2@DXjl2 +PM,w׳[Q)i7}ƾE(~dC/M͓ק9,qzoazFH[Lד7Yُ?[1MAmn^mY#lZ{P/e3a>w(^g c1Xoh82MmNN'BN m&DeM+ w,n\ifWp}iHK&͠X~# >IHB8UV 'y'TOX3:_e !pc84agV3f<{s3/͋ihOIC8$(P;r3Bƒn_q95(B+1 _f4a~YNcas_Tӟ8SMRIgG.{$s_iI#N l䴪9EȩQn:LJ,cr;oozW%b;?}'\j6&G;sHh,rs_Z)k-,u.S|8EܽOd0OT|eigz<帱WQt#=$(O%_8UPe^Z 꿼Ƨg d55hlJ"~N7o}lkAZBNjUZpҖ޹g?Fo EY{/GtH Ī?;XNqt;F W@k(P!ʂ()ݼZ}@ TdH7v=o=XtCoҭXkON=: ^F@yS7%wDa\yڐ/NCoEUy!tk,~^>>Fd?e A<G5ASp܂>ZMLֱV{I8jk#/yC(&lQp/Vbj: NWth-CTQ늚4󎡷)]ShdLJ0ՔAR>2m+&S]ϛ{ 5 {G)Ktz#2+3N'pL5nǔl?7Ix XwM`Тe `Ґ֮X>g|X>V}'IcIc(2Ic8_|@OŃ.'IcXp>i,m841~{ G"q|ұm0 Ygjwa z{1Ūu<".U7>}a>P6L< .'ћBֈG(Xy?B}#\ @d1$DGKbM8HǗQ2CL_^, =M.sy%AL`C~ ~'z&mEǟ%n,νX7a2sT wbLݿr5Lc4=j !3E@fm_~{.aY_iTlXuF[nJ|UG-Ӥ=%΍%Hh(inļ7_?`?zӗH!yxoLJ^:V^{Yi'ldZKƙ"27!#p27_@wS0PQ^sVs#eX \,%$+c?R[Xֽ3,Ǡ*LݏvCNl7@ ΅#ĥze?k#h8r|62|ǒ&#9./S57q8A'ATkɶwM4J"pׄnI!?}G~"(`/~to?:'!;P ^59BÔmPg{:Q9uP{lPb>(%ӐVk!$^<==\<~.)'R0Sg?!o51򓛉/^ ˊyL8.d',Ff+.*0WCu۴5j"ןȱy!'kax,}xq+W,OvtlD0s8^;?tCg}?9/Ovm/39zG>ɗ;(Q>Z[8vDGxrH{VhY}XFLNN-잕 `~' OJw'ҡ 2ZG}" -<I?Bq$'}BiI`=~ǟ.~'qI`ʧ}/zHx`tC''n}B*/&'}BM_'A{i}r`7L{B}B:=`sH'tc{~kP?3qk鋕_P}Λ_$lO8( OhH_Ib*6L#Hq4{wthQF_I v?9*J-CǪ5Y>]j2-i^'"%J>~$2m"l$X5yMQd.I>M]d<(ů"2orHx-87"^J ĝ~zt!QddeWstSxY\Ipn}uOw<\>ɏN]v#`%do>"Yق RB>)|<9N>əg!񥏆Z>jUj$:e%}BrSeĔdR&.[Q;/D$rDL@e3ZzCZH;#SeEf\N\A"뤠)8M}?,,)^60^pZTqxG V-b4Z t+% '_ ̿k =:^,F(ś@' 90F~|hdʵ' M4 cQ ;u8@L^_l#ߘ'rJ;.6b} ؍_ľrC5sY}!,PwP<ڷ}QmKFDh M H{ }_F Xj?jcu7H x&WT#=Tr"]A;Tk/[۳.˿-HE3%,$$S'j ٞbX*StK沐1tZ(!R4McQ9s,7%^nN]FV-)<Է-7ճm+ɉk~f% ǖnۘG{Z!W3`gkF9܌ uP(Oxz֏c}Q-_082^p2wrx@ SNvb@.#7֋R%`Ѿ`j\}Z`SΉ~șK4/}&ݮx>FKՊfFHFw(Uuz2QTo<!8HC]G*=YR{O.w\Xa#򪹝դ3g*2r˚{JBǟPt`$yy)ɲnBlqXAOnbsW-L~'pӧKSdLŊ8n^+~q/nI"AU֪ڽ&5*OJ K| xC@_UgVOGWUrkO+%ĴE^bSla 95̢㡘9ſ$U!)Gn!NoQwIG?~IG[ IG5Ae5!rWy z5H\<"/QcazGz^"u~IXB^NŸh;w dF;f_,Vx=r"@涘^]C;*o JřbC eH!M~$̂OZ}ᇲZ=+x(^Çmv(_/xxÁ cx/e)x(, ྞYP'4PyfCYl_ܾ숸<|~G #4yCyaoPPv\{ϊ*Hu<-m4sLelұs<’wOo2-:]^nCi1S '\qXGG`4maHG2:Sڡ>S̎VTjKbO~w ݡM8cP Z+]",j-|҇m5N~vl;~ Դ4/,֕>sbplٰ,s<|TPx4)!nӫHEQw{PnX >gtb̭q'KF˵(wY2ΏDžO{̽ !C0tE~zc϶4TmiI}n"(عMBǣt7tn2i(hfQdX~ Ҽ<[keS8=\^:Fv:ʗbI_JxuƊt 'E4 JQ;>*amWc^mpw!NhԂ_քqyFq?Y&V@u(P'  <,m>7VC }&T(O$ Z 0AhupJkW -9uzv+ї=W9#D-f'4ĉӟPx2&Iv="]JCG)T@Vcu:5.}s,~}ѹt/ /bNz M"s؏߽'jo_PczFr$}3ϊ46v-z(^늸08YZGBQIfc:ӮWꘙÔ:fH}#A3!2X&c MvפMCsk4\az}3={j.϶[!oZ+'0 R@90[.بgU{u" J`0% WiA--w^ѲUEn9ץZ5P<z4(T|/fhĘSiZI|TsƩu=ۜXMӉ^ bCDLa& λ$_%X{!ІpfW < ^?cְ g_$Ƒx3J(P{<C{ htCVt 'HussA8qZ1)]AGlސY̢D6~knBݼ^~@S蕃- ۨ&?Ľ*?>7D^լ^JzoeٲUljn"N^!@PB !\L ;$7Ϝsv쪭]}|F]=gyfΙo$mwjQ==v S@ء+nnjU->A"cUfꎭ)rq9N]M~/gwثV`jklU!_o}tYeԡADW})BSwr{vWu$69Rm5Mz\Z6Juu IB&voݑ"ʴC:t=ܸbNffj.g);3G{i#׽1SֈZ$eFi=AG`<2%[G]e6{ʅ^nY¼41 -va"`59bFy⒜Ij +^."TK"gIE{/ػkR6dvnjߙiΥؑZS32oXsg {/&;նxwviɭÝfj/ZC?@-lHx4 j@sS>_x3Ѥ6G '/ԏ!?hL_TTbџM0!i,kDXXEӭzߖ6Sɑ%"# kn)[5,Z'.v|I{ʞvMmȁ/8B4u3PSvRx< )M}7)TaU6b vf24F|Dd!{CWOt+k^ʹiyxP9>_0*Ι]tZֽT eRVڲ59%.ؑڶ8_}Ll+  bFs6_)MZ~ݞVf ߟҜa`z"& @k?N 5T5|gQs}yá/ЧcaQʘ@W<_I/il+&נr }:(q0$,YUT*FQ?'vEZC ֥mwmB6{y9bg@C4,{zx|-y3g׊T-wVl55;U1 שn2 m)3?ܜzc٘((q3g TaZe#o^p#5'/Gԟ'Js8:ڊsԌx缡y]{+\(I,u6UkecO+.w;uo4,[nq)vJIi|۫㮊K. hL]+\2h'<%0/uՕh;ۉzl ~4dڐ.R2\w) 6cP\5q ZS4a>T-ݙ橹ڢu5r:A_ݣoB~H}WnG[Na9ۋΤ|oy:w^]ԴJV),4;YTƚr{FJi4D?ƕwghΘ4ߐj'њ)lIq9 {N&_ԧ*Зng:#&%dJ.UH-z24ULf/-޸4ϘףdnJ+WK2E䢴*[o2EΡ;YɳyD9z5rx|GZ\݃+ƒ 'ﭔV۹!Tp BU`:8c=n-ۀsb!(JBB0<0Xe쮁n{%-6]+牐WRen;^[`KH 6F :f*T(<Yo476Bt7L6,6_p̄ͧ;ڞa^Xޥ+£m2ʊ Dͩү΃yEp}bmZ\(]L"|v!AӔkRnKmhM - >RtPa˄9QQ<_2[?;a ?yּ5r/ &#뷹9I.H[X;TlK*u\S/3jf @TAtw>'sFܝU!Wv,JVk*SCio8N &cvJ#,hѿZ =P0yTe, yCT(יnY ץBJ[Q X3^ m@ rО8PHUUvS+ZwwjbPo>Ph*#ZJd3:˵,G{il^[@ϼ-YR-XUQT.Tj8E[^H;_Mu,RߍӺ+cFhi9 WzW5mX-+Ζu+3/#-5; F>WL$Y'2Lw7wK݃U+ں+[@g\FGnKZ(f кֱ*&|uifF~`&ѓZqb$ ҷZ ij!qͽ}y#]/Xi^$.XϔAH:0$~fc!縙2m%?Ym4Βɲ02e#NlKwZ,,xuYH25!2yZv=i]hUA>p/,4?Ox+0n*62ł^1M(+e,]ԁZOsץ9/y]~x4ˈZs^#* x]4LZbc@_cc*_߭s>S8g.q9,Le mujIqqnI>q]^giҊF-+'N,n:ssX1.]%ZQ~SցJ֦id*[IJ`aZS"8f]#ŌC=y+4L ]>3?z([eZ_2""zlkE/j:ru89j[㶑>b2ޟ v[X݆QSS漞VAQM|߸׳ ^_H޾i-Vn9(I3dPnEIL  yF k_XNf o4iVkػf1Z"-JSP+4snq@R[]r[`MQRt`e[;^JPAU, ])#$içb30cczPe(UFaF}VHד{6LB[\rJb\=?߄qEǐ 4&-2nvu|t19.F{}>o$v1&9MrhgGwD`c~b̭Lu۴:3vI {>]I-xeAPfAZo)eٗ٬w'VkR֌lVj9u}Z gxmu|+c|S>)Z?%!!K"ER!?QvTb WFU {K};ֳ멪,YT^^WXZ[TXU^xIyQJO=i n[l󲠡o/S6%^Q#Z7=i2a0?YuR KІ[Y_0ުׁU:L~)H@RuH`Ǽ~Y&43]?fm;&5 yCrLdyҜZciaK&Zxu^BIM9G4 H\YH^XUvyr|Q$t>Y\!.HNW& _9PamϒSfKD++ZnY*˫L3  ڵttZ[d4Qu\f|Q2Ōz#+_\k (r6ΖI5W2ޞ`X+ y'5ؖO) 5M/XmJc?0f:<m{U][nάt9V;0(yK3.N;W,}h7F EaI9W.gz- i_q(ܮ(!v&Y_2bau$2Gzy?"#{]ªQc[^,Tev-=R=}[4[FiՑ9^[NlC^\W`LHK/`4iQTKU ^aJGjnrʹLV\K-f꣺u}qi%OE+ZDu8z2zJ  c.G[*s1aa_݋E3* m.V66e*[ 9Nҟ= ?f։_6!95qݕ Ç#auV teTZ1?e4Ngfl2HwE̓&̴T&b|pOyFˮ]֟?͘ϛ< fn2/ƴ;v4OeRdJɼ49dl#bه_YItenm.K,ǔw%z&p'i4)ou)NiX{om]Qb ؒY.*CByp%"8ƾOK礧p޾ɲol5F9.1mҙqErA[_]ҡisQ|lðd3~Jo}fdŐ&Fx3Ø␃1x,_fyCQ}9Tyfx;2ݭW/GM ol. b7QUE^HCZ5.2̟_񿑥ئ? E|CiC0"eS̢ _ޝg2lңʧ=/|H"݋:e7AkĎ]4@ *PS醬5i%ݫ? `!{i;+Y%hiĢ{cMuWLY\͑Q2K\Lej2X*j2Iҏ!8r˦6Z=h6'{PM0mNө*K4wW!;IfaAyaoV#1ZնQ(-4|UgGeO@;<, SVseYr,R[#o箱vW<<+d%}-i=šlNV`/`+dYh쒤Rp سA+BoU+7dۛl6lOssX˛4w­֬ ]gS%o?].k {W}{z\hVU'4t.H޼d|ry2.8.z Cڪ?3xۖ>LK(Z:T~YHs戼TNŲަ5%mumKg6zH<7N&ť7?gxtΌbcE LJ8{2TǥmIj3"|M;RUvtf&&[kE}Ox^#}%h%}O{ӌ\֖idmQ]1V7 ՘ 3O܅l |oez0o$Zi7kJ|oyh2L_]U 1{UMKM|/[pJMvWgQs8pmjZ|>kJn\PVڲ%Qڬ7ݙ`qtKMqTq>sqs,$ Cdoc8 x18/4/N]R^S:#?Б`TNi2S%Z8fL2t5gfdFZ(8:;_z\1 5iԶvk~Zi7m@fLizceX[X`˼,(#`\p:ұOn'c#{-ڭuMbo5e)oHh} P8My }xKnLu6kxMy 5a4MՌД7>>@S(fm} PL Mm =>>@S(&]ִ$-&@1{Д7]6[&@1nbhʛ5m} P85wTmzM kqjZ 9Q7g˪.$MFi}l &@1 Д7i#aMy __& PLFB#4M& &@>XMy ssm'hʛ:u{mdCO3ljPRncZSfMjc)fYVf0MY֛2{![nkWk*N7eLc@j(fQ#}i6?H7`U-+f4k3}٢L4dCiد>KOKlo_[:”jZk4kk@|0we겵9 %ˁEZַ(yVM']lU_IOP{N f>E]غI>1ƭZ]3fZ7TĔlar<$^'^Z;8݋_(]8 {M'X-~qWťh˻:K\sqyk- kcbOwOϏMdoj^L;bŖZ1-n;X[ɲ]:'?u]]a#9m,Kəz=؍Ƌv#֙؜zb7˞csĺ^Oċ.'te9m,3p!u9r^_#N>'udy/. SY\Oדxp5)%žM;gKrH^"qe)=Sv99^Y,(v\.L,ۍI5z^v{VG#3٬'fؔ:;Fy )%coCzAUp9lYyz9 Be%*11n+t:Y.+T?+Wk0cvz\9RY.)pZ#k8# I!e~՚bz.+7bmL=b0RY&|jXЭ{9Ua\iPNݙ8w8sqFɕǵ;sK;#ǦI~a~Nv{}?R- ?ѓ>goXa|~2F +'au:48:v{?au!=@qLsEqaBppڳ.3Dh }5f(ܰ ~1+PA3n%׸$=Fp1i ũJvsmr\x%MڹXB4_l&khۭ\3GE)/Nڦ㸲^S{xS#Gt%./Kx5E]JDs Kѡ?;d y8 )^(Cjmjs{e fej=Nn\q{sDGeٓ |R!_]d'a=fs'/ >fi3Ԉe&^)CPzy{[ ƾ6Mr ,T|J\y{Ŷ h}8^ h-1fqzDt6"hsZ_7}C+]];μX[t'ŋ-5{?!B!BEZ-B!Rk!THBͤTǹh!Tǭ B!$:1!8@!RKxBHxkBcB+~"B3#BSj?B_!PTePBhJ RkBETT!"Y}(lB վ@!dEV!TJ:!4R" IqBMT4h!"> AHk XR:!dj T ܓj~30yB'~PK!R?`AET }B֕jjAYOXz`MBꤺA͞Tw,B3/"Ku!4}Rݞ @MM0D/KuB(|n0@.6R.$TGPRF( KR& B.m ` RݮItP4Hu;)Tn;3 B&m`@(R6YYJT +=X R?AHT{Hْi 4R]"fJ6@tKu.BS:  4YB: 0W@(\s Ɠ: 0WAh4sFz h, $pܖT;4@x#u &~c09PJuOL]R]`@!R]`f@+ufyR]g`A%f9R]W`vA֗:j@֕jA֓:A֒YGX ^XNmjA/6͞T"4;RmgX3 >X3K `B|gA|IEփ `=փ `-‘skz+ 0 >X3 >X3T+\ `=&* 0 >X3 >XH9D;fփ `=b*R}T3 >Xk6K >X3Nhaz3 >XktfJ !>XkTfZ !>X3-NH `=bz"3 >X UR}V `=BT_?Q-`ETKX Hu9X Hu9X Hu9XIuyXIuyXIuyƪR].*T *.KuR]>MHu9&"0[DTliR]^AIuy4*0DTLR]~3AKuL7"0DT#tmR]AIuyLhr * 0Y]`2DT/D+R]aHu9\& 0sU`,T;hu.R] ]`Rm(X ARm4T.hlMRm{N0@I`&&MN T 45D'hRmC.H z@+MT"43RmW\J} @#v͎T"4Rmo>HT R'նkJz kHu=뀬%T P T PT}PdHu=ETPdIu}ET9PdKuET!^PHu]ET':(:^A- &R] |ܒT94w @0.BR]':$`.XR]?"#`.DT[hHuFB) @h&^D2ͤToHِzI 4R]BXTKuYEU@jR&TUm!KuM$n/B(`&A(ҥ L7ET%h60UVn["m \kR*m`$Bh`nm!4TS[ &&m䤺@tn= W4D6 Bhv@]n`mBj@YG!dM PdH!YR3`fAET^B%>B(zڿA 5)վF!T"A7UB(UsT.h!C}@B3!վ RAِj_) }V!" U?6!iR7ftBETWɀBsI}.X "V5@!4Th[ TnNBMTtlBͼTz B!u:F1!R;xBh|-@t~Qq c)!!>.#?C>Ȱ^ڢqtGm2h{- |A*0/)%5=3fw8t:춬y =o4/|.ߌt{, 0/Y7tx K+*+FSeeEyYiqa|oeg&K;9ՌC͍ 2|樅9숣mw0oIYEUM}CcӒ-mGS{{[kŋk+˅rݎ̴y G3Ѱ}V] -e(G3 Rҳlo~QiEUƦem;zV۸i[C%_ܺuM֭ZuYsSڪa$aleﳗѰugJ#h,QH /\uŪk } ?v򩧟qYgugu駞''w}kWww,omnj*/ c˖}ġh~[cꙐfd!v|e&..[xYʮu7sIN9̟{WʫkC%_׿s>㴓t6YeIc}0mJKN [?3 |A'&g;sU7.iY),e踓zE\nn~ǝw}$]wޱn+K.Źgq9ᘁMWwƮ*+ [-eC>҄+j,\~'n;ѥ_yu7~ylO>B |'||cE.u7/5Ӟ3_xas[GϺcOgw%~wq?c?{ŗ_믿.ݡ/믿_|??;}x~uW_qEL3vƺʒ\gVZRai4|ؑ!e KZ;z6ϻ+#=K|oλG},Ho|ч;o7_ӽw~׌}iN׻yhyn[FrQqd40Q 6EVtt?= .O?3Ͼ򫯽-ͼgW__F|믿?쓏u^y??4c_w/3~zTCLJ,֒!u8xϹ+_o,o5G[_ >ckŸqCu/_ ?>z 4嘽;#㥑 j.]޽n1?9/o~xϿo g¾Ҽ´?$? >HM7W_~'}[o핗^pێ׳bYZШ'|qI鶜Қƥ+zs/o~^zoG|aՌo@7T'uk/_دӏ?'a: +?;FBQ Y% 0pgv}6ƛ/LW_,0'5kw|c}-_} KslIqLWXYtd4 rA򞾁N9F4{x?of_ ,7`}jpinamSѰl{.@7 K滲R2V]1V}ETNHv6uzgⲫn{EC~vvQWib'f a~'b~o ~zgy"#hbPGatXta5v #yOȆֻ~hi?+“5[k}W]/MO>~ڲ|wvj‘Gu}D|rk~h}'vW^;4#h_|ͷ gZ>;_v5+6Tywct؉Ͳ) />ğ_ڹ>6r3ca~S민N4ϙ{AaaYYe'~%WxxEȟi4dѥ lS?t³z+Yqc,rRs~YlMqO=pע%kFgm-/>ほoK=ā* stV><.9˝_`i-ǟsmݲW巗_pֶ7t6Va`is +u_v{D4~_ymszהcd]fSl}K;oN=~S/dXy}8Tʽ;K}|/Д-ed]&S;^yi#XZUle *E[t]v-wI:?$Д-gd]>So| {֤}y>"19bakwqʿ'{E:/ jʪm: S'KK?4҇(,)m ̇ǧ,x +IAa^{!̤C'f=L6H199;yU1'kV~HZ}鰍r@F=Ң]Yʘw!0=W ̢Uݴbs.斻|VzShtKz\wŠ#DCwު02zb|DB3}֓U7ԭlrت7f;؍]KC[yGyeph\[{ g\pMsǎT+Ku??UKjr2y~{(ڛ#e|y_=쫑ne,]\s9._TH?}bI9fOsW.ZVK<>6lqxf?7_uљ'niYPN:ꐨn|QIٞ-=Ozw>(T~8  ] Gq7]Ջ;6{/q~}ߢRʳ{o/5m emgsrm_vmZ`/Wm$ V z媋8&I6t*14wpEWrϼOMV2[?zx`w&E[6cC'wt/..+K,ov }5ubWt6i&̢7Ϻ[}ٝo}׾]VeN˗륧+ךo=~o;: =b=yY٘Ͽ;z]sYYʰS?zvkEޤEi1>`G|иTG~Ϻ?ܻ\? $3$5M̓5ȃXﻵ~'f,h]=xyR"L ;MZtT;RcXn>$[^ESdžoH9DEMYXgs/QkƬR/>~uI7t4Ue'ywGpDύ~  9«oW6揿/ˎrmIg)􍗟mkҚ{FnֆS?#*?g]Dd|YuϚ4k"J{v̺v?bXg+ngw1 ISnҳOر›x53|X|zN٧_t]"꒟eGo|ѻwy۶t7Wے+4~$Bsb|QϾ;z敷>9٘u:zpi+36{˶Ǟ/٘u7+O?K/IK-Ok.:h )G"FG[;7o;[V]ݢ3m^ŮC"FG#eϯn8|fۯ>yՂƕ7w~`hafl/mY}i>̍s'9 ߭m^T~{}rvշ;Koa?[|v mp'oZ\L8ix*Vho8gG=?AoS{+ b mi ̇ĥ爎vO~~|]|_g@#ӏ^:O1̼~r? 7sw贋CtOB҃}x眸aEC+5VKY&3W5}%sCrŹ?h,I;z6yϸ{~겴 - Go9_fLrӅl03xc/s:ao~n3&"F[#yNjxXx H|F˘ t CG[|=;^f:?~g~/rR:h}"r1f~MmL,l?{Cԕr{a CfWf~\K<1Ɂ1yG޺ę|{aiJm"#ͬ"H7fdLv=]t掆"{ih'܋"_D^E}-,`986=b4Zl̓Ro^б䋮vf8w^yޛu1+rӵVӺ1]PyП3Y\.18gͯmv},9|o=7^r`wS[K)]# ou˺~|? f4Cw_~;Svj15*y??={oy op)c+ S12rD3u}O)eG m\^wgT'9ĈjKof .;菉֚ }6Ѻڇ% ň_p/Win|>+9~t]| ~{2?"aifh=1czRIJjYybD.9p(#1黯>u.;}ؑt1?S޼sÃϾyeʗsŧl^ ;cDY{oi9,=1&%bt φ6/6k!_<ء ٢Kn㓯Èj˗÷_u z_lbzp>8.#} kˌfFFx[}1=Kɳ|֝xy͏Q͌g|]z YŽǜ} FT3#}tͧ"byY yCed,`r3ϐNOZlvBPhӋnW>]홒4zۮٱFYϭ # Ͽ^|c`3)__ןX 3ؠqݲ#s5ׁGy[7l}3ϨL!W옞ǼOv-Y}Wo<+2B&Ci[;gk @gA^<[Y3--D軯pۆj\͌ytoM{ɿާvyvdh1~tzI3ixgj({ȖΡ~WY{;_tcܲ= :k_|}OLkc~%1:5K&;,Izϗ^γ(#>c@3kii{/Cg]!>̳%-DS"I9bܢ9˼vMz{~/RͪyWަb{!<{[xv^[_>{AtuŴL9>k+-7FWlZebE'lYW o}¤"_{.?}*ZWl:h?~`TW޿Z[Z=H@ @@[ .-)[\Bp+Bq(]{ nw?ޙ$H ɹwy}4@r?=rLl=e[_9js߿._rS1mM58pĬ祣WXHm)+fYSݺ+w|o9Ub>5[5/~Y!XvŮk;4(OSArϩ*60hbֵՈ͒w(hIP#ܮmlR mmqAWr{MXqk+'nmulǪz{_^9ձ+w2ֵչ'}ǂs ћ5!w3G~+ޣf)HXSY4qH;ÆL mm˽'v5 Z k-]򱳶"gm FgY)ga_Ж:[rW`r.&νFљr6vmr.| PCvl0 ZNGj[Lt_(ޟ.sW-z2٦mʽ)z  +wg+VA6v6ւkUGhQ5Ot=v*W"[kga5~@ dḲI~ߙ)g)vl"5UT;=Uvb겭G.};l0,}GkW/dg}vSyPe +TrU)x iXZ n@ :9kbBzveQE9jaTv/䙘> +XZ{RhAfa%HY|=ݻUO$,0ͻ)T]|`L1+'fbrO,I9[ga"lV{RŢʠ/¹6kVUKB,leێ^0٨u&6﫾ڞXe{?,ȂK,4$QIl򉤶aЄfaF2vr#7K$5nѶ'U0faGym'qkm[{"yֽ0faF{Y,' k\t4nm(tȤ%[_,h{br {̑=ZTORvjͻzkÌs6.ָ?zS㶴m:m=Ҹӿqkm`銍ÆN^<4A!}],$fҸ/ٲd ˗?ʒd`',|❧ms`{8jzDfU〯<-VU,S_L[=mɳ'$5wW.n3EQIGmq?sEX2_WnYVU~{"I$qkWL'-1۪jȤ%[\?hfA4??qG|햘r[Ui0(g`y؍Q{J[U*]U1̆l#[L^wmUvGǰ2U._ Y9u!*3a]YY8mmeP>@'fbrC ϪxH2zsF+9{{涋{bz &C4MQmj&ŭVnn5zg3ЗlYgf>lH9{yGL?3Tأ<^vIz%^z9G=}"vS5?DL.Sb~u>M lȉ7N^zpo+n: tQJۣIyGᄯ>i+v`ӤG>]4*#'mSB_3) rguY&y%n{AdA ViXVUjEʬ XXO2>|iՈYk:òʴVqO2XY-!b6#'SvlP.!<f]>shȬO2ܹ[ wMwPpgb;:4=kuv~,ʋZ#7h>tl$W&1//GټGv%'f[B>}t۱ѐyзX?};L{;N\{6ilnUgwI/oaO󢽽ԝ7-gnŝ2`Ĵh9߽t(fmjo|aUv4̤XNXp[s-I39#[ON;9吾guZ~JΦE{[Wm]2)wmX^+ifv|C:6{I%g 64/w+_*AE7A[Żn᭰;kշ_tbY=ƈ9DfF| tDצdXso)0%r63#{aZ<01<ȈCx\efwLg>0olVdru)}͊R:jIrvPHXr>%Ob{0)߅큕49;(K6Cc09;6/g=Pu?rv\9o;ÜU$1<䜁I9 $I9@7圾sxnxÐut$̙: ^yc cؽνxV8 ߷ICL-3CcjއDߐ6뼯{\&*ę\!5_~?@cP:hZaDS{1/}^0aя*30cnW+6>O_r+m 1Ɵm%7(1/y7?˟_U5|޳l*ހQxϣmFy|I~|l+IjxΙ%d+Y@|ͳn^l?YAejڟTffg{ hb=5sŴ'Jz hb ۖSC =?ڴXNqф#Y[@(]nծ7XX}YuvʠVjʯ)XXe"*-g}a%b,O&͊6ݶGXVH gO/t 9џV8%Uby*eQ}yzm6lO mXTe'ͺ}ɓ bnoYg6/,%m-g{-&&ͅ.|('w3v9˜v,ࡋKiІG7N]U^~(i)gk;OS]yO cwJrgNɩ̈́><oh%I;#Vqذ˷z?<Ml~gYY{;yW7N^3cDwPĆgNIQ hn<,+gaBew1cS7XAO9&z<6@7 f&hS`ܞ;w+z~7@+[9ІOZ5=k*e]^5<[hn5`D>{޺dҐ+g^i׈V풇i&AJ؍Q+mΚTOZ%meEA˦ճ|v.sb7Ásh&*}Je #fYأZsmeugи@ܪj | _Vh:tҭG.ӸMݪJLhXƝ 6Ҹֶ͂=vºz妧}.YE3C%4n3_^:eɤoXUYwlKWl6t 4mֹۦkWUָ?G6 mWѽy5¯[UѸ͊m_>u!*Ϫ^ql[kmku4ncmܥoq?q}bĶm*7[% nd<9NJNU9NGt ٶ}W:I=mа[RڶIKt)l`dG?Ü1}Bjy$^ݦW;A۾|]{ol۶UE|re&f`,{c7EYromڸ70hBt 03.ٳe 3{#;O1ޣgČayаI=׸y}M:lw,L_<__.?Κd@]aٛ 6 N,y&il2*Uy/L̸faOn_8- ,_ ` ;hsL (gmvjG IZЖLl|!fbxa td):5ųXM: O=1 xr,,Ӻ%-hL,|h{bY [6 ++gkL̳ZpoWl?v/ o;1jˉYX-gbo?`|nRukUGhQ5w2fazAXnW-zK+KAr$UV(]0{2fazAXv*zS,m<|3Gj$H&u/̮?KVF\U hX|lL+WU%_?y8t+;N^q-W+kv ]9/8Ds!r :cBL\DA r5Z+|Y?ΚO+ѳ(h޶ :6)UIrLʕ3mdR)h㒒LA-g ڨl9SF*E汕;F"Tzr_Уc+1MUXvOv1W s5rƪp8˭9 糇7߸AS onFg(ŋʗ`ܹtt#z }roP#~ ^> U޻~U,We*60p{O_V*'ao]8eٴ-j˛RY٢{Ĵe[]2I/ܳv~/_-N3p)]gv,a* }sOޥY5/ΚO.&EΝhsmѵ/۹fv *II%g10cnuZν:wbkˮa!j;a)8 "lF}Νhkܲ/{HxAEt|}jй`!}_K¹2KI%g[얤ھO]= C:5&Y?NI5-s[-!TG_RK\sd0e'a֠[̹N}!:іTݺpdck vyH.rʉ??(IU/Tzݰ}fy,R}p6-EA%\;Ut\niȄ?/: ڒj}+_PLRkk9[:wڭzDL]u}&C%՜q;4Y4O Zv\+4hwrqӻjhr_;wHu^UoU4A"f}p>?fԈ!u\rdLx9Ν!{~5w61jWL_O)KruZx1K--18?7r`XR˛5F&_?8X\UlQӗo={X*B֖+=&ڼ_.Wż~ұWWm?\,]+bpn]\]R,ϖdԆ΃[sAƬ|ϵ9T~#1Dnm:Ѧm[5>s>*9XEkiK6c. 19E17~p'rsj/WE=*7=t =}uQwkY+%gT_R,3dWܻZ\lۡ??Iw ?sOM٦^y¹3l :]qsw 'RXWTwۑ}5Q4MV MZ[ttJ:s3Fo9ͺ1%zÞotJa]Q8{pY_ mZݷD9:>#-޸ۏ_/5s)i:ŊyUiYQ-wy GwЮ-k.$l Z֤C1ӗm' w$n|XQMGH]9Ֆ`hr.V_\~ A5֘o_>gC^mW,[46ViK\kw8n-ggcݰ|bFUeOWm&f}.&&݅J5<(R񀍱wٻiww N/VWek2d5;c.}1Kס\QȘ&"_>L5O26l;^[̧,1h~VT^n_Lco[be3kVEъ*~r-K*o+bJ=/aeArhErܰa<^Ye̞wU1?x/ I嘛 ,]8OVT ~_>6E2hWIE1)'Abm޴ zo* :iXc~McNG?U+_C=-z{ǔID;V킉uQcleUMݡ)Qkw:sch~z̿rl̘v@7OĤkŞzċ>u^l/Aָ]/&]+ o@_ܼt|殮9.\.%}6lc+G옼m>z l\2}̀`l ZKxWߺېY7;yٺclX&ϟܻq ?,8Ȱ1AX=+meиbd1aڝ8։޹.zڨ~.QcL>ʔ#1 uZt8vҍ{]9n!M/skۡiMCǬG+^vN|x1[Html>u`"oR1[N1["eC;&g4hߺtr+~Ev- _OA<ٴ1~W[tvY>=4[%cA[G endstream endobj 167 0 obj <>stream )\ڿF}"&[倘vtd̥w=w]n /o]9)#Ck/?GpX;&2pw8tF n -曗Nqռbݴf{|3ʳ`&9ގI:`'/|zbh޳i{\Oe˘,1호-Դ}O 7=~^Ҷ}C;/nDPҽc"ҥlK7:|޻_h{#rCbռwjYh[Sfٶ]O!bp6ٻnLn}џbVΝyZ=m Ƽ*iy+E>/ݿ:m~7{ w_2#rXv30LX19Hk3e>cݢ.ik1?{|ƅ:z` Λ<Ƞ?8S|b֨MAc]?"zI;Qrd,GvoZ>gBDu+ztf`:Θ-ORb%"L+c~:zYbvRxMbh=5Y ̜1[_AGyٻ}=sO])JZ$ie1߹&&`W͟<UJC>3gqtְuׁF"c9ֲO _\GBy_M6Nֻ1a /|Ox;|Ҳ4[c{lژA6({vmr51oqsWo}Po/Z|Ie,EʾnEg[WZ.WAN8{'/޸W9sֲŚ깣{\ۯh$g mmm=[.Uvz;m{b3oKښlُ޸x2v'~ٯSU͟C̳߳X{wtf?~v۱lmٗOjCz~֬vzw=KyWݴ][i!1[y;Tz6˖-ؾk&"sHj2޻eȚ`1Q Z}ӣ߲YѼ[iIږ??}xѲ7,;y֮]xoUhÏ3%U!InFNtvѼ~7L;D)ˁhٛWG}7nxЖ D1+:s-{&b:5W2~U5kܴɋa!򽛗ݻeY|ٿKO*xbΑcRҙ)T³\F;]45b}Zӿ8D Sr뗉=guq-+MOeȒ3>|g/a6Ls|;6\0mC[5QP92;j1k'Ko~1[neoѱא1S-s:#{)'bwnZp~eתV$,bdv X|dI˵t||F!>>+78pUS'XʻbVGf5-[̿rg(ٲ9fc}+nҶkڴ3y&mE3'ԳCdΛ=\3;x1g_^g03zu̮n)~Uʳ&ܫcF5{ZGzv?h%=UoТCA&\$>OR/^!CCU)ZYZ |L6Eݼ0ݪc&II|_k:nX?9尐Ƶ*)ljZ ޼.ZڧbPCFO{\~c}ǭ  Vʿ={'HϽx9eaTqHX!{Gڸ}ߑSݒRԆ֯e)e;_J9e۰μ0Pqw?=#'LZ~ON_[FjEm Vo_t؁VGLr֌ƈsla:CI4bwcge֋XQۅʏSGm߸J<*ea3fKU""]fپ|_uۯbTul]"jyO K:a}1v̅+osKn5J/CbT>1/zr"$LڷBu|Ȩob]zmz 7ev[v8r.jmZ&iYֶB]:{,݄»o0_-i1vqu Rq‡ݼ%7lݝ ?+xz*Bu9ѯwnay"ip|IX<XAzjҌKlES;uj0&ʏ|_hδ#!IjKsKʯĖtl.^Ƨ|MDQ2"rLk'ωiٝXZ:U~a2 W/=qdV/7}˜/ k rk<9kГ{d@P9A6Çbū7ne ~_"/OB~ڲa3&~ݧg-שVǽDg)@@>nԼmX~֨7lٵ/_yC~5xi'?nKXDlտf}֍+Ϟ<ӎV-?cCdnZFEObFIA$-wC-;_bn~iS=3/YnbeY_~S'qaҶV2a{&z[Y;uL֍kW,;}9{hz)^@Y3OH9 g-pҞQ1nVݸu׾GOpڍ[w,a?myg?'_\ZEO='z-ňB=m؈}mިveK-7(e&*۷(y mh#N6;Ju̶]{c?}O~-y%vkd"_Q"`-_<~("u ge{o^fY(Q_ ӵcr ~_ϝ]̽}`iؤDlEqFuAkԍص/FEN>gU?lڲcϾGNȰ޸y?y-nIʿ-يp)>ߴ|E޿wW/_8w}/7c1C>YCgik2XE]ZBwoHĘ񓿛xŚu:{ҕk";"2刺Ϟ=ku]fٳ_Sweׯ^x̩G۳c˦uEϛ1# ݵcRףt"lR~;,E?jUҢmǮȉf̍Z}CGO:s¥+W߸y;wݗ[B_kpޒ^t'9"5+G͙>e/ =]+Q+-Ur\}-(uJ Tvæ>إW_Z {?lܼu>z3_x׮Y]pOCׯ]rΜ>yam?Ƭ_z⨹3M1FukV)YkyeߙQ_ȵdOkiشEŽ8uKV^~wٻ@C;~Bŋ/^xAcG=o۷n$^tQԼYӧNAὺ n\/joQE ˝#]ȔrJ`Z69r+XX)w/ת߸Y˶z"F?iYsD/Yru7nټeDcEڱ}17[f% D&O;C֩}M:S֭DQQٳd@ȩ}2e.ڥHRԨֵ9&S>kμ F/^l*zLf M7ٮZl̚픉߈#޻Gڴlָ~ˋ:*7,d1S?:xDօ]K akԴEȧºݷ#FոO4y3f͙;ohϨ ͝3kƷS'O0>򫱣GD :=vخM& ժ!ӽTqq S -jKYY̝Kb%K*TVNFM[}N]ݧox "b1cǍBdqcnjŰ! w"nټiukըZ|[ "W32!.?Y.'XRAu5hܤY!|ڮ}ǰZ.Ool;ul߮m͛5iܠ^ pr^e˔2Ι]rj^\Zv -V­LYOor*UVFP"F4 n޲UHmh:UM?i$­Tz*"?ϲeJw-R`~-bQ?Jg͘BNKⲶ []d%ʸ{xzy^3(VAA5kl+ p(V8_9sd˚YD-cBN[޳d-agMĝ;o>wEKC[˶tŋ-R7o\9g˚%s&٨e2{5+㽗#-Z9rʕ;ȼz(e[ _]OROt.?==]/<=}/<]<\Ok98}:oOwOoow?_oopwwwsvw }ct>v}7A?w_@?;WQvcGo>hߣh"/w:*YoW_K\wk>]pt.%JlS\ Mqv^!'{$~^'rmKlվjtدk^v)gX_Zǥj~]*:$K٦v٥.%֮jv:ڮ^vg|D^{xy{YZӳYؠ~/p)7,,Who۴m3NаA/wp+?-A@<,mG#V5#w_qxy |}c`u7[~zz{=ϥH'?}<ݽ|gOIA˗C@viilj] ;Kj'v=]5.~Lnޞ{ 7{{kޞ#Zq{,dyz=);+PI_m_nL_ϴ`UsmuK[WJe}׮5+gu]{YgⷊO%av ޠU=+n-/_+=? ХM5tyg`V?-O_ɹR9@_/uҩ$VjFGGX8Έ1&9G 9Yz,AkQ=^ 50EuG #8:4Tkt^ >2bB18{-H)-QCB3W"F@%{ HKTo/8 "`~TDLyP/1㢺? Zx h@="SPGD izGDs :GD )FDR'kWH>CH:Ox5[xu(1ΎDD|ΈCDL΄zCDLCD|[u8 1%pT"bj `VT"bj `6T "bZ `T "bZ `dT"J@D4FBu= "I#(JTF@{DDV&@jGD4{4iDDG P}/#":{Qx[T߻.@rQ}"":IEl (" *Tߛ,@b/1~DDD!"",87?DD|͂CDĤ ΃{ '8>1DD|;qQ}o!" { ]p,TOrc>BDĔ̏{SG0/DDL]|g1s~ADĴ̃{V0>DDT#""{ B{%G=ԡ:{DD4#"Gu戈h!P5""GHT猈R#"9Cuhn!Q)""_HyTg!DDD"" 1Guvۡ:7DDt|! CH:BDDѹ7:#DDtNըWx5ADD^Fu&23ADD Y ""Z@DDј:3=""tfT_{DD錨戈oQ}3Z#""&UgBFDDL΀k\mtdT_[DDķՑQ}mEGD5EDD|W1%t$T_KDDĔґP}-SRG@5DDDLi1543""bjifT_;DDԬnQ}S[3!""fBBDDL+̈́k 1-5""bZkT_#DDDQFFADDTQQ}]UjDT_DDD ( ( Hhhш26#""OgDDDhL'3""d|FDD4͈Ɠx2>#""SfDDDh<)c3""d|FDD4ψƔx26#""OgDDDhL'3""d|FDD4ψƓx2>#""OgDDDch<'3""d|FDD4ψƔx26#""OgDDDh<)3""d|FDD4͈Ɠx2>#""OgDDDch<'c3""d|FDD4ψƓx2>#""SgDDDh<'3""dlFDD4ψƓx2>#""OgDDDch<'3""d|FDD4ψƓx2>#""OgDDDh<'3""d|FDD4ψƔx2>#""OgDDDh<'3""d|FDD4ψƓx2>#""OgDDDh<'3""d|FDD4ψƓx2>#""OgDDDh,Cׄ2>#""OgDDDh<'3""|.DDDgx2>#""OgDDDh<P!"":ψƓx2>#""7CDDtF'3""d|FDD4ψ2)MgDDDh<љd|FDD4ψƓx2>#""kEDDtgrQ""":ψƓx2>#""˷A׌-nDDDGx2>#""OgDDDc.wE׏2>#""OgDDDc͔BH2>#""OgDDDc^ŔF2>#""OgDDDcnjBDD4ψ25Q!""FhF'3""L+Tfx2>#""˴Fh'3""T2>#""OgDDDc?""UшFh4뀈h$뀈h$뀈h끈h끈h끈Z ""Ԩ.4:"" 냈B ""fAuBDDLK͂너V 1-4""bZh6T_/DD֬nYQ}SK!""fGCDDLi1%uT_GDDĔP}=SBGCDDD|W]tTT_WDDķQ}}FGGEDDL΂댈1:7""bRt6T_oDD7鬨YQ}_#""&#""&tT瀈h/*GuR<!qT炈+ "s Gu>|BP"":4T焈#$y!" oѱCun»:?DDtLQ!"":2GHYT版!<3EDDs \ѼB:_DD46% sFDDiT玈ԡ:{DD4#"c>@DDB{ D}c@DĴ́V0DDL\_1sAD̍SG0?!DDLYqP}/!"b ]pLTWcBD ΁ .87DDL|CDP}""b|Q !IDDg 1Tߗ,P}"":IA}L$+"36oYwAcDDG %Q}?#":&oDD3 $@ZGD4P}#"UըDD `T"Q0kQFEum "訮DĴLDĴD쨮!DĔQP]K)%UGEum!"΂ZCDLΆCD|ΊCDLL`FDc Q]8kSx3KHk_x;T.":2eDt eQ]ӈhn P]߈hN mP]h mQ]hl@-{"O0"ހjsW b Bu@̍)+8{ "ศ/|9Pk1is bHT"DH ս YH {6]*@J!:ކhVս,@uC4FAu?D4FEuDLk̆꾉}1pTTWķPs_'0V1WGs GuFR=->{=CP1#c2&Q=v 1c 2@P= 1$ c**Toq e`03_"5q))ٰϒ:'h/qxo|]|Gn ]Z2^n>8} 2d|⿧OGKG.ۃ jp3eΒ5[9r:ȓ#{Y2gʔђ\[+nV-b`=`k9sɛ/AB):)\ȥ`|yΕӒxFH[}تa'Dov ΘI#g<2E\(Vx{{yzq+Uk"Zyȓ-kL,iam\7Lĕ&)s9rɀ q-.-PB*ժרTUլQZJz{y-VkB͝3{,ZjmwԱ]VM֫UJ@yr& ;.kN -,8G -^SD\FzCXמ}1rW_O4̝`ABAtB,7wάNi֮Y|ysfM6yBؑ:0wNʰkU\[:WÉ:Yr|+%2Pf݆M[iߩ[A"FfҴ-\l6mǮ={폍=xHpe߷wϮ۶l޴a+.7kԉc>H M˦ #=hc"'N>{~k~e{:rgΝpK x…Μ>yؑim?Ƭ_z⨹3MZ Wv!U^Xy:Ő!rE!V^aVº?4bti3F-^ܳA3.\|7oݾs^bp[7o\vʥ"S"޾eӺ5+haGڿOB.QxZq ua)?\HEێ]{SωZbͺM[{g_|U>z_&OO>y_|SǏܷg9ӧ"v6Ugg:XJYAdjCwoHĘSf̋^ZDcϾG:{ҕk7n޾s/>{~&~ߞ?&B7^p kثEϛ1#ݵcT/ާgʚ#O24jަc>9ieko޶{_]|U$|W+_uO2<~ޝ[7ɰݷ{k/7c1vmۢ5jm(s+TïB: wp fG-Y.f:{{ВzZ_KG.~'a=u=Eˢ~7i܈=>p<9fJOQ'\HIwUk7j6GD!/Zv]D.^~SDSkzxe/F\ZZ2mk7_xNf{[̺UKϘ9rh?=j<܊ʗN*ZYL?riA ۄv:2r7)ɳ2ؖ^0pHEa_ղ޷kˆ5K̜GݶyU,RRԖiX,ke9"F"%V^IHn}~eQF7}U[Ӷs1f?֍+Ϟ8r`֍֨û nT9Ţ_I\ʢaPVv=(I3D> :~? 8mak/OD]߼vɸ'Gҷ[&uW-[HIXea/\Oj;v7|-YA ܥk=oL‰mZwo]|5Ի֪*wt"R hI5R2r:pԹoݵ-ԉmYmQoXdwF׽(jKsɛ3+I5e9,ˆXvV3aekcv;|R cK!)uZdx ޵eS#ԻӧkTu/^(I'._^6aFR^jö?+ڵd-d-: 2~9kYvQzܥw&+\ouV?+bمl]tzwi۔N-+Nu]Zw9oܔًWo}P,?zooɗΝ>c fNK.]/Mv=hذ wK7>|"?MonGo_OMA&BSKٲE.{z?Cl?<{!a)N2n?ŰݛɗΞ:7"4dqD!^tZ)s/"W5e!& ЉWn.ejZF6,kcEGnN{me8s+\ҪcO \WnWoK]&d 6,jqR_I [$]S[҆9gB8jҦKoQ["Şgirf-:eQ߻);N- :?`.V]F:qL*eg)f\u0#huhdt̙ɢʆlS_ʩNOݻK&+).i u6dti)[L8nҶkX\e ;N/0rpN-ְ-_H\JKZY2e/RmF:2zUvls:M,9~slUPޜ_g%/KXUv\q̽˲A3uƥ}E Ξ.PtE.t'E!a;|S,psgOظ|SP_he϶Ȟ` *΍Z<{ƈ'&*[z0g.:cᝫc"t\ٲޭ_WTڮt2Ke.˒Ǵؼ=sbܑrI}K%mس ٤ B>wڝG䖝%ttZ,_eS~}jTL1߻l;yZAY̷bVSv,eϿE{t%eI/?ȣ]UKیLԻTaGLK.滏D);oFeX/>uE. KgxvlVA9VƩ.XºjM=x]xqq2?xҰ3y8wϛ4Ww۔.lihΑg{~?{C1,ObN-%-uMU+`fq4-m@ٓ޺X| 9?r-;WNڴ<0nm i/Gs*΍\J+G/S˜z>#XB?FbuftEm9Q>u/\L}ٿOf_Gn0[U˨Y}|'-ڸm֥ (/Ҭf%?SvѲuD5ag>yϛu*޼t:z:Tȇ2j68`C~DGu*-˦jȱ|R`6M;(&D98fK]Oٶu|ٿhb V[Q3Vl>}Y2/ٱ~ᄡ߶SLWEshMaS9!'*ڬscDLWkc>rVLUqg̅kLTP1]iլjXb\Tͻ69&)ѭ˧->³}CrV%*TwL`p3W0QeLiՃ 'voZ4ٷGe?Lpnk"ɇLTSt"޻[Z?Ly oL>TjVճqhE\w;d0vFlbavDgέA"'&pzd,Cy͋hm83pAkV ya'mׯ\ {wm.肹28HP*[yLIq8sݙeS1;5qP"{9 ePuQÙ6gh[0³]{y3nRwbu?+8G)~x<'x]h,hu.,;{M]&&gy86gt)G:EG02f665u`î 7?NiGӡuFܩ[с!G]U'gڜ%tA+˹eZrZr)3)aJ88tr5+$XGT&QڻbEoY#8ʰ 4~PVEr[﫟)ooq@M{cMR/"g ٪M|Ox-< ?r*R,v礪:6/^}VHJooܼ O%} J[7 _9ϣE 䳿w$ݾCgJYп~>+)̺ZS!n; U,gSj1}իRꝓJVRTq\ UJ % %?_~ж:UYLU3 ً1:}RBet2Jnܿ>tjd+SUr՛{ Tef%~:YW2[>!+|%lj3wÞ+LUfUղC4/S9y6Ea^TeNeG1LlZ>tʊ ̪ޕ{1o2=y،y)UmO)Cy@/G?~ӗLϞ޹ MtogezkfVb/Oo_ ۭi7_q+۝'-cz6RSҹ|7]qkϞ5;=822=s|q{Mg4Ӓ&{WNνC}|Pe[OY+c'vwĜ0mÎm.7WN^7{T5?}Y}.C~mzidQǵw3zR#5pkWJ״}V1bf\Z>xے h7Ǫzٰ'6>3py.`eIV >]]Vi>`2ג[ V'wT+$#M緔dۡUf[Uʓ 'Vm6J}1wtV5Ӿ:4ig\$W&1zexEl٤ږ*`tQ"Bl9&EC[}_aGmwI̶S|s6u;n0^4R^㑫ibR2lKQSRm%+a03-ޓ%+܆>˗I3.>OZ;kPJJx)}6R~ru_p᳒\?^ۻaV)|cU3מ[ڷioժXȘAF>8*s.g>f]WL%̓>>ZGUG&s-A+'}JDT|Sߑ>gJ}0)>gRX>YЉ'v,]ZO]gx?u>ži9(O'4{OxiԻ<=wyπ̿!4zy=,P> stYX}sY}.?9Q11|oE֩[a$ 12?G!k^)/U<2 |OW{׿P}ms)o~O{\՟"{wc+[T`Wso[}ʏSO.̵c(b%[n? (1_pgGzQ9-mOK:% ?/v܍{㒹(1Jy򚤱]~^zQҢuO_m%_߼ JTf0~P9a|?=a!mexk(zg7Jٖ&Y/J?>oh?zV~ٖkf h^\1>Ũ]Roca VYXuAӴ?(e*̶*Ct9*`%b,O&͵}f dOg'@ˏXG'pgOc?iO%?7j|j/UӪy=[תnR+5pw=O2̶ q{=ԧUi%*ngM hy<-q;IF{A, /.YΊ7\v frآh/~"p̼v5nv]"onQ ٳanSbW 1aY1 ̱۰ ޝٽ!)AFFQiϪ/{S K'-M]i0ͷaF7bUk_r@UJ2c>kj&&9ͫ,oICɡMҽkgEUo!{BfWz<mM4,*r{!]9V'(1A+nyrdm]H3R3Lj[GI{$l}*;oÞIwIb&[7ݛU/___d[ssy腛$1-%)/m^(v4<7\78h+%=}xT[w$tj=r֚)IF\嬤.4gÂUA0C$1+.,siW;φ$#B+wb,h+0|m\_ ڐ| ?r^cAZ)Y ۴@Iae l賒 Xio{~eA\if±{)R;φ[&65[>#8XM+v\thA;X~.h [944h_IUrCUֵ+)L]"Fa9'EoY<|"waF k9ZtxKt\2 ʰ/Dl[e7o_ϼm9*0$*" *e9xNj]R7Ͽ:OrNM݇L\ h9 ˹~.KmjeLRs|vAg[LYГM.g? Ĵ6m9M[,hS.3 tK̂6u9M],hSU,4LR^#&~rN'VXbIs[Q!XbӻIBMa9Y]L-G#V[l{:y:>G0$޻9h..>|9.j.]ڼt >>i~sWѫM:,]N^#f ?|RC[N0?``&E|rt֎M: a׉K2^rm˧hYҟx+m :GJZ|yYBˬRCأ 1;ۯCC{"VyX7o̜5Qn2T[L{Ke9k :5w:qq>%eRZ~ 6:p!GG(HúWծUj ;w~SEN*K=ݿvH9tr^Af ٵF ?W4:K;jcЄ[Ԫ\`.ٵ>k;wEg.歋:zzD+pVGeӆj[Ϯl<޷r.TJV>mGtƖz8?T5&!Ihe.jeߠ˷#itFhz8?n^`f5l, ̀4};w%*85L`p3W<= +|ݛM#UU42gC?"WAJ5u0omsW26ţ[p޺|O1RYͫ77o_f#:<qW̅d W굶߱SƎTFV+k6uiޓd*-ݻ*&pڼf,-ɩn}GXm,Y,cJY`bvn r8gH_|-h:Pj:}{BNq8Uk8sStn_y,Yd"1#q)m=|&msA::Qxt;1OctnlcY0WTF}GW9+а]"9qƃnK^>s%PĚUtpNmE)YlkK2th0QM>{4j C=Z׵-Q&t6b?PB7֫R&vn\4ɷg[%j\REsCKM*DuݛLپQ5Cۡ篍8t K(ᅜN [>nMW,Q3Xj,fm_m GrG^B?K9gJ>hy/Vޱa^~68z691VLT ro.k76Zf>eƝRKh.Co'27[:UQv&Yf1KMds1a3#/&zvVJΜ6YLR]:9]ޗ$\؇fy?r8r‰>br(W<vFgUXz1'˨GMNNTr~g=o2k62`̬ۢO_FJm#QMپS&MToltͻ7[^]EdydžES{uhR2&c/Ӳwܐq4ϕQ(,L^otbvZ{ȋFG9m3r ؂.|gyaB~z֨TH6Qo!r1+`F3_kzc+.Dڜz1VQ޲N_~V6?R"q?ȷgr1Vzc!?ELvKSc)(`bn66kc 3~n}'_X{M6x97fn@m7jcha{b%ߡTo<x+anD4Söf-u ?(y4=w8r 9х,+86h}ИK7<t7&PJ?_>RuA{jF3dyz-d#I[J /ܻ~kNbmV-X4o%j7&7!h?{BO oLZ:Wֶu;bJn tJ$?w[>716Rӵc.oLRi A[g[9;`hvvF[WL.n~u&W)AVbѝѶMNd۬4/,r-oL=~.Yn52 ڗEm\AZ7VѲY4Z{]tbˮc"v4! G{DЎXh-9/Y(w6oӞGg[S;c٦.߸/inCi{8\p"5 & ӹyr% A;mF-WbnئۀS*QZ]ҟPh© ؼ}K%e ;g"*Iػo:t:) mޯ %qMду=5YU2g ϯ!]ζܻx]xI7ŒVOOӲdUv3aX?V *G̳)tVްu#& %\2xe+'7xƸܚ=Tq473 J4:un'` ŒNqO ޟ@Ӷlb{XG޶q gnGsj5?u객O'$ߒY~~e?|FX7,̫Szl5=P$ݕ_~sDz*u3sr;V/?ȣK-J6=[R ];?eO15ygN,Gw_~i~}jPrD6=P` * [ xݏ{]v杵:uٰe';y0*t){tpM `Ya6w;+:qm1hԤ+6E?!ܼ䝥:vQRܲc޶v߾:W",M{EJZ%ݪw~ ٺ b~l8LS˧\$ϙ4rG{:Kfw#o+e*[bIvi} ևR,i.˃9Ql;/6vgVb1[,d`Yڝcy TE^ޣExe1%:n&?}x׶Kf~?_o\jۋ\ w,Җs/R]mؼD>'gY|%! b5[NJʛXRE<XW䱁#&.]n嘾: qEl^5-;D;_V븴u?)Pޑb'^}}Y&Wee)2/ǥSrH☶\nӶ^Cȩޒ۷Mթ\ϟs`W搥s Ի ]~cwh{DKC6YV+LsY.dR|5™F[p֤1zvjBRƾF}׶]z3il} ]>޲N&gɷ%%:zǏϙ0rHoZ6]. SVe+:i>CFLhV?xT [:#ǿ,G]k͟1aπ^7Pڲ(]~kv:wBKXavbQ>g "ݹ艶^gH_ ɗ/9u[֭5yܰ^Zs*Y@;vZޑs=Vy=aV_|=eYufp_ب/=y(򵤋Nٿk+~=:mְceJΟ;tw+.RL*5kw?r"Ťk7{(z_~54;M߿_ab^Llwo]rBCvn9}uX*V(]~]SvVmE+ׅcߡ\)')NQƿFee Wu|Kl gcBmӚAE}zvskݴ~ J(R o_.C}-uriwiٴ-b#O_t;Z_vn隞3KiᗢO='+J%rٽcUPdтb)g]+]~L۷X(}FL5iXQ{9w.쇏<Ӻ---.˨Jߍ,tٓEo]t;6<}C&ܰد/ŗچM߱Ru% nӱ[CG8}vвCܵf'_y?y[[hoZӵZoEs)~/?+ ~;o^ONp.T̑{D7Yh~tZ6ٮa2KO*Mk7tmg~gY,xfǝp1U;e>{/eӕ_do_xOeܿ+|-9gN?rp߮ȟlYh) ҿwNߴt_mreJ,?WڢNjj껴S Ǥ-^4{1'N9{+׮߼uΝ˞kMmWK[D{o^It¹3=|`hqئ˂ΘCzyt QU+ZV_t[JKXձFFww?oߑcc?y:..'&]Iz )%׮&_I|bsqOGmƵ>)`0nm7_ɮrRŋM5K+}/eUSznߩ[>(͞8/hʐul ܱ{oG?qTl._N$KjoŞ:y"ؑ)iKNؑ==ڶpiPXeJ+\ QYzTj@b%XWb_VFM[й1͘=/hU!6nSxDdԎ]/~4Ft]IQ=sTvw툊Ӷׅ ?g)1z>=unZΎUmʗ 9\9hrqsʓ_,k2*V\~cW^7lԘ hɲ+WYnæPQQ;=\6tӆukCWX$hcϳGڶlڨ^-EK(ZH.dS)R/$z]ڪMUj5d[i^^ 6r؀&OÌY[hɲ+V ^-*$DgU+/[h9g͘>uʤcG3dP>=uqk׺EbWRq2MΨRZ-k -aYƺB%l6iֲM;]ٻO 6|q&N2m?h5}iS&O0n~ z6[v[4;XD -&]tVV+Mwp4**/gmULRE *?_3nlyjӍj7M鰩'ᆞ˦]O >fZ_XF a)}7Tf3QEYY5lӯgvή-4!L~^>-ZgβmTspnWRm Gv/ OQ/tRCuSC,߬mN՝8Wnig+]ݢz*5lST5;㿃 /WC, su_a'_OL%ݿKv NߘjTsTòEv {ٳoov 5,WIc;/gv 'ßx Ͻo{6XyV?6kܿ_!=}FYOO7hf`Ku`YOヨ/eY^^ hgG Q:|!ޖ u'ZVur"2kNQ)Bm/K@&ˀû=)V:wP6<;eSvPjjЫoo~*5XըU.UmխSGF:9Uf`ݲeu{vn4UQ39+wV?< jZUsus3>((((2nGfgY{2Yd2yLDz"S)@/ '2 \Dz!W+^@/ r\Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \Bz!W+^@/ r \BߎyAae. M ')vxb(OdSJY݅̓oe&H]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+T@* wH]RqW+N)Twc+T@* w AwC+ t]9ݛmRCo p*M Rn M8$I$I$iԓ:=n{{L=܋{ͻqQ&C򊊊~^mq7&VTWרT]_կ,.(+W8ha2h`M_yYE~k>bg4z2IEUaG{ u 5MgșCɣ0ã8˧hjR_S>I/=v`g>5C>gܤKg^uuϛ?z@oyue';,[E~>g韘{Sgnֶe*rp[K?鿚wM%udʾ}Y*V 6jԙ޴>WTᇾt,Ss?~iݹgTO~/<կ}k֮]J+fGVܻ>~QroGԎ'njW[oϩkoU~s;soGݷrY6Μ _7߸i-*6o޴ ܷ/-#kk7{.?hSǾپaO~ʶ۷R. oO#_3=k!fw5͟g6_cgG.v;w/_e :v*͡#'\~gڷ_vz[o|7 \zˏ[Eu׿c;zO^W׻}h? !>g3ǿ_;]{Sw}W n~zݨՅ:#a44Ϳʎݿ۳o˽o/|/1oFړW4uŚMvյwT<ϻoغƙ kk faW.h[}ή}r/xYչsۦg׬hpΰ~ZsU޾F:tΎm׬h[ز}W*_'ʭΎ׮l[pWlmy5V#ʯ* {o *^ܓ+YgYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbVU(fYbW}skWY׽?hm+>e{~G_EgnEVy{G*̢t(Ύm׬h[hϭk߁fQ:tޮΝ6=fESi~5훶|kY;xpwڱuwW?|3FTw>_ٱw{?pArW}ox<;ͨ][]ѧ>յg̻ˏwݧo߿~QCOZŨ׿c;zO^W׻}h? !>g|Б.3~[/;~ηUuv_~k.p彻*>3gw5͟g6_cgG.v;w/_e :vU] ^uS냏}}Öm۷oU\޶Gg6{VѷrY6Μ _7߸i-*6o޴ ܷ/-#kk[E_Z~n{s*۟}~jգ_ܧμh_A?iZWWYv*rՏYq[nG pH~} 5nkoZ{+V[C_oܳsoe?B2?|?1~ǝmmmT^;~5K[EVie͐7ҙW]s_o_^t 玨ؿٽeU|x^zMMWkj9}>:iű,j> MohTOn?;oG2',*3מ5|QQh6d`uUE~'"?~UQm!d<óSַ_eLF=L~e}(vFyyEEE?ruyy߲>M7vF4rP)b^$NFqliG$N܆zJE IӘ13.h٢L 3m֤G]Զ5Sʺ;2skhrlS4OkN~6IuL)'NXo2I Oznbä/:5ßyLEm'0؃GN|9lGl=Xَ<0~bC]_Gn&ms}Hqv-lwͿEbҔ'}X?٫|~2TN͞?6{wO|q.<6fqs}<| >Idi ZXr%-KޝZiӖ/k_7Nu[?|yYST[s/?m;8ҖSٗ_~Ҵ[,-sO2S9aF<ސ{ϙ{o$.8iƋߔILuS/í;ew}J6ٺ)o[g}cG'6L{U~$IN/p#p: p+íp: @1p: @1P{(ƭP{(ƽ^q/Ÿb @1P{(ƽ^q/Ÿb @1P{(ƽ^q/Ÿb @1P{(ƽ^q/Ÿb @1P{(ƽ^q/Ÿb @1P{(ƽ^q/Ÿb @1P{(ƽ^q/Ÿb @1P{(ƽ^q/Ÿb @1P{(ƽ^q/Ÿb @1P{(ƽ^q/Ÿb @1P{(ƽ^q/Ÿb @1P{(ƽ^q/Ÿb @1ԟw9Vp/'s/Ÿb @1 @ A(v+g;[=z+gco7 JS  $I륞R{{ӧOJ{Bn}+**˽}s)P^QY?S]]ү:ӿ<^'M_Uf!Ckkԫ:dLU=̀g 1rsT5rjT0)TU={䘱NohTiP_7±cF];1:Oye3krW̘tJ3) ˞9ϱ-.t />kn@͝3{/yL޽-T ^x٬9 n;[ږ4˽wv ̺l퀪o ( >ˮ{+Vt[箖󮾬g* []VYS;rųݲ>k֮}RڵkV?GW޷y.7CWU0}-O̐^2VZ_|۶oJ+m/֭Zyͳ/pĐ̑1#'\>{}o};;:v4ع׶9O9-Ԏbm?n[_ηUuv~s[_\o{Eݨc[(]?c^˃kvn{*ݺ}g^[ͨ][s/-vN|g^x7ڳwJ{y_x扇qf9jƦ+V?qٷU^{֍Ϯ^:1nm+ִoڶkT;xpޮΝ6YѶSoa;:w 7RiÁ}uvlܾvl-;mJCr[xc V>®-^7ֽ]-Ƕ0tzϣO?㭯cgG.f;w?ԣ: #̐^2VZ_|۶oJ+m/֭Zyͳ/pĐL-|W2kA}Y׾~ 7mޢRm/lX߾n#,5eA{wO!U0}k֮}RڵkV?GW޷y.7_*3>-n|hŊ*Vx﹫e/?#<|eU35*3*+4k([^^QQO^U./[_SȍW~{铛J{sCpdAzj' B=zxc\xƢe27yr̴YvQ۲L_d*g*TN2~┩S&5^4eJS'e'f?pwrr .6L2~bCLE'xQݱ>ƺu~T~2S95{ w%,=q1 OՌYxʯ縇$O4˄if,yْZ~c||Yi˗dח|ylvœeKޒ0 gϟS ͚pښ[?׼xUw/>stream %AI12_CompressedDataxiɕ Cvt73C "2WӔJPI=Rdvno> _͛?}@1\xW_o?||/7?/.~ ן㛛?~y {yu ~ p~9Ƌ.y盏_xA#pW}}~y "yß׿8r«.2K)MW_~~{o?y>bͻ_| \7r}s?{߿o^go^޿{3x{ono_ݾ:ͯo_A~{b1jtl?~7Dp >kO-  z@SK;0~5 Eoo>'"704 D?Lo[/W˖X_~yne$6>}#a}~s^2Cn(P\V^p|,o>)0/n~2C+uYH1'1Ƌ81X`=s/"A'g o_.}^E .sktN{+hf= -G y[~>O|y??t0(\?\Ǜ3C_a^57}=P@ dT׷?^ϿkB[ `}|~iG/u8n[~[oTvk5_yk?=_^\bz̷~'}D񃂟>I_G` ٿ{//YUW|r3Oi@Y,oݻO_j֣{O[Xh{-7zOeu} 6Կ-mTQ>^p;} {W"`ݟo߼Al8 훛w7.5`UvڛO߁Ta3 <?7?5->w@m}.~,Ou?|{jb=~Eh߽~w{Ё{OZE/iמd[?~ n!RTGQջW^7WV͖2>{vfx}^ \7o :\l_ b?a m7\A8qMc hqjCvǻ nr%hhsW]j<~||Po^;˝;qzOs[W: YЦCS!@ pz)L4Li;i?]^EPG|~dHCV`64r;=pl@h>:v#G/~òW}u}ͫQPRYk/y櫟e79;=̠!"#$%#'()̪+,-./pkY?~4+*$@4SJmūv  R ]L> mڥO089- $h9ۼWy 0y^+|XRɥMSvЮʾ\òaaMMgm65,'&l'M=Mw7hߡ}x s=~?}"=~o6ml7vװƝ`"t.lvnwﮁU|U6W۫6ò`  }onq!y%iwƼ]-~֮vmE¬RS⬥Y˳Vf vvvUۜⶕv%m/[Ҝ4zY&iIҲ"m#m+m' `3N@^ yJȫ#H^*a]>}"kW'OZpUJTV*U^^y=U! Ҽ4]tZ -VeiIZSyi q??#H,0iFQ4R4n@Ӻup9g bRx lLytx569GFiv.vxO0_ Ixux_Ы/)3_N^8C *n?ނwo?d1.2/UHےEN^+\w-A cS+1 Yņ d)PفeKb-U\\Qh(&dn嫢V3.+ݑ lm<Ąv;-7[7Uƪ‚ *0Ƞ" =,8 ,@&(D A(N@qE!+X䄃i‘Xtˌm٪Pe+TWqYDAV(B)Un0= JGp_nƚ&mmW]]t+5h~NctC.C Ay{w-]O1 0{L,C" GW_U"R'R#{\4@NZ#p*f+qtUUɄ Uaػ Üi:͘D6 @@+vY6@=2P6Txgs`TX G6N N?<4@tE9H<#!-5Aڋ`\H!J55@A"M, H_93$9!{ ;Y0$dN`Tam*stdOnbݫj]XnY[_aN3h9Y+6ku&4s0:{umDft , ?GͰ% 4-pI L@=Pq7lAMj @GCHPq;ΰ#0t@0- /vɟtv4*cWlPZ7d=H {6O1xC߻ٓqU OxzmPDŽx0V&\|^OؙJب$üχ.;Soyu{=wk,CoZr1(|Ogˋn|9}4N\_ﯯw[ Ы D 4N d"Æ L#NI":@rON!;^V3Ӊ(2Ё^$hMDjƑH~+ŗ ,Z^NBVn&!H(нO&I',luQ˵/ 8ٺ>O_,m39ſOoҵU9 vW^/!\$1fV8.L(AF}'iM;vՙwn޾V@# %4ԇi`Nj̕pBc,(x]fs sa2^6:?w{'WϹK3;Qo~Jz<×@AG/oC{Ѝ)8c.T׋ 4"M i7 .J~ PμQ'/!RL.g[,O=|ÇX@*&]@c?l{ V՗}v0jۣmsX-4kkSmBjk`ڠ xnl{ 3 ذdž8[ oR@x#F(VpM+B͟ 2y#i y< 9d6ኺ-1QX$Q\]zg謚'vQF{嚆1@xdUhUPB5 +X`4 rE@KD\s=P{7USzsiҮh@<:ފwc#q$sL ]UH&yIu}2vG~ 0F `iۃm@LkyҬŮMMB&wGD.b"W"Wb"6AHddtpIbTeIM.E)Rƭ؅aEJb"Ҩ"칄GJKs24#˘?g>/P_ pQ-I5w0+4P8KC!觔&39b+;q̾]k k՜emM\BỊZ-]m1(ɒɒ)Sdx@vEg٧OLS-) @{b8Q8Y. .WGԇp];WClF:wfr.$!E&X .]n+&d;%oy|$V4VN|ɂkT5<A1ZF\Ie9=ST˾ܻOd-9f@X|>+͛E2&{o޶Gۮmqw]ݫ%ۙni IJE*DcC4㚨  т)deX3k>ZJkK[I&Q+IFnj&bm(@4MƢ\tO)SUֺjӳ,Ek^R("i)ҭeiZ춰^LVaaYiÁbpi'GI@x0#a(''ε)将ڍ LRTewf="I1Q\5$k |I\㺴4*-ۄkK6wyGy"F!Yq[vb;a7εHDd͋r/I&)w~6MBʮi Weߵy#۷u(2E[uw-nB6}YkJ 0~;֝>)m1a\HqqYtǹSUO3#s/efd_2ÀYKZ0bP!sCXkYnÆV찕VTmM͢Zh7=yw۞Iם߈zqjZl=d)@ XE>Z՜4J=#IbbD [<]fG5lnkɌrQWtpk KohK[ߩԭYԒxZj] zX~*2t,zBpq!`:{ɰJ2A}9_x;=Lg:;+@ ½#Te0.bV`t^xrSUbw6xVyVz=Mh>~cjz,Z%E$.dyb,oX֩-k1;k%@F{nIjiZ;YkC5kjm)MմliZC-ƶVSeΫsP 8U+5ծ,n+XIX1uO8TA9dDEً`+.G[cYvOHV]ThrpU1j\)cVO1+1+1+'Jyʙk5(1>b|OE{;1~5+e3Fd2w`Zɷ|@'(4Kw(F䈴6Y>+=cKײ.`Y=]h{QKs=BfR,䊋+Kz,syr137Jӹ@(w~Ѩ#{G`ʴƇF}Nbm nMʩ7C͌+Quif[V[^iiyD#VGSOєhz4epwcb|i4푦';=b|A0m~ yy~HMr0eVw^vQnI D3@G%Np{6^xy9wͬ%O9ް<㌛O)a\aq7-NWp` hT/K{؄iI+(8h`aE㠹,碳%w͜rrYǹ~p2x_8r`\vԍgn\b~Y'm)ٟ1?cc~†1;[CN vFF=UchyNM Lqs<׫MLYO,Ng338T>=7~ȹ틱1zb@|^ƴLYL馚Ӄ(˃5 `hV0֒eâHm7'ΈXMHe=d׶m6CP$/̃93;[Nj;?gb|n|z\G#n0s?ǢlwDӬԑPTS-!`<ǛCyJ۞WJQnogR`,c>lEm)/;r$I{}Б[:T0p1C̊EZ|Cet#wP,bcO1d6%)X,Z.ekrxm,[- 8> $-Nޖ" sj[H(ohG *c 煫1Qv2 5蔚Zϒh`ڋ0zfjO_&V \ T| sܿr~ }lR>׿VD2Jji}ZQІݢrMъh d-fv 4wkC3T8+>p,Ѐâ=oM?vcGP;x, 0nGۑscXqJFVYJy+MtZ)wJ*WP6ʏGrt*S8\Nḙ~+_%*'P fGr/\eE<0vnY[>Y{EB/2>͉GٲhyрԴ.Cu؁>FN9=ʣ3sPX-]- >wu'(w4_Tz,r|=3q+Y4<'Ggt sCc(ϗemiJg\Vڵ_\e))z٪ZyJFz驑ڥb ˸b +8*DW/dǽFT(rF݊<ǤN?fڃ%D.֟þ.`Vۡ3繕>N 0o3>o6umsfgyLO紳3=Յ:Q݁ke.^h>柞9eGRܭԷZJԽZJg;2{MvX8/4ܾ<som!,ue;TrlY;K=|f̡z6t7)S=.1/:GWpzJZeum)%ym6U k > Og1cicXqBlnPRprtz𦜧XSN' t 1C Mig|WЅzt\?6xPк>ɖ%$͝?ݕky/S;Pu5q0Q~GqQ~GÏnGΣ[ѭytnSq===}Ic:c:c:c:c:c:Ώy|c_T>b~ƒ)V?fOC$LͭF0 kDTD-RP [effͽa.Sh.L%Ab$5& w?]vZ-,MWDՃjS/!nꔬI\4c3:6N?ېDл&柹{wȀyL> 7)D*d)ZRP0."<8ȱCiιwp'Tة4%)0l̗wa5ع4%qCFc33r^<']ŔgTGҸ>@vH #> 8ب'-!0m fɶYmnxL5wYٳiM3A}A f %u5D'Y5iZ53++Ik#+6XsaN:KRgZYδ\W25eI|ĭsf٬?}ḅћ}9jֹW7'V+րi<}Ҭ5/Wu%jftbwFW ;ʶՀ6~{Y~k|OB0滺̀٘muj[9oup,W7|}γ|t\ϲ:AtӎJ+m;>/Du7lp:q4wPӓN$Nk䨘J̞`PY4g6Sq\e>/`ufS':|rm6xN˼Ym[9sgF68Tݎ~`usG3YԣUQ,5̋iH'iOTeS^%+啳.!US{zԞ`&8mm)#$0=RrўDytSz"1@~)O)G`K'Olq‹$W8I5w<_>ydQN6GVڱ|$hwd,M}߶uV{zx]̡v{?G8q`%p"mXLjnv(Kz-k}-{=i'd/`:g^e:m}۷9sq0?&eSKAd#r4>0.|Ond'6/vٚhcmIkmv/$es6~Xk+<$?[5zRou)vAݙ[LX@Numt]t׆V%2X̝h&ii讪$|#,Q-z+;XlT(}HdS SdEε8,q?-b>W/ʨQ:C":EqDt< yN>Y":E|>OfR} 1DX?}"6#Wa}^|'wGX_|Ug:TՁ;"e߸W-z΂]V]õq:sʙUsSDMuªV]ꨫ:u9R]uکc^ݳ꺫λM[xZ gF}~Oc98=.O|岴*ѻ}|k*]kūw?XzIZeupdf|tibݵg(έ1MckrN OP<Ԇ}t|Q : @CrSZ.KCqDN#X XMgfNHru:wuA,8qېW;| <}LsrŚuw]3pD6ޱ;s!߱;j\n^!1qGđe ("1 (` l"YⰊ* YA`sAbT ^JT#jژq5?5s4YtFQiD 0R уp6'7[B5GpU"-pFPq_BpaU;5;fk$#QrDԦmkke6mסJ5kWk }=덜^_*m@֨#UїAhoUmaW\>M^R=YkcVcBBI -jd}v;%Z\T dTOzwe'ѝA4@lc+KuINS.Mchtw=neV X߬:dAےN-kpm_-2@ Hwjީޙ+M('b!~iNA|Ay6#gRY\Ⱦn]WhjIZ29Ɔb^e\҈ȇ*GTKccQl)$ ,903k\<Kj'{ݕԵp[5UHZX %1^\ITD1mJ+m;kՏ!G2bD9pSS9.)]ztȟ#^ ;͛4*>eyrggf؋Ƶ±J)H%!XZƒ5f#&yV2~N8~d)TwtwsX=b|(˙My65Zm-ѝJbEzcj%zWfVӻIr4GWNt4vGBdHMԤHI,jvmKZ~RrʬAgaxE[9%uylSZ;Rk+4ɗ#Un=l{gQ19AJd%syF^5RW6RYյCQ!Umc)zՑH[y=w=E4ٗ+gZ,)aI'J82>BCBtrg5U,fe[՟OZ;y虓v{Z;QZkķ+zyYTc)n+AKM Hnb3w;/ h̙R߇)P!T@8!ϗŁ30aPq];{u[|9H\#ugY~%{nvIӋ*8+fd7ۃ3ptYWӬ'y:^s9?m~|riN>vvu?!qZ\݌BjM4>tU_#Qcj9P,1P}Rs$0됷ݍ], Ἳ9ez6!KpA8s]rݔ\@3<9#=8/&' 8 ?8?7k/ĸam2gj?q9j'+',\R%[i0DPç.g [9gZ9;g~`wS"cK?zyYq-4|/'y=_4d!i,4)K;W+e_TJ*:/O,5jbTy,2nlO՜' ` 9vĜeT\r'VTFksU`gZ PZwd8Tꓚ*^J+!+ˍ@M>U԰rR*M3 s'ۊv0e+m(Wq=`{Xajb^,RysݫUjEoI3^zak)KVQUl*ٜR s^ uvvFb=6]KhGC\uW%6hb&c%!QM%U:is R0)99~*|$WT3q2<ֹN o~o?~o=kYi'~oo>}]Wd.'7N~ Xj&eN%^&p7M.X@._п\__.^=E#~KbW*d"Xv|%U]QKqN _8]$X>_ypvGp L8?Sσnn^(a3Ao>|zn>pK\ԧϿ3Ϸ˗^?4( 8Ӑ(FF)7):9A@6`%cB^ˢz9{j.,y 4qmX% ML¾E8^% ȨKw\;だԩE XEqǘ }8""FЇ O`8`7xXrG <'a2E $MIfT 09`$+dXҰCA\9@0VJrAap,{oo ׈1@e(_ \"i5C?lȰzh?nǞT5Le$@:F*Fg}$ zNjAǁiJ{G (İGDHo+u^\I0a'Q ե/1iQJg}ԛ΄vsG vu=4 @f|YFqP=[p'uwyTL ^áF]hGC] }]v@ -\] \] \ }bAsu3"%ٻj.Etѥ \?P\#n&{sѡ~1B\:mA\%ysYWN}lE6y%fᾝO}\0-hUJ`'nzx*"zG_|utYSU/ӎ.\S 3Giw<\R]@(ͅP̗ZI.HWov @PB-R %n5z:|['^`Q%:BxСWձM4\Gt/#Zrk1ߛ0OoԞޱu(QutF=& 2utF}!h{h (F&Qa" D%-Qw[,F%Qo2(QoI^["E%\Uqu(sQ/޲ gO=\%_1pO(P'FTa'ĐP"QFaq-;E]E2{]ұ*'Qu*tD=H3!,B-QLDwYB"<2-1M;>rs QQ1:Kxdc|^I gm %߸Oa21Yg_| 0+3XJ |Td`x 6FnZ'=(#*>g.8A! >9zrFt%d,zIvev2tCB|xV;ڎՎԋ/P.CŊW&wA嬳rwo0:`z1\34' ^ =r_\ij|eS"Cd! Ћ 7ƺŌ ~"w\yA<4C te <(F0܎OV#"d clgD#xGq+K0Jta S)$@XXGdDԂI$Ucg1!ڳaB.* ^=!{)H>sJO 'M,*… KǪ EҫH$3i_eaN7`nw Cؐ`-,0ԺӼb`Ć`L5Œ0p:S;kt<< y #T8fǝS(gm0L:fP8Rtx0'lF*y2&FW9ccI*G}E/ 62 Vݣ>C_ ~ݲbp*#խӵlQ"8'[a.JJr(!00Kf]:9GxT{IyMgBW@.sL!e8噅"|ʂG1«U։S8F px1H HDelFuУƊ`,py`\&t2CBy*Y{pz7ϖZ 0[2!^CL"ٕ(,+Шb,t'i2/%)dNMdQx؄4kFUO7"fXgg !D"z<G-NQ1AׯZO&$F%[$qh}*QaPq T2I*8xۅbp+"ڲ"L R#G,ij :Ds Y`44rY'Nd2fGUm P[K̲=}0'_&T9Qi";bq?Mh`i0un\A[\DT@DW+q(ՊbsJc);YvF <L h ]ƥuʣUXHSWhOWlΦ4Y"y͎FKZjql8b qR?ID2D]&"<ֵ^ʈi3Xf"2`Z=X{F*f툗D #\Z&D4BLI#&{ !-*TEmÆכ+ e=шL9&r-:(L h&A/lu"0M+  p-Ϻ 8U!"dFBFq(+jAltDfCm뵑S8T'|TCLUn+-˱V"0&vV^k̴h<(T̬SiwL^(Y"(T>Q^,;.%1cVWZb5+r0+ T b-*Rj0:,wQ,yh$n#>4?f_~ ®@9LDȑd͔|6y'v&z)$-.xĜՌQS yTK}"69BdeSVRj$bs֦6udMiXU| a0%A1TQY7=7LF8x$!)b$K"v, fxTkY@ `Vcm,I*]ֶҬ&QGebQnHA Aain//Q0:G^nqC01[-R?P8eF 1(QPz  Zt Aً*e&˲ GDehuhc\iyzVH&+£f`Ĝ9Rӑri˙a:>.Շr"Jà&hѵY"LD`洩ʸ1IDHtdc5e1. dVK>4]Hc1&Aj[bLA;0m;qddc^'\R&AeD@iY N *ן9P*İQ-exѦ4Xa$- , c*r)i5+G-pQ&]b朴5lLj"PKfPe :PWejb?"^k -$kd+Rb7,j2G @c./2d/J֍+';b8qڂ&E .V1m_JD$imld/9|ulAՖ)9 HÓל=eIDlu#0껑Ah7G4fW dD$,{`aQ!]:`D1 T׷ gi]a Qr>p#pmDJ#5sA 0n՚TkЫ.#k 4 k)j[$cGH5[F4H $͖(làDy>Q&ej2X%5V̰Cq֛Pe$PyAS#핓&!L^1K0?gUu[BXJ.wڤ)CVdg=e1oX=Vפ{1X:E NEE KXN,I/PbfUfŇc:-%qT"I^׏ 1tDŽꊉ ˬb eOq NHO2PҟIץ!Ϟ fD? C9DyEme'c$ƣ%*]ڄIփއ(["bmB(襞14az()0x]iL!5hcdބ&`̨A AvIQzad}g2(>݆%{"&+^2F,)BAѝ$LLҞKH0NAKo갷˩trѤpvA7 ,t=ObU^th^$II'nD\)fH} XFwb3= ͦj秘()<Ǩ20z j\FlA+1N.it*?|:`Ȗuk:J0y2:,IӲ1feE]MS |pӗ܋_ [MZdB3f`_/c og^6)୪ hBR³%MAQŏJ0U5VĖ1ѵg/I_xz, qΜ&v;aa VLQAGҹe'4sƹap=gVW҅Q wFbALJkJFLPngX\,׆bgrd?aI86j0x`}ӱnЊ&&s`ͱµ"(yWag}x$ IkHqugTHm]LCf![@ɀYGJWjo.۪/ 7t«+e%E! OTIZ/!bTsAStEf;-Y9O#ade0k'Oob| Ww5ט3F jHь[be4={~`5xR'u$6aNb45`=44CBϷ O&uIC(i뙸\TLyF$uih޲ wINe DE&{-=KgWjGEd6ԕBNMڹ4h 5h15BZ9-cN:`}Bj]R`2PmjhnI㩊&ɤZ >똭CʡT6Q 匠b钵Y^cUNs?*\@lŲ ?'Q2(<ڦgq1,k $ۿ5xYMdRzq_⺊JuPp 5kOK`2[B6E͛ Dw-?+h!x t kMȷ' L|3 {в^Aںܳ ʛ֞@gUBhوP~DԏH8.MURUFdVF)H@IVxN9ٴobnlaB:&]T[xtR+w:h. y8XLo>QGB< hѹ(vU9B+Bty\5&MKT%ߌ8 11OCD9m ^{a`(ѳ57c JA> ny f(mRIw2v2*mzAL$`HZ`'@à@qtrr|(l;-1,Iάoc$\R>Z"PxeJ3,(V⫧Y=Հ9JhIm܉·zA/2z@8rN ! ,f,,]O:M1zgIb(:T mb37wyY%/Kڋ1,QTzf YE7),vO֚IGk/Z7F79(=Vx^5Vƈ@ϧ k񆺪 \J= C?AIq@\n~B)}L sJ2,М&B k"nHs49]zuA%V,.= J (Ɍ9K7/c>XK}RAdŎ;󖯉 ir8Be8Jz҆:kjV I.JˤAA 9G}HRU"bI1ģB J2:Mř/+-rv(HEˀ+Md=ղ[*5 "j"WR,aB<<f9 S&kV:ζhL>HLxVht~nނb PTC˧4M KۚW =O z0(PfWGYpNR? {0&Cy"WɣHeԋlB61k8bpy$&FiH̼B,4C1_4.ɴO(\K%%K 8,*2L棷1)arגZ~ϥ]OŢE#WIEbi^@YR6+iRZ-9J:ںJ]228Pl_V(T7ݭlÒr 2VL;FRr(Ai1d|=lQ _WΜ yQy(lYNPUK`=.)ISI!_jCדX|H:chϷRe`(T'~Qh`®,X2FciW}3Lu+rp'P<_-UN߇ҍ(2J%}qs ~'.|Z&2hB3B-CǷqEaPAb`>VyCfޛ 81rʫiNMcRE-3ڥE#uF>+ s38;`u1 ًBO 0fR뤼h'$aX`TzN@O7rFBf{"qҢ1 rQX~Φhrqʅi`O ɓ\?d,(Ĩk'Q\fTR1zgo22w6?[9QےYad==%^X[ j ~Bg5|vM9g#gz|l~FaKW]r:1+|"5f8>4Ǔ-œQ(bեB+h*3+C~,JmT0>ϟ`KPWQ]\JY}:R>s6'Y.Nhӫ5@e=ʖHS94e.Nṫ\mOTTabBɕ=ŋS.)ˑ,q$yz|f6'F $1C>Z N'PΤb%S3$gٹ.C #'_79{~HeXhgӋSF13 !?K7S}ee`!yMN,W0tQdb ~ƈmȵtag)ȯN8l.q[ %BezasVBO. T,>To9|1B2a721R o!J2G6]A x𺄅 Lq̇pB `NǤq܁8,Hp΢ -y7}ln6!K,b%8:EJ:7'KU>F*cyab-E)͠2N\>JlRC|9@@GKZE6TazqB褐GyhIFItIr"8; e0RlyAhnE}HiўQ e~+K+Rvj}x8 Q>r@ʇC/Ă>8Ox!}@zeFvUA_dR%bHuq<NiJVyx ),NG]ɷDK~G1SIf#`} s091zU|ʮ<0% yx8IJJ+~]ܙʦk7bڠk3#kiD:/tp$TzBwv !ݐί.Jg6lX<P Xe(|2™]J+HD,sF2F{ q;g.Zb2"V'ufZhO?&F#C}q!1!{T4d9r//4'3a9'!=ˎʨim[Y]Z.HVzJ\pѺldɯ&rEyQ QJ۪*N0ustڅT{ ?$S-'Jy'0ףvX(+^_3nq aii \+be};f%P+FgdU_12y\ȋ$drG\-&Mk3 FiȲuݕZj1(di?dciE:D/.D/`Idt@#F퓍<)*8kDezq*nGXYomjeIP\n)≆3nR^ܲњBسj5zԲY#;LwbtLI%[Ŏ azad}cQ ǪU@'8'I֎'&j>/.]I2"Ru.hqۮTNՋ\1]|$'3d82ҾT]<u#ٲ6VV.N[yzVj }~wENIiZx4joqOF3++tĔ$h#8S 1"PD'FiWz9G`:#y|bƉ著R2XVZW摖0.G`ryB&4ˢLP~GMqORK zpπ$l"X 2U6rmNkcy@P P.S*#JNd#FԛB7r* 392W:~jA'c%M=w[Ař,P dcyBxM.NxEefk'D233,NI)ATOK&prF$g+r6ǎF: ݪr[fV&[/ _@W'`!+#syV8&.知,1#Q G9@QRD͉#“jĒ !׈ecyB'6@r23R |8B2Xܹ$5{e$FFF-p'l-gOy@d 94A?0d!9^9ˏ:ďkp`E-9%3-VWP]$Y52coՕLĩ^C| ~1*4J}v 1:ð&S`_@o$%:X>ܮ/.xMVnk t6T1Vlɸl'K3}o#{v51X^# 1 ar]^XP?L/>]2{8sFȉ%0'=50F=+@$Dƒq1=2ib WLzpM/NT OI,@2H-׺,TR g6}".A?lHO/c>ϙ+OۉNz1#tKU^_lrjímLNsV)]K?2Wlp0k~jl:&N>%:6T˗Q ܊3n6^j#."&C}QyB߫̓}_It+'L2ømg㸾|V..KFu@01Y^_*b"bh˄Q fcy/ Eb[8*׫d++}?&KT" 8}ci\Q+š^ˆ\<ئ%؋h_N^l$UM~ K͖o+EThK!'C1im:g R}N.]*4XrFc'*+OA`irXyXcOVzvX"a#8Z  /)(^9<,q9Y !簻tLR%u MseIE>)I&"ʧ]e%FK)SKnX{.4TU4 k<\L:Q Nuh,o &\wjL &&Cb+C6M*;8R'!tq6s動1SkcxYmP%x_x;Wz8.Սx(ƕ.VcSKRɶd+V.Mk5̊-/v7Aϧ=A{m$c|hHJ*ou6w' 9[̆XX&g51-鲑 =EgJWrX.LY6z}~ۉ2f\z,'Y1q22K9&ULwU:h/ H݁TS.-/NN%t+\q*"UoxR_\gmdU(UcneϠN~b?#|X BXQ7LQSo٢, Ӌ}:\ cZrm$/j{h#/vr)|Ƴ¢(r%tOd Z>]R "6]6&OYX?8 9gыbZyBeϘ:#LStcojD YdBw9u&tKqf5߻%~)A\69cTQD|8^|([ ΕPTI;0ڻzqNųgmW fPIb9eZְ| 6Nv2QeZh+{0To[R#)&%OIe4! 6\0bI.+Hzb#Fϥ+$!8zRWuƔS0KܓWt0/rҤ.-=>8਍(PS< Ivaz1/ث{) Idz1v~iY'F.O(v W^u7N(89Hi4fNF6G؇tbfYә0{:O*`8b xI~mRRu_VZ] S$ @(U?FOsxJx0ګFj\LQ3=i.LZʧ*Oh&_-ej}XJK-k&3֚mDi]m$DfJ\Li9Ue35s.L%m&6RTYg_irj' Tv^o3 x.]xmnqIDn,o\is9̈́V$H_/m&7RgܛsDfJ~3}GA`Em)K0a[s餍f|Jk;Lٹ*>nf ϥ7sYs)|v4*'>ջJ ods3y t DYsb3M9AA['aBjX/T@\DԬha4u3֋lTM5:LS=dVjK2`2'wFi%W,M%yٗFLSMfNzSVY#@3FhP"-j*C*KmI~ӌ:YJC6^jcZjm͋s5_*5؜HYSlFlPDU6'Tvkj \CnNnFᮩF8Vk0\8J 64 g jm]"SŖ6㜐cCf$ÌdSrDl*p:=*M9qѦiStD :'Td]z6cfʴM uܦn[t;N"ym I7ugWZ-QsS-ܖnE(KP5k+:9vwC{N|F?4^(7dћMG:qJI~Uq>̈Ӈ}S~^ %o L4)hV4h>+ЬШ0WY١Yb}IufvъfV)٪Jse;fj|4̕iiV$YWdR.\UeJJ.".uŗVmv!Vՙ4iֽ3WNY{Yg]IOP\Q DZE3UwVWVxw*”/5 ;UUK5+Q5kV[5+a5kf l5q+լ֬6[hUYlIo|sJ[QZ^+%A({U=oEj|rMmz*o*64j- {%P\,KHW%_URjj.gj95UHDzRMYՌhʪuZ \sr]Mm ؜dXS_D6'[8в;ϯ\^. gIVfv/D S.3v] vu^kҴf+Rc{1K~*HWZg O dc^ w=('4mQ|ne0/5*bU-Hѓr}t12pCw+jJ&_)Mzdc?GGŠ/՟Soë_矾͛s5o}uyQ|ۋݝ?noO95Ћn??Y~|ҥJ/"a}]TEu͛.ߋx~Ïw-_=g?xZmċ7uEjwͻ7߬Y}0^{v!5-wn{wy~}vq_͛vR5oݞ}w'Uo;[V |~qy,ܴx/gwib}߸ߝ=u{w 7a (ҳ 77np{gכ,kiF|~{zwsy;ve\|n//:ġA8~8/84zx{Dh"|HtsH~<@~!~rVMړ`>`ta07?':L| Oݚu#Õwo^8kmw黷o/Nˈ+hbo}m޾r/k߿={V]aKrusG 6]?\n6gyc_cswߟsv՜ܵۼq]޾7[ ;k֭=~{w10ͧǞ3ۦQ?7v u=&}_'e/]m}qE>ٗÕhʱ|ppfOkPI>?mÕo1ZIԮwn޿ؗ==ބOo/~:bV6 ?hѯ{km}Y#6wef;3 ٜ!Oԃ@52!c_7T6{Uoޤ}۪m~{7g[w[zo<=;:{]m~OZwW5_p3{־>삛7iOAd)/5Jn3;<ޛh&7Ezl_Ծ܊u8y:z'7W[ PmLlJE:kvܰHa}[rv}szxzvzFݾ]5t󔢳˳?ݼ٢j9͇͛ dcWmqlƭ޶tx|Gr==>MSGz6ԾOE H[U/!P;w6͝b$}0Ξ#i&f}`$[{`$=MKb$/͖/[c$mѤ=ͤۗ1hҾycȬ~b6$ø<O|'%쏯_Pwpކ>nx[{&  ,m§Cxp|<<>,e,w2.-Ox5ܼ'3÷Vj=q̷׋_-SO3QӿmnU%ڶu{~hξꨅJw *œ_\9bcO^lIV쪡_j۟q3Íܹj҆ݗ+ܴ\mu+plը}8,vl}ɢo{#ps5ݚaבui.cs5lzpp[n"Cdamo1ZIԞ-o^Y;w,^xu~;uؔ&!cp8)|}hpp8<=W>pӭ8WۭƇL=Fxw87p OkǓ<sﶨ/ƶڷco 21u ƕw痗7ڸ?x/8:]ogq;߻c޵6#>q_qDY~uq⇛_\}u{櫛6c{ @(cqT.ݭ):|îۼq_aV-;^\0&NH/$H:{IGu ?fnVM‘١lyufNh|gM3[4Ѻm]5nd\9@2_ $yA2[ $Im }d4ޝnC3:O/d7[PMNxIUiҖ(6oѤ}o;[NB|\mY&7[hg]K\xo]5;\ӷn-tcy`ORlūw׮&lMO^ jAko|:_ ]ƞOtuO=tlB`Yo ɯ{$m$ xFpjsv6w7lмylN+_BC(zE!}!}!}z'ѧn.;m<Ģ_:!=Ģ_D,z\4U$nѧ> ;iIדu<;~8Ht]v\-~_FZjwg?}ϧgwst݇-tvۮU{mIW;GV Z'Or͞} V'o])yO֎m۵o ƶ5'9; ۘF:`]_?n#0q3x[vizo~zv޵e6z`aWSSǹe/㿶ث[vʽu:3#Wv,g1 ;ڟt~‘y7w>~';^챺d&}{}/Ӧ ~ o;?PjmѢ5I^h~?t">ۛ}=ބOo/~:ߦja3~a3~c'c'֋^{>bbr}bF瓾^Ώ'/q0~K^{\/H yޯ4>ns}ԢKbٸc~}~L8SFoyyʾ0yP٧mOvҸ`gޏ7|MS?p0:Qq_q'gH;;;֍ߟ߾}4c}BmbC·[²v zw}\~ժ8cH{FŮ3o nZq=U$նwEPcPp 08G vǮb}f~2]o qtεoTN Jw'񱝍̭x[͜߼~9]g➿?r}d)cKQ?7 !6fÛ_8zt3E0wW:<o9~v Vc"]p}\dU\o ~8\[-1wϮ!@k:^W8` } ntoz\|`gҡg} \؇!Yq^4-~1hh[2:#Dltsnuq1evʢoV,uO Cr{;љkK]םΎC+h\Q:g:~KQ:&W8v^mwػ6hvI7 AP:2,{% ~A77ߝ/NN///~,e>.bWu;Ye^gUœtc'K繵:=Kqνks'Ny˛^Ǹn|_߼iqkxޯeSo;@-^1b1]VBwY>v\w6^4ǥcSVއpZO`~vkpDI`5tgnŤI7buXWKә4(݈uie(aއ0YjP)kD?,Cϫ z%Lwg8túҫ!n)ٰDŕJ 8e1.O;|ښFHZo4vxK:~*ֳYs.xk{i^q2/Xs;5s?4ͷx͏?=2}k.x kŵ;?\o}}߮ߤvƸruݝ=:?|_-~k_E_sC_~y݄/S+7 ˴#u_8;}t58~wx BYL䣧']Bz2pkV+w~[ 33$ba-!.A}E8(Ycw8^x^ puݱ܆?`XJ+/[}ܻ i[7h`lU!pj0rWh55D:cH|s p"Ƹk9эQIt }Q|SWG`j@v`j C9:qn2%8-PL'x́{SдTCr'=־Dkc3,j+6Gxň^ZԌc6(Ն[8{ĤdlPos35鿌G Л~chd1%XR %XR\4"J48of,}1zxbWwѯ Y|㬂U=3"nc%`nݭ.x'VUt61[4t MB9൸>hQZ"||hĠ3,-y=9!S.\:]X=VZcq8 D?ZX]xx}Z } ŵAi0nf|"ڴ2faqJ0 2$0(lVW0-5-j2UPZ^ arIMM~VTN@%G6]:ŁP75zN.YN9v Em+Si2NhI7[5Vru ^NoƇy 8DZΏYpp*dZVn,ĕs3n"ij:ddcG }2^ؾXsūg@>RR:돁âe అ $G#ۘYۯVޤ Շ_O7A֢ijnb@ÿt!gNm>.!&sƍ_#d?7MQ\L_Z;p8ʉ ~c>0 mI;qĝ}ԻaHY/})mZ`$Qw4FqtH&!?O`;p܉}AP8/dO;"xܸB%\{c&!.`M^qvZx~#i Po>`8sNlTEGnf\Cˬ >\Cl^ĕ{oUj_ޤM))5asKoP-A \|AYGWƯ*df'?{}LLnD۞ b§6+#ZDވ2d `pqۆ/C-O} .9A;'11)]hcU"u]$\G"GBF՗);d|kr+vxM1~2!4M8q2X&SMyB˓9n_F|!_ ө1g2CCadT$'Y̜?3N=7r,kss"}6_ԋ/ ~^SV^r^3T_#6d i| /Icl ܶx=Ё3w0V\"D#顟 Gd1! uR 7ltݸp*KP:/?tŶqsej #wx\a;x0X u\ei%Yy-PXW`lG[ }-:upSLZ }ZU|S*fdU33g(l_t|QWva>:7+/~e^fyt42qR 'njM/;Ѯ1'iɆљďPGzx1ses_zՙzt:5 !8)/K>H-exvԪU 10ȐS~ՁJo 7~"C-;00U'F!؄1 t%Ѻa0,yvc-wjޕͼ~`2nͻqzd-) &uмUb}is}Nq##!q{k6d[%,Zm5ƠԨ:x uGcL`Tyzl+Zm zxK+l j*zHp)ܬ.ϯy4 f 燘`15ә9 W!RY<GP'hr~{ Cjyo^ꓸ <Vo8)J0a܎ۈy܍ cM*TVk1sݤ_7r%0<3ot[I= ?~f؁c6$EYQN 7z!E#2C{QkT֐%#_#*o)iw[Fqm9'UdLI=eK'0íN͘Ȇi.z-X`![Jx!ngW1m/a? L `<:>)WхǾ^̉8oȹGƂ3؏00c"!EcK/ Zbځw=ϓF=N1 #%BK@iqC9"ӴWڼz^ݼσy}/sҿJ4{9G=GAgw/G/JKP"F attSXZ8Wܗ#Am\8mWyr?5@tρ d$zXsn qFq;EՙZ"nj8oDTۄ`ͲcF!S#d`<{͋Y@DWtY_ ~&Ycx$emWk# UD^r@^^2S^0{.w8>m/!&d_ h*<̒AMH0+A /eDQ:vJĮBL"4>n M)hqz:G g) pmv ^;M>`} R؝oy  ` ^PWxi{yb^ܼ|_tt85_^h\9S܄}{ u9jx@YH%PA!O<'w#:VhT oSڒ0AJ86Y9ݼ3^fLiyAe̔U8Onm|\MQ8YV+z7m,r`FdoJYczr=mƆ pt<܌5kpe2w`f?ẇ>HW #jۓ0gLr~5>sr;}LԌ3iE4G!Z}u;fP>B@<w ` LG<O Ionw\3'tP=CB賈c D>pñסՅ!>  fL3hJ2T) z H'}DDZA $8IhS K7 ς>f<jY(, ֳ5a_TBvZFLF y|!N-{F"ΖFwtqb1if뗹D(DE' )xԐX4یy޽4/_t~x#ԍ3i{OfDˁ#;/a}4-㢿YZ~ӚEvs|sm[>JKzu8Pʠ#;ttEOŞUeg ǿ7R[p mY6&>.Ni_}^A#SXH`2Bp+،ȎX^NFimDHƾs9EAr92Bt*aA&FWW(e|{BVt1:#j^1]KRi"`KEe34NJ)$PT&OVɄ,c#Ps_y)UjPY-FA^V$((epv}<ϵ2P3^8F7 ~%>K T¿痣wO_ehj.Vg8qu&U+9l\n<OM'O?pOx;zxi{LsRnaA ?7Du{/{D2C*Qϙ;N>FkYGt{.37c!ǜ4Ogk9 zu7b=ۉ{?oj(t~hNj S\m1۠+T Z>y"+":/g@LC-9{b?C|gㅿgMjT^[UpT<1W$Wd4H z:3}k*N%h,. lQJ{Y{/,XC)_6&q(s\<c΢tNLSY:Bt<0{Dc@C{G_"#ȴu+h!2_p",sc,݇ 5SYCmjͻ{'=xЙ{E: i{'?E1 ԈAT溹976At tpA`Х̺ۘp%AT|3{ϭ^wOZ;bisGB' BfL6?Pg O?LVbCƗs` /?r|Vh1+~Y]vkA%VhUJiHAL+.BƓ%,9U&APeI$Xp1e` Ygh121dL_^ &MNTi&y'z Ǹ毒?*Չ$Z**M*%z3Cq^+f/H HmiX.PFrKS!z@22f0`($huL<40M4/][˄(էGUMpa#g ٭Cy@{c#c:*l>fknqof8}-H>kF2#B$jG<r? wOhKr*!/(oD:׏d}f0hF`iPP^t/JNTj:¤]>`k& ,BUWBNb߉uY3ë bL(O:8</5E v.=POUX;,g⨱8hS-rZ9@nPBɗ0` ] )NIᄼ&qy.3pGJ4T\IO6,jdqqYB`@a[aK) k܍ #2U_j hǽDWd*Ln$O77/|껛K1Φ>˩,X鍀*?e+E9AEJTEll(2Q 8"* /\g!X~h{y\o٣i~V,동l^ reeðX.Mǹ!(Z6far  fJϑNnCS|d 8@FG1,N@_ހ!dPP%.p|F#٠O{<2;s!kkV爱3hR754\ŷvŸf^rțr,;^u,2"KF(wQ)^_j N7MEDIkdSdn0%6̒3 n`DpVoCÄnMzhDفJO%T?S@^JeS'v B[~+n{:$?=F?fF5)zH2K@ǭ`GJ*sy|=˃x%],: $H_`vVBГ;ñ^kH :}y#sg $r.ͬ#뤥.i['Ly41e]PW аo)D\h"~.`))&Pz-[%sGJƫ 5֬[G)vզ|'geRw ÇPNz+.X{ɐflONNEUcSH5M{\'މRg҉ 2vދ i>I>}8(mĽ2B'O&ۆ}Xm'Xiʼn#-\=*:XR2I]H  5 쵝 h{MXzu"5? t<83:QV]=B %$;XtXzM9P]n Jf<(n\5D*3;x{jO/R.: 7)~) WC;u=H=S: 7}`4TϤR<63*=c97)a|ei/D˱xɲeq}H P['qexp1Se 7P|Y V 'ioPb:cפ#ǀbi\;PgBFn8&>%P5AvVC^P>qՋ383}J!3\02:}Iyu"LؠHq>Am. Ʒß B%nxWSQ  eEe(k]*iTFNLzMnNxЕ4[Mj1bL0!#0ZrElLqj@ђtulvz<} L҅^`8-J4r byKmXK)J+~hG܌!t%UA,PBtU`[X.6gpIJEgi-uITg ؤ} +v{5{7[{s)H1 s8UwR"Ix{ ژ:aH JE36g 9@i- 2$M3:*hk/a]!!f9&?8Wf|9 wnĉERJ$mB\°?Ã'+&uUO$h螐$ߕqm>5R2ɸ C R$? 8ŒR1c^.)K,wy#*3<F"& :9n~f؜D/. ¾d4v[;CN%fn+)NwR2B#TB #ȔBْE@$΋0"\U$ƚZ:IԅɅǚ*L =YX {aUI9wS}o)B o0DN8l7%+R)Kz.:1 %zĮ\,@|Ђ#oʼnHW٪#8 :"ӧ>>;bHD ܇ShD$q2t(4a ^(l$ 3*F NԀb1r~B̭Y:< $_v+,wQސNJJsRgqF˹Oa0+\'8\/߃jK}sӀJ8bfI"QATGSoEZ* qMjAQ2)Zcj[^>H4D(yc,=ǀ[:v'yH !kKeE'Q,N~\#q]9bYngA0z8>q *~.| IBѕ+Qe\?d¦C `GHT2{Bi#&)3e<}.E\2̓ /uј15%JiGpBpjOԚsKjCɔaYZQMQ 8Lt>IJT6R7`JPe,0$f^8ǀstM%~J]"7:k! y 'ȲoU@7OU,5p6,GU/>Qʁ${85JR+(Z9J}B12]'P"lY.$H5Ur֤S)ƍ 9b%rH%HQÙ{RD6>m@:9Hm^hf6ryǐpҎF,\E}$} g@u/x mxI:(6'Z$5zYtʼ_7=N,BOB<+" u^2-/CZj [Pv ,:*#GxҦ:io/ a!%/tZȭnpm~r3}ܜ@dp6=dmCZ [H)⽰S|+mMk/>9_3 d& o9 9 &=tY Q(ŒR q=D)g>!i bPKoq^BVB zo^^&\:yQ+R'IbtZ%Np> 9w@iXar3 "qGg h*~'䓣,෴@$qwN 눍8-88c bGVN4V3rxiC80-HchI3p yL9 @@z2Bq 5[̺ 򣿢?%NjJ>[ n9 fRwK =jg) ߓv(,lXq7oߞ.zzBs&$M* k hʀR‡_Q q;Mŀ̐5 4u a!TaZ,8aƍȾ0.c60#\ Y|KR }HTd הaZA^psK>BDα+d 1*-l5a1GA" ; \Q!Yz's:H7+{9*qoÎG \6bnR] nHr`zXB} L!!!DxR80sӆka_p=K'||&>W9 m8j ,[&0A <0ژ>aX_G3̺GaG4zd\TZ ,gOK،wlbIE]*r͒^ ה"%(`& ±)YwGku1,Ar7)]I[-b>w8ZX,- ?$64 Ye j>53F̤5t6N<|yX% Ǣ0PJau' `YL3כ5k8=2)C$Ydx*NAp}q>^Q- $pȦK8Ò</ Gc maQv1k c+*24SO0hzZΎ{Sq&X:Y0$rþG>+:v~X$dS eҢk{),3qylφ/`$Ii;;qZ8flF {fJyKD, ,{GdSC*\ǏF.0-"N2NpťŹr|^ZSIOf5 ;%wp^N|ؾkyEV9%@&,1nb3YK12IUz<=j=q!EЩKȏIU؜jbHsLǭ('%P%:/Z6]N/E AV͉EŀXIF :DSO@v['>jЉfoA ]n= 'qUqJ/}9?@hLy7~C\⽓e?#fJZj2TH2\L8当ZN)ĔmrE%߷e$Qd"!W A`:cDA7QsA:k&G7LX.B+!#gj9 9 ~DRHUf-(0˵l^ Ԃ c!b<BLtyN@Ґ`BF x_LapYj\MB G^ K.*u+a mOppQį}R 8ISx"mh `RI xK\=xGVvSA0r"/%z|x #s{=@+巼@*y"\^b#)6u:c`4Q*(Iӄ o4,nSF 6dnB tm|hE#.`\QR}#V烰 Doi5UR5|j h#*G}II\;Tiwk"۶;!@EE@V:Ipoϋgs% hۚYZ5/cjuFF?B\6`j!#^G .a#N:Lzߚ=$%®L:>vt+sњC UHH")™_;%0P~{ytUSe)" Fp]}}2 H@hF@"XT@Vb \ÈhCme7QF@5oD30\7!h61O>h-Ep{TE?84*0U$n@DЮ ֥% 2hIc`a@]-,Il2#G \{t;3Q'Q% P )" JHƋ9T`@h (Ju+#=uzID%f>#ń 1d# & +_a@2JynA2/* PY U.JC  x-h2#|! a@q((oN 'FutG1Red&-\F ϐ]&IemXҒ"*4nXqՉ$jUK#'4%a^â7$ <:HC XB *aA,y8m658ng0-jf` Dq,V@B/IJ\:T PVTqV%FVai,X0G) 2uBv[c "4M 텛0v ]V|!\0xSbZQdb\oGU M J08$Y#<]WlG!s)9< (Ҹwa萫E)M@X4. bp`%Ě 2$yx䑚Vs>1Jʼn @]րB@7DBݛdлOGl`"Ƹ"Y)OgdO$`h>xx_j-)p+=;biqP)UކDdӳ@P"q{croy$lD 5o }hd((sD; XS(3R\ErɄFT#ZLb\0›|51"{P",kyqyt- f$!vP.K*=-|F4(}%% әFA. W7_!hHSH +\17OWF(-Ą  QTpO:rNY1ç1($U $8q8'ӕt |(d;kbÌD1`)3a b?CO[́n &B^.HROIȮPpn6f iDyw5u#O9G;s`Q {j##=QHw.XLKȫ++DžDf.H1z ]K1f71"HG"t`v2Cq. ZyʦG{ ^IO#G^p/c p ;ȤG \?G:bOC#? qf@PZ Ws7}%pRx55+74:O<"*')%p*X (#Dʱ#{BZCJ,A8)ƗY<[@mP )ZySdO.8ʈ=]Z['v.2Z62*r6 {;c- -0@P p%i5[T9KA|lPIp# plGaKB^v,E~BU%v W$ #M"XR>p3-^"]jKJQ]X 2c (}8)Ө"Ra!nU)d3 =JpWI4BO|ia L\RbB-c`4}ZYGDN*%WKFD2Xǂokt3) N+V6uCp((qPPmtUn9N)@b(!൚BS CHK+ ̕O-tLЫFZHxjLhE˄࢐j—}R3w3$ +z ́oH4@2 3I!šD3@wEo,:QQ4 {@Av#;N@oN8Q.GTg0@|Ϊ-s³4[N/PrE) |5@ @ y#'_HE!<$ >)$b}M@іR(s~ xAduK@\SD!gI3}UeŊ6aFݸY@j@hq h} frg%)!%Ӗ8$dqV_A ~Xt#R2PeB`L$Rp %ѷRe`~>ChLK]0jASIn 1Ӗ)EODJ@mC^xWF\g _W"zIߑxAP?T,@I`}t#*](\/4CiMg pjJ# CGbc2d^Nhm8KUq&wIL82iu}9s'(3o,!=IH'G&*u$K r"ؐi3kfb! ӬjH>g 6<|=! X` _JW{lHc)3WBd3K0J'>i%"G&6^*eL[}_rcя7I'HEn B𛘐&_Gu8]j@R[+Xil1tL"Cp'H@.OVJ4 pK%ye-`*t B=!BkI$2Xaf(vn&9=%Hk@4.i,Cu@@d@VLHNPh!#J⡌f P/NZɄ09 Ø3`epŅ$>أ4AC #UC&!dF~aW 0siw7 x]@ O4?V~Ţs| K `I'䱧4ܧPaZhQ(][, aG$Va AP@7Q`5k` ؐ.̴' "c[RJd{+WDiB8zAp%"Ȅ."$i9%7s&hAFknуL9pu Wc.Ẁ)γǸPҤ2ba  5Bj@>'w(84f# ?0 p|:/rwAEȄ*5"@г/2Y(xa-bÖ%&d@_xBןX(=OXbhO&< fW?\Gt/ \  Qs$rPh-0J~I~+0 ,xijQ0 $Z eIW¯'(c瑔/ 3 -N($'k -N]``݊ MwQAjG !@Os0Oˉ6s+ibZ$KX3:Ձ@-t$^f˹=%2Bqr.QWSYt[KH}˱8iQb$k  b}:DS:qj!3b2MJkɁXrG F u[I g|+T]y^"(\alOc=W<,ғ<Y@ KWS :as'+O1Y BC (HZ(ۢ,ڕG/O2 ~aLt'XGAn(F|\W0Ǜ}B'.~HxY? PD ڒX ?a(5MQ&9:z$5Y+I JpH`Kԅ;W1ݏI e@8V6*#$"<ʹ5MPb B";eH'cv Zs8"QCi@ [$6:…B(aq>glS #O. ci{&L%d 4$Q@!ɱG!-n4z!U@bCw \?A{Lp+xJL+QBEBmhym _qI(3@dp4P #DEl I;ФT:paH9 !wH EPD|$/_ #ͱ)o!$MaN8j D0:"IV89xD2דd1IsI( +l!z#M4LL-2M"X#ȰILvike=ILJ:Z߈ -( endstream endobj 169 0 obj <>stream _pljWuz_2N+T A<Zd. F5 L00T:,Vv?4)X̼$%i$*,nY<5́͘ RpI䊤P . 2d5C  WW2VK.8P'Poq ijd8mV!haOdP+SA0 hvuĤD_SdFJږ%1I()n|@WijBOq@>XXwChOS+ lj!BVϬ&et{O9q~FW8Ih>+v@ӜYE#V@M0kuH:4sh^0/SfK `Pa wFIҹՔ" 24 4t04:'1;(""} 2?% 1;(\#Vȉ$Q,t.Xzr|Vw@EYG<]R R'x2ᝀ)5P,!{VY (B y>I%[]CXbas)`;ȡH;L/5w4}#!+r&Dӈ?IrW~zt>jR̊2E8 \sdMBt.1C[lU5L@ô)%:0>=+PDMfD4$SA#RVlP<'"1=(aGVyU!z% _LI,Q Q2ۜ8<oL)Qk(EF"b@p+uE))'@\H=2nTcuA#T*GēcH$/ƥBG |EO'ʔI]x V,UbP&`1XĈV 4UW`MqYa4-F*AI4NWO4HZO$-&dN-'QI$4P+ɤ[:F#D.HA@3. ]|Z鉴bc-~;=nk! dFifBV]110-$Hʋ}CBPe b i(TD"4[k)s >:}UcC"Z^^X< `?r%" ٳ5WJ%nA O` -GBm7&" F1%5\Ō!աv:JL.FBLMpf@ıd$w@BCIVG{vQ٬ jc%=nTG `4m($r&@n" 0B( (FuHhžlԀX<̢al;[a] (յ3'(#q1<.ÚC%to$Oٶ>0)+w$z/)y pNKIh0MY0>V{,Y5 9Ʀ b52 4R?BW1τ)Y@)Kʝr3,(->;$pG"!Q"y(!%ڙ@6̔*KO$lHB Ocq@0Ι)@f轸"aLֶ &sauebhE\i< kM qyIBde_Qd9Gr>ġmz@ ό@@H!@^? *Y$u$ĪXlK_"M3 9d`O ːz(A>2qEV- P+t8mADT"Ih:]j |m핺Փz鸽WZ?(FauaYADovjE6x_֪^J6Z}Vm>4?m\h}՗Zficj&JV'NoI:Fj~Qj'zUta2S% DcgxЈ하QY-`ٴ"P@VrB">Q|A9}"}giwsԓnxw=RM@so.( %u ւtEraMhGOwp[%ŀǻ&]] *xX;A4S@$Q!;r*s/n?aYQ:y _2I,7ldl]txB'aeӀ|0[F~=^{^T͆oNB{XڴދAPzf/YQg<-`oe?~/<>w}U6\JhEK>4R5<~ X_aZw$JL]80!?8|H<ȥPEH`'lMJCE]+-)iaZ4$`bdPF!^4 &019"M]!@;߁f"5[*UJ~AB4E)G<LQ,FLJ#)$/jb$fM+0QPʘ8م#Cea):T@Iu&+nڪw΋`dg)Ā>ѩ]W4V]v^SZ֎J Vޡ)'Jxˤ(.D,"$V#V9*QWD j!B4z"Q +"(!Gތ1\hE72>- Y.sgQWx޼ ( |;spD 7n"7:Jx8MqBBcM ) u$ [$yBsMh*b=2S7D&h@_A-C՘$<깒[^rDOȩ[hAcpV@=Ⱥ >$K ҭ)DS8&,$MF6S SJ7A"L7 bbb9kv$ >yYe=2Iz`Ʌb5qr:hYY󡐑Ƞm9GM!e.XUXJ(\zI2hXdlu{C)@~Љ|H`1x`tuU5R-O=$M6si6 yYg0iS>B)Q@aiҠ_,X^UnxrD AHfH18 ^8R~C}8BdK WV@N |&Crb@Sg~ 7'ۻ@c W>9B/q鋎|D+G2H3ʌ,{ב~N%Goq/330ɗ r,[ { 8@]&\ dHHg&8,);N[2jxz%ٷ3-[ r BCgtBmz1:8An{!H#=]f+ ^Ph$9(}5T )#DOC%s SD jH%N-x:(̆UH|#\.\Ascxw@4LMSa|bHXrd[LAJ& avMNyX ;zS?$́!8I="gz*m jc ʉM-p{QWx#<0'BjKQz%Y"OYp>('B$t(Y:XvZ2i61.qdc fDHe\;A߲݋Q)U,aYy O HF‡To@G&Dϔ2[ZNxYb V\eh ^2M86y2#hdJ " '~/eDX1͉.Hw],rDџHh,6QL[sJ6s` 7ҡHJDsEd^B"[D>Q.H5]FI׳H"wfGU)[2 I镕0Zx0Y'4y-b}O=+K[zv}D\X*q%<DR4DpJ6N!B`&:`:'0iYz1 h]~#Hڗyha ЕD²T,`JXf$(Om4ca(A9eQE) ?0mh[A y!6B\ꠥ" M'A8& l9`m2X%_QeD/e0UY&^P\&K0@2aMW$ -vHH>|\ھ&..?GBI!0դ3JVtX &UFr5u۞j}ASx&L^oN`)"۵z/ 2* p`r-V3]ͰA G9P R !g $MpXA!s f$Mŵ@V~’!@|XI8؏\N1`IGSCו ėS $\c0Sַ=bV L?TU D v`[DJu†5D2AP= f 2R%͘0_☢p*(t &QШT X[ K2b<c {Drtqs#I()@B_wfH`B]"Ѣf%ދ :<<*6jph~'"yb[ -.SF045<0~+D\1Ժy,Fyp]@#@SA!SxAlDG$31FR^,2wxe(KpKs Ly37L"J*Ycc(5 FpGzn\x ڲS|Mwe3ЀOW;ނg*lאMZ#@\$&wOgsWxyBAc4y1sPca&BjrMs4!Q9m{?͂nb1|2(Ȃy0ŻW'2W9;?H}V\Nw}`DUyrڻ cYO վ`._aSk#nVO^v,7۝jqRKnh.{Qed ,[89?yp~z/rje6_`ړu߯qylCvPN_ˆ_-C+17_[c~[dGR.%6]Yi7gwq7Z2;^ڎ@ꜞz銦Yn4{4Z%"ŒdZ܇p>6|̗xF$TyBۨn6z=meZ;=`{Ơ\Oif's櫎ZloQ-km+izn~Rou_ QmU;\3M|F+)L.KXCg|w5)HTb8X߷] =}#]ǂO)ҫ[Q6?(cĞV/W ]"ŴVޫvFY,?Y}ĕSAiR8+b/9'wK%uw0s'N_GW<)_w뽒:44;SG}ˏG?|l42i懝Ǹuǚ1~tgEg[hh X),ئ_MLθIKZe]'Ii㰺P실}c"<R.mjv*ճW9kn<@lG~07Sӭ56e=jTk}`۩z_r.qq"f 8a}$_oi-UڝVsW;K^hȧya~Λsܜ9#$eLLc!{z_zNnovg{ OpP;W[GW$GOAbU'8r *?$'ڎqsK {ycI# 6!~BwSh &xq wy wr+Lпz/i9A~+h=1Ѣc&E$IXO,gIXİ3&01Sa;1|+hb9'QخN:w'{~:Ă{7lOh1q. 5>wO:Ώ})L:abn1|%Khb9 begbx:4_1 %ĴI?i''B11҄ g` bgboigr9LL;^n&&+Yv& fvbיu; >H:qKO52h݉R]ơĺ rXwxowXw~%? XwΟowh &杉yc7vw ]ya.#O;?3w\x={`bXy~+bb\+?~`-@`ҝN <Nl<w&CNOľwNmBN/fщqgbܙ 䎘wi7XAG^A 盚w0|SS1neg\w|ܨvwpEA8s}&7Hb#M8"8_4Wt9W٘kmZ|pѭU.ѫ6;Uh9>Q(.EO/IOxy;LκY7Yg{iL1;9&;S&ggݿ4䬛u?Y7a/M 09&xMqQen4OY'^8"ĵǞ3/;a \DKG_⋷nz4_J09'xOdϿsK&7κhr];ab{/QN1nNN6I^o;IH i9wJ92dׄyAn0 j/ݮzfuqB,)$&* ~V r'Quؿ݅0`&$+D: )M?0 z NE}v~3^M:&77ϥ@{۾Rˑ G.NBճ/;{e鍹<ɯ^ yRmH g; ߭51eȽIZSKea;7i€ZgL\hZ3xo̯$;KN(@vkche-_mfIkvߩxY{Ewj}]ѫsaqNV.VDW^l?/ ilm*5|wGj7iКX%y'+!}ڢпgk~Vul5mbE 1_9X1{Zjռ`˶lS)Nd}u5᩠ZpV^Q F Z7*tH=φhcQ 1p>yI׍J\%NRЏ["{,V4i g{6jS*Ee7()64_ȃZ8gTdoxAa^?[bjwiϗ*Zࢲ_ >i# :cuCiGcBhM~~V0ږoD#mmmO!od =fnoҠ0FYS`v/OCOnW/%.~YU>xigzߓϗ-\c~S}XZqqio3[[iXyNz7kk=,N俿^t2Dz ޶~!DN˴_Ddfd+`FԌk9PJYkgui;X93f׃|F:^aN l~ DѠ#7n+Dlrdw.P/PA<|jm!3 > T;XB|[ΰN;x9w/VLF8Ǎǻ&^#MޡU1QX?V{@pjߖm{}7^iszl+[kWݑ^Rx'mJ_bvzljjwYPYKFyt͖Pa{Sl[[ͽNЦ瓺VwSӝJyQ O+3*5j Qjb5LiEw=`3N'٨^7ctX*s7n>gpC>g힕2VWUjCߍ@r}ک`>$ {}8^\S{XkP[E}qz\ZwSuqRC̯S2V:=!DުwJk:8Y 2Iu/U3@q{4պ'`jnI4NSZ?i]Zv{N .v~%)Ta6O9cGVPR B^V)JtYao0J>ԫoC㨝Kh_$;H::7 /8%Sk{:vtj׎ڧA}zr~݆]y^ݾNE:9dxQfs9*t m wKsa!W[ޔW۵ NIgƞ]j⸰)pj;r>oj;{Fh6zsa:8Kk//zigd?[,vZSv&A|:?@Ojs~r3ߋ&.qvJiHѪzv3Svf3z1{^%{At۟'"sz}ZŢ(?՛Z5\;,Oj[mՆ䫁gjƉ]l׮>ءYv ݐ}uteԴ=}Ֆw{k~Y^^}7Nw]ܶcs!򷇍g7=J'~o❳m/Z?ė6.x|E]J\ޝG. ⧪tQ;i=|p G]f߼ nTNG5hH3 k4kY7R;Rϊ 2rMu39v!^TfsڽM5ggd;Ҿy>֝jN풪:AQf ϫ45 kÇ@a^Eud5ug,VԐ9DsZokOjjt;!f 쨜j^m2h.9j;ENQwc.{W.|}-u[_lhϜT&{g>_\<}qk;/(ǙϾhڵNyW\==o#8`Vky2}RP,fwziw.ڡG=j$^i՚hZ٨}Yc7kf cT 2Mp۴zڇ Dj*ɻjs=x\wP|9@~m\g.?;{YZ/>/3Qg'ΝVX&cls<=g/R/}\bxźa2E lj.3f_GZQ.}whJW6Om_\ԣLl^v@7 HzcLFګv.y+Q[.vL~zZvdΙ ZQ0TxCXک^b=|炝4[2;|{حԻ3Ua"٥ZeZq-7WFVE?KrJvCm}wQ;ˋ۳y?jyxz۪6gTjzIm%{sXoOXjVxr|/U(6) 5Uz+stbr$y5>td?=ڲ}}E^j\-5;{Z;DVZ4寑Zն?N[G! u'o2ֶCcg:o28 N4l/r,6{ -J9؅X]brrմZ-iȗԀQIwKOE~%Zae3ogoT|U^<9>|pкl۷*Tӥ7O?X֋i-Z^r_7_wŃίs3=ҝ>wgfVuZz O_=ԭomF7D=-~|V?^ίl= ./]ؼ~fqy;mF~?7+S[gXGt-޿q|޾ kO{.-4/*ooDX3=||뭼6[pm_+{+_7[҈wսS{Zvߙy8ûyo?UnYO>zvpVWƙFƯ>h|xмVsՍwfz3V3({ڊwd->nّnգQI6/foڱa{\eKlf+E{~^>jݰ]z޽i=|na- ˺@VÛgB*3/+>{PۥRrOyι}wÙ/?p6ZZ ϲ&ϛ`nϮVgYpmaqmO~[6󿖪[.o^?|h~֛_b~C{-i6JxV뇣mqs4Ծzޣ3oefsokly9|?x1g)ɇzqӡūgf͞^^lY!/zf6{o>l>wk(ęTc~pNד'w*oKûNS_Z"# gkaelS+2__}Xx\;rex'yx g/{-<ݼ>;ێ'G7[oWv$7n{6B;W?yl,f,L_o};tc`p~E`nks~g?G6"'K/wvO_7/p4rL֪=+goͯ7O뽮>G-?Z?;5|{e?S8 ;sWտ6Mr}WΝk=[u݁Knwt6k[zlZF:w U6?Z^~_ٛ-bB3=ߨQqןڝ֞X7{7C<ٕnfLCRe'3Xu_㯳|؛|;cOL L<_ΎpGdU٧䍰G޹"e?u\c T^9sfAFWu`d8;lXqs*Wӹ7`aM|2c~Ėɚ#T7Kw.4EQXf/44d=z9A{}\csM knMa2ߚ_?xn'1G3On<ٱ wzwfk=M ) QzXDf<_oYg[oѯϨ%w̉Yةϯ6oVNksy.qwrL_* klT:B:kAfjo?/>s 惧W柿} ZѓzGep^LezETۢݍV^"¸ҙLQ[*:'WˋTVk<3h]9Th_onf8xxPX{^ݪۮL7>>UȮ\[ƙVU: uZrq,|=mBՃ~ݠbwoϝGhjK>_ ; 0Җzqͽ'^^t-V:+!CÛGghחZ78]:{V-^>~_nl-/KM'::]sVqsS9ڃ{Ʃ G/Ym?߼`p^ {MwK o]Jሯ_UzY'S=ު~a3yU1Ɠvnf;ܛk 灓0{ԭc8vO9k{|wlvvN̛[S4?]8m,1<-ggr{uc6.mm!Ɏ\nBK5g{~ր{W xauCy>;]Z+=x(6_[&%X>X~73aßҮogGđ«S7;ݥ{w>,s՘{0rUr~[)_^ϵs۪󽚝f'V)cW(so%~\ݴ7_vE_s/֚w(f8kKԝ~5:\ίOY{sSz~f[x3s8=U_vV:wOӭZfro;3+nE_ξ]4Gڞ9IpVgb{{ W$ZãŪYhcǽWcMNZZKnn7͹n1;`F5|{RyYzazDWjk [74_zǫ?{* DNJυc*Х[73i[>:hrnxG}Vق˛}o=?sҪ'K ڸ@j;x׭[ぉ\֛m _}roq8mmWGi,Gràe3߇^?.'Ϛw7?;盭_hCX4Xp>xYc"ù7Kf_Y}{^IRm:kw_:|vksNoN|kwoPi^;Fч5wc0|{x>99{mUm] ;\TT0䜃 Ϗ<>s̹KcH[VZ@hR ~gb(DG髿pO!O٨ V4Y%&K2YTpƻ¢*dMH-"Ѯ8\7AN'_4_y{W\L U?"LU`V'? ݛI$F)׸ӵjn1eOrEuo~*mz]QY-7.1lyXX7R{ө:)Hn|GaGhRǭٻI_>(i0#$A%JdzϢ)bY:,5^˟߼AMy6f10*1q#?h,kx?M]͘m)oj) )7IKiD5y8j [1>uv gsP-O U:볡6uy .w1M:[f<ƒȥm_JWchg'wf7a/z?7k,SwޤKc) [x?2J'ӪQR7d]+)vg?wObN4.-7rw_M1'YVd#ąEu d-ej͇hac_.g5߄5绕(+} 0~eK-ȶbc.3EM)r /N=|)(rȗ0l~qsޏRfIxK?FTl3ar_q=Z:MjQzC]b_N`ڽ̦hS&:6w^l3s~9zm$BܼOɀ0Ywvϓ5qxbffYO%R?6r`ΛL:TQlkiMhH1VH\qvzA#JrjLrr>t_oKx/wO~6{i*wEmircyU }Q'; ~L_m8 gHs˯9QZK;|l?bmϱU3t߮_i_!IP>x^!?ٔ:QӲNO-3/DŽҙT$_QNQ[f򣙟p{/)Jx;Œe4hVNznS-[ǣ^&"GLC#J&Fto P0v+y;?ӆE\=YNI^:{哎y%4۳h鏤J0 %PQo~FϥS`-3$pӂ fdƫmצK.7o^^|I2gjSX魄[ OHd< h"6|Qk$U2t7ֱp|j!wU"jb4*lib3!Z¸l;/2,._M|"@ i-g"a}BAi]}cy2Vc6|ݩ,[ߛŦn_,W)cFeliNwK٣&OFnwY8Q'p2N˹`|.$2Y7˅a+n42rX-x>I.pٙog(્ ߗ;22P9򧠞bgY6sxx()ǃpXj:.Bҗxn4 *jh6Z&D/3;0qkŗQUS}ؑ07y&f1 0lH=FU@oEMVηk`,H(+ 9{dhrWf魱'Q&v8s֪lgpQeGYk-Fl݃ΥPdwnۛa@0=0Yb$4Yp Hc ;bI;EsRg 2[dW)~6yz}%TOy;zi:CuwƱޚLRj*cҶ/2tupfuc}yQ5_~o=EiǪ΄I"qlF{blƼxnn0Z7monІc9fN;+Vj^ՀPpk $lDy+(woP c>$=[:]{ 9GGVHn}]K^8z{EJ$)^|<-|2kǬzHhRRJ0`Mi`?I^r[j-0Hb&N{Î#!n:uGHDֽ"> ?j`zlhuYAe Scyۍ1OFtiƍb׬L(2`J(s͞@o.ix'nn; +jYrhSrnЭQ%PWjr Ε,!=Sk$vus R6{@.yT%vGQvܒ"6xWý6SGG#҆S[{Y =iقX4uPjߥ$t(𴽾C*aU\ap]nn"׺ '4ű렽ga+^\}2rLI΂/^ky :јnnZcܡj'1ƿTtg ;k~|W3S{VT rXj!Yx/׷#}ivq=jE4d}rg<Zy*qrcq WRc%ݎJ4^wfVv/; I3"G!Ox~U9w*ͥnhߥF{XDVUr׹ԅ_&rė)Hsqde+1%4șHsn5y*.Kr& /yX8ι [lWz2iAy=fvDSεo|}Ce5\rp8DL+u0cpuԛ@NW<F,zrb̴\v7b\3y.7lmƼr2YK9.Ey[/t!6ԾS9tfï4-uY :7X jd\C~|'KJųDX]/rv^f93hc]GÈc@)^GENVm$@ L5!.;|:U&~}+lTu%ar?CgR8["!<1;~Pk uGAk뽯fܰ>x,ټjYQKI`g-WkoR|. =Y O3ʇQx}Ĥ{-NiC0 k2ri 0'kP*LG"l# Y]g-FsB^Y]ų0?mձ]cH9a(Q@vgBpv=$ %tҘ|GS6QtU{:-$ AN8X+I;WQvDQi _澷NA@`(iX#|P8%jЧ,e*)hA޽Tq */ޒ! ļ&kSᵾH¶eOJ6غ o EQQs+icM#1o^}B./l,lѡ !:+4=rS*(4´gknfpm"~L?g)L,buW%`eu?K X{ K{*FhR"Pj^zĴGEp3#cZW̘"/_q|XcokbC% +A J &>=YhmEIMT;}x"nGi qY?iO }fKzu#j:j4{!8wDl1`{ԐoƅCYUF.~kvYhP;Uutu|ԝ{J dw! [w&'SXp.y Ԗ&3nۗ7+˃"t^bbw(<|;<7,ˡ)7[nĬ&ӏ֗_2:] F>r(=zؠpKWu?z0jRWRD/y|)U,JHDVOAդRty>=.pFA0-0;Oy2H9׀#42h`jACy.ɾߓ[ܒ̈́%}𭀗 ph{CMAT稚Dv%(vN_RV^-̞M],S`!Ԓ6q/ "AKY , ДmhŒ_39sܗ~;MoOƵGXhTrZEШ ߍ9{WU9-<W֗Ό6iJF,>kRbxyi:7 2ä"K{\K=9'GVT[LUh[w/ߕ/3jH;J!3ghYftc]Q8(zkm\US_YY֏`r%وUheEXD<Լh跭VPA6./eoڒO]bŌVrZ'$LגZ?|֥0hUh#܊?<|ӻ+(^&Jc0h krPHw[G:W_ʆʺB3{0,=z72d wY6M +`Xc'aٯh`Ws[?܍8'œ[dxV:شK!npϒOiʦ$߸xM/WwR{3ڣw١?qya ` w]R`TKAu>"19 zY`r3 Cru4F;t+CmV1nWS:u!M)uECm4%taŕ:6Mm+AH2CvYR=欴[tԡHdg3Pm 3ˆ OHTy9]yKD9kQ#PTD+,hC:oeh;+L{~mQ^:f bfsi2ye4zzXHߜ}ΕC|Y:0P-$1?ۆb6~qVɍVVVu2\]0Gg @yf62 ĉXw dn[H7 +'r8yO=mdj5'v};]  2 \ꂑP8Xl"Ղo_sqkxP\Aa|ɖC*L ԃduPxTzص'בFRAb۬oHE誤mʁR\t{cf}!Ut^SB\~A·RWd 6m{JR ~TD۵ψYSڽh?_d+ &%y0d9 괗|̳zP#lhqX~J6^Bs |/Qe$5`쬑<٥OXFqݬQi.Mf.7%j_ܫ@ D*?18PpA>d!o$%YG@@ob{x7:B%vtIY$]wxF5o#BN}nR}m<\z3SG>խD $=Q*\S{]Oѡ b,G$o_Le6 Uˏv!wKbk%*<? 3Mlor\/ME/[cu[Y='w\3ILy"R8m`]}IEA~*M -IDkԬjNt:dIʄ;`g׊6{V]gc,*m|c aUsdHu{fqa[oLnQܳrI<+RPrg/)5u&ݪ~Dc}]R9}LDIY:! '6Ugդ~-H!4֊Xk^X]Q&Tڅף8hrd6ভJ_04bͷdaYn cjY)PF1LEy/^Ab2vktK[3^ęu3;yJFٙl@̙5X>f;/˦pUeell!76} V%ZqkSӦjp[%ng|Ma;OI.}}mi>l,qLҷwp1gmdn?M9"]Un{zT2Q1ZAH2:$M!#vx_*2'e3/2&`°|xZ+ m:vl?)#zN̶ fl Τ0>&}#Hp+ٻmM^yXBY^-mqY2n|`^Tֹ<I=}|Y˭{)PF@ZZUEm#;)ULT<)y!"j-| Ceq ĚMׅkKΆTTd=ekry".*,ia&DkW(LR&.>:r-٢1-Ѱت@y[5Fkg(Glc*~Ђg'O{s,g38>,Gp -PZ+>5er{2< "MhY^܄tHrS-KtӇc[ zfҐs~-6Zң[S|P/@|R'I~E_F~m<(nܷ"T_m 巆Snt>εnhKGPNWAaԶ9]͚zQUJAןog:j>pfN_rA. &s9[,+™MibKWu]P8=zSgW|WN%吃n[4}j¢ߥ+f{f1qNJ&Aмdut!g իyp;҇|LƟ1s`HS?3T[ovE-τ&K>_>^jNFPizWX/7a#B' R9k>C6pj:6U0$|k ha8V8/;JYV[ˠSIi`="i YZ|rgXM-Q"+X^hT>7ED/TQ.3{<|R>KIdI .RxA35CW멋w\ꥍ h`x-7xqŮVS{xJX2J11'LժO429ƶiKͽj@ml zE =˵ecQF8s*^ļrpؚ,p~N~cE:&_ٳ}ӍlK*KPJy9db/uT22sʤ3| wL}uZ9W׻"+uH"0r'fH%t̆rtK#x\N!u,6Hntob"Yo>{o/wk*x_\I~#AםF>!?muB ;1zM3׽(&|[aҷ d9_/jH\"V Tz)0 h?`W[>[쪖rjqJ.rNnpnm}vp#lk/HxdG7.g:!_^ԣgP4Xn@#y;U'Mq=es9|4F`U0ˎZ1 jrM\g{yNsE*LlwPzנnĺWXjv1RW&&UcsUuy3=of>g_`Jq Zb?NFLkeV uKqU5Lg*08sWsXzGΎ3+(oH'*!JƱ=CjOofC-DD`[cA36%[Q*ɖӫ$ȍԔ j /| s5O P#>$Q9޲VxKr*Ec f v&mF`(?Dǜ*Oٍx5SߴQa?$a= J[R}~Y11I"fSg]wViP z]ߡ[o:٢n5he;_bI8`FnU΍Ten[q]܅B/gΔm}6IάEmIG 4Zx*4CeElfj>)"zliEw/bEI:a3V$a* \tw'1(`O}?jK6Qm2|%|)k#-\ӞcSЄK[`}/\/W!a^hzoYz#}w΅vY ; ԢJ~jүoqa9 2䒝Wo\l~l{S[|,P۸̹w{:6sl{ɣ"4'+] =R3#+SuODx5nB}bbzyzmv#&X:^&-KZ7n趃-UB:1ٳ}RauI N|B9_a,C (s$**T"KJXܧE A*ziʨ۩ +XٯV$<2~ssxݘ^Wpo,tͱ͝g5꽝5ґ&aS_иs+!Ўȷ ЙJϙdnyk3$Ьf\Q-%ɤSu4Q3aRw1<<\f0aIi9`@mmkĩO@#9yR=dM}G93\`w\9)W^cۚ`oܨ>L/mwVӛQfڂ+Sys)'∬F%,B@Ί^ϬqYҞo~ 9 {]~~|^DMK .yc%H쭛=s\PCL=+J,T5Jɜu8ًszlϥ6Y::X |) ;jxj/ߠe >u seGwS3/ :1xsheUvugښdxw^*HQsRggqK%:$ڣ6y  K]>4q٧aי-egJ÷\ǧOa;4voڄnGk\P'GΑ3wo\etsZu!`8Z܁(7vLH,;y{(- w8*j?`/4O7 u9ϭm~3/$cǁjGXZ5•"e+qϞ']iDkN۾ rh]4 =YQlĒښ{P`oCa*mDr>\S$q+b/m+kjrͿ.6=}}}voD(*j;ȪY)^Wߔ(w,#˟. -9q'`&# uK:jeJ19RCX,ZͶ ƊO16c ΂*3#߃8AvyylWd Z<32ht ֛GaBhOn!9ɣt(mNzmQK56tTKEF~L+Jm 1`-[™i"HJ fs73/ h³ACNwuQ eZ{G)H"fU {.Ȕ˺4 ;V!q(K5~|:@Z˕zflXwXӯ~`_ЙCȖ$Ѭnga!ru+tEZ,"?Oי|{Eqk'oN(fZ=m-f;fx{d]PЯ7"\ }Q}FTr{U+&EG}o[#לo"m'FNn0.w?Lx'3XTjwض겋EHKӖg7H093m]StnCr!:$bwE*:OOx%ziޖ \>xzi˼a 5_F++Ck\[Zf,8 ~Z : ~ZMX-ԇ_õWotYPdžNB[kl:S]+̧M;xcR*k]:wR 袯>ؑFϿ ߻DM XQҙy"l.:pN|^[ۤyod[o1K̛R :G.f)LuͺYf{5tq  ;Gɶ 5/'{ |sI?׿_20h!v+=-܋Mkc]\eB8V-g-w4m\B <~yz/܂rM!0n!j_}[۲ HαkvD{Ь fV4ҽEMU1a@KctLNfjMn4vI}. [n 'Ç.X\^)@c޲h$}kɬ:ϟJh_TA YA%Z#(^1NWh.L7XP&(۱Utl/)\\gʎ#5Cg HZ.=kvxZ]$.}x}]ֱ85s<>ZIR#WÄv9-x"Tu֮Ew3;#sk#~ 좐:*3uP)W6!Uj%;A%;%DԶY_g fo"HhrDތ_9ם煶rF KJCW?;p1B& YòWç5QXJ+ڻsitg:(jTEE`{@aNSgx{jIn0s!Ј42ܤи$Fl#$a(nqBB.WYFXf `'e"M(N_2[!h5ݨڮWTSHjV1s;(/K_Fwn* )/Va؆K2N=㔺X%f)^lpڽh3 }KKg԰(m {&?u"4C_RԓM ͣ;ûz{aRs=ՎyǽyoJ`C=yӴ((f۪b>$SIWx]9B-^C_nC)xp A3E*<|_ϰ 1 SERbq24cAiYcy/g&pq'8# uy_..9T(fH[p /@?UQfM?$.Y^iL~ݖwm+JS AD?0h1cg 36_W1A AU ¯/z9<@LuQTNEάW6H]%a ]\96ŃtrԤ5 K|+;~6:oo?-o&*bQbc[$agHZZȩwYxl ~J:A$E;KᒈgVt5tAma!o$;[Fk+HOLP^1\R_"fFlޗT1f\f,ItuӪ7!M_haVl{ligjTOjFy=G>6c"ʟ : GE z+F4Hٔ>2Fu,9.y 9(Ny C13NPTAKW__Hץ*hn15QxqEW|F3,-gh*F`M+F3UzfCV]t0sBz_olIbW99ŭ5CvF& -wQ;ذqQN(7Ao`\̖ kPIZrcS{pq5 +NK#.P-Yq}:*i:'Ti]ಎ.Nbs /HL$~;VP۱ě 3-W菖PW7D)/|,62Mt="m5Ơ>3zM&Bʏ&gףj(oڂף]Z_O~.uNXX\O7>t2U]LAjU~ ի8.ĸQ&ispMF1G]Bi%𿃕t:t9g*sSc\Ix2*J01NƳQLw׋d/I/(ZnT HJbHz>W =(TyHIwWUp;~PY"ެC[[Er0O! 6&{h>mxu\[ww Xb˦+0Z[ uM^ͰQz,=X:hb(jjQ|g<8YM1g&]MHj(L*Uk9:u! P?:n;i zځ- Wdžj}kWOĝr,U 8k0f܏nkQ:WZIHݨ^s@Iw!犲e(,6:+N,@xeԤǮÕ!~ G*ӺE>Xubog jAA#djZ7OζJtr;WUk=R}ik/It7jOXrIV5RN]t7q;xdJ24|a_3_V jKwyeoYlͣa8bIe*ύ2qƦ&>l_%Э1 3yWLwa)sC< (J0@!~\ } ڰQI4]k(̟_gJs=nwv-t}њ?loU3uYL _ Sy'ݿwۿ+'򴆗 M1λ;I&|OIgΐN mu;Gk,pE0\e|_ʭM˗0p3ǂ5nŚA:yv*#7hglo7WRwm4a5NZݼU+?䷖0 ,wT˩gZ`LBjk]I{*Ԛ6=M=MSJڥɝoe0UGWʼn><9mݡRC^9ˋv dm^, &o.1cy4 "c;ˑȢo<C-rJ_bvZ숇]?֤+ZB;5E"un =+oڇcf >\D(Xk>.8VP'5U :5h=s^VF)p6%\kh4n,[o67_ޱW#Wt&G햻ݳ{n+5nV7f=dX$vӝj]AN$h6 K:nEGXyD*6_/ S!~%ɩ1;!nEó9Ec"Gl/ b-yhxzd|QSv# Ed=o*R1CC+B~ZVȑ5? LFB&~p;CHqyGEW ?<`GpޔФݥ ԡft_׫|EqͰ/E/?0ƌ^A6[Z%zp2䍫fyvkLRoJt WN©nXOnG0!_/ŒK<KTS/ N6_rlYsCo•)4vnL1r+|g0 jt~]7ROwP?F>S]՞>OxZ򤂿$Ha~ &  Kuv~_W=n~0{4rB,.\*X!#2_)Q<~C Vꢄ>jDivZc ߂+KaTZTCX-A v?!?gJU@=ʭ!xjtb'NXAj_ SҒcC"=>IU\rSf&%E@?N&G5}|#!P\Nѥ'aqo}9*6ouTaB$?n"\+WI,FQg&OIa} o:DeO >3Щx~̜Nճ8A(–\T"pZm OkjOl.W/rweŎcљ;6 xq}KǐFYc# GĽw]퐨G[|A5XH4?~%tF<tkIX*8L0 Σ~ȱ l.n kž-ܮWo#j&W` K_t>ۂ_?HzJtZr,!K(5 $aa/KLi{L4MZHpџlf5H5@&u+1Oҋl`ƛ18+-dC=v0d}ýd|k.CsmWkS~ee~7 {hG^\Wr+WDfY|:ZC*Sm vVnЏDiwjnfYetH°i^nnRJ۞]zta1yL[b9[mdWo(en)W*?I[=]#}7]OǓncwѡ3Ufg>;sЙH'IWJz 9 f|(hnC*A.|sBoRAE)>=B U.5ٝUqq31s(dnotSĵ^ 8jfHW%K"9w&COf+-9ڸ\-NO)3/ȄQދl*m>hrs<>ψgpU[C 1[ʸT-.Y4kejC$iñ"Vcm՝kRe/->S*燁\%\,MIw{n0+|T>C|Iv^,ۼrXZMqXpNz)<-ąTX+ gqV&%dys<j2`-'muA,܎b0R{x# acr4Ɗ򌆥ru<}DTmz}XYW;+r㗔\@q O"+$&u@Tzc<@%ÇV Z4@I+P3 u¤U΁AI"-y1inty>uc4Gz) >^)vʍr)#D4pŞ2B6wqcofxG5Y* cnL*; )-fU.2vor*(r>6FšpQMeRt(R tXHc@{p>\)p:R:I_I695?Ix$tB Xxֲl\JA w%G+ю  =,2q@U~_]F#*J֡4GqB{W0vk֞:h#ߍeu-vYۉ8>q%R{=52q^m/>fW̶'UjiGRroE<)ª >quٲrP =xi=;%˥]"CVeȈoDu = hzӳ]I18A:7M7Q15ޕ{1Fݘ:y}vsn/Px]UmM?ϲsU:N=gI[rW#c_q^h`z]Ю:xՇJK"#_v,<~v>?6tC&VLgO ͈+?L;qbbk\ӗfq2Q3[vۺ*c-ȟ5sk*4'ojۏx xe|6EYwy:'E(Q'z4U:/SMK;&Vcw2a?\Q]޾$#AHN #.xF 8|z_K>7ū}.{(:kRɽQ ֪:M 8vpIZj+Z}-Xѓ0jY[D&Ћ80l{C1c&-DeSt=;sG|Ȥ λhq#bܦD0pѧ ζLLEt[{m5^L~N4S/Z:]v(.7gfK:5~4/v# NEs KnCx=?Wl;-NZ=G.mp"S( sh[9+$%q#ZG7f\Yم"TypnO_P-pwØ=rx,CTnekyWmHss']g`J+6 V QJpY킑^aM>92V #k &L<*;Z*'x9\~[/y%"Nh$<R^{ޫGXM2v9]w#ΗW]Cp0a`~%q8Q\O"bz'}f;ggf[=Ys$$3;ZgGcY)> 85135o-PjĚk4{CHC";JX4>C});R}V4/kTyk5z5L{f(:./QUE^gΪ.Me YwjL[8!Ҭk9QXKGdi2QDU=$H 2JHWP9n9ǷEe͛$iݼ[BĝK7qTN+e 㩰CB6ZmlQEYȐ؝D1uKucxcѓ|Zisu.t@$U)"^;:oH3?|95̩M003D< i0JTU3zCbɻ5NoLvOnfCmTy*S.Ps%P~?zz=&.(X9}Jl"/O^4h+!ՁLc2(1s3ʆd1{JCvɿyGؕkҧsQKďrREȲ)xLEݠ%<āVJE{tЗo/.MX.6ud9 ߧ]?DC&*;Af`Hۖ}rkH] c@5RÂVɈ6WD>Qp>/,'z1{y{WǎSv\G6擴uC0{p+x5kRC-.x_/n} i.4Wά]^؇`` R&lyQ%t@.=vQ8~,nQO  ֩,+>4Af Uʍ[l mv5S{9;P3  ƒ?H0ޑO7j\eV+GV٫$Ncl=>vV9L?T{5T{Zȴ\1}׭a)Ȍqw!*֟A~:$}!I1ݏn(=IZ}&@-H(]Gܥ ᛺!rUp}<4eeG-M qIߟ)Ee W|!K'Q^©ȝjo@)msRkQ>*a/ 5wY=?Dɂ}BS_O%ʶP0BBX3?:KS*1{v %k3D2Γ) og=Ju݄j= Bۺ|EkUnٍFnBi6auAsB\rkruV=wU@_ሽs"83*sɱPu&QzǶ)OmIܗ4aTƇ)1SMUL}oo&>1f$fKqe/1 6M3ѹswW4ܟz+c Oq&J|k;I^Aƪ+! ~nz~Q/2N9Y5+բU^1XUaJ:=^ U#&n |7h}:^D;³IuE2% I, ѵ :vH{XV$s1X\dRU8qǍܔ;Pg 2o!gjqO#V}i48u+&J3NHٚӱnf*wv&xe˵O]ʟ}CבII)uC3uv{.9SP/J kWLZ*oC(HP.WI4 4S3DJ'yQP2m\}lOc@5̐.n+E?{ s<\?ESFsu)k怒x߰ "fz>3~ʋGMwwJ )T]N} GizQW'@(ޛ'ɟv2[AwZ؝lXCuE(3^w54 b,<k< ["66׶qn鷆;cFaR[ZuDNgI#7wV:Yձ_h_+@@]Zطo`QC|ϞQR;?~t'p{7H9l^bfwR 'xە*Mm Esokƒp~nB>X+i" GZo,\܀@JZעjyk)&|ݧ@N+.'+ObM#̕TU=+&) 2 FC۳ BɃxͰIܪu칶1D\nZӖ12$uX]xuȺՕ\-6ll1s 3Ż9EJq^yƠ~>Wozh&XŪ5V3H\̑ @yX$sѽ헚5&@^o#tܓ^a.&o`[pgӂdBx28+>9dz&?Ġc趼2ָxL[0v҉?n}Yy":$w^9T24Y$a8;qxC.xvLٔJzw]c ԹjQzmt&(9DKKs$KϜngYw Ѥ!mpHaq-rK{~ 7sH|8tI2X^pFVN u7߈.!ڻh)م[mv'|&tK/z-[_$Uf_ =9 _xZ?c:'6Ĝ"zKs9y'9 t%*! 4h00Bܢkvť` MQ4k>J^K(x#vGs-JF(g;y!77~hii8XOcw[Gʙp(db /9+M_c.\ss`#XV^0>y*lgP4%EŠZiFp*Mz#AH?_Vfe>'^Bǖ} W c7^Hov~Uw`[L~RmKzOћ ɾp`Y4Xi#EJ&u?U =xmQ+*ب`mL8kQ'2:%ϭEuKXrjm^'rOurO1C~LZ6{Fvs;h qaqnIR4fHVq0%Bc_K Bǔxrqn$\caM0T\le0Q 3vw*o7Wj<Ƒ-XM>.d㶳؀S0pט|%iI CtP8Eo]wV|nR|LcpsKerr3NXNgksڴ$p5_P퍘3v.6²N3%! 렡Yl^g~/=IdLu@hOs~%Q^& 秢A_SL;ܲI5o5ڴ;]6=>[m[`,˼j5^?ԋ2i9&x>p s?Q-4sV)LLj9i:ȵlP_c[ ĚN{6̧q|-s%|QlZߛv|RWr{s8;;F+{ըwK\חNPW/0źd?X:kɨeNCR-p+ 7?>gAep~S)w4DMJy2>jZ}4&}%Utr"``dM*$yW?(m ^MvuÛ8=!5~pi%qteqd[ >7.hM/&GZW$'n"Z{/tQضQvjsm܊J>##tXkDX%= I֙Ez+k_Pme8ֆn8ӲޗcUъcWRIG4*ٵE=E#]mqb/Qc aUp[k|"Rk8uF#s`&kt6NW9~a%ݏ.$.+ ZU:p>oVF~b)3htHհNr.{ͧfu*,/Kt+:Nm>~;ȺIy9=ow 5,gG?aj[E G0)fY xі5fHLvw:-"qP!n,˹d- |w}9<$%eٚ^5>wu6ORiusP[80A`>хkIE7F@`MۓڨX0C(@lSιGeShk,fpV[GW-1VNNF/rmL-2NOnt|qE\כvH4Qaoh8.%%xCY+Nt>*qa:gS.AFJ?2: Zk],O@-( ]yGᦤJy1ܯ&*" M8 pN;ʊ,\TP/-T]5{7zXeY>n5sWohM{UWeCokN̊,k`;DutNTJ\Nk|.s iubV9h|L,z\8jY9[ZZ cMR`v,Mz"Y9s7 37ח*NNoD"cfd!^+م;c{%M*$֖7ah93oz'iw M | lu =+ clygT6|fѳ2fTn؈nP8+ЭUܻMmJ-N_Fw_/I\;˃T֎PЇv.7"K B:EOJbVd8(«A"Dѱ+ur0"48xq4+^dxڳ7A5rX]rۓ& :zBƔNV|úU7V;w[YC&v.~fd^~fcZ_9{Ɨ_]0p@{Ayn=dj_u7w}u 7e^qJ?gsJC#sM~KB˚~*_S:oN;L; N"aWgk{5Q 3fC[-y̤&o{XiQ\N:?=ncz#+'{]^/X| uE/UxUv̎ MſĠ?"?K$u^C,[xMp[JDTNG"؍=V?\ܦ5Z}qP&K1dzmt-泹_y2{4Ւ+_. Jߥ0=TSsPSi{Ag {\~xe_p>2S~+*Fډ |_AYNU|8suEKֺ0,XҮ2ߪ5Y?#Uޖ|e?*DDeך w -x7 ;K:d۶ŝ M6 NL.X/@nq$̍ŸS%h)hBFUiNͶ0^'ڣp@c9=.{,gys]+ΕáݒH&ߓEb_2AjI-4aAO[nu T{*:{יSҟZR`Ʌ\g%`Ԟw+ O@ ǡ?Kǽy*L.i-1 5|b0w`Njd=wuRg򜈞XSkH0guqvI=nnˢ2e]zKQySEYYV?<|Ȩ,VnЖ,ǎz?ho篘pJ<GKBr됻GXp2RQjx'8Bhù|+"EwH*D;(ƫ,o ~=u~XL"IvoC/uhrg`wHcGzoPk?LC '{ĞU `6)ujEpд'irSȱC0 OgtQl$.7Kx?ζNb~n$raPx0QYhg)Z< I}cθŗaɸ wA/BPAC}pMh?Ϣ|OCrTw&o4gԫz-t]}F47xv@-GݠZ?șWS\T??ݰ[8|b彶;^Ke٣;SC%{o]@Z~2&[SW>+`J²3uO-PJ~2ҾݥdSQ&FtIm *,&y dՆ?)ҕWv+5Qи*JBP<[k]OfmP ӂwy~?+t tĕӪ^] M{gQ ^"Zm}չ6:~IF5[jQnFA@Xf8{i[z}eO'~ 4>#UN6)^Q *8K ,gے8,|_5ĄQNJxޯwËFEڽcZZ2jshnY1xos ?\pSr%}DE?T΋^?NMh;:TFzw;?|m\)R/fHȤ]+wGi`(m[pM7h*PQXd.9ZCT@w;љA}f vQfrgz0cZwDl JǦu>,Q8ϳrQj"C'¼ABW s}%w^<{PٿVdҽ0y8a+?G~FH[ ESHQP(7joXE1o[ZTI-b={H T1"F}Fd9@Nj9i;0ytO0kWX"{X2ǷoTXhrs}Ve<&ê}*CM* _σoMx\jiIuÓ95xP Ud[MM:Re6ƨ,SJeS9|؟{ye!UGҽ/#B8쨍۔$SbsS>1ǡI[UJ_*x3dКXI]1& KzFs~Yx\ɿ؞Qn>磧D=B}Zx9~fkozL}aW]ӎ'زY "퐣jSCDi"V&A8a{1@88>xfT%v7OcvBGswUɤ֓,pg㷨zZ/w+:h=5d'*57WךD yOh=,!9&H u$Dyc~ u^wǶ&X&>LNn_iڒcȁF^(9 *aūDGd:EQP JClv2/ztV|`0ʼ^hzLFԿMت*Jzk*.*\cL6ռzɭrCuwgDSBQd߼96+[i"Ln꺶 3r幥@) o%N˚NC7!b[RMPnAfL2 Npa=,쵰{~<>KD)?IeDi?V&ae\zJW$ե,o{_{Ծ̩Z)yGȋ$Tu(s*tI9 oGGr[չ=l2wOKWw!0GۡJsfKɡy]\ʑn%ofysnWs3MHoo{Qښ-] ܒ|p. ̌DG.6]?uKy>)wa^Tkr-ir\2:)Yvn- arnX4>Z+ZR b|Cyvu-߭O1R^:k! niœIq9j$a]Hi&zp0>[yx"YӚoVov}>I P\|< 7d7[y$&qd r<7 ZBg|RFdZhHhg^Y]_YoFzBuOiz3 &|Uh_ \E4Z31׾y*Vuj2x,M&K~CytE;|kumFjo] WOǁoQS{-,`%z׃x󝣥~#ћ/o+ؙLLCa5Ӫ;<>)f:uqUgyZD:CK,w$F Ɩw'n%}N$,=a(FQfh\?m K>ܬΥ "fcAǎD#lW.B~!>=pȸv";` Jp֮T/uAgWòo"Ea85 KõY.s]N Wp_LV=<RPnP|`wR'1/}"6dw7]bS|RRߚ'eS:O0 ,#L:ĥ8{CS"6vU}_K{q.u< N"] 5fIBP9SE&x\;KRG)ϫ$ڳ*0ObZZ EEibS,}ju̷ j~qkֹ:x۩獭ڪi˻teԐ=Wvo)\qK-kQb2:7#m/@jmy۸,^?6hCY<鷋f5\H^ey_š5GN_—Kŧ d$\}3]+ؙ3ekϰqhP1KҤ&=Ej/_5;2lВ)aƋ/KXܸQҳRDT'QS]쌯^`I7.٭ "֐YJ3BL LoD!Rr"t0'>k]k1IOu\fhI/#v/0 ඗6ج"sLq-ӜĿX?lA.mƮ\F,u[!C3.UyEe뿯&5=8jg@#V+];D͟ƟXk'69 ]:^N5K5C}} ח;dVֿI~z(V4_/Qv;ь(mN?gs zg sPbWh`iUVu~|4zڋ,r)P6k8vK O#x;ho1RI`gw.'v9Fy``E:n=ɔ.8 ƫb;[^z3^nVZ`]l\aZy$ejXV4(̩qu&UJ9+{-ΆHAV~(V܈aa8J'~2[XM # Bd !~qHqLR-b&FfazC"d&cdѣ[L.e?-9b7MPYX-#ROz@:#S榈(Ҏs{RO)SMVA*qhws$x/M0.?@hwf հ\_7 Ksof62Qο,iNNZ.\X˵eH)0l>[:X>:ťߏMN^c ά<oiSn|9%9_YL;p1e1JJ3)LˆyEF\7rbYpU59gmHaiaD¥Q; fak-W>grĂv($V7>[Zk>ۧ"[oN|r G(Ld n]奘]K8Z}X[ao@-mz4*0]ZNS\Ik &NPiGEFvhp$E=DUɉl@_A.47N]'q;5p?L_6j͘+2P39.xV΄PT:H0'~ޚ7,BwtZ}ِ+aS3cwܾ})86oz=˅RK]{"TugVOWZ ֤yW3X;s 8M[I[ \.o:¬n匯7 -?w{ d6b?kz?r{Ы3QT9P[ o"fԔ'"ĠTlVX2&B=Qhõ(]/kȝ;# Z*r P)uXX!4e՗=R.}A~ -M?= x3LE}qb$SheJ 4. 9k+' !ϥ*y၉/,㛂 )<\I1լXv/S d)aZް3ӟhXhS"T+}CV,2);]i&+msgrVpMm]E/'.k92(#&y8tB=MtT,9/G,r ׌U;ۣR&_T>s3 P / ֆ&Gzp7hr4!}+dtU2> =[rL QֻusR+1uFKKJ%D Ej5zu$@H2@';ux\v}i ќC,*`SXYpx/#vf@~-w+-e:љ/<3ܣNL xyz:bZg4S%Q-5rR1gƽ|q*[63e m?(3:*%u 6u _~ tXq.'tըxvD?svX΂h);/ ~i1>♭{qu6['↢ Kp{EԵ]񳴔+#6Q~ޠ6i7tͲէy٭}{}ᆬd?, M:7]Q|r`5|#ǧKhp1, Z-Kh@XJPԽr]ku"#h Tu$)Syh/V966C[Ti;!W [Y1J9-% ϛCZo)P=bB@Xx?efoe@旪Tj:{kn>A~"f'">A\N2#c|7ps!xKέ~ Vb 4x)/6xGL%mZIy\}oVWL^Po :=I tҚ+IUmhm.|bz>AqIȚ4,lϜ#Y҅z_[m@LBܠ&UֿyV1$ v׺9y_ZԲFh@थSd q σRL7:e?j^W|/@ע~!bmsӤsɄEzlU=T[q[2/Yxڍ\SNNCamHӼK''RWrQLmd*:/)j\񊊓!*:lXݏuD]h4*/T ΗF ɹt5f9b00_1YSjeY \W5G NU}=L]E7j/KO|6^ RTd>VzJ*c5 3f5kJgYbY,^A= NJzJ/Zˬʷgnc.9ղsI)vIƁۼ:GYOr1 ze)6c9|}R-.J3f7+ I/KzTve[z0f9UTp&E |-Cp?Pqd5boKq # aqk q͉?|ywuW0/̍ey,x!P [[`PMh=Z!'aG%t}2M׊t0Hq: )h {cL.=r^i{)+"/VO5Q{e>;lM̯%]WpX~VBPu_)^\'Ny㵚7A:F,|f 4rM&uͅ+l9!,762Yb[LUYI U:3Y'.KE\v*e:mIq\1>d)2IkmzS' K k:R J/-pO:n^h o*u9(.{ l W712iƧa}Xr/WohmZ)}KQ-S^')e77ZYʺ))9s2`m$_i?(@|vOB"^|-\HS-Ws8Ky&A?2 oߏdkLf9IOIocXip`[!"oK&ȧH WҚ%PP#C/*n>Q7cu㆜ŵ d>Gּ<`0rcO-qTMbpڊivjy[ sP4*yhLB1L6,u#|kѠRr QaT?uA3e*Ls \0vU'BZ" `n{}4V}C&Np%| {}`u&ir_p߻ul)o!@0K$lOћ93Sjye;`@36AٚkjLf)Lc%H{MB)JF?X]-J ̎ /*Z~ krXmhvG`gm9k4 V: 4PʣfwmIR;\V77lɿgƢ~W1K-p_@m՛)YYdv)4T0a^8 }"l "Uմ- =؛oi=xXz xY.*fPg4"eE&+NpDKvർ*1keZ1Dkݿ[6R33vϛjUKwފƟ}KGN6Gg[ڻ|OQ=|/LU:W'i^HZY7]KgA@ӟstj b3@ZfQoV~]z%0|o:{alyknߔh@w0/ vEF`Y RەWT/w~^Ks;My[MNN[j*b"Q;ĦV\kT9&@CòVDlr4ѭ *=mz+NW7;|5[ Yf;4N0EM,괕LWvSy J `3qF_x_ފOr,~?E6̜5ۛ^`fOk/:#hI)^eVkDs%*ʼn~01ż.A>?-\Q<^Mq=y{k6= 7/~JlSooM~_F^#>Ke:8Y{9c\; W.T./.K]}>:fw0|=.VZmt_L2vb[JkI sRڞӴ;VANzKq{4&˦qiBٙiS{fKv3;OYҁ6Ӕ%! !Wء*G-wScW9`* 4y}/OAH©MnK].&ӯ^_lXqYc7H<2$}^j (wlŘ"cefygNoNm?=}&zKͮG7?E&q"l+KJz0*YX-*>4Pu0wpiD7/9FUޯ8wقˣ}KOl00dZ :{蓨΁,ھlϒO3m0"f_LJll/%9mtH_"+?;[yJ/ endstream endobj 170 0 obj <>stream ҢkxwPc}{9N؜Y`RKU'ޜn?Yu;1|WUvWQGLo4 S4VR*]zȊROŤ_Ch9KOҝu5DI>:\<ʾ>lvØ'Xt=<rZ(k}f ThTXWdcݬ~̭ȇcWnzvg'M .=o+"XL:$Zz=Eܯ4e!E89b]ۓ6fc( N 0leeλ˺f36I2qr>އ%u@i[Oqʹ9%ʭkR7yBmOT%Z9{8ȹҕ;hO͝Z]~G岾˓`sߨ[I* Gu.D|!]K.L*w, > X%56~6?Vo=Kŵ-ת& ?dzG[*TϖĽY1źWSIf˳ʖT'D<`zfP /CZ7:oZejܺ;f/iTPFWP^lVXG?)A#qiSQK0Wn }q_6<ShRqvhZ^t)һ2\CJ$(Т°a hbw>ΰG*7&I|0YU PoOl=XȌ*WNN0IqmE0(Қ!kx2AE={C6WL@3Bg{II9&:Nd(pP=hJ׃hbTMl4ؕKùa+1h{uVU&S)OUxJ(6s¯v_,\2nFӻjF{N"s=5{*}z?)G7QJ`( 2;}fgxqx5.gL,$ԖpSLsʓoC6~)m[N^%J2h0[Qk믲w'w)ME,9s<ͻ]@' f(mNZAcSw3 6Us 5[F"zJlWq0.<*ߔ'us{kYJq;70"S]+t]ׄǡؑluQOAoz]rj"R9OF:U-['Rv>5*%fR6V\[Kd Uv⓶"ncM5}*о:wKD|WBI˜3>amUV|kA=}!R=žig.aݵ/xdŢ[IYūУ ix5ZK0=21դK/h#Zb%u\=K9|?9?Jx]ӥMz-u^u^>Eg^.o{w*˘|rwdž`rJ36]3u3Kq%f[1~y F^N#~CV'ol7ݫ5od)C{kH:-Ofx9\-,* B0H4% \`8}C6ٕ)uu3,pƫ#[ãy|-ƪxO{xJ3:{Y;rޣcH$ƾ%Yfweph 2094fGfo+ ]|Y܎Xk!j*Gዜ$. [9#7FxKU#+82 !NРcm'ۍ{ޮDd*n+[xG>5 :ԏSX }ԿǛ-$936Qfr_VPqU5; r& ƃOb;s9L Xgaj m]wu| tyMshs/:HwT$x!<_z9iFO̩fX佂i )o|pQDڻ~}T3#7d6wՏ}`Yb"FelC˞z{%Uꞔ%5G P0rּNIHlIbYPr<œ۳Y f>e^uE 1>#[U[5>zyOg♈R(5 ]|H;6I|q6Wslr6Cả.桳3167!CʬzK[z:=h./k )$ýv^7忰ŏCLO:ԌNyUJ{L4;K5UM~k7_K`k[j7sUɿHIOHz Uce֍<"hM^Ų:5^GE-!n:?>oM;RXtUDu{oVd`T4d-f>P_G=E'am u@[6wyXBR9|OSg"n}HI7d&Z陀om#.'Rr5MVi= Ydl_F+ߓJa+ = gۤ X.z ,kaμ~/6=>gfQ`k=¼Д82\w,Sv qޓһoTT[jmFjBZرq,Q2~tj݆ւx3H) 5ldi=f\=8ݠgMY X:O 4ٴ\Y*]}#ML%ty5RqnK.Цciuܤ)6jv^ [~[3+XZlC瞔p<}Z"~Kp9"ؖyTr g޻ 1xQl餵=`ދձ`wjW3p8kVlcMn"II\˽5ttqD&,"{"GAJ;ǯ; !JϊҞ uuNlE.$>V]J2h;r mj5eYu9>(d@V?.IUx+{G7%IO!EOe*3wI9NyM!gշrϟ{j;HK$U(ـZJQ[kL>+ w+T\®_fvbpiLDK,X,?&ĐT8H܈?ΏLRy_[rvZ*g' >F^Ӹ`N= կlp<g#}#kƷ Y~TON>/ f9gUˆl*{KG"GrzžF@Zє mu5u|* T3'fhF%/ώR6#3=91|h(۽p_7>V? c]ݲ/0`>38r9- mTu+Xo{w|fFyVpIeW f߯<ܚ ^8[jU 1`L7oKUYt^ftFcI_;đ:Y ;=rB'™CN^c',~x(iĠG*aI{iAy1Ĕ}A:G[ Hd>=\ A_!$B ܚqeCi;]ũ| 9ԧݿyucu]o WJڞ\5W 3۞Ʀ>g4&[kSfڭΩV4Տ*U*6x&j螝 M=ʫUxˉ4ɬ>1Ӌw'Sըvw^&Tߕw $Ka] ]{#u [ܯpMvg/hCJEƊr?`zk5'#}k4C=vsJ]ύ',l٨ņ!Ypsԟ|V]0FWOV[iB:AA´A{6/0T.U +4 έ_6=d>H^9+FMd Q=I#m]m/PY- X T `9Yhp݃ڵsӾ]MxC?^]}k:)U$^ :FwuoL  ]DPjT W2o=uḃ6d`-oy*Ю9؊iiosC4_oD66lJG>9o^;r5N.!#9r)'glWr͕= VzZ"=mwrٓ/E 2Sȶ>Z{ eu%P <@pGNmVݫ&ܪT#1bz_Cp2)wVu;z%[ou' bT`B'^sTڊ=3d4v_Em&0=ZqAc^:bڡ^ 61Q\4@gPjGڳ խ{VR!J%Rߝ״18[ٵF/?\}V485Xa/?-[\\ܕ߷ L9-x0Sryg"7=y3n3WKjx PdnU9عX ^9hִw* s2q:Z^}vi t">wMSJ~|W!J(цB :<'(g0 tPa [;禹kKϥmh_9`)?dkͭ)DF;\P5+ IB:4Гڶii]E뮁u1AYg|'{-=ApM,OK3QfzovN?Y6RswݑwLg􆧒'R#xui:ɘ֥':=meAy9ۼ+(;q 3Juކtb*NR`S0T)>jjXlhG r08^4E GuP|:u絫L;+ٷeF 4i.,-(+ϰ;ҿ;jo8]x(@(g۶E{;cֹF;ɸM J?ǧRwͰjYm-Se22,pWA81M^l6$pe>>X^|ꓑ "yֆSR.Nn;M2ĥae57ZtñL;]W(-wMQ+'d sL*y7<] 1ut?HZXSwc 4O8w5!?|ö!㨭g"۸RYz#rJ6HF "6+hcˉ]g/s˒q Dwd]%s!+ZV׻S\J,v6sF;xeD{\,-C bM)~̂o=]&6. #}F6DAh`է}tT TuCABjO=|XxsbtJ sѻ|яH+Q5.?CKٛjbzuVj?.aßm 'qܷ(x<Kk:EK Fw )c=M]ҩhN;{`Z3i/\<9K9c0lz$Pi U55geJKf ?c'\kmW$T&|kRvyg{_{_}zrR:u/>c,E@|zsWXCR,7qvNWr\NfB&IJ 豿C4DcV]+MôFWVjՒE*B$KP*Y J!.ãKm1.K o5G#픳f=Ъ+L~0bOmKHl?o6䡽HoJ:!`s`br}G{zMmcaIBV=N䇍zzEt%hlԲjIxuH »_2cPmpi˾o}Biv0M[5U;juLqDFwq9V\M\gf]$>=?36UK?,rb]9ϥ?7>x&˸S=YE 6l'eIrޥ!S(a5Dϡ@TeLmP?xK0Noz u<\C56_ q#)"PV_CَXڶF|f:>zi%Y{YϏ>8YJٻ+O ia76"yxVlF ٙަvhU;_:bVsVFhN֟b ?:4y8oaS-1O!ɮW" fYRcZGZOv%֑6*Sjf&? ˢ)ERI*pW !0T#ڄ|(9Mp Y+f{Jܰ]55rPPW-fv>rOn|1`=ҞklXx2z2]µeȡ Y9Kc-f}GjVbJ :a3Ћ wgɢmpMXC>kRuyRl4si176rhb=N'w3΁MK@7Yg/C(L2֘-jZ#%[^L6{m]/h. tPj9c;P|[ ri(cЙO=8G;\+!wj];Y4L[0.N\&x3kY 5&2ڋuo?2{i:Bq/ Ʈ~8$T<1mFwI$誶TBB8]}= j~acEϣɳm{?J']I4%g5D5X*~^m{Cu.w/W8CISk1#3WkXa-VJqwK`CuU\FᔡK ˝lw;osgyWyTt̚ӛu_[| (dy}\m;%-猦r͗[v}aKjTCe u?*Q_"y"tpأ߹I3;W }n`1+,Tøx:N79=gqC)Ba/ {)~>@ͳϏD:Do0Y%DD_{zu_fGzdN׼[9,{pǔ`.QNpT*CrM,|tT/Y-Qha.%n ?.eޔFyiϋ=uޮi*W4:O?Rʽ[U6:"@^K:k R!f?bOВ l>ә/d0 ft}yſ _zkw[v 6Ю6՛.0^K5oߕ30k ׎TV|jJ-\m81R@6kӀ?~.Oi"T*T𨺯58^ 1KVWS,z ?I7oOv8O)'2XQc Mw SHP`3"*6bv߬C)N1<-xzVXKMhgF# Z>"v*i{h tbAE>L7QEj-_ _h׊+WQ~Gs.X-;pF!ĥ>QOԌvc>;/ %r;g[p,[R<,8]}<>c32J][e!i7W?lhmeU`8c gp4#Ͷ'ё- fZA7wHB=4|.39zWK& 4:M0(`v"-ZB}qozsiV~mCP5&4K&;, Q Cf>.>lefc>Olx/s+bZ*m7>ZPP!(. NX,ϿѾ>Ml/&D3gSX/+Aɽ75TUnKiF!xB(շe) %iwӀFG}]=<$]EoˣOl20SBkRZQV,e`鵺^I[rWfۜ\'ͰܓIv r =>0Ow+Mi~}کoM)Io0I_VgYd!)0[zTǮ{|nͅ=ɀ[!Y|^opSb VNqu3g]U wt.>cP/fn ޴öFFLh44Al F>|vV;MnWq ODri,M2]'S2ho|><2ɣ'xޖ"{JWPaJc<UMvrx~gFڹiIb{/rR{M'<ߧrkwaE;tBhk7)Q6u]dfjk7>ۿx16\ؒ_1Ʈ":pǽ]MJdݺpTr}m:**h\g c_Kw̱˺eńQTaNEj},Z7nۢĆ`}r>Fђh$P=מY}GJXB`u?8c)k5bنJT:UAݵ :6+m,  Ros_On[ 2sE6-уZg>ý00h[n1W>_?7ܜZA֯}rNkLG D<6efg:h^& -* զؤG{[f.wwqm/J[OG SRu&ip5ugr x>; g4Pe@3*Q&Mq}UIVȉ\ťg(D(χ.X1K9Sm~VG5%:.K@Mf%܀]9Pe `cf"zgC&4`g(OWhfshяys-y+PWʩ!Ղڑ.s=kU(#dܼ3,>z(!-Cͤjgդ_:jY?a8~?iǢҌ*[[\N[M>wl ZwͫU}A>*a:t4rOo}?JS>E/՜[pd9J vͻ@Yix2Mr]rDíKӭ}pnLQ.w6O ,BPgfy(Zs Լ'}"L`)u-ækcU_53EΠj4fdSԜS/>6LKrdzijSKls˓ ='gĆnMfqpPI7<'K?&6$O|xI ~hO8^1Cj6yu!3.fg# #ӣK[x%t}UP5ןfj6 :Iޞuru86z#5ڿE wMptz3`/{ڹ.z78؀To\`]||v*ڮ?kjBBTif4Mk],4MZ ֤h Apg}oKQݓ7&b["=lV6&6km 'ZDwfK_R2u!?02Ap\bߌK-V z;%fÊP|ʶ|#"I(fsdJ߻Uquug }S6k ?a^WKc0^ګWCK"+8=e P>Wؼa/q_a 7GRͪؑrqpY9䎟Dq~)Zzm=7T0KH? ׁ̭g}H::?&YW)ľίI(5 䪯gꊆWɶ ̘(myL{ HN#7Qc0 $7LD|߬y as4k{1,_ͺZdPl]/mK)ޝ7/@j(IZԠիPƝ09{vs?(F3  i3/Ojþ!˭?/oWR.9T^&+|-CjHZEDr>yxw⋓r$* [feK4Ffue3W#7fg#pl .osg+Oҭ?ʩ󓨳|(s3ʁy;?rNV/)Iְ+yߘsGJ~c8Gw \iRY76PgĬSb-~l%QZe>嚦3ӿ*ѵlkS{Ws'Yv; qq~,b+mtpşI{{ }Y6Ԟ!_"X»dn/7mQ>0=pfI-h`2cA\ ֔~fԜwVE̻r6{3m_mw0mu1fuтъ-N,rt4uW.!(duZ*#7o@4z/sz0ҒGTe빢r%'lNx@q {*aZ/ :{~$zluo-O3lnn<<6Un~?82I4cg_!yζWKW=ٮ̸.~|Dܧ XߖX;iNl~V;XZgDZZ06;hmMVEF9{8.2CCЌ7B0͋N/YQa\#-UQBH> T6pQXmt?9wo\El t~6bJGv;翞iWw0t| WaԃR@]'jemҺYQrT݋He\3KOUۼA˘ai.Xڍĝnv5-JA=BH:GUkذׯb MV`3'kGWe_:ots.oQ aFe)ϩַBMtEKV%>uò{\JGzHo,c)^$izEшr^_8Dל̑5Hd< DbRO7 \|ֳC6Lj 95tgj+WEfY$ qaPqLW9}}̯wSJ{1icOiں*D~4Gn^)"Nhٜٻ@(~7nz[fi2?Ӊ[O\\P[w!&}~S7zy}oEtvZƏP=\+e+ +/t*\:%U&Ko%l~> UĥIr5gj>oRZv9|J[+vD$,7xt \|eş:zȺd"^Lpe> zVcf5?յÝ’sre$[v5pvrND !/'ѱ̂ؓƭk4oҌ><6mdaHt~VS&/S]նCl:? Wmœ!W`EQ[p2~(鈏@^a2wv@]B j:H6V*`zy2Pg7(._QT3*0`4IC^ڨMg8zlPs%&^1lގu&bb^mL_X=ˡfKCUfFugx܍OK<u;:B(zP)k\"ɟ׉(Fx[Y?re9]ɬr Z[x:Db5 m:"\5i& ' V jNwkTsV%\]b-EҥבuWvysBibҳjZD=SC~KCԙRg:pjЍ cKޫe'8Ӑb%]ǂK_g\=7S;7^ZVr?xV+jδYT.iw+#~TcT>SН`l.1HVГ6N{QzymMRMGfb&lg\i4%a6`Q;2YX5LQ~ 8,Gt:!3UxOxOE)tGſ7V{:5MSwZ~LVv]khVC}i#䒼Jo**҅`XT 1_Dm3Ozaӹ=A>öyG%mpXʶV}hyRunU ,_"E%AZ`Ym "n b W۠QPkFk}5+Ầnc|Y)̀V&*:邁`6Dn|ci-Q;uw(j8kkS6ZoРRrH(`I;ѝMڻ x^7j6ƣkl(*n OZ^s[f|5P7Zcqr؉nnbK_7div} {<، M1Z;7=s/*zOΊyfM돿ıXF3Hgq .f -0I?KpAEܶ/Pu)97%g x߹)b%.!9i>{DKyTIz*v"1gxLHXիOiս eb驹inh'ߘL#f (!ANN?ga]p&(ث$^OFܣ I|S ¢T?FЈ0X것#fѝhݿʍk(IU.ް^PQE7Y '/wa?m^X uyt7Lv7/]&>mm|pՃ>z]`AG^j16B=$tyr2Mgҋm1m5~VM ZtNful;3{4^7)\{]7b_"Q=̴gF{[7&!KXK m̖q?dA>UooTrVS2}=+XGG.d~x)=#m"q2v- C긷=^gtRsۂ됝-t˹zd2*|8ڳ&{C>:,xAsddg#(v5ds^SyRgK~j%ޕDWu} lʘ&Jhf$[םӕYk66MjËY+`9JWG)"(r!-HK1 }61Ztj  M1O_@=ۂ`\X@3a_%ip Ӂ@ Qjņ &vc%ZDKڲFINY F;׀ "Ӹy}OsxZ-zBQ>ߨx)|pyGɘc^%> Q|^LbTy)L1I$?;ke EApnrrY:4g?hcJ9oQZr4'wᑛ1Qq͇Zduj\0({ 3R}#{ǥW+LwMWI˜k`Q)y4]-*Qpz|$tBsw͘}p+@~zaOWϹLhEKs~UNSh㶂۬c+hXp/`ѐXmFm@1_b֌c2lj\/Ԗ:P"̲t]Ao&n#iwlng&Cg֛o6m /Urۜov|lSgRf FRάC[)ܙC4%9vm2o]Pܭ?6/WG*7ә*%Q aQQRj0"BrG@~nS̉г!5rl;5rjgaYZ27]ZzNtSk\l1&y2N[SFjMqYWMVn=e<*ZǞ)!$\nJ:՜**\=p>40"M^?SsoY:gk>= t"A*hkU#yDlJ3N>U75eXM KӵlqN!JR>^_ܜ%5Grë k.8"1RmI6[Xߦ:y=kα7|X:YJչ?J;׿_3X-mp4r@j(hK&˵o`܎ ǏKtilDzo{`}C&k1ay&nzmor]1:@ش:-<}n -Mza4e{=F oCk`GO*h6nAb6茈n/+j NPMz+_O"[l̆*`B*^;=߆}>jhz |)APEȞP.L~}+;p( 1E1WPO}!=Y}@3ʫ sQ~mIڳLCDq.Pn1f BkdAm?O`@ce,H5憳5ݍZL _=*Y\xoé$iL& 8[$+pw/'yfiSXò zD.gV~i ^qмARVnߥs{-zVL#'|%z B(/V=/.=Y8X64˧Fw^W/Nk&5"<,<7wT^`Ljtך4=`~1_ RRg/yHD\l[rõrnoKNrC%{->n}Fk'iA%wJz6R"ylIk?z'ZF8j1]cnTH=ʮ˶*ĕrZ7)޵YJb+bK~Xi:OSNcT]I 〨x^D H TS@%fH{@H6Drܧ䎤V!ck0BvO%^Ҍ\GV|w8<`:yǕW#^0{4]Zfft2О:#X+˿xFg>`6ģL;pٷXMuX"5HjmLcԜ^Yn,[2ǝף2C뗁R _j$ӑa*_uIBښ=$K,njt#¯X,9{V&@yX>nBhj9޳o'c`hZ Y,h|(i1|VȠ((CIfA/Ga} d /ԑ{h̍)p]В}n2&| GvjK\dNW^w^`/#?٫L@os)}KO+kTީ/U{ayzf}8Wdfgډf]Ԥ5D{@ŠZBl \ǰ -6K:̀3Y@n8V+J jyccݯ7K,MxSƿU웗ɷʼnokzӉKV\ _y 'zZ SG>ʲ,YH? ^ZȥԚy۩޾DHܲILq;p@-^ݧ;_)@]Zzn^uluE^s<]bidK=Y,'QP/m1lR: 1w]Sj_˅™=1G^k:"ڪ{׫B vԘpW6Vtn=9ha]Z,ֹo}M)Fx|Aג|D[J- _#N!J4 Q׷1ߖ/ja$WДoaD YR[P>\:OjB޳S" ,|lԄ|D`߯FfTJ7+0T_,X}F'iI5B.=i]vojacT0`w*Ūr}(0:]5 uwUͥ2! b8ӟhl[i:?] y03Ũ\|W_ăwN;8?.L^x S)osJl䟯Cx dVtNG\-=]NkPi Ed1[m>uCq5 ^#`.#\J OI!T`oWvbv~anג.Kʪ=6a|?vvGF`qI-gg$?)iWH'O}Φ߷мYyK?$n6Tnf}ąJ* rֵvVÿAXɮ.xy`z0+42f!@1'FO |yi:1f͖ $ 3jēf+0O;FTZyhT]y=#'ks{jB ct׸l\oTDj׎6|ܤ`ДTJoU hHjp4ձV5υe Z2[̿8p8b8%Xw_>(6#FfPj]^gk+ZNu~AWrxֱfz-J{iR[ky[#~ޅ`5#eyCUS*콧t 5,v6i vX>4 =8չ8}oZJ׺k/U@Lv}+K'|6t\kC%}$)?kRw>nVs0$3hm~X aoFb2?TzK?~|p@{Usgö[FV\FmF k* \6d;Eab&kSUx(1@zfR~ZxK?\U [1I!Miw9"ϟ ]i}mN1uk7Zj6. Mkow2jt0v͆t`^Xh^B/G ؛Yf4s,k@Y Z̵X4u[sf>93bsL%ú·[]o͎ځvmh%#Q2ۍ[\ŕuXcN͢=}^&?uc)Cff*-3dŖeI^IFsRK}헄Ttx Hz\ed~E}7 7nJٓјJ2r?wCki 4JN1;Z B&R-聦-(!*9ċ"|(viS+%8hG;*9H!;B\բ$SGq{QѩislA>ӓ7vuP7v̘V RLN66?2PS^+v`pXwkqa!u&9t\ȣ罉Z.pt:{}7]ၨh _5n[y=ȫ| 2{=6 3۶7r8X[[sWg(7fxXM鈟%Ж{ %lv-!uOǯ{vnʖl|4r᫑Jhnq9\p/ \3t c;C9VJr,K׌F8]|Z0ߵYu?/;;m}0}z|n5QiO{g_\;熅6挛.Ioh4h5Toeydr41.f2FU^&E~rSUAˌ:E\jqvNQ3d/ 0\Tq+K>`cnmH/Ks Y/a2C*7W}BZ::؀r@1xSE^gx$~i9G]f3@}<xal]*)_^Diكuu= 1&6aZb ڣ_u̇짎z#`P@տ*d=*LèThx^Ǖ0 m3r7#ESv9̑ıb'& ,T댂j`%UVn>ueV ) и)N<0n>ޒw->=`x+/ʈ;qKR"yjz/iוmg #|h&7[S 򥪄IRШ JɯuQ + L&6j hiW#1ޞ ߐyFWQF$gzB]j&D3;1Ŋ;vz'h=ޖ¿iRة?h\踜,P9W@MJ$@I3Ҁj(۟ӡAU ,n> ?6]x[Z) =X;zkp紛.=3:mݖ- r,mf!{Y~-Xw].8mwG_A& `\G0$:="/aMz8DWFL7 "9w6^FTQ/NG6o1V$y:{Т+re%s)Tg[9j,]7,cfJig*2_~i 29wA vUVgiՊX8bj=,!^=tLsM?3l<~gd 3W$VW90i&ɪ<+ʥޞöEYs輦)82w+lOqⲷ&:7^IYF%_|iec$B_i%ˆ7`g6oƷs_nB6y=v&QS+ݤڙ;sUy\f@\90*t:[TBTic2DCw|X!TtYݺ _9A+?PAAawRTZ%a}ޓ|NF9R(X2u[hG\F$s:êգIF;~fD|q=[8*C8|3+R3 Qv, 58ʴ̸܌aUƯQۼEʥhkuHsȴV5-S1|xض̀Fx0vAWN޸>+mFҼԃU+*kfN;tmرXq")zK5FMOL5'^!iQ׃b /wm;%'psrnku[sѳe$5RyDtLZ6rvg8t9vO=ΎFe4Uv]8]J};}}(/WORlԃe$NS61]ll iRذo(޿|VrsNFPDh=dEJO, *.(1)zh6'b,|&iΗ ,xQzCnQ3Q#/Ṯu`e:2༥Y"ϿlYZ̶Yb}|,` XǨW^OG!; BW\Mn6WyW,J~]ނ0`>cA4^[+kŲٵ5l`?)%1d_szW1Zq3guݶI:P zR/H`HJcMr=Ϭ.e|ʴu;8u_Ƙ/[lse3"m5vc[$e\׫F1dǵZPÚU_;V~.n,Isݻ;F٘DZ~y56%8hVVى=)['ߝmP {W{XGe"TCĖFa'vNfm% .zm5>s$Bfq Ej=[%UxSۙt)ҹF,-yrWy'BB2FsPgKWch6i{$3#督.ot3 E[RHuzw׆v˜ت Q`<y8 Gx4LP]|FOܕRQCxXʊi:ǰ1"~fLlP#WWynm7wI).Z6rc#-mܘ q%Vلt#K;Ǩt35eH8+4{Ͷpr>i&=ײ|8GuZת7E@O#ֻWY!b6Y7c/$k >R= /E:v4Y;fŃI4uRoƓך;a.S/fnhjPG^pb`CS|8#\}mݘfF2啵#qMlz6ܷUޜ B#69αI_V[tc4 [4Ev.}22rtTw=cB|xTxg |o!#ɶN c~EG(TiFP!baGDbڪ+"F'i;쥾gYE옟^Rexvh7C8_C='﷏o'3 LQ@qt 7q#=79NGvg^Z!0j7Py Z}ׇA5X̸'Ÿe1]w4e/jtIoo&r-}Mt*.̓7}r8T5l`kǁE%Rj1 GXᎽiC,|u,\R!@1?y5>&q.eBask#xXWPԞ~f'2~ZZ3)8pJLmJKrA;ȩ{PC_lGc;8Vޏ9Z#gunC :2E?{_ 8k^0eAb7bi@ZQ&lSx{0T1^g=A\2_Fkۉ='ʄv-8?#cX<"wžgk ?ʸyЩkWEDCk[QJzf_6vTA@y8`Q޹RݵibjMX2t".S+48W\:_verNM^ /)vU r汑n97 = 97mwީ¢>oUd4R1{N?2|D dH;ޝzY .qYuE;y^>}R'Ja9S6{~l#&HЫ&/K=Vƪ$bu N 7Xr4/)PA ]ow_>ͺ3Ⓠ53)ARYr܆9ٜDuX|&z %SW6P V)`d*;0tOKAq4v*K聾sNhMGZ6h;v_=J5-d)m v ` m& Ƥ#دҲmؕ-=,u"rD&k ܚ?qSg!"Yk G,Px3z֐e$xW;c{:x7i=,,QDt;[L=Ɣh S[$cPpZ>I^ݰ J 6:fvxo1Yu^_=s!*Пuڣyc0bkns e-9n$ g(*,O;,vg7T#㌠8GK:E5GoZs5aB'0Z#i7GjӍM~:lQfXJ_c #0\ev^Q;8L?ZNe4z "d믻RρQ0v7_5^D:S[ݠy "q}[4$jAנT?s3I[ Ƶ C{qgYfZu(j:WP#R޽/z*^/Ȯ-/snÂ] f{4\2%I5a`m7R32z|@ᅳC?/ޒ%Pꋨ Hȡit?kSL 7w2̃EYտx?6%K wh.c R㝵]˷cl5fH'̹#-Ԛ!c24f֭UMy(9Ķ۾ԑjGzmOl-? Jãv%usOH"F {m-Ȋw*f z^$⩁o|)],._Z}r8>ϊp~YOxE422\DK%IZ_ 9yijykm7B,91@>12 ?w&jud]*n틾XMMuז?oہ+f-f^XIBΙiC֙n q oҲy>fPl@;W2]#$_mH Ѿ#'/'܈N-SȎ:&[ .S%91KٴװIAf8:`%mDE~>;x`]7;ivn>V<)Y |;MNkռ2xjҊIzը|6lѧI4j5^'nN8Ӿa0|~K> e2e/6Qn׫ RlAEuQ?hK{3l=ArOv_fMCgGq^ͪ& %/PS^vȔB..Ѥ4oQxjFulK?k>AiZc(bm"VYU}2q_\dmpk|D(E1ycgb&7׆o/Aj[SPCy'}:{jS8nfٿu<56 ʝQ"GK;\r.ku!\to#`lGjuߙEŀG|o:3mņuB6φeQɾT^+|ؑ=埄<+]DkN8xvWx=g"VQ4h0iE$hT 7?kcv;2*:i_f2u&t kxwIN8Fn]z/)~kQUn :>3l)// %65/b֢;٬W"Iir1O̺_ zuFfi5+V Kg;?U3ȧWMud>8w$s9)ö'Քl*>`B_*Kxam˞rKܑo'2֝{78 x]Nv";HLN+RZD5Hs髴rlK8qg;Rq/T32rd,>(м}֊84<ΗYUAGO1GSO dx0k|Wiz |,hRV읬o,?`{EmY@?yjF:O4GŢ`0PakwS^r&9/ubѝJ;\ V6y- ̆H QҼtt sPVEulnc_/=v@B0Toˇe%͵VTfyMN:ps_rǎ3x3Pߵ9#3Se(@IJmr3dykחy\wKrѪwڲÜ߬ v+-iLyt3ᘹ>ES!fB+Yxzl}^q yi+7V~_V<^:JXG˔qqH2!_.$"j]_LF|\:hc1}Y?a4UA}Tg6v5w{om'㷂_7T-=goKvqjWHqCg&[F ɠg.nշ oe"KuG|#4`ck5K4 +.u1d*i{0jQ$Zp~;?qVxn2%M 0e+~௯eزZ M/zc{\*`Y Y.)W~&H2`ۼkaLG9XS $`-acۃ9VQ2YfBLX%捕swp02PNd_mØ,_r,+[-ccoH{ޖwSiK՜;qݸ>Ӏts97+L7YrBl /K܉^C gν:A=-]S@l2D KƝuxC7T]$Y!FGWs dk0*8SN(1DWWpG  O\8t(-a+`\M`NfJvLƆ2P8+I5 'q];QS\BrɚQ`%8-P{&eyUNԫ~j-Hp&JF8^Uhwv~f :>g|N8N4Y c7Boq77dKpCѮs÷h+4>+Fn ]uZpyHh~# RH7joߩxsO+z iCISD6|pER ؓ~C[WC a ^N6-~<)ޘ<'K +#H%2 UO$]Z2يMC2x±ݫd/R#}z G_ttQ'x<+f]Qn3ͽK&M2~ wn=@?vvO5,q'E 2 K⟍у:M/BK  5oWi!΃MF'6€KT૿PTГG.$l#n3]S[4kӆz:>;m3}-י{i}? ZR 浦*(X^ZMT\,7igԔJ ʫ{>؛h" Mc;u-/g|W:/Oy[}W*r4Myih Pl׊6qI8N ճF<F}Fis-\V4즵;;foplݺP[Nj%}hP#O?Ôk> ڑ쳧MNE@,fnqK 3tae(ћWcG_݌IgM!F0\+#;o <'vs( `_ >lۑ<~᭟a˸䝀-.^Dzcյi؍^C֮Iʧ㵫V Z.ct7wdm=I ̋FuuIJ՝YtR{~;`e [n-\[i+3,1qzG:/p>-$M;iod۷ڼ9C޲/c_MwYؓ}uTC$ 0 Yrhwާc5ƀ$ ]p/sHFeϽ9n1ꋀBQfyt Aӱe4f:mŁG~3\݌xvՐNc',Y_^ÛtytDg4j_etaa˷4]:}XQ{W-b f^ W)}-^,xjOz5oixEjA\'>F ;;%WK:\}O5u!n0~BQ/x8MPYӯWdPR>N>¯~kH6WCH)ľܿ[vyv\hdY[N9^i!*=Q'Ĥ#q>4 )h8tPlU}Xz iq̖\{f,VݔHyl>΀]BXPom̊IQP{ZSw58Zf*[iwvbyw۱I61SԫM6eW⛇XQO}xYE^^ SrT^ X@³{s R"J}&VBw?U-K7k@vXȭڌ}Ҏ{Z3/ׇl9?my|uJG&5F1]#W s)MoSjC5d_9q2"3e?\QI(Аƪ@_4u)XH TGa?Kl0mp!9륷4'ԥXTZvyh.9/bڲѫ#܌JȨo"rWR孼M\8-&yF fX6d/|`UϪؓ!"ٴ×t8:c5Zzj(z[#ݪu ҳciQtrۮs 9cYH0t3wZ Nǭno!8bekl2f&mSA@'9te]G+~a=[ƤCBf3ːUMuIwk,[<2 .(TјWH_$-<&}_?mm=#7[o:q7Kk+,Q[]gN5`}NH~.vrȽ2vz.V(*I^c5KĢ,1gО[4u9f~ĭZ{&Ǟ5`7lsTe)ú>mBM3ܼ{/۟F~Ѳo*h;5Emw3,*ޣvwڙUu 0%]DTeD̮_+OW ~ڞc;3DgN9M.=H9XwGZ\Yj-~ҁ3XmL2@fIT+pu'37FxZDžjR4)kT p&k}U$NΐRh&1*-B%unT~sʢEY0 h# \.uBAZ]*"dMe#'&c‚Kh@1_C>mv:Cf,1Xw)V0s`҄[(b\NET*hv#io3h\koH8,]J\/ *ȸn+hF%ǽv鸘f|IA3q;x7BA{I/zk2K| YrSu(jh6H߮`k`.atMو*Gw0!ҩ\؏#9'< ZݛQQ2;&<-Wf V;9ւu4p`Y}C ǂhԸezEI0'ؚJTV,zg)Pjઋ q>YWDd&F4ku8KU4KX-! ex `1=WVooW;<3*'{(/`nQ 4[Xm'Z9`~n}KwSX` uN֪݀@L(,ߪLK`'aw29έ+kys$jxХ8s[1+5{GUv|8vO %K0anzp-wy v!hF( ŞYJb/_(Tk򈩳KۍE ]b5ˮ+5~(6q,쑹d @b.[F*ZVYM J_6 H' d^t:(-o֦QVG~F+5fF4ajߚJ-δ|c{ڋ_3Ө=S4'`N w3sA/!x.UپM2HHmmT$Wt0nR^&Uݕ(ڟٴlqcxW7amSRUTYSJa m/527_z? G ' :p J9vyuer/^~E߆M{`! "C3탬줢y4;w8MYk&S,`Ѹqu+e=ŝ#%Z˛N{ypsNT*!'D:iKt[:Gw:ߣ;r@u5(ESpwm>*TL*o'6qAtBynJIYI2JcuOؙ[qtOĜP:]Dj[2ozۃ˥G6=YJ־u[܆RT\/'qWKReն$n{B6섋w _-a{Lz_ytr[3u7;3 UBPޒn?RBBUݖhiVߔ?P_{{8݊Y iZ ]i܋C`Ku&hwvv,kSˇDߦ9g$/>2*:;}|-Of/dA(s9ں/REZM`Xc)MqyKxý;EWKyiΩ½ փbU3{nU4Vn `ɏ*MUҳ^^+5tK)` p{? ȵV.ppI*w E-5m5gUgٜC]$ΐtDĽ#TA_VR5a'[< VXo2 ti$uPN_ِx~/AcSh %Ț4scfJrsT(xRDJSPAҒ$yF}ocb>ӷds,xq݅R40F&En. -' nM_DžyoI{U* ̀)_#?WڸT*/`AFtiW<8[n4ykQPq*_ՑaheրT D-M?W~Z\"xV991R\Eu O YDFF:ڝ4^&H^Z1g7eP3?uZӺ'9~u@,pXah!y蜌@;_>.G|{38wg09Gcb&/,$^F|.C ӺS&=C+8'F1ݓʂ랿.=yge\YݝҒ/'>ă˟s3E|7?m8{8fͯ1i_7;">jx,TOZ9!I_WN|z5+j3iީ@ IqƵ=ñfu4{ߐ́j=oMٞS.;u]ޤ)پlF+tF@G҃`?Xwe()L^wYѽJ*;rgy%}k~DEH̿1 wꔿK+S^OqHJ"cU&Be.UPN'/si unCEo(I-^2iy6jE6lx;|g358 ?PFbʉqg 2[yߒ{ʳDRhCڝ]q0e`_9Li^4S*'} nrV5,mp|jqV )з捓K< Yלּ; ?QKۼh43HyőV~֓1<]B>H﬑1ؒ0<'bǹܠxrQȪzI{4Vmǒ*LT@;wOFRUjj[ fO/\4$dB-<9i 4>%AVSށovqmx]Ia3yEС#?G6k\5 "~+J8t2m? qo|}Gmsw0gnBRdE\ B(6LqZg 7I&ψTȠ1Šހ5@݅BjK W 6^:Ky8Q1 ]yq\ĉIlft|A/J0oJX(I(J.U匄_ϋS4+L4h*plqbrGƽ mw:r]M˻7AXUF&U۰-3_CJ/m^O) kY& ]qd>D>Aӵ$vo7${7 fd]yI~6P\0@Q I"G4,m ſHloՙ \iB ¹ݾI81u#4e?In.q/j]s7D y\&IBàcCvu$B 8e@V\X]nmM7@~'U?+Fw}VJ0)($l(s}ҽǺi+6=WZCrbjg708/oOmX4g]uIuB5$-\%ښV s+0Ai97O`ٝJq߹Nʛk:̑ەx-3ARX9b} ,oN,'b/Eֿs9󂶓gQ"bYY&Q3M|{Xyуbkk1J3*k:݆=I5ML~^l'+y9pY9ﻕE 6@]ۧ$r}Q?(,8?6Q?z{%s)կ3~\kɔzus|ߜXkiɔ (BP/˧?آ܌j%8D^w %Eu[k"מ<{'^ yMxp+#3qߜ#Q\A+#gɒםE!qog$烏nED@ ʆKy9h09J;25hMGq=jS;W"K(B|yp =2.yz/Pި!АF\#-`ە:k tG'oq'PoVv h'65jw 1۸.-}k' 'HR־^f_LDl~9#e,Hv@Cy\v]Rm8lڗӕ,fÑd^g$05B=wz|\wE+qKyӀ=y\'P&c"UGKzg&ev*:eV[+F֝ Gxe0yo%g'N.;YH e*tڞ蚵"uK #~Hs&10\}T,ǿl'j5;ިFӖsىk(g8$|rEP'(68 ǑƀnC!y .iM) aEPxrdJul=q: pg&qN:&s.]Nr=nIlFcP(rVz^~q6c`=G⊮oD`dҡ4kϙ?n!+ʝm$\.>CHtfSٕ)aiTvt9KELx&vsut $뚟wJo['#T<<#]^^sWkL˜0M;#pTm+|␓C.%q~a"Yr&zSa}FB@Cs>tXU8]>Yr[;xw3J^5̖&9R؆▶:Il^itZI$hxd%?$j6S"udҥ2<֒p-y貓JUGΕלo=Tx&Qev%r]/,^(6qy{J"9*߻eG>x~ 3[oMwuf*H#P8VrBq&fHTWw.!{LB_n\~>ϊJ)̘#CW yKEq~`L ,`4ma$80H$6ڷCjGrꄵz=ut/*Iĉx>Y5iB'0[aIf7EgN)p6d8K=k!F"~PxV6BUq 8`k[Ƌkl I}ؤPDē/">J.D/tA iJ.]9i$dݧ:ܢجo1ogxGLqj3u-Je 9OpK+$,l:LQ*5 ٩⦲jTuGK~[-aU;뇁 w:]j!9( 1-9`ļP]׭r#þb{2 pٷ'"l3Hi4βkS>u9q`NFFJs:SK+#XH%Jȫ[֗0I¶lwZy!0w9hNy~ڏgk3#]xe=7rT\^d C2/>u"@B}r,Oؐ_,)a4/\`VtY VִKRgׅvhk3kL;Hk.jl9>V pOV!:u^exjBuft`6g$e"V`g n pwTA+'!q]LoA^,Vc=1IJӼl 1^F䪶J6_[PN}OR?Nm%bGZX H\,ٽ\Oȭ\J;s˙=蘨55㼀eP) zuP)ViNoq@]_@/q["z ~:U cer]fZlk\<)g6ypQlBHS!H8TAfmJ֛TVEu.S۪*g^O6T' ը$gW :GK$ p,CT d^~^""/i r|~( `&sx0_,Alu^e2w\9)V9lepz]nH&{W@iLJf4a]ǃKɘ"8ul9wB~T-чЪ3XEj֓UK\D9>V}\tnh6n7$fJs#ȂG=D2XO%Usho-YgWBQ ,B.Gk ] V7Yf?/ ":MP\\3nգ!u9\Tdt.i/2'ޙkK4_+wfLGlTY c>}yZĮ䴙{dVx56t\w.LL[fC83n7Y$)M0n<'ĥcJ\+=x]ht;7bזh4i[}/Phf] xm !I3 ٱIᘭ> iL?*zgN;r )" 1yeKhys%m/hYmЛy7=&M o:lܫ].GȯG'>4|0:+l8-H̿m7 ++3,QX%&Nߏ<}yނEOY5Eix:kQX35)Δh͌l,n[(Awh:FCኞ#&Z8JDWpwgQ?~q>kGoow|ߜqizc!Jz8jZS8JI6"΍qbňlޜq2ERz9N;(pX9'F=GU/?J"iV:IDa{GGNΟڥ8OstڎI|&,[BOpriCS7YK_{n~ݔ>9ȤFťK;Ldz\8vRŃ`fl'a4 >N^40>/s2;4:^FS\s:v+U/$Nx;vh{ lϭs&h? ܀ކ8*+*"F[XhZ =uևS2;}DDŀ$ +cf/מW"aEtϏ&Iw6cWuۯJ}-.svȦ5:h)-8=Te?*qϕ6nL(r< k|guqKu0l~{iBcԺ(B7٭=K3rAa\8h6-'),yNOB'~ *:%l K)qp.T-N% E'Bgs9cL_ u9*e+dfqX[dhH<[oJXy^v.v*MX4o\8fձ@o葬8g4C~JR$9|) 0ȁR1ߗۼ,݊Q)K"ݙrAmqYN`nBpz }e\mÔŷ{e?ګa$ظ/* hy s~e5,wuw9.J&@u^&<7?}PqXb|d=mh fK` ;3V(L>gqqa'O?P?Ϥ`ݛϣ~3Ϊ,8ɔ/7挚bwo~G]4w(eD)D%B؉+Fĺek U锎9)j< }go=^.W0g08EF \;J2eG"D(2)cl~˧]P:5/. 9=lG߰>XWI4_p~oJAR7'x6'`׏7noǷ)?W/a1!,*L,\o4e3'Z#DzJBWaq:Y7;ejHY[m /33'jwL hD ="=Sm2^OnRdHb?τ@1oF _sWFL-iv:˰C (+)J [sھ95F@mmgyu]:ai4J:qY4UFw3Zn irCȡ7:q/Ҵ%oʅE190颛=-PE}K[&KF䯨j90BlB`<= xg2ΥOε*v?Sx+Fúdr(&!CɆݬ'?nS[ ,8Sz=0,ۨ%)˓ } y +Jr_֎gqϑӺ.Yv:o~^05C@ZlD٨^6M~J +qt4ʪa*Gc$oB׋{d6u2V]֫}U[S`ʆLxI;]Ϸ-l!qݿlcK1B"ry".A݇UԹݙW+(\ X\qcf2o Aep,;%Shmdzc|u7,Ϝ"籰:~p5/^~b6gڣ[cc'^ۣy]l.dųm͊'/Nt1k1aiӟJ"N"9)F jUnMYo `r`?r.ރkN`{f84z9+rxzhC?'#f줋-ya Qd(ޤ#D|soWϋq .DQ==|KݛWg걭g}y0-9CmHzw.=f?[ N_uHd@OF|NzhZ}Ea-:^g泩GH?ՕI(եrzTZ$PˤMMEY#S v۾*3) +lZe>NNZ6tAo|qybg(%eu;8lg USFP hM u3ck}%N濢6\X##ktn{}Q ?8f/AFx"{H^" EܗvʘPކ8Z3y $H4':o"Vڬ$]d#]j6$4aMN7mM,'YڼMr'r6) sY GQ;{|YJ ) +V):t\ kxr>mUBÞj5>uI b $u*\ Y(,3|K[v@B0yI./)տo({ptJ'#ka fhwOuoO Z"wV_C18=0^@v^@#nhe!\>r csyg[I~J =a_a4oG<,V yCYH>]Ms w^pն84EjqfzI e:5k]<en辿G^~TŠD"#u箲DZ.E-.Oۛv-ڿYgE ETQDeT`{wz. 3ɬ?볏Hqe?T>4-̙埿oR8SXdǭ>HĢ@o>+q}2$bfnߌ*# FaGDbϘv?{nbҌZ.$R1fp $ג=G_܌HV`cK]RxrQrcGݴ޿L^^e3i3u~CF0G\J$j҈N,AHYI䲛I?4 sӪﷻM4܍@l:5O-˸{A<} YIӭW'#]H2 }(Ո̙k$S;@g]88[epD:19pi|A roZЕeTc!XqAsğZtnX*--X~$>&t'2Ǐ%!Gu.p]>v}ɷgM]ymYʘ@jC|*o`40 Ђtƣ@u$[<GgǦ xȧܑ%%:`L&Alaե=U. .e{^e[;9?3@?L:<ת?Lrn[x&M߻~yߗjFG==_ ۆp/iCR&yvJvy-Y2qUW|}gODU ^(*@C#fB!<_n:8ǪO8U8薜ٕBJMJHjkՌuV8{];MO]Q@\SI½CPbo=]gU^sI=գRF}qveMBV=eݗ9pvCq:(u৓{G  |GYGW:+3(r rAgw*=Re?ÙȞ r:`a꾜{u:ܞ}}R9Ԝ$4:`j"na=Yn@$J1'Pڕp2N%퇊ktUqJf\Vcj  ޗ׍|{&szcq/=#N ]`HR{Z&^\Dz{b'PN×;rAXKA٫;t)g]׮­n'{^>vIoZ1t YoP)@ "tdW0eˀ2Am ;^ EzU؂vxBY_u<03{:-xi޿+S F@kG]6o6 Iv=yO񇪣ŪwK(%d$_vxlvJy9ux{63g;"t&6,୨i+)b{$ 9-_ᑬPJfG'1rM/1teJeYѢzelt=kiW>ށӮ3NơpIEUu <$3ڋԲFxOSDԞJG5 ݼ?3M^۩{e9V0΂-Yv5XUPqE*; iIĖpU{$Syd+n(9.ˣPXLDBVH8^MES[SFrȗU,a+U#M-5-_O ؓ/U4YƏS?F (tgQ=R U|>YZHV^7ف3:@?sbeFk\T[ $ $D6u.-k,KSF3.䧈5VdI.\$** 0r<?jZXQQh8ў^dom^)}TlHW6 `SXsj9'.5dc~! _jǏUrXB55C.qV/y}f➻}SWP}|HүTUSb Us Y-']u{,MYg5Ř? V_9~b6&Mklܟdӊӹ-m{Rr[) e >X4\*=S^^Oa$:MnzSMQ{SĵI6Wc_Au=oVԐ7Lxl/7u:mgCkSx[5O{ѼKn?i[f&m0*ʸ\X)&5IH%?KmTvTaN8;xj췷q{kv~T2sY?TYuyhLw ύ<1_50bkaZۏ>~=QZ';c1sPt)?!w7hoh^>(38-zZȇ~~J+͠ բEW9+ȌAOЛ_nΠ(ToFngg$.s%mSŲ֥+ӓhZG*;zf)g:M 7$Y-||T\;so<*l{ڡ.u9Zqg]S}¼'3>9|o>^Z4T3 wJju%^LO)gcG/ ^aOHiP J*=jZ(') CJ.rNi%>&jμiiNص;=Rzro33h)n/xV5Yb}!0"*G)(Yrlu6/-2ns2ڝM6nn44q\ofLtBI!Cۓz&"l<'D9:?w )@{R9RibM/|nyqQ46R`jhT2Xҽ5ROLP0'3$F!()w{H 7D_DZ*Q9\jnMFtYYz z?[tkiSNkk|oخ0\_ea!h0ι30;=BgNjzy'ۺ*6)R 7v_(8fI BJDֺi*Cv."t8v9i$߮$]јOovDLH b'V\\,F:XsWaᶥu?S VSnw#qт$| :CM%\{AVL 6qok|iI̕=yHfG3hG7!J8qw3THw)Tە`|ZzlI2ֽ] G#:YaSZ 6@D6:8"@g`hE%}B {yQ ՠ3e>U~̼Yzzk1e9v3ޛlnC?G edv^V]܂:r-K/{8c>\6c'%a@O3jw_9=׵\ n#;ATPߒO3 g^ֻ9׮ɠK.g] O&h`CڈU݊ Pl[nT_Px#O'a> g`Sc;ud[Ы}ݢmؗtku:Y-+͸S>ʖF-0.-NGQ({g#u~^?{4qKi9)w9.jTӦiY+TPe- 6'OPʢ?KrnqnQ9@A(< _N=Y3|r>r; t3i ղnyn&}05bt~|ѸU*ӉU9ay޼?afAiYN#BEv . @jS=y*#Eu0DK7䙦[i>xBK@ D'Kˮcϋ{U{X Ě|{|qpVj[; uփAӡK-V NO9B?D9Z'b 4$5%[dma>|l^Cwr#R4W |=]=A{㈎$ܴC65۷j>1>gIH Y5]WO|0gQ,61y{A夛{X-mt>Sf/`6jMmUz͹ͱ.&[ xEU #V3(ԋ!(e4Yn g']$y4;9վ½ x o# ll݆6GsY.Ik}WusE'[Ȱ+R Cb_JǍz&K}`}Gʯ]M^sh~* )Xo?O@z6EڴVϨnRTV#B>v}V LʪpiviY"_?E4B 8ɖ|>bC8aw>8ڟbkz5}.ko&#_//"YV+ BR/[/!, Tx"ib`?A| ,LROŞv`?dhic*kyЏS.? "l[,j<^r[iVDSTnW(I_KPJ(du_^Tq'B|ȭuf܄^ZnWc;ڭo"zh}ih\r344o>safՊ&q9bqNdL) ڸT}gY\e# *5\h͟V Ur]f?u(禟Wn?Mrw_ZιtEuJ]xS]m>A LJ\ce__ϓ_hU=;V~uŨeÐ,\uӖ;{>N#֨:B\N4k;|k۬ULƈK#"[-ڬQFUZCazy3*j0/Y_̓$۝Ro7[ӖQs|}vb[ʑ&+bG&nXw"=| ]:ϜfOQrO#e9S/CEu\r,Y ٌvve@yOZV}z$DjKHr#@Fb~\6r}\Kqe;51x\Mucpt7xSbk(x dF;ݪU+ 6ڋsopYx k)^W1ν^ޖ^$~wG{֠fWPn#6mOi=WSXC ԟmc]dvVeΣÉKK(E-I5C>d=<(Nse:^Eckv;G_o&m3`yXt<ʾ1у9mDee8w n,wW 'uT}a&1!]#BʑtB.@yPVo#PW VZ'l>qL<#C; SR1E;x.۹;M0xAuEye2yfBn *| L&$I(5gWa6{XM̚oq.٬I -b g 嬴Xeҭ9dfPϮsp#Z0M-j§.c>n)|px;xaά!? 7qCS87[]mW#k 5;9ήPE*Dҥ#E%`5 k66ǎxڝQ ;cv4\m_ @O`n9 q+Wct>$a4a}>f#pPȲZTko7+(:}$#A+?$C.t&},B}f6ߐvz 1{z?=m 4,)r/-~,RgWkXgO>ȓnnԘYЇBw?nUqaڳ> aBunSlr7:?qt/%qw3q@D ?ڔzHr:qDU2iL֨Χ#>ϱ_o2<:\Cz+Ӯ$taZiIlaY +TḩȍnCMZIV!dٺs-eU7Cߪ3Q:En8=WBy ;S.(%oP6hlsR``Tªm.b`Q9?<%j|#{Y|{..!Rk (V7N\Y .$e28ߐĖ MrUnкߐꮫK!/_0O?2OI'n/wg\'*XuAcM̳+XdI~y¹r@|y)9|ΫG,>I-ּO+w;TP^;| twSWXq9 '=.9?5v. r\3f|sUЏʃ=A}݇q%ܫN#INTHAL?.ͻ!XjH ?Vui\X] l h/F0~_MD;O=ʹWuT=S^"< 2Ux5Vf?X*|qx8Īu1h b9}"V&8=l|=}}/ѰZpeA[޸}{MZ[[\uh#^7>1 'Gp#'):2I #'Ӡ/aRΎ:3}QQN]wp6t9o59jգoU~\'dN  jgؓ2 o5_ɒR{/U- ~$>CUF~6-t?_/pNGr8N8TP6kZ7ך!)r̄ T׷72TO/Y{=br\+V@Z&M>)R.V˪o4%bb:o BWJϔ6_ji9VsHeOV4|9~sY8 J"Yl~@ow5Z0tg=p A:Céry}c6գ˶mC]~]d`UHmE,2wB)IW}ҽ)gZzDo PzI6O{n<,prۄk>N.XʯcEYQ~4l/d ekEaasյ|Uƞ3Ld=Gv/;9{\^CgׁdO}3Mi,ˑܟ2e2%Y }Q;ySY4kԬĘbcƟHB- dXs_&zZW'ouV'̓4-u Z$f9\en مP9|iYx4rO3Zsˍ)]j?M E!kL ѫO]7gAyAeރTvnc˶7%Wm uVL-B'=n0o'ڔ3q[<ߏd5G#VcmFoHy\JƏ?FzE]sg~5@ +AM\ /ghu~Zlq,#Lڃuhk-OĴJEZÇo@_K|v-PBr|sHXMGѕvW,]J7gmĂ*HT5Ex!en2fl_bPΙʓ3s<+<9^~DץfEͬeJ *0-ڊU(GX|˳Ior/auS3k6}4B6m6^$HƜJP*$r:{#kyE+ FN;fp[G+\VܳK&_=nsjr 7JU{tc{D]P}Kڥnzv}0W$Y9# A47uSF+}g⪄hl@7OVv_ÌO2S`\m]]p ,} %X:rfgjewO-404NsYꜹZS.v zө ZQ+] T jt{`:cخ]^ f[dwvLca/Ցr%yvݥ5h}<+KO?ߜ?WsB]zi_dM>{C[v&l==aV fpܚ;YAbh"xabF(NGtݝxpbmQr4ֳI˽#&Pcbb=wJ{Iiо7rf`NMe6;lD쬡q A0gząJP1{ݶ$zoݥvvC {DN3'BWдz\U+$(7$.Olf;:dwkftcHnZtka"-M_{546 ']gJA\Es;Z=.d$]U("~z/9B ;"@u|2Lyv7{7?Os)3cȗV`*Yol›0za>dG5==WNUcUǖ@sq٧"׎=K1( Ý ūޯlUr1*a SJ`>k)pݧ=⊬'-J @\j$٥|`"^{*34TcUtյz CұlXv+S*5Y>]x}Pã^F}(7֌G\ ڝؓjŞ&lp87IJj endstream endobj 171 0 obj <>stream 8(1S:Y =]ZAH}Ʉil )0Ij_KEx}')^>ƪ1@6J M4x:*ˠyvѵOqd9/n!=jL53m7n)S?-sf45nTC෠H]/~$P<¢Sa&`*Z>R뭍;9g_2[rl~"/@\.^ᗗU^>).=bE=cUW b! !(2ē&=-SUwR[gH}8tMSHClZLx6,y8'5LCkx+Ƶ)b$z?4>{J?P]w2{vMq.[ljt2?ѓxsN%BZǃ:EewoW<M^߶mR]/\CEvP{~D(N_ͱ~!PȪnսƟ[ dX`g4>T{~q\-eG,čvmݼoF^g뢆_I)-VȝZ_:VoA` J>>OJ;65sI꒗flIS.=CTOKMذU[PatPӑ,۷4*0ĝ÷X}ퟐTs~|OVEAU=(s?x𑻶>.R37ʧR4m({9yy4I"W$ɑdexܮ4xtKL{.*{ϒ(ߓ;'ɲɯ3v68ueѥ|-0e#\l3#ڮ.)فhyR/B3YKdRH>ҋٳPIP=bjRJ4ŵ4^K|yf:y NXx9h3>tf2jcVޜ@I;:X'\W+aa6X.EZ=ֻR 3_{c26mVePZNbEWem>ѭ?;93^DVԮUܞf T%،jqlI!ϼw!leWz.d󜾱^i.QHwPtn;/n&'+q]ƞ&XCÍn?լOjGG? ̦L>BdRT6\-xL[DϯgtKmEt.w*;F9s7W~PXTf+ >'+ךfdK e5χG#LNzY^%QEW*/M=NFl<*{m/"h '8>o2iS^KZx xp3d]zjlW}do%evO-&W;"n$,NCiWI9b+ڶ{oҴR{\ w]}[VOe Oyk<-Mw+ `9qfrOz2.W{ {d*}`V{.W0zнPĠ*"}Ɲ4sENMy4/AwK=M.[DiԂ`=>Paљx2{;)S=S nF߽G4mW.:bsh qeUKUޭm>ZYpH,c%Yws]1|jVJ#L1[]y4Ѹozw'Yw:ɼKvVcE ):5A{aTfӅ!]f(L:~_ϓ JT ށݳ/5m^74!iSarif価-V".ɮ$CӃ<}:eja-ryNݨt]jCywP.hIZcoӂ Pî/\h<ˉ)+3ː.v\Av oz޶TߖxRgw\-5.Ap؟I}kudY%oy&-m ?D(v K<=&'Tsv73o.:vJ9yҁ;>?ޟH]-kvjvJoqCy ܲz08qm M& -3߼NܓHiT7{x;hO6_̎X̼b{F7Jcc6lc(,] }}:Fߜ&3;(m3Eo"S !׳$5`)e1ծ*-Sj-Jt͎~<6)jh{*7O*ٕ,( fΎ 9{d=̛-л>c2! Cf߮V4ASvӍ<$*6lG֙j8g<|U/oTRb0:ּ)հ)zYdkG}rH'p0N,-R'k4 p G}v4boge|/PbCuz_kԯWwz U8=6Jd(RHeop߲Xdݛ 'LVwϼeQ ڳՁiau'Nк>%*iuX\T\08_K ] {Ȭ_ޢF>j{Έ(+h7p5j9J.ՒH[n*æAB^gJPg?'NrBdU8<|P/dQ(72;pd3 -?!̟' ?-@-(>&(L((<(f:({(  y:ǁ|,ܽr0LZnڼr䠁cEELنS>{'yp˧A:temto 8VUiPcS:IT[ϱ΍}͋ؗ*Ųj;,Mb sv&<:J57_*6+Px8(zs#x n{[mn wx}O~S˅wX}OGėATbMUp+8w 4 ("zjkT?56"22s=(;$JCd)F*6{ V4׼<('`@a4¡rF #=?=ڥsl> wIܯy6 l~%q9{ { -ZP7 ~ PI 4K׆XӄisQ2gmRUjmlmQ9oHmUhNb+pġRn\IhU%VsG'ʾV_S+G1n+UK8y统ۭ@/$l&ͤ+{| ǒKRU6hC̎zY,z^Zbl0|jUsf<@qz *8~w4Mѳ>q~ ݯ K~::SJy(3;Z-3^ïm}!ƫ̼OFjnδjotɤz|ŸG'<~n`KR$~n"UY ښGI?ιd`2/!,-ʔ\Haʍ9~lO'Բ\uj9eck"O :H"$ }&@ݞZaWX̡y 6F|,3Ȭ]:\qꬁۄ1(Fѱ;)b.׎"irjsA_E+fҗ44U 7ӟ`={yچk v{ݝY.Bvvح\0 x?4}HNLtdv2gj91wmS@`p؟HzgqCȯ[zIpyf&]W{M{,ƞΦb6ނ5 2ZzAT]+Z5Ic1Hֺ'աfɼԳ@>&}i[K3p1*3brJӲo\g+VLn^fT(,sڹQ6,OĪ9-qԭg 8ó|Ă D=bߏA^E@>JEPRX>rcrg`ÎuuU$;$õ +O_s` GH{'ȉ2V qĿf†ͻ߁_>?B?3$#X"rX {'ߕre9LxQ>0Bs놽s},j3ǟ>t@ٴ{+%VL{t,|zٝߗ7{DdVEjnsOsZVA]kR"J݆*<9\mpJ,'dCQ'n%RrEK xIռoghzoSF}϶,"J6R쇍l4N;YV8T 0 Vv+="+xoJJ G{DnxY$X"Ρ-S.ΰs.Z4ex/hSn0V>t43LWfqT|v+)RMLR۝d;yFd>m6n*Jn/_vJWp$:MGv+K:R}͖7NLZ;ahb«lqCrz7yn XȮq5笚etV#~|gAw؟H*>'W=!d)ky81MVd9Gy/U4%D^ r(iUyB@-.Fe4au@Bг^GX;+mUD:t:wZIn 瑴Smnu͢R넺NzRa$9bC r7FoϱQ[ l-ͻa,p!z5'UlB{7˶`4luhNz"=SoNO\vȻn]bwA4sj@iuPG#+_3fAl` mnh]VYɃ :7n'y Ym)۪Be{KG—A&M˯8buYnk7~N̆(#oa;"{PGy|G TJΎ)mɳZ7[". =LCwk" ӓ,y}XϪωUܐ;VVJ?։ xqǂaLڵKJrEJOkF~H*dfҨF+0oh2`Vl=w5W?k< 5e\?򜔗E 5Y1N%~w!Z^C߼] ~?bЇZ' V7;Hj,o&͒ңF5$!Yv*9uȴVs|q /&;MrCՈ$?pԿb 2d; >N&e(heD|)4<ْ/[x*ZC//Ko.RP(Va-Ϋ0w cz#;lG/?Dpj2ċAj-" kĻ\^BU^**ܖ0fw:RI_S蔆A~1>8Kx.ʐ POX|&@ J 1daaOlU{5c@F>hI@ %6ĢV$wk) w=ߗ`=J@@4ܛHD*nH M8D츑wxD'tn)=дKp"dctkL_ 4ynN)?IJIEn?tt#vbaIx@೚Hv=sm%QLt>DwMt\<(1j0hԫjasTjIMq. 2bR-?1Tjԅg*IXov0]&'+kiEԋNw aԮ exjjԲyKB>O,u{pfO$ݨH.N{$v8|;%jW3.qȏ.7N>]}5b'lQ/iKq|gwMݑnfQ.uQwrԞe8/7w1 %i_o{8J5 ^Yt/3EQgƃ̫Uy@K}睨v\;[1r$ka/Zq=s=E8wYlpw &c?Z+)!fO:ρBF1W q r. \ _Z✚g΂.x]q|p׈q@f߮,m2?FOu:X9Ru] >'H&NOj>.g+syRl׫? (>~689_7LJ5I)+mncr5+,՗XR=}&mT%OI,̈́LzՂofKS??zחN28{<{ލgv[umdqC XݡztpT \bnqVc޸mP˼HrNLt(Jn_w:]{4#P㣤H<2Yݼ(8by7r#|ZܚlqpgJܟvZ]wtqi2{bv `y~ӨhY)dAwe̵pKf_3Ao)N }^{HBjpܫNmQW@UYKT) ]3]J+^os-^$ENȋxPDQ;8>sZB DL@X[®sl.^ayleΠlRky vޤ~qUE>=U_}'v~n.Tjc 7h kNJ9jy؞NtJRn蓖njT[ʮHCX:/l{ LWۺ;ӕ[z z'H*{u@ ?2FpV u+_m$i㾽t^^\uI+W a-OH 8Jv&k=TnD oo!Xr\&V,,/9}v?kQ3[(.(97t-TgfI޸r[w;ư5'Y{u$ Ŧ3s65+reb7`ˢE{ }Nݨa'Q-i{O7Z8Ie,iFCcQ[h}e'+>zZṬl鰂\ww>$,>7VSN;'j0=Yg[ɇ>ls:2LNB6ޖX.q<|lirC2~2ĮF ct Eҵfq6\6rYmp{Ie]>[n.KؽwmZhO yi9Tc?DC+   {F&DoPnS(;Z|^wjƚ7)ny}ֹz\ZjN|ߗ{[ǩG8|G.t5*۹ڬ::t)eHob‚< \Co]Vl0xBQ5RύA!!:ZjUY/bf(A 579[,¡`gW;0?lLw^'Y> Aqq"^ɠ˗O^lFpvro0nIiӵIڙ1Gu8MǨ+ fuU Cg OW 糟-?zŬ'fގUCsǟ|\cʷ-9RhֈG/5bt(u{s/Q 'xuo=ÊUī27Q<0+};6հ G.:vyv-ޞ`9$lA`BΖ{Oʕ{#x*2/.+lm[B#o& 9;gtƱth#( ,kr>N =>x"XM?521UC_"e;Y<(5a -NZL>i2-и0 &&4h2\N퓉$L>*N}ȕ:[lj|D٪߇N%5{`6li2/QÿM\k{Đ.M|VX'GgcK z N`~O Y^$@rOoraV39K`aϹW Zm+CAR$_x wlDOt~.;99_ ,։5{!(*q@*`_4NfDb;&I|V#. w[r(Rodͧ{.O;Džo/Q?oDcߩ4PR@I:B U\ @m[{'i ^q1UD hnt[H͢2q_y 7+4y>mwxԣۛv%eO/Z I*-+K>o>#a>6ˏg?cQ+uŀ/=-,2 9}ӌW+7s&˳}6ͼ׫`oH+9 -C'$`"v:i]/zN^ =U 2S~OC@/co~u裟m{;fH'4OIoI8*>> T,Rt~R&l֗6?o/u ԟ3'4qy79ZvLqVv/WPp u^sP?PHLҳm'L/<,댊l?lRS|.T_{Hc޽4܃-"[ymU՚kc y-4t͐%ha̒oH Jtk'0Lᛇg }J6s 6 /^}u\7>6]i[Ϲڮooic&:Di:SVP͈t[wvmD L<@Do9(&"m8p\?ȤN Z05b t1Eȓ%)Az\%t6]{*5ba.j; Ԍ-ִVStm$# >of㈙[8@e%^E_`g~I'R]"܀byx 3v'xu{, s S<q]'AVR\SC)S8ʼ+ik 0Abd63"M8<`km }Y^9wOtM9t?V}KA2/$MNKS`0Zi=Y`\dJ e' Uq@?0G >iv7PoH}NLR/Kct^w(`t3͍͕We?g槜E]Pc|ErCf,^F̊(`2 K7̀hg#n>nZx2JfWR ߧqw>| ;\mz^IOO`pˊ\<߇\Ĉ w鐧{b GCoYrL9{oɺ c]fgݮ0K8{^ox@ѵo~y˜Z\MT.Q-{1M ͪD*4,&{5JЧ>GY\GOt7 Z^"9v*4w7B7!Kc6fɎwܨR.;C aU5̂$0-wP$爋Fa&tCmsq2dwLiȼ}[Lm@f! јLZ~v3'#7fLϗXrmY ۳wXYIbњu,E9B.J&/(vXr5b|.Ёrib;QvK<ڧ?M'&G= {;nN mׂʲzJgYH;=#(7melv#l\@{ a(srN@X5N};j,e&AQtL13ZMY(<đӕ˪OMt[-8%Z4.&w~̼9D]8t^EBL\+RB謎ɕ^N#rhR0+)Xđ-&wVuͦש旗Q A41!1"r9[`:нuѻʳ$i]ysԩPh ڃ "Kv0Fy<*=Dtvtʁ ܡQ\ɝ|a%Md6C9ӄfHN5,݆2.}j@ 񛇦=Й.81&K9t6V<"u{VeYOi zIszіMw ]d65N,Bocȏ6vx%bfR(.pro(]4}L}+eʷF4ݠ>z7:x:*@.wj'Ơ3: 띳MV4G bN;yn(BFGjC#%—Op2̢ ʅr®r v S@1D$}l]b>'*40eU >H̦oH*H(:Qʏs[bL&6 s4C@ +%=dV6# BmOȃ ^ Uy0o@ (ѱ|u+K7UH3qbaZ&c<}X{-8:M|4^Uz K_m}c-lxg'V葱ܝiGFzoиL"|Y PrT9(Bz>?VޒJ$[q_*=^.8~(=IlP|b2f|v8H-~.)Hg]1[oS!id"p&#}we9gOE?g7=VN;obYGʷ´I\G{Uy{x?ye':pku]~;F=BKf \!rbϢppk&u|/xy N:wu>ꭤK^ށM{krC+vqMW=5m1sچ{XϣmgV=O>/W|wQ#)o[8\?כ1mZűs/Ǚ5]VvH.]q.Y18͏i^jYO~bzV(c˗'sUa :9-=㶋sK]YvG}m,:36Y/+sԗK΋!;$K52SоCm72z9= PCfE]x1vc !Ro`_*Vmы5֟Zg-skUneV֑ 9ciF;F0MM!S(ߐK Ks& -W}v a;O@ pu/Y*/XH1]). n.EaRaa,)ETu/4qh!v3C@Q ` ͵@e/c֭δS*."W*bz]iӴ ;Cqa{24Ǯ2cJ+ص4lprk5ڵa&#iʔ◿* 05<znvt[=ǓBܩW)=b [uL^'ܼIMk9ܨ+|[:4n7 =MGq]X _m%RcYSUd Yg4oEyS{Ϛ@ 1O9Њ'% `HƯ~tk*cFVH%3O7$}Mtulx.:]3%>q;((ƷȮ]\u6UOk^#˨vފUkFi+!(ɹq!!WE< qȾG?fGťTIh>]Pru-J7g̵nd׫+|PڙjMJmW3KKH2Tļ8y}H XT k&(µߗ$XD.lrqk\k 8Y}b;,LNS[Z'b=tLŹDeySk#wD#* 55qcysySl$xۃfcI?Rڙ 7kjwm9-G;h<{EZҷ{^b3/ї![^VgD%L!oOCѲ:__}%5 S'ؔn^_[~ek&7j+t+Թ65av%wPu(9t̫߬xP!ZE~wW3a7ꥷTJ`9n! t`W/`}q  k|--5k+"onv2-|2KǑw^>}BġkKt^w tjljR62#dGz~;Hw)M9-4:9jNofzVhvw/bySj&íw;0fDPuDbjեE:8MH͒BM(&LtMLCH~btb5[V 0y5?=SR>fܠvOx?m1~r;8oguF]4Mt&e-F'w;ӡk_C҄yoä@oGt=&,ss~g8A>铕a5)xOsv(f}`6\>-?Y1KDeS{OZ,Id`fζ uwtHkuJdkSb.վC+.}݌mbfI ݭd]m>aW ep{VkYwTJJ$d)Ŭ1X??39| }_MϽzt󙏻(?ʳԆ5Тڨeh-9/q䱝Yg#+Y~Uk_6b>| `mq9~LfIq^m#r4:`{- ӢIVpmЃpm2d5[ r(ozbYy$]2Ǫ0u@2o1V1>đS?Q~d.U.cX9]MռݻrI"iMD 7 (N@`g|:cb3q3⬒}Ǘ}ʾw^{{ը3Hi!7KuxԽhEU(>盳o|=zd5>4!'N@?`#MIgQM1D]ޫ+f?WxYnHtO |PҿQ;o24szedy]'@@}JeEKtw *8wD2r?TK&ϱG%b~?d=׮\_OZփ|\2ç+Zx8>h1rm ^(9g\״bũdP"lY/fTAMl?C|rNǞcS!†^$>?<z^VGޙ^;[:tB ǻ[%꛼mZGgVi>$ Z,j]끬uAiW8_|$o}tP1\t;c%Je9FyϬO[ex a6UV~- U_T$q&d#/ټojf8疮\prG 9;~9;l["TVUfVnRnetkffҩu5sok>NTR:-Vi oIK"PgP6}.o1EҎEx7(bR_kYN{6hsf@4co28ܤ`S|g^za-dL U%xԲWα }?n1INii:H<Hw*ϯ{r2}q{{g]6'd8%(?mV,F˙xˬlָ 0nG蔃fÔ-aCVh,<.t$U8LCc8i͹ 6'ZDY/~%\  UCɫ\!Sez ή9:s{k]|l,Wmc|>]nɇ /<Im:IqzOZ fSu̫^\sff=f`48g-]M N;˼[ t&=QmV9zMe Pgf GMοO2r2WyϬ3fDnb5))V 6r`"7Z?Y$J;j׆ɞsu =Bam`}M[AqRF}Zp8͓$:}hz_>7,Wg-ۚbrv0||.qI㒉Tf<xFH%SypYGΝ2qH-;'6+U3oںT~qRFfqRqŧm^5>1p:TIswgωܓOhTF ~`\x$hVcӐ[ʾ]|>$\hY}T9|54\фΝxO5qU;7;"b]ńþ'B.ٔ/cxqtY,ýh}vB-v3 W7Lw9,8yրDiĤoߊC[7j뤸܍ƣÆaYa#I >7WFcFjc`cKt{T:8"p=q#zY؍B]f XTrqZm$ԓgC=v_Ή?"7}Qxy z \m s7gDǃyLA:ۍ z7mۥ+6q'?X|TPs591]c\[Cn_ٍ^{ aߞ,2v} ZxZ:{xXx~ӏ;964˝Vl})Sq-j"FW%`o \PQKإY("~I=xsVʅyu\ʺPgae`/qEOkh6NAV+e ,b]z@-;H{WrĚqr=PfrZ9 jvƭҎ\vޛQoRfPfSffGSa⺸ V607?NE]ZT|֬u6&,e@B}l<ŗ93^-W2NZ:g;v"2`M'aٴ0[nY#K:GttYoffW|ʬvǜV8764;JU_>&:7>mHә6voXiNt$G¡N"B?d4Er<ͳ6xhͼӲߣ{-GϛQw/mv̱ER]&ۉgY;̔\S޵dGNᛸP,Q<HOM|S45Wm(޴ B/~ 5uVغtZ4A(=١>Y9ݶ׈i A|gt|ȶ\ɓF+d ݦ,X4ZZ_"wɞx-ԁu{HS`GPW jW t;pHWefBڪ6V]3ua$@S"Tz$ܾ+ A@|sە#5Ze]Wo)_${6 0 @aWS$06O6d`8jP;KlUc{ޮJbaJQ_`8AJ #4}` 2dϳE$ixI~%\c6=&~lw<2Ht@I\g۪O9ڳr#uPojҤJҔ{k  c0PdJtd2Q̶b씈kZ(>x = qE="6 8/FI?όՇ/@K9s iVk6r!uh  m F]7.>Wu_eO%p.syP,)tY;6nyeN/erKu7/%!IOT~f8 h2Yr<7[-W-VDoGƊki/ I!DYwVq,䯻&6TF+?0^#nT.J[TJ,gپ/NZיQȪ&3aVS;ݗxwZh!PT,t/[ҫK߯+W|۔7mIM79kD=vv3NvӃQ8{-c䐽ttJ=ϐ,l%ҲW2k?YZ`duz. IrP =e6l盷F+˂' ,/.|>n~a_wRQ͍*V44k 󟪁.*Ylrve{4ͧ,4- կC Pql0F/s+!&Ynf~_;[>D9Z"-Ick.i}i܆k;Om ir-N}5x=U(ZUv/:֞A,܍>2jf%GΓ~7ƙt?S { M-UO&ư}o GꥍQY O}OåXUPU_ZaEmh)jB+ՑV N2i=vHP*-Ms16}\_F50 Ҕ/QK!0/ĉ!1ŘɶrB@A=S-iM0I36EѪِsOJ{6L:63mz!CAmzħ#Atފ]!y9˖joK'చN*Oљ&l0k}jtܚ)}6ȁާ:SxC|wf{76&0GT&)_l#-Kkٚ({GJ%ksiDEyxzLLQ+,7m4}7-8|)_$gQ]o6sɾ?FꙎ?ofjtڜ;t-풺_K;*a4\Ti" \|KA#6:Ɏ3 mLSA'J'7.{8' Vji?mtg\jZOEr,ه.vv=\uk͋ ԇ.ǐֺd mhVII/ޖjwv,sPVg\3Xu''t -b}2VI1:\^/҂t }s>N phæ7-`Y *0$ X|v-W[N{3=xu/ ¾@PeD;h8جgF/gqdUS*|/a17Ik^< V^=;%{,I5Dum?Kn:iaRd!~ȿӛn !@H )1:|/41 K0R?^ B/;A._mf~E\y,w,+-Q\Jzz;z`,Ȕ|D հGk9XnThz" Oއ^q&F)]d5x3|z^v28Ynj(fЖ;O"w$'&"@4Qe;|28GÛj-: aϤ ܶxM77-p{fW?\voqWBr9 ?n# fP<3Z8Ieg.5=:immW6RT|* 8'tFVsqO|7ȥµ)HPZٕP1Rc}0 ZE8.V"ˮg`5nZyerڻ'l^{ e4j)nVy*9 r@p}t.<ܨJ;vo5,[UnVT LuV)q;R9̽w<bqeq洱dvB~546iڍ%?-+3`\ϭeѽ*iZv3SV(Vʲ[\ۡ\INzWD6(2kADS =yÎ+MLĦK؆wr+fR)aƸ,z>%W`@ܖ.Ui:&mTEWv!36SN{=Nqu? k`8UkFeV,f k^e(,!ҟ96샗ܯjb{`CBZEde6k^-l/o`O.I6SlFb$Ti^Zqo2dYM.k3hO 5& v) /."Nڠ?ΤPڋ0M>&|10&Ʉ+l-;\$l ]v_4ιg)aڴg7;oT@; @X0K;$*MK,g@ݜ_;g''\7D06˄_qܑ( +B#_f/a 71ar߇$,ܓP-N7@QA?Gm@N&͠ U4A al9q#ULj6뻠ΠJ/KO<.=u j%My@ W U?)nB}'n,@4;hcUFvok=w1iRx!j8 OٺG-r6-"{}s(sGm~DyL=H0 _4lkA. 9 3C3:#Xа@&PP{ fHLYc͞ rz~Yj:'QǤg(u$R@xΑJHҪ/i.=\'O*QMh6#(⧈|I97W&~ UyIl&'uOK0S Fۻ[Avtʺϯ[Z23AMC&NAmfBb]y*+Do>BI"4^1x9dsy9Q+ vR\lwaSj6Mq阰,xloryF<ץb4 0ڭAεUc*LiJ\~XFY504m?t"|S0ܾfT[۵-LkJN 6iiUXz;U8i 0b(m*2(PCz>*gNf1"7W?j9}ϙ/ڇ<[Փw)v*Nv֗E 0ۛx=˃L?(WV*JkFe,DgJwX&YI`yViޤф(;/:^IkV2Iݮxlo[i]D3b#sKэ%44P+*dt9+lyM8·n2Eg\h@s3Z7kF_dY878|} ƣ|^ TaF6σ.j8{3jE\„8bK,ͧ,%(9[ƲX3#ZFkyr蠛ř y0Pbx~gh&=(OzDďڙ[]c|`@6Z{Bl89>tHl [~Rsa{MI]] ,ΡpZ߉ǝys&WnW0l{M6j*e엵{RҧvoܽZg.?;2]>//jOև6LQQ|[®ÅQP5xOL6)9w ex.%GD1,1}՝8%7R;w(ei^LyNө;r<{~HVU*n>oknR j* \r9f}Qcَv+1|en.a&9yfJfzެEtiKpKeۯ)뵩6ozWjwJ_~_"jVeoΒmir^f-Qj!ot"fJ!qA6FS?!wo=os=~̕»<-m׸@(HZ 0d.=av=io=Zh"w7:=mwF{ lV6/#;Kq ꏨ1eӊԟiIWO>mjIr&^@/%yTq┟7nJݤTUqb |].H5P Mt]b$OQ'!&Ar !A_J Wzy3ukPkܱk(9U6*ܧ~f|~2ҭڥA/}hX/(c:exB٘kAЄMn~];Azȡrz. YS {V|L,"y )L!XQBUκN9FB9d2;T-';d__,"cIĞfnAYp8Dpj6}᱗x- ) Q[ Iڇ4g;k) c?Vv-{wlr_ys&QxAn{H#EpFFr NK҃<4?ߖ\H!.LPY=j6jb{Xxp>`+Ȭzb37F/Vok YtVq Yy%Ƒ7D$!E@ <`&' &U$۞?F-{m.וr|K A5T+}:*2rߛÛ3dr)5fIIL8MTv -&$Be,{Oi-ٚv??3:y"ğğڊz 8ᐎ֒e?b5L'lYJCщνSzM"q' ]Ky]&M=^-s9[Z9%τ'ş[0'Z ^C\l.jboә"ؽ2ISR!=o$fv_|qf՝":@ jZp͛gmA&ˣتq+]Yc٨束'c)JV qҌlݲr&iz,@j'T& ^0R## @\]s] W-$]Vơa|.Aҭr2_my#l b1Ѕx.\ mI`DMB4?/|K8^r7=LUi*^vOyϓ.@%K |B?/iZ:ind_^e#6u˅ދg\('šl{(-rt 8[_8|( ?pwݥ Gڄ|W ~'q^$f)nuEqɤznQm֤ө3X8}P(p~6`ˁ=jy-)6 ]&s_fTzY3k1@^&E?y#It<(eɍ#Jb=LJ3c~*`/F5Z7mFq;#6 OϲVk}|,#U̾T@})>@NO/T=ׂ{VغmUaׂ%HF$Y̠нw6c](^ExrRd/rFbW ]Gne<߽浆j֣?nSw_v|x^ӵ,cƚ319fh^Ni®K\ptg7'{PYԑzK'A Χ <rnPVl7`i'6ضœWx1\FG: .5ܶP|'b5=CZXd&c-gno $VzQ|ƣ Ok.QׅZvH`B3m|`c3un"EU8Ɠyg5IoJ[fQ֕i;jhE&?o(K4^[:B͎{B^3k̚4̖=tD1 ɖQ=rN+2>+}'ȏ!Ԗ!IXH;H;~9Cv;c{8s7KV׻s(}ir6uyV3S}{o&R}C-]9nNc_m#Kli.+ɏҐQ0$Gzϩ^7;%~ɘ`?'>W+"Ji%F"'`#|ly[}|YZ>@uv^f9ZVѬ0mCg vN*Xl.<6䣼{UWNG۳˙`J;K_I o:c\y!f11|_cVz{6(V+uJC V92K|gz̪=/϶ߝ,ohճV'1Y&`[䛹296VA鳜("R^f--bSc-4dI.ײ+`q Aq̉ӎ`?|~!+TDE4Gw +WcPx@OQ ؾ:lMg_MYsjώ4ߴ`8 *<'Lbߧǹ,鼵ِawr AӸ*&Z"Z#GmW2#8ua0Ls0)LX\'<|?lJŚ?kݩǚ k4{5EIAWA2m艒=& O ol]WYX!*㐁?ʡ"~p@X` 6e?uY[Sĉ&Fh\V< U겷 RfPIUJ 7/rBrϾ1Y% 蛶ï=gMgug  !yڥ .=⹚A:Df7q=Zu؉ uXP;ٯs:HoWW+qZj0'59VaI Dp}ף_'4G [hApٮ>AA54\uQ3iҽI G;tamy5l-˭EWuVAfs[6dnb:SIOK|3؎I ENha zo@éҽ:P~Dq\'QO"hoέŐOsjM!e6Dq+sRW4+Dd՛Va[ZGͩ 4JC͞RjWao{ڬVKƴ5G D׏\CfZ ۚkjV֩b<+0_I5Nff%#'#V_\o|Z?)s^}cŹH\^G:5o 0ߕ\4Tr[VfHVrrJ')ݣĭ;w[.JuRAb}uua&+{(j T 4vAFٞJa5h\*jXvaqRMvly}r Y#'7 ` Z~E0iڗܮ\Ky }rRkꟺ9=כ\.oD84r! xsp8^8F#|jPyU[wS]1G) bCy}rv~G"q Z;cL_e(Ji :cy\ΈM *c}U`$fQmނ݈OE;'=[#I66ʸI*UfbK$IZƫTP- AWAC|39JrͨSH>ꌲP5"L>@yw.y[-yӻ|nɻģIz^dqƥI߯_$tnla3`F<.rhG>W+>`5*5 Uw#nkO˽enmϙZeASY+佧eГ_]˰+IN_?\sɳ"䳡D^z7s)L.6[.;Kf1=>$H4JF[@Zf:}m|b5J>bL)ȿJP<9w^m2'ˇ|^< ^9 žxOQN_pӷ 9oJ;3LJ," 74\g,)\ ''NGdzȗe_>1"&qx^L)i1 ij8HJck#vt(CvC4'|4!O#yZyr/,v.iT±zC3AolHly3f-J#¥&N]WjbUkW=՚YtId+.c9ͳ Dr/x-Vsq7bNOKK^#(zX5/֕9x qB:q`\ܲMp'T;t%h#;|0}H%u:'61VGu̶oK#ҼQ]fApT[-CU\^ uiDxPoak.i9zsP!Ùt׳!wZ4?CV owyzX-i[T M54_cR@Wr);QljÙEֿ[OΤ~6lt̲--I,s6]Yl4,D#,h~fp"9\֑kaI~9nsT4D6L.#8S5ǰv0}[eg>֜w#幊A+D,S[rv?d ^U)k&]N1xl:*tbL\"%fm|3f1=!Ǿ@foG5 w6{4X*Z'M GMdt l Jx}S! W ox{Dre'Z' Kw61(6j2|GS{eGZ6KN+h@aQ%OQkldgyC;q@aLDWIn 71:dd \u} W[HH$)[ٗߋi3y=Mɬ+U'㋔ s S'ܥXA6el_/;0^CPë:oc< qnr]:ժXspJm{ZV}֥7@d$k" b>kh󡍓a$N"f=-;f.4dǪfqQF4ڟ@gcz5̭Hw Pنi7X_=nV~ƺNx2V($"lD> WEN{Y8ri'bnyȘ1M$c'3j~1|W友ǵcia숾u92aT~.R4²:"kNJ4;-aE~Crk> -_-,/Vũ(^H4>:JW/* U1#6dČ[(NV/,r廥l2~t,ZZk([].*)T|fBֆU_q_>7>( 6Bw]8}IƕM"igtry[GΆ‚DUO'|׃ᯆ9jW@{2 )/z߉!,+21:8uotyC~~xRpPL"HVE`gb/vXYoyyT~} |]VV[:hTUoe%s$[b`87r}k% ۽~Qp hE{} Yb w-b 8Qg Wio :"}WW{[}:^ηe{hQ6nRC[2]5IJq!8dWŽCaσA;pN-%UhYOC^?5v1B0/uZIAtPr*92(D_c:MЫZTϹeA>`=되?oU}$:? BOIy\b݋[y7b1ƜPżf k9bE UbE|0U#qiTNpHû Ƿshc܎YX+zj~a/|1̹AL~@3$מ7SBґIrO4 A(|$vaBpN1z[ꯛgPd|PD|iPlmV%mIt%<"kTBM*n{߭'>|&T)ؕIS~#hq~c5W0ym?.Ɗςګ4:o֫]W'1W27Æ~=ZnN4@ۇFb!.#65aV _]=(7!»z wڴs[ s[qÒ, /ondpcTJr5M:}q y*M8;A9"檽{΃&8^75I`-\lAGz^G-<)-Xij ;/%G o3ֹ@{n0{0sm+ :H҆&pzQAg]ᤃi;6D"|[Z nYnl#i7Z/uKFӠ$7Fc~,U}?wM톰6oQ!p(P\|{]DGέevh4*6g kshVpkDvY}uI^ֱVd(Az՞{W{"7y[oIRÈw;]vgxi$gw w_Vkv}$>ZזU#վCJ5A>+PyXOdE8X?RZʥGr+u4qBi(LF}0ғ`(H@EͦU»gꗗ((nS6(H{(:q 4,XhrW0rNT6rM{;솉g\?S9QH%'epCtxʉ|X0IFؽ'Z\n@4Vb#Z,0YŰ}} A  h#q6N ʼPTUʆNiFPG" doϦn`8-u4R?¡[S!am P}|ЋZw0pL#Pco>y[2nRhc]Vȣ?R&n+K_{;ˀ{r0Κ].!ι^'ځ'U3'WQr<#2~^v^#E8L)sAwK(Ž_t7 d#p1b08"MwTuxAˈŪA]dɶ{a М;rۦ(3ĥ4PģHCӜP|-jGp8%. 曳򘳦YlZ/mc(DItfG #Qy^2MnQ1W4sr\enII?DJ6J`:ibs>e3#ꇆmq dUI?Vy1 =4T`c?1h¶=%sk6@.TZ(B{"#9IMI͸v7X*~֢R27C%7g/{05jD̩t"ݸg7^, v"#|#;44j\kE|IDr]|\^LI'G< {C}7ap2VԹ^_Xf+E 2@=#ptGm.ts g#$tJ_=ْV7yɠ:OB/bD/:fp߃u"cp?RO&1oItoO![%/nךUǔ.2Hv[ QTI@ɀ`aH\ !~~X;c55yb_0A '>X R30-쏨{;1ܲUD& /u V[nh`(Tj&vΐhLz,>v$,nt b{3t d pZSn" jdɃB 3/fU~8kh-m7â2]Bx 7:s)R.FY22!/-T, ñQkW"Y`7h.Ks"J0d@6=aƢ W7 (Ǥ3vw_I067r&{ kW6:wPIc^ uI{G' E0诣ʢ暛^ KP xfv'۝`Owq:Iuc96DgS:s]1L\whzw6(Q}B9nX8Hky's%05jBiF{g_5{>]/2>=ՆL6$m4㌶z 杖,XL*i[&^oո;3J<[k.kPNJ~俻v;k6:eHZ PwehXkEiƤrOCk8>ul֐nX*o4Vz7VBT#[ñ}"hrvP"ȣNDfm/&?^ĢM{~8_[vK"Q'f~o6M&p۟4 7of[gڵiMgX:UϭAzJl{"zAO֙N9&.ktۃv,i}׌ut kGp$Z՛n:qhA¹_yv\b99eWla\&NT&hb.mn<..:yIk}@=%zmߴvш§]SO^y_P2Y/.˹-SKK +VכV\oI#Qa⒂8y4C8++;{EYHb/Y( u. F~ݰQ^\M+gE/CR|;y~6}R+i^ 4_D3TYsQ>00Xc` !0R H?&0bFsWB w"z}7Q۳&,VUt+@,ȭӗd!z<~<|piBrFȜ9S*WV"ȦelSٚ)Eq@Idܛ̞i!n'ezEe bҟfw 46h/~p[m dH/r]0L*##d?q.{BIVk2[hSBQ?R >*Uaq\͹ 3X-wfnNoqq8ns?G Q`( @Z m0*KXTtD菒d(yMRRƬĒ8q .^y) A=,v4[v@sѽ:{S>yk-.LjᵹvW7EɻQ'ip$wo+p/Yt\8 _&=Q|wOxO{}ZoJZXZn{=˵^4+]QOkc>FgKi}3~MZ˂H9#~pilk9oiq*'Bl@/RR?]GjoEH(yRva=>P[^~%+0^\`#~yuߓ!-%M&g$ne<ח~u5~9cŪ.M/^o/hhJ'4anl} K֎>ZL Dymւz?-Ϲ*0=x&N$%fl +))tUɣ/zԖA\7Hΰ=_;XkOS ᵻX4SL!yW+6׍-Lqy~sJv}tlXnL?Yu>+5 )`؝ӊqpY1FNR֫fXU%?d6/fM~ }5 YZ)즵JB4Rܝ6K݁uKy/Նsw/ǾcObz+ִ}68˹[QL®Q %~/ʚ;,`&tJǩ֮4_$Xzѳcvx,{7'~JzH4P|iil|$lnzڂ3hv݆lT߹K~ښ.U{u-gV^-M\ 1?N.^]ctour­cu[%xR=4X)/μp5׃#-oK s^Ϳӱnڴ[=4Q-uZ0{Z.Tqngų*a1q;?i(h(P˦`[M XB97cSOU.Oo]Z>W]ZGg$3:^a*FT:`mECICDU#8qE択}ŭl&J,=y*CEtKyH$!,!Pw,a_V/qN׿8牭|Y^[[`b/YsNj!~+*x[BQzV-+KWhHEy 4+E- 2jPmn+۝6+CK7-y/ng*%"}@<=Zw $#8CN$Ҧ I+C ek=i h }<{A]n\m,X9vRՑN ?ѽ2ޝ8R,8NLs &v l'[t1kH{*Oi`)]`#@,++yQ.ln4e7 9RoJo"šn< 0&.Ee<2ְt6^XY873ߡTٚowy]t 踎0 #I՝Wv[UgwޢQS)[3}Be'HTIo:.opo_o+ %\rONMf:3u(6IbTv"x턦ǪH'|fn;9' br%II;6\jX>!}or;8_X;@46j(W*1[R6 ?w<`w&bMtlݍѥ1wЯ@cڝ.jo/tKdׇ:19dSMT[ŞzўP#֞+!rvD`ghuƥ*bڏMް]녻yM87c{"f|wd9~IWG.de, (A|(Vc˾ΠV_FlXsuna^o@`q@joVXWp2&YuyϚ~$Py, g:!#~{UY9W@M!+A$]gkIQEDZAPA^?hkS?Y3##ތbvOnӝpS+aK h Y raReod0WJ,d8?#5ՙXGmʅ4z; ZpL^K>K}B3+J)& %{ʧ.JC+̦o{S :؜f>%ӻ0#܉ W-]=];Zh%G|<(fO\Pn$y}>gyZ;8*,[ݏTf GcLkmV0hd0unNs ɎD~N;Jk:z SK,I`L떋y8$pdv 8;ջ0d.b17>3;lU"BB t̐ԅSEuo]Hqp▄2<ڰڨõ5} 0EzQwF|@=ggvǴ=Muj<nxlK[ʷ1֘l,n TQX/!W8D|hKˋ$ 1/_3/ h~73,^c.B5)4X.+u 豵R'BܳWEe%'Z+A^"1$9H7De %@E!(5 vnR)>鵩NUN6{$Fkh6#xogӋJ18s!͜.;ưg7}Hȣ2!jW-9yhf-\Rx!p3x8n})RLx% x<gh x |ҫ\?˻@3YAA~ 돀O7L6ZL0zbi#A$v OqiRm> sҠݒS\=k,^µ&y_@,J?xsR 6\^^bΞ #ˌW`3^7u @̉!ny D?D?q; gąBHiHc[H HI H ,O19=Sx:^b԰q)>Ǵa<b3lο s!Ճy~N?'? ȡ0@ =)dް ?*PD:q%߀c(i( q H.'I*Ъ)$фE%I*E(E#&z!jW 9Ih}G[ͽoםt|_w 4p|wg);4}=I6"MZi+W>}e Ʈ$%ѐnklݰ\D+I-Za|΍ܟqLj2rTRaĔ܃vu)ڽS!yC۪$ JU.D˾MOm)w9f7\'_ O#<({ftt$DcsTvFvww4`HMȆX9f]]ӛV_|[ir\ny =Xd;uqjlr{Aw ~mb 9Prfōp)JEFr'Hr9lKi/$oRra%mJ{Ķv_Ypɼ>ٛrg]Of,/ǭ]3_c|>x|dϻ}j^&,h SL f{ߥTGKju=MuWZm32sG ,n7Ֆ>ŶZZ{1h\l6w3K랧1;0ٜ~z$ʣ3ҰGluo΍A{*MXèҩ b+Ϫn֊7Ө^󞣁MNHϨDֳ `muSl_[ƩՃe~J`\⧛_:ʎ?qi݅2#92UW7Ӱ}GW8QY}]#gss uNBNXک%g8hosaYq_} MsHݾwzQe^C&)k~W7FqӑPa \+vQ=psQ}k|[H}VDNaaMnD4:7WU_< e^6?5X-mƵL8jXyŕޝ(g%E>h{jܟO^/&F_isפּr@4h݃m,2mQ."+Im7Ӧ->mG:Yոw &ʢƀ.٭~ٹ_+^Y_8+*mڵ5_2akP\m5%YX)NMS<>z=T+3tI(0˦6y]"̪.;htLh;< XkM=w§IQVsuuM=WXxfbSƙwy#|5&ϫ6+O>?3k[+Xejӱڷ e/6B&+L4R#A?Κuxrz0=6br^5s-qA~=cpֻfb+}* f0hZ4'w!umQՖj+PU~>wR{hd[^l$}L]}ZFb=Y%MEy'mT4?|aԅ*Y_;m9`ܩL\)gQ_HjAjRI dq1@ei]2t}Q-,]֣+̨[tUUyzMeD\EՂJf PJI7)9;L*#Sj. Æb{' BN[x`Nx@ӧk߫(v Dl65w`$t Ű^)U^^>j&`'Vzsi/JrI}' AP)s;Ł /1sBeXn+N<NZV1'4mlwLuԁuIRel8-\ytc+~:ks%S^(&G7_Cxr'Lÿe̗Ǎq=xF>0Zή;I7.W41i7v?xIc3k9?FeyΪQUV}Ms*q(qk v\)~'􇕋hA,"7lhTF.=n+͡ę.:|lTԀ5eJNMoBFkʑai=5 $(|#7<>?īFYA0Džiȸ @3*Jg]<9AÀbgpij]}~#{O*gFZ23Cln]`o X>(6 oX^]#_lpم:FPnȑ:޾O \fGZv>*T\4dN ^X,0<`zב-zҤT9he@BzUv(߀edr؏PzߢDtWЍVׇn8*[S䂅+ = y(I4]ah- 6QTҰӨl%7bi XiH]VA:_2鴎yA~Uu䑠ҋmՃ&l.+7^:7~B\V\opPc ?[a_T&0DhbxFtVԉ*%+ *u`-8V/X0DI~nnvRF9crpEddˢ"AzAN 3|poGP_ Q2lRt/?rr1*{KV. JH%+#qhxerHeBޭ݋!Nj!xCla:Er̥0Z#``` eIfߥxFvX\"+6W^:8o+0Kף늣d9g"`u%ŠboUR]!1po'S|pjI1W6/Ӑip8]>N~ag#x,ŸڲIkNIGWpwUqrbWCBtc7E}b{ `!K@(n| `%Ho| H+[8E=xYY_|ѻPς9,j®s`΃|K2n;@.nӠ;@ &S - {RO?+ NSƐ~ەŀ+)MyGzBr;(>F\;W Mj@$Z ͪ3 HHg d*Բ&y>2(m5P \bA(Mb!I$~hDç):$+zOjIRz {1}A>#me{/MnX/K]Sq43BOAFƙ[AƧS&'qG/BeLOѪxҦVI Om9c;Aq̄a7U\V4>[zC͞SvN:?8%|vA;{Wv{VۮY:7TvƋo<ň=45I.cw/nu=zvrnCt>&Upq}Ў27ʹOغU`C~j4YKP짅eD}x* +Rү;YIl&s}~vsυ(e\j"SOq8_;4ؠw}}4:ɚ^t]Vf6bQi̡~џj<]uo'zı䵣% )i{c|}rtLJGeO?3*oעhl1Z]s8XqkwIdPt>J}9ATŽ3x@ܦNH褒xS6=&6 e$`oxivLܷ}du BD$^ZcL{6@U{d#̳`S!Ɯ:;Y_NI?ƻ#c0j9D0q!Vw.<ب=KG<Zg7IF/{jlz\3r1`٬q}M72Ҙn=b4 3X޴oXKgB?զԤ{dy+npT-[KzPd6T@>?|U일[ J{?)e͌4H?rw$](k(Cs\%Pꦣ%Unԡ}~Wm˟[ݽna1baS{J=&'Μ9 <ôzqm}o3 \8HD(4-ltܣIN"RKv0ZQTø;5'jfaܺU݉wPPUA)ު^zpUY;_\t쫱UXsfdp uܞ34{NwBYwhIIpj݄`O))R4*댂'+ǵcvn0kup{qOɬo9êqkZV3KeMўĮm[筍˿p$ZvJcPh$RGxgb}ojxl.V්C%k\|=qθcOUZr&ωs^kccs5sǰ4"4L1< ;HȎwԬs-]-UWט˷|z޽w<jnР*^tx:[6YO'Jf 7,8 e_2yZ+oh|Vx?~p7sp7/Ѿ{^f475\B|e7hԫYGGӣ,oh\=dΌW?,WB)Ʃoi'f68+U cDڈ?CZCI.ӓa>퉗5y D>RE>F_͆&e;5G5k6);wV1^bƯ֨vX.q?[^g62 oJL4!5"'^U,#PN4^.ҸF%3ZaYJ2c^\HpLkmguA mmeïy>̆xnWd~Cв..K"MyxY4 ߗѢ+b_;aP^x3.~c5>,q  C[B8J+0 *_kd)IDѐaD&& 7`|cH@+zA[F=?]pO3@mC!SLM@MP9 }wS'rˉ쐃6K]}a8{}!BV?.\qsT)-K{Ǣ)Ϣs1C[%@ `r009O)b0yC2A v.SZ!:kZ7Q٤'κum|J6Qnlgusfw+~tQ7 <*`ˠ ت9l[E;\;Rޛvzd4K6}n-? l)&P>k>VOl@xѡ: 8Xf'V`T/pnju:3|%} ~^M1n>,_G?)l}n`Hw嬾dY27!Օ6w"@hnEZ+U|X9ϑ h 4& 3CQ _xk Ro A|ٞ"xq<ƅ𩍞0))=p{Gufթ 1s&ʫK]FĵHBfPnUHIϫ/$MD2JO=uK#ت[F%7T{&&ilMƫ=pJROJC 4XӅrSt*ji[l'<:֋!I귉Y((Kv١/!F/Vdw1T柰`ϡ.pgFBHw֑xxg1ff?轗 T';bˎiW*T>ȣ QY(MVc<_$v>kי񨾧ۇ'cѱr#zJ67 c1 g ^~ fCsX{˓sT,Gnm2<;Ŧ>3'^_\&-|c|ע~|X:94 \qRVa`EFۭJk/t ˗%E\W"ֺw:t3js`궗\CE'iBHgb2D}~ƹ:jB'eO'LZ3wcǣwʍ/N`cV*a/gD޲*wtԺzkZs:M5s^w.X/ n˾}h5F 뫲 +5mЛV 8U߁1NvEL`Ar,k>>87AV-9Q:DfH% A Fdu|jjSyxqt{ՕZXLL]PgJG löyZޱUmWwVs;&8-o~Ɓ|}hPMRmnnPih5; kf8^ajOEnrTwSBA5c-\56ЧU}180Q4)^TAAܮ*o:+lv6*݆3JLb4[l3Υ׬ڕ T(8Xܻ"^;lتV@͔ږk} Չ7ͥI0ͽ4-L@:JF*b-2D~=qnFV#S+JAYF0*1㥶$2k}%E%[ ָywTBLبS-5qTzA~l:7ۏ+w뱨NKUy]zhyd]2.iTT韔ł+*%S"W%cU^]Oӡ%+k8, $8_W1r_NEUMT Wەk5}x6G|Z|$Ϩ,'%Ϋ-a柜៼ҹ?b,5v8i=58uN>KX ү36ePg85%FGAuH̖gmg87=䟍ײν;7ɛP+Mn(cnH_~dzxZ,ԨhQ=nwoEn\U&JqnfdRq]m3yX"s~;yޟ̕,e;gʬa,d_G uY)=khި/;9(.6j`V t$XW<6(OC_ŋ7ц9ad{, ;4p+QS>FȽi+A.7d\cՙYHcW >eg#Wn4pCv_~D'KF4ܡҎl Xo8hsbap(eЋxү:"O>.>gAQMScUer`H&1ɮ8<` 'M{K$vw@QC"K6zL6G O+/iZqOu![wUt(n.K^pI5+dV9. 6 Vie0]_oyʀ0]s,bbK&™zC3Yfońz DPXdbT+>S䛧:τҾ I? .Ͷ~h.mM.d#{F.kz*x0Dy9>Nd[G0dR~ʇ-V rA_U""4l~-]V:ܻ}XW[_sCtɕ=8${Y}J9i{꽷57;[q߮Sumeע:7Vw5Nj O2g<ȫ<0+] $DnVsN[9~Rܑ촄Yc/rYЂ8:73knN _"q@ ,KQnqZcr_ӌP䣗NrOXˆG!`viy xZT'ؗ[) 礼6Rl|@e;@=O 8u䨖=9F61EϚ%%K4uJE̡z!Gt0 I=?u_z-)._y O&e@+j h:S9f]mvsJ hA -~e^-ͅ`٨ctc1x%,aKBC&΢z+ ^sp LO;pedtd0m6̭L*"YZNqn6[&`kRRYV&0P-%tgK!{^޴RՀGʙfmC+[ '8Cv)o*r dc.H5m7[7WJ;+;ڀ{y| Ox蜍ܖ=FJΏj?nSTq-4 XYm^G6FGJ ȿ㒒T@^RRZ4SiR 7w}K<»𘣷|_ӛm٧to +Ѝ3R+n<ܟ8)zx9N?f#4ԟ|3 +Vd&36c7w90_a\b5_y-}!w4wS.zzUso;M'9lm o.ocCڦ2ϥ`?GqS-MU/;:~F Ҋ?m{"~>"޵2M&&.UP,ZH-i/7_i/zn_(9ƙ}F13ZBgiD!3W] Y 9}-wWꭔ)X\.|1 hneՒ́4xJ,&2=؝n|sx GJͫ 7ȉpgRBٶZTfآXy#<+09[EwSiW[Id=2Ѐ \R|k טǏ vT/de^Ӽ-xW~vwcbsc]/>,:j#jչȕi+wU'6 0y:i!4F+.#ߚ[^t`Q[D|n|T}14;rqCXbpsCfԮ۴]>8ji_G) ? {aX`&qn>>=*~'kv } U176*oRsބ]}m/{]Zk.5[(7êIqxmm"CFN7?sV+i#} Mp7ݯ>jgx;=?^FR&%.ڵmmЪ?G^=DƱN8. /LڶGl +~'_X RfO K:i'iq {~gTP_>%^8_eN H 07D6  fshdd8qv:1XwjXO?ocEK][9抩!2A͢|gmةN#祠zNvx;g2pN?n 7zsm|ZD7N- sqs̰lب'C^fϞN}+|UEmj{Y=nlpL? ~M;ցƮ3.U^ǝ~#ugÍ2o+{y;KvԤƻvWGv Җiiqys!faD:,:5dF|ЮtD1թbuQnkHiɩj1Z7~ +oq򨌓Vv/Vz^N􏇪QRVdx]H|m2v0T] #]묗Mn3r{UqP LƥRA%3+a+ˮ(K()6bJy_,aE10ؕ$[3\s/Fy~.CULWG: 땉[[̵g /Ke*ӫgH*0KJ3娐eKخشb;k22Q7sQX]=UnUQYYNb.>peA P˝gn2y!Y+W όBڛnc4!s ٢(L ԰،wQ89UB! ^!hi[éCWE9u[g=t4OƒiuڡMuMl&@y{՝GcYƫѡ{Rn+/rbmu I{ ɰOԲblrB sGEd+lukvovyFYoP? awm㗧fM1\wjB6 rΥu݉ -@jfK$*nftšۄַ{4Ȝ)2^+үERHk'~;~AJ lĪPr rl8U+k gWjrv>Kq?&$_^fTNF,XNxPl4'&(_^NŦ`2)a@ӁN IZHd=H-W]ڝRwF3ytAVHB,-WNZ}Jqw{@& Jg1N6}˛]-;FB:Lpxo2<܄ Ll'yE̋q.^Pgݬt`k&ؔ gKVfvd'Jh~k45dDieN00.'-2TU^2H |-n{;+CZ2 wI9BW8]r!>-+[':~">QR^j"{dܑk[bԿ,9gS^5\k5u~L)Nq|ξCrSrۀ]Ȥ5^ \6zVK2}7yk'>p8;SUg;&>tm(,%bW?ɜvיH VXb̠+ rl4aAlf5Mb⊐)9ElR\ր7i@v5@, VcDYI45iFgՊu=v!-2E 9XxnP;!3*v>-4AZ?-+2\aly,S΀|L@PPI[P)n#@=`y~ڥ[Ao>,VoR+OT+RR'fxWNcn_n86,ʀ5r˽1pxz<#)n5WyHq/ﵺeŬT]JYF͕Xs TTĿ:y*R'5C'?J? |n +D;X~QbOS@ljAݶR.zԨiboBӑWΡÜMF9?o1C2< ϯt̽ב :w#(e3Q(H`sG;i?o?1 c6a ka>sm>||R\)v?%Y{m8Kw W>)?,v @E;[ȧTZ Ou9}GwOnO~Wrbf.<{=K Ӹf?f|H1x~/]9?[oblș4ZKJ|L]Bmӟ6>w$K\nTwc:fb'Zho'ѮI endstream endobj 172 0 obj <>stream ϶r;om}wzϫ[[fdazRl[+3lsxL#L[xvzSKзI'ۑ!lNvNX%-,?ޣTb.>46Ϝc8jɴAv!s`/.'?Iȏ!;>a_.Xerns<,}|p4Rܖ >w6۪w6 8Y8dY|71fŸ`~<`!i,]JXz'oBEjԏU7Br`'}o%^ٍzu? ꇓ:wpVҧBFzN[^>y?R_p^އFz Vz=36Zdt^11L|6qqSuJk%6yv?+pC?]Ϯ)ڣͅQJӨZvSA͘Q(\TVoYm^ ;S2eݍ;}ݎ sH%+g V!kϭ~ 6߉1Umڼ鰇kM&vF4hť%GbI&Z@Ćᔞ 5 &ucb;[HO<C1unU|7Fm4|Ѳ3BtZ/+FkR.6(;`/:ktlNP>Ԧz5m$v5+M ~u{ճ+,oo+wZjaZPn!lTvV:SVR¹QI6ִVR`1EgkrzEGqz8}UJ(Wc7?xU1׈j|U߽Ulu0%*kJJSSX5n 5K1WK)lRؒ_6ka=4ݒ?ڨg --ꕆx6~SC!~k̄j/w@ƱˡX04iVSjgT/u/%]rOLhLC:d:Ŷ*F>ܸLoV+v)y$Rɜj!4jT{PmjSy]cZt|oeBb=FZַ̔P@ʸ! FSn=ȝfRN~OK.=܇Ebp{~>u ckȚϚeW՛YO5L YbXM T~XmNHv Z\C,|o}ZҚŜr{u$qʊK؊𥓿D~'rΗ'ʽo\.K\XYf DmwΡz'h|C9k8.CQ.xw? F2aZwFgU Nоly ^j]Yrձ ]~1krva0һ ~"S͠ڲRk8!Hv^8Kr"GLrd?GrV' YS6}ٻdxM<R ,LlXNQCPj}KuĪ/`gP`\b:U.s}] ]l\`++uIV;&̺{~Т:QM>Lw9 (y`4tn v-z~-^'EBVJ ˈ 2xCݡ8+C/e|I5sR(^q3zQ>-\ 1}covb~aWUjķ G1>1D]lZ3%Ẑ&LXC.W:=H GgGkWJr8W"\=3'?Ouf^3IL1M2&}3O!),Vj8qF&Pf!#;4tu NJ|Cj&GkF>1^.t켭 ]dVWRXp  `oJѡjV?[I݉` X v_ʼB ,#NsMG2'o~0+ˡc?߹q"x+DN#׀(o@3\6jR$Ra:?,r'} y{wk.+"LIAX[O :rgܰKcb$πTIkLKcv6e@jeg H)N,$*^UʆgVO Qktv0 +i$21 ƀʽ&"EPH?f5@j i9=PI5}uޤǥ֌O+ͭJyTXvSQ^?89!> 1'(5|ﳈ-9+@8=π^_+rNJY0%LmR)VO ^[`6d=L8~W"Fpǿ%AO:!G5`63L1@xop/~y6Dž\%u"`4t'>Gu9i&8 f ro7T`]*5A|LFCVC2! g ?'\1_Ds%~>6m@p. xpN@x龧{^< $Py-@`XR!)#Ǡ͚:l۬oc4aaF &HqvHj+?ρϿR\q jak7g I_=X~:qTob = t35s FEL[ȶ|+dΙOtf CV> ue{R.}g=/.ZHV5/Ǜ =۪~/IYo3do[?#tӖA 5'GUp : B=XՃ`er~_}s|sQoʟ&Cf'K(Fa)Woe'@;4 rWC;^Ԝz̭t}9Ku׭չOZZy'.*>9$ak` 68Vrn=roHCb߹3ŶN/Axxaw"6 ZirsLK[TWk4ȫvNxbodǣ^hz]o:O]V V2gf0Ҫ5a"3W )f[x Ǵi k߽Lgl]iػsm6]I,wMGX~ЍVmtNOaFBM냋"宩Z/ޭYAnc6oq͸׵@,c |kK;,[p ~vp ~X<; _J9RHm8MnС::sCueTfW5HO+tfjj^T6D7P F#ޜ;wV˨9V<SS wQ ݒd[pӁqlyC ^7:52FnNV#KeZBjcЃU:Sj=̌D ݂xN/9+BOlA̭Eݸ7@s.7`쀢x!R;CkSKif3kux+ v~])Ħ]Bj[nw&&HL<'+L"BN=KMsojYwLQwsv1v6?J̲uexa#'ů9kͭb0.q V!5T^ǮˍE֐)vW_;P?9rƗ̚{摛@&_xwq0]`,[6cʝ)S|3tW-#'nv )$'T۶%mE?5۾nᆰ|r]2_YS&)^:[]Wu ^My,3ќ AGԔ[&N(9A,mJceҏj_-E,yw-t?͈<oD}Jd[x]^omNUi4_V5P`]eRh6n' ib66o )V1CgHos΄%$+R6SVHOl$ 8vo=>6h!~?0;B?Yt0BT1۽t?J-xȑqMmlUBVs12+i,zhH(iRH8#i-j'. /,Hh+Vk,xb0O_lJEP>ͯJU |Sxf H^ezWq~SHkl!c@B? y ׃\3ؘGux #ՊK{.~ȢӑE?X|Tƨ o/ňGUN/d.GIm(^sDY-hNJaȝ*ѫwL|ج}W0z^aG{SHъUj_4~Z^\. 97> NkaHNn -aJbݭayaJ}OZq߫MmMI)+)t0(E]]@Tp2ܸw͢ ˭?zuJצaA U#;?; +6w#zo[Hn$У ̢.|8IQG!~`jO(;%W%Z7/mKk.?l>[#|^}&We}w2-f3_2kԤ蘵ZV)> ^׭B< շSz nibxν+ 0#S,*d7E8yT.UB+}~!YQ08k6/9a]tXMB-HbfkY52#u%ObL˥U!fPZUes MqV/ǭ=2 OkҎ@+)w$-ʅrG2˷BnQ C-o] c;Ż &P{ @= | R<~iUJqbf1S싀X }@x,⍖ J~ , !461LȞ X;I6]q=@+]@1 ۯ Grȣyz BP2U"f)v7@)=PP< (l#*ǙK4!d~&t;~#}Y Y~ Jr8ա5*=LX~|s#\Н ݷ~7e`z0ئ>A-0YMNY@?5 ᜹>T|-{h|Nc }7"Roz=_C׿V h#,"N\*fapYR\xŀM U-w_Gw䗻A1*O=?2,6Zrޟ_ٿMZ;W_PECXKY_]~)":w1[f!뻍˫+sN[\T9_)Uζΰe.`d* C6섿@ձd oAP pS%}~.ٯQ9qfˬ/}Ósϔ\%EOD?x߅ ?@y|g#C69w2Xfy~}6C}Vz{Zl5*_+nk,2s@j$uhI >B, X2[k咾5^aҸ1׍ٹnGc/Z;eSuCUd2|~^ރ~d{VYF Xو;E{0?Ϝs|s3@B/ J;73ŭ{{nvmZ+N#&7m6x7n$ CAƿeJt) ``M[/q7NGAOg!f=Pn5s]jCNk-x?tuy^y}OIس헫I>eZzVw')ŭ&gi9p)`tvyr>thCeK#j%|vdlUQd.V`u\h̰*ۺfP\l:nְewo;;X?,mAn~7E]6=nq9;[#Oi}P7V2 V9^ fށk~kE+7֧$uGִh4Vm;e*+y_*2*t^c U.6tF;Y7I8(|'-Y#=#^6Lc] Xo:xu|!Y1QsX*h=wڙJ٩7SoPeVxVZy"ӤRj@Mʕ`dE6ҒXK 77e'mp } &_/?$|'8)-a2`G흧yYΩvW3#f\KS1Mrdg갶ۂ9&3&x}־y,adixhh@žYǤ%ƗчgEl,ʤ_2x.ZKy]b硵67ZS OS㹦W1lr(,s YodzWfߙdJׁkMJ:źEX,ȑɘRd>QYOnѥZԕWr Uq!Ww9IC8tT+lԧcn`Q\z`\Py6GU )V&3 ?%h7thNSv:44ިIO|TMY}([!N#JqoY$W݊DӣEL«[Sk[)8p*{hEC`P.f^u뺮BTyR3shP3F95~f4{jYG26 sy 7ߜeR~qmLȩ%zvr vr9'Cvr# )dkC|VmMNxpLV_T9u50,< y0K9Б 2W-a!3Icp.lK #ڦ.z2Ylik~ȆX1A=Z4ʆ 0.BSw,;61XWf2ZgU˫Ɋ%9[ iWlO,6H+ٲPf4;=fQ7$й.ɵY$`}v5Hü6Gymu֠7{`dz;aJQeuwƙmvˆxZ7lueei$3REzx7 rcu(r]ZNs*uX8ݮb 1o 01vqߠc5Ǐ׋?Czn阏_t,Խ_;g}MfЖbL͇luzt)%~gPieޙ5095?˰gWyh35`r؋՞;=]F< wv p2N.K*C.C@a[CMI;YG kwZF/kF+HoQEO΀Yc`^ɡy)KOjܓ/.1x5#3< /yr*dhT2$wNkJ^c^cp,bAu5`Cb TbMapΡ*=ɜ̇dJLҲvҹޫ$0yEw/I'ʕq".F-A] kTGv.`KE'HG^^)$"~2]W]Xa%̣[ϦnZa\i%W]=k9S&޷<ڭ  :7ȠI\[CqOЂfcs:64+ڍ.B']99xΜBkOPgV:E`FǙJ-%]Z˪ @cՆЇ'Ҧg:p P;E -O$X2SV!Qܼ*rPjrH,NiLyGk[#C)}34(ۅ(Z;ڢ}i냭|31)D{8#gQN'HЫ$vU {@57-P#TsZT2T@M@]oYiv>|zVnՒԩ"6Ͼ,KnIDF{XERFvB|hZh'X]t'/]N q^3LpC]7*@gk2F`\\ݿa<PL{П9X'[&`j0yr}cx":**LbΫqL.HHNu::|-Gv:!Y `+ɇv_]Fp 5LJ}# K-ث3ց\e, hesv)X+FuIZT@uL<-fAp:\^\ nw.y&'{~ߖ~~.>o7Uw+:;yq0YcrV|-߂=I"Yhcf9l,o&uɛ6kBYaNH` '`Wb;;b< dvK+ɻɕZ> ]3[$? |)[k??e*#As9NM#[r]=b,P h?oy]ZiScf-"1|yOFZudګGHV=-5~/r1Q:h6ЈE,bk/Vj*Sy@Oo|#jwSĭnHAg_ dk7-3ֵsZ˒MdFO7'--fhb,K 9 YǻB|k=rjwٗEp_wۺCK}@G<Ƈ6#m8@M%Uբ"QtmT]V>F)Ղ di])'յA}w7~T-Ֆr͞iXaxOBCذj- f [kjUroN*,*9ܕJZ8lJ@+(W`[|DK1JǢ@i[F %@=rv4;O\z]m [BjM+!z.W_O6Q+vm~: W|aф;w 㵲{LG$.lz`|`HiM&nIa*66O> %݈a-`y*GA|p~Ri^#k^r=WFَ+|q"CwꔴU)N V1?Y<q)PAAkٶ\ܪI cLnb1X~@ƞyW|lU iV<)6e^e>Z+${}֭98ٓD^"ڈ`,+v\c23Φhl-qwoJDc,9r^ ]>wTuR]Of掼̭gq}wF²8kӞ*׮DLdumYTZo`CAN IZ3z>4a55Y$]DSSt{rKJngOfrwvƥPu%:)h,[6|Vs6C8 &T;Fy#_㠋 ]d#֔iT哢'S^z]h$gūhJϜ<Ν"y\/wQ][Fф*{P4`PuP;kGȲX|̒^:R n3r:;u-jڭ)[Sj OQAC^V)͎p(H\ߣcLϊ1cq cyiLw^.q]5#Z.~z ~Qtɣ9'$EUEODnAq,ϵi2lR#  1 2_g_%:W]dG}Ho7nsTa8:pLn*7q]o,n+8e,x9 D9s$U)rf'a{^\hE,JK4:͹Ur䫕, Vθpվɥ̆1`ϙζ_{3-'Wڧņ?K|љ7; ڞy,L\\uM۔EC2\ y23)ZS*+ʒ lj@%RW'*/.wߨkN̯@ìr̳s0}#h0?֛}lrm&XLq ޷ΞB5ŚyV:Z~T+`e$"~`B|0\{I>3%Vs}2]0=џK4CMCU䗚a"Mj.i&ysj~bLwP!LNoG}[Mqs g]J\ %L({wD${{iױ'1`#C,Ovv{. ^o mHrGXSb߈uaNbD澇}c[6W=Ro={ݫ|ZmU9&qU(Ű )c?fC% t*5'cESmy4$ݖXG(`DHwf qy^+=e]Om~/*^@*;/ /'BT/= Aa512zx-{]]$C{QDEvEF(2F$an̻"HȒ40[MPyɖ b\7y 7M9oD\'P< Y5ӡh23Nkl H@{ гݮ=b{]%5*xɸ<SYUz# 2~)'v_$rT֔c}_*wJ?/qoDz-Y ¼`@x>asv+{1JpQJɅ;{k̐}T&~Q`7P1GWfAk+=N53^Y@Vw+ ixF{A {n|׿\X:V" Kvڕ>vn"&-ZݎU;JˡtqGwB#g @>LaH;YeYK+ne|'U{#{z蕱rˡq,/2w: 9N/Z:8"|}2n&p M#%c=:ѵ6tY߈R 'm~mzqtGZz< a+4h* 6(GG CiCeۯIŢCɣdvC^uS69.WE?.Y\ Hfl㰿DZoø*c@2e~ AϾ\Sw?\!y~tө/Qݐhum+JK}ffm =gp/E/1>)+N6U˭W'MO-3mGF=7FJvZ#lGӯ?;94=OǬ/}- Kujm`Mm)T+R?4xixȄC(z|-~d.l[mqPdžióQEVEH;Hl*UaN[7h\xХ+uJm;KK(>&{|R'e, `l&Y)quc:tIue=G5l!u*!i6x]Cm?Ş@E Ȃ|ImEI&p|_+AAfP_!eN] r$$K)LfOFiIm^t Ş]ɧ0?ɹ@G#YCi^ʙhf4psky K-gXaR %nȮP/ oɅ,'f2_O>5^[ 3IkѐV>惕vIO{};8,ؓڞX5v@C+,es4 v 1 c''e[ Z['/@/?[k6jf9B]b%!?5k^Q3,j^ɞT ]+jV Ofv۬e7jlӞWoW|M;-3Q]FIFUl&Zl[[t׼h `mTu_@Q/E -,xeRH36fk8u}uu!sjgAjr-$V dj*}*2?C,1)iQe<* Q,SStޗF^Nmz8hxsR&{&m9Ppkڹٚ|f[Nuyn~s:Q,弒Gr-!aNIKZ- y(&sUB$ z>/EyQ[CQ~jNfV~c"t #Z Tb*tc0ܝ\Xm>'=iga)Ŕw-b\+R "Cn/,e( ;DҜs|;x|MwNVk6Xe1QUa[1C;1JU\夜'm{5p}?[H#6u'I,!V&5$h1Hニs?ؑ ZXMK=a`GXTJ\|ToJhenVF<Uϑ^ORLNn*'Zn;CoMiu2.af~bӢ=5+]QHMv!a sJ2A•\nm.g~Ǥ^mvw4Q:%!l"*9TwCx&b72́pAW)D/fۿ`pFhIڟ7%qg>wyRHR.^9M`x8#y(sJRɻ\8Mg4<8mh8'z]R <Sme"|=KJLfГb/2>`^+Ox?T.!ɱ-:gNY6 I`zA|ܓ'Y vb]M{@@Ʉ7K"UN*]^0IgVXL.7;#l2CB NyB/AȎw>&(gQ95D"_I&W9ynkGӾqҺU?yiOInq#tUQ*( 0.&_zo6^ȋ_:E_o-GjJЩ{V @y; opw8 u 9u 4Dp}? Eq9t:IIٿ5 ϩɗC&'/7\HRMb4@Z! H#RI+ށsV/c^;=z-4zE O??+?߿ox_l(s(ʷj(@٢Ut[tGznU 3!dw ֧ӡxFRq2\toj#t8,:@'^ ݦt%@ @; g=oY+o^Χz~dӐw?Dby_xWEwq7}:ZVaw]w1`|Y0%7* P{Ƀ,$GŒ4#|t?Rx'HPo|'rQ'4P 8fv1_{nO4n?ح}̭#lͱAэq.;x,DtէٙHxVѣڟ;ww k}lJ4{wٗ^ hKV.z={o.n ]dz<_Y B YZyA [T&UrGW  s=+.4:lo"0Sj1_R\칱Z4a,#>Spp>+;{û0Ulҷt|靛~h/38I)oO:ա[vX I=<5ٰE# Co(8Ȫq>^˼Ge䥆ZG{s5w#yOF  Ǻ1^ٝqbwߴZݰ {"zy[9lg.kyl?%gnjw2FϺYz {=\'M E/n/]?{c 0,v|vqmʕM<@f-^ZzX#i6Z UVNgcf4ܞQ\G?{a3Y8]ړJi{|cç nb|3/c3#t&>,e].c]WY)4[Io:m[piv뎧 gpqN5Bv'@|vxs٦I>-Zł|ke}?jfx;Li2lZRRj⦦R~wMpk9#+eQ4ۭa)yLʢ:\!Ԥ/tؖʱF`>$Ы. Di&1Z7M2z] UVJ-(&-Hvdk%ŷU $oWwPFaW 2^ _.>~;*%<*SN {BpV<殭ZLm}# .r+ădÌ\3e:A Q;+g4k& 1&XM"NnBq{$ɇ//G +;Rgm?'v6ߍsw&uLkcDS¡ޓWA.0zF~J4!(Xy?74O'lL^ܒ=pǂp-ue/eg32]H1z<`Ѓ,m)SzCeǂ}%l1R!rDKfLC"ecw5^n-]^)Ł 8@ "9gn3Zxu:gUb1~]VGw~qMZ'G,fervؤ?fk$ MbfY{]EL!)7>olotW%>h6ZiTܑgy_J}` Ī!~[9mkj[[ =5s@, I`T }oggۯ7G&]=Ct8FqO(!4uٮ ӕ{jzd>80gØtI;5<Q؇%(If,_G{bWŪ y=m<{TqHFnQ@qt@tT֪4*05B)Tӕ[_\c]X 3mh'W[P^IL&=9 )nK/Xc5aR|BFIpWPιmu6H?Vs|sBr>K.y [FSw[$jyql/'{==BjJ~Fe23Z&8{OZv@mTҙѳ~q/l5|ҫ|;+ (=dL]jQN v504]Ng \SA!ܒX9^.prv~]6`빾C m M2u%葨&hзvL^6@@(q?Mbn{'/uѝKuZ=foǃ7zTZmIɭN/:8&Uw TE/Pv,.t_ N0KO#UZ@;(lg~2VqAG vq$]jBwMD``-8GK0j w3! p܇{}R1;;Mނ=qm X0/[G/eV7;LjZ[T̑ApxnvxxJcJp~"Ϡ||%Ȧ^T|ޣMsK9Y^8{W({z)?މu_p>Z09"O({?} buG?o@ : / &d|ʀ~&/ 1]XQjgCS#[O2g=E+x/$'Q̊9$A f1'߂͜sg>,[dZ@b0H;xSԲ zS#W6bQ3CP`AU$Y@}PmP AR .(K8P%T9&Ȉ(G.8}V1'bq 0W9X~zLOʜ$6ʇWZ}m' |eGSO7g*ou6]Zz1:̹A/)z% ӤwF=6Z/L Ȃ:ߦvZ2Z**W E jXI1$l\KqIǬ`8Ex:^3ѪrF 9^͹Jqf#]}/2A$'@9`wR Þpz^CPM7\S^~4p Z m!ދl(lD?2-6k^e~$>NFM*pA{UQL5:USATd RˣF o/n9nBZJc54m4F$sl!m{䴶7⋚Ooj|PWOV\UνàrNb,ey ;+\M#9R5bK_WKȣy\+['V$AG!-6Sgfz'ت^R,B]`&] sY:.i\쮑eR5siR2,@{>|)+;RC~s:Zۯcj"q3uO U ieDJn)U7^ԗ|D&JW {~++<s{gaK Y༬l\3׍Z3zf\0m2m*XTUݴ1QFvɸhʭYMC=Vh;gޛݯ^rK'~8#M>噬R^Jez.Li;i %eqJL%? b\@C59zrܭi24/=. r={ŋp~l=~6u.279mvk~Tp_HDJLViDNfpP@|)lF}#`kS_+:A^b]˥I54l<)+ͿbNtNXf1 j]\,<$i2.m(fy {%1|tn+!H,<7Ar[VqyXqXc9aJfB;ucB9|~C,ͥf¦\&[-O$nBH鳂>S -Z; Oe |R]6l~ppd. %p^ơ6e&̦?fL#xנbfDa7vDrԘ،?[B%*;|\ -8CpՓtJ|b MZF?hfEj";3AO:@|j֤^Ld1O["L9t6 ŌNNxlj~`gj)h ^'<%9f>&ORXpVY6B|v>-&S1nZtVLd;[u6e xɔ)]-&ٺb^ɈfuP}l#~GLG 86J C~SPuW @t~ nKrW cR0cP㊡"j)5l,rtM }|t\j#LqN&zcO5bVث-c6s=riWDN^΅t;:kcOH%ĪW!~%t4$s7^:^*DF}g4]7 .ǎpirXC,4|<23cKΒؔ\;mc| &v!4EM\&b>]`[-B?;'>.!ix k 0g2;A6#mB/ez'Bg6sOd0gǩ‘ۖ^+0m0Z`Ydi6 | pxޭ h>=x^.{r7e|7xx33x&:t~rz'~iĀaK-d?42)>\4O!PKi wB0%Ta;DD@HU'Aq^0>7 Hjbe E7g@,32E ho| ad";$l#@\: # f U1cb*M+"71htI{_j%ֱjO^BZ9]ala@> Ї +)@i)k8qKHsp/&g[Ǒ2∿K]x<6:fC hk*{6;c];5vUȱ\ӆm&U㹨3 knG^8$ ?ZƟY%!EED}mHwjcfߛϗW'P#vra/QV]#~zCxa#~ӠT*9YQ8VѦ,@g8o!AeiեunON칺Ki;OOx^,:DcVD"!5Z,Z0,by'U+3Tk5ӫ/!;=\})yN ^Mf^xǷddmOm5Jf-S]wԄӇy?3F;-c g¢ uo^Aُ TO"Qz?g_jC,⭖!4ecm!{/*z>6Z42Ļ7XM*ĉ*j*6+zЩdY6;W.G rHT_ *j*-Z,Cc7<4h'*EaUJT.TjfTɢIlfr,`S_VwE8\iGmbJs.VX.N7S彩{A䇎TU%+ "olٟ҈(X1Vu2z$dY#?ZIJr綍dQhg[3<ln_zR' T\"JdOA:2ֵ|6u5eLM/mJ3Tp*/R2>-|}^pM.T )TM5*.&_zyTrTfR{U.s]\7,9_~6oipO8KY,"%|. Np^Rfn)97d }I+G&ڕ!A{{﵆.^d^W殶/Ws # n.Anȿ(W9^KFa!gt*TSin|X'|ă=  :(MUզ2䖲/& D'n;TxS^ye\y|H/R3'rrM)΍Kj^3ե#fX/i1uTg ޒ<\aryC'PwM^tE;?b&6c.^BPgL,V7nQ8;Ga PY> a$FLS$Ktᘠߪ6o,f/ps#y klNFX8,3.}[S]LqU蘈T!{\W#Y$BIX;)ѩ+ 퓍I[.vcl=nacɢ’:R="2QH/O/+ҡy8t>߳N,Jj@&Ր$$l2TD! gniLJ;&6|tNt36IM[^bn``9?Ec}afnGa`L㘮lqs dlq׭IK0xIL$4,yN 37>gT_hI>cX!F=Ul^RnڄpVpI VB*?hũt`&n9͛.$mڙ5[5r^a ǦD0U!;?%aGd5*ZU C YӆGM+ >i8qT E8CJ9 ­cJ-dM>+I5s'<Ϫ?K-GAjCR7$Ms6=Ju%NB//TB3 #ƂYMq7MP]?X_o"RR],t?@T)G2;"U9^t +c<ʩ9>0eTbd- >CψBL$!K"CZ@U>~|8A ,K. <^@6or2u"c&&x祴{2džٗZ[-lXN*'MJg Bc[Ze:W8APW@9PG(C #`5@F2I;"^.v }8X&XĎgdĜzAZ R srL旀H߬ P!,*cX> T‚:GqGZj";ݨy"έb ?'k-yzs>1a+p '**r%[+@&[@3 sq Bk4 9䐿yYZv-Ɍ@"{)b'V8rIXr#7cȂuA"o?S}@(5Ge'"5NAxd; PӁ(8H@ %%1Է,s<`b5T>lWQB - ~ˡw G"`U 0#JL>&aI$h .dU\kz>,`>#vxM H]m_ţ7j&YJ)8oCBSt$HW/5H\$Fk*V]!@(,A$?X$$HL^IW=xul(^ T yoByh_x)T‘9#)2os˽QHav,.[ZAZǐX$H H- H Me_EDP[tSIDLXuޗF)~ٞ˽ KDL k9e\dްȘcG8'^1gSҹ" d hFnm4#Յ{*#.{qAq PDg㥤W+hTfd(K.ٺ֟r8g`k{IfN$۽jmnqTĞ3ޮRzkWc 8<@7aPT ( lݑ8H8E˨\(a3[ Դ:=g} W-&by9n^Zuc'ՂeFg|z}U7zYiY\. fGjIް?$ɂbw~<ӅYXer=-uYX]{&##wlv 6ypVٗa?4=а(04@€f["óCѱusọ P] #gl6aW 2o0d +eьsʈO S#*"S6zn?e a,ݷⶆW9\/ dx?X@gW6g@Ķ~ٟϕ#C dl.n.f>)wQΦOO{vV_R5a&m5< i7h#?v4\Öj̄w"qm~| y/vϣTʥR"0-nM:$8$j\B D:;v{YGQCSyQ8ۙ;.~斂O)0M3,&g/K@x4?kë>SF :̀W-_7)>8Q_<QXƒ L`Yn/s< ۑ# v}Wj  ZȽ 2rd5s*z3M"}2}w0}4g.:IfiأxPF]^Zftoiiz7m/u`?Έ%WRId?ǹm7ՈUZg}3? u7KitnZQug\LMfߗOK1/ZecC}cQ&;mC_6`31( RoJoجݕ1y8(_ I?y60#NZ˵Q6-#>]-:d^U-s ;IX6֓Yߖ038/gmV_i]zPTtg0}*Hv}vnj# Eѫp3ԯ,SbϿfiT;<تuLԔ̖֯ncm'.~Mt6#|nJ}u:xUSf^ʒ2Xh]3U#QIՖ545?]/&c.,66gV>ȍg;ؔR'í5 ˗ઃVco,kׄ14$fClfU¬>Lxss< ſ(FYhMU)[e]h Kt֤V5U>^baդId׮V3Ҕ)t:sqY}Sek46,p U/uYo?`/g%iMBG%2 SXC֗>|P&dkkH(ؤ~#GW-lEͱ\v$yܜ5TW-D=Vz;}_LΎJ\]l!KB @3 jxLre98݇8nY?q4 g?Kx3! S8_ϰ#4)ǐ#/+X?POg{ߎ!-!Og>LFlo7r`IX4O6_Ruz׎o2ƺj|/Mg ubu6"fdm[!{3l-FW|P- Τѽvfs e&%>fTgR)k܎kilrg*1s< W}a/}qֱl~+,qҹvvbHuɭN" s{S#ݧVJڃ'SͷGݍr{+uEcjAk֒Z="f`* ZjaaKvuj3!twː tQ:j$=g{rLmWqiwk_)νRGSCigC :N2qV4ey.X++/Yiw'Ui\LRm!U+^0y\Xj0o3w'OFlce.B=M/ sR͆I{#oӨnmzʅZ'b{y3> &tFx܍06PT 9{hl ⷿE3U}T96#׉zJgk:-Ry8b|rݨM:`np䋇Nv"=P-yzx}%,obzX`ѝ _YwݴRhN^Vzkߝkl;pTfZ'r2ͷ/[G^$U\v/4r .K9HB>UWL%WcZCJyITKwu߻y(j?%c&ŧr`ȠJk7j홚j4:Adș87M*@x4]Mܮqk[ZaYܬtΘ/6A{82`:&K<HqirCWЇR'fԖ^^_jĢ늶C; ;@g}QX5jV弩jH's}3HUMƄ,ET@$B׽"]|3u&#(e׶HVIG^XIl(3  Hl[qV@y~SF]A5*i}^7T-E͔34j\]k_u'9'C?6 ALϟ7Eɓ;2xWPlU.;a cFޓ/ 3L{\k:' t:D;ݾQ}mm4^D_~%\aǣfu՚$/-n}}s^΂<).{:c4-i3=ӻzqYX@Rgp&T,h?Mx)~3J,h&Lz4 Sghd ):_}9 S}J5ːj&!f1Z0c]6eqNSܙ8b!SH;"ox%J4.Ð0o<' |lQ͊6ue[gg9hޜfP~8X;oA~- CQ,Ro1zo|ze>ݝ雋F^֮'ɍv_+ yk!6Q:!գL" o_ro^yN.$;mƾ;ԠyV:yc r5pC?/Zn? 񍧋AzV Κ$,v=XHǙ4vp ]OS,$ WwT*ټ@Ri ޏKFSSfz$Suh^z7u+ x!+J X%Om)1EޭA2#ljD 5_r:c0?PWuKC_T=PdQ𺐪Rug',/O:h䗵w-2p1ʾy5zr GE{<*C^5`{0{Tz3[O4Tx/שmDo'P;},v5 ;V< yЉ y*zP:Vv:,F w.㗆:xdZ>r Jݍo^cWsi93Dzᙛ>CʣH 6jփ屸N e.5D)u}l8Of/V4Oɧ?{gwc!ߧ(͡T ǐн1r6{d|jn0qyTl=;Or|c:&1pv5cPC_g?<>w\[t<S԰=d3 ` ZoLw([9cjMAe/~;ȁ!+VwZh ce+0{Xuz wjoi쨉2Vr jBJVe[TcCI-SWaF*pwJ*?q՟ ÜYkcZ뻣Ƥ6<c](ch_ԫ<$7Q#/ vs?m|f~:1zԟިfQ;6uzC:3zHL&9]2[Nja GƌEU])B#  (U<8rΖ638W;|¶`3>FJͥ5U_tr3hӏ>]!G*h7QIDt z{A`Lm?\3q?mݷ(bbD:Dopfvgv{vd8!9s%_x?-lwd&e0T:x &,3Ŗ7@n5.wX=<Y[Rup:gѠp-UÅ<bfVڎ9calTqrqj6PVa7WO~7'̹O3gWM^$`B  Rlgh|ǒ~32S-wQ 2qj7!kw3[h$/4Ҩ n d*7Bʍ8١q1o˽\xbKP "~҅Q-d_Sk]w_6ai@ Y6j\!|Kg<طތK*ZcNbu1)_h?:5}+ u2ڶ_?J{gtO"F>݁d􍙕Vo"P "sUFM ?PoV(NS0dCS;D7r"dڲWwOkJa2] }:-zjLN*Q{Z|编^Fy#urj[-ʗ=.WCsVP̓|Ch,Bx:] eҍ.|S`ƞt*"5&5(.m㐋|9~2)٧K~HߝIZ@DrAYmg0 ) lqm#ormB QO\ೖ@Q\ee6*.cDȍ}"|5+Y,{z:4"Ԟ,~bILJFC`:gDY*cL}7d=M'ƶShH{? bKT@Jv*O`hgjO ?2)f[hK{ap4pVw^h2nM6}y>wM9?ȭ@5~r&j;;mZʪҍѠꀟN \p)g97ye[o ΛL~- gweUG:-IL@5}yR,گn4ݺJ$xR2j)u{±#X ίuFTA,VKt7П>hahWr8Qsw`]ޛ[`m {r翯:_0'opQhbrby%TzbahT 8x[͝,wIIVaIkd*ʀd-@9NFX=jQr'FȆi[7kL"klRmQuYGA?{Jw'N_f䌩tnhocN'.Be~ *U[cNFOg}E j[J7[tKuN.5Kz+5]px~` 0ߡ=17 umdE ] RDb>y?IώA;3o(&bxы@:ǴXO&gSwӷW d#r+ eaw:Hx+3tJ0l0s&&3;O֥?)ˤc,[^kcC[},+~qVѭsAFzI{=Sl=]5rM[qeG~r#(n&꽱4 .]el[ڐYwaص7gAS3(+ e |>h|3Rt@&t|z:igi5w w4m ͉ryr{x ;}-m. n s_9?}R_) Lq7Fm8&?3H@Jvw#^iaH8EYAN, /p&8 {?Ml>n|J~u!eQ6!9ud&|vofS>u\Dɞ4Z=cU&>rf')|3,=֡@P݇4cDo'_H!eW*@&}>bdɌ{:nz¤LNV!5Ǚ[*ʏ̍i(\ŗ{L w_ҿ`u9RYRq) Q%T63Xpn45е: idKj}ɏre5?KTau ( $8)O(LrއshX|Y9@5i>׵F|Qٌ(9g?O=gi84)ZcR_vʇZ$OKd /;E|GEҩ=nՈV\Rkz{?5Q*QtdBF ӑ?K"ҹU?J:&@(gEo>4_RDq;:`2R'zN=^eK=A|KDiܩz?Tԁsٳ~=O'*D0)τ0B'"oT5,ʣz(-[z\DV# Ŭ0^8m},GgQDCܟI ev++B i/ ȧGmP7 L'8o]jYE eOy@~O#ƿGm>T#G ݌f_n1]>kY3Σ߭k{D]d5d>e81ba=gOpۙM+rne2tnnLuIIN6ℵo F3\]Q \ȣ-)zdrC)$?6/gkosLנOBռ3,տ#0 i'wb^yh㲹NF'6[*Br9Zhhہ'oV7L6^VХ5oPW(] bu¨N }WèaLi큁,۸^UFbqI]IEr#}=:IIW"aH伾`HY^? rx)GwVky]9Y͆i : G [TJzU>y` ظ|,տ R6&jd0X,{;3']fpY8|@'w%݈xDAۮt àe2e_xxO'گ D&*dWfy ta}R6 @Mry09|yĜۋ\ѲkN.|kոnIԷtdbJzΡi8i7n|L] )uBF<<띂Riu@Q5Q/8peӗk2)Ρ$[68nsE:,a DANJ=*4xcW+#8}2̓%+Յ?擝4$Y5E[+ቑ1Z4>(DK'RK*D=B[&N݄Oΰ17qY3\aLJI}DvFٜɂE3L6nDwP9@/m;w_nIRi͉%Uk#ڽMLi%u:4=?96$f0uEI Bdo0FI*7KndMW *vt]X'XTa'x3{ɭK1:oVu)Lv7/Id2?4!sjwX\)dظ]'Ze,oM);˯Q:cU얖R-/[^&(اVEDy8{qy@ ѦMy <˰RnS.~r>GӡN/keA|2`OQlE}Y=xLcL f{b䯋gS)9JQX<'aVt!1Dx6ԪumoΓ; TgqtyrZIkc5ǘ;0ѵFlbFwS_v2;, 4DD©nj>]gw:ljv׳]E=ㆎ1 9j$fxݐ.a z,DWtݖK/9*@84uF=\hrq|eLQw  xu?{̴/n\xӓgh =xQ l{xï (k5PS(/qPzsPا{T c 9;й)wSb[qCG2yafkU$hg*Uǐ¸w,=8#?ߢ~A>&Dgd [/pǵ&Ҩ DiZ6B] *?yzԆξDetuk:G-!4q&W3PziP)w/rLc2|"PNNeY_f䪪8/G;GQ%Wk(#zPz7W'Os'_Zߜщ.?Oj_|ܨ^6B!jTNjugR[3s):.RV^j¼w]M7|va,1+籹kqw |DRo&0!jwv݄|6ҳayJ%DtKsʬ7f>&'K, 3R,XDhgr4YgiD%Q ,l;9> Oa5V5JKЉL{"zk({$qyfM:yM[,J(%W5԰nL_[i4P׾n-(dF3GfƣQa/kJI|ZMkt{FL.PWCfrUl%qFTn>|w8"*\L*H[8>nへ%$2~;HI]eake!*-`xƝ_K6y>we]۴O4kVSo9(&S&!8"-(5=>ѧRInIjH]_iÐ (,>rPOLwɜ➗R~4JqZ*D6U_ۡ+ȓ޾=H963R}q.¡n#syߧ0l  UpFcJ~c%$^?Hݝ玵y4D| IaCj;T9j I2ƧF*w[|Sqo̦lj`.1naFgu_ikkt#i;߭M9.g9;a ;ي~x_{ձ{;Ή:vXimFv{-Fz,{S (Z;(5#Pj'PJP$(0qN ざi9UXbdZY\p7/)x)5Sd.95Bߜ(_qF/hoQ /Q@Oji}?@i#REuJ}k oD?r\zg[,|9S^]bajZY<%GT[1g3M7>ojgP a"<5z*I_P΍Ai{q@լBUþ*];cznaXurķEsv4cIc/m$/<# /O!@6/'A[dJqw"j4KZ@sFҹeW;+β)կl–U"[oQB/!@/RCJJ88O|U`EDlNsnFuT lfx8h˲u2pv/D?ƝeT4AReU Ka礹ӚEÅMéQdwwGT/((^ڀ$ k;J.˙{ite:ʳ1L&vW4pz҈iшtUtUCO 48X| J)oP jB2@bb7=|O9ͅ~D)ۓ5?M>S&wRR۪88u3:!̗UNFzн]i8rͬy91JRh$x*s.*Ghx=4y)^O%Pg1Pz-"4 kG/^ţcsI[l9#;Zծ*@iqq|6*հ`l._*Ȭ=_P:l2-@9WQ=Nw*HP%i>l%̷ѣo Fe4WNsOiQBR6O4hѳg鎴{qכ%,0o exV8_Rda\=`];s+yKf^:~*g9͹4>K@C},tHWwW,C$Iޟ *V!=E3)5 Iˏ[9N I\ktma i(]~eht|Yaz]Ff8,"p` 0)Zk|o>tF5fR6 ΓǍP@HJŚ:X#s.5.P4be;|0*3mEmu[{$*,} 02y$V{~K]gnP]UN vZPkz ͒= ʦnFH9٤@ٯ$%Kf%L"'QVaHX4XӓbK$+0!zN ; ^ԓf‰ogN|LD"YY ]I>U% @N܀}Zd7Ac&oCi[jsIzi'P*&"KL}% ꬀$ߞﰷҎ`:U86EÃ<`ox8ҥr9i'ԩ|lԁ;ڋȶ߿Gɟmu鍾2keT"6sP9 =]_JmPQ6Dr;!{6WH]q۩>VLs"Vz]lUh]Ig9v*Vgk*E T{ţ;g*9U *3uTTOD.xPx]MjWbIֈ6>Ln0tf_Mrw+m~NWNh*b> (nYPvC h-W.h"(U/8ݙUm1.$X$*djQ͌G0 hat9ENmFe*noZK3ʒ!U40=PeSj[zxb`{$7y&F/Wnl5 hKhv˯ @>? rQqrgj:r=Y ?<1xtY1nOyls`\T# /TbHlf84ʃѫ$2!;KR$Jn#PXHC "v_{Uܮ6BU8ub3u2!؛l3vs~\S-e]Nֵu[_hԟ~8I~UfRu7X묋uMa >OC;MĆ*1UV,,џfыј'm<JrS{D՜!ފ|~2{18&ÁU;5J/+er9 IMEe=ou_鏳T7ެl6.x9 :n:EKdƶ1}.2*(eOWH|4 N3:swuEȡM1N:5~0H8sz-.Όka6޼ȃH>iDJKM=*"BLQ9:ӇiY B-3dl|&ߙp)LcigS3m16r;@d&3⎍ª3݌aAA޶G =pj0>r-~T>WՂ1t/<Y窛y=jm΅[Y+y+Z&~T? /w?._EgoQu-8#.($((,6(PX6!}&o_kl_=u49zn=V=$ HysVwIͭb- sh&45'C D,d@1 KM{NCԐc@bL |8=/'J{F@ K WƹXιl˖444Ղ)'gl[еa=rͧ o 57t;*dHv|cu}Iܭ l-ۻ\w}VہkaI n:J}j_:,QHtHPvS^_r52SR+篖ɹ^eB:r'.11ٝE`=Ղ5<_n>=_=jQpg,m9YPDt@ wli:_rwnXh+(dsj҉0(ZOo ޥqz'aSV/*(:m}4_R1 u|"DJ[f>&Hf\r|{p#*G!m.n-LOYt,ӈK٧D9,5ʝc.nZmZ{tD.0V fR?E" yh*w.dzԛc gk'|ǥ FǿPDeX5~FD ?x|LL;u["F`vd Aw>n>0)fmnU,mλ[zPP_ϣ=BG@)aa-?A+A U9P)ѢW8 ^8`&<0d @_ʩV ~R044cP b# hJ59rSP|PV+ޯ"퓗iFCepx?p]%{|/p,|J[A `*E"kɕ'?Ph:!m.8PoAe3-o2j\.F{X;;Y*<{GX2.h*5a1t n@:7J" h>oRa_:Z 5 rל~]y;UhrYrj`(.ְ񊫯K(|yNVթ~BǏ C6hϿ"x=_AqҚ7~}d@溏l,5~D OH mEwo]bڰSqm#teaovĉضE2@ 6eb3{WZkX(]@)v/QEQ?/$4}SI#ΤjW}wі7Sq6cR$Fskr|̭jj{6fSySv5^䒬UcQ/(Ik&$/@&׹6mkf.gZO 'xon|kI.[CnYb^M,Nz9R)?f[A`'`HwۊCĒGϜ/Υaٚ&4_!环qe| `ؔeyUrhl)g^2YI>!a"c'U6= O]?)rWg Iy9`PHڂ8ɸW' 9`:66Wf/zBM$msK$o3c4Qn7"2:GI~؇޵J._|HA9Hi0xd'جhUn:>r1 (vaW:Lsփqyyx<9lqX;`->&dq ~XG, J` @YAE@,T {g;f"Sf e3fXz.,S&'^f9m[a/Q :ahufD$rX3%ӣcOsó~-xgYo3b;bmEZ;Mk yW٘~|153\H&2!-sGe*n?Vzٲzv:l}% Z>QFMA#V Zn~: Lyyؽr"aׯ|b, EèT/RFoqvӳ82oJ6SG@؀{r*}D)2$U㷠h@ ^T$9ˤQwI7W%|[nhyDN ,CԻwAG!3:!Lx?{hC~`{.rI\&Ջi:ֶ[lADAT;;7̽Su\D\X\&!\ Ko2dtݼ;f,:n- @B'@V sH -6;دȌ:vI-WoB7ι";uk p6AmHv9I#vwm6ϙkְ\P`9e~[f-m@W=]L DFL<$SgGi>~e|=iy5F2ܧ/{InX+;_k4u296`kπY@>fbK={}w:,no6}t>E{zck{)'0&Z6 fRo@7@q2l-3$;KS `#+[_ډ 7Bý69(6J]όGX#ܶ^y<`0iTxt8l!T҄J l⯼NkpvGSX=wlxy}&}!ٍ7qW&{QzJoDuDi#LkbHmzo,$S)+!֝`%ϛS=~5aV.Uju'X[)ZA tAh4 ylB~/$Y>xR, KuH vX5|z*FsRq<]OR}Yܿ9n󰹴};,ʅq= qWzFZZ~՘B4Fjߚ.3Պ#U 9-;n?y yߨe%|Nn0-7~7 us&vb {"$r_TJj̬y/穾ejmXǕ{cfJJMIWJmJiM YY^6mj#Y r[,/aif>;ǎ=YeSmzŢSN>qL(Z;mi{p[v2rp +r]O=16rj5BQEY+f-5'"lo#a֑V2cwvF[UpR+*'Oy2S~n&ZSׯT(n8"F2"%MX$[gV?j^Cq+^ҟh^z<v#q]n2Z۴ |Xtf9fr )hFf(:ZVn3_7;I|>)uV뻳ZtLJjjH+LІ C1-?ӻdTXO&}#g286G{uFχ{$yި[\a-)N5$Ţb&x1s{Ygք'9S2Fڟu2s2o4N[v^׆ʹyn4\/<6'x* 0==$;妍VHb g%g*gp]V5$RDϕ@*~UhWS2+ܱ( -OV.Tl\%}kq[1WӜ1N iپ+znWSͦ 6X:}^kUlTl+A^;P[E\e}/3=yꩬ]98*yyX, {k/R@XYpG FF/tRo׶asj9>LB%Km7?XNy(z4 `/5j~;˙S˸AЙ?)LDJ3Md'٧WJsR6Y^]7aΫ."?cۈahw񡍃e`/NMoj?i/J;:+4/;< lz_t+"4}wdױR@t?zuTk=7 :x<~^)`-7Y:%V[7ђks->ALWRˈRcf: 򿐸 JOش2(KW B\kPуzG#_(B(ƾHUNX!vLwf>/}t3ttgs_^K,4 >NAd#K4ې=8NjLwxo?Lӥ! #FWf5, b{1"*֟Tiт,Ú9 DB .Nۓ~Gyq9,DUZeg K/g@OS! ] JǰT rbx/,@k"T֩y>,0;O$NTtN\+8Pe2fd1a&z\ZSmџŚ "IÊnsߐNO QƀH.[yZM./7yWQ]5`}NxmD/t5ix:fNv[_g 㫞ZcwI/Qh!l쭵p):*扽_D5M`&_1P%9T=]D!Iz'gq**=Uj i5#;;IKްzt;u,ۆb d6z*[[jgB`a@vE@ٓг39-new6ԹYP\&8?iΪ8k>Cܬ {ͻ]3{Z/Fdr\`s3}ja͊.'#`k"=/T$ƤQ 0Uzr{5+ː̏vנOj?gt{LRmy4 UrV]{9F.٤:nYYV'^( <v& wIO[[OD6c⎵渢qRhz۹1!Fqd`CmBak?`DV5,*)YF|QȈS+;7&>Hʆ_&;*dMUžB}3y(=)4n0<źXIשĸPNAMk*gϟ=qTS먛gbvb&;;7WI795dH2>L'EMx)Jte̥ʢt A#^LKFP} g@~@07z3\5׷acMxQֲyTLw\E#VN,mZ"1seqi GSz:ɜ]"/|B"r5ӵURW^`hPNg ǒrxcVWaޭjӪHT躧ӼI?:kwQnTM>J88ebJp$QQgП5.W*_H[Ҝ\Nn9NBq?bgk5v[KG|ꔺ&Ⱥ]͵P; 68 ע:}ؤfN7EX|_QdTIKTբD5`N)|N8y;Zڟq8[zWvV,l [>>_hɪ]"ɗޫY9䓌.AgRڨpQ+_Ҵ(Q%sz 5ۏXe,'@s\HFJ]K6YQO d˝戆utrw<~D?Fm8Ǡs\ l$}ԟ{,/6M0jZrVmK0(qk8oЍTg 0U@5yvi1( Hg}dVywcׅ99h ':^GC]YO79 K-SYYhALo[\MApULD袄K,9y׌*?$Q's^\bvԢ'Azy%ЃiG3{ uMbCKrϚBtn1Y9;. в0v5>.@o| u1@ [lyHƐ&D,)@l6MbkLJqq14s lP|b{~`qҤ~-.7ZOwJP@]ߠ $ OC,PozۉNy0fug Vr_Xe^{P{FvZ!> /X7Xނb[f?v%:oMB8˸uׯ!uX9zj^Tf+ذ` ITs>6,pS^.XϴN;RPoXXQP \}Fy@ QRPMȢ?{~>1a~*̈'gn?_|W@d@[ 0F=Bu+pϰ>^^֓X6`P !4V]-}vw746`iFkXWxdG{MXk R@?'` [J M3 Q[Op0E(Vlln\9^  2?`yO?a8WΎ.9y`s|@''׿^b7g׀ @h2 \xh<;-x[5S57!.?;/}ۇ_^B52<47bo߽UUEU~ >BnP"ʭȩ1{ّX9:9P/[W2x1|J\6Ʉ6[>dEzAoYTI~Jf` 01d#GTq G 3\ AbʊO.fWqe=C%y6)ZU5Yw;(F>-|45S4(,qq }=)cX}g,/amC@Bji_tjRb?e}ݰ%c7(.]ج;@H/كdVu*) _(HCf (xg г 曋9Y{ur]kQp}Ma1y*mll;Hk1;l}a˟$LYo=K#RhrfwY/TO=7 P'I |ȕ"` d+W3ênHkH{M\RlF)/;׆0=يk6M}so@v 7#3(^9# Ih;;nWaF.JP0ye'^9h`JzgY((e0Q[<>ča+ؽ'z@66+-€ȵ_qCx/To}t`Wu NI];u{u&|0)m8?iN҈~F΋;s8.};ч6>8m8 nEP@YzZ$.hF;fHl9$#|m.{EiD't𿣥?xa6l$:)! zRhZ'#n4v6kO?r 1=36&t `ؘ/f/^3fM!:E͈õ@_\!)gum3B`^ ȍ7>_‰rk:mڟ1 'YX(~=Ջ-rxnlWy@VG*.5h֪=uw~ ОK7f ^e/{j!|~\)㰞}ncU9[Lǃ~<53YFU >MUR|Yad1ajʯ"*д Orr_m#bܟq=a{pf]_]BZ۫G8wfrv?sQ2;|9Uijժ ]^UTgkO5wv}REavvaKQj58.K36{ĭ5_zM)X#R-KUYn.ȉ%֜VU|Qhh 5L>5W2IvB5ݩ>rKh[_k.w+n@ $xh-ZiBD=;* 5%>/ b:τ0uEY 7[*dj׆M^cAȰ1(Ygno74S|`OrM ѴEgj]'8k9xHUʓR]{<*:FIhF՝.o&^jͽG V: Z?mz4wWQ jty[3Y!ߎ4,JlsfwzB戈}/*w5Qfpa5[[ݠC_۳⏇*"j rrs/ nجvQ4WEi>stream {+&IJħ]^&yR}fJ0ǥCą&G!ŜlYd 6)RIsc DQ.M*bbW]ekǿsЭ0u:=P!@* l O7gX!~#@nf?Gejꍸ#t_v¯RPg7M^^Itxg3!`x( :ڔJ]aK?aeJ LJoB-3>fa4vkxNzrh `;LF{)E<c dgON1P?uS6,ap=cGdNfD<ғKoN=.#$_y^ O%1%Th~r~#M,i?xP;G1}K/~㾂*85>qx_8d+ ;=Gso'#-9T++-;Ҹ4v!y@*Q U' s]e=9K 3zkwu[l2v<[ 뛊u:KR)ļu `.,Mbԧ5 λ' dρT(qnq~Ry& SD^/|Fp8aIgs5ҏAco?Kfi: >^}XMمa0D_+\:/ݮbu7"@q BuuC7qa?;=3(ub}iOͺަ)EGv}mS2j/˅{nְUט.[{;$Sfb&6"F:bvBƤbzx_J+ oG@#QA)SrE9-yE><:nzNk$yq1ٮM0JTt= * = L푂R <9-9R{v/W*>Oײ.7*FL%U~3`b䣙,KI̍X^Dܲ~w}W&t/LS(g~ fb_ĖIſVErxC%H.J;^gKt#++goxlFi]FSqD|~,d醜VFSJ$)51~fj/$1E<3kZ2%7v[Ŧ^lwyp:{:ֱaf >[4UH[tz[v=|{tiwgMŝ,K' 0Dt͛As7>3NyWLoyzs9<*CyT*݊[;Yі4N 5ΐ#t1}'nS!jZ& +I~c 8/wdVoh|1"xx쥼Q9~!&(*tR={s>$\i3!?x }(tj;^:)Xpnˮ[Fi d0cr`M/Ckg_ O&G4|Ym&\Tշ/Fͪ_z6__9AH9=SνFNI)CϏ顠}iss ~,(꣋Xowz嵬*PWa5ii Ðzq)ro:ɧq[?[ٵ qC37bKl6Pzpz@p]NM3Sܮ=x,<׼)C{ 1R4GͩݚW\`hUϵ+Y ڃtZzMjFn G3IySQ0F>4PZe<[ktWFc*,[ISkc| qoqZ,3U-R)=ߥIO9ɋm m(ȇ. aVO/ҰI~37Y;.r XJCwd;5m.;kܬ'Lw3y7|נ;UՖuԦ4TeI壘D{sON9y!K-ZrbxcҲ8% Wg_wGmBakC&E3'㛉;;:}ɷˊOBb^ $(¼<׳Z 5 *HW|XU[;ɉ9b˓e3PUdsJr=G>œXK xC A6?[G7ibO+珔 ,%ICPFdÑ? \ |O(E7Uz>[w<#|R$W-?Ja>d~[dj+EdqkaTNy,D'b|ŚӸ_pvQ@p2AV-V⤅YuBF\\{d8-#\)&YA k-DxVQji7Jf nYk5<R*`vnymH~a*2b5zy?ޮ)cYzK1 \}HpUN%JlLâDbNaL=+$ʖخ6~s+x1]af.eֺ7H޺8':jsnlno%HyVq5i=ѩVzi֨6AnOb鷳d){+"ΙhAD͌, ;nk3%f{7"pv)w~L: >ŽԟSՋlqWM.@)}Φ\!\G{.o0 b))ERbCpXǷsmd)CuPD\D·w0Wo;(?*%%ܡZ=z=¦U1 }L?Hi:iYF.W4qlphEjd*K <~m){d-(% l?F;l8Ys !o*x6Ȍs I2H_:HWvaГ'^fc ##9mMLX͈@@vKWAVCG{)ڿfE@)*RĂX@,Dw1{>\$wpnq_R$ `nhLH/(ځJMb< @S55'yE)XJ$VD*xgEr4`4]~ 3Dr4!- x+0x-X;lcKֶԔ"`*u,݇ߥ2ĥllQi~~Vڣ5ٖBr-6٩B[}|B"- \р )- i4!|`ۼ0cgf.su$KEA\tֽ_ ;I|܇"PJ]@|Ă>Tͫ{ k|)\3Xb05x8[.Č>6Rs}%M[^JVWϧwR~ꃇ H-H]H_&Hx_i=@7 =i=%Uy 'N;$b\R-L{d6NFzw׃|}/1ܜ@R;mZ u 62H?K gSX,ێ˾^zhJ󊰏HosvoŇ XGb, 9ܵ.6?|W?ѿQB4H Ua=Go.ѯc9](Uש;~ے_MNg e#w?QA}G^ ?MR_$ 2٧' rJ wH]}s綽vf7H0q1,q)Kk CQ5>Z|5{љn X72n?ȭO'gk>d}z.׫_BwU8mkWhƎqC5|. _3EkFTw\@[s⸏U>?O2m_JefurGvN\gT%~N/mD757:Koռa{zD^yܱ=R'5L^SVe*\>Rg3-9-KӢztۅEcϤ|(?=8?xx[/ut*Dg&MM8;n2ImsX;w[ 6@cc/mϧ܏ڠɹ'vvaךF,cոOͨ4nXy&/ǴR%9V+hޙIև8[*!hάɤP#VB=\F\Nj=̜-Wp8\d | |?r䇧Gk=8}-&syU B{A{25E-_i Z$ч2;Rab>c&U@i0GtZ]cu 6{RA4Qj^W6"-hk4se`^ |J@-IiZJ^2FrW(l_gx7Ұx;0e}Jgr:ğ`g-5T~2>3ZQwX-"aZ~E'iG===zrB^N#Ƹ0-:|-93|W5*a`JӪh /{ }L 6h.ZvHz; }`JpT'6'6+e5-L9@ ,^>rW'vĮc-hC`lz"fGLZ&- '/,A};ސ"9 ȡs1ȟ 7ٓ=:TTXj ɛ :{OՋXYYvZಢB $ ;w:X%K̔=-^? m.u̝~ѹLp8A]d9՗/aƸ+ceXMwfDTkU tU*6]\9pANCͲʵ;׼*d)v{wQoS{VPgzMNKpzYJqǦ[`6fsv+1TV}]}&pQ t_w!A~m&(!I"0"FK7:E~qݿрֲeK,n $}0W+7RWMNKP$'1(cp[Uۯ)AN-"i| ,IQV'l~71ƥCbREA^@E[8LՇ$JZ=)ؗi}T\l5`4?QUrk:M״?i9e עNwi>n ?ierthD5h6Ǔw4z϶_ߓA**ZL.3W8ΕWo>/l xvi,rWx$#AmyQKjQaa }֬s-d\}领]v5u8 `*&q q Zy'ϼ9 'ky.[o=Q~|0|)Rl:].#tMaMwRm$*Ӫ\بt\x?R{au(Z8oJ=T^p2Osd82dRK6eZ`ĵ/$FJ6eCwǜѱGu'v6L9׾GZ[%b,t'54GDc ׊KiLVR릊xS Tontg׋"^>͊f9{ Y J$"5zTVVEe.aZ)zOMv3 4@NJS{⼥_7@en,D4X93Rԛ[$o\V?o84n@q 41KqzK<˚y|o/캆:揭s\R IDAh;@_,wex^˚ ;`v6 1LzX6PO!W\f^C#^k4ӯ|`}O0NNTf7҉cC o~ 4z\(ôRĺsۑ>J{Lܧ=G~64]>03~eF&?/t~cHMeHMd.H]pz= 5V@ Lx83]yfF%Zb~V j8^'x˓G+ ۈnfǑ:=\̄ƨ*,.Rp.*.&^}`C m0X=o)_Fϟ+2|N^6Y1 _%ìi<8n#QџN:/߆R͠ 2Nwr|>o7;TQN㡺l Mvb/\T;z{OK!-jZTܬ﫮 W 8wOC0g?$ jXI'I%w j?KVIhUr㚍n˛Em56E {Ίs_M<o~ : &Fb}5W+$̯q=s,oiRjrɴ8˴DXJP"Af@OߕE== Yn}mgqej,Y0@ad_]k&ͫ+&Sq(wT PC?3~lI{ܡ+_ E. ElR \^w6Ccm;sA:}:T.Vikvnן{̻d?i{;*wܟ_3w7$țk`-oR`[:5@:Nm.G_tT{: p%ݜC.&uIF#5uHΊ73Q󆨴j0.p]!3x#X9ca4RXJ u~4a@Foxu%}ʞ3kPby{}g1iNl|WW9/B4-/ tVIyz֤ZSšɍ*.V?J"?56gm8yZluͿ(ujzQ^U8aoyaP+=|3-;MPxuQanAyVqo@wT:?P=(>(NW\g6FXU&2afr-:=͢E.u,48Yl@nn27y֊.o(nU>(aK3XhŽ[B7CZ1òX4 F /ΞL3TZ׃j}}]9,!bYY6|[вۘMg.c />LFҩe[d}6@K&/>-5RU|<5A<[}o&4{^& &g1Ac |vh9̂|2!gG[W6c0DemT/3K0ek!1aQ|}iO*@8wzs2ZKwy.#3zf3ƤV>x8]5U ApѮ&tlZ1ZUotMdnqE"彳y9g,4W2^^S !N!Yil&jL_E'j?@{*_ooBQvLWr"E'<Q*۠15ϵRR&?rKRYnkӷr%jѲ%vm؛[5rGHbN7Cy/LiƺX᦬m&q29BRVї\-ӊm3K/|' v.c-Y"]56A5n#jEc:kuo©^Gnb7o{%$lj_`_bl&4Gh]w}XNƚ?͒hv^Y SƼM"ף[T@ہY[F|z /j]*Ļ`j]bTIPTJ{͸G6,o0&Ig;:H2[0vf~ih;+qZNZ$**#l/s Ǖt-8,TƦI{V+)~}_-)z})tJ 3rʭQ vPm/ԻQ[w;ꖦ^Le]#KcJjuW~TRkRsbBhʧw/sIDyq](Jd7D4wÔ²׳s8N\ǭ`fS}MMrPA3d[if 2[넶-%oH,irrT}~(ݘ gr7 sn|X?4L-~wlΙm=vKm# A+QE@ΠMBG'~=GNlk@z:LNz5(Ϫ$0Z= #7~ofJ݌Pb@v Ӯ*HV~R;"BhK X 1!<@cj_R &Km[?$IBJO(UB^ `i `Y J >@mT(6ZZBa9@V A!L  ţGOL?\ pZ*KP*Л 5s [N!;Y|h/:aT_2*K1 F4zk]ȶaH={3m]r Z?R.-.*> d>ہWVj_t2{©q #!a+s{6jcu9sr'K)61)R@nS\s>kVͤB}!z%n8y<ԁ^ڨR]NUϕcdGLz~AfkP\eyH[d9fm~'J ErvUָ@fɫ9QCk7+.{}%C|s,/Zĉ|ot}M_%{ԬPtev׭,EXi04UuSNZHK=ʂTeX1@dKd=ֱQtԣ͛(Nc4ެl[{mK Ӧs -zK7Y~pk2൹ꎸ4,H E/,6@ǚ=.7nRy$\w3*Y<PХb%S,&lp ݉ib!i΄,rtOMqzV'MOG-N\$$΢!z[ yk.UxgQl.]0Ok0\fbzf3'Vuwڇ/d.˳|:b4i'>͗zzo˱p.agitNcY=NYƓ %{qce$41k6S3\Ce Q^  -Nj=Ftbswgu7fPmʪ=3AU86iPhn#gdުHio ' sOBmm!و995 b- Ja?+ؚ u gTo^CN,5gxgcov}3Km/ ݾ幨jY5'=SA۝zPɇ0\.cfq+Wɷ,0[  ïwl2 SG--RٿYi}2>Zk.Bv6Z+H9uqIk>pSa;u .?X&l LzOA]ҥA/@Y7;/َf'ߛMn!Kq/?$Iw)xصyRIie;ƯZ^.op•X>ǘ4J^ro;՚Yйy&W.Lhw/ݨQhF.@D.T\Cr1qk~`wpY L g-Ո_mOTfYV@mjZD]Hf,GL=sgY5ET&Leܶ[{`>ip*;N:o_yRu>j־GJJ* pύߞJ9nu$5m@4 #]**OH8f(dR6ϷelE}+<nmYߘ':T+nz%XVb;/l|]6L:_h:Hq!H˳C K&3BEZnNsjU&ٷ֒ 5/ o]-_ :t-2zRK:ch5Rځi;ɫGJ)Y=^06{HL[.aZݐdG['қH6[J j ^C#S[m_E)U4Uarv[}Ѿ}nǩvJoGCXyF+O]d"VmeۖV.*Owa2?Rl;h|V1V׫V>oPl7a**I+/lv]R6V/`vgv%̧sc7Ȩv YX6qhxP_렶bU+qP>n6Ki*VYXP7X7F+pJ|+~aYUlvc0Z s2jtɴޔ]K⺺CeY\*Y.tHL_S*?D3KT.Vbt-Y,3"]Τ>0}Xni'3_4_WXoz'RcV7BHրSczqWKBL-S˅Μu]Shṋ[g*AMa)d:l]#*y{m,  yk4sYar1JiWRj2'=yׄUFYJ*YQS 1eo1MgԜ"j!mkRFv8,_tUz ZqS]ՈwbW&[>RYPj={I̤v+YV=Ba" g*JV:4B5V!>+j[1b .gP, сSkJ Bbp(3~dQ)߼\?C3%_?j1G z$50\qQz6#v^N:HYxt$'z؁:Ul9M1F0&6m為5_`+!ğ[q !['a8s3h泅?r0(gt0ҷ@ !%L$@<^@3 _ k 5P4Ͳaw)Cl}o(f(7Iy <9'9+@&M* P1-Fk5?P"ĥ ~`}}S)y fOo1 jg[~L_y)S%$Gf?1R8fr%6}+A *=-V_''@;zG :LMZ!)׊Y DwgT+jף|/Y[{4,iMK f0}J}R/ewJ$#`Q1H]$^`˾K垂K çH\n]2cΟߡ ^OP]y2[\A0k*u$ eDex[Pj">d:MaUQt{#t߅G G!AF;Uk'|l]ޜpKK!ڣ;VUv]r)l-\8_v,a y؃( , V?DHDnb#Wit\D Ftź{+W)>`kn7.K{8ggO<=*zjߥg>GElK{/$U`'_ϼsS8/jh/ZŒšv]b.21oIϚ>BV_\]]Ja!yz!ޝG?T,?lvވ>d-w%996e~jbhJ/w iT mX0Co8 RWbCJJ1L1M l?dc_(r8];tێftnxqcwcI^۞Y1 _:)J5 RSb]\:\uXcǴ~iK]cܢ~ ۳mfY NX*RVk+c,[\ɜbͮS㚞Îֳyw(U#v*?^)qg/ 8crnCŭnX ڮa.KA5#KƠ>1jS| :g1L Lh~៥- ӏS~U3 Z = j{~ձj:2OW\3X"7!/5t1 ۜT$9#n)\ʦZ9TjוW߂nn[vw>h5s؟gyۊ܋LfeM.w+r/%O;ASF[Z/pE>q'P)<73:x(m۰ZC֪ٚZ_ZaL=LYi]-?lNJQa|؈3!fF=A-,TXA# j榚9Bg},F=~XW\&LLWjL\FͥĻQlzg+.RB2]˝/s:DƏ:CcSJ U~|sXa_jݽNm?֙mѸ35۪8!d`H,zQUqtusH g޴F ',iUsZȭiT2X ?4\qģ<_lǃ:VfgSі&9!#5JL>Q=nܝϟ5.25n8(Ȕ)K)* &U-@pQVQAA@QMTP\QQ@@u;ELčJ/G4!*yʳ}[h|C5Y>jLg M3y<ŵ8%c`ퟨ *p)-%4g pF !sy~hr"c>7QDwҹ? es Bu9wY-NV-'-p@g47 'jmn 1rhlj\ɭ~^> hpD:'a1G$n1,B=-zW E!ښ}$ײB^s臣3FrpG 땯߬K-XXB4Us`E%KB^fUD yE{~GK*ow8T:O#=<4R(?)\ZJ9w^ E#;G*xPMĸ1E)|je?xۀ(_(tD#wOßy|`OLnNE (|_.mS, qd0]1 gzOaӃy눔Z_~yxG#R+v\ o̶_J{T|s['6O]hxʥTk_9{"9)Q!KK|C㼋C69pqA{j7< пƅPR̽s=ZwZGT#r}=t/jK ǹY+?>ʞ[ƥ_|QMԎ)3.8.Pʷ-#'>{{\^Djr:w qCptt$ag..M㝈|*vOS+jZ{9E؊n_8l + -Y2cpZo=FαY^Cs G*imrt)mvv^E`vF+y nkfauϲ>"lUkąa181 ZSζVe>!O_ـ]Z[[=xl@׻ך{wky Mڀb9joKgl%-ZqG1GT2ov+[7ځUV|"@:xiep9(yy?H&rˏ=X/ͭ|7Ok;g饳sN]籓`Zpm}ʘD뒤%F]/ >q꨼"Z&7y3y܃BF~ܼ*ӉtJRYsכwϢmFUL]9'jdll)WqPͦ^;OBBN4oUڞIntG?v7ʼXf]^E?̉( OV)!@F\x>.d{YuyDv|ם/Vf8[?ʖ4U)ҷh;X27^LfH^PUZ#]MG `H{Lf=qofAJ2ӈ'5 Oۇ۫I[Cqefg6QTmn*չjst6[ -S>ϴ$I˕*ʢh tʹɛ 訖sO~Rf=W^xs0;!d2g̃`T-꾧{`pB>KޏFE.d~v4Cq`6 ?:O=7\>,'g†bv+Zn68=wj\dD97@qǪ6|r?`n&.;&^Ko2j8~s aԼ.QˮFFHB\=3k-{%)6*]E3 =ș|iU}@H* ux`Cnvek-YBYAZm7vvDT 9,i6:Wl'ee Xح2tmztOO]Ď*Dr/a(n>9:|0wgSxڻanZN;E+JD鬸?>˝iNjaFfz!p9×w-h0U?]RǴ?E@bӜEiu2解WǞM˲Hz53Nn s9gVoQKE|Wv`h..',be0 *1\Pj1w- J 2e)?;){2II)Ѷhϥ)bˋxE|9.i )ڢK*G x葕rWt.heY%y5&RKVZ^IrHU&]!+~;̟iHRǾֈަ5X3z~qs,'År<ͮ&x0Vm{\(, F }s֥_^rȬt%4B:_QVE֮M[^ -PӚdܛwroޭIr?yJ_b'e=hL& 6bL' C24Ŭ;n :Kw#"hU:B;fRO`Akk6 ][;β6z֎c2[vz?،GW0 em1QK!O~!Gc@:zm]헠=y\ssPBXi^cMH6`gʫ-U;|mV[WIW%BP*{ȳ3\yZʯx*ΤFs@͉) %SͺȒp}oƫY\>0QJwqKaJ8+ .&SLFWqӰ że;N^?a'aaha _ E@/fYq|M4*_Yy.S'qTq}gYEW~|(ż^F:HM[<g++\? 9b(ee~"RʩEy|-קg˪mz~/M~Hcɋ#fފ8\?v\%!|`οx]FB*/d![7S "@G u7 /$X"?D b c,0G5_V9xnpi٤N[|E=J;7)l:WA JQ)p}ޔc'}QV),rT:T2j9Vɜ@}Jyń+UϟdЈ[YkAd90D`[ sk?|~zpHJY 2iOH\S^_YC}a(z^J}!CKsY=8a%ǢhMR 9%GyHYRAEc$[rD)">S!8'S1 Iܟx=GSKQ`L&ݫQnrNC(v!-lQ$d4K+)yQL.0<0b L^|Ÿ$%U˧.q};OѷNfaV/hFZ<)k8w}D1׍RBʶXH~=ů?mD9c4^:\ӃEw[(F֎AE_E_PRm}WM9BѷEf]QBáD CKcc.s g=io=ŀ;NzSq:L䰪Fҏ4HL?--6rÎnO 4}W+ 0T4P*jTzv}ڟ{)'7h5Mnƿys! ҸŹ*sy/BO1 x8^營r'>ݟs;i|c~}Q>y_iOO>Gxb:yOȹ%tsVލsvz+)(yy(dK]_/ia|O0X+Zie(_Β?N~X O]]k`?a|4⁞xMS.m?fc[wm{{6h׃-^tnmu z9}hVkHi5ht͸8~J..*ܷ,L+ik}mhN!ݻէSCN^ŭ=PzU/ϻ?gӪ9A7EEGr2N1.7,?WyvNMO}y7=H:rvt!ruJWa(JTvu[6)8wVTҶ9lIMmEߝ bb>t=-22OO-옷jK$y) h̽З.g;UzKIa% 6Je3.Gu2ąMpyb{mD4l2vQpiG{sE ͊Z NVz,;ϰ}AZƬ%(!:@|@Zqvon2ؤ̺{*7ϬYKNy;^5Z~ɍ 'nX뽶n zDnDX:;_Py7s;̅kPTAdB6ցvS-+h$pQpޮtzjs[ Ճ=maV';f_\ٹѹL?f=Q33YQG.wH.9ʭr=wyzDose~V[t/m}g/{0n /Jcq=4:7L:]+$ݍMh0iOQ>!ߙ wpFurmD%y6{K[Y'-|fq4}JhRn׮1YڮdTgSvD:d63t[pZOW*WWVWZŎB *sw|xm-f'(j-c9"'jEpQw \m6C s;.<ٷ+@[1s>Sd !x%pp΂ʞ! g.$*4*ܰ T[zdubSpVgpYȲ?)ѰDTKuЭ+w"a]/[sit}`ws˷+d:InՒ:t5_&lئ_&,kNLvL̽O|nk/َ99z6e 674kljQ.cff_R>i8/S+}E(M,#11 D8W%'v;]jb JS"rI@[G@'@z0e~ U ]%zlKxCXbޅ~N`C[fVsK[F+V ÀpY$EJ>||ppu|M?=8[z^g-{7^Ƌ@ր+/ї>mSl+d#E_pETGр:\?QDş-}@hXB=먺My)= )(2QOR?/敆9} :([X/(4:L)e6'EfM? 4F1[Iv"DQZ -Kg"_ I|J_4^<=EEO㍿ij5=@8e!s\Kvq=# a2 U>{v;? KJ <\܂1.~EsR0'- [37fŬT.װ(x&œ8W"cn#"!=\>={`PkfC@z ߳s~s(w6)-J%dHw͉v}'֯P􅚭xа6t)M΃~"o$NV"k)m^E~}mi$@a^x>\wƺi^2~VJGFAP~P'=i/J=JgB%9au۵ǡ{g;Dg8[TBImuӾ^Yp|~sf޳gq]E n^=mj[P')[RKx#ئso~_S7oH6{ʵ\lϗK={㭶x@8 /& @Ag|zђv5{6eа;/gC˭7q2V\\9ycw l ,z傭6si 4t3 |rl܎rwy+d4rcNn=N||́2ӬEIWDyؕfYFW}=>| >SxqO3νˈ^D}*;,gV^z; YGGKAȘzXk,\>a/K,u˥*i|%!sMn3Y"E*g9;-6R^l}cEF/mM ?O^cM&hNSl1Cs-cSՌ3-%![L)׵՗g8³Ǽ#1wg AU8П'ز1s\xNJS"?ؾ@9QZa}){ t6_{??}%3H̢;'8vypgDde97}T~^B<5b+y&25#Y Yy Y3+g4iqڱ@%Yg쾐B«""YqܹݔL.57aR{&{'6$evG}Y})8ڷV^}eZ>T6*$^+{G[[<}|pSNp1nPİ멨 Yu!EJ)<~|}usll%[eJ ==է}@RF*Hbcނ|3@i>^?,)nP :# * >N ט?Ki .~ {[]*ȁ˕!iYI/tWۄ}ȭ ,MO 0ăb̶Ori ʗ]Ok`1E| ;( #56MVo<8v= w.~ӄa~<ڋamo¤l=G@{/?־`ϔA8^4|o䊥ţhrt_lncE1ѽ?DZ>_{ݭ+@ 9w4̷vCr[zj5FƥVnb<Q(Jb˒|8Q?\E=:'TNu|YDۤU.^jALb͢O~(ceЁ΃X(B#+Gn@,i ch>~y(uEIQQ/ӏT5g+Z%B3§s&@NwRWT8r2UV6 k~ɋgru1łExPj^6Gn "k =Oq<VɿX㎓<8:UXܕގo´4XtuBb@6Rg};jQs7_EY%{6v!AIȗI. L>5 u6u^ǻC1^My=LuV]:s?yhKسxɝ8n|ZyܪI}뿓wE朏"JRj NEt+Kj>mSsHyQ4.RJ{L.}$# TnpR~o|k} ]9w_1aKCD}XY6(4JHղdJ^ҧ46)DV<8qdBDJLpSy üȨ]~F-Y%E$4T_Bz\4Jhq 4N?FW:ZTk5xyw]n+yg#h.Dy`|?OMCo,rQ|َxiz|Rɏ?J>E ##]utx!p$.,\?m=ixln8OpGOvn4:̝Mg DI[4`苜GѷJ17? 7c;sj.&,ڳlȧcEv|78Uu~۬-zggkz>^WBMد0Q_D?-d. iqMZiOJH#-Ӡ#r]K"KS_6X~xކEʠb6z9d.Į'܉zW>aȡiMlA "ʱ=!JB?=7)[6J|ސ4|SB[|J|Ho0rv{CXh?aӦ;=7_SHٽOU;":pm梵y A֤;0` [繨*vkA1sI"*sս{ΟsIFz0^ 4bS>ʦ@'{S~XΊ~KnDq,pwF3:T\do\4gX7&7#LiY<%0 o<#Ǖ^auj=7?Iivseq [_ pCrʩ:jG+faDS669?nf,D\76GqHVS1#`^A#}dE}|Un)>P-K;;xWe["F&r9zeԧLFQzFDwr?26ǁMgS*u-$OLꍞIF7Oʈ;_n Z$G]?xw?.in;+^57o۬&!6+sgv#MÎS `wX 4gr4+^>"nqrvӝjrg>dL^Nda"fR{%޹U8>ZP5ձ~'oK;ua}׃sEmSzꍭ4e[}* 3];+<9Ս h8mNZ^&}^ F ǵXS'N]|0Īz#㲮MTWkHٹ^XB(ܘ){ɐ~iwDW9F&:~eݐm[QF/|EQ̇]T:7;f1ӎAV7}jlebhDY/ZrɄsAde lF-: >?M}nۓe6/?;orQ.'n6۠E k!Vî|upC Ce:>e KR2gdPZ/V [&-:76k21Cܭ}l>K?]O&?Df{cH7/Ӵ/V$c(tx dmZO\xw/JV+ߕ04#J1C&fspbn1'3_>ヱtYgҶl|ͻ n%Z9߂ -w<{^IB^|]:r WP|(̣*Q 1q(P-TQ<&w`O.>. 㿏&^}"7z 7&%| #J)F)NhshCvrBucnGl-Y_llJH+,1g!֕L`We _LY/}bd8&gaQ-\{uAC= @(7Ue#XH ]7:3(h^l B؋:iOoM'aLlV JΊQcag]Y~b$BQMeP;a )@,m P)ňRCx7h :@ @(@Ӧ۷?TUvX84=qPSvú iSke_u"c|d;pBH P`P6PwmIy %i+fOLy-/FE.~9.MH& ]5nᅺ{M#6_ -@=Ct`S``v_8<=N0^ 3EvM[' Qe4>$m^L]Icx._t4>6f,,~o3 `Yn.MVBNIg9I3QM֕ĿeMqmAYe{lojQVOWLy#Ewm<8;~Q ~k?%j9!m2;hOAgW{$뫜ŗ~|d=C{^ғ ܡ{Y? W7Okezx~Bo<_H>dH>XNOp/~C}OAO_J<坓{''swߍӸ5G#|':bqw΃JOxq{ՏW&[g;h޵bΖ/ɇЊtz).m]pѨP'.~>9J)kJGTX>ub) lmloNJܠk_?_=犾 ˤe\P+O|55".=,Y'G8q꽃0uۧ[Giŵ9u`jvqe긾Z5)WLkR]q /ʎ%# EuQ7a^su5(3=@_HhMN>I> |&JM!օ-C6A v#3*fUR YRU\(l6s}-D2E EgGf ҦOp7~ RY{D(:182\@O儗WN_#}[=lťpE܆ٹ!0JdYm{v+W\BXtTwni[Tƍk^E\,7ڽҢY̯ɨLx7q;>xBKތGbz jI=^][Hp^KOGKn5͗bH^U~73#ewey5gwTc} +UF=Av݅NZ ̿HkpV17 opoӒG` lӔ VkL'\jszi|)}{=dܟdT23d\Jk ^kV-jLGyqQحhǸmo3LoS;JW Bzcz1f4Iz}a+ GNN'z ӵ*OȊW1͏c*έH.HB]YWF~rŠ$^Uz=&c|혳G>ӷ%}VZ}*HT>G3~p9u< 5->{Yg,|?=p"$a2ڋQ~} + (į|E3~Xx'kDz5Y5y1)P# vJ΃(y>~$y o:?Ѯ$] v O4#OyrU1]Grav=S6ݭ suWi%) U'GJR%J hr1hRFiO fy fDa~.{ܤ5[SC"^>"fCn.{-nZԅJTf2b֦I%p@AfN!;)]s ~M|2^hfC =! G` oۇoILSnEQM|kN,ȁAi[(2wOA&A|y+.ˋyKΙZ+06NZU̦Ҙ b1`}//*J]?wF)KW( Q/ @Դ@@L:Dc"u@o( w plA}eôcd0׀qrvf)Brn6s gH2?HI*Lw DU(f=*1_*3<@~\tb>o'yh KhU -5X`r) b7R@9hbgP] 4uhk9B`o``(q 0r300]0LM!xxCG-2hAu U'drw˴b]լ# z/OZKY-s~"-1Gp2{p$lpSlJ{(;<ne{>(?:O- ׿,/7L7=+]D-Jqx<9Fr0t~H'#B$B BI&ɧ|KUWfp5AN~{}D&tuy q9i"AQk}5oL|A&Bx|z^yma;ԫS.WAm5> _Ӿ28_wxڹgs]^*kzo_ѧ 1J7_bwEqv͗r k7CRχR{o&oyYKi/Is^>W/WnT8%U?{/qq&j=e_G׻Nc&tn!+Rs-F]ݰӓuiOc5!kqyG 0?bQ^?ج{\G4NVLN)ߓX 1_&?C%lSMbrѾH߽E%KlʽXꨅbJe - 0W_Kev܀3z~*>nlzNzad'wO(ΏO;09#V5'T0ja.J2;90jH1Y:4N[ϧ~o{w; VRGdZFԠG%[pXUwydd8ZGo^ ]mE鎍ܷ>H_l`+LV`}eXXܾ[O|+lz)r1u%h\^z/;5>0þd*㤵4j>ԉ9zdFٶs[yaN>'Z p!KJo׶VV_ qKoǩ kMzD#~t[޲-Dh$WKT—W&6T.Qw)gK3?'nM>;+Q>O9eP8ᡗ]u1(kbv/_ߩLEvЂ9wnѶH /~"VӜI p,`ZcV9~M9^l<=a^6wh6o)Quhw8=wIewѶ3/r [<)Ar.~I5`\`g& vUIz۠ zﴏUT؃ M'gЎ?s.j=\pO‘(䖲/޴hؐBs+ߤdO3t/_3 WoU+=7ӗLd_]If#q' ^dx\py*2cg2G29'2Y1g􍮏MgmiN8,M!IF7]X,"̿-V(u*=.UK!gVl3\.91x@:M>H'E 嘘)Бh8"hu# cn{BC k(@98SkukOrPxuz+p o+c]d"b2x猾#_"5|92mn3rQh \Yu:`\Ze*Lw8a5tujӹ /b# v|߁v.E q^?٨2A逞0kyW(VϽ5$'|v!d)થ7iHcvSQ˪ǃ'5/nv/C} 3B0{xM_H .¢KcvX3d7'ؤY<1o18y|9Uv.3]P^omi X׸+d6&h$.hE!dm#S 驹ai+~}1UC57>&Zk]?- GUDuEo=كDch>}-#{mcchBݪ RH ^X*>TP젒vOP;|~A d9]җXEؓoo"/v |abC8> 0B05(nMHP.jkW ~AJ,q>t*_jHm*Hg= 2*lYW'smUۧ 78 $_I",C(G6it֣תXJr+Xϸule w$p^L#ԋaL E^@-Lwx ٚHܺ]"R Vjۂi2AGsJm@82K>`֙[ ɱX.A aES2G`YunzlJ3E8B@Ѝ)81pHiCv7`˝x|(O.ڥ' pu"j %*NZP.^]^+./)\JO-/,}M |u8 BV"j<,XJ(X1fzJn"?:t lAD133 h.Gqh٠). @_j/X,]@@ z RQ?nRoyؕj4blA֍̞-N|S$QJ_20su E:@^{.?ݦ=x='^@,l^M'bg=aa ?8bPf~Jmi* m0U[ݿ=Ǫ'q"S}kܤw`Ʒ_h_Sk\n\ZpP#B5\Dy~^jXɲ~֊{טq_7\Ze8?kY~ 93o5K' eM3#PR u +A%a)"nP_Sh;}_O;\x!nLO !_ `ԄjKhWy}:ʇ_xc7$|sq*l Y<GRoo[Oc}\{Q}W״=]l돊CHԑY|=qN?ڪy"n Mw{ൣx=ˇ9e#$~Ɂ?YĚɻTMA^uZ> ttߠV6WtR9]y1:.Yv)q˖b+#A7q%s}5`Mj + !~V?k y ;hZ5=<+B[qa> t +qwg$l7ݠq4_xzt>eKrS[I"?(>_mځuʭlo>fv]Y_$ZO%_} y} WK^zmϵCb˰yf)vsj^G[c~h#zQ|f{͚p9u=WI!6F)M :G8J p^ yGI#C[۟+i4޸9/\[dzQʤ|~zlEs7S8~=oM!Lt;^bgYH\a|KvTV1e&o}WͯW\O~*&a5}ۻع[lo)VOO Hr N3pa`ڰn#A LA}2]#^mlzw|'P{Gv;Tn v |CwX>끟l>MMWUߏg6f4[2Go.]DJfӂ[fzp5W-ϖ\'M#GnJF}A+s'&᱾3+||,Vs~5C^8=jʨ80a5,a zU8s񒹆S.F{3y UkTǦ;ioLLܿ:O fzcdn8u*r^vAzjvMw`OjZHﱰX+ّݨ=rej{nVosMU:ewڅ2ۏh ߉&~kzZ֢.]-LY=䵢_!${ v߽wcHҝDWvѾݬiNGc{| U,蹸Y"UDԸjŧVRKvXFkU}deviշ-鐿OaYUJrTӠn7fļ8Hl-ةDZ$6ZNdrZ1z_e-K dm *C5[W&uy+=WMVLfbCta]ۙYq(?} nEٛ-g qJ1P'X7xbxE uiZ9OJGJ=ݷmrm0Ȯǃ]mɆ!oaDgD^WtU{ "z‚!PU_lI~"sarIӴĈ73+P%X)hm JP6HCM-V:?! v8ư۴ B_p/͈9j^!\u[\[=!= a3j0zb 9а`VQW5V]nrQ;4*qBbn2!ɵz 9=[p|۱Mf[/oIiP4~Or["C|^k) TX-c]jQ^غ*[DsbwhG+Wp匨quX1>`N2c\M_a&~+GѻZ -3`@Uky˛qMM"Ɓ$ET?̾7m'5%.=s7)Zrzk :4Bj" P[2(?4> Z񶩷qê: GSE7/St3إkv<*$,ZVI{(*i8yM\_X8зJ'O=IB 8F#ǕBW&?s2&E_#^POla&Zu.y~೻ L3H== ksVlӋ(Okހ p?}c;I٤T YPV|AV(3'!GxwArۼդ&Yuk3bB19?HwϷ~yQ)*ZzƝꥫ}*];W B/IqWza'5d:"4A!^x~8tv?QlY6lt8tЊo'w!?BA&H_ڀpd1HJ8M82'y rq ^ bT1ORO8J¬}pza4'Ϣ_3AikId=ڤEH1}3|NWwSydbDzU=Ƙ- jMzض9QbXnjj"8Iל+{vqW Htu'e G']o8jz2৳{59;Gk@C'PefAuhԩ5fP>GDHÚk[D>5F"C ' $:t_0o@8@x 2AsZ:4o@  q4zgct>dN5}Ъf¦0OcCFu_$S'A GH$үeԌj@Z>[@Agc,@>`P%(x.߀"MLW \l}tdR<\$tdl7+:2E;NRDHHCr_Ukʯ74~\&5*^xuYD1 shFob%fZ 6*%Fem\ҁ61;1cWoiZQeTNlxeި¸UאL<lj+?7Xx%/wǜ@/8/[ B Q-!wɷ0?czoxv( X{ |0c<ϧCOb1zg<=>E|Xw/;'&Hܲ{ӟ|#&iD'd7}7菵7 EJO~m<:J+w7nsܰ&3Dcv%:Ry =x!gn*6Ox'!Qx K]3&iM ȥBwa-d.7,ȼ _*,s>yDtE$NI;N,¡O*O[`7Nґ[l7D.$j?[t8+#?4yU\U;+Wa57يn|P)Lã2W}-֞3zΥmSz]y# wvy.襸E3(e=iH3hS7J?@ƌ<͎_wd.ĨCUN]o לQqly7fF=tD\-&s㰛`e?116(: a?vCZŁpkW 3>RNw{zegYzq} 7\g+kHXUљ3363gPs|l{]i܅ ڲ&sFQ.eevr541Hk}45Y2J.'bɅk/Zf U֠^I~ݺ~oWUo0_ٔۑϥz:j,ҭ.[ܸ?ERv+*s^aXj SՊz/5>)LTpN̫pRI9xMVk-30 @Z:r 3r_/AAt'%/s_4ϥ U`/ &6Z"NZ8q70=+<73eak%,<BV4H߃we{2A9Ȩk\A^r ?(iiR@:_4<^^uS la6Xh_n--75-I-Rl/]fخw a'?#v%t?,z{a<%29bwn3mTt:u5s=͝%'|m2xWl 氌LLK޼q ̇#䚿FյY&[~vW^8Օ rI_PqX6υ'ɗnc/7Éo'wl-S`l=.󣁻e̴ ZlԪ^8EM[M+=3T/RZИg_kow9ۉ"I#*ӵ{~yN4t'΅BڷjEc۴S2Rת-3jC:Sc2hm 45;((Kj~j$~j <)3"+A+iž!7Ѐεzl\'Z,dҩc$ʡ()tl('.+Pғ[X˵RWI[Y.8$rB\l g I3Yg(Q/&U.6a>Tm)ڬM!_.~G}4N/N'ߙBjNj:S pb(t%m-YsUIg>8l֗b6> zEklr)^J"uwsWƂA]jX^3FmhNn1F~M~U뢽R &=SݔWYN$Mי"Bf$ʭbc }ǭޓ#,[yYKNU&S3 ׳ WT^Xj7̢^qP@e9G̦3%f:*g[}DD N#ͥ㻣A<xqѹ՜sgT82j?dfxcx#7t8Fd*BQj)Qu(Ur+TTǶWZZ%5>yKMyڼܣ^=,ߪV/K߱'[;]'L$.J k(-U3ߥQ04xu["M2V rԸOShOo MK'7݄IE\ժ^s3seZ6F p(,NpW&htkJ]1F%†&P:Yl˜rE"hDq3QN]j$Ň~,5K]Sӱ sT9GQ3/uݯ3Ŝ,mn=bܨbN,;zpLG%>7wx>wdWdK#+nsMۦ6V9d;e0pm6%y@ҾΕPQ2-uTf4,N@u5[=G2Í/6J/ICgWub :z@[4\gT*9o.Qs;HN+-闶|6. r8 Sv-*Q}/stpGf#ayN LP&wOԳiY!~Hw0j oW8]$4ÍI2I-鹖*l Pn(孲*@P¬NE]i,^~NV-_ҎƙG?;OZ/~"gs‚h*c{l>T #}b1v/@[@_27 tWc : *Le[]mU 1%K㦸 AdF:둭 zĀ{xT/ӣrg^p~\1ihp]'nC%ѡpF'<0v &˽Bp3W~ \"~#:5 nv:oYH[5=O@v 9dYiLi m~jέd0Z*I/OM*}ϼ3jBd~+_q_DSYJ(8qo PHz@#5ШCVo|d(RI:/ YC-zv'&iq/q%韫7Y,k' ?ްcYӧ>W(ֺrO;#ƿͅ4hqx7~Z]ѡ #~-&ds}I"*jmhƭp;וD]Dռn|0vf'8_U,'A'n/P΢&S4*)~ֽ>72{ޝpve+UVau'nxsQYTCO%!Twc۬~uCDΚ!Uh?>Y4&DM xY5T}gH\K.{]G@EZv.M:Zrowpͮe=8/1sHj ilff:*.ֆU!XF[HECaW9{T.-{k\ \Wޗ*⾚0ge->BA5u#wR *cm~ :_ ,l [?ߟٕNR|溛Ż eg֩ݛ"d zaO-/#z>|Fܺ!f9}){n$"meNEDeA[6m\Z  }R dxJyن蔫0 6FAӞv{s7`s]Om;Z[ZU z*E5ϡ 4Ch4nݮP zT::8Ii(u ށӥ8o(F~[ٌv,CsA Y"ƷWyDkmKrG.hiⲞQjEm␄4ܺ.Kd0 #V4)$^>g閃٭qWw<$sB0 {B>.: b:W.'>=}zsڑ }皯;u;`*r42 h܉r@MY*y鎻ITCƢ1@"0K/o ~.6oã[S[6(α}D>9Nla`7[(;Ӷ3}> 5~kJB MQ͚^ZjMUq _r"%aI !iytApYc)T^;ϰheeb_+š[g0s-zbzg5{P9zܒفtv ^E>1Ng\4g\3|8Hrx)ajHO<]ǂnXnlG}} چ߬2Y̑0e7W>?zu6חLW|S!W|]~J,njVX3RԭEp0:y@ L}C mg ^'^1oA1F#gP"o1A*^ّN#,ҾUk[t&=r@4׮6[rPpޛr;MPp&I@NT@_:4[} q7/J~wN A@ UaΞAsD]=ek"+M:5b刢2h! i ssJs! HJ26.>Sm͖uf"¤'FV>FԲjӬF=g֝2aO:00`])Ɋw *rp>21עتAIO9>=N l1i`Qd3nizrGa3H˓ZH~֐KRP~YPp_WD_=(9heP?Ŝ©e>u $؅ <#J |d35gqCO6"Ɛk琶2FӏNi]Z{;ؼkDQ~xsG1_emsܘWF?|; .2I3|vkb4 X^4ev%JtZ>F#y &!ɐ :_ >͔83N@- D_'(`+),wPǔ+\F}W/ R*RҞ+%}NKU;v pLϻ~j C@N w#Fq7: ) +ƕrPdJ"[ɶ")y몰lN J=ΐdVowdL3_\rc{n9Br/ݴhYFklE1l1.1:@ga<'8Z&RɆ ԓ>XRuke~PMmDq{p{t2 ~iиs>SMYeg}t_q: Ff0C@O=@?(L#ߞq4`*}082F} ގQ%0 _sgQTj^/ESƐ]2B#}ԠN>x4=O hx _?%ɾh'ʽ7əH$gXk{t(r j Jt9Jn 9n 6Pu&Ij1v|kvW6m# gE,s21'zY{tD.'&{zԁ>@".bpk?lo@"|.ϧC1x=92۩?Y'|| ZGȿ-:A憵0Zz$IW_KM-6?[I@&f%}7Y]TwoÒaI.'岕L烑.^?i'˛C|}ZGH  ;^51< 79s񩏞ÚC7.[3>9Oo~9cQn 2۸:bbUh td-ŋLgaV١ ol9hKYhR#9i'1PGeza&2)=š!ե4ٚ?\?>Y$|f3ReGr((`Laṱ$n "{R~Cc ڛ&jjtyvvͻ9t"I=&+kF['EOgeg,+>k3^d=h}w]$Euޮc){T.fL.LZmD C3tκC\{Р躩꧋[pCZ6m?;ZZI$UC@!w[sk2mOlCM?ؾ}?EvN L n/4Q.5s/~fİAf͛uit御<נo\zpx;B-v Y?*y%7bݡ oBby۩VW?<\u>,vEt YAܫ^ - rXwtg |eO^7߸2b}.Q6oXj3qmlڀej/=imQѾ{jEj@VTSjl]ai/7h% &Z)>PMWJW j~A7j`tZ©}\s٭2'zz\~fzc[h\ޫRGsoT]pRaG 7yMIUIdbOvQ{АV:?v !?vo~4.A$a4k-=nT2'8iu=S]=N'J])2rodR6$E{C)b/2mKpwl Gx'^xDU3&Jive8l9HaT+9S(nѾɷ9g"ڲw^VBM>Y,#,$>w*K R۳"G $m[Ulptb lC^f̝"';Y14|ʛiNthcRJ|_숡hV_,S~<:8K,Ň*,R5:(Ƽ[: w". %3T6C/=CRv˿$ɋwhϹ?Othu;r9I1_4CQ7 \>mu[ Wֿ,9Ρ)8}f{ljqk _:#W:sO'Of<7^vgݛix4Pu%M*yw/W:Uv]7Y316aX Z=wuN z'Y7YeVG^$ܦE-vA Ow/;A;<-~pZ"U>~S[ݸKuWCG%{t!]E 55MX"R˃;$]- 2XI:M/WW*i _HjA?e*<\ix偤if>S$N.rK 7K Cs1mv_Ūd+;htLz.g3D ᴉic4L9>rN@q}|e)4#h f..šh-nYD~z>l˘_.1j3`a =\ Z7 s5$γ "yA1}ǡ^ijCdWVyLw}<=O~$,*7$r\𞔎`wȗ5鐯aG! .T Ec+5ŇDGDd l* @H ۨ b?1 (Rpr-=$N'<ldiDuW$ҵUl6/Zeضq?E@n o6 m(5xV1(rY!FCbrĺ=? 8J=@^9"xmβ*Qz(pZJUNLPFd)LęFz h,]WwW@)@lׅrʀ>>5N>?\آ rmӻ|:ֳ۷><j8Ee0NjaYƨ 5LeLos9 .lqI$NHvv%4cOy,l7l=6p.y dqa^Lxd]}GdiЉ8]p;$ x!O83G-c#MLT}DX|ә|lz lilr[T1UAվHө{ο%̞s93@AID4 < vw4G- %@ʐi a5,N3փsY~צ9~ hC]M߮=BȤ+g;NșH$ߡ@@5?GtRP& (NG=@E f:'b7R l%3oZG!l|N(L$Zg '&DM%N0~_ ]Vs@O4Щ te<z֒ޛ$QziT7k-_[x% ImMzklKY쬟VUP+wgnsaHpj>s#z>Yv PGj?MG~$D>f ]˾ͯvz@l[dD!.Ü#Bdi;*^'Z{PS8ΕލOFgdgРh_&cP۷®_fjcp Saj7v;/M5:mupKq=~C}Z?za#?5?gNW)ִ۱O3saM]eq+Y%٧3 D)Op11ˎGU_E=G. CH nj :-uIy $BСkPW~1޼Tku:M^Әo#z>{G:~zYe\KVكug3ca[ꣶO4:tk[}|ZUv,Zu6Cm:o܆_z9*z\ߞ 򈞎aH䍁r4~_(Wzv+A"ڨXUcߪSx_sQUeօ\DI9# bŜszsRĪVjH<ޭKh?;Z6]<~֦ad|VlЃNtR߿<dH.޼BQnӇcbb]Ѧ6K58h~ѷwo]8֡tT,mf[Wr{Y .Z![*G+ZӢ x3 (o˒t8NrN9r%ce 0- qO giի. Q%diyWWᱥBJQ*R42U"xMb6X,|SSm-#Iĵʅ_[3{g8#D$_]7gkǪ%mڜn+l7O[6RԱ5&\Q姑}c.B˭?&gUW{-NGmvENѼhʏ=p;/?tNb+j"V>UAڿ^g[o*T lcH\X.zj-m~t2hŮnV֌A`|#k9-[^x)+}4V%%iihנV x(LsVS,8(Sȱq&jLmes˱#G;?l}6ʇ73wLX[ȕUy;2(56h^^<_FKg4x@SIFT鋁+<07Oh%WsSˎ}&ׯ%*u ; }ZfaL"Z =.unc(hFjdfuoJn@BG~KeשʯeIeh4rtq9gJ&0ey:;jC釻Gs+gCW֚\Bb$>JdMz5>+:I%P]SjOWWܐP{"}UE3M3W?鏵iuKbVkb>+&n{,[Chan54]c];DWnymVzǤsUG1;"#geKƝ#y*ΖPĩQXdDo1EBma_?hfp7,oa9a:tٱ R3R(W/4l z f7*L#,پs3v)y(#!) Jj._A{ NU\m9azqlqO-sΕU/Ý{U)jT ϨVCW C/ʙ]&+XXP_{7a?Bɪbv=J5A 8QtV;fe:}Jb1ݙNanbnF# 6Z+.C۹bcļm)dG]dT \ Uq·S~kr>y.^\6y&f~VKں/Dj="j]Էjghk& M^L3O՗mcdJ(WIxMJis};6#2Ul;V7~:PAvȥForK99g$&8b.VI]H??6IвI÷ g)[nȤtq_ɰs7((9ux|q)wMSYg26圶e.5ȓD8?[ev)s4!XGS7h*ZDs@gDVЎ&q-@{9~ZIЄA꫊: \ع(*N/;e%I>d,r".hjv-Be|mJZ} `^0[r<LffGKyn"3`0fptSmp.`t6i6wC}-DڔO'UYa5<ˆKJ8>`3"`/\lIhv5|]0΀G_ H\VN^ENi>UvphK>S;5^BLCUȽ ΐ\}]2`"\6ax]k*QLc@ 9Ggh D>ledև?[Y*?K|T05;C_b%`l7U5^a&Bf}@x\v@hWx+g ]&uwi$i-%H| U@& [?sIs&UzER\=4A| =2}OYiEi~Oq|Ӱ集M 2,PxPJ5m9fRF@ M7%%( g ef; W A2DHfr?^>ELtbQh"|27 ^Sb tkNnz:z@/6+W"Y 43ǡ`xZVu'|t7eizf ghɐk쀅#4,K endstream endobj 174 0 obj <>stream &l?P%.q7)RuQ"\H$K'aZs=G(<.?r{\u|c{f^vବ^*jz2t {Fk9 i!Nt˖)z+]k1E"9 }bF=Jphq"A֍ե*id]X )C?o*7읨[xW.Ɗ Eh#sg31pÍu9WZ/:hh_ljrt8&̤9񦙲Av_@\igpaΜp1ԷMzmT•)1I#xG1B F^ls ~_ozs32ۃRcmZS:W-u˶7d È\ P[^$x]cn#ft?4}~ftrc"&3KCoV1pxZ=4oIdyPwa/u^Zf9#PI3Y[3ax)b{#SQquWiJFtVڑxZ\5ҴJƫ_6l:G^^SPK6" 'WU/UB)зKj+͹sץmKR7{$#\ya}b݅ZVuQ,Հj*JT Akt媴fӢt3 O1T@1#r/ցM׹wwTd8kk%CF M#{;δJ+JԷ]w)s+-|h0**aZϕzyZ\}VAs~Ss1F .>n4긺]̴ڈZbfRYQʻ!"blcqa`#Ң0*i<>Zs ˭[]:*Kͫo=ɢp_ƹLS'sGGX6E3Dn]%ƥrhy}VۏB{>p}a6l\͉Q'd mZ՛-˕܂ջ9퐌bdgbfwyXN!Gch򡾝LO|<~z{ydE'ZuG q GYc֛aKZ3sO3OޠlK/3%_V^W4Al5^ʰaV[hRQP[\e Vs:ae} 3]v?۲3gwzdXaA/}Ka!Z; !ܪTle)UvLrOg{~;|}R`T>x@JپxZd*V{9y;;oPoզ^ݤkkwEm5 _vm`8l4lqrsǂ= ?jzD j'8gץnJªb\#p|6#FkLgV\LtoVӦ_jH9쒂͖obAdpiZ}qrǸ'*ћN;ɶtG{^VYtMZִUPz}5+CL %A!^Xd_ M[HB8gSi2lx%4#T~bê5VAnmDU*#*a0*\*@Sʯ)/W |w.%ò:{4E[Gr'f# ӦY<<~8*>[hcLor+*-K0r-$M ]а<,`cS]lxta"&8Eʫ.ИqDbU*ب\7AД;$60$~Րf黨]J EH{a-h-'y8ݭWgl@e*Xigfq5xK;{dEɠ^"s$ !, L,؅{IQ1Lh`8rk,#? }ց ø 9.Lj65G;ϒ[Ki ~9,el3$9o^>]=#7Y1'[HbK%& gu3h<1hʉl,@ |"~-E#z Hb215151Ro;EvmS2PQ 0˶t\\E] h$&% {9oD knȮ 7Ldy%Ң9<ڀlM"^-يkFhiFu k!}џ kuOw(Z@~vҡ (he@(7**[DS@MtzjRjps=@+&T)H~tei'_kAG샹C ^$;| Y|7kUC/o2X$@ h: 'ǐ^Z~=?$2N>5|Gހt@6)<HOŁhknߓR>}r[et xh{ŸcC(&" Їuz0V)mGO"q`Uj x[B?԰h/|ܜ6x<9ic,:#\ȐT.:{(v$m}&l pf | c<8{ D.i- "=f' 0|oD;-$ ~ÄxPFSv/:|WIʡS2߲:؟%GW@ nHbb~Q[:) 澫4bdU&!q%'_u4'`WN=ǩi<|CXhH9ZܯeaDPp=f0rg9^ܓo)7RH;QTQ;aI/p OԤ_oڎSޔ9TRCm./MWKבc2,֯ 4h-:M@MUj`coӅQ6G+A5<y231?>&_i7]mP[g6 0fPYUs9'1՛7.YMRIϒl|\NK>v5*5P}Lon;\o۵{K#CO_c'Mj_$$|P?\,qz^Hd|Y }v]+p(ݧ_'}ɀcbÈÈBÔm nkqeD+e:RBA 55J}灖ȭt+Q/]_;@C~W~˅lvR\=JYCeL;>Ͽdܡ, C<&뜋wACs[(?ҜCAK8}/$/i lNjk+ YJ*b&iAZ۟KVt)H%{VYRWYovmB޿fF |^JPٝ{ ۾s^QYƫ:waҮ2gt;xޢH1{Q}]q?] 'CV7Vu:[Ɲ,OǗVO χH|;k߀ۻEJUcdk#׌F"U+QPjͪq~uQN_UB.rU!b(^2t:^%2Ȫ _ ~㛕2㝛gSYH_WWl,MsȮ¥W5 Kpe]]KAQ] G(]lIrpX,m;qd\m5i-Cs- ׸ԭ/r\9|fVc;L_hRFe#gAGh\1+4ck^0ׇB룝ΈǫVc@=VJ]RM ?oV +7mmj EʸFjM=t|ٚMM1g`ϲd,f뮣3t^Z͂;iMfT J]}&e 4ih|N>_]&E/ם jcs(pׇ*o?;ӜIXm ^^^}HuN2zMm5G[ҾCE /rO ^ICE=I{{pa}>?*63)sC#4\.D( ڜ|8~{l}ⷘ zY1a7aZ+Nf?#eQר^^55$:.E5 YZ.8I_F$Ad뜘8~~mk|2mnQCW< K[`ұ58Z$;CX@1tܙ5\4)߀o4ckW)]%79%h~eE"q0x5nVlQ FwdDkdΨy}z #ͤnϞC2JA!)9:guXv>8;>eHC+(@IpX`4p{q{!B8I3l:տL:zh^PjB Ȏ+Azt'fN`r/` .NE9&;n;pe#bQw5 '){Yvn_+Ӑ{2$]1q2H;y*,~=j}l"fiH35nW|9(NW*Vi!z#v>zꢵօyI_B^),vڱfIזߙj&g%Pr46*}ԧ!Tv T3D:1"("*xC8*,=X7^k,H7npgnQ" &zId pq"u*g~8e46RZ_Eo"<NNO.oKʌF4 [%6DIDF)j)\@@X90y嗀`Jw@_<_۾FɆS*^Tl z#UNQ$i|AExAME1Co}w@?91_H^ڄb/~"&~&?lm鴙ߛF~Mh\ G@}0P{1T5P{Quv>dFNhU%Dl-K)q??%"M_~n[&x?.ē[ŴDMpw0Y Lcd ?aixOm$>7/?!Lg3ge)h5kwVKם^k\vwx|#oi K?9cn/i?AzS?o_YKsZ݉8Ttv99,֧ՃvmЦ ĺ*iwfq9jϹ@ge4O1|ήnEpky/R:HN/S>ٚMBw7:vx-="G||]Im )CMZ7-sc ̝m&"$L8z1?r=m0/vU| C/|׎e#ԺծrItlΑx֥QM][+ 6FtY8C85T7jLľukoF:U좣s9&fF^ͣvwcu>7URnM3ַƫ5lG9C GЫ.*HJ4N8ðJ/d#ZjR\ilL'DǨV$2frM!ƫ፾#,աiMXۢ_ bk+lv;`TͪxK1HR&bUVQ"&:uYhfT"C_UrU elc3Y,)oS.T42!sh/TpXOlgtQ|rvkϗW g=NWm_M s4!UbuIC Kʳ/|gfXpqf"^ާQ/}41GߖWo?i3'68\&D9|6-gvry6eLm xNKL\X-V>WpoޚJn]?J.X[{t݆*◨8tTXd3k5p}lV/UMwN֨ڻ[.tWDD !3}="_zY"ӟ's [V6-S[ۋwŕ*먙s]~-Ƣ kT^Wς 0 lWn,Ld3?׽JAۍvꑈz(\[JT>WU.[4eNl`f7Y̜1緈( +V_7Y[:xN!6PSJT.bW݉|.7r\Wo.%V{ۺDnF,ɝ4N'Y4R#W|Guk=8o{}5f_$ xUQR8N,?HKkőv'/yY ϖd/؁8&nGa݀5_ڛvqײ0*pSu[Aŵ%PLY|0W2ʭy)ꮴ7.3cdXb$DUqn{Pߧr˺c蕹GU,-mU~]R} B"1anjb_);/YqWWcW[;c\n?RhdlN*O+tH~&e%WR ?uwfS&YJ*}+_W] EOGN~z=U dcI {X[/V m8L.2mНק^ܒ*t܅߻55 |2+D4\ClV0n߶WIUm#,ƒ_ OGn W.*s07E߽7G L բ^F*9v191 *\P< [7e,3tW-7j>g\vB6bTVXX\&~\;I/uDtcJeEk(e[c~"9Y#N`b#ƒh=I;b{hu< Q>""'/nn Rϰd;ۿƆ,juE^H?Ұ@_?mlg&::ހ V[39&;7 2D"I`"/ͮ}ۄ ʌD =_ T/"=+Eڧ~ӱv|ᶂ5m"j,e=f;,V̥qvB yfVztgJƁ 1kmxџ^}`3b@ci驀 y4WKmrXx)y WPg>҂L?CiLsdkiֲfշ.PjQg:oFKVμc8St ~KJyS+gPl:}v.C"-ᐖQ4=! `,CT@Vz|Ȧh\Ih)7sn,b^s)gk zYuOô;;m9,y ~c *iMWDɕm~bzpf-#CVG3W>@v r9P95[1@TF"AW6͆QyS j-<Vm}"&9GA3f1g1W&UtZ? 0c 'w+GS6-+yXbޓ,&,*sb &UGy3ND hAq<TqlZoK?M3ɉ yooCtig?>l4wc6WpkHiGvaeW , ЍC @)3P ]҂R*@9Qz^@@Owнz o,c{|6M["/'̄}n'6w.<.C+3iZ'šeش7`M0=RMB9S7{PVk4O+ ` `` 0F` c.P:%wf #Ⱥ9={٪P{k_d)>u[_t>w|>b>_3MoCX',`Qn-زY2w[P6f` V%8>\}K^a.ڍk.xu&CkCaAxb'X:`nIX\W\pJ~rׄk\igv_pl5B#vSw Y/#ˆ$>wX$bبb'ްE<> \&G<xSRL~2u(o;'x_rH1%xӋ7|Ȼk\s/* aG82Ǚh!g(XN;@VTP_hWdNZrڽyf|߈BĢ~́ @$%}ȣgGm(tRnl|C)'ƌm5T2(?t䟼_q!"J- [. +K%Upb@6 %PR@y@:SpX=K,WRX7M )ZK&d ?/6^6h":w; WhAET\P pA"&>cOW܅{=Y嚬~6=;8N$:+^5S6\گUМiˉyL-dwʤ?H5~-adk:8> @U1~_O.=k |{JXulPW <-SFt}f*II&;ҴdS_o-n{>i 8~dy'ӱskgSakZ1_t<=z o>ǧYWҧ"3g >iQk%vz- D' ^'5Of-2ꉮM݊:3lbăGL&gJT!?jwMz.𢩋%|k:UaQ}˝#͐ftGM>5H Oӗ=o^6u֪=1fG-VјQog \Nn:]`bb=pAfzӃB]cv> i_`W;d2K'ljM[%fU|%ZO oi ad/Swu"awW٨ٸ6R%sm6Ig9Ql·ѳ/ QCYMoU/> C.p,V~u]:\̦ϸSO2"/w#*eϑP;EWE<請CO([\hBK>V}]QsVTW=Fa^]Ω\Vܥz9IpOC+:Mg\A`0rQFc#[L&QkbF?#pՓM^\OI#ZY]Jgswe-;/x yxHr#t鐅ee$ԯ-};~SW > Mz#j:΋kkR=)u- "8q_G>Y=SQq`EJi̳\-*WYOM|$5Z" 9r#&twDZV ?x:]^<3lf,Y<:k|0%Q,Vڄ1mJ6Б{8[K\rk8<,&;]Hn#^.½FB͌ۏQKoEvZ,M:ћNedQfAG"ڷ:_aVVKz-:MctɭR^Ϗ\vVd0 :L15w\=q{Q=a'fSR<,*]n#j[ WqJ(ٜ5RK?7%;ds}hw1>7\+&[{(m^X(Fub)ƜȄޡҙ5;=]'*wfi|&ml#mP^)|FK:'v1B~Px#N#IExfA6VWbQH_dN}/Edd<6E k'g$9|w7bVepಥ灮d+-R*\ W;=Rctg3F-Y"p)1 c8n2X"ژ;mIYܬ`mV4ӪO)RZ G }ǖw%7Gfcx]zj.-V{s]n(e8AIsg`+ĭ:T plahCS e,D_bPf$m(vE$Y5^A*C!*u_yCt>a8g<rxPʳ6#[ޚ][1Cެ&`J*a8 2{ Zf HB9;y@  Hij[̜Ia(ssˇ|*A9ێ29SSZG*dk^Os{7R{,yH@'9@.d J؟%Y2ree@N%@7+@vI~[}] 5o˛O[GA>B:o@RU~P2f@YJ9T&لr*yJCZ P WM %NvEH+!ERr <ɍu#G23kzfT/vf9'zw\xވUnR.3'>,%+@Yiм4v O6#.y0'"/n<`G8fC2c? J؛@y;ƪuW5.ZGDٮble*M''kϲ~hg ;߰ 9; DE|r"r[l6c ֨xܧep$iw,m+_̮FT|gz @R ߂DI.BP1vQQPF 2R60p-ց0KI -ovż|%-aԬili=+2D`i,gd<_ + ="IJ4I"Ҋf #. ȅ<dTyY kC yH; f~1a$SZZYJ&BZimċ+}έ̙(KqhE5LP E:!@=Ѓ@k:Z@@v)RN( (*ـ7Oi&DڴQ5]r~mZ[*gj/ߋXoh퍪7y_=eX>f+W?x~ҫɁ dQ-mA^(;8.~<'KcAڤ_8VIw;oph]8s19y9dv~7Y#tO8/>> ~<ixNUNZ)^\s3e׽chѮQ n>gw̜1loa-*M7Mgݫ6د@Er͵rGo{rȞʟ<~\Zd{v^xτ65g|O8:q^||#Gۥr6L4ȑ3NnPmk /|PÌVyQ2wC@ uV̩%&.=9ao]7h‹pۍt_eZ>lmzzƩ+Zvfd//S6Mo+qݒl6F5ra(eBM]y; FvP[}e8ls1ϝ WG۳VQ&yI{S(_S61*f10/M+3/+HEi 'nؗ"!rɆٖ^M8נfP[,̔oƫJdNf+BᳪVَQw};?nyWsE]+Y!Йz( -/`S:y E! dQ@1$O%|NjP";M_9=ѐM݅et X%Y.DEΏ t&Z/vQKy?yySX"lZ976y,>BS8#cS oSWRVɰfĊmj"VlLxgQo\E꘧o:5:ejBBn)bid8s[|k$b9:(FKLZTuhX%jGvCz]?tIu,j-jI.PX1|m㤻McFDC RұNi"dxc&~p~9x½ٜK+{vzL|dEP`8 L|MP{y0} )I6E |LDM宐 deZM@4&m_eAՔxvgeDZ^gk=sc]s\\6YfW`ša)lt}~}R)8NM^&՛'.ݎh~wKuC~ϩZEB))Yd%H-N D{2>X!xa@ʪ:{΂idkYHi?&M<@8 Z:/͢ٱNt.Dlm"OacKd-g-òŎkz|*gY9>k[\[Cru%+C0+1w$P* H&rim,%> qGnFn;($q!,h"t\oCW<>许tY~pzy@QK)&nmnC@>7c@9гs @YQ`i8B!jrT巳pA=̹˟y+LuE<4_^n8q`Ȝ)f#Zrd+n&,vz4怊|ր*Pj7(*, @:Q5eT G~ (}4f2IQ52b.vnj6>SjmDq~!n r{, V9*/Lhu|}:P"xщLcdH0Iu #5U)+&"QUc SDb3һ I)Ӳ##=opA W0haF~r2e&KjPJa8ơ +=_x`h +C| VXѓɍ)NKg? \fIq$B 3*6YEymc_Vy68`PQa.%z^*whmVc벘}l?S/CLݕw8WuQ7V^{ t;ٕoǢ|PK̜\*#̝h0@ϘuQ_QP) /fl]weove9m_[e۸/$"7TLFM+S jχy,J#ȋ7_\1FEE! s'Bh07V_N0 PkG$%qyԚE4jiՄu,S9ubE{2r'vJ$9.YDwWt󏠘`[JaRTu@ ]:8RE ߮.~ *U.wx*Wμdʺf*gY9l+':;/WQm{*egdovRAZ'c81NXJ|NQ ZQjCМ<7iيv\ץPr-#?Nډ0HuI_~Ip˛["!]{l;^bB0 N&A%;wu(,3MDWGh8i$[+9Imt(痮p:ӠdlOB;Bt傛1n|%y.d tC|:3,Yeh!zjit;t&bM2>js{:U>b)z -eН9vS7r)K%pmq u)qD~;1 Z1q`JNEŃ\A3;,P4HGzg3Gzم=1tNKQU7K< Y}/i{$t_oϲ-.X2=3`z(C]z=vt)5<-=v JU%1[ɔ3d. `'⾪9o n0)T$5J>Od1_bͥ rRawr&% R5׫xLTn]r\)ġz~1 H_ |R4Ir-  ;O"mتRYDM#?tb0ZsLժ7+UK)"!x[/ۣ k[WnʙpOw]@Pxġv2f}TT|*b~=҆g.rváWT)Ȗt0ico^=W+DPIduhFn=X}|@?#o(Ѕ|T}&^mt=tL‘QYz?c}~KJR`%N<B̩ôG'>7g\%[ġ#`;`n`' ؈*F`j,kq1`J# 0fj0(}]Tvqid0'S,G?`dp>SlGD$2L¸)51 3)*`: sSٽ,BmnI_yVlU?jg*#C;| <X %~ H7*_e/ H=&x^E0SNpA'`1`(BrUi.)HHh:$Yq1-N&ah1#"N;1Ot_"/FeP+";g_@WV޳.ж2?>ߔFEǔaHo GAJPZOX/"Ep5 Ƣ)[-|]T`Z`҉-06Iαwq ςjYC#܋F؋$9C ohbQrlkC98&hnze`%qXH.?p+4sD۹Յa4X>z6`iEQXm=WO 0,bmmrro+O2;Û_ Ny` (j6>?Gů )Gjj/<%nR^]:t mݩLa{1qel'.ݞMn'Ww֓oc^6:lsʟxZ* * a_5M^JW:<͝kCts!Kv06ٿGXF:tkkX#vVFmvkV츅}I>3;Fk9=sʽf3|֩'ޭx}TAxgѡ /5:qp5u66#8arP74~;<+DkWꐪ{PW1 Yj]pNskʏd/ 需ܔ{RZ>Y"1>g msık맗(m>5{lk h^u]syrn=ޘ8CVIH]1e,ɇgU֧ld{v鴧?Oxz uN>jl/:^h\vN^2>Ij},R=S-h<[?{W72$˿?_qg#_+crɃR">Lp9QeY^z^8nvl꼼V_y@03ۆ c֟# C}WSjAuw'N?]P'\͟.?5Gu{&nk½!!߷obk'7UKC?o5Y':ֽn͟ii){?l߷C W7bk`LGRRL_䱁0gg}6kM< nY\[KWG?uָौK=AG"#[O/OOӉ`Sbݍrώ9t6#^ \ͿRG=R6,Z>-ҵu$[<Kop64/KW33mwWQGjTN_>~)\2< ˇ|~^4GY6S\HӠ)^t1}_?xlFj%:s}-W?df{,@0m3NDBa#N'juԮQj`j5,uR6Vk;cW֯kotv }6G1V'^lkL=ti)sUEY=gKx2oӮ׌k52+//߿_K&όf/e}ßT'u+aO>imU1ch&x7ZdztdNjrMٲtb) %w-1P~emt]M1;x?k\W-'W-;t+݈&aTw] Pq0Z9Cjmn5|yZ6"Dv<;I7|1rT+V: ?^jWN^ R}_G- [%-~Y">CǽvDBlwƖ=Kc$(ǗWac_Z­ǟ}zm=1d-Oew>s`g [Fָ|(tk] nǾ' חp 奌u;vwB6^pɗ{Dk=|g/euۯ4.Eϓm7Ŗ#xœ@9`BD ]z=lVʁvR8cҵ7-Xje!ql@I  e{>t8e1d聉~ؙA1/AR֪@~R%˹{X~y~@LvvO5e/q8ԋ>`Iwz{f+ :a3?-g~AgL?p5?m(k׺tF~v5Ia3.Kot4ϫTt#z~ Ia3w5-<lσݞr.r>x1qayR;aYaE㰞Mٝ[nFiF^,Ec})Q \P^J&9gY8U[N z `xWQIyg}vEz_ ~]|ġI)i1s(|l={W$Sl3sx>LʛUI23^KFVZh)"f3-u;vZ!PNz{o]7yj p̹{h1MO yu"*F':ϯpK/NEz P~,NtB 56ޑ~΃o*c/E;ŷ5>?{P}.OvM?r`A 嵃Ex\/Oy=7'P9dG$G.ۥ'πt0;Di^N~7iM`chs@yDڳo0 ϸ֡{[嚶;o^oin?;{w>Eq ŠT==ڹe$Bӛ!贈g]IƏym7\òZ3au`w7\Av^0ح)nV~}2y~O3jBk:fwZe9_Dx.I#7UW'0kLm m΃s|j*zҧt K:sgPjv6|:k>r M3J7խy. :в,bz\OѶ^ɐ; @~䃄ci"``ƊNff7{/u1LQϓ2>Ix.bT;on's*UDW?*S#/ҳf%JM3gBHunu|H)neYQe['!1'FشozHE Fo^T0E*M8gۊO{ˠ-V {6&% <6KɆ.,NNiuSO&m+%j$f@_v{0<| .TZklXMGͪetNԙ͵8J^ޯTx%If#!DW@ɸDZ*+J*j]cfV\FscY2T'w-F聤 \*v$O|gu Pqg4jc>TuL01i@{w},P#43s)X 8Ґ'RNй5['6늡i ==LƚJ(Bt) PVJ~?!G*;Ф.C孳pĐ֎5ގLr;LiMSw^^i|jճ&'AydӃeNV).-$1s|暚C/;5:czw,Mf`3:|ZNL*4Ӡ,:ݴIB{H֒1ђ(r2j!t;xA3A}Ajj=?OJ΀bՄz7P*ndcC Ol1'C>JϡY1;Feg`BQ,T4lԖ S2 (v-(vGñ@܈}w/F[>LygS5'c3qެV2i&W6QH9#>D*V( PQmP\\ALJU|3l]aȝkհvir {f- q>YZ%?75s>2ǎ|$wN u%~?@Q\Ƥ8tC/}\f3gD& dVB3J0S>h=n<orG7$ R8jO/Q-~_N,zYFkN&b|&ٶUsO?3'^q7WHuKc4(tIy\V 0 Ѿݟ0%9ߍ-t]Pв=%ZJCneMXE7V*`ncި~!5 rM3 G?Et5'06%Tp!U9Y-hRTMrqk>P/SqjR{p$GDXeB腃O5<9 @9Pd5@p%vbBN|G{hnم։3&i޲BP pM@0ET&k)zEcǘ1d8he6?(^nuBN*Ce;rkКaQ%TBI21A:>/OZn 1j SznOg:.BALA9]EzOBb Uq캠%@,eK sX'`Z<42{*t+$C{WuSm y6@o *ׄ?+jRfWf)uSrG3SgTڞSY{fܷ,W+]pCĜOE|Atf~ZtFFY_1 R]GgOt_Ǥ_Q}/>g tn޳O2?086/kxJQ4\ܘz.u2 ɎQ1YQm3G n:UIRϹ9@Ӊ`qgA(Eu6H9h̤|ė͝v ,}wߕ'&} ׾OJ|;MTXi1'PBn'۾{/\:KX:<} OA3.ΣO\d,ylzLTx*ckYd}oOYY7o͚b5{ڛjM$f@#@C|V?5K*(oGW*; ~=p_sde:.|1;'q3?/QxS+A'NO(6jOX=M6~j2NjUVUז \Z*X "Xf0-j4-˴խm(?&NCۢ}h ݜ7K4NLY(&%hg#=SI1Ӌ)6JR͌oz;LgP:Qɴ)OhF/sH.aSM73Z3Kd6@ WW[tt9=rV6TCuFm9M`2!jzI+8'"(Jwo37:6"j%r|FkeY:",]̍k ";U/G{B$2> ]" 'z{ ՅR ʙ3HCv;^2!cT<^kK-.Ucﺍ>g0uF|'IBei\ Cky (x@q%>g!<9Pj q[hv3 pE: tH1 bz%qJUϟ|8̄T@1|Ptc4k9l번*:w\'ՈKN!T=^zcRşԶ={v hE?9EKj4(.U&|Pٹkq^B8,}0M~?*A0'>ܛR*]^ ;VBVZhbsjԡio9z3J̎) ~.Yq$p] ހ[)W]yl&G}"n߳͟ą*UgS;wEפ 30/Cu6qd>~^8zlX 's+`ݖA(r_jWclCφ಺ö:DZ]͘[ QX[%s7dk06zrP~ Yh_vT=ް'Nrݷ {1TC{wPaw$wVP(.\3=SΤW{a2M7m{p)w#Zܱ6Fol>m̏tNAloqPp2(}0).X#@=ДZ F=NۘoSv.Wd\K;9b6R]$OJNOKNOKs5Qt_?mR/'Zju(u]CUJOI6Op-=OQZ_MuX_1/K*_*~fhTt Գ;[)d8 #yhRg<h @Q*AOngP2E RX8]]K;TU(|jfou|'N\<=w<\W@ҥ 64g 0̆u u@GYv9i!Zݍ" pQ>rLW"OM=]yf2ʏ 8ܿAF - a*GP@FG zOqxx>&ڲlC/2jNӠE"w~'ҏ $4)V&IZWs{:Uc,XV௙G}ӧ[_]cK;Ӝs➦ϩI`T'*S/WU.&Ն&UFR Y-)oE=SJbeIbVj%1 sDV&@6d$r HP̥T`'dX~`ؿWj3UK;pDZS FWDZE/^Jϭ7bX"q|J pاά`tJ~qOUx,>vm.\Smkjw=DeQ2edgn:.c=]_>G#ATM}>OAu?k+x}'q\҃ 6אZ-} Y@Fzv9ҷb-BFk|p Fbq|9WZ~\vvT4r>6+|գzΤ襱K#s(9\hkV.Jh ǿ'Rܔ UGF|#&TNT(èW Z|XT]ͥ,;W)*Meۍ1۪n`ü[Ff!./ *3pUX0Ъ`TH;&>aw|ݍXU%y" mzhyU%xJt6#>)9Q|Í){b͢s`/~sGp̋o1[ jP}_B ?ZfyyN*pII<q$[Fxyf.VܬwXz8 * N.VL1+*&(4(wEU__"?^zD6|5wf96hWgE&]3vypa2劋6H&M6Y$fId/I}RtT9ete)sV+:<̟B؝%~(RR#SX&൞Lx:;K#Ш3|]<ڭciC;/ |ѝomYreٝ/b\}Z_ Jq R3@ dU8=l;$&{a󜳱Df97/%5[*(WFՕT[R^>mTsDiNi|GC]'@k nF3V3bi4V8h;$f~d7SJK1Y,Ҟ8U:2:I Թ4gkhg\yDv 3Oo\r^cTz|;}o%'&?Wl*$#j!ƉIV.;8cFB@WB˃6(ụԻsҟۜyuG|4vımLk5Qnjy@e0njK0L = aUb~ayP -ߪ}TsqULe W>: . w\t}yMReqpT7LdFz-.h>IJvzPI(Hk˥@S]#n\^v.az.R^fh3YjC9sqOeƏL'3쐣,DǙ /ZlPz?`38XT::A@;ʾ˜mU~*Zh>1C wN{f,QY(kkd1$k[lWw˶1&M$~;^"s"e3}je['3U!+{>fGbcXNZBdw6c|wpu`@E{zO5 ~W2銡E2١Hxz]1$q9xizv&S&9ԃ 1s![Tʊ7BՅCBvԓˋa)~QJ{.%lL=X1:\hאq'>Z(}+ lp_%"Tν,NqbʽR^^֌^7aN$1&,9gnYMh1UCn!)WsAmbζŝ26}R Z'XP5ϠQ4zx^AC -1t}RpA~{TEui z GV'le+W?҉`c^r\ 7A_-;I>5,}D2;5xEY}rNm)[J]d@~QefbT[A/#pP05W(eOrljev f?PxHO8.Pu:A>qX+跒/oVA;ӻ;m`gVCtm"|O7}J.тq2C Qv/N(U&ڰ(f]`ś JqJ|>JcurVt v8>[OZ "֖F'rAoL{8?(Gk"G9 e6` ?egCth`yQl 2ƌBdp["iO9jֺPWRK.|]7F<[k '\B4ѱh/pt:>!$~YxX*@>k,(ɷSoy=2SQ%uK;zH+YwPYB}u|'[l$:!灱ϞbB[aW|oRa.i(Uonamc|@¸.xN] NCDԖEiJ"–q }+RY5T)hFs]f:jtS? gX \ PW9KǙݍMpcmKI%Z+vˉFZy琪VXr 2vHTF|TI#6!yI'pzX{D 5W rc1NU=D7$6bVH(ٰj9Qz4g'k.SbBh-Of5^9roZN _&_*2(G%}L$˗6̛9jaTL*jF&z-O'p̟?8 1< gmݯtV?h vP Jv :w(u 1ᔵlMnb{ oPuEںNN'0AP7 %6,Z, L*=w{%7ϯ8tJ{lzBݫJwEX'Si<]yWJ3cq:Tl-H*zDu pR *j6 @U{/j~?g+*Am+F[eg!]<ɉBNϊ_tfoV4uwPGI=i0ki˓sP# >/RG Cz&R!.#Fhl"lyXH [U'@Q>;ƌ٥5\3[s2dx8 L~@o&z] 2Xͽpc@D%P TZ{us5 8̡=-u>z[qdh-9As>oS=Xݷк #y*~K)CwdfݧlC`%])r~?AY<^{*5[f{*vG"Jtu4nvHt/[m,,=.A|wVR3qG?8~V˨4)~{U!WcVE^213E,x\'}NH1Vڹ[hWȩ*N [~>kbyH~KA(.mk{[3&'Bu}5cWJg m1;!q:,{a J$?atiK D=!.,p jeoM{qUyˈe8ih+Bt/N|jCYX"@i(UC9$E?( 5r-תx}xtzpRȩUAk0sZ;P,hkK+zB]{FF^ qf:c:]wlo +~˛\3Kt8~Qiy7V]UQRAq| G}ʛAzSpx)閽kӻƴnq^dƪ >8'!RtdU;|=y":sY5*@P%|pSOKJ0{%+W5?k*T:{%H>Nk.$BXo&ĖќiU|/~+G (v*fS}~`3nׇ4t4Cx$oc;%m, 2n}U%lnH'2%]lT);:4㵂#6IO4 ۑU~ ;a8,}<71i|\YLd=OjAເRQU 5R|G|zV zvcr"4) U]\Z/?i4|zPfx[`jޟSg'-O?AKNQbU|vT !J}= _Uw~T&u޵/i6I8{]uy яO^xz&?Ű7tP;zeR7B,(ɬv;nWQ$LΗGyZ1zA7Bzx;>Cn|f??.=7z@* ¯diϿՒYR=kI;C)wZIr$zpfcXs8mߍB$MllI?55?E~ۃ1U(u2[Mn,+MoPeĿzͨ +AJޤ–2a1@ EC`h=9bRxVD"+tԫk_ލ\Rq}] ɻVODe⚷r?(mUre58Ō޳kUl -&9 HPE%03yzg5adu^dv>糸[َ. 6%9Iim@])Rh4r%uBkOث8:=^UH1y7ܝҾS2xE 'lc٬?*$4WAdkq)09t]>ǷNm>iIբo3fۊ~\qzڶo=En~Ժ dBڞD>  A+T@k%(?At@wKw|zwY7ּǝxh fwy[23o{kǫia)O)AYRKM(ս|o'P(eJʀ*E(V[C{Fc|'v\T "^>Ny}|E<чG8]L||3)O='~z[ڹ<|}_ؕnPa@>;[ʀ.{zEWTGw:ʆQZYy1<9}Gy$Ϥuql?!/X;y2<^1_x, H%η ϕ?IҲ @j4W{< "+V4M?{_]>2mD|7r_8wf+k;d4;${ވ?HR{{o $WKJ4dJuߠ $N/4,2={DnyZo]t9\OOC/8-[Cp9|myν?᝿ER,.I@f$W+F6 g RrxdȥVqP~&j\ۍKJ>:IG/u5V#e~y?T8j9OOI}$J_sբ$2Dzйw'U7e G;tjwcny'\v/+rLo6^ j.MyY\cC9zQ #r`{HfrK0Ϡch$jLȬ ՝{aA.黺 ږeҡfS9Vj ?7ge[&k|:saަ ̲u?tG$5pi{V{pr:coDzOtwoqj`^O|s[sVO3 ;aI%WB92@1wŷV(z:L*{:﷓; 7:oܒ~[sa -/jf~fhe =H~ju'Dh6oQW_p,.9(6߷蹣05OKGiEBqݨTw͟x|>ֽw섬y]v^vEv-]Msm,r?@tz&)I,qB GQhBe (ϗiTZ;Y]ܓagsPh5ЮW}i\ f^2n*Sy m5'P&M/{IO *^XTWcɮsfZ37^_u5nL3vl}($/KD^NeyY,[7/~l^' :E̼e귯A>taa057^lV&̍xkWf3dWۙ'y dpS,$ `M Q.7b~Zkҡ'tk(x(꽰^>'VZ;'k./6DgjصaOb4zpܞ>Aakk\H=z$?䔆RżvSPו0?F+,;A 'O TM׊6ى$ 䭈Qt> w8~3J!nZ gc;9 +K8ḧ́O2SS6u (V<1U|yڑ͍ӡ[̞md:ʲtyԹ^*t~h;e= +(Y2VkSaL³vI銓N<YUӣ{)sGlc [h6י lݶA.Ϩqaj%p$vQO F/VmV9~nU;baܝx78NXʈvpe[0VN_ڦ7e|NIKW'% @6Re!W?lJG.{;͗0':yjW>.Xb4i]=@zA/q=8ոkVi Yg0m'r :;8?6p"){3uzti>[z[5'>?õPZ8Qev Z`o Y q5dPkDDJÝzYYs|O?:,_K=\P~8UX ~%h"ζ;O*{s6owKR0(Ʒ6sտCvqLYofxe`t֫׶cPKFA{t ً&DS w ns ԫSКX1dq%^iୠQ'VWȷL/Ա 01}8B"ܶʨ pqB"@Tw ^P/z붡xIܚy~j?2Qͽrſ6+ӵ%RSh}G7JM|* dOak U(mn ,JuPsC| DPԢzQy?6Wſ}071,ծ~O|ϋ條99pf0Mn9pTYgIQ b$'Q.c|w\ň}P`~r/=Gj`WC^\X* '¼ܤa_vx "(RH.`]e^WգӢ^͚Q7ޞRڀpl |׎&I[)q.}k41܎/Kf~I\AIlPO#zdGxۯ^OGT^+uJlwGvoKLmᴜft'Z0W[m}q`Q`Y얹EEh5GNk8j>젥()՛V?,h4׻Cj'VЛY{5:t6h6h\LjY\!BOۦQ\fub{ޞe' W(դ}G8),[QwdnbxC=B~/6RsdN2Benr]g&nt3J8oWeY*e NDSޠ*SG@%ɖ²hމ{G8+?ڠ%vl<YdzVS/lQ&ʾ4Ue5uErPAN\It'~$!bӶCK};MJ^A kQt1`%d^4}uz\kd6q*Jz2{6hFÃH ? +i|I 죭&(:mtOso~p2 z+ki;j,A=vzɲ\_m{a˲ă.}S}wx/Loê % %>W)SÛTІ`d.X Vk@^Sļ5ᨊ.uC=ZaW/an$i D}6m -<_>bƠY JY-] *]j(\눯f'q%yffJj]$1lDoـ['*mNpՁuFWd_\R^6WBT`3vP5"wmi (}B;yU" tsK"bζDWw\ 0vV 2duJ:|Ix<`9"[|zwh=q-se4N^W`t}Z k  >I Y38Exj6Ύkm;u@IzLtB}G|20ȁuٗ2*iةmO+4rG-4(LhvA3H{[#/J0?mq2WpϫoP^7sSu jR IjUS'"l6_W:m:t9t?fDnZ1'zvdi.zY6.}/K៸+]jYl`_ױ18ݪɼ~3emCsΚOPIu6j?@TݲS7v'Ihs~EMlՍkRZf2z~eAa7bӌM9ʴtvƳlp ! ԵGNoyn gXSLsӺn1jo+5CfԘixs^uB|ccf[2֝mF2̬_Dv^\:sѐ# ]jQK-4@7tz%Ej-Z#6UylVʥ DK$为̫%J*,!6s~ǧM\1"흜߶oLZfZZUKe*(Iƕ2KBcɕU*Jt/iΛ`~Y~ZbRqi~X }'n=d̦BoiQrԪ5N+\R(&O(n:R|#_1e4 Jiʍ.HIN_dxw,u `ul0J],zԣ@!@+  9?: Vd\ߕi1^;cO3Iݙ[ڇT96* zMj!k1](vwb@S4@^:Şq[dA}%ZzDq.$tiPІuz@ -R`c[xlhKWbЭf_8]"@pF ˨0L"l4f9(͞q'5C/P\߇Rkkr b&|Hǥ`>09P,soO=t Pg }4]ǻ9cDu4)gn?:DЧ{jK' jw~e,ʻWi@[nF7@w,@-./$ߚF0L{&qEP˧U.7Yh<=*=IaCu  ]p`0ۅwAOh,h!:F$]K\*Zx{K+ Sn򢬜ٹo?H5bh&bL'! < 73qhqueTN1|5.ӅL8z* %1W{<ބɾ:-tUJ@7_sV~.:O8L $|ӽ_ّ@V; -F ONh\{hN}شK>#vM /@~TtqSm!o" +7 rTR^. yZġc.A6{{g[:Dz\e.j9W4qjx}hNԶ2r$8ϴgG.7,ݭ:^CwR7T\ŷր2HOh8?u]=N42UIsyj1Z77V5k{97&#~yDwKvDӺqk0unf~::I\N>֡PԎK%|xP[ ϯy~|de=+vzU1#ܜGNS "H57]?qgT@[X"4n5;rJe?A~tu/qz :p>nJAk[5f/ U᧎jo6]NܭuɱuPE~ze>vnN<lWTVOPhdPh#9o4Y{%o2Z2C0Frƻfk7󙻥ͺ>o[ucfmKƆYt}KOF?jTS]=ڢKj=I}bKPiHgcnvUwYE#qvM p#r΋o(~5!2jrnzKNdK}!^^r q%gpA~ `JrfKnq{BOVo㎸ d遹]˽uX9רd܌zJ{|Tj%*6N?ZV2BH=aC~Q,ѯ=Q+,*0KT9bϋ=r`' P׏i|Jv{rn4:P3K=~ {qiWTX?] N`±?&/?+sδRHdavW^w`XٰrLs҃C {zo+w'70wOerMF+|sv+NGQ> ]ވ/zTS8Bi^=z?}_/V7r,dwxj\c'Fj|ury2ڷڰ\eZ)iC)lO7sVG-ޚz:&q kޮj4y /}+8y{ȱͩ,k^>C:M۽k熳\gqmݶM$!5Kdȇc6<&]uoә=hBc %qK0FKৃ㦢줌׶́VOf&T{3,8za\Xd{irv|FԻ_& 8J{p:C+L Uq~Nh7^UhX6iZ}^mTJfr!4b*ΕvvKʎ T*UЩ:4G _R;-|3-x&l|0Tq ,/4G_diC" \+Tr['-L @ z$ A.Ƚ H-qvY-b$Y>blF 8')#7P.hsB5:Ek+C:kt E  t |U${$WM\)\AVCa}bx|Յ?D{K=@gnt6@GhkD@[<[/ R~#}{f5 sAbFY 2 JcK/ Y:m <:|i$ăU4\u  e 7`@ v܀?/¿k eW  ђ<_ 6sAk"+r}XlPl-k]kTce U'=xQ! lPB # vl8Dq[20#û[HP@ѡA+^Ի^NN'ox)q0LK;fe^,9/+hQIV6H J/wPvFm|G//&` ,x5;}x"yw2{&ԃ>x!k`X+zWaj$w"'eY/e13i,*%bNWba{$׀cɒaxdA*٨R2)C~]:3F(fVo8:*w޴.˸ЌP6j8>[u4o噕;fAPn7m:DWrCsnZVgg+Λ;Wc鵷$S0GRҢyY</_ULOԕo0yw@r̯Ǽ?ci$\ jc**2zQSa8VEwwkTIz$@ ecs:6N*c' 6kj ){|-DVzqa1Wnd9An:cpؓ` 7+z~ki%#ǫUS!Kdjc˒3a'@Wa{ȯiphߪwtKy_({}0J:re53y!eOd_>5{X1b9-95:z| VӅP'5ԅ&]ր7r/]srUt([?2qsu1E-\԰z0=j~J4F=J^#÷oooK8XZـ:gힲTWwYkH{&YYM-rVuQU1y|mRƺ=5^dz1'poTnoe>G^dݳ?:_6ӌ-m0=GM{'CdKFe/A8h䭈~pDuέjS uɟ.L#ݑIwW2Kp-ݔ/L;|un<˵s\UWN:VYxa^tS-%>)VoBU!L̬H [}6 V{6 @ >Gȋq^~nZ oRLL}VSD-Ɂ&|7#,qԹeOvZ%uk^M{}~i[%gSt$C Nƫk)>MmCx'WYz|>,:وo)bj'xUdq;C^:{E3t|q)$4_:e6V}.9M)ŧ9IoNH8/ÍJsc3'IîZb-|36){;apɕ>.y.DIeVciAۺഽtncM"})˩Jq FY8<N+WRh_"rkNϕեeU0$WҢql ʖ]gҬkQFYqAQDQJv|;Ύ^nJy̚$!7tKKm4[xc4jf:Y۳[IZyGLPa sh6y7+̸\=x@Zm9w[I4W#hYp6u:H!lll4^rh&wjd&q*K+Աl/%sA,:Q$НuHik`loQ|1#t:iVn(Bi8mWY+ǃ8=VxQPY[FL<ڰZpegAy?8拨#6$W\g Ԥ!2`٬w:R"mPL-5Wcy [>2/B mY3OG+dcc:9 ri({&)7Jo\i}|~Fo{,݉tkDh#{Ϣ?5mbU9GF]d+V}o+ Zek$٤UPd{W6 ( mv^6e$6&$T ?F'L\U .x}7]ZT Uw} ulo~,@?E.S~SKEY`.l\4Nk, W:Qll?F=-7;ﺁ.J-n< Hb@=?v, vbkZp #6o7]^,z>+4||v=H5nNBENN33BRι6=}Q1k-'j6NV^Y{J;@W?J#@պ@O @ -75ZMv>_ ~"JzzCr]rv=_rsϧ9+-OY^n3l޵GK?{[|j9aW?%GȽh\}P.g0%=A]?PrK]rsv^L}tl͞"ģa *ٍ Pknh[#`tߌgn uƊ(dvN NO^e_a/]gTxbzrui,8yl4my=i>i+( `u_7w㱅Q=-m H,m!97D-f{s,֑ϖObۼv-- KRm1>sJXKCVݜo|} QEWןwv<=E]aO1ufg/ʰ6K=`;fl)' 2Ͽ=fh2Av㢒!ԧe Nis3x0Smu͋J'?ILCdNgLQڟH[FnzΔ./e;wd"z @fS3+uR=zq>kx^ΎxմzҡӮiɯLa!Jp#2l?\ Im\S.Hʆj"eI閝Oʺw=hJmWr8jɏ~ ޶NE>HFO3Քa/[0]H4Bx4eJ;\M.skpHo<^^QbkQCNGѤ]5)+4s xؖ59ۿ5#l/f{9C\+KkѾXUz栻<tNJݱǍ6 :[\eF뿆Zx`DO{ˀר@`oCn9s:/;7ukv_]fwt"Ԏ*)X< ;pO4Smu{~eͰ?^w:BAo*fz]'ރA ؖ3XAg2[u=yvZ[s{Z돪ּhyܱU ~Cv}b;3b 7(xcx餷ZdGf*u+`]^IT:ԣ(#~~TUƋ"H]x)8۰PW{*W'j5}v-֢z+tRVk{ GlM(1!VaYNl^2ʼnYSDC7Q^.9`fs+a dOtB[" ѓ'T[#%|q-HNb?dz W̓oq"eKucϤ=o␅xWw,@ ~$H߁@u(IA\_XO=($z2վJ5@'g6%Jt q*@!@senAJKd~λ=ѧ?gaGp$j:Py]Io0](\t:@Xsr ggbxt[ާֈ~fME⯀IBt$-ۙ XnF` dXtV$ 5ޓܼYP蹨C89tLjPӣT>@6ҨT@t㏱{,|bvOn7v]xc̭{;?SY6֜!,!5STh#Qɧᓒ a}=z 6X 6n7R/xcCL20{@> ,|Z=O=gp~ss5"IQiO '\OҖ{sC\ǬT?-| uZ)\G2 S*Y HX ! ԺSݼ^qT|,\jh`#=Y?;ܺDzs6}N 2 eV#gӿ`Á:Dn1*+@.<% k1GyrTp%2N}:߻9X';.y?oU=Nko;z}ܧ]{5y2"z5f۶;oAZ' 29rL䀌UbSʋchA;{ƬEعfνǖ9=ٓG9UYWV4G;)c̊>a O@}X1@R13U?J!^ 1L5'JKpDM͍11KXYs!M+;Z6۫k.}nj.ux2HK }~H63 P (ykqOij?MˑI2*AxUj݋g6K:MwvޢPOn נmd8*(G+COڍ5gl4M԰thlb-IMiPd}?YL*AU}o̺e =ؖSXAjܾT'9l몱^'Wx閒ĻK{m*GV e ;yy<4'ݍ0o2K:g$fvueO&鞳i ~~:o-M8@=Z FcI>UY;|n F-զiUk3inXKiy?n>}9ii.Q̬>χ<.LIzh\)Ǔݏ>6nb~QHq2]AfB h[Ӎot!mݟ}Or ^mm}&BZΫ%>[qnl>7SכdPWq/] 3@l7' Qt`h#as 8oK{ \9laEXq0VH`v{bFzT8HLlz?aOKl{ReH鰋WtOlırQFٽo ')ܷqZ77 Mfϔ+L`dͲAMG^5M$=%N q?'t,aBXt}Q'd &HJ/u~35=JWWZʋ[4*ć:O>IdHS5X.鷤~;*1t\oQ}^NkrZjޠGc=?j;9ggIWރM򞃲^'{k^J;_L /vK o0&axMÙ}p%zGu+ۭ343ڔ HUO؊aڈVCan٘)q X1j |k.d?O>/Kj. l*۹vnn'\MQxJOٛ֫w8Y.ݕ#Z٥;JRZ%v{%S0RPVMQB!VbJ!:rS8{BJT7<C7(h\,J[0S/9u7'x\.6lQ|_)=Rd+3}27O*(VֈwnCmVX >$~/O2~z-as`W5' G|;gp~N/cTaVFܭ"[a}윯Nv5|^&R˽;9Co3M +"ZG d5WGiua i.?V90o~ W$-t2o@lpܹGEș W!z Җ# K@Z|S>p˯ssf:OB;p "p!@rcYV*-a 5fq$ )vw , B8A\N(Cpwx(աTw=6蕂S'?Tp"($_9Lq2@qtzTץ]'fٻ y."t` 0 b|Go6 ߙr=/`2WmT7sZ#Rsq\@o @5h31!7R'$ДBB<~NC&k~ۧL]Db>\\GowP1a,M`N0^1nYn 1.n%&fZLWn7p k-i7JGAA]o[PxHb$le{YGpL<jvY~L:9tfʗH2*eSŵg,P̃y?vSϽӷN?^|"o?=:^ ۈ endstream endobj 175 0 obj <>stream D-^'!QDy>!|;`C38TQO_ųU\;u6S7Ɠ:ԩ@ a#9M" + Y%[vlE ^}h]L­/7=nn_=evyyKݛ4Mȝz^?& Z=Y#)L_sNn}:r`W޼$ϛ=@Q/#w/띏9V`wwkV܄#Yxe\6;~<D|=#8u+7يWPC 'gI c}gğ2"[go^TtKd536չL߲fu5`ݾn4#Qұwj,2RlX"B@Ɯ5hn:>`wBu '/}:.eނ%Y͕C퓖MSVz Ex#2hqvusF;MU4YIkb6[P,ʪ5pϏzU3E)#3vߌΉU݀ ȁ>2-qVHlLR;赖kY5uXk`~h#i1e2qԣ0|#zR7.>MúAݔ0Шnεq5I '( Xcv=Y{R/X-T)[/-[b;wrآ+fi t`dJWzƳu{6ݘ<%G'(~;z{K'Cn_-fEe毦7@\bZUeXL-Wgo)$R)Τy;V%m\W~! JNx@S;QSlDZ>g8W t(MQ"v2ܓu+dSm-q,$yXhr{ssEFdW T!k24Ud[mSjLqrնW{g*rڶyԏzxHqN-r! 2uE(mW&c!`y4`YfΆz7^(;>zZ̶˅:?zUƆYTtЭ^'s1z1s ,763o s==CykCN7Nrwi>w΢=٥0gv=V\J&PNvs,2QCo;eKrZosdvqJmQPPc.*H&@ Z\䜴xas02Nj _\[3]{gqٶ\m=w]:H^t7¬:bƱ>Hm}{u?ܷ[T>ٶtfK?vb[?:d珁9;,JƉu0N+jZf\ͶZ4E4ߌg՘=pg-v-v\)tHݜ w.L/2 xUZ˫`>y6DeܚpvOnˡSy݈vs/5zj5SpGRF+>&;Aj Tz t}M6'){kfqGuMǯ8r}m^u|L[}p.0]&J?+];ݸ՞XSpL5וRN-ۊZ6sԲT?NTZgSfJ,ﳗ,.wsQ<&w,;$BNKqκfZm18ԧ航EqΘ)2\U-Uq=V;Zpm)c6HL5l ottH]Z-ߠ5fNJ=qZ\w޼[k뚿̪xUh[Q\ST^rg}*篛}-fjl\Oh V23}r'MUNVL~Hzq57߰oCO|WV3Ct\u&V^)OCɜ"[;K =WA5_>q=~9Qͧ[  =jxHwAVbeJ~@4 zMʆw=lП}hp x@^u]w92kZeq|-< GȀݦ]& Wt6Hp'nRUHujJS !T w74 s)Ke5=u~҃O4vT)e4HmuU ^mAjY &. R RƱR{1!NKP PE̟_4,WCOBMW }@ZI =@ρtj41oC"DL,!&'R 3=4AtJ(あ l~H J~,?ki(sO*@) NiAW)D$VApG i&3H/ğP-XLJnx&u\ve(CFh|^@ZbAW}ZMn᣹ | lB`f;S֯l{Gf=%k-YFI`i0(Y P eY׭ P5:n‡K+ +A;ɰYҋA_d_ۤ %o,Ge|zDc;NἿq7ot,@u{Hi~ r3``"۵>qu+sHx:FT0!' +DڣPfnxqTv띰QHzvU 07,KBC'nSoV*?:FDLnB6Ќ.;w<1q{H]FƵK;wǽrNgfiL?H|w.:Mb2jQ]@YYĀ;,`$z2c+@rw^;Ϗ(ի׮43ǘؾ8ڹ8Ϲ+i,͏Qami]ύdz$$ Pu V}cOsbOBtq@Eb$27Ľ~ߩI~0:Y ۾pdrWxU4bY-U0ښUYMesټJ >~ux~9)5FuƦ1׭5˧|F\9o̳u:Bpnnm|6NPf̀,_d-( WpM¸X'I.ӱT1J9ukBzߪn8A[G^dom?L~fMyn=gM6^/D%jLʤ>Sru7z*"ۀIy"џ=ړ\) 2ٕ: 1H\ř wy ',~,JPjf_pU: %ugKsfnԉيU=&qlsRC*MVan;`|T&%WA '5GsSf?R/3 w`R@*z(:[ ?J#pLӫMmdM摍lb]囜a~F*g߆i.a{pGCi=fUtȩhޖ}PK" ST5Gjڎ?wTi/9븸0Ջ$O_ԕ< YlUpyTej8̋"{/iLԩܠNg|+ nq@ZƣĘnJnʶ{Z rܵKLTp_Z:;rDBOʉasSoRfŬ|([N:o&r疪ڶ(g$QE(j}>i(NeH۷BXIBR-Њ OtsSDhj"ky~K2 ߧ̤7OpY+i5ik8B K}VB_+Lt8bN(%߈+{c̓WU_&}1 >v>sDRjooMwS(,0Tr Gqq W>~_NJ4q$]@Xvwu.X\;u^A K_1M&PshOknlZ-9Y\ltu#sA+bMX"t?J xoL ~oDwZK~(Ae^87R pݏ]%p ^ÇUe7>nF?R:G@3&!XWGQl7w0qqmGeMu;ff}m1oe&-76/@&˵XKLPã[e:HP@a?^vVg,n4F gkde7LSf%ZFd@Eփg"ӦFv!Ȧ_x!'ݚz3472]Ԟ{o_yEs|[EÐ>KVULȞTw -QZûoua)Rzfo>k%CR?욈]׋y3dQuwuզ?>-S&H&6 .,ݍ? I£5KN@YcVtTvS{8j I m6<w`"Gb*HNF>g[ruW~wNTNv},ŎF|Lh:6GIbo*q`(.FSqˇ<O|w6&zX54TW9m"PR. zl ٦z_JyVW{`7(s-v,AvQCb?I4Ƅ fũ/ f[T?[Ll\^P {&v&dȕR0^0rrJ[Mѧ)D1L)e O1Ȕw4΁GI<8JT'AXxKin$o$2BRڃ]O9/#0 b<*jaؑ[YzK|gtmn|.7?I4"Ac& u9(kd\agf^8.D *E"2Eئd%o 7Jp3vw׃\^x"|SL߈8I\RHeN)}4FV"or]atǔmb25Xu09}UA>|{zR">8dt;lh߹}_Ь/"H.(4r7~;"G0D-kG9ռF~֮vO;fM~=ոݍ'(=3Y㎉G#P=W@/=أۺw#hzgx)N2 ܖ5q=cN\wʒ?1{@'2(qb,"DCY(Ja\fv~\J/]W`ҫ-ЋdЮ^fbuΙi'jO]@\v]=H#[f~׹?3 !;v(73r̾Z\3KKۃ/iN4|\Aޙ ^~`i߿ɝ[һ|#l5;p(n#w}3P G}v|#؄f y qkӳrHv7Z½} =\BcQ_DpMCDiVv(釃 4LţNpߧs3 ]]'c ݠp%Dhb|9ŕY;L ͷF:n'[I&e#kqT,:~LmNd*=A xWM{ǣ)QUb)\Tɀ wʺN_;h)ƭx㚘N۴csPwwv/bPzعWZXLzcn^Ԟ~ƓR3"UxGs m4p 1g7/Ǭǎ/)aH~U(}a={}.V;>l*<_՟DȕƗ뜫o⻹lgq.klßޔ dHjKN :˅Q۰!s%V c~xB|1kv)VK8(JuY kV>8tl86h 1|No0)@r\uv) (*]9AO(O&Z :} & }3?Ϲ\T.頻/",bi3v6rv64)A2/Zr"Wn\[-Z#k%Sƭ8ըTJPcu**J/6{5sh RTCOU@4r:Fwi [3%4/ӥul]$(gK-lƹVIYes:+0w&_l,Y}~)LYUoZT#;T|) W!W>sy %UBUWkR{E}NUxIa|< +4Zh/q!|6C]XȄ1QU\+i Fz gʮޥThYx!3 <;8,Z9]CfCg}bF1?]UQY=CĝjKe͓]Jֆ.Z Y>A%Mv樫Fr= 6工F/sLgVZi%iX007~d| YA,v%(7C3}R=bv{C+ /Ƞ/|%}JD10}WkM5*l'ZF4#aF꿈 "H{#ȃ(3`k?)U kE$D"H/C{?i{<%vsNuc)<:A4}<{{= !c%7C{éƨ| A/"HP7 ");)Q7-Px-N.V^>bۈ/ 6kctκ)]vtř:Rc2GzƯ{mi{fqwR񿤳Li~O"uP/Gb3deGo6,Zα4$ngOQHN@ JsD)KDlNhw5;m^Wi\ vp{QX/\y4D)Eڷś̘˼SNk n}/aϻooﻭș0Ol>HiKJۉW]pKJY"_DFYRBlw@ғwQm.áxh|*9F'17s_yZ[s{d wԒWyysԲ 克\WRC͍6f9n^4J}1CO>:M?K@)q[Sy۱-Zk}yӒg |te2 e7rpe6hv֊7ɵOF$޳9c퉠*@ Eאtmnsw@/78p?m?-1;{uR!uL/TLC*h>p?mapb@TOtѶ:㳸7zNgӏ3_Dpo+~F]ӳgݞ{ˣ^o]ByE..͘'M#nM05:=a=^?,'r0."YR}ɪzg9W%֩|օzl͂[fJ:9g#{kʋ19v-ݬ~JoLct֝IO?*OMXlbGUohnaPa{l) \%RfMᰝߌ/~6_˒:{[Gb$f^#um>0h7?];HS?%LAb_^1GBqQVl=tAnwv/jn 0&ec ,x"B[o^Nyv=j\5po1-Elu$ϩN@Ƈ뵲|c]Fo3' 277Cՠ;ݥgBU/M˃#W0&"?_D%Mɶ͕rY1Jp}+ ^;z%Ĥ .ok_ap$t6/yhJ hkhה`2}4w-C:N[uvF~k{Lrt+};D-`@Ve7W{JjW֧7K i X;5:4M h(MY8!GHiR"˵ʞ,%YzwrT~s fU_M-{|LP&^83x<1\1tlGfj@ue%EykƥQn7jd~W{Wm-6tyTLQ rGX_l[7 L-NGa0]W_Kvlrx=|ke%WGVd,5FG@z?0_ox635*SNI˯V hө7);^>~{l~. L`E'dc=RHt!'t{"ozkp5"k֬FZ!uT6i>Keǩұ.JcI\K 7DK.ޠVTͧ_Tի K}oBXjIOq4:ܗr8Ȧ2=ĈzWRTjet/z(&r5> O۶0H'?O #O]J'*-"Y K%OK[;/:y}'/}ۦ9=w"cR:<5~ Q@Jujn dh$8k*X s.O /0rޔ](1d,᫽ojǣsf`Ă*tkBKJ~ l2D16*ҦǟbX!4,C8E|6&^3bkS[FKf@N8b:fFџ;ljBL2i,(呮}L|mi`ڦ#[+FFVF=FlhoRKJ #Ru>] &JMV F2p #MQQ¶s 2?a<˜y / #{mb8)t;e8hʻFgBS)nJ0 K'xaqmo0C:i۰/"@OW{D0Xj-+&(RN0 )]$z , ˜J)42aR.b/Cnܾ^ ?V>), 29=]:(и[a/G`A G>'#ra7x4W?xREօXxܣ WS]&\쓞ć+nll7x'xur\ёl5_D`,IF6~ꡃJ|e!w4=n{?Z,;\j`QGcv]{L6hnVze-[xk،Vΰv˝evo^E=#JLY-`=70l:YUO0e\3b\:4ɻ;OZ@5yMj?8ߛu:IAVΡUfXfY]97:Ng_F[c.kȑI}m&)&V{Q{U9(g枏4yJ帺|8 >umh`jhqƘryl3٧3SeWOc&8Av,^ǧvF]l[V܌sQ+_MW{w!a/ -g}0vy5^#{EJǥhyIY=SeW1fo+ٞh{gzTk#92 7bֆo M&wqg|WShs^uxE^MN1 {f[Fnن&y1* *ԆVu}#pva95ƃNX@/[@y3Ag=d2?d)l=HSW[%:1ѳcϯeLZr_4lYXig6K/x\BwVykyw+l 0ժ++6S; G}q=<|^^ֽ^8>|py}ߎKp#f V7KȮkrK^wL f0`l&r 9iQy3z0<;ѧ`bzv*?Ʌi'LK__>32 Ӑٿ<j.^:xzE˅bU lh`5RXw3謻_^N|nLvyaN G6|bYԬKa3_eMm_Nz3b_cd+!rjSWVQt DE{%iuIas1<®^^X[~NM= /?Whl:oŠ-5AMC<AK}qPˁ,~~!e#{%mD3M:.۴Njqs|deUc_n̞ ?/.gpVZ`nyk X^[z?7{Ig0f7&O=ڲ&IŮت{.9.PSp^”*w2s)S?zi=)Lٌ_xær:Z6t|9 ,msu,?dAۥ]֯Q{ s*-Y)un)q3{:ll K/Vs5 l2 :VQJPHH=䈧!IY(PQ^ozl6MSjFK0珶l;;c&z @XǬ̦VwFMK,Y8 .,(e\Yv &Ojo ţGK*HFa#di?UZr]ԫV{pK>Y&UV\fVmVշƆ}^r,!0hKŷ93mov6[jTrgyCJk;-$q| >d%z(Ԟ{U^Mf%' ^-*{\Wgf]X:ֵRƄ4cmޣ_L]賮rL|q{ER FS挭?r>Wo[Y=TUiRZг=vgR ` 88G;WYxht7{ mnrcy9OqQzWxZ,rvRj%,(JL*"t?X^ZB~no,WqMWY֥?5J:ZQP%W_/4@.tHE(3:5>xibc8-^kX\-ft:`؛b]r0PCNj(|[w)[وh/s[M\hso=$6 M3ʧT}1PAf00>H K)~ V̲myEN=1@&CuW ^0qhKh_-p)c.#hM]!jg.]:*<:7,;"Jwy䪮UYy|:Оɧ @<=E~~ `Z`iJtj?h&֚)<篹afQq;a; 1h[|!SHM(SK=)ټ,rXXlq,QSnT6mJl Loˉa8ݥ"qZY=tW1rEL\/lk>Il'Sg8h7mrJ{(昍nk z&l&yb6s]Ic>~( ;Bʍ nz]3>~a(v)kLya(OWR0h9%H!' {(2g??f!s0ri6nc-W1F( uænq:¤qG,)4~SaB}Fһ* Db Xt疪6csd@$3 z8znj5>ePc5ݥ1OŚ/(8qbh'ZH)}'bB}Sfc#Nb?b, *Qͮ]Mp|?~t|I:RZGD~J^,Sv}8B?w B(`.zlϸaNU=/\$O $GD;Lruң.F:sHq(p8oR^^H^ 9xwfh, R8Nfp>HNv|>p*FsDr95MyR% qZ*NKFnkJq!Z0HR&9h\wllWbZπQ`zs 9){LW[G; 3"_]ɧUm/~.Ir+l;u]y]zz41w8(ή6iN-x=@({n4Y B(?Uצ cj62cެ/BS>ڹOq(qT*ܵF5n++Z]d;@n(4^d7_U<:fԊ|ŧ%c*-ydOi 9Y{^pYoؚ<8g znLFA>7pcjMʻXnrmj8&3?3iVsӉqh z'WzEit<aU3i1c+=-~PX2Կlswx&-3_)$ԡ5Bm+\#Gm͛u/5Fj\O무߇Ǥw_Ų\ۓPFuȉy4pyn7ܝx{yk|Ԍ_n]KjiP ,@+Wm/Bl~gh$~6[өBGVxbL8\)sM^Iy ݇d٭D\{ՂO F|0)noN,4k bىd1WiVS\S?|&.GhylD TO!aV{L1e&c$,DɆvBY|m.scjqCb 1݁6r6 E~pH*L'aya'.l4w?ˡ63&W^tuDfAOweCT4EOthNVy.:Ww-G}ozkk7d'm@qԂfvR}_rU(S!SI󿸣G oWZzKxՖ.@@\U߽ɰKp7W*Gr-cwh~j9T:u GCrub(2Y=8tc8!2%C&sCz[ci뿫r_V| 1-ZjLˢ uJʜsVcݖ niqSh!mU% '&su^vĎvSD+gmW Z_.|(d{7g.%X f̍0=*V.sHW*z64xfqR[a@i _ъn{B#/=%wW«DIKxv0yԗ6on{dz ѶTS1*_Ssf]fPF/|Kiu?qSjqvݓՊ1ØxL~R=Nr/xdBfX/l!G%ƘYB9@5߇G3!C!,RͶ\nd9XjPZ -pؤ-Q9SR1ֈ^=ZΝuzSϫH囝.I+ Fp:c+h.:;*.В}@+`F# 4\=l!Ly<ܴoR`3'~J`b w2j{ep-t#K|'jeKSN$kK@/@ϭ ŏ 3՛6H+?-F\2o;/?v?Ŀd-jg,zGcD G+K os}Z }6x`SX/ } ܊/Au1ΰqڠ-.n~;F5G|er?Qr3E;EܔR˟h'bNݤ ۓb}YHJѱb3`2ɅEquMM ; nu:l;YjqQo,nrhnmJJPc\ɯK"2Q6R}zX%T t ^ MjDPSL rڣA.Łض6Co5h[~GS{;V|M96$Y.P :P^ey jvَ۟ʟ>.TqT<WT1M^XPʋv}캼4\ݷ6l-waksotpIT{܅|6[HlQ(բ&x/4Z>X-|/pie/|B"&,8WSUT2M& wt6vmުއz_0ʪrJoźm^7^yqݮwNܐZK P ڴw)in E _}OE3U0|:pA8e@}7xPvڦ[~-^n  %`|``}\`:;CH],A  AIuĤ&xy ^`pJBΪ!$_ݗƦOWxʋ_ai -  Q@tgPO{h]xd?5~jBZE61zXtqx} 6"|+4z\RY__ 8'gד@G?CKjWOFl9Kqu68B_2yKYc8V翞q6?w)(IjR)92]uRh{|>q)b#e3B,\ߏzI$uӊ8{]Eq8i]?Ź% )fO `)RbX;,)WZ` HA+1[u}:z:E_Uҁk=?Tg돬K:eZ3?8A}߮iF=}T%`Z]gʹ: beCo=_^KL䢪֞+<KB|_{~g޻{?oݕ_ߠ<-Ϟ=7bȃ1N6(NRvH@kozy*7O OI)53Z*ϳ)b3U&sa;ܜ W4}K˔rZߩ_/6 σ9ǟ?مH9#£[?(wMr]ùʧj/9`)J~PbGN jd nVNG(ܾC2- cVJ? L;WfOt{WL9֖{3m]> șΨpi7jvNmof?m;o1Mi~Gf~iޞ*TxId22R:ZVYΟT}yg`PWHp{Å̮!ߡ_ Zi?jrO& b$IkBo\ߔ?'팼; Iob͆U"䕿$9Xz[$J_FI9Kl،2Ǯe6WJk-WQG3)rx \T.ld OpвC.ٍ7]d!'E`E]?҇m)К49x/~D;=  \lV;ݵ;5{GъY}>6\v-&!re57;w.j. ü]?,@ E#1; 1CKK$;Gxw;,=N5gcL*!aWs"&Qrk%PG$b$ *?k[^*Z]ݣn/uGm`qH禱u>mͫ iC: JeIOtMyl5u+nO̅"ɑFN(&ϡ1ω`ӗ=nmx}S2 /e'|ԭGKwh:7s7!a}_WhLV7ˁ2!ul8 QW EBUC$%xJf~s٥o!UV' hgTu>hkFEx;&GvƼ6&YrT4kڒ[ó/.ajĊx/ g)9)oTIlV\w1t!9*޲u{d{$S<<^P2:)+ [Nod)4:`L h5^&'h*a0<@=/;KL=EY$fmhym~`֦O-[z3$ZYBVsvz >O3ih<6GLτOpzyk=P+eZe[/6W]op/ Xƅ^l,}Wvxcx?ˡѹvs~?䯃x(7s;44dgl<^@H41oPji'kY=?!s M`H'J2[:Gl00&7쁷=h}_WؿztKIWn3_.Nx:Ym;gnNU6^̜o˳DʀRΖ4ES? yi3ww2[L-\JB fCYRc*fbcСU\CM+=f)GXy8QP XLDc ˲7&Ye, R6Rľ8) ƿr j@l/BbիR_V]ī,nکg3voq1*UM%^zpRqo7Mل,,w )#}v|UIBmVM+}Qz|:y`R^[ 7NxY@7T;c=wG) .ׯ ~ⷀ?*-pFurS/FyN\O@Tֻcf/OЏo&RNV,H(ns;- Ӷ =J=D'/T=ܮ(>8|_VJ@໡?.uqwQLtb nCwF8%ݰYv( ;8q| v:2pTS*R c8Ki-d)?yuҧA01 kXΦTYnk:ҿ1(X_x+=R!lzۣuer[]!'w)w@@wb?>hgiGS:}̖' eRE'@?q/=q\t$E*]t 㔷e J)/C ?6I0dV礔`?S"w| S?]8\*ѽwK_I0~!h0{'YT*+NyI,f鸙r5 Bb_ Ub4p_/׵o`;/W[//DLr.O16φ5E+z1%4>b_].)SVZ'{4'Awik_=&xd݉qyZb>?BL{ow^ކD.:o"V55q9ݿ9>N"ҧVYƱ$|l(~PY=>%/XK2 7qkNj4W<wCK˲v>/'Qj80ܪ?cwls\Zt7q*v(CmwCP_׮wj^N_Ly\(!# '^GIe&pg}_;Tdmཝ/ֻQĴ:9!YOIi;W؏p3͞ qx\xU8C֡[yvW؁Hݾ>#yNs'ˆU2`n78WY,ʙH$@QC؏O"یP l*aY4WA2wPN9nѶv tlq;6[7g˯}O4sנ@ڕ7\􌇿ڤa}7}"%8)vƿEWxwogt>ȿϓܓu't\3cmlw VI'Yap.ĝe/K*|Te͔lαW%$W𢡊Ӧ/‚ztPa~*MٟwV:M5H6¯g+|-mbo\ؾ.BXU-Ӛ <8sv90h0=#f7glp % Vkz\WKq71S5LN7i!ʗXU@y{y2Wo\񑲫7>צoj}mYǣ7/N,+϶*3OfÓv։^uVFY=ҁLT^Z_Mî< Qc4[mVs'Z7 *5 f"d\ش3ZH5^ IE} o\6FR_Tt;Ёį1 2. %}s54dOe.N3=ϲNZ&-DAڀ H1%.ynMNqwmu]v@Rufrl;VR%a$+_s\ڧŨԻG7r5'?,ovZ6D>4o| xfY9K ,GG(m9ky-h/B5sq+I,%??y~8I͒[F}Ex5ꅺ 6!i\L$4AG 6b5N'~6YF*"aM>jd3j'n$E Z7XͰI!֭u s*wЊ V~:s9 r%-Q>AuuOvLlt=,C%UsF $<(V霬?׵jF~y8\ -F?dCwC6?m[_]ιc,3kݶk#?\NևpQT&.k V8:rrS+/-Xs}ClZpI5 .L|&>v+.:7x1K}jcM.UI[ |_qnrV"v(zO6TTkc9d=>1,}V(jНQF_(&V݂,"v <7 Qk[fq{tG2.:gJkqEd ?lATR֩{S ƛ/b@){9odTA@UyA6څrJ2@/ֹ+N,' :z^v:mE+EOd 7Sgkءnȵ(Hߙ/`xK-?<,ZQa}!O7} |ًuܝtCQW(,uȯZ* JԢolp:^mčh%nvgB1kmzYQqq-}u8ؑXcP~_LY^tvnGz-&5Eڵ`׺yQ}իBW/ёfhz(I F/LgopD-;-83cD6ɵs4k;[[AݫT[u[ d D9&J*ntȩҺ6dJK/ޠZb>(.v슇 W[ƃ} ^nVp_zZʍfwWGjL)߽T^*e-ݵȖZb9k- {,?tP9%})͡SZ+RZi~1˖5a ǒ2`]'S2u5u:¼-\7.s|m"۳yYK^T!lY)q );T+B! Bz !vFQ@XHy5GBsukm]fe9)%%žϧ'\e#QSӔKv02k`)0zn]̱6 FzM>`'`$R xوG$" H8?-Nljwa,OW͇4'))x&fTtjDgW1y E0rɺ AefZdkw`USq9 TynvAsk[&_U7ȿb r>6u$]|MR}q2 |[Z^h'/*4);s+J/m@; wzsiz:R: ɡ5r迭7;`/=LSz(fqElퟵzk o v|L9~ĉq8DU2!$nkqL-J.Ek<8F;>mQUbtT%_n^_;-RcS4-:C֙liueOk/#Xj}pc4h@ʮkϾ/uJo7(UͷCT r':+!~m('Iad"{[Sb4iF)? ىQʁxŐ}ѧ4nUo5ƼQ~6Lh/}۝ЛQku^K,=muyqc=CH\amWګn?  Wh''LEL Һqh/펟׈\/1҇9ϙ8o+`m9h{ hW.lxl">@Zq&@oğ ٧p]2-zþ r{69 SXa"Tܽ:vU.ĕ^6|Z3^xC\,;Cd;m~ 1vc ,Fn:mFUv`=? r)VJϊU۞[Cwι%r4 [{]_}Ϟ_]B\-K²sʿO._\wa 7t^K>MCV=VEB@ۥ,`׺SΚ0Vq\Fӵ#xg lkI`4{5=B4To޸aH15z7I~0B}o613V ݓZx\Ɇ FVaX'̼-@6|7浿:}'h,~r(Em<i+X{՘3>e!6-X5VY9\pAcC7|_#Y;* HTcmچƌg P}ʊN #ާR.(Ƭ'OtrDL}7yڼfA MٖwWدsW,clvF#J^舅6*!E=p氩 kUojk UZXg=㚜Vj0V6ͯ%/a8xHRTj%E1"J/u[ȟ4Vov빼̡ٺ942=RpNWYڅdKLGNwhuMBGcDbd@6k9Stc 7)[vW+;_vPZJ6~5wb{m*W,P펧8LX$w /,)W1L>sėmK5WGV:2o)L3ӱ+.>Dg-HVg~1~[6p]6hWY+5gQ&JS[**![g@pf [쭓/?vmnk>G\F1ciMbuAH-DIٍ|}X=?ImRvCW#זHն?jqǼdv.M;]1tIDIwVN?-^(g "[tmM:ܹ.Nņ85=fBYgbDnܜ9IG8`C C7}Y4W8)-&c#1y=UE2gQc=gx|xdLۛQ5cU}r)mp,a3Sm7N7r)l(v19HXjN0,J_|MnD:0@ s,%(WtE4ύ"'.QmhRQc<$J}CN[_/1ƹݗv;Tvȩl"!roSd8;\c԰ 2m汔(91 (& zvyg\hݔ]U[Qܾ\Fl\`]_*PU e/n_`*ϸ!71缜VZ[W{nUz䤤'BUi薐))3WG+$!ub#$'dt{/cndN84MiCp`Չ~1߾Xlt{f=Clc8U3ԛ4?lsY:[_Si35'*l!aev<^q-J w9lkZ`n^h:{~P.6{݊w]bR-7hlZe?mJ`'B6u2 EpK(-Tӛzas}c& hl,oyyAA9kWCst{'er{Q k - / zJMvۉ+լSl% QZKx=vnekLE̞wy2˯O0 QHJ~u=KQeNM.HflY2ei^(cj}jt6tvcNg<{[?4#x^=}~?cD[}Yu`@.##̑3sbˁ3S* X`z 3> $;Vswqix 0'/z(Ѯ!7Z*]~|}A6[ >s <[HiU2A8ȀuG`g`| { ȳ>p 8Jז* :p]#}zfe̗lNBex^j#1yjLxj5E\`[Ho丞JpܩԿx / C q1/a.\&@8# m'a}@؄ FvM+U/|4u*D"vx]SJK~4o@h.CW\lRw 5k edQú@:Nh d d^V?I9H]}ɎrIf5&CWv!I5@i 9׽.:r_xXP}.= _OVz$Ʊ r%kwXL_k2Xk /<$X\Bnc?<?&hOI9?;vig|w,ٿ~ O~Ip'_v\PpˋxC{{&6 ]7k>u/?&_!O%3x{?["Uq^e'Md?ޙ339kE]j']rnּu׽еx^Be4X`;?_.Tl(\8Q:#7/>Z="~Fv)~zB@e{ͣ%;6vmK(狺ɝ`T@zRA=Ł1+nzG}FA>4:$^8o36z:*Xe~?GUtq'-[Lx`^` 7\fFz[4?0_L_WEi$ j&q/+(\:ݏ_#]񾿭/{^v1Yvy̌I<=b>zL08{.=\[G[SYuo`A5{)rb^7Ʊau'5>xx}[uz -x;W֊ FCL6X\UۤWz>7#_SFuRFԲbŞ0zf'K+ffEiz;91⣃J|Fmyi4s/g}v;&"tG#+zdӍMdm 0͛sQL/rިn.v]Á[IȷB3ޡy_ӮQZ;̼x%vx9G?Ohx[-8G>ȫ[аL5r#mҖ֙mH-F90 ~trC6^mWt?w<3 92,\>8jTyHj存^w4~Xヲn㖻zo +3/fGZSbp]j>b7۔OtCwb}uڵŵ5jURiGrPr(/S܆FZ,HϢSN:VO]GF"ضgfZ,vlhGRe FjMn Tfee:?N9j%k5iM5/'ޱ8m6qvաS3D'W_Z*dëzSVCz&]XzrJ'@8Hn~-u Mj[{P}ٝ ط6,M>At`\ۅO_V?8WL{pp}7fwͳn ߘ ^I*Zt-$TpUᗽ~]vw)M\Z q[JR-eJ6&,tuռIlzhg3oڅ,Y&<\Iƞ͞nޘN4`\UIkAj>=y2J}eZuj )ex𑻕C䕬ܩȫ'tv.MjMLz-Slb@uX>ZNf[m.>Ccس?A)[]fUm[՗qrfsV8`,8ҕSr3;Ʌ(I銦}bAGNׅ`Wp?tGgFb%2gkWZ &.vD|҆^USjT>uFcy?-fJm{HE+V|TygX~`7_, [ꛫ^4أWrPs6҃g 5ے]`5߭SDrš`) n#jo[B k/wS at9.):*)Z%\nOpfL5!/WwA.IfjLp˻L%@*[w3`OP 0ogj7KnW+L~ޯvBM{O^i@nN"bQ)[ ?^||sչq%Ps`iweћR\1n lhk KJ%u} 3_3ʾ>nGziX<o:(cq1^ ]b\{Ḕk>lFk [ Vq,q88[ _3 NDXì͹ wki;F8dג0 bưH:ȒH8.)x[U[npvg`J[Xa@p$. yʕH*P_Bt!CN!7J  u3( 2Þ/<`+;"~L:>ZzwtUY9l*=V3m`?NmWb K2c/ K k~m}A?ʏiUlnj] YBn@k/MfZ\90grrqsJ& ne@̹( $_MPvKPe(:&P/_|h::}fa=c<Ӧ8ԍbs}\l2Sd&8 ^ _K@܏ԋi$, \%=>t䜾s>Z;,Ygּ랿8UJqc]K)06P> B3U/崽ύ΀t;S6E$iNn$=&z/o2ۆR]_1~>A~V+ t|٣拇gv㧲Ń{yce)*p.G.(tH09>ie^r*rmJ)U[GPlpO X|괵[W>uxۻB.y ƅ9< }6mbgxb:r_/]q7 { $-ힷ3;~`{E6<&`>~M+}jCw8? i< ?cD>&⑐ }_C ZМ AvTr4b䮇@y t" K˗z;y rc5W$l!1Bȣ!ޏXx} dgk#^.ξØhbg}'k?). uDRvۼ)SF)KS|3v/ɉ5ɒK3` {[{JΦQ`}.pHB&Tb\vUѨmS,Ƴ( eP'梮kZ]8:fʷw!;f[ݹRcq*f" ktAݹڋhok ~nowcl xBCy庎ylתԐV]Mxfea]/B}'rG|oujKqAtweyT}iэo5٥Ӕ{1_FJ|o}gV F88u 3a*[++gPX6ٸLP?۷ΦOIk_ oe)B_1z`i^\2.YQVDS:}sٳR/DqN-{Sj\*yc_>nK&~~+e4cvk@-bn@C^v|mϴTnߪ|JΖVQcS*T%/&/WsO^Nc^Z~}ׯBMq 1ڥgtތݖZt4njlÝ%=x)hZt(5$;j|.9C@YI ֥4 `+˨GuJ۬9$cI~ޔŃP'~w/VX1} |cFZFfʧѝu-yg$O[TAi٬^J]>e}ysmypZ9%+LOיj*09|4~*2+Ly'ݽ~KsCx61ƌ*&νؔ:LiڀRzGJyee95_H-}IX9CECtQo0=n=  Y!78_, v~"4l^Aӓ =f~Z5zQG󳔡HAN.KBjMB^tHbM$M-#L g<f9rcW_c)Yldlj]k'8xyEpąX5h;q_ν`lRVyݝL8˅ My0--e6ޗy׀ ^@q{7'K;nfvSL,8tmJ -е2z.?rQR?H_uBL%ݬgMVTr,  v|Dy^EFɞʲN˪Bǔ暁ٙ^JsAmg*VdG^d}~#fz*2w`֨nTj:x\FݛM)Nڣ4 w FihARZ^]ool:>yK -BZH 1S&~ewUwVEpۙ(0DW4PTo>;IԛŒ?T_+UMO č卩ܝ^):G'P.MIu󈞾 |yz.%bhϧ1BG}K}n>lQ ?d: mmRg,BO؎ #i0~%5&zD$eiU w}+{6h}9rU3ɀoVb9r̶,԰ Bt(jY.T_]X75qsW[r;Yz;S87L"^ㆎ'Jb)\;ja)  {**d?ǵl~z?t u)k! EΆbp$OHwxɦ,a) ޭJlcسy-'6'wPT$${ol'*YHN>avݭ?p`i3qˌ933BfM;~aqXm]Vr`wsXCI̤2Vf(˓%[ICBqa'gxXa9xrfFO2*:3*dRGߤg|\ P6N+X­N52jZj (~.lD Zkǝ|gd a6cJ&+t @`]{CyLJ*2Nt "m@H, YBDQR3a%gMjH m4ToKiNW5JV-*dɩo4[tuzh?9>Δd bcz%xѽ5 ;d% @ '@^ |u Rlbtrx;{f$rCFvOxb36Tt~:K' X! h!c&8s*`Y Xj󀥱v,IW O`alM+#ϔ -_#qias`OY)K5h)EΪtRrl.bMvs;_q=9|{^_jW&ָ x7ׇ?P~GVbN i=a{ޘvT=t1\~ [A_O0#7@L@T1čU񖊀z@*^M HX'$Z7kcIԁidHt3p#Jns?Wʽ_z-3Ҽ2eq@ Pl tO`}:DR)9R8..jlߐ*g]o鰯 P2-kosx>R< {:z͏u.Yp:xISo7~r.n~ r&>1󕴲Vx'1>=@|wQ͢Yߚs~GObA'_BqdO7w|+*ɮ|ZJ__LyL|/{T ϥpBe5BMjIJgvnVN^nq~"ks=ύt6|b̊X'G/*_?C]69uh;Rrf'6VL;!S`GvqvG܄Bq}id5+p+el5K:wjLr@2̓84j||Oq.RVE;OjuOf[ ]D/&~|^cPOA٦{͊K[X%/fM4f(rg2 ?ѿqT2&r̎wXz\EtΔrSO­6oƘ oݑG??vS/`8QA%3eEznJ$ts}GspZP;C=jj/}6+8AnD2Xa`ȣO=@`8޲B ^n}l\TvCXrj_fc!yJHԸmAvdJNVr< 2%s.6$Kӭ> ոȫg נz~28X˾ͱ G'V3埙XnjbEl14): j2q!sZGkk;]+Hu֍ &Ҧ'ζa_gIcFJǃ׏|{+9)(ށ&omLcӝL%J1{a)pra=bmOj澘Zs%M=WNfԾ3k%īgq]Éy mBka ~\L5#xŰ*r7F32Ѿ]q7utz<w, 6uR+DSW\w|gڄJ bUl(~kӂ\c|ߐj-;V?*Y]m}==}-9/KWtyJQzd4:̍yq.~a}ۊphuok\֤gəjZC+۵ɔ+ޢ4]VL5 /JnJ+]*uR+M_$5J^a^\2\^X 65 m4~^sGn/m^ /{gG6稂29 :Ƙ20>ܢjZ/wJ(zR^,^r..J.KEaH%d3ZK/6ˠKMHUwmnx`VGRSi ܰq/SLbƠ*3lI5-\]7V$rعJF f[ٕΩR繞grR8Pq{̎}UɂLѓ61S3:~04H;k8XWw]̍VjAA<+p.>qwqzxp'fTQ[i% tDL{SRD%XubYzӢ9Οh\I4msԶׄvnbΦZ3oy^+')žתԒl kP4x}xЙ"/tukl?Q ~P$w D {؝R$v4$GH 7tRpb׮{߳ w7aN꺙vw ;6tCfMLf~LыZ*GrNg/UZ;Vur7dF I DFO.9u &48<dK4 u'/F刃~OJ!!v`|{Y|Q煫,\Txl&n.w \K~&B+n#FPoH=?wU)Vv"Vtk7F)su+@X#y"!VN0l2u րqg?T8b@l$ [d6<i#tFxcJ[b6jɗ*Zܙb𫓂3JPbuWIe0<*5xvƀZV 2Pc Hu~/U>'hߺūPfENoEB[<,~r~S=ỴU}n݀EAm)6}q;ɿvV⻛ p rf Y{M,  * Dׁ8ȏDKKQ\NGӕfvCy.[ E( DA#(&Yzs^{yZήjDj0jHOϴ`X$c,B#&ތobM_镤3@qԎz|@(P'2qxe@JD4M2 Z%!S"7$񷉫7W.2,lʢwymnTWq|۝ݬ[+~mI>^R-6M Hbz8 o3 ]ryhpz7vI];ܘUoiVYj_Oe'^F2xͣc=Dy=nUzw76Qxl#hl*Q~B]>i{nlV[|?\1oP51]A;t3wj`A~'>c#sO'IDٮU8Jf}np#[ZyĨt'ᛂ0^HRN<2OB&JON_imC?-xw_զ̂V*vWB}U9?N60vHRzJ*7Y[ı2zQ+lFҖ^.w=tWsIF K|M}f3,kW)YvwSrlVkUnVAsZsˑ+"vFwm+u6 25KV9:'ܠ:k{.i^^1"ʽ1 QhlPK/ÁUFyݴjebܵo{A¦Z֮%ƽzF3:LQ=g{9s1A6~phlfVD/_o椧}ol0kDWkÃMl'-f vc^sۆli~k0PT+G_5m,]ey0DX)i]LU7IXi Ѹ1!D_Iy)mem/%y~ ugBa4f˩Eu_Q陪~m5=:LGJvoes~yd+KWj=j#KHG;=_|vxa`~BV/wduxg.,~YZG-}XdRnh% -y.إK;QM9V:h2xp iCK pf\F7v~_Fc9$f>Lu!ҧ~b_@BV%LTZ6;[\Bay5l 8}iY>g蛜,险++<ͬBwZܴ7ΨL*6_k LNW(S(REyyt˛.N>Zq)+kdO3]?>b9u Nʲt_\XL0pa#p!xػ?كRelcpZ;;VK,b3JBaܑڹefOu )&ݍ>y(-#hLң.u44y_$kt tŭ}(C4h+{-v\fj?:zU,UeE9w";7v(Nï%>=Wiia4e cRLZNY Ⱥf $IU`cL.7ZR oS~:x=*t_TؗK2,A8u?sOsًz3*gp?(bwo/oҩ]୴621?j! = E$~lz sR^ten؅Da丵W4Kpa$flcwSĕib; +Sm,@ ;n'l%ZvS>2s oJll})sX*>:.j4P1D5w 1L\߯@}PgA<}D.yql/$oC{u{Duu9c{_Oos d:{r#( <99}(C ;_ 9_!ILoR[:,q&%8g6~|*C TS.w`pkߐ^>Ow5$H\»id*ORbBDIF7r7 5nlmAX/o\v"WЈj1eOↃ OUoo&I-&orwSPݐ3 <:it3KI\| S{cEP%gw#+lOiu;7V W,}zM%ݟ}Is?t]|njHG9+}go騬3ju]56;ZKfV!hysp)"ӱOslf<\ !2]SS' ]ޜaܦfdZ552r'T[-RFڅ&fƃ`ÅBӵS,Ove >yk U+}"K+Vʹ0yx42jL+Kke3|euwYtΦ&Z^(h~nxd$=*ծ[dU ]s:i+tRKf0l֨=P ,]:(PǺwpй}h=zjB]ꥎZ j\SSLG1j%O,&v>I`pv'Ӧy,b;cCTS8-"7d,JLNhz~D`KSG׍jL_YG1ȏgu#/2.a]Їl'lGOu:@FvWZ}wBb>mb}OrOQ1Kt m/$)>srE2 8NZOJ|vpj@. B{˦+.RQ憗B1\a]r1.Oc+6k oZC7c[8T/캝" Dfξ-reZʨE%gMG_bnD 'yJn-.NN+t Z9\2pJs)lsP8r=(~xpÙ@%Ht g+;C^o\g]P&Ni=+OeWɮ{-i4\ ?}ɷ&),.;.mTVAA4)(/u{g27<\Sr)bKʓ4_3WB-3/s1d63Q*7q@ IT!tc ~&ZF֕j{.Sφ5`=a)/%67[TS#X#elf-3i3W붣W#F 14O 1qK9'h~ G/7zj4N (\vn{h[)ֵ 3%r7ܨ4< Xمl&y>0VI3HWuGcS;0oP0B*J2F2krF2"h )G$q MXz-6X YWQ?3786k 5fڌ533?nGw:H?"Ǖ,yt4&`d C«'Mߛ)$APpv1@h0}{eg4ǭu)f7BWy{N3 ǽ>HMq6.HʢiԮOu)qd6$'"=ק2D {n-~ثc 5c|@,'O3k'סipdivۺߪY&G(}r6Y<?:L5S) ۧep6х@(g.;6)J!?itYgF˺\&#,e(ZBo qM>oR`XNe ,񂦽{uKj];TDZ,+Ycàa~,?|g~Otlr̡*"񤡄vn+ً\yєɶf3{(15\ZA,^rv%j-nq=qM>G bޢr#{ !aj@1J+y7мk9;r-4w(ՠ;Fx?C^s$Qd mjQ&ܙ.7B37ĉ`B!p'`ЋF3 f=Ի]h{"U=M]ބx߼Ew .F- Z'},Α=Z=,)Lacl.0ɧ2q^H^O?$Ws0h[=K>JIн໕䧐YkmQ m}{%)Av(=ONZ%|0BX73]1mfO:qz˫zs T3( 24+3k3 _4h}*̂%f>l{_1\%c\⢨1JEĠw͵;ud_ِT.` ?Lc< ?-c  | $_  J . ߉΋yw2ZH20uXu!N=bZ" mͨLkr=ZN;Ƽc c<%<^Xdc|&یn$B>X GcBCC ǭOA{?쏋x@Tsl9%tC b (S4>+\@h6 xCbT'1# #F?(AB]Y\ h}&b>Ռ#F'=Ǯrz(N,[bv6և{3𳄐:l9%~:@1BC  Bc,$@.yy?F-\}T,q\ӈy:\kI[k\.T,UK$vlw>3~jp4WFk@M W@s3F%1[:Lк+]@ V]OQ}7-JUWvcx=\ݫNN1OT tvLfbq\C`!ܾGb0:APLЬV4&v<`]mX3mb6Q]{-ؔYA|琫#~\5_2F@[\)񣨕|_d&u,5pύ xm\_6΀o!Hmx׻~NxjƯ"pAZu4J1Sjw^9 Bz)?H H ;b+&r>( "U(5 Z2bm݌? /M]R 'RJ—nFL4HO7'ڡinCA>Ø]'0{~2&$ !]7\?%pz麦۵K蜣? i||VSW!_wH?b{H룙A2O .}XZTٯZ6,j۩hy拀ZTfƽ b!4"6nIr1f䑼0y(^vլ@yXXwx0ү?mk ݟ?':4 Ip!zɮTeЧ @X6 }dA{JC]vyV*v5w~.扤Z-hIͰƷp"4BEjܻ47ie|xkP1V=lŘ&:^͝G]_g *q߶qnuohTG݇Gګ>sܛ͵ZmfUlZQ׌^ԱcV۵VoU(ujy*S&uRJ!u#[i++f\9/壔. B]$ˑвн0t|ѿYCδ>*tvWie9_F 8?[z#vY3^GRbV2gSl'#M$ q&aQ$poBDad%VZ=^-7\ SoN]8qTHb._햛o5/S=yZ8$˪ΐi;ak m e7*x.,皿-W{2/s +Ms򂙥.O mLՋlCO147~PnZW{8@"*d6Ij'OS3#9@ 0{&mmr/FqATqP I:ذlwf-5˘1C/HդuJmF F@Qc9Tn <[j*w񑒛"5|T!904qtZrve^v9]*{:WjCT!2I;qp[EphGtrZ},{`jsZ(Z;tl1CiTfDƔsp9Wlvef&G]u2J5+(QL]͗Џ[Tas#Xrؠ[6A[cI ~is+Q'ݟ47͗ CrjY %ފgsMHտfCQ"[dJQ]!%~ݦx>bŋÔi?Ϗn}*4̽54D"Un ~۽Ǯ~$6Mݖ0̖XkTRt 9Vݗ#8DoM5,\t_2.6x}LWncuk!& C.A*1TA}TJЯutm=hԻZ♻5\KmOy+[ CnbuYT«mE9V Vg2Ώ j5gҼU eghm,5,5NHeLíoAª!Zakm.ztr8Q(hLǣ gj{ 1y5ϰf;dYJNnc9|g6w6+K(|dص@`lWC _e.{9\}\u|y3E q};="} *%"R[/ԟ1wHza+ 7ju!.ls/*Y |vV/*9?\#ЌܜBftFOB#vAowrH˫#= Zܹ`j6edݫx{ʴ@t eӰs.i54Rza#;m\/u7+#X#^zLJ尅> z6 A+?lEj3VX9DN#Q:*FS]λfQ96;%ѦŴ,7O6Z(+K}L_eWh5ERQ )ST6k03@Y2c4ϕc@qhƮxֱsx)QuQcֶ11֎KOEtc)rsw}sXqVwfLL)T*r<~6cHyb-ȓ=y@>;A>WcD4@/@ @wLZֵt3QM]u1eVjt^lӔ : : 0Ĉ?>L:1F`p1`s 0bl nA> FΘ GO?㡑ڢ$G%X֣ڷ:W>WbLm_nco-Ռ^^5r08Q<cvԍz7# yo1̣(a# 65k1F'LOw쿠cxS )QjFkPPs@Tp_vG@PT d` [}̩Z*쳀! ѫ:DU'j=#S)C9409gYTf1n:`F`0ݙ qS9V=5~Խ:\lsm@GSG>@#7/B`u#7q?"0 }pK#l=yۀnۅ-#ȔX3vRsDB&a=9$OhΉ7[8 vHd z~SLN@!j'jQB&1aB@3@h& Z- jx7OuFl_Q%?_#ѡ.7;$obMs\̀v :Ǎ' [КMo@"&qYߐdߺ%Wo{*ȞXwqn ޾!_<]_1_\/\Ԇ"+bfoY7n H4|rE oR q/;\kbrG(>34Νp>>QFaL>"qUl-쌳v j(?Iߺ\gW %rC&…JgL*ǧլ>N݇t[`ͮ 9y{~oR'rųŤJaqByqV2tm7!Qxl9/#ȍz͏q>mݳ+~9/^(!?.]i ttI#v&j&E7Box뤯C&@rz<3ߪaF.Iw?iyj/әs\`o&u{7>V()żbĦrX4~B+_ɶ˽uuaۣ%攳\hm62;$Oܲ*HD>@nGu`bh=b6P<p~ȜzBO;^t=45x̙;w<7{KA@[inTiG onBW޴o%ېx-"{zKQA 7kkgc -uDm޺V>Jk/[Ьj9ӄWjQa}PúC9mkupn{E@VoSIƺQy.eEŌ]0>彞o|.2@q,OAxT4֨l!ڮբjdv|~KwTzVUzN%0-볜ָFf[O\ӜrHǧn1_koWOg}sVL7իef-h׎85hd0Ϧz vKE},gʡsG5V1(]0ؽ`Y׏#nyer\D(JD~u-Jd)>`1IkXj>690}?*E,jMӳ p] as5 }B'vnJ vԏA5۪ТѼ.|QBddU%2n5"r81/Yzr q)35cRfHW(Z)lo bz@6~4j jR>,cЅ8u.u0LA 1O)NNivLNyje['W^:]ߙ n6{rşd^ˑд ֆEw)0vqi>&|k;QĻtWBOs7澷WcPn+mq*\{܊`̊FЕC(d gl}FU16I'퇻%w|/l\P Wο)?!,?y.*[ E䜣("( s9쵺w}?dZ% T ^~K6Icuqܭux~);w՝;᛹gs<\`HuAs;xngYad.U1ϴǨv/=sTK'uߤ1ʑsG!A̰kD %9z|{ +&R1aqaUdAIԢŊ!IK-\n!ɴH;N[zhU*վ(*dT}3eOE%=1ZFq.RbK.Ao۵eUNC *UX%Y7,.#oKl.@V)*sy*5UrZd:$rD+Ĭ4b1—|6o¸(DIz9Mq62\MSdA]MIεW̚ICLm'%~55&Qd 'QN ٓa bD| ցa j:VyƳeWYzCC*a"^C&@TBT( *f&AҶuڣxʕo#T~*Q˺kܳ= etC3$F$N8(,<ƳF k~a9>i9D(l2ܧ5 5 MKg7ֶǺ; g6VV9a[9I[oBwԩ@6m^ 5Z|=RHK1 ~ ܱ㳓 "PF!pB&)qA^ʁ^(t+Fw*)86aNv|YFbCf^x"Ws$ge$f$G_ 4 %(؄)d`uD#O]&c][C/;a7`v1`3/7])ȷ.Q/ e"Alt)R/AQ&2@cQ/1FFW  Y9Zmm3^"QY܏ae1_ѝ⪜FctPHouXZrґ5iz6[v˲aJ-r]@v`#jbC@ֈ~:ȰBY+@ R,]Rsd`ac;VEc4w>,.p5mQ.H䠼 :[J0 U4C[P##O5jTNJZ*H@YPJ =" {s0z:89$ } s\2қujĻá_@9'F֋Q ~Y!_*&:i:=@G񏁮6U贯j|$uSX;-FY0JNW|07`?QVZ#׋QŘ.lαb|j5ԯck艤\90P1s sU(!=zUbdf9Q._OA_Cg0n-:w~ ~cŘȀ"MxG#. )Ƙv.Ώ3"IKYZ39B 2Rl잷D ^7so"N`H'@$;SDpOm@ V3U^9[pv#B5xmzL߄Aȝ14oDOIp3U?U2ي`9@VuȚ#o@x`)D {j~VU_HŽټqV /S@LCNZ?Z+.+]8q~-7֑G@^@]Y^.Р`Իu`ƫKhG}`#s@lvģY&eRUADmM%$o]Lo6#OQ_1($c[@m`[=UzSk[LIJYސd?_{(To>ۜ[(+Y5^z!prtV'e?yWfUx?.oD@,9=Kj9KGW .\tkL[p> endobj 64 0 obj [/View/Design] endobj 65 0 obj <>>> endobj 95 0 obj [94 0 R] endobj 176 0 obj <> endobj xref 0 177 0000000004 65535 f 0000000016 00000 n 0000000159 00000 n 0000066899 00000 n 0000000010 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0001178885 00000 n 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0001178955 00000 n 0001178986 00000 n 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000527856 00000 n 0000528171 00000 n 0000527718 00000 n 0000527527 00000 n 0001179071 00000 n 0000066951 00000 n 0000067463 00000 n 0000068976 00000 n 0000535409 00000 n 0000074516 00000 n 0000535170 00000 n 0000535284 00000 n 0000070316 00000 n 0000070658 00000 n 0000070989 00000 n 0000071329 00000 n 0000069039 00000 n 0001178848 00000 n 0000069751 00000 n 0000069801 00000 n 0000526863 00000 n 0000480168 00000 n 0000526927 00000 n 0000477963 00000 n 0000477899 00000 n 0000503273 00000 n 0000467373 00000 n 0000072255 00000 n 0000473239 00000 n 0000467437 00000 n 0000071670 00000 n 0000464446 00000 n 0000071734 00000 n 0000072301 00000 n 0000074553 00000 n 0000074611 00000 n 0000464562 00000 n 0000464628 00000 n 0000464663 00000 n 0000464970 00000 n 0000467260 00000 n 0000465045 00000 n 0000469177 00000 n 0000473355 00000 n 0000473421 00000 n 0000473456 00000 n 0000473762 00000 n 0000473837 00000 n 0000480214 00000 n 0000503215 00000 n 0000503389 00000 n 0000503455 00000 n 0000503490 00000 n 0000503787 00000 n 0000503862 00000 n 0000527043 00000 n 0000527109 00000 n 0000527144 00000 n 0000527452 00000 n 0000527600 00000 n 0000527632 00000 n 0000531695 00000 n 0000531722 00000 n 0000529555 00000 n 0000528382 00000 n 0000528673 00000 n 0000529875 00000 n 0000532134 00000 n 0000532419 00000 n 0000532489 00000 n 0000532763 00000 n 0000532844 00000 n 0000535484 00000 n 0000535872 00000 n 0000536879 00000 n 0000544385 00000 n 0000609975 00000 n 0000654128 00000 n 0000719718 00000 n 0000785308 00000 n 0000850898 00000 n 0000916488 00000 n 0000982078 00000 n 0001047668 00000 n 0001113258 00000 n 0001179096 00000 n trailer <<1E7C0F40FB33D34A913FDDBA51388540>]>> startxref 1179305 %%EOF buildbot-0.8.8/docs/manual/_images/slaves.svg000066400000000000000000004754761222546025000212070ustar00rootroot00000000000000 image/svg+xml BuildSlave Connections Georgi Valkov 2010-01-28T18:17:14+02:00 2010-01-28T18:17:14+02:00 2010-01-28T18:17:13+02:00 Adobe Illustrator CS4 256 68 JPEG /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgARAEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FXYq7FXYq7FXYq7 FXYq7FXYq7FXYq7pucVSn9IXeraf6uhyLCGl9P61dQycTGBVpIozwL77KTRevXvRxmcbh8z+hyvC jinWUXtyBHPuJ6feiG0ez+vSajEvDUpIzF9Zq5FKbApy4kAitP6nJnEL4h9TWM8uEQP0XdKH6TuN Ms4TrbK8jSGNrq2ikMQH7DyD4/T5dDuRXvkPEMB6/mPxs2eCMkj4fdyJF/DvTXL3FdirsVdirsVd irsVdirsVeZebvzLXyvp+n32pTX0v6SWSRYbGK0YRrDAbmUn1+B4pGpp8TMfc4qh5Pzl8nxRvJL5 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 /wAqz+t2/wBX9L6x6qehX1/7zkOH2tq8qUxV/9k= xmp.iid:0E9190091A0CDF1198A8D064EBA738F3 xmp.did:0E9190091A0CDF1198A8D064EBA738F3 uuid:5D20892493BFDB11914A8590D31508C8 proof:pdf uuid:fd772761-a3ec-4632-8af9-c0442bd7dba6 xmp.did:05FC8385150CDF1198A8D064EBA738F3 uuid:5D20892493BFDB11914A8590D31508C8 proof:pdf converted from application/pdf to <unknown> saved xmp.iid:D27F11740720681191099C3B601C4548 2008-04-17T14:19:15+05:30 Adobe Illustrator CS4 / converted from application/pdf to <unknown> converted from application/pdf to <unknown> saved xmp.iid:F97F1174072068118D4ED246B3ADB1C6 2008-05-15T16:23:06-07:00 Adobe Illustrator CS4 / saved xmp.iid:FA7F1174072068118D4ED246B3ADB1C6 2008-05-15T17:10:45-07:00 Adobe Illustrator CS4 / saved xmp.iid:EF7F117407206811A46CA4519D24356B 2008-05-15T22:53:33-07:00 Adobe Illustrator CS4 / saved xmp.iid:F07F117407206811A46CA4519D24356B 2008-05-15T23:07:07-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F117407206811BDDDFD38D0CF24DD 2008-05-16T10:35:43-07:00 Adobe Illustrator CS4 / converted from application/pdf to <unknown> saved xmp.iid:F97F117407206811BDDDFD38D0CF24DD 2008-05-16T10:40:59-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to <unknown> saved xmp.iid:FA7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:26:55-07:00 Adobe Illustrator CS4 / saved xmp.iid:FB7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:29:01-07:00 Adobe Illustrator CS4 / saved xmp.iid:FC7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:29:20-07:00 Adobe Illustrator CS4 / saved xmp.iid:FD7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:30:54-07:00 Adobe Illustrator CS4 / saved xmp.iid:FE7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:31:22-07:00 Adobe Illustrator CS4 / saved xmp.iid:B233668C16206811BDDDFD38D0CF24DD 2008-05-16T12:23:46-07:00 Adobe Illustrator CS4 / saved xmp.iid:B333668C16206811BDDDFD38D0CF24DD 2008-05-16T13:27:54-07:00 Adobe Illustrator CS4 / saved xmp.iid:B433668C16206811BDDDFD38D0CF24DD 2008-05-16T13:46:13-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F11740720681197C1BF14D1759E83 2008-05-16T15:47:57-07:00 Adobe Illustrator CS4 / saved xmp.iid:F87F11740720681197C1BF14D1759E83 2008-05-16T15:51:06-07:00 Adobe Illustrator CS4 / saved xmp.iid:F97F11740720681197C1BF14D1759E83 2008-05-16T15:52:22-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FA7F117407206811B628E3BF27C8C41B 2008-05-22T13:28:01-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FF7F117407206811B628E3BF27C8C41B 2008-05-22T16:23:53-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:07C3BD25102DDD1181B594070CEB88D9 2008-05-28T16:45:26-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:F87F1174072068119098B097FDA39BEF 2008-06-02T13:25:25-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F117407206811BB1DBF8F242B6F84 2008-06-09T14:58:36-07:00 Adobe Illustrator CS4 / saved xmp.iid:F97F117407206811ACAFB8DA80854E76 2008-06-11T14:31:27-07:00 Adobe Illustrator CS4 / saved xmp.iid:0180117407206811834383CD3A8D2303 2008-06-11T22:37:35-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F117407206811818C85DF6A1A75C3 2008-06-27T14:40:42-07:00 Adobe Illustrator CS4 / saved xmp.iid:32F582E93563DE11BB48ECB7764A1480 2009-06-27T20:06:51+03:00 Adobe Illustrator CS4 / saved xmp.iid:530E91AC4863DE11954883E494157F9B 2009-06-27T21:32:58+03:00 Adobe Illustrator CS4 / saved xmp.iid:05FC8385150CDF1198A8D064EBA738F3 2010-01-28T16:15:18+02:00 Adobe Illustrator CS4 / saved xmp.iid:0E9190091A0CDF1198A8D064EBA738F3 2010-01-28T18:17:14+02:00 Adobe Illustrator CS4 / Print False True 1 792.000000 612.000000 Points MyriadPro-Bold Myriad Pro Bold Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-Bold.otf MyriadPro-BoldCond Myriad Pro Bold Condensed Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-BoldCond.otf Cyan Magenta Yellow Black Default Swatch Group 0 White CMYK PROCESS 0.000000 0.000000 0.000000 0.000000 Black CMYK PROCESS 0.000000 0.000000 0.000000 100.000000 CMYK Red CMYK PROCESS 0.000000 100.000000 100.000000 0.000000 CMYK Yellow CMYK PROCESS 0.000000 0.000000 100.000000 0.000000 CMYK Green CMYK PROCESS 100.000000 0.000000 100.000000 0.000000 CMYK Cyan CMYK PROCESS 100.000000 0.000000 0.000000 0.000000 CMYK Blue CMYK PROCESS 100.000000 100.000000 0.000000 0.000000 CMYK Magenta CMYK PROCESS 0.000000 100.000000 0.000000 0.000000 C=15 M=100 Y=90 K=10 CMYK PROCESS 14.999998 100.000000 90.000004 10.000002 C=0 M=90 Y=85 K=0 CMYK PROCESS 0.000000 90.000004 84.999996 0.000000 C=0 M=80 Y=95 K=0 CMYK PROCESS 0.000000 80.000001 94.999999 0.000000 C=0 M=50 Y=100 K=0 CMYK PROCESS 0.000000 50.000000 100.000000 0.000000 C=0 M=35 Y=85 K=0 CMYK PROCESS 0.000000 35.000002 84.999996 0.000000 C=5 M=0 Y=90 K=0 CMYK PROCESS 5.000001 0.000000 90.000004 0.000000 C=20 M=0 Y=100 K=0 CMYK PROCESS 19.999999 0.000000 100.000000 0.000000 C=50 M=0 Y=100 K=0 CMYK PROCESS 50.000000 0.000000 100.000000 0.000000 C=75 M=0 Y=100 K=0 CMYK PROCESS 75.000000 0.000000 100.000000 0.000000 C=85 M=10 Y=100 K=10 CMYK PROCESS 84.999996 10.000002 100.000000 10.000002 C=90 M=30 Y=95 K=30 CMYK PROCESS 90.000004 30.000001 94.999999 30.000001 C=75 M=0 Y=75 K=0 CMYK PROCESS 75.000000 0.000000 75.000000 0.000000 C=80 M=10 Y=45 K=0 CMYK PROCESS 80.000001 10.000002 44.999999 0.000000 C=70 M=15 Y=0 K=0 CMYK PROCESS 69.999999 14.999998 0.000000 0.000000 C=85 M=50 Y=0 K=0 CMYK PROCESS 84.999996 50.000000 0.000000 0.000000 C=100 M=95 Y=5 K=0 CMYK PROCESS 100.000000 94.999999 5.000001 0.000000 C=100 M=100 Y=25 K=25 CMYK PROCESS 100.000000 100.000000 25.000000 25.000000 C=75 M=100 Y=0 K=0 CMYK PROCESS 75.000000 100.000000 0.000000 0.000000 C=50 M=100 Y=0 K=0 CMYK PROCESS 50.000000 100.000000 0.000000 0.000000 C=35 M=100 Y=35 K=10 CMYK PROCESS 35.000002 100.000000 35.000002 10.000002 C=10 M=100 Y=50 K=0 CMYK PROCESS 10.000002 100.000000 50.000000 0.000000 C=0 M=95 Y=20 K=0 CMYK PROCESS 0.000000 94.999999 19.999999 0.000000 C=25 M=25 Y=40 K=0 CMYK PROCESS 25.000000 25.000000 39.999998 0.000000 C=40 M=45 Y=50 K=5 CMYK PROCESS 39.999998 44.999999 50.000000 5.000001 C=50 M=50 Y=60 K=25 CMYK PROCESS 50.000000 50.000000 60.000002 25.000000 C=55 M=60 Y=65 K=40 CMYK PROCESS 55.000001 60.000002 64.999998 39.999998 C=25 M=40 Y=65 K=0 CMYK PROCESS 25.000000 39.999998 64.999998 0.000000 C=30 M=50 Y=75 K=10 CMYK PROCESS 30.000001 50.000000 75.000000 10.000002 C=35 M=60 Y=80 K=25 CMYK PROCESS 35.000002 60.000002 80.000001 25.000000 C=40 M=65 Y=90 K=35 CMYK PROCESS 39.999998 64.999998 90.000004 35.000002 C=40 M=70 Y=100 K=50 CMYK PROCESS 39.999998 69.999999 100.000000 50.000000 C=50 M=70 Y=80 K=70 CMYK PROCESS 50.000000 69.999999 80.000001 69.999999 Grays 1 C=0 M=0 Y=0 K=100 CMYK PROCESS 0.000000 0.000000 0.000000 100.000000 C=0 M=0 Y=0 K=90 CMYK PROCESS 0.000000 0.000000 0.000000 89.999402 C=0 M=0 Y=0 K=80 CMYK PROCESS 0.000000 0.000000 0.000000 79.998797 C=0 M=0 Y=0 K=70 CMYK PROCESS 0.000000 0.000000 0.000000 69.999701 C=0 M=0 Y=0 K=60 CMYK PROCESS 0.000000 0.000000 0.000000 59.999102 C=0 M=0 Y=0 K=50 CMYK PROCESS 0.000000 0.000000 0.000000 50.000000 C=0 M=0 Y=0 K=40 CMYK PROCESS 0.000000 0.000000 0.000000 39.999402 C=0 M=0 Y=0 K=30 CMYK PROCESS 0.000000 0.000000 0.000000 29.998803 C=0 M=0 Y=0 K=20 CMYK PROCESS 0.000000 0.000000 0.000000 19.999701 C=0 M=0 Y=0 K=10 CMYK PROCESS 0.000000 0.000000 0.000000 9.999102 C=0 M=0 Y=0 K=5 CMYK PROCESS 0.000000 0.000000 0.000000 4.998803 Brights 1 C=0 M=100 Y=100 K=0 CMYK PROCESS 0.000000 100.000000 100.000000 0.000000 C=0 M=75 Y=100 K=0 CMYK PROCESS 0.000000 75.000000 100.000000 0.000000 C=0 M=10 Y=95 K=0 CMYK PROCESS 0.000000 10.000002 94.999999 0.000000 C=85 M=10 Y=100 K=0 CMYK PROCESS 84.999996 10.000002 100.000000 0.000000 C=100 M=90 Y=0 K=0 CMYK PROCESS 100.000000 90.000004 0.000000 0.000000 C=60 M=90 Y=0 K=0 CMYK PROCESS 60.000002 90.000004 0.003099 0.003099 Adobe PDF library 9.00 buildbot-0.8.8/docs/manual/_images/slaves.txt000066400000000000000000000020461222546025000212020ustar00rootroot00000000000000 Repository| | BuildMaster | | (CVS/SVN)| | ^|^^^ | | | / c \ | ----------+ +------------------/--o----\-+ ^ / m ^ \ | / m | \ checkout/update --+ a | +-- | TCP| n | |TCP | | d | | | | s | | | | | | | | | | r | | | | e | -N-A-T-|- - - - -N-A-T- - - - -|- |- s-|- - - - -N-A-T- - - | | | u | | | | l | | +------------------|--|--t-|-+ | | | | s | | +----| v | | | | | | | | | | | BuildSlave | +----------------------------+ buildbot-0.8.8/docs/manual/_images/status.ai000066400000000000000000060703121222546025000210110ustar00rootroot00000000000000%PDF-1.5 % 1 0 obj <>/OCGs[8 0 R]>>/Pages 3 0 R/Type/Catalog>> endobj 2 0 obj <>stream application/pdf status Georgi Valkov 2010-01-28T16:32:53+02:00 2010-01-28T16:32:53+02:00 2010-01-28T16:32:52+03:00 Adobe Illustrator CS4 256 248 JPEG /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgA+AEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FXYqxfXtRvoPPnl WyhnZLS8j1I3UIPwyGKKMx8h/kljTFWUYq7FXYq7FXYq7FXYq7FXYqx7zV5yttDeGyt7d9S1u6Uv a6ZCwVigNDLNIfhiiB25t1OygnbFWKzW/m7Vf3ms67NagkEWGjn6pCnsZyGuZPnzUf5OC005/LkJ UhNS1eNj0ddW1FiPoedl+8Y2tL4b/wA8aHSS1vP8RWKD49Pv+Ed3QD/dV3GqKze0qGv8wxtaZp5d 8x6Z5g04XtgzUVjFcW8q8JoJk+3FNGd0dfD6RUb4UJpirsVdirsVQ0ZX9JzrzYsIYSYz9kAvLQjf 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 XYq7FXYq7FX/2Q== uuid:1a110104-9ccf-48ee-9877-59c19a690ee5 xmp.did:0AFC8385150CDF1198A8D064EBA738F3 uuid:5D20892493BFDB11914A8590D31508C8 proof:pdf uuid:ae4e0ad8-c5e7-4dc8-9f8e-e06c1f0d8b97 xmp.did:921461FF4F63DE11954883E494157F9B uuid:5D20892493BFDB11914A8590D31508C8 proof:pdf converted from application/pdf to <unknown> saved xmp.iid:D27F11740720681191099C3B601C4548 2008-04-17T14:19:15+05:30 Adobe Illustrator CS4 / converted from application/pdf to <unknown> converted from application/pdf to <unknown> saved xmp.iid:F97F1174072068118D4ED246B3ADB1C6 2008-05-15T16:23:06-07:00 Adobe Illustrator CS4 / saved xmp.iid:FA7F1174072068118D4ED246B3ADB1C6 2008-05-15T17:10:45-07:00 Adobe Illustrator CS4 / saved xmp.iid:EF7F117407206811A46CA4519D24356B 2008-05-15T22:53:33-07:00 Adobe Illustrator CS4 / saved xmp.iid:F07F117407206811A46CA4519D24356B 2008-05-15T23:07:07-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F117407206811BDDDFD38D0CF24DD 2008-05-16T10:35:43-07:00 Adobe Illustrator CS4 / converted from application/pdf to <unknown> saved xmp.iid:F97F117407206811BDDDFD38D0CF24DD 2008-05-16T10:40:59-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to <unknown> saved xmp.iid:FA7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:26:55-07:00 Adobe Illustrator CS4 / saved xmp.iid:FB7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:29:01-07:00 Adobe Illustrator CS4 / saved xmp.iid:FC7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:29:20-07:00 Adobe Illustrator CS4 / saved xmp.iid:FD7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:30:54-07:00 Adobe Illustrator CS4 / saved xmp.iid:FE7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:31:22-07:00 Adobe Illustrator CS4 / saved xmp.iid:B233668C16206811BDDDFD38D0CF24DD 2008-05-16T12:23:46-07:00 Adobe Illustrator CS4 / saved xmp.iid:B333668C16206811BDDDFD38D0CF24DD 2008-05-16T13:27:54-07:00 Adobe Illustrator CS4 / saved xmp.iid:B433668C16206811BDDDFD38D0CF24DD 2008-05-16T13:46:13-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F11740720681197C1BF14D1759E83 2008-05-16T15:47:57-07:00 Adobe Illustrator CS4 / saved xmp.iid:F87F11740720681197C1BF14D1759E83 2008-05-16T15:51:06-07:00 Adobe Illustrator CS4 / saved xmp.iid:F97F11740720681197C1BF14D1759E83 2008-05-16T15:52:22-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FA7F117407206811B628E3BF27C8C41B 2008-05-22T13:28:01-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FF7F117407206811B628E3BF27C8C41B 2008-05-22T16:23:53-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:07C3BD25102DDD1181B594070CEB88D9 2008-05-28T16:45:26-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:F87F1174072068119098B097FDA39BEF 2008-06-02T13:25:25-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F117407206811BB1DBF8F242B6F84 2008-06-09T14:58:36-07:00 Adobe Illustrator CS4 / saved xmp.iid:F97F117407206811ACAFB8DA80854E76 2008-06-11T14:31:27-07:00 Adobe Illustrator CS4 / saved xmp.iid:0180117407206811834383CD3A8D2303 2008-06-11T22:37:35-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F117407206811818C85DF6A1A75C3 2008-06-27T14:40:42-07:00 Adobe Illustrator CS4 / saved xmp.iid:921461FF4F63DE11954883E494157F9B 2009-06-27T22:56:11+03:00 Adobe Illustrator CS4 / saved xmp.iid:0AFC8385150CDF1198A8D064EBA738F3 2010-01-28T16:32:48+02:00 Adobe Illustrator CS4 / Document Print False True 1 792.000000 612.000000 Points MyriadPro-Bold Myriad Pro Bold Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-Bold.otf MyriadPro-Cond Myriad Pro Condensed Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-Cond.otf Cyan Magenta Yellow Black Default Swatch Group 0 White CMYK PROCESS 0.000000 0.000000 0.000000 0.000000 Black CMYK PROCESS 0.000000 0.000000 0.000000 100.000000 CMYK Red CMYK PROCESS 0.000000 100.000000 100.000000 0.000000 CMYK Yellow CMYK PROCESS 0.000000 0.000000 100.000000 0.000000 CMYK Green CMYK PROCESS 100.000000 0.000000 100.000000 0.000000 CMYK Cyan CMYK PROCESS 100.000000 0.000000 0.000000 0.000000 CMYK Blue CMYK PROCESS 100.000000 100.000000 0.000000 0.000000 CMYK Magenta CMYK PROCESS 0.000000 100.000000 0.000000 0.000000 C=15 M=100 Y=90 K=10 CMYK PROCESS 14.999998 100.000000 90.000004 10.000002 C=0 M=90 Y=85 K=0 CMYK PROCESS 0.000000 90.000004 84.999996 0.000000 C=0 M=80 Y=95 K=0 CMYK PROCESS 0.000000 80.000001 94.999999 0.000000 C=0 M=50 Y=100 K=0 CMYK PROCESS 0.000000 50.000000 100.000000 0.000000 C=0 M=35 Y=85 K=0 CMYK PROCESS 0.000000 35.000002 84.999996 0.000000 C=5 M=0 Y=90 K=0 CMYK PROCESS 5.000001 0.000000 90.000004 0.000000 C=20 M=0 Y=100 K=0 CMYK PROCESS 19.999999 0.000000 100.000000 0.000000 C=50 M=0 Y=100 K=0 CMYK PROCESS 50.000000 0.000000 100.000000 0.000000 C=75 M=0 Y=100 K=0 CMYK PROCESS 75.000000 0.000000 100.000000 0.000000 C=85 M=10 Y=100 K=10 CMYK PROCESS 84.999996 10.000002 100.000000 10.000002 C=90 M=30 Y=95 K=30 CMYK PROCESS 90.000004 30.000001 94.999999 30.000001 C=75 M=0 Y=75 K=0 CMYK PROCESS 75.000000 0.000000 75.000000 0.000000 C=80 M=10 Y=45 K=0 CMYK PROCESS 80.000001 10.000002 44.999999 0.000000 C=70 M=15 Y=0 K=0 CMYK PROCESS 69.999999 14.999998 0.000000 0.000000 C=85 M=50 Y=0 K=0 CMYK PROCESS 84.999996 50.000000 0.000000 0.000000 C=100 M=95 Y=5 K=0 CMYK PROCESS 100.000000 94.999999 5.000001 0.000000 C=100 M=100 Y=25 K=25 CMYK PROCESS 100.000000 100.000000 25.000000 25.000000 C=75 M=100 Y=0 K=0 CMYK PROCESS 75.000000 100.000000 0.000000 0.000000 C=50 M=100 Y=0 K=0 CMYK PROCESS 50.000000 100.000000 0.000000 0.000000 C=35 M=100 Y=35 K=10 CMYK PROCESS 35.000002 100.000000 35.000002 10.000002 C=10 M=100 Y=50 K=0 CMYK PROCESS 10.000002 100.000000 50.000000 0.000000 C=0 M=95 Y=20 K=0 CMYK PROCESS 0.000000 94.999999 19.999999 0.000000 C=25 M=25 Y=40 K=0 CMYK PROCESS 25.000000 25.000000 39.999998 0.000000 C=40 M=45 Y=50 K=5 CMYK PROCESS 39.999998 44.999999 50.000000 5.000001 C=50 M=50 Y=60 K=25 CMYK PROCESS 50.000000 50.000000 60.000002 25.000000 C=55 M=60 Y=65 K=40 CMYK PROCESS 55.000001 60.000002 64.999998 39.999998 C=25 M=40 Y=65 K=0 CMYK PROCESS 25.000000 39.999998 64.999998 0.000000 C=30 M=50 Y=75 K=10 CMYK PROCESS 30.000001 50.000000 75.000000 10.000002 C=35 M=60 Y=80 K=25 CMYK PROCESS 35.000002 60.000002 80.000001 25.000000 C=40 M=65 Y=90 K=35 CMYK PROCESS 39.999998 64.999998 90.000004 35.000002 C=40 M=70 Y=100 K=50 CMYK PROCESS 39.999998 69.999999 100.000000 50.000000 C=50 M=70 Y=80 K=70 CMYK PROCESS 50.000000 69.999999 80.000001 69.999999 Grays 1 C=0 M=0 Y=0 K=100 CMYK PROCESS 0.000000 0.000000 0.000000 100.000000 C=0 M=0 Y=0 K=90 CMYK PROCESS 0.000000 0.000000 0.000000 89.999402 C=0 M=0 Y=0 K=80 CMYK PROCESS 0.000000 0.000000 0.000000 79.998797 C=0 M=0 Y=0 K=70 CMYK PROCESS 0.000000 0.000000 0.000000 69.999701 C=0 M=0 Y=0 K=60 CMYK PROCESS 0.000000 0.000000 0.000000 59.999102 C=0 M=0 Y=0 K=50 CMYK PROCESS 0.000000 0.000000 0.000000 50.000000 C=0 M=0 Y=0 K=40 CMYK PROCESS 0.000000 0.000000 0.000000 39.999402 C=0 M=0 Y=0 K=30 CMYK PROCESS 0.000000 0.000000 0.000000 29.998803 C=0 M=0 Y=0 K=20 CMYK PROCESS 0.000000 0.000000 0.000000 19.999701 C=0 M=0 Y=0 K=10 CMYK PROCESS 0.000000 0.000000 0.000000 9.999102 C=0 M=0 Y=0 K=5 CMYK PROCESS 0.000000 0.000000 0.000000 4.998803 Brights 1 C=0 M=100 Y=100 K=0 CMYK PROCESS 0.000000 100.000000 100.000000 0.000000 C=0 M=75 Y=100 K=0 CMYK PROCESS 0.000000 75.000000 100.000000 0.000000 C=0 M=10 Y=95 K=0 CMYK PROCESS 0.000000 10.000002 94.999999 0.000000 C=85 M=10 Y=100 K=0 CMYK PROCESS 84.999996 10.000002 100.000000 0.000000 C=100 M=90 Y=0 K=0 CMYK PROCESS 100.000000 90.000004 0.000000 0.000000 C=60 M=90 Y=0 K=0 CMYK PROCESS 60.000002 90.000004 0.003099 0.003099 Adobe PDF library 9.00 endstream endobj 3 0 obj <> endobj 10 0 obj <>/Resources<>/ExtGState<>/Font<>/ProcSet[/PDF/Text]/Properties<>/XObject<>>>/Thumb 62 0 R/TrimBox[0.0 0.0 792.0 612.0]/Type/Page>> endobj 11 0 obj <>stream HWMs ϯ>4M<Ʋֵ)o6Aו7~tGӣIɞ&AW?^W?\͕ڽ1F97pįO$ÎWQN>:e2oX *Yyvhݗ1ڈnuٽ;}/Vs դS qTu^MJ2O3qxANAZ̛>74ay gݿA|>AYT߮h_m>O}zZv^NA0eʺcZlU{T[+qh"p>RNTLxRx[N2Y(SfޱPCb8!#YeMBDFQɯ$v ,Swh;t1+gòtLq@h>o>.t^[lAńÀvI./b WUk6M(ı"i;Uc&5ERown B]"JQoV'IIR\3,V `gnN6"'pH} C 2[ d~3"a5' Y{a)OEڌ 3Mdp`CIaI[ZVYwLQ&v1P׷A'IxĊ^ڿMo?L>F0R /_C C*ىF߿{#+oO^s:PHcIo 02(ݘW)(+>؎ċH\?aU%Ft!)y%lcYXUoSכE_4rYoCɂI61uZk)ٕ &ю$uq4!jҲ2̺T±n^ni{X2 2B jy0) LCaFbi%ٳA!D0͆Z eE?xr /~~mmQz2"ߧLjbMZ4PRwչ4xWMY+hjab&/HkMLXV~!\:J{B_ܾ$~qQ -lҜR"&]ZHSf6X!qi<\ɢrpK/ypv<ϰ\Q2IES}q%P~ҤRLjkZNcL¯5nljjlcΜɠIb%T Ɍ9 X*:g 0r(W0biYDۡ31E &(p^e0qiPԉe_|yUsPT M 3%w3^<ƿ2o1DL399NO#щPYꤻMs1ŏyh=?mу^   RH3kn2tFJ5yKĿw- c2gsx/>2:P Fpf6)y ə`Sl;mE zT kICx3#>"딬/>@DJC>~XДEfNd~9i`%̎̚#1Z2A pw%:f7:BD { &. K$4m^"'W^4׭ܴUKX qmxSƌ$Mwեmh A;=@Zzڈ_CR?["*R!rʩW Y"*8x<ߨ>r`Z)sJ}_}H+<\P8׎Bm v\M~q=dxL X_ԩՓb"rI6T; rI̒|&yAگDƁ\lS JL$DiG^VyMRB=SmvД(%}0r(+\Qwj`TV@ C||RWN|^wZ8ڹ)<,EH+:؁՗VsU`k&W8RLJ)>q|`o`?2C?nf맇f !i'uL4)kbfK /ɹ%BNwv!6u^C `N;^Cٺm>1W@AWր)z߼h:)JIhSYV*9Z"~jA"ppG~xjvv3޴=2{~_^W+C endstream endobj 12 0 obj <> endobj 62 0 obj <>stream 8;Z\741=-&$k8I<,?J$lFL.@I7?D7V3`kZuP)]lfBCt^Y"rt[*:E1%e"Q)bUY%2gQo[@>aZrqBQDsJ_gn2)%.6.)nK6EI\# M?A@p[ET'YPoHEV#5Jt]Z(h;#W;p^=h%)`nNd?@,nYg;iXf0t/lWE5r"9GpCR!g7( 1P#Ona]C)-O@SRV`7qZ?ncJZh\@hmnipCiEMrq[nUeZ%1Vrj"-jN%sX(Y=9&C^>l<;I<3-kaDa]fsKLa@6h2ITO6h13%5;nH/o< *Y1dk2R/Os'SP+>q(;ruQ#OJh$c'%bJPc6UK+c&-ea-SIGU+O,_Rd=,%FoeI7);T> 2%G8AS/9gB_'Hor54*qmUTfR\ab:tF^Q/^1Q0os`5dn9Go-1JDa_i2:i9CbI*EWPh P%#qH3)UK'H]PD&s.*6C+V?MEB17t(bMPOi&CI.q8#"Dn-bWD:#Y\$SN>SBf\KroE ZR:dqSi1sV2IZr'82"K#'l=m?2^EebCjN#[;6j.'7_3*5X&=)um9K)/@r J:/-n$UmAX%qt@"F'VpW$d7-JI8]>6SM0=dEPspu9&`*PJZG8rjfID/]m3J+G[9k. 9s=I$6GLDT!5Y`]M@02VLcP0)0W2V60F5l*pqX7l+WcA"%u,>UBs6mr/r(U52tu^f BIf[ZK0lH;eVh,i #g,(JBEbX7`TpDT>\BYl;7^q8Y7V35*N&Ke_Q?J%m-LsO50aMWL@hQ+U][:t9bkt; cd2;O31h/_4c&A`@OQnF2-S:rh%4V(kksbMr/-^ endstream endobj 64 0 obj [/Indexed/DeviceRGB 255 65 0 R] endobj 65 0 obj <>stream 8;X]O>EqN@%''O_@%e@?J;%+8(9e>X=MR6S?i^YgA3=].HDXF.R$lIL@"pJ+EP(%0 b]6ajmNZn*!='OQZeQ^Y*,=]?C.B+\Ulg9dhD*"iC[;*=3`oP1[!S^)?1)IZ4dup` E1r!/,*0[*9.aFIR2&b-C#soRZ7Dl%MLY\.?d>Mn 6%Q2oYfNRF$$+ON<+]RUJmC0InDZ4OTs0S!saG>GGKUlQ*Q?45:CI&4J'_2j$XKrcYp0n+Xl_nU*O( l[$6Nn+Z_Nq0]s7hs]`XX1nZ8&94a\~> endstream endobj 21 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.738 0.637 0.582 0.543 SCN 2.5 w 4 M 0 j 0 J [4 1]0 d /GS0 gs q 1 0 0 1 336.3848 287.5371 cm 0 0 m -18.885 18.885 l S Q /CS0 cs 0.738 0.637 0.582 0.543 scn q 1 0 0 1 316.7007 305.6226 cm 0 0 m -18.112 4.481 l -31.852 18.221 l -16.649 18.221 l -16.649 33.423 l -2.91 19.683 l 1.571 1.571 l h f Q q 1 0 0 1 329.9717 287.9531 cm 0 0 m 5.997 0 l 5.996 5.996 l 8.54 3.453 l 8.54 -2.543 l 2.543 -2.544 l h f Q endstream endobj 22 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.738 0.637 0.582 0.543 SCN 2.5 w 4 M 0 j 0 J [4 1]0 d /GS0 gs q 1 0 0 1 261.4995 287.5371 cm 0 0 m 18.885 18.885 l S Q /CS0 cs 0.738 0.637 0.582 0.543 scn q 1 0 0 1 279.5854 307.2212 cm 0 0 m 4.481 18.112 l 18.221 31.852 l 18.221 16.649 l 33.423 16.649 l 19.683 2.909 l 1.571 -1.571 l h f Q q 1 0 0 1 261.9155 293.9502 cm 0 0 m 0 -5.997 l 5.997 -5.996 l 3.453 -8.54 l -2.543 -8.54 l -2.543 -2.543 l h f Q endstream endobj 23 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 102 0 0 102.2399902 268 482.5402832 cm /Im0 Do Q endstream endobj 24 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.738 0.637 0.582 0.543 SCN 2.5 w 4 M 0 j 0 J [4 1]0 d /GS0 gs q 1 0 0 1 421.3848 376.5371 cm 0 0 m -18.885 18.885 l S Q /CS0 cs 0.738 0.637 0.582 0.543 scn q 1 0 0 1 401.7012 394.6226 cm 0 0 m -18.112 4.481 l -31.852 18.221 l -16.65 18.221 l -16.65 33.423 l -2.91 19.683 l 1.57 1.571 l h f Q q 1 0 0 1 414.9717 376.9531 cm 0 0 m 5.997 0 l 5.996 5.997 l 8.54 3.453 l 8.54 -2.543 l 2.544 -2.544 l h f Q endstream endobj 25 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.738 0.637 0.582 0.543 SCN 2.5 w 4 M 0 j 0 J [4 1]0 d /GS0 gs q 1 0 0 1 346.4995 376.5371 cm 0 0 m 18.885 18.885 l S Q /CS0 cs 0.738 0.637 0.582 0.543 scn q 1 0 0 1 364.5854 396.2212 cm 0 0 m 4.481 18.112 l 18.221 31.852 l 18.221 16.649 l 33.423 16.649 l 19.683 2.909 l 1.571 -1.571 l h f Q q 1 0 0 1 346.9155 382.9507 cm 0 0 m 0 -5.998 l 5.997 -5.997 l 3.453 -8.541 l -2.543 -8.541 l -2.543 -2.544 l h f Q endstream endobj 26 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 87.1199951 0 0 87.1199951 208.5 390.0313721 cm /Im0 Do Q endstream endobj 27 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 87.1199951 0 0 87.1199951 342.5 390.0313721 cm /Im0 Do Q endstream endobj 28 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 75.3600006 0 0 75.3600006 168.5 300.7913666 cm /Im0 Do Q endstream endobj 29 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 75.3600006 0 0 75.3600006 262.3686523 300.7913666 cm /Im0 Do Q endstream endobj 30 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 75.3600006 0 0 75.3600006 168.5 190.7913666 cm /Im0 Do Q endstream endobj 31 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 75.3600006 0 0 75.3600006 262.3686523 190.7913666 cm /Im0 Do Q endstream endobj 32 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 75.3600006 0 0 75.3600006 168.5 83.7913666 cm /Im0 Do Q endstream endobj 33 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.738 0.637 0.582 0.543 SCN 2.5 w 4 M 0 j 0 J [4 1]0 d /GS0 gs q 1 0 0 1 336.3848 174.5371 cm 0 0 m -18.885 18.885 l S Q /CS0 cs 0.738 0.637 0.582 0.543 scn q 1 0 0 1 316.7007 192.623 cm 0 0 m -18.112 4.48 l -31.852 18.22 l -16.649 18.22 l -16.649 33.422 l -2.91 19.683 l 1.571 1.57 l h f Q q 1 0 0 1 329.9717 174.9531 cm 0 0 m 5.997 0 l 5.996 5.996 l 8.54 3.453 l 8.54 -2.543 l 2.543 -2.544 l h f Q endstream endobj 34 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 75.3600006 0 0 75.3600006 262.3686523 83.7913666 cm /Im0 Do Q endstream endobj 35 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 cs 0.738 0.637 0.582 0.543 scn /GS0 gs 377.231 340.776 -4.816 4.816 re f endstream endobj 36 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 cs 0.738 0.637 0.582 0.543 scn /GS0 gs 387.008 340.776 -4.816 4.816 re f endstream endobj 37 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 cs 0.738 0.637 0.582 0.543 scn /GS0 gs 396.513 340.776 -4.816 4.816 re f endstream endobj 38 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 47.0399933 0 0 44.1600037 90.2841797 524.3868713 cm /Im0 Do Q endstream endobj 39 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 cs 0.738 0.637 0.582 0.543 scn /GS0 gs 292.231 285.776 -4.815 4.815 re f endstream endobj 40 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 cs 0.738 0.637 0.582 0.543 scn /GS0 gs 302.008 285.776 -4.816 4.815 re f endstream endobj 41 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 cs 0.738 0.637 0.582 0.543 scn /GS0 gs 311.514 285.776 -4.816 4.815 re f endstream endobj 42 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 cs 0.738 0.637 0.582 0.543 scn /GS0 gs 292.231 173.776 -4.815 4.815 re f endstream endobj 43 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 cs 0.738 0.637 0.582 0.543 scn /GS0 gs 302.008 173.776 -4.816 4.815 re f endstream endobj 44 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.738 0.637 0.582 0.543 SCN 2.5 w 4 M 0 j 0 J [4 1]0 d /GS0 gs q 1 0 0 1 261.4995 174.5371 cm 0 0 m 18.885 18.885 l S Q /CS0 cs 0.738 0.637 0.582 0.543 scn q 1 0 0 1 279.5854 194.2207 cm 0 0 m 4.481 18.112 l 18.221 31.852 l 18.221 16.649 l 33.423 16.649 l 19.683 2.91 l 1.571 -1.57 l h f Q q 1 0 0 1 261.9155 180.9502 cm 0 0 m 0 -5.997 l 5.997 -5.996 l 3.453 -8.54 l -2.543 -8.54 l -2.543 -2.543 l h f Q endstream endobj 45 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 cs 0.738 0.637 0.582 0.543 scn /GS0 gs 311.514 173.776 -4.816 4.815 re f endstream endobj 46 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 128.1600037 0 0 173.0399933 477.3105469 410.5786591 cm /Im0 Do Q endstream endobj 47 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 125.7599945 0 0 54.7200012 478.4755859 527.9645691 cm /Im0 Do Q endstream endobj 48 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 128.1600037 0 0 35.0399933 479.2675781 318.457077 cm /Im0 Do Q endstream endobj 49 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 125.7599945 0 0 30.9600067 480.4335937 321.6034698 cm /Im0 Do Q endstream endobj 50 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 128.1600037 0 0 35.0399933 479.2675781 357.1904755 cm /Im0 Do Q endstream endobj 51 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 125.7599945 0 0 30.9600067 480.4335937 360.3368683 cm /Im0 Do Q endstream endobj 52 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 128.1600037 0 0 35.0399933 479.2675781 279.5342255 cm /Im0 Do Q endstream endobj 53 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 125.7599945 0 0 30.9600067 480.4335937 282.6806183 cm /Im0 Do Q endstream endobj 54 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.101 0.888 1 0.015 SCN 1 w 4 M 0 j 0 J [6 4]0 d /GS0 gs q 1 0 0 1 441 528.3335 cm 0 0 m 0 -473.333 l S Q endstream endobj 55 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 54 0 0 52.0800018 84.5776367 313.3604279 cm /Im0 Do Q endstream endobj 56 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.101 0.888 1 0.015 SCN 2 w 4 M 0 j 0 J [6 4]0 d /GS0 gs q 1 0 0 1 441 583 cm 0 0 m 0 -39.392 l S Q endstream endobj 57 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 128.1600037 0 0 35.0399933 49.699707 417.495163 cm /Im0 Do Q endstream endobj 58 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 125.7599945 0 0 30.9600067 50.8657227 420.6415558 cm /Im0 Do Q endstream endobj 59 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.738 0.637 0.582 0.543 SCN 2 w 4 M 0 j 0 J []0 d /GS0 gs q 1 0 0 1 84 459.5 cm 0 0 m 56 0 l S Q endstream endobj 60 0 obj <>/ExtGState<>>>/Subtype/Form>>stream /CS0 CS 0.738 0.637 0.582 0.543 SCN 2 w 4 M 0 j 0 J []0 d /GS0 gs q 1 0 0 1 84 463.5 cm 0 0 m 56 0 l S Q endstream endobj 61 0 obj <>/ExtGState<>/ProcSet[/PDF/ImageC/ImageI]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 85.1999969 0 0 85.1999969 69.0297852 45.1300812 cm /Im0 Do Q endstream endobj 139 0 obj <> endobj 141 0 obj <>stream H nIP/mju-|fx wf[YU)]*ҕj^_|e^y }T^rwGigi4Ki [iҞQ KS^)*x_Z4si~MfJ5-UkYwi^CѺJc fk/-5Q\iFӥK&/ST4MYiȫ.-K<)MR^{i4Ki 4XiJ;m?J+y/0| /Ha>SOa>SOa>SOa>ƓOa>SOa<SOa>SOa<SOb<SOb>$SOa<Oa>Oa>$SOb>$SOb>Oa>$SOb<$ƓOa>$SOb<Ob<$ƓOa>$SOb<ƓOb<ƓOb<ƓOb<$ƓOa<$ƓOb<$SOb>$ƓOb<$ƓOa<$ƓOb<$ƓOb<$ƓOb<$ƓOb<$ƓOb<$ƓOb<$ƓИNb<$ƓOb<$Ɠ84Ob<$ƓObNb<$ƓNb<$ƓNb<k\ݛINqm'&1ƕ K\xu!,q]]k\եUIwMqM7$1ݝ%IwO|'1ݓIL\ BLMFxJhNXCH]GqKqoOq_w5"qWc7(,q{6,qc5CqKS74),s?5-CV",]5_W$ee_T'EU*IU.,5~ %DX/A 3PX?%֘xK6^$2m ojTxK/8 qDx'FAM[70N?8QZ,M4VKi 4Ki [iҠ'IKsV),MyY4MiҌu J>. Rh4^citK(M]4HiXݚKC͔k*4[4PEi8U/KT<-R]4Qki giOH"}W]KwI.IE*m%Y q endstream endobj 69 0 obj [/Indexed 14 0 R 1 143 0 R] endobj 142 0 obj <>/Filter/FlateDecode/Height 355/Intent/RelativeColorimetric/Length 16185/Name/X/Subtype/Image/Type/XObject/Width 355>>stream Hy<]g/څ}K>R!j!)C!)"EP,ɾ-;g9w/2OzfJ_O?f5oeܲ,;ֿ#50kA-C^g-JE~zjj }**D$C%BZJCKKGOx==-- &QW? VlYX98P(ŃBqp0312jzR%d^.O@Pl\< ZHPPYh*dp2@ m-z#VTLBJZFV_,8(v#Z ʂ8/`^?A|גKu&Y&^[gݪTWUQRn: ?/ʎ"R28E-ZںF&6vH_W[Sm&I1,p}fCImA^P^A VLRYE}>_G~}{]mh*g Qa/fÏLeCA^qi9e5vNn{|O:M$iÉӧN"x{v`cindİSJPp8U 7mc@5|0"2 *:mw:p艐3Q'$&̾_PPP0`%?/7nvfƍ q1v:0VW$EP׬YU&(Uz&m\z ;pJ9%*WUU׀\z\񨼴0n֭R !۽a6#]- 2"2\er/2_5m3k]{ MHJ~+;Gk7>knimkko>4?kR[aYɽ7Ӏ~/vxͲ@28WhM5I?stܥ¿=y`pp]m-͍uk Đ8XhAe4?7+=D^g'ZP`N^!*:y#.]Iu'ae֎}C#cS ӟ.NNN  ζg 쌴sgO<] 2X!^NPe W28:FP`2V?i/-~ۙ4۷o^OOёW/zsS}]Ң磈^nfZ*Ң~.L(Aa Y% ]KWO_s!9=36>oy?4<:>>|; unn,/){+r|txH7 #JA#+$"SZ:{GC߼w {H/JIɼUĆ<𲧳Y}MEYQnfzg[ C N+ȃb]Ȱ|hQi5c+'w#AaQ$ʺƖ;:>9 pɶ/ 4t~zjr|dhEwƺy@9>*,舏J@F9#&YCXhD\R\?4x߼]`/e4t`_OG {;-)."S]uŕLe4 ٹ;DSTFzM]EEgrAa{;[*ˊr2R/{ؚ-kSnwp9Up%y<>(7V/JO=j쑑S^X90r*x#KB#SnW?_] ARy}uyq͔!>Y+[j>S|hxbx#+/^WRQrp<_LR|TWQw+Bɣ>n<j))#$IIUXVw`xlc<96<46@ BdjˤȤ! O9nAv=ljӳ=nh~3~~]-UWLp$/"f#8%ٻ%]ST^T2ʯ r,2ɺˡ`Hén5up:&1Nڦ)RBeRrwk9143_dXaxQcQ6ٱ?4bZvg`B |(#U frD>gkcXȰ@Y95 vz p5^yMS{'wNyQ=$0sE^jŃ\$`M~Sq3t,XEHtse)dC*#RԐFRXH 5dK=KDȒ%[m~g|>Qd>k'wߋ7j: ~, A.ͻ{hEJ2m#3;7NLFEȂz:("Nn*x %ȿCnk.ɹ}#1;3CMEZ~έcOXI+qŏۺM}J_?*Jv鬻!gh54ѝcfݪmds1ֽʺY%]ϞVd$]v3YJd#hd[= Jȩ: _whlrh0!>o)ItWq33Ҕ,PI ;'soHLJvquc f0@O[}Uaf" 47 qՃ6[В`fQpd`x|zAE]+0X9 o7Z[3cRʟ M0Cl@ڇ9)Qz;UѣZX)% &]gw Ov{Wߎjg&#IAJ0~.8l_(c^vzNͯl)C  ʼ(caK%# ; `J[xsa񪫱2?5 06Ua[vͷK֮cV5#~10*RCG@WSU~jdTGYJuڥ kױr J)=Qƒ7ow{$q \cdI*2wLAJsa0K&k+Hp0.YA! elOJΫ\@D( 7V&Evʉs_tÂx&/")ت%'(0_kdi5M{%7r_u֗e%\j K˵ - M*| Owԕ޻}@ 1H7?c,&T;y]YZ?: cMgt4UYtCc'7s^\78V9a1׽5 %q ,&ٻ]Xz6%>ۉgiQ팵9(=yP &Dil>GJ_3<FA>̼n~|fM||WDF}o73U24~f|ESENg+`(c8u`U-6>~=<[%oz*q\1ϱS@RIa <$#WO.LBXLN#(n :ȵH_[̓Q$hnEtLg1?)1tDx>^>O8rp717+#Et1OXR^U3ĂC dUތ\= hC@Bov+$>k jrn +=̌ $hˋm@ #'*ZzS8uteh{mѝ7۽2"dK;~STR2$a*b%e&""2"SQ(Heri~7m{~X`~{ƃ&' &* ^AL1e*"+*@ȮpOXܵǵ͓׆o[ki>Mî]g%zQIkCbDE}e~zRdOKt S12ۮ 7rԵMn`sŅXH`STӷp >~NIMd ~h{x6شn]wC0*i{{#.7kcMem L'$9UyGHUu-&}L@5xZbrgV5^Ǡo]Ccrq^vOz$hϟPe_ l9TIlLK}^\ wk|Vq>2`b-Nm M,"a7<:uĄn}YsTuVژ01ZI'Q"uuQf@wmLXmWDͼzlcxmƅysjA/sKwK_4#`&fҠ\SH۸VGG/ isjc0A;z|\>1\ ?`c8섥kYC<H/6BnǑKm T Ia/"$16b1 _.6b12"ؘWPBqwWeC61dΖĈd-} fci362T())ҷp[fqİ1xbC9-ӛ%/.0fl?|f7?bAe;k#R|(;ضSgx<F6.˾r"x 91 TuDl`vT?x؍'=+HOs]aedHlM<[j3&h%Z*2Qvj\# x\AxPw~ogK}m .;PvF좒?+hO6<'Go'Ng=*^\ve7|AW_ T#@cVIAĂ?ђnU~nb{$L J<P/;6 %@{=i*0w /#Dܴm欒 ]&lHꢌ 018'װ`r[""7 1cO@Iql|5qbhw`LdR>#piBܣ U;r+pN-x`5Z2"|ߍN  j\|#9QUp+့L91~I1WHr\#M)w`_()[^ГeuE a5;>e%k`_ʲ/ 3(^^XG1BaXv/S#Øb2@Cd91$cQ~Q'TljX;"qAƳ1b^aicSs+ps(&2x4I'U5wڱ?&bEqCEnjl%Z¼ Y\hI589 o^W܌߿\WUVgA)tx˶ qwsMqfjニGбupq(=m/ݹLjm'4l;<<(r4>l5@2n;3agn㶣 +ɾ$nm3lF淰-1.晰Nf:*2"X@LA`+vh}Gc}yqوv 7F}}Zr51'@aJΓw8MhP]Qyf@x(pq,w:\HaĐ٤:l =}# *}E ŖՋR Qlk$b$)8a41r/-)aMK7 "0=Zs]Ufj'GU AnyD]o9ڸy Wn8 Rj+JD&/|bSls93% Qۑ + xN nWW4tbf"ʳ/dc4g*bgplZ.lT6 cpbZ^! ,Q̹u5>K pyȨ9nUP݄/{S-G-n|bJB`T]p+>l@'2gxyP~!tb~Qz}#2h˃{3"v}b}pb9z >1U1afRzKry8<>!tTD~b5Jj C/:!'.'*tڒ,'u7}!kY"Y ,`J$c,2D(2 -TT,5Xl},ٲgޟ=OOxsRPH<%&.qxƠ!PJ<@g(%q3xƁc%ΟNbR jBJV@|(1zQ89 J57ܶБ8wJ yI@b)$6p39J"z[s#XW],N#*?2)J*=@[鯢_tzխC,$iʉ8UOE4YUVO_O@Y]ev5?g ]ei', I2*z[]OET BYutCsG>Nf:JR"|Qb#I+Z8]ˮ| %fؗсPヲkVH NHٜ|"RJ:fN>^7cgcIFL$x~WXrӽ!YeM]_ƠDwyv/沅#$mpΏPb}o/zvnKqsPK,pf#7t@pQ!'6w!sp_zg x&?Yj$n9 ;).3w,$}_WC6Msȩo5(HT&Eo7TE`RL;ƄotShqQ]iSwڮo;%)$NAhbwß]ѳk-mGxQ^$BA]k ~ؤCwn-=|w9Oۍ1_o?A4JbCO8묔&qF1%W1avȎ~m; q9-)MSePٺi8 GAmk L eRLܽYk=A}]1hiʮ'] ꤐQ^g}AZqC{,ʮճU&sωPls(WҍԘx5W$DܹqBM'ɫi ژ.erBos9x?IZiK`mLD٥Dzk+J qO9y.]q?(rL<, &1`;z_ZT<`í{|z:Vt G s}˭:"F>cUi@8޲c1*bk:ƹWǨg8+6XCVLpɘ PROr"Ճ@x[d {IɯhAˍ "iO84"#$f=cTFǽH>cS EM:j52FT> l\}sQ_p"Vr' >Z  BZI̎>y PLj*1$ x8/j LGI51PVQמ#S s]/@>w7D[( |!qy=c2:*ƌE =tjf42޸iv)]~:~6 @1BN]E!߹ژ^>Bce݃bդt"=aLy6@}x7142 /7bŰj [ 0?@Ă1б׏#<`ڋaZcu- +f!\w7Wގ"vj1KuغzG!ݘ6+t2bUp9%5\nV]@sENrjX\VlXr:*H9N fה=C$Uy3ǿ LgP{ 8=aL!V(79쬇rO"c8y Z{.\Kmhポny:ZkKl[f3R69MJ+(od ALkv킗5ȯg2ݼUDFi~OpLzae{Ɯy0(mE)+Bx1+vٺe e<3q޽{f~nf|gƓb1+,y`iZ, 8h 9lX_  V.X#YcCq|F9',нkvjl0!loa!b)c(K cSc~j}YAz!lGy7 lB g9%,)19^Wz?-&^ !c{HLҺޑI X<N@ d}x@wਔ{jzo̔n.ɽys^5¬ |HnTt/CE9]mh^vIy 䜢0,豤ğaA^.zrKx+(F6 d֯,~>R03!ӑ%V01cYUG<φ&dFon~2C@cg<Mv`A3qnkG3ش_`HPwR~#:XV!&Ŀ: ㍛v(it r 20d /0$48d 3NDG}Nr$;w{w)) dCîgִv 2;C^`9֚O57BnmRc0,Devj;{Sr}AZ3d<>jcUqnJ49L_CAZdRS ,DtM&" ԢB^@B='Y$_1+598tl@@X]{,\΅ƦW5P{Ӡ20hqz/8/-6mDбam"$&i`fs?82)60:9؂ 3#Ԗd'E{13 4;2>K{7+qŕ=C-%d0舡Ը+An$ЭmJ|*i'!'f?n죏O;b+u6U?J'|3RŠo` /rȗcnݥնG&2+鉑~Zkm [+H$_P'!m;#_}آkwPiun޻C#D AT()AeC&PEP¡ԣJ&.,ͬs/ Af\}-xnrlDqnz)[a-eY1pZ# c,xEO8xF/m餏nd!wԖgވ p8a(%n[m$Xo|->xǜeu]16wxZW0@)k J !8H.2Xd]4 I{m2 QI/}Ezb2J&W iTZEB| :FMK _t41P+c\-$Uc\F$q =ʟCkoV'EsV [Y+V J+ٺzE&e0ʛ5ʟC^^Ia^fFҢ"+ Q\e`pxµ(r=k2˗]r5=)SWǵ{TTdc_XBolnөξy2/\ɱގڲBy37@ICsۢpv1=^IyeM&r$@.,mU^ZP1exbiSuɃĈ0"cn^ ,*B")wT7=~99 +32o2 |W xR_Y|?f<94 ,F{mF1n'ȁJuuz#SOļwv0(5SbIW\|{و'yRlmÒ֎^ 86Y{. p8Z'CY}<*zrZ2Hhtbjv_`g4-݅u~}j~vjbtu+1˾^8{K!R!'p"K!ԑK{׳nҪ^h 3 AW \Օ%ީ!zog{sCNKN %|lcnQSP6D$dtP&XkG7opxD܍5- 鹅%gǁރ? \ݥŅQoKcMB{#|-9P`SAEqa~p!PXܼ2*zN: '&ʤT6WK+ By)Wkخ,/ZM%E[7h5A#Ąv0d!1)yca|FOI-5m]ã/ ^WVA^o f]\x9:QyUm㓶ݽ}ãcS33 s~@/GG}۞4V?SRbŠ ՘eC@E%e!e47 ʝ h*<}E}`pphhdyhhppƺʲGrPRc'^ pu6: 23bC )em}ãfN8ONKLΦ=WV7=inik{Ӷ'M u5({\L{G;MIM"^OQC}m5+) fb*( T5ǰ'pxDRdl|r*%#3WqIiYyEEE%GҒEsegfPRc#Iī>xI1!*le i*jZz(4h0")":.!)&%NfVN{ Tf99Yw2n&'p=7bX%oO7^4JOKM倜4ӗ_deA1Iyy ?BPHhw$rdtLll\I&5;}태>s=hogceanfzePSQR rsR}0<`4E%e e6>1=nŚ,)1ePUAr}L<>h9 }PMCS[G(Sw-Ptl^(Μ *]t_y=r>\n""};w_똝sI|S@]ZGҺ@,Y(t/6!3 3:iR|$$&H0+Ѕ@X;gҖ:#dv;έ4R/=yeZ]Kk=ZF ro2IoЧo endstream endobj 14 0 obj [/ICCBased 144 0 R] endobj 143 0 obj <>stream YGH endstream endobj 144 0 obj <>stream HuTKtKKJI,t(݋4K%ҹH4J#Ғ(H wqyy~3̙g<3Y9El @ ]!O-@\+BVKK :OX~WCaiHKL0qY `5ck X]x= 8 XĿ׽>.f#aPn D^{y8  dp H st:Y׬cxc IV?S!:_9[YbQP~+rA ShHht^ '0߅™kYXY9Yqqpl'WzEE$%D>,^|t*K)%/`\ҫ:&D [7dplDa5|mb4,yy{e5 3⚅,t+whlA   m k xYUH&%Ȥ qO'Mz3KT@v[NUnn^\o]abTrtlmE]e~U+jאZ:zaqi5};CS[\_ۆwCaQ1;>L$Lz}4:%8M7l̎Χ/}XT^]X>\Ym[n!ycskkƶʷ;v{pIs0Xݯ3s󝋒&$WWW*)!$$%!e$cHNOAKIMEq ƕ;KLw@YX;ؚ8^+DspfKOTCPpJ%D=++O%$*8IZ\Z^UK_wL"dx]}>9=;s_G8/̹N!Gz[<=2|B}PQzlH0Wc(Een|Pds::5&89yFT"od䳔i/ZK^&gd:fgQl kJХeJ*+篍kj5U[ZUh0|em6]B@`PpH?QM1Msψ*iϛ.Z [JYZ)X-]R޸Ѻپw?@?5 ǖ'vNg W3gLC#u!MMMEvAms˔FVNA̝GLwA̬,llؿsݛnͽ+!B²" 'R&k?3?4+:6oT\ұڿ6VʝoF?LT;:>::>:;eqvx^sawݥʕ'_EFO\DKLtAnFF)F|ԭ6\`@z?m+F;LwiAhy͖)Mgw~_ @ZH_XA,"F)%/*9aZ:Q,\B^_AU񡒀2 *'[j o5[uR1uh`fm$1xJgBdrltlyyEe$feg-g#`dGbwj0TOC9; ܨݿxz6zx8IP=A!.aAxۑϊ}bG-ޒēx`G/Ԝq_O?0"۬խЮ˯ǰı²µŶȷ͸ӹۺ 0@RfzƏǦȾ *GcЀџҿ'LsٛFsM6+1MZ:{T?~ò~i~L}~cbA~Dad~ty~W~O>~\/~|~`Cx}%H}1X}%z}K} {N}׋<_~7A~-ψ||Dz|+E|[s|z} ^}wO@}-~ċ {Gu{Dz{]Ĭ{f{Zx|[]|ϕM?}R<}Ǝz]YzHħz|z={LNw{\|=>|v|ېI8z/r z;bz'sMzd6zɬqv{D[{0> |;|yyaIy?yazYvzݮ[{^=c{ФI{R*y߄yfUy`VyyuKzZi{ <{z%zȎ~+~}͇}W0}3}HtЄ}Zk}=~zɇ}!~Єd*s}Y<9wpSwuuVrUW؈|;,뇔{RsѲ;:8q)PCV:4.8Ȅ2񡂡?Up Vu9S c bփR.ՁNn U388A/ͬδz6߆өn1T\e7݀tXT)$̯̕6;eCʷˆ imw3SƀV7M \lGNػځNāa5tNzlߴS<H6*-N}o2ن N%է>w֣A}⇤\fXMݘ2, KԐ3g°[} 0e6M _1 ? 1ӣǾI^I|B̯dܪwLe1$: rW] 1S{z|diL g0\ U{[G{!{ ޔ`{&yE{xbie{Jr|/c5}~ ~:f#MKx+Ca|uI~.yW ώәߎ%¡唘[w!^T`^H*- 5GȨ瘎=Π4rv_ҍRGf,ދ̋|,ƕ{ Ҙtٕ^1Fő,;',#h%T,Qۥ{[s:9󅼓&^!Փa@!" y .Jl6mHju,bU6+s hܸd-ʥ}wi-sun=0Ľi-_*)U_ˈb$na+;ϧT;ppA7C4.*Iߥa8Mm.ACi7\j|fiԫ)]ޭjʄU]3(í whJch-4x7h׿*P0H됎L랇ڡuÂ,{Bz}8vggҲd[!XTZZ.vlAg {;Sm`vؿ`~?ga. 3Ì{L^WYe4]L7ok!wI~Ira^=C#Zh`Wu}p)"z7ff&3$FJ8Ҷ5m uR_,^VS&aR~PfLL_Dw*`\-9]q  TI6)>u6 D`e͢/xqY%9ʜ;åOd\˾P&eRz;].R<oΡ]P{?: r̨\ʻb Ҥ3|m s؟W9oZt]RnÅ\cW#+nI&gyAjsN06HiD'@J+a5V~cRI̫vwtUc[3+?F|l(iU^+O?Rs1Hqil$Wþh=(RE 1BvџnF/ BsGMY9>ܖ3ȗqI ڣ5V_1ȣβiJiX0WVH[8g_/ n3 ` 38A.|f|ј0I6bv%& ;Y㿜҄#dD.).p'3J12K[Duɥ$s8IƊ.z^48e!R6}vcMiozo0'=~i,3:?-?oS,9w#ROa; ?pB ֞IO ݟe#}ԯN$\l?], y,>&Рq]yh0AqK)ĝBFҍcH:-h-ǟcf)K9T127]qEjL<>h;|U dpG ƫ`&!8al`83>.qɂnA9 ; `HByg KB*k㰗2fF=#OM eT? mTm_OBۊV<ɆF('n3uG~Ȯ#7Њ9[١`Ns.P..콤 'KnpF\? B>-`NWOOWBlfxW^b-_x&*/(j_=߆󑊢zF`LdE:SNʔ@S 03|TOKokto}bFz$4-,.m'j*J|)J6BP ^3ewܫpX.*,07xPڳ:2XOT21|"7=0ߴy}ĸB)H[Fs V+̯+Y(I(x&9JAI'tXmyG=X[8TK)2<TSRvxlȓGO|g/{>4/gRFȶ&A52 uЯ*B幃AuFǞѧuD)B,*?n` 'qQIzK֗4{B_g68#ʉ2.A$69!̒ub1&D3Qx" >ɏnνxVG&TۨÓ)sxd-5KxߣD&1±jdGjJ|J{Z ޲f6/vTp̄ub PmBU#gBg˷)-*E ar>>Ƶrn[ɭF-IByѸP=ĶKUC wG D}"vN.p]]Q8uY{#qCv}sax_oyiNr( d8aw2CQ}V8UWO\g \yk@dcZt9$u p-1z(=f) vě92 w u煼ת#{P6+Dq3HIi%BCb!kc5&U ):X$܎[b2*@PkcӘdoTB_L1Uwi")=2#pI9,RO>T@>;bnDPuCfk^^\G~ oLRcHqܮ=-8^5Ońy*9:-\g8:T<?*C;[yX+I;lRL߭$DvYTQ6DyVmfy%/sIsmXP1Lռȭvow)QBb_LVwupeėO*|+](uHװ4WU.{ 4\m.QwR~MAiRz+%BKz?'{ k҉aa{H]sX}da~3_auQz VM\ĵv5I0LM)DŽp1:5,&4 %!$}ocޤA]R^xT◬M&/B:DwA24?cd&g]5b4a?iǐ Ĉ.OA 6vfvsd(5yTH/P=(a;zUs bWxDa)Eʼ $sgPJreY3w`cFo0|U[j5k.5J&eTor È´}I lpjC8c5J=g%Uo|L58E" ِ[Ak]J͆VBM"{NrQihЦ@Y?6^߫ZWٯ]ذc؋hKSLj:>O ɲ.ݰQ{5mm<ٷ?^v"}ъw9O&vX7km[ ,70nΒ7|eP\I;-wgFN cIP#qWI ;NٶA)H~7i thl~~dzY Cx2>*c&mb{9f1X*L #> V@g蒼]7n249=MK% ;,F\j 1klZi؊ΐ.|Q9а$_.!;̿lE,ɥDi}D3^a`Y5g{J=mɳy3CM'jM-iЦm n5? SJE+U~ ;q.tXd~~p*QeS%.Ћ"ưBsZ6-6[\d;^z4`;64藸ͱw;|+&AfLU3XTm)lF'l VɺgcGObbɜ9;v \CL, >B?KGCe"z -@EHILp<5'҉$>8#gL2m c1 c Fw)P+rkC qp/u8#!*g°Pa`vu@oH`"Ž:z_Q<,D>'ӅWP .`xW3|!6 5 El[",0 e[Oz0~lUO+&xkPc|u$k.?{Qp""kr6isVa=~@W_ .<7 2#h?c~m'rE_xs6aG+K 14L^kUp^^_mS^dШ'>}5$:τ!E[bJx&n t(m;ZsF5uqX.ՂBqKP *l%{ٓ{'f';,TT,bhUq2Z3;}T9vwRR;GD K*/@hUv$j!@ vyבm,W|-͢ ^ ~D_􆭍"ĉ#c禘*X/Ϝe>|XH;:)d9gƖ4aBQ4Ew,C ۯBU#>SV$L-5gV ϯ*B#} npþtdU$Db&$^\^&Z"/˺+-}%Z:}9AYu rTlP0"~! ͚*@5K?߫Z-P=j>܈[O?)a5 ?WUsy5^(ge${Cm> "Gգ+$踿ϫ& Xw8?g,'ō="/xNM)'EFqrf CįQ9ZY$r!6m)4 V9kJ$# FьX٥Cp[ģ)CS;rFP#ImKGɺzj>>X9,ZL-jIbkȉ8˚?vtxPIO}_ay@:|Ve6ubd/e3<֭ztea'cLaM lz&,f^_!?l2x2Xyń3D)\?ye ~4O+9$  EVDTSؓ7X?MM!ԼuOtP Cbt;iްa@gW#@4c9.Do z2>M5i~u0 qswQ9ǸLt삟Mz)>kɝI;io"U)]$YL >$$T:gUo$UK,C`sCMAJMÄKC(g]ٮ9sUG0?L5QM%0Ol5&`Ƒ1,x'{k+mY}-Js#\d:i/NK\8HstQ#-ND).s*Zymnf\1l{(E=VGW9s:?wǟQZsC6A1ƃ6K@8OUY^`7j6@9?,yt4&}"T- \Y&kVx녣391ٵqQ=beMq\`/nņ|2͌JkzDmͫIR4\~5NlօKɁZ]TC3l̅D3jSS)tWw$IX[wV WTUw^PeUhWE^ؓ~Wchs sIg`wgs (5mr] B`7JfAaA3ƓG?{O[ ?xj/Z*7exXz Ά})C?`KcMՌ&)Y5J]q':]$؞]Yv x(ıH1eU>_0b?*񸨎b¤،D;Wxm]|N7U13*;.=>SÜj)CM>.eI1/QvН6Tkk+Ɯn\\FFV#Xde&~WE7"bju^I@j@bQ Wk8w_D ^z xZKA _`T}] x}ЁM0S,rV+ KO&ƈ`;E{irf0F] w86f fm_8c3V<)r1p +hs|p!QP'Ղʛ2rӤej4Y r, r?4! Uq]f(*&umM+;1 -c8CjL=L1TDJ7>)BH*cHY}~xI,{7WjWާʇhg_YovMKiN> QRǧ}AQj^G syJG"?txt,L>֍p_>Po$^<%}KDS4 *S<ܖyd;éIJ~JMn>ȸcI6uɖژ䩊i77_5W2' 9t^}/8%wd0k)ͦF9kih3ShPBULzs'0$Y/L3ol|f ɪ\AW#siS-O^I+36xas @M A hm45V-' ѵ1S+ ~*%~k˝ʉl * lك=3_2~OgPs Ccd[aے{<ХjA {! ߲ۓ;O'9+wEHE&JV?fiӺ j05瀶bhWZxo=ƺ 0zhK5mov (YOut;e=R*yMVn,$v:QڳE.yVl;svn,Wi.[@34SD_!MF>J柣ND @$Y~-CMu (+lBpБ^#$~2è /@̣6 3nh ;۪.3Fq3\َvZnZ"/vNFNJ2V{#ΚVse_쑮Ta8C¢!Η>FL\M{5eH~7;F AB?VY=۩Q i9J.sӿc%FVbdեiL`a)kD=W \ne>NX7Ƒ†2IYf-to7/~Uas[`W*v3_`~:kjR("E * e)DDIss,f_n6":hmh+]AqñQqSa9{~8|~bh6GZĠםN\h+(E30~kTMGβ1:zka'LG2>,gt X&@?e% =@Ihs)HUOeX^m7R7~,, \jJԌfͬ8!*]JR:WR]Mɚ PZ;JN.8ɦ,[r*Α]MM"waX)Lbjd`>:?|:?u>^G$fa. ʥ_S%ED8 J=ĕK{6r zGG Ui<Kg"^ q I6vPWy^,uc/5@:ǹ+[N+li{P#^yv,ñ-NѳH⺣<֡gxV</nb6󴳜Ρ +nhB˾PoT(W##ĉTwZU} w-vT-9O᭺HIz) z9R'dI5aZGS˟agW=.P1ٜ y?2X)r4VaGXBe`9Q1͚@85$W?D}z2* pt +;Br\ܕ'> -vCNeʔL-ʌqKHr 7I d<BgNelB^փRγF2AqCR&t7߄{" D9u)Cw1t}?"'[7o̩~1{>Ru* ʖdClutqf2[l~{S4>J$.nQnlP#x])By`r+wLH?VD:|iUG~ժ+&+Rb gP>}WԹkQǖ]WSkqwZ DQdVd24KGMvU35KJ~4&jwJ*y;X߉˔O@5hw)񘴕o-9E:_̂o&6#V(ѽS-te$ פp}4%4mrnzhe4KX*KÃ29ʩ~'Ǥl|O5ÍB ;^j㛑Q`exH;J\*`l˴Khk &tF|(8VǡܷR:ϳoG*UjSKknRgl ޅ-6&Nŗ7O4rGmO[du_TvY{ ̏Iy\aRKy&P7ݪJ)l"W5{K S_j0WSW;wixF1^lО伴^'1b%OAXhq)L7j}=9PX=n`ɗKX#CùA *7{ jWܴTByufכd=Af]F=_u*`q+_i݋\^`BaE|S&%Z a8+QgQ[IK-jIKr2Tcju=A ʧQ"7{ٮם*X|,Yzѽ}ƈf:jCo[>]x^hlhNrϳEDkcCǪ ת9c Ht<)}z!hE~DBӳ2S͆i{;ouIp??砃46ٺ^"1R<-65sjpCSjqi6dzھİ紈 41.$5EG9:=ob쾄 v#[xﯦAF+T(C@RQF772I$^a$Eq>.AEbiO0]ТK5ΫPÛG ZdJ*$d ^}E*֤>?Ƅ$dO _tl%$^7[KSECqz"$]*B]}W zT[Rk"n]EUYvFUW\B6-RB^Me2B4/wͺh4Ek5˖<1U[tD>Q!.kR涧7uJc>c l/i^3;iڐ0sĀZnS qW7Np:([568ViAFޜ~h9Pldüj2dO +61--1Ewv =JCHW34܏&x8,&#Rc3Dvz6RSyu_N/nmكvT֥Y˼?RFװKzn9Q4gC^5l`P\ܲG&ޫ` 9PҞٲXr6 V4,{a؄\tcY`]lǿԾar鴯؏=b!&Yb ^[\aYt$w [R)i[{$7f"o Xp zBz'hO|Ō4ǐ|-j :}̴a%Tv5Y9QK d0 ?$ćH|#uD3 phrd@,@XmVKY@ou([8#!OM~.7SoJn%OG" Ü3N|/'O-R_1Vh&׺ NPz8de 勊ZTH;XQ6}+'h_|ȋCcuHjBA,NOS{3 L`]1> A rxӴ*E^.ؐ`Q5 v{`=W6뼟\9avGOXc& v1w~0W:ʎ~f: 0/˵%m KRKAcR% P#CSߥfmD5oEx17B0<&Yd8"1wܡ5 TaaJ3p57A>+yIMcu Zd?Bk1x-rsV9sH6p]DGgO| y5S$aE`$Ls [Ym ~u8p`6*I ߕ`S88sn9O3nXOE /7f^lbN[PBFO.9Z_.5>F S̉R'}ΪѬ`_dX|{dHXԾ3QlZe7PRqشO5OkZrx5u`aǂ:*`T), DPQʮdߓJRk=H+ *#u)h) )B6s9߹瞏HZGzGT"93hDͺ sr|b4y $TK "$I~$v(B#].qi?CN ~ޱ|ܷLcOnT~vxj̦5<.f\K<2p:CpSy,66>|zC E T)f/:X1}J+>_~Q;^ㆪvs&۸>.k7yZS:˩㜍rݖۜaKa!l.g57Kv0!;ڗfe %]"XT J3aժlwVj=v姠αe=bI/gH& :g,(y 27>aba88fVVqɌT0NɉB`( _"fo! t}Wg_0}HX 9,Qx=~Jٹx>ӱe9M2mFS)Vk-eZFF٥btg0O?Dǐ%7eyښ6WSCyeUS}l`a8i g"1лJ"|PKڝc,$+&PvꖴGBoj_t4I vqf熚(eC!b׼^SbYi1¨;2W`/7uh?4 !z@#(T 6 ^!R S#>E/Sq9z_ /G%ӈ0C9[ۼ@(٩P ,}XTOkpQȫUG6 x2e,> -?ϭQެYz/T5FL^`tީ3\#̬D:,vw[mDW)TBZ`0Ֆ`3tBQ˟kks41y `\޸cV#z`XHhwA0چFTyqӵܫ*F˪%*/>9 gS'"b'zL=N)cs*bR)W<#S 癛)K &L\9WtW!Y17i*%wJ_ 閥nWJ!p-0T`:K6B+SzlL,~J#ZLHBEe߈Eq1 ڸTD}bB;*OTCnՍl$OYQ0mz7o9NŻ|hDV[Ve֩b7YZÖHl~I)ܻJ5oOݑ%(,hZGҼmRd!/NEWutV57z;jjs^^lDǾ0-a_aL؁w44簍b^ppi&nX uƻ-݂ -cY4_g ?jGIfH %J҂[%ϩC6OzvWzoZtA$?z;ؼFT2/+0@@S<@>0bSuqw;j4S'/4sEթ(P[V^5ƊHkg/ۄw 0*֭ ajyB5TC J(_F4!m, RN ?S9 :״OfOV"յڇ1,V)S@._ #Q`K|ͨ%cj/&\: [Ft^Z"q٤Jm뙊jMarח`VCg w"~>< 8i}XT8dzQVY<p%HG/Û`rq;Nm~Ms\/Zh:(MXа^F.꜋.Ys}5`a((X0T+JS 4&~|iB!! !)$)ʰ WFY]E븎3x,˽}|dc |i-0Ws Q_GpRjy0׿tjT̎ԍD1څڍ›N:ka? 7ek_%]a;זF=9-b= &Mm0-vD'^j+/5(er^+EL F1$1KWE|fOFMKm::1`ڥfXЩM*i9 l?+Lw?-Nx͈wɳ\C0瑃f sM;iđ`$O0z*RٹB9@"k5v~.lB?ug]ed8JAj͹um.DO^^v:y;ske+,L¶vŝҼخd_5Z;q#k> MU\J{l*͟ґ3Doy"UDcu#H)BPit/ v`_Sʝ{e5mpPpy=-2[m+v6*.WۿSǔ] ^DMk,2.#ɲ\!{^I4Ԉ.~çlDcBU\b"c jvJG|H`_2rHѥ tHHBaG :Bf{'9 [jaЧe &hz6Fdy?>gۑx&l$^:^nx-'-]O 5@S Uڏy]Tu _,zWPT|BJ,ɕ}`8ߴy?p7gˢu\JO(_vOUue4+Qbi?A.jCxyRJ駥Pt㸲rTfdd$ֺFR>PaL'v2M*׵T]`W*cD*hAe#"ɆKO9JKL2J( KgK3jԉfZnL5oM(_>FOӹGi}<@w#Ndhoo4Y ̾Fٸ2YAz$W֜5Copli\ 32l;a<;S?B>zprjsm1tZc̥{s/J{c*#3ހfϡneh->Bc9SJ"չO8'8ހ `yHϤu-*` x[c')Oy\x!QS9q*;$;d'=NY ,|ܶ34qT=ka%hs䬺UX7Fl[ o1apuxf9QGk4;e ˸7荇5xB:yZdͫ,`2?_a[0~9iY Fs3g Ë9u<,yx87 1Ja,O@/gO㔛94 |.]16'^@1'p:XtwL,jVQv@wl{έ̱\?R^UV\GI+9D03oyd[R<""" .2}"!<4tH~(-r25DH@l"K濣,/S}"+~wF}V dRz,:w&?C~FqJ}JݢJirjzEgU#p]ZF%+[PjewVjlW7wR/*C%%jGx @EFH)&0_Օ|Xu DRNXA\0JSH307͛73 CWc+U#r# aQOL4Eљ?s~{sIy?y>ҒLָKd-ޣJ1v*fH 6hz+~BO:IQqZUՍP[UD#BM >$ z|?^!J0W8N WzXfщ@'h< %sdR۔e[$z,Z2H5[&Ht L UO 췯+52j&P6uRɮ! a+rk!o4 `ܗP)f%VQTF(Z]s,TR|O)O?ho# ]6yл)OU,F٠E})gsٴGyҘp/kw~˖I'Y;TdgYU'I8@F* 8 $I+A2((+y8OϋWȗE {բbW"@}@C׌teYgvֈHofE`eagbN_4!/e%O;mhtWv6[iyFy4ʔat V] au #QYm3rM/q{~tjD 7fiɷ  . =[n`4qShBrx_5wԐ %nQ~x'G[ `+qb]Q2Ըi=UGn~ڋJ(Aݪd E7Kz +M]!} jnh-Cզ_魺a٭Dfrj6$-4nUZF)Zpux'@]U/ٳۿ3Ug`iU}ڰULWu+SU[;uXJPvOŀ{$KF,qQruH.}imfZh~atMBb0*iWC䶧jZmn[nKfi c+.&oV.&ʭ{5_s9dmIA. *s5: 1Ů m!|fl'6#N Z>\oMkCZ8)*bEE@(27{I" $!0a=+vUZŁ`-xEJUǺ ~~7TSsV6i1=2J眆Jh@ Uu;7!0 ߽\醮%-;=.e/T7D$v{.ʫ|ZѮmcDֲ+-Cu_{>1H1]"D^nR ٺ:E3[h9 7TJOW+3 vœLimc @6'[c`Ǧ8v!bR{1_ӵuoPE2\@;4"mO m{ ߺE1dA}C=WB}[3']\PJG5VmnYG Xyahd'J[U~ vWۅWo]WnGnR9H7ѨAu 1vZm]lUrTVA sj6lhm,My4A*0vJR? Ĵ>2C!*#q0MJ!:ŏCR|dFa?2݂ch3dBzSIt?%LmF[AxYGҏ0m;GY1űh%[sጒ@9 q_8G>r Wn)jodEzC.qJviN&If8bg v|sd%:uTf&L0~p.(RU ; _)w%$/ t# ~#u`u[w.qsY_-*'̳ɩk/)2* i9$7fUzflc9}],툏WYCIkS-ty7>T! 26Kݲ m&cӣh' ..+upC6&@j5tdP0=I˂Ė C{޶$tR:(ϭuOR4$=jluq1?פ9Si|cqF!_z^SK}`d%DT wV>;<'V=(5H%jWMV#9YD2֓p~~J }D]gNSsjJmn->,vg&SLl#>^i8ʞ%4'RJDhRN0hBA0(r0K+aMY|"EGE_R^v4/?m[˨yN`K/5[71[Gؒ' '铯RGhqꭁ]>iIX 5'\GB ćd^ux+[^%e ֪pxE  6%!Itި@Ҿ#% :*h$r7שׁ55׈Ց'I+6*ЮwȰ%U#zD+Jt BaUؕ 6}uOr7dP Cu}FEua7RV"KST20 EN{^lkƕ$vW(,F7b ˢÞOy<"_).kh[n 9W?gڈ7yș*ӼuA@ OpIRrP($e[iVYR n#(aFq&mq3%\g?%ӆM5XD3b$ʁW ƿ5&͔D4®KcᏊ . 1Zo ^`~¿`6z q aXǰ)Ӽ܄'84 n"Db.yC<K d},{*h ڸh>wMv^ c8Iƻ(~j? eoyl/Dl5Żרpy1ܣܵ^004{ .%CA22dWuQ>okL<5.ſȠiffh7S-|^TjX[wCY*sG^1Ve֗+˃L3 /2y{+.;CtJ } ->٫y6q< WxA_PZ? Q y1>yK\.!OqM 0Cl];Sk)=RZ@[ɷ5JBeǐ$Ni"0 -úR4H~9.☫|Dϸah-)r~"eoMK%4 _7"‘e QD~0T.>"x*O>酧.Ey+HVy55RWsEk*PxEGB;(J X(8hiqmh^ 0`}_APWDLZ‹]<4zG֦`oyZR|u^gCF#nr)Va5ƪw9njyIt xI1bIy>}-AگOShKFx6xqqQ 3SU\ka椚̩Di~ ?{>J3mtߐZt]YNju]ɒQYlZZsNѴѷW>Sݥ0Bj+7q҄fU7m :8^;#eտ+*,_CY3MSU*LX.jQȖg_IWJ5a"9R'C\y׳qH)VU-Z.\+Ѥ/aen/|F[?SPkr" ^Y>VH9 &yaIxQfd}+] U.o.=q-y][viRgk*`/pLBu+A@[)&PYQ?im/K,Y*gu(i2`؀V"fJSs=RU@7+>dْsmY)w=U?ο3D qjv83׽} 1r@vy:{Eͩԡ.޸,珈~CH{ksv_l毁@"lOR."0Fl]]C˧Mfi nq˶Q{56ef e l[IuY_(i&;to 5kZ/ jjp~Ch⨿䦿iRs!G-֠5 &wa7WAƫXUr8+}E)oVӃIÌ}qZlh<gw A?=$6-ޡ|,)!<*ǘ*z!8߀ϸuPpD|Ŝe=sm4'ҢؽYaPOZ(vj?VGgxI=V-̹uMCJH_-C]B~2A\8*E8PTΔTo 9/whaߣby\'F,Ռo%wU/ժnM*T Ƌ{5NJԢT9L;y _fXD\uַA:x")V%V/*]1# )ԋ@X"SVӅ4u.f?Uչk%Nj;c~?]Pۺ˄WҌ=V듍1 E ֻqd{q׉; NYHdfttc #&vPtQjd1o ­R)ʽ@}<7 &8wyybH04͂@>o` ~M`Oi#T2"-!NSn\ z$SC%Q%;OzcT)!M.wf.Po1U=Bl1F#F0HD\u̞rڜ*ujQO5u8E$7:"І(UuANgulWYE*Z"cT\kTxlx)$8(YBIY`[}.Bb T$=U8Oŧ yP-x$]0_ j(sOH|/=wKR` ptl>f*ӡuU<=Ts(&zpKA?sLo`N0Mq+~*m-~F7^5惬H]${|-Ҷ9Y&=X'Vu+^ϖEm Y/0X cAdPc_X VRx6b|C6^FeC]o-F?f7Q3V>͝yFsy]ݯMF͊k^NնI#FZ.7ƆQfeϫCJn;AjB JFw mԗ6t(I5beElXQ͌ i,)6QS 1zJezVBf ۹ʹ/ HQ89SnE%o-4NJ``,)~utyQN]vحp+e"xN6y*,7$'x\CQL[8.d@}CɏE)1D?@晹b$?7 YM N| _Td'wa}0Z<9|3閗3~o=Y>l0Wb=P1jmE XR[louv:.C=;.a.BřS[nWJ3ǟN1='\Xr8۲:KXj6e g΀ap%z"K1.c1ɇzɭGTRiVBe-)K@iͬ!u@_`&2q up%P SЧ|NWP !o-t_ nyV|ؤ賐e`HʏE=>\Tǀ|cҎkIST!%Gu,%[IR'+#T}m3\/df)`n2#\M(CQd6flqGv첵).Z&wITe{JQܕQE\m`p`Ҵ\z[v7OVo9ݜQ}$SSFMWdnyuя: *o[3 O FRJ0ոl+L+&oE+d- @?^fEkoo\fyJ8zΰXmi  -Nw}OYpz&@>gݪHc. ]7Mz#fe"g\a@\qyºJc\3ܔ r'WQVE D|PLs\h_h#9Z-TdL>˼!WS/bniA3.1Fx@Ǡ3UNN^nPOZdtvWO&-8ךshveSȉ`wPU_cař=շ}m`<<$+UV66do88{ηzkG}ڻ<<7\jvg!5M!w&GmpfSgO3x? wZsLRq/~lK]QV:om<Q' R]AMXyu ^ȩ $}! 9LHaH8hʡrTtD-*fY]]wuu[bgg޼ߛ"ȹ I7HR7HBHudt *Ჲ=eJtj| #TI/W?{ΝO^'`v'$^E=7ITF2˵7-^'Z"[x ;[U7,QyWrr9E6cy'I gIRm2ZQ {0K,^H/>>G@l`T=FZnZH ѳ$m¯鵩KA3D;w7ŏw^J<`i$M_x8wU-,/h!pbP1|*k _U;N45jX_:]$ %ͫX+é Miwzz{7`fOE5FohX}fL}k%Jq_b_A54WK'h?:lTHmm. m&"X7rV7l̨b]r+ OpK[{0EuwrfӵFajCCPktMݻVw[FR(Y-VE8 P?)p>͛5 #TtF%3 qhk ;`LVOpZۓ. j&\Cʡ <*g!r)J;ȁ&xK0N\B&Գ$bԍ7fpt(0H23ӲG1d?ź bVֆ|\[w+tjj?b7hwJCmm#b.^VBDRb8E]4J 7LGc.Xd/a&ڎ @顢zQuֈ4Tqi˽èb˕ 43~,ymoθ[0 l} TCuLBt 2ZW>Eh@+[Řy0= sU"r];û](̏{e E=ma^2'FKv~.Оm0Oj(esߺ Pk*!3IBЦs4{^|{6k\* }XYǠD=A %$hǹWǂORV UBꯪr+Ca6 Kԣe :Zڿu6&?W&k).]%],lb7MX][H"}WL)RIrfr?AƁY&I~_IB${XlZXE&|w#؆`_vߢfu3fm89?9 ̟NՎ`jz1*.@爎܋`oْJ_+-4α6@/DWEjE}HRDl;Y+ z/1Dѓ(z)oι&;.4aZ#gsbZ+XWi;<~n"( M'b6!G lP<^\nM8--aG+dyXP^s:0q \p3bWu.,R&rm#қs)lej(^ ,=/FV6fj;ex%Dk%!FW@ao2QTvs 5h0B{UHiGCOzL'pbIq+'_1Lv QA%$[H~}{1fKٲ:HmWS ëd}2w7 j< O7i2G;SWݒ!@YsZ~*PƐ6xQܡ/9i7cGHVf3R>K2jZxH"Z")vHD} @} YJ64T(P_(*C]miSJqOZgA(ny8}wν37;?߇*x"D6HaeZ 5K e tE=H\ƒW8 72ym]Ly 1N<8͍@:> >6pӹ$.7$C$pA)hJewT*FmKg-lm*{{v\ܲsJa>3_*ݑہ>V5|WG_>RR_YL!RFjz S5fځO2< `}I\:XiZkRH*4[(xX$u|I9̺TkVzl_׼gC%*wXR nY)N.9+wZ[E9ľWJ%wp`Nj[.b|JOsdW,R~#* ĽyFdwCp*L(8OelL˞)A vfFʹ.Knd~A򥾺]Di(i]YʯJߟ?>w[侾7KK6w"!eDp5V* 3VEa{:KoEDcɾJ#oOU44lTjFk,>{S?ýSk>Su=|j}T SU.nk.mcŮ)RxbT<TV*yÙ<+`RC;S^0-itp<ȗ2IZ_0ȡVVKHWol9=fd jb%}DCy{sI*{ZL1r`n}+D_*Uz3}i779_kjxL+u ;FxL.mmQ`sKzK#>&ޗxiBV^\s3_XX_رC+ҭj|S kϽ|j|[X ΆBL.?\DCqߢ7nO(M&JOiݖw0IJLM,NCOYPoQRSTUVX Y#Z:[Q\f]x^_`abcdfgh#i3jBkRl^mgnqozpqrstuvwxyz{|}~ˀɁǂф{pdXL@3& ֜ȝ|jWE3 תū}kYG6$ڷȸ~kYG5"ŵƣǑ~lYD.оѧҐyaI1ڲۘ}bG,{W3qHvU3sIa)\ Z,      !"#$%&'()*+,-./0123456789:;~<|=|>|?}@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`acdeefghijklmnopqrstuvwxyz{|z}o~dXMA5)ۈʉq`N=, ٖɗmZH6%ؤʥwog`ZTOLIFEDEFHJNRW]cjr{ĄŊƐǖȝɥʭ˶̿*7DQ^kyކߔ ,8CNYcjnoldVD/h 2 R e r xzzzyuph^RE7)4=@?:4 ,!#"#$$%&'()*+,-./|0p1d2Y3M4A566+7!89 ::;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{||}v~oiaZQH>5+! ؎͏Ðxpjc^YURPOOPRUY_fnx̰߱ 8Ql»!Ceª9^ɂʦ2TtҔӲ6Lat݇ޘߧoX\[VL=*b/fMq T p_L7! }tfUA, !"#$%z&d'N(9)%**+,-./01y2g3U4D526"7889:;<=>?@}AoBbCUDIE~% ہ‚rW; ϊ}bG-ޒēx`G/Ԝq_O?0"۬խЮ˯ǰı²µŶȷ͸ӹۺ 0@RfzƏǦȾ *GcЀџҿ'LsٛFsM6+1MZ:{OX͙~ʹ~y~eL~j~Qc=9~|4~cl@~]̳~nf~C~لOiZ/gP8v}6q}0}>ϲ:}i^},~ ׉_LpK-~~,*~&E()D9vyowy=TS3wI!D)J%OBvwN64;>FVWm S^Di*bPkpة?%"1#!ϼK`L<n-e2*+) X䥂C@v2l Q?(=0q MzǃIz7MEY; Y@K (-\U&>rI^2IMe;Ya"VN,S;o_%sD;fƎ.R?l ;0Dq>8zDKG)3o+&<4@n͗0EO94#ҐnW9 b_7}B2yːv/ąJH삻Ȧp$ȫވy;Æǘfo虔F¨LsI,KhW2!AjHE^τ _wdlXggΩr!jU)[%B\DCfp <_\?k,.wȲirJRݐ=>0+cvZ{HllLVAc۠ ^{6oCҏSمbȏ:sz 7jP@Q;[wg|z30Uq`!P-~|X3+z2lIђ:_p-FOJ*Yr(".O'qäfrCRJ'dc~h!€?`}WzBd;hѲGϲmT SAij9< ߨ%@`8xLTqė=,Mk $hJdx_r̰gʱhtG,KytomVK0X?R=Џ ]ٛa`sʠ7g&Grŀ?>r&z`b>&z%sxbw&{~څ]"WR%c"zD zA rs!֝=jcf]rmANJl$ے#ؑ >wTfGFF699<׵.'SZ*˺#-Jl.ZZx%m*| o 2ӝ_TWK4eRsu33'jRFBWl| Fgml0L1, y+Hu2f;[T0BE{:qntoT]okI, LgV_R:Kϋ0dP?= vE̷փ(M4m\Tk׉o,H=Zw/EI-LQ[ 8F/g֖'$?[u~fghXjݚ- VImKՀ,%ibQ*e97WKMYiHtXTBUDw-49#iԗ/r]hGވ/ lD2 h‘%TTT*Fdw">GY?"[f r5ʊ4`TAo4H5rWS8Xy;$Yr'q vUPV&4m/5LJE:S7Hvy.. kPXAl` ,e: E$@BKr.!{A$A,CY[EA;| TJkU>41aƜdcT.Us R&BchR) Pd;ʟHbl?1;_:i^mMh9Ӝ+,x+(‡j3=P6u>a}&b (0=.À<2&m%u9_~zL!S`(6͟>թVlW䨸m5ypg!2< PR%wC>ubvbF.0UK$K;؂P,!rA5%\v" [2gwdxJ:_'Eښ_+^Cژ I! v,V72UJLNITUKɎIy/R+=+(֨v6!M @PB%R--3|4-)#ͯ w.ܘ<;b#;*>$eG >3"و~AZ$xOUx f𜓜x;٥Q h X(Zx=`dš 8b†id, ϐ!enZ b /޲І2P0~ +1baktT ?g)˧9 С`.ޓ`>'4\DRdPaxԗ?i|9,t Ĵq]"m-9OD'Ex>#Bz6Nk%tm6BDzVQGq,2O: y{iHcy[]vaZT5 ȨR 345N@qG!fYXr{3^M7HX1ey87ҙ;NP9tn/D=}*I:2s̋%G{7abTBm6ۺ4JZmI׶Fהz\FD*rEyք ̣V-8ˉi#7XmZLW:2 $Iⷱd`U+z3 8"}Y\E^\Qܵ)<&uZ!FM)V"ڟ}&à/ ď 5 O546PW눤0 fGlEbdc 'ƪrӬ[{K("M/y%0=zFBx}{w6{Y50%,40R}ԓvTp>K@fR$7HU( /10f<,1BS>٨RI3#&&pa5j19#yTH9cI[էjU̟~? +7NzM`k|-kqJ}(Ҙ2SaӼGi ; b:`uǤayU}T 2Ftm̔%OpuDU0m~L-_:qWg0~huw-] NVrP =<]x;Y1iw@8,n\(zqb !$zB&5dn61Q& & CuЎy#c%$7]w'z\0Lk{8 ;fGS Fx¬P~Km%t3MccM(bCB$ _ J,@՜ %ӸZ;.6B)PT~~:_tHNITScΤ5_3bO6-[o 7$cn:zNqnE2~7\NT' "[fTT^2F&+c5r~ԕ(jl 48mWDC]X#<n_ T 45 C0 V~ m&AGA7w@w;Q8Q ?d9#1yʕq_eS]y|d*&6Q30J(WG>HN vAg+[o:y1ډGmUV'pJ{"M@3X|*oƙޞ%sfJ<ߔ[-0R'G i++qNPF\&XT~ykPx>–~u2LX'P MOW rة Z?qU\+w>-q}y/sRQQJ@737Ka[t̷E8X,Tp!PVK$`Κ׵bu~*LlBz-f{i8DbMp/ŲF_<`w[Uq. Y!'i7L' Rz$v]c-ީ%HY~ٕ 鞀ws{)Wa˹ԑ`{[z ϡZ& z - U@uBP.8jz B{GtϤ1ޕq# ^o2N*`DZm錞c@QY@Oy`ŕ^ )H??s %J@f-H%{#}řPKn@u5w:=YX9(5#p 9#Av(~-"]Qb'䠡ya '£ +vO@%7_*Z-r*~z Ց4!wBpG-q.a+c"wmqk=WfB +k^0>npu5㞃= m]0o-1:ǒ~%ui;pVO/a3;0oKܼL6Ed@ZU%{ ^ ͰyOVNHLmu?uMBEQ1\IُOui@L7Nk\dd[i|lRܰ3"rW^  19~(VZQjsfb5~Nl, $LAE \Yv3k"*Ie.gj4uDk"*T~~g^ ~<|1cPx7kF84K(/AI\%HG;'6`kK ZJAFqKq$5GT#.a;1 p't.t-SSUn;QY(sў*M8= BHZ# GcDS{d',Utl=,}*vcr+](_1rØ@?A[KDlv'”o>=ԏ[?Q ôn!ܘeoiB]u3PzP'ߧ%44Qw L7@?;gSVjgohop7syR\7V%xL| 3n|2Q|-GotuV֘Gk}fd'̐yQ/;^+b#&~ي2(ɚpTֆ)$Dru:5zj,|~0T\~>*,6Y ]7E9!7;au*8Y?Ң#WfiA~\mB\$OwDhE16:_JqBR%*X3 !O:`Iok2+}Y'1%Y GPMJ{rK w_ L&N NyA'ճmﺾo4gz"v;L je %Ɯ{NS6U'*@djNcvo^=Bi 795l€Aⶫ627ICkyV_}B.I=YR2U^c~o\Ƙa3Ƹ2@eU*Tlmcӱ~ xnNU)o`Iχa]PFŚVTC&ϣ࿋Y=d]/..FBXs+$=}buM>RWm6Ŗ6ᢐFX 5x{v*j;zv<_~AVUJϐ^IjQxシuQo=lK_ՑEkZ\4sqU7vOa J?Q)4C^\k[{3y~M|J'g4Ay,$0( jHl:Q"V҉1X&e s)MZ(W |Ϲ\88&tcpҔa͔ CC GU$^fb|8u̸&A֍9ke7;㥦koAvՏ0o5y'M3q"y$[Y@SgÓ=ݎP1)L \!B;U!)/C$N$A³ueuU},3Y'/Jc .8_[ON-<"NawGm_+yj~P]ſ^\y X,r-|㒒ܳ<L^T},^eDR,nkqց%|r,!gJx=~p{"\eeEN;Þ=${q@Q_\?/иLe>u#Mp'Yn_e<q㼅Ra8pLB=(YK[l`BKB#4;c;HS^OA>Ʉx\+0lkOԼ`Fcfup.wlCnKJIi]&fXPAn1کFTKBoI!ӮZ f)~Xhy9 ݨOC5&|T2ӲnSLB5eD0:yP;(w9mΪnWhKu{`wk kH>*ڲ1 wp5Q݌$;LvvJ1f3n*Tg@oO#9|}?V0M5.ۀz{" NK?C_$ P&B̆e>(qIu`|ob|_0l2WꂝsCܴLTIa?f(/+PIwB WhgšH EiŮ(G6 "  "(H2̙dfr $xZEP>ţC~EF:}< \{ % rH6N$(߫Nᷘ_%1]2:$o-8ȥ I-qt;'kTjJW^}kfQUr\ulNkHn᫂H*Wd6M2 *{`V%VRoJJ`+"yO|s86Vy8 :+;9ɨ=.qqѝ=ɥ^ӏwldG;fH^2`zBȳ ŞO*{M2MoR0i:T~%$9ED~cj<}${.-+P]c=Vzpwz\S;!?C:GFIױqYŞ ݇>;]mS)yrEz_n˕aI"l|sGvmߵ_7e]֭>ГU)i:D΂G}V W5*{f? ($p\)9D$ZYr|(4D܁OHʳ ;ܫv۱jxLr_r ;Wi nV|Rudܦ;@YNl-QnJȲc/14C:'K&̕BOJ{ߴzfsW|F-q2 ?}Y[pXdY<\v+M{ir8~LJޯ vlL: ?@o[g`}>?UrǛI2Lk.}GpI8QRV%܂L0/PUE ?ɹTcۼfHs^QMC!)$ ; ej uIy W6#LMi9ĦͱP*HʘFg]mߝn+|X$Z6K'OQJq m(B~ljSuZ ťbhWP"z@UVJ΂\,<\HA 5Oaf΍C75O Uݮx7F>QL~:ʥ#][eTS2%c Æ~EWg9i%3W4ފ:}޼0_X|-ƣµVu8H{YF"qĔ-F95E!L/3zLw@"FRmOQ&[#ZO/xˤr~9T00bܬ 4Pߋb>_nMFY%MOaN$ʡ˖~ &($~>tBM%^i3ϐEf8UB '`-icIaͨ+ دR=ZȾŁ=5U#5HR>njky/s6H؃E oLyCG/?QE%FvMMz)=ZB.ϡƋ/•3O85&YKլ(ST eҝZVx'xaV4Ë*H]z~h~ i0d,K8CZy{jCF')b|xNJ>V{0e#|SE1b狛*_R"37Boξ(p3_<ݥ%-tɫBetƓpx HuRuɵ)H?mf@Iz͂qrgM_D|Ce ӯ_wCՄYK/Ԩ 佨/Y0y̸7.]*ѳa !d[m9#{-;W[ U$mb?ci3ؘsq6ĂT t֠} dlv{Fyt/ټt̰KQ8 N"4ʻc'׸Ns6I ][#?wsb,4U_ f)Eď* uä6Go76ɵ{'CGa+RUA=@5_rgs1OUG*ʚO&Q͡4%nlc=%Z vY Zeਝ4? eC` _wvĦ10KB/*Brv4όwM 0r `$CܝGa6;g-N_&ɰ.` `0M/s\PMf`p3 $A7 i c(y jӍ 5!UiMSD-rBFL&^:OF-T4w T3c q]2Rd/3U\;?Up=@b TYRJ3O)*+sWu.[L6ǼA. 귒hoN_=C|HW Gz}w\2h{?Ur_ס,[<4DmD〷C/Fl Mr_򑹾g"P\TMIiDw$=` IӐ }6.jYx^h}]"]l 8"ӽ΃ǐL"Hڝk:^֖Tm.^@1~qxTlU#U75:LE|4&W25exz*̖̆;M0do^lpmaIS7kD#'͊$"lL?bADINmEh 8Ԍ*"vұE݌5Z5 `z~x[MN&a|b(ǁ$ch |cq)M_Ɔw>bSО$  Dpz!G@o3a]PnN2);K4 U"p+q 7bLay$04iCc9(6>E3a{ R䏡0`?s07y9'`Lq`ScLr&MP.ڽ,_ru/F=܏=1ltŜ 9>1lם KX_t+ =#ثL uuWK̹ u)F@jR_$YuBśGbQl+$,o8qlg!) n2QήU>Ytw(^'Y! %GU9, &>YcwU Mj"Zo6VWF9=al mynqA/2AI̐i qAN?!9NxlbO{eiYQ̶>SZ .&sbj?1_ǡPkٟx`дY!n6fVJ?ffon06l)7BuyMAѢ&m>>Nj#4J%&|E]ۊ:i2g0io*6zXh +҂3;1"2ҍ+O?KjaY|nMHpA/LsI5cu*ΐDx!W {|mpq%qehrYbBt M7uA- w%5,x+ z!Ί}|%wpȩxeXx|Yy$M}yAz5{+=}5"6~{άq~p^Q~Md~*XŸ~,LU~S@~5 ~+f2T"P{pUIpf P[AE;Z1ٓ0U)Fj"0΂op~7f ![BPY_EE;T\1撠C)k"djpmfr=[M,1P\ǑES;`Ћ1')}"Ρmfni=pkqr^mtolVurX wtDyw'0|Yz>̾jqźjlr`ntpu0rnvgkbtgwWIv~yCtxz0b{x|bh|~j|l|^n|~pp|j\s}AVtu[}Bw}0z~l;fׇ i 9kDmh5})oviNqꂿUtXBEv=/yVǧeP{qgi卞|l{nohLp(TsuSAv@Z/ryX_dִ2f}}hƖMk/zmtLgdojT3rxAKuI/8xσ[c&5e[}gܞrQj.xylfoDSr d@u/x\ębp vdܫg%iwy3kyenbSq@to.wUad`RfWh-xkkemn)Rq\@?t@.wZtf4uhvjxxm0xyosekz.qR|{itP?|w-~zK'rp{sqԜu#svFtgwwtudxw*Qz%x?E{zb-}|Xpzr'zssj{@(t{vxv|cwy|Qy }>z}-R|~H(oYpq݃^s=uPt;bvSPPx <>Ay-|0m{opzrt?s^auQOw+T=y>,{¹luSmoou{psGrlatqOvk?=txj,{ @k mܖnlprxqؔM`WsNuȌ=&x,zj׫4lgmomqq0_s*9N uI_|2so|u]}@vLO}xT;"~z-*|Ly(x*yyr z$y gWTaˢĮkTd@D\dPPp-HG&]30;sCg( 1DE*n6ܵaz*&>P3ĸg| ,X񦁓`S$>BG DǕu#i#܌-`xJ!wم:(`[HWeQ2UFD`|:Cd2~TvkdEeUb2̽p ʠ~[@QdF!7H$ #dLt!BOK*G-iCrB.UlmO> ,B2W<+367ߛ@ )۠&KO 0ޏO igm82=D 4FB[!AIb4~Z *fz\OtF&ӝN&3xF[Hjz&3n14bM zB! |+ /hw{V\lsTjg?қ۟u 깮D}û.5ʺ(wM ұ=Ljeo(u\ yPXƢ8p2232"uh0 ;(3-ybݷ3WdsF@w ,8#!H*9)iF^ P7Dg3I33D_)JQNdOm2ta':=J.۱ s`d+uu- ǵiȵ\L kw/i&G1|91:H^gW@-Eif?QF?/KvřMkz݈uN0:ӎ3BJ]PU@׊VVzDPC9>RTl{=EY^ScyjN96b~mwj[ Zl'd}[YގM:tU9WI-#d=sѣS IKuƷ6i/JO{s{c@6oPU,'9cV~M6IQ1WwoT+mlF0\Od?oi4M4MC%HfM[r0p[p|R’/Ld/_c8]׍ YpFKM(Ewo@jjI0/kad[H>|/ѓL |00SVRׂV2Cæav4x,'L82'7&n&CĿf]9-f]i{Ta4EeNٟή"V_ǔ3tf65ҷ, jP6Ex)ͻUSu@6M6dFVSˬGŦwƠuy@>.TȆVOdj?#驺sycA)w,zl<ـB*7ij,\P#;}}~r4fxO"ZhNMBe@(78,iA#FaN}qǖ*lf Zۋ M2HB-7߅,yY#p9|qeےNYƐ*M}"A튘6؈U,ۅ#||(qW,esY!MANJje6Ç,}#5tPcjOf=_`rhTkHm=op2s(Hv "zbtu5k#jl_-$nnSjpDHrB=tytn2ݑOv)yL |triIs ^ٟtSuSHt#v=_.x02y)B{! }~st(o.w]^`cCcHlVf+;t)i0aldOȯ>tsw[-wnw\-_AMb0ke#SsShA!a7kO|o>#r -v0[Dn^aaShdL%rg{`j哟On-=rfv-vm3Zp]­ `܄cr f؝C` jRNnb=q-vBw~o`^q&ccrfBti quk_wInN1yq<{u8,-}pymjynlpptnRrp/qsr_;utMwv2>@?nC)HKс#Eu$%`^>[ (?`~^x0_+OËv&"YD>s5x']~-if~>NF" P^OG# ǖ0<7ӆ7 :sXL!kݱrx{6Rt"+@q*7k1U誘Y}(~\H`J䞂\ 52[{F;Onݦ *C{2Hpuw0D(MHOB$vKѻX{'V' 5c sh]T4I DGãTD(2BNlz9eB_ ݫ.#JUbGɰ Pc36߅!3?o/˼ 4Ta1l-vKWZApɾ<>\Щހka8Z5$GdW#{{ߢ! e8l&Vlu4ʚ@ԸQWJ"쎛)9(6gf y'1?JL)b쭢l]4LkۘPpuﲹ)nCA Ŷ+2dEH'Hm&Y3uѷkѽӭ1n]_Z<ڮRvӛpjm9G݂#j}dA-uڠ 0\C"dhK>مٸ:IFq\BVhF'$[I&3BtK\ D'`;I ["%#N\I |?a8+ş3"-Aש_ZZKO%u6`X{cͯw1 $+OM{'E],jz6+~ Qk a=_/E qbVk&S7fg\"&]KOÑ: %ijeB>%j:l=T1e~/ߪg I0^YV)<^ϑ% զՏQS-WGpaθD8ߠ9D֑ՃXM' UJ]I"mteuuE)-3`Ҍ SoO6Ju@$ZZǚ;oam>݄92)@m{>-V|WU>r$Ӳ]qّ¸zEYuɔ>GT@蚩\'}њG9mp.d.@L4c&,r;b ӂdlt3ݦ]Q<b-w Nk k bK%H@ j"W4sf|Aa{8c%J@bW\E':Ehsř=}9fǹTW !3ߔ% פԘ]YzĀ&XIkWdPيb]9gbIi $ O1wu_)xS$P)m/UI .mpsf5Uwl}oyh 4;=DUIKSDSjj:?2*w0P4o+G4O6jeu HW)ϛ=ݮȆs51 okaIӽ֒Wo0%>#}?V5N_r}%7 Լ{!`D}K_4 !Q\HҽzȔHN>uA-^Ჰbg%+k58W #wi+q0khcuTT[`5Z[`J &-v**cs0:-7o3G(Z!d  z Q}vx'E}aQ#*'viƷ|'in˵Y;eR{E1vikYT24o/;K |O c Rr_T'UtKyγzaL= zs#k)|OĀ܇:axim&&^cŽoIѓ` W82K/ױϬ˽^ipuO:JD:WtG<8YJ] ՄyiZP-|xm4rQe`dZH ;4SX1̚`wpu>7 H2%Cd>zES?+&e{\Q>+) ^T9ZPFV+@l@ A B r3L2$$x *,^-ڷ[]<**RInpdk ŻΫ :C>KXi<_TTՖqcs.JmZEŒ:^΄hsVIbm8tSX&^ a*Ɋn^m=A2s^mICca|k`K{"Y١:nf,ڱW x_n~ !f睥# Aɧo(u gįVg攷E)?n/ؠbdSu3QQIB`\C!d P,2QC[Pһn`RXYU^',|Y5G4-},V{:T5zGFdx|4Zٲ u'ʦ"Ww[f^'0Xcx2rKJJDJmB|CÁ=55oc/hNL9'0jI. =$!_3s^>pX0]ScԹ`gi9Q?+,O|ekkC)6bf!),MjQZF_Y[-ۈfiv&mH!`5oIxudP#F P&h_2nnmMsC?wOt[Pk+jnA ǐHځY*zל`L﵋TL01|w:44o(%j̨5YJ_|fyl00DO+/.5T"$8[g)T`MH?Ɠ\fިÕyL/\Zj@Ν(Wڢud>P"Yd'$$ʗVJ+W>pG[^Gڻ2|M 5kci{ZJbILFPCR7<]'wKÍQXb* $f»~ ^̈́:)]}pA(+RXzE;b1t!9ݠBj` d> !L7gh%7nׅ _Qg1R2Ǽĸ:@n\KX)'WIC0hݤ!XL}4l5 Vh2,?bLb#(sÀytk]:ibP_"2S&F ߆*:/~5l6fݻ Ӡv(l1u;8qi7mL[@Wxlg Y<#nMDyYZOEX;/C<_IfGuROM++c7S 4ƊaZԃu Mߊ]>]o/m^&=Nh̕.g*>d_$ ]koj-]wz`g`@XRSZ^6uV^og~XQ 濮a%{s Tp4{HLydW)YU&R?FD/'gH7yOG S0᪄g :po)-.XF:e*diG{.㯙nwn.tY<"`7dsSC!x$g:SX9Y%r_']4K . q cYv.㏢Mrm*ADbW냊M1Dqby9mT'buq7Or }yXK8`微.;~1K}wҭrB;ҏޒ &6 Rr*?j䆑lugICkM|vhZYHn8VzQ3N??֫zGP5|No(RGJ[5&Hs)qq}^&2n:zǰkFmP03;7Nsi+ZiӍ ^zs7Tm , zb@p22{96ʄ/= 4)c x t&83B-(;^SedSy7yG^H@Es7<AQ|h[\jeZҎy1|i-M']|k!3h{&m5&[KiK%}UEk̀u hT[*FkkOZ e ev]G ؼ;GLW[d;oo3xY{OEk[@|l2섐^򒼗F6a 9uUQ[Em'*uWAw:^WfAw:Rc$DZ9-N7~c ?;A34VfO 5*DvUe_Rqr_pMv]{қ[;f4( c5ڑGdxEjO-n | g8 KٶŲ]{r3J(?ұqlu;S7qWA}ǰ=o nxg|GCTpTaH͗O0U`llڤClt0jh~pڱY_,x',IUjn\[M zDBb<Ô]T7S0Co}2%sF͘MQ ś!7fSѕ&.!mFk(+O Oȏ@ W1fG 0JZ-#=qb>@@gIxFz|޴\E=Yg6atҺ*SY5T9vh  %2{}n}I90v zRf8kOʼjVo:*xH3_ 6WWx4\;5juK::i7rʶYAd~X:J1<;e (;MsrlڪU[y5vw(k -OlHWeG㐣݆L9sŠFp6i&xИp0C2}TxmCH#ѽZyڇm{+EAaWdVSy%ې8bש"SLL14$Bs&Bj&d@Y?O+82}-D^ݒD(PR{Ѭ.s!$4Pڣo\i(#u"D8 :]C>6ڒ׶*m@1GQm lìOrusg# tk-ۤ^G) yۂ2b+PgDWB;T+4Qv{9輵;!f6~/ė|@r~EM$,<`2+oMҿ$ȵk뤆)<$\nnu|LX+z-]:r"Xꗺ.KW;–YFC :Aǔ+IU u+U>.+͋;SN@] LUXKx6 ͑8=*U4^qݗۥ>S韒+Ż eLsf v?m!'粈Yv0zْ2GwT1e{BHM, &fr(y)% P Ehl% $EVDĶt o \~6-s//E 2<뤪t :mbpVn(Q7:ziZNl*3miИ` snX U\Пbi0^Kc=!!{pwpyKH&Ș/UDg#M@1&yf_sIrŔ\ Bc7HexXltbu!hI &) ֩ršbps;Cu GFq~~c6RbO'l"<͖z [T0}5y V|EWrф\2aAA0 /ɷW&aA AK]מ q\kPU"Jѻ?W{j#'rG^$U)~VHDTup7eÊ⚊R"I^w0^+mOXiMi-T5ȝ'N]~{e r5Ճ-wA-VYF~UgBOJt8y0.{KO(vlJ uS0փyk^?6Wc+ Cl]Eko%ݼ脦g}h0[[tVۃw,U^|}X?4:a<X s%هU)<@ZQ/[6 . 0A=fxIҗQl3\PBoJ]Դ\>[3?,ЛMOyIOi> '|2kxo6oy*Zo9XYifNP?1k𾠣 *_BupֲB[ 4Xφ}P73d"dٮ&<ăT>x4Y"GXF%Ngt2S 8.hpq܏#~2HleҢ(j =~n$ Y9PKC‰/q䢘&lrS1|8+ۺp5q Z(QӸAX!\$$$CsrL2$L%,*OQuOłBuUX뵊]xV~n,[|nC -bY@X?(e92"կ)fm6@>_|Xȼ L N+VJ2v&ǂga:y*=>C,꽅zqwΣaVbP$Ԇ3H* |tc^7CvfCUʆN\A X)MȊQrK{Fۏe"j%hCi24.$ҲɹDӮ?2]HMtaPZ+C9J*_r%QNH4r{W) |em}^e ٻ .v_.e'T)V4(FoUgzf0=rƣ[(hGjKҢy}%]ʟ%(y쭬0L1sR1w^NJO7 نyoxõO`i0)¿6T@JJL#״C[!)9!w+@,&TQ0GU5a 5\1(-9]s41y3yʍ/ G䇫~IĴ41_35g%@.1N§ N̡Pi'74@rz8Z? i;f cENOri@Du{A6.ѱ>1_:, Jf?/LCNN*E]٭!mq=p)ݍ cFMH?b;t% 7r~L&3>ﰞ~6slD'9?6T­ϙ^ 5; k[}gX0^hq$WKJm3qV/f̔&|}31sO[9"6ε6 9K+|dj8a&kɐ=9wUͩ?|0,lugzeU,}* e-^uGSoy77bC#Qşn[,( l^ 6!ʌ>":jbiq2$V1\$ǕwkGԣQ%[`ѐJ Ή `]+Y)u!*5(HIdaoElw17hYxЈrMyA39ScLYgBل*dlQ P/Džml)IR`i?ĞAY訌:et/ ysn琸M>dSG&HPe*p:vFӫ}9|%*CdڌTm ؍θSVkq~VQ< f CB'LH? 6ǍZWzjxA|+cshi#a43 KZr?'H:m2AĽ eЭdcM^k^Cj#,@DL2I~tHGǫJ̀e W`_qZb "pp߄CH I&d2L)xʪ*jXEtJJ]EZ_=@XY#>(UT#tgE UO4E]cDix`Ffw0b(U Y]sAvjfhw@A,bx#iu+E_Xx˼U-EW'_@ce2b1( h^EN `V[@-kbn_Pe:60lu-'\j|Dme;tHGD˪&աD!ߪ@M?B=rΕtSwo2Y!;DLž]򮆁˶Rf;˷-r0ۏ첸R}"?5#mk+3((.RxP{K$ ~?uX m(U$C[KIl9vL"F]C2q.OI61Qx 1iQZxle_)O&uZCj7$6} A~8zXmb|n^i>]fQBchJDj^ k]rou#Ih 8ЂTc1)üW+-*kxueI~PE:LR] &t-¬^*$M4-bB c鎳A9ZuKDۄT}pp;dzx0w 7 ? rlJU/3BK3hf@jm1RזD*p֓2O(Vv ndmMAO;1S`M-a6)N˛,_ l[c.Hі%Ŗش+#]lcٶ$ s~&b~In^Y6-쪸ʟ/FRa` Ei|o$Գh:)=kZv6g|V'E;R^t\"ZW YnN'⢒LiK[!6bjnf$=+ *.ӃKvIchP*%zډ,1-pGsD8DC7x&X8e!j5kL4Y &XqYLA)$]s_g^.[fx́{sHq  o݌ KFaa)1$PoגיDO̐Ńwq?0$װޮxYZN8$8 _ُ$`lcZ6ݐ?ȇY+0H5zቔkQ}Ö!~QQ2&P{BcH|7gz9^sylu^A ;RckU>)vQ 8:oVcsK68#7>^nNk_<w*>mڹ3"ΨŢl` D#ޣ7W-#hD:G"DxA4 >X( 6b-X>*'qkxOOX+{5| fP|~NEzEy?|S-2<3}=`[~#ltGPj_ _߷,cn$kaM=UlMQ"gɆ 5iЉ5M%7R%qvLSG[]]M vKsw>q| 7pL=#.[CjϨ^wUOlTvCe]j20uuFfձʪ:AƆ"E*S'_ !Z:Qpt47rv윽Ys9{<Fr׃d+G1 F~ /bm1&&x, ^ LtZnDz4g?x7o߽06m3fB|=ksΛ 4|K5~Xp%&(*,.0<664^?|X@`PsB#b$ PX<1A͹O3l.O IrOS#?UBP' BPT;} *~>22 EOL_~[ g ,v,cy]zFl(}FVύPq㫪J6A$*H$Ρ`v0;f×9zL2ٞQC|QM5xzAR+Ԕ k*xGjsH%Ť^Vaݼr~Lȡ3h5$؋#2'$ ,FP].V!foDc&2`* _'ǹ{# ݰw%{2>aQ*X SV*5r1V/\2dL9x~dE ]0 ^z[AKmILŤSK``;m\ojc{.]w{]}A][UT5䄚T9"#֑$-QJ֙ (R;7n^윆a:VVTST@e& PkLlvw6ԷU8{`>5#8-Eʦhc5Ij ɱUx(EUu=XU=ux}{tjG 4a(=Gr(nËqZTivU肝 F7 :&|ؾĮȬ8CLNlG\nt{Bvx~T2?]ъ?:B': nAS+w."nG%PBRBz^MLpz&*T@ mHh؇Dc΢&ZT_Wj 5yI5LOї5m һE/`v0;fˡp;ϙ־A}UlK8SQC#kדtYFUVErAF̾!b7E|{e wY쓌E8T@V4U4<7IIiA(R@: j:8vug*tE@EQ*r 럄B; !rIC@V@]_ӇQ5UW/)aY/-Ry%F2"  InK/i"tY{p8d|Q\Đxi'6ĩ/UUi5gԧyebLY(ke&\1q(h-Ev;wΛ6 !5kC(xH@m՝N&וy UFeaf5n\+#$,۾.wAڐ&T%_}ؗY6"s 9G&j ơR9aWLt~-m ANv$&! 2p0t{z$?5Z uTj]Ġ`9t& f,h؈!%gS$&T<6ncK /'z&bp`F*8b(@H3x!}': yo8IP&\P{C@Rt(ɓʌ*rH1𵐗&dx'McČ`$f>m|S~䃱ؕ$x0mq]Pe& i#eF6AWB~8QChiTɞ <|]z[u*nz!bg9Ԓr3lq Xr3" >4SPh=m@A8 {Ͼ+\Ǖ--F3a@4M6;ҩ'Z8JԐpjj6 DzQ0'չ=;Qv(X N#0-z#}2Ң>ƾ#Ahw8Vw5C/[r:mU5fYH7H)N6S PX'>}<5ӽe~y'NNdtOݗdjM Z̓x3YAdECM&-ڀjG ož>ْm\-u ZTS#%xG;Ѣ8]0^`#Hƺb~ںnA-9*ViTR8 `'yM>aATm#GђZVZ˪ݐETD_l }mϒdo8zPc)VdjGT *:YϪ z*MSqKP}W7K۫Ov*om;Czzqt}JeVl|eryItV2j)kb腳h ?|lIlN^mzQr}\E+ݫl([Xp1ٔZ[m@_Xi䮠pvfy?q)?GZ3=@W =T2lvsdrڰP챢ށzE     q5YTp yOCŻReb &l[Ghmb9M%>]8!p~{gkl’B42?ȩVnI6 e%2G-8o QP6ncN/J/FQ&= }-9>#, +>nƙ,Π z,>3'ЏԍI6Mo$GWdosfܐT:jGyhKڻ)k[Leٓ#ceA>Vl oiEǪ2p˪lMe.{J~IT"Cvnc53}-"ÐhI'ِ,kHM"D[YjsUZCM:fD˂+)U Naa␽Zfk@ 0,"IBLtrAlĐ  N9Vr:#Q1ha x!coDjԀE_dLqi&]8NLSNIS/)WKlƜ5==\[jTv]٨@(WKsm!fwO)iiLڤ?鑓#tɕOL=?ٯ9,o9̳t2UAP@C6-!d!@ BB6BĂQDkop94Mre9*ӍRMd0W:rB5*G1GRBd; ib"P'dh8^`B5yϕJ\ L΄*nW2b߭L)3t*E&' sdr* i@s?/=:Vh,~ߗ;{u15k}6EnA;xobhS$u,N%ɕ8j 'q/qO=`S)г ,Tרs=@o5-z$^˚Fk3(lUA?5(!4v(_uw1ff:w-}hXKvzqAOQ NϜ@:&z$B/ $Gc*8?z0;ߗ]/ZZV#sY]X&qzlKNCd P¶GFޜ=;èj!,z5ϥ+D`C^n"NJf90 2?}ɉ=yΝi*mJnL6M$_e A ($eEU Ȁӏ^9,>IoGs}YEHBWh֯յYTwL3rS1MOeS-)*d`[hh%؝jӣ͓\$|[XRK@-_JoЌ+כŋ8V"]?/&{d_$]B?,kʯ2xF5xun#s [oyDs?{how1,8 fL?CVAyE% K.?)-amU [5[ڜȺMtM0o?s}*Ϝ|-.̩ {JZVu (lIneC6%FQnj̍;\M{w 564q@p${{bKXQVx &\^fA{O򒻭m.B0b @ħ/d?4m/o y0wA6kloz=vVtbd.RC{,DŽ4]@Г zӁ4#L#y,xK|}]XÿC>A𵲇i6pD1|܎,HψP(@c ii@Rq2[eaU^FR6Jz!` {v' fQm)0}^(6Rc$5 (r~P,y9wM:(^։gDHDϡyl"0A4t!5F5bl ”#@ )ۚ+Ou`;\ mqׂZ4++'8bqu2ǬN Gt$ F7 G,)O '6bgSo/+WuQ.mlc`rj($oQM 0rIF?i#@I_S>8Z7gW-[ܫ J?&[1Ck\B"mф;[ 7qD $fØt;Sj͖%qzfg,;-^Q`-}"ҘGHv- 35Sl.J7oÉ@ 5pNgmwٱٙmu*ꊸ/#7H NH  @HB\$77!PxE.ov[O8bD>Π)Q6AY-aWjLGU-oF7k1Fj@3\=ۉ <'#Gޙ?uߎo qxeP IÉh1nzY=Wu Mզgԥ'(e]-gCGi.];^ɹ>~o[?) oOP^M!=aǠtRl69m^rU4\ O%%-,O]TB*s;?Mw+Pmv{ւC)#HܥO)ih\LC.!K'b1 HQs.w{ϟ/2Tp c6#s6"bI)i+˰exVz:;9 sYAnSKG?vOW{$a R*ը1o7l ˯WC^kh+qf7 :B|J+*u}B2#PCѦˋS%e*:g cCh܁li) `Fm5{kï 5!>s^sUXt9UJ厓7YΆ-P7 $*gz0W]yl`\:XA>s97<5'&cE=ffӕDdyix M8ZH6."4Fm Iz9)d1 ź F+)mju@a7gDfFiUcԝRڊXxi>6|XG/@@+$kaQbќ0/nMҋ]%:c!רZTxY jq4Fּ]Xyw?=5a'v:u]㌵u=,"@n9 $$!+E@AHGBBpEA."(hA P뷙ӗ}Їw oPEiԑ9qͩ[ q)Q<\Uh.gY}WS(35QEJYj)zS h/Pk<^~'?aS| A :8}F/R+|cha 4Y^HjZU7 [C1 ?w<}Aw{_Kyē]Pmp\+ؐ- TźˠRVYĐ[tX;-i(i7[9GPq4zg6@0=4kֈ\c-MANTij *A+7V |ZQ4fmld/ 5@ ݽ#]w̋Usri07mN wˌ|!WQRQIc fWlerU:Gg&{ q? n. |f0rg$u͚B869A$Vˊ:bVoi L,EUJ@!Og)Л@v4>4=A[+g $fy4"nv,9r1gJc:5J-AYL :J匞Y*ϗȭy5Zg!W6@@6,GDOMBӆF`+٘^-+*uj/iuUcnC9K)7hsz 5]Nٰ;Td~>TJ4& *ow} u?zXcΑggS+~P2u.3MV&*1Z,_e%I#\iPpYRg/PphmsY}~'kGs4Tj`ޅX~>3en؈24"y 'ʸq~tZh/5kofصOa8s߸F_$@3q˰>'n9;7^^^=1.5?jD'_X,D,Qn?t/J\p &w!ב0؋gTStZ*j| D„=bCB3WYx{ot}5[,w$ 4LBA#oaQQ\xąʈ}IHNK ȇߠ Ke's}*_};v$p;$p\,1~ ?$  ! 9~|?}SRwp^@YH{VDrqQ"Ş'VpoTU$VdDױJtzt *BM"{i1a=~oضR[ Q!q/eUV.yVH[(`IʪYL 1KWiE2c9rg0]DgQ])ܚd]ѯWiMU}:o@:vN?ćѱ@Fq?.[cT(y1oM70œh~8Jh.#lQDҭWF[3j;E#@O<~.;YKhk&qtd=rT}J+zPUX}Ψ9gTz<#8:<1)y/%O$yevUm:>Cn^!R$,@P18Qr .eFҺs&o|<#AD1@q47剜_NJ5yvAT8a@Â*2 hc^3~13JEi颸r!:Aj$U^NMrs!&xt~8ۀ>4@sWѴm)9PV-kQŸiP8SYFR4c4Kl] IC4<Q zás!{2 ЅfNxfKH~JμΟuF^4܊prfJ@г:6BRBd Am-[[ꍏm@Ch[kd+>~r`vS!CkBD+Y]d=a&JD;Dlw؛7c_so` y툈z6tk4 6֗7Z *-Kآ&%ת#qfB׆cʡ2 GMTC?.X [ZH5:Wt6譥dUEFIҬŋ(ZǗkxZ,z0= >=P~?Y9=1y~4tV$aix%A!jLsLdEԶrV!tZQ<s`i ,{߸?xQ#/Ne`%zyx+UnGz)xVY'iNCV`k"|FyT&`y'_z>#n/F\Lz2Cs/)Tb%Ӌ\8yU B+|Ȫ/: {7Ӟ޸ho;A[,8N(V'O7* xUzjޝ;Wd(aCV%l`PPyp<}捑^gՕBkQG5wa…g7pkŭYlhd˿L^b/IİK(9w} ۿy7S[Zh=(L0~l.}-ZYn@."@P gSDFd{W5d˸:n8 \o3K>^=ݻ_%%4$&8 j%| A oմĶ^Ƿî:fԌ& 6-LzH| b?ӑu[}U ^^_b6QYU82Tݘi-434o'iͩZRn ZoH͟sӹ?}W>ߪm7 b#1en ?#s"*aQ{u5k ixtJK} LjH 0}0:[gAM vtv3tљvZuծ]uC;rCDD @ !`BBHHBr;\BZPXnŋu ؇}f~/76ذQ @Bbh\Yuun^R! lQwLs6H-M{#RpRʒKʓ7k׌MrM'?gİkS!" q8@& xw3KsޖG!禼:􊑟 %X~H<齾vmWkaİu~AD (Dh>F,AC~I)o|J"&xŭԤǮ03bgF}PM}3-z[6|ǓoK@C' 룐A PtD`#c{xʢHjl80bÀ!s'<jc/q/Ӄ@ | 8- QMxFeU>iHR|/1{.K<['-<+AIgPW7 K g N H]iD/X"IYEMo( g]Ytd_6]8|pR~ =)L}Uz{@ yf4HsRA:VPRX[CYqDu*ܹr. Y%3XlsZ~=*UN^i\U^,t{gP5y - AEr(ӣAeQq>IY`<<)`?5Y^2]b+0gnϪn]T_\Vc/=˚%>x[@A#I=,-B- g Vm<Ǿ_%߭PfZewJ-۸?{5# %SryUC ݠ>Ф'XʂRlFyCrsTI0%ŭҐǞ݌!Wi KFMvWZfC?]>jqF-VTyl?d^6b#Sl0bYKO̹4KftDuE5spx!DGSvWLv|j'mmcUZգ_E&Ѕmc~0 ֑ܙyWk:nv}þv sv$4y4A֏K磻2nuJUaDG222qwQ؃RpaWPgM/ uLnmXivu:3_0%yN䍡I/ɴQ:8nj %bP,|Tv@^@q;$8ΐBOGhOtP___r:!͆i`=li_(x1ra q#Ь$ $v@mdx8$ F{8 ;("a)^STS 7 Ә>ɟAdL bc!3쨠bUom`kRS2i@1ȏlr>>^@=͚#K+ڴW+lc4`}_81CQ~u6hxF 0l? y;H !?)|$Y"3?iV徊H!fLSI̝Itx#{vMH!!M@0cr?H+e.%fNMcH͐/dLk V-I9wȫ_G 7^P6P%Ȩea-\`XL)jYFX| ך3"紒jro/&ꀣmjv;!NzA1 1+d)VasYV.o*X0N?'Tg<'TZs{ZI=yw)=?S4О\ p|*N{?(ы Q#eMeXqiJѳRSFz9XFRwOMnUzwOqKqOVgKx}E5qcu(:ʢ2 R^P)R @JHC"BE0 A\ gnև}99? ^!HyYz@-F*#1KcH9}b_Rh2/s/gf 97y7 HPa 0WRX3aA *v=A)%(j*5ybf?7 +@\MH@2 P7]APeB<*#q r|h%x\N/bz|VViè- 5(n@ ^$k $ub wkd߁zf0]1>F)\d7KheRUr:[Dx%2Q5I%euaYI+tJ^%(G-il \~NSyU0.FyaM𔋵dCPq d&؜L,QdJ)BJ)dB֋$SC wNyߧ6Ʈ6/> qJhMIlm"Y+q &WQ%+ŕm Tbs@@ӞEoܭ-~b0䤶2'rą >UepKyBBc^3XVVIqUz1 >7O;AtzB;~ICțF-LZ,8GK(^4#J]cz9@YA}O_\;nzGPLh%%lƲ.I*\Y(ؼX%mK$ik ^-!Bs@i ?lu?ov9цwD%HS2{31| n)c!5*!/Q)Hj&I A |sPsp3F>M/Gl|tĺκ>mw3ȭUNӑ98żbt,Bw2IjVs:L&9Z&9&^ MaݕɤvOeq'Ey+_hbh'GDzCȺB(kAzE*f5Ό0"4ӌ)ftPnjXo]+o?سB쨅手e36M$Po(u v02`Ry=0^G/z*TN k㷩a#3 sr%ۿ Ve ˴?si1ߓAԇaqIw3SY*v5(Y51讆to40xQ9rl|Wӆus^Y~mKw|NQ^#Bqsғi1s̈9Zn0/GϷ`{|{cn[:6-2vk-oVZm-FC q4Fcqƴ(c j&Rߕ}L{#}9,Wϼ3 , S!VCfi}ؼþMGNK?z8O.{—`bc?[BD/b>bSPo93){J<#}Yw:W@F4 WAZY۾[hΪ8,v ]#xA7̀}@a zZ`C? O-"ܖ#>65ڷ;2"{+vM%\ -ypI^vq2_gQMg9=ǥ=Gg>(*(Ȏ;Hd%| ,심@EERVOU0l*wo{_;Ci zCg н|_H)Om;ݠ0ʃ]ʬ_Y4("65p`63q' ܭc~3!>G P~؎wr+ ..:rN@uᎅEc *lظ zHMQ xzAԾDkW pN8t8@`s$@fka;PYln "b HQƺoc.᮳cً9 ܹ11?` v뀍5}wG!Bj/YD}鈿S +5wqY.棇xcy/q14o(v7kHx AAn8x|A e=1ı.${5pנq &+0ȋ9 55l eԄJtJ{UK?Mj>"k>G>EOsE7ڙ+2k1`0)쉑KxP{ ]D#؄t J2:xՙ&V"_8Cj71RuӲ 6YPsMҹ>jY,BOz;[Rd:MRhg75V]={__Зsbc kAENBv?k|?0j78H89PE -aoPoꤜYB#k 5*a\pP&k, E|>O<3KbXC㟡m+y~oߛ`b<&Uȥ\59颦lY€VɋTg*uũ 6cdJ3Ft@6cv`^GKq;}^] h;c;H N]/eS  VUfRe $7eMZYWF0W-3|@oΗ l1a ؜um%]V;B=vB\pW-%\gKERSy*ʐU(E_0}&79 @͟ S߮\tncuO:>hp{+!Z#9RM2Ǫ* KH)T*mN6M2յ4\DgB9_2?B p%MumwuL@#pBA^ ST::8iQimlY"YY9}^Pd9(R6 D)LI3 %8)|'r2$E9)yW ro?(}Sӑ) ֩ COǥ]%c7M5Y,iY!iFy-_RM-ϻR?{9,Rl|RRF$5tYqE7 )ɏ<ޑ)  Y4PSF5;/xWg-^f72.ԊU!AyW2*R/}8Bfzc%9gʥAgjĥ:NwJCrgECzu6Wzsmsw~a5eJmN qȈԪkRbWH:&*_V/+w_rDgfIkU[4Pe1vGO}MO@ٛK_omϕY' YwFHNM?x=G_sb:Uݔɬyɮ|ɭRAb/+զtU|J WmR}mNW)6'|cDŽ6%ňw3\Heܩ%w_J{1 GV(d2*uTnVyxիE5.vmyN5ҏ.b< >oDrZc}[-U$rD$j {.TB2/^#.SjПS3gi{ݒ>'Oqb_B]\~gݑ&ft{w t\ ꨎltz9)z68D WoZ?u#ꇗT ,iCzҏNF<,iQL?ЛO`S,W}ueyUL+vS;3$~S' j#*eߩ]o^T,7Y+O;'=#e4@ӑ/rdbO,B&xȏYhuX#wvݗ C3깢L!rL:{NFN&&%ST˴}P<4Mt /fVwWkS%*4ҩǡ; Ra:6p`F~ 0cFnuF##G! E$Ks@9]0D Te8v,`X` N70I>~ r>ę["fȱ2E>ރwf6uw r3W)˕ 0b WS $x9[LkpXBA{c7$;C#@!MO/ X/AbAh)c52 E0"Z+l xj=ir$5w« /Urc3\嬃hD1w!av%8?)b|Jؠs~S6$ o=OQ3MAdpm:f2ɷ@Hq$KˡS YeLT~Sz7I}t _(Âh#t! NuM5exuH،x1bCp = Ȣ{v)Ki5)Zޤw=@0A}N7PF,`Ȅݾr<`&OlX+m$9CiFg#Zd= ̠W5o*oQ+~(F{.0F0Lw$sD% lggEw:v/@2ڿ.bϰ=l.R-:{RUp#V$BB Y$9Y$0Baod(PW+^!,E^y>9/yw}qzP!qO( CT=gd W o#oŸ_F M"#Q/IѯȷP(7b5. 0w~B~`9PXT?9; @X\V?, !tǻ4̡Y%ԴjH#uz:~CCoX}:No\{5MU?ͯO+r3nwfB` 9HY}LpuD(09ZMF5M.t+y&A ?,'L2򤨈2% `uM%;Ěsy~QC| %'bzjb72zjRXMI\I-)'Kb mB\@ḨOH8Ww~rCsk 3s63Q64r6[!¶K&~˙F"D]?L 49.5%Y =7pH`1],Y1W|rTMOweC/0m|L"H Qo\JhKٍU}_6HϵIӹ{n OO?|{e/ʏU{Pu''L٠KT2^fq OhgK ^\RQ?& lLjwxѬw݂{"YMв֞\;Tw}˄ nʦD֤ctB5YN7)S92 C'NEEC,PGI1YR PJ[rY¹}'}K5Uv Y/Ηg1c|I'SCR(NYd*R!Z2_ɞ*!hTAc2px3H]}=@]_Y0^}gwt# cOU EttAVJNSrY&U+UJJE1HaU@5ikwxN|ҹk5zC'KԘ<^-j3$/K5u&-Qp5 J暒Qr4rn,Am@7dK[>Tluٰ}së otxՕ`ߦ*P'B2p5 (\R' G&w5\gZ׻^<|}WwVPr9꘩{.+a%R!(Pq9g83mRa. $rt >SWV:rk>WX}rKEGK 2؀9ZG@$Ub\TDc+شB-h.YK}6(E[%XӸ$.wBly; OU+ڼGr꽳ݳҚ7y(n)(A=Ǯ52:ZVf$+̂J]#EOP)=@/q֯/qxpoӡrΟ}=K+3FNȺ :VMi ӒLC5vDS7<]~QmP.rF/Pm`C߽yݏ:6Žў%GVg  uDЏ fB)7^^Lu)6Z2>u䝆c Ъh](VED$ *d/FI  Œb#ngT-.uGܷ0n B39+r?%RC]9˻RzU.y;w;l`Wqy-g?cS_iy=*| BKZJO6>b)MSXT*4VUj^cu:ZvctWn`>ӳ~˴[9N;W/9'%j:f8#mϲLviTv:^֚ۖǔ.[Wd1uV#eߴj%?Pbv$k4mv!&2yҶ]7tG۝8 /t)]8IWN0׵^bvWrRsLyc?=*˷ /m $KQ TL eP`F80+c_ĴŦXJU$& U% J>=r25j"#C##KnD]=q=ɑgDGw>ѝW!p|!ݲ7=^Jp|Rq^>(9!Q( HaY1!;BG.;QȞX?2n )~c3:Q/H&à r"d(|!/1B?T`GMG b ֶj+}<Aw#` 'p3nI`ǃѴ(ȦG@=# :d Ry=[9}Ʀ߷ V|aStD}Hp GP''C>i>ԓ}<9S|P6%_z=P5uv1 ġP/r. ܙIH@Z^(%Q| DJ/&8X`a:$I!a xa;{K!Ȉra93aӡ@ eqqu1Syn-\Hnlf裆XT?go"aHi9C crY3aaH @FVҖECm<$ 1n&x k&i}V3 #~{Pi کaa5, >.A C+Ĺ!<20DC:oe@Xu QS|pS\(nD{;rPo,'!6@f A c8Lש( _6 hLj] 䛙āh'#NwY3a)X<,a&Fc42Q)mkD,Bg_ ܒZTO.P&6+%_e- / _'E}4pR4Bo`,L\jV[x~IvX%=!+9x-7+__)[T-=YsSn\V/*G5f. 9sPl8PY^X#*EP.r`i^|onI)k-筮EҗvElSecM셦Y֓~G>A^W֯;8"߇UcPwGs-}5bc)pڳS2$kw[4UՇ5wtO7T]Kzuᔦp?VM63rz\?Y Brs9z!p2;ik#|r[a[!g=,Ʈlׂw1XWef ƫVD)tL^Nn?Γ8rFJF7qxg3Pr|UO3& S5`їƽ}/0~_5t<᳷9h[C䙆xO$_TN r0󖻍\g'9YߎAZ-՘MOd%LM59U}v!5J@XĖ1fGyPdвp.O80v9f< smOvcb8fZp(%-$T,,5K34HDuQP"KٗdZN<9\vupdi}{>Q `>7ZNHM$RCÆGda+2ZB'pĂp2SHr] j yhC_K^hyb5b=lО# pQ,[8XG*cE_ODNCVNš)i8GU;ۈ&_HfPHZ!I!q"EmB"~>"pg#!(Ohg3aNQ4NB8kC{-!v,t5J d @T~|p7c1?#HKAo*V"t@' @ P{}dY7` b u: Z34b(@,i!֡%`D(0~N} G69?CL  [(B[= q.Ш=4Bqq%xg`]y=;{5x5,k 2)Blp'0\Wx@c2;U ._ QM;#tp[\6scc~pG{ÜʘT e0} 5alZ(~'gYb.cny8=לOO11v {*D̿D[!އ-L쑾h hśH 1%:K谺8|H!rP6 ca=,(^%~wBx/[bE܋=!9a grܑN6C=ڵQTUnE/?%'bW/wsᓸےRȬCAIɃL;8bXɜ!|n>sZzs~Ē7 ѯ4[؝>sQSYr_?ߓߑǷKWҋayu!CNF ;ڢ0xǡؐ|Ѹ#i{KcmJqkjobMZ:Oo tgw%;y}w,p>zݭB/M6小\!8D߲^7ZՐUPq̸%5:=iszGRUgcefobEf b,g":z_Jמ 獡#NvF:unrsԱLvSQpxWZy}&6K&w*簩2yCgu9Irr{A"rYLtފ#oserɁ`{&^ɛu6LfJSdSy:qMP\Tee`KBE~Cb2isjrqؤϷ&,%!T ; (]@{:!PRB( R'DD H*" qwPagȇ99ߒs9I$(BVK S%> ~"^=7y^as`&ETSYAʨcGq'y3좂s‚nÔ/.w-XOlDde1%PD _*s:bhqИvN~Vqt`~xv>ǵ6Ç_TSq4Һ"މDnW49z)p}8EGדrlD@`VFExߡ³JdT=bH2`#7>"ak{?~л>;0y&6)!3)l09l:`9e̒ (FPyyX햅#`\/X˜pQ<cr9Ut(PZ=/2*PmC|zu;+lrJ'&I̩ZgTn$VlDt_$ X' ڤEmۓJper7ujRzdYgg穾P3Qֵ]SNA&&t.C#I.^hz-;XO#v>c>N6nkRlrk}xg.+98=7Q; pa``4ݣARP.F}CycJO$ ]ㅾjQPpav:MaC/ao,lfʹ%?wHo, ןDY\$o4(^U5"kUfJglYsVXV^ R x_md-;]:fֳ{l`^`h>jd~rgc" t^hXx@@!`CӘJ*䣃t'w9O~[=>*~fnsK;jZ|[=8t#42B/kd@su:pPQD-JSь6t7t䌞[_Ce!S "gf(`*`Tݍ=.ne4.OH"Q(D'P\ЈhCFG t}JaFK!k.:7ict5A=Ș0EƬ_lWXi?M12qJ$ވ:&$*eQyPEY+:긺 (# ~| G E 3N:8ͺ;8Oz@5!8&cǴ |5;Gk :{nq#x9g 8fӸ/<.ou[@1?s!p@3 if o^9-j y;Rf5@nrv' tR/2}e_^S\?zqfLxÞ7$>hp ANAF\2r6hjіI,[t;RZq3~.Ӿg\^3E&$ߑN_%| , @`iRkCٽV@8y5l 9H:ff (wĬMқ\?'?z u:Lw~v{ S?xJ;oe;5CB"/oSlKlYk3)Nd;9ut3{ܟ1N|ʸI/WIs >@e@>AngkJXO]%i2Bӟ֯eǤ鎣2Մ!n 1!ktkk:K7J?(}\[0G}Eb=l AdHQ@[!Mڮ{W{zn4yX)(6~;aj<ⵠ*+6EI>9?nj3qf K10$H 0<_^ ꝉh4 ]\ܒ\w,_!5{omwrqqQ{/3=.iH}!徽jϾ&)id`Oˬc6'vMUE]sz=H٤[ ע/Kj{FܕXRgkܴ?ZWLdUE7pQ=’_DőEQoQ3C:~AW= 1%ޙhFIiV V\-[SOxgWVS{zTg*|$1ZpqXqU_-khbOc/scs^r⦅sx!!n꽫QZM}y6Tvnj Ҁ' ;#=T>)2U>(I*ي.Q$]qWVS4)u߀`_vP@cMjM給`:IkOk[ lZ ϗΉ#j3I%iCibVvr/]$8)NIC5Cǝ/: ;/1n&K `ŏX4jFtM@- aPBzVYaLYㅘk|kObX3ٱ~&6r6ȻOOG6ɠDW9i"ӽQEhƜ ,0b*e9,'aՖS3c3{DQ4H0)ځPqE! <Q=0i` 4LOt=.a.ʰ"aDCE4TQDU8 cPf([ .Rn(ASxX9xG r09ACڗZ1Jj ֨IGբ8hJ*\'8(>M\'ot b`8dLT;YR6*q~uF.J=QrNި?(KGyR$%zQQţGC1 0Vg်Qf@e;b/CxbQި$D*,,  ]彂w9zЧ[0OE-z c LZ` c16\0j #ڭaMzo0|?@uDЧj*[>*/x}P~|ݣ|ݥBY0< }c% \*fS1wM\H tdrtqƽ7jCd n]7{G}^kNtiD/5D/4Dj=|f~Rc5uԙqIDQ⊈ȾCHrsH }; #xZʴiZԱuZ>sx9||񐊵n.5YMAJ"KA 5 *#pL6#-pͶz7ӦJWn]Rc&S٥";H+,%p jHVJbe)Qa^b(,D y)|Z)qn3כ X)a zmVoRG,K)kȫvٕɎ|3LV&V%XU?@Uw(1ſ!1Ő(ZeW0Wi x6}=A{a.'M6eKȞ&!>6!$.ݙ[+tOfUUFW#ȑWy{R"wypьÝs8>Zﵡ7"fi-hgMoKKiIuHl7Iz7QCi n \+k{'B>p6?7{qevCd]@?ߓv> eЛbw8Gv廝xw{S;|)W[E?r/~V迒g9jfjk`s@=aSN3w1_3"ܑН]QM^i@AH ,!!   aȢ ѶNjkkGfܵ"hE .qj3/s{{sfW/=4rl4:&eUԉU'br(PV_}P#>NW8,9u >K~i]ԅ܋/a坟ÝyDUD^Rj NOD{Z\oO#"V7ЊwXN)iQOͿjr˹jʺZ\25/$7'6}&o 7}״Gm:i=ic l:;wP^Ս Ϳ㌊|QMD[}fpNۊ<zǷ1tmk|cm_blԶݜǸv ?6OvwP;;ye*pALdRԩ3vΰOJuvuO*vt/v^^ٳK޳[s.=͐^cHzak=U>GhùwK[w@9(+JcԾ"_L+)qZ;@U=h̦E;ȇ#J$ëpKi נZV7n7ˁp;8]~QBi8 c>H7'""zBJ*'T"}kC]dR!EBXd/48pܑ~p֑ ͎,xx5quoC('u"4c )d $L.9t?$\0Q ‚̷C|n Pݠ}f>g#Ѕf!8w W(|!g5q ̤+$a.9N )Br=H$$(H-@TPiwgpZwl!_t1 b v{ cbh01dU!$Ą Va8*Ĥ@= >re(>/}K _2AR]`O!tZ WR`HR~E$bP ev0CKq'@7' - r\>&@~ aأ+{X>߀8rɀ7(qVH pIj*$&9f̙!vh7z+bMDbGd*FU'9oTת+-Πӧ<S@?IH䓐0)IO0M_=_3[|5略3h5gx/4x57xk}10=c ֟.)~ HEnZ{4:ML5y$҇V'c0l{nj]^An}SwQDMЮ$M|[:A8n@,ҘHB>#/~|qĒ2U<}̷;u 3+ޣ&Op/Bh3Pxtp_t=ᙨ*рK_걺I& (NBQ(e(:\ Ź77ǽ#g={ U[Zm7SH!zʿE-!ƚ+ƛ9ji&"N}} {o7sY Rʳj)s\ΞMoBVkNŲZД!cR֐ȧ̻$VqSmDcYi@~<4VJ' s<0,bK%!dW"fŹbR~]ʀs> *SINf패';Q̨<Ѡs,AeԽ"xBBZuh)MְBXRȶ[ȯ)\.<9q]QMi$((H*"@V,f5@ !LK@(h5x92NGǶsȇ߹~z}c)̓*u96Ϝ e^*3WuZM?YP2r}mob ZfkVPa~RM|%Qz|Ǹ$~(ŵO%n %ZnUSOPj8=G`ߡ_ҥhܟ)<fA%z)U#%ܫefeE䶉ò3. ҼMBZ P+ڰ¦9$P%+2-%&DqlZ`ߗ+ ks9l3k2"Z*?﯊"you@+a{6 }jKKKbA*huE!j iTo5&#YP>e~L`C&ZSXQr5\k޸qM>ʮSkMmeCJ)׻_V& *W"5QXN< @>Bsoh\!B-"y3$0T`½z5:<̶ɖøPFm[ÉZUGJ>EMʪ|oHY8T*Wy-$W6Ec-sFF*"odRJ,48X`f:` -ؼbt̡Bpק{+y~š@~,6<_ɮdUL2d tt[Z?tBɐ!䭐\oJީr1p {@0uָ]r]Ky뀨1dzX]ksTu BV&*)LU*CqP|Ce¬Aȿ!mpLp~Wy z{ô1){˻O9w&)HWԖu㕧4K3!i03"Y3JJTϑ+ r|ȭ^:OuW) {hshF}p|f+iZ@՜#H1\%wc홗.:3.f$p㦥NNzj5y˟>?}?Oq$6nfDgpG p =I=@+B;D7xxK>ؼ04+6g|`rŁfڵCk3eO=IW_zFοC#fwv~Qir os+ k cLV-&۞˲?f`;Dx; ejgA'зhv 7|fkg/] z ٿկ{x`),@ [ߙ@C 8`64f1ƳGm4c5ȵ4W+jv8N Z] _;{ z LpA8"4```"pP, RFC` l¥zb'&jA'^R 4TxPQ HG</chm6F&Vjr l&e #n#D eSNCCC@:*"=S,kP%;LQRBlt$js_%nsFΐـ޻9sG^xWSo-Tj}'润 MuyVMg/hF5DӠDdEa0$L!g*Si=j0DG3t9G.ߌzFZd-tm%mӅZ!?9rNGؠq;EQ=QGNZ (M4LfΙIJz{zX[3ح ټkqyVcW\YgCSǟ"8(s9~P~Tx>좸6xx!IM8JEo`iǒ7g`Yûl;x ʩg[at5#}!UgєPp6i 6-)>$VG7yTE_UF?UcP=LxI ds0<Z@{-ΑR.¸j8]ECF.-D ǣ_:N N&!Ƚ2~"RVws܏^ZqO%(ߓok"!dc@13E4wкXD]c[lظ ]lq|,úՙ3 \+ֹM.}7מEIRN+g^3?*I1ބS8Ä́!9&1<&_b7r2Wi1_ì͍dIUTfgT6k^QIɷ<^3{{j϶:-畅w_u+7nJG騘=C<R}ZVry^).jpdI*/Wy`vs-q-[ 5gdBV.YMY2O(g6yK.omZ>a"^.#NzK\ g8@U+beV%y:Ewn_Bu.Ϩ<PD H)#LQA,"tІFpF RD *1XQp]f%'nf=G}s=WR*x-^nAIܐ84wQSQQ;aQP_B61xCTT0^,p̕_-]Qךnܔm^`UfWH+v)OmRIޒ)ܤ޹oEDBLH$ oA26.98]pfnt.*[;hQ]&8+e6lDzBY[Q+HouSEg|2R>H{-H#BK&E20\ߖpQ )qXt)*+4W֕V"ҭ &ۖg:J $\IN^vNWFv -h[i Q^R"K0T꺭bQ#U+,-}).$)" &{d1pq5k7٨&+46r5 j:^q:(X̝),dEK9wkE5/snAph}OQQQF_,Õ2ڃJwfm4Յlț5{V5d7DbRd+>6)uSu墈&ކ.uCq~hН) Sxgz7.^܃ZZi>5Pt:2e^iRuI*Knm7rKs=M2 JnHC{p OpCpC:=zW? -4 ]@e*{磤ϖ)sg.VY97[pp֮(f):v!;ikw۪n{B.^R=lRMPzA]H-u̕IrbVύ>u4BcuGLBd.XPWvﰢqy7N}7{;s& 9:t}C@HĶQc$:2%@`u#BF6_s*ppqp5~'[-LjL.7h2h1=D[!b܍Y?.b/Qߪr#icؤ#7&s,17]Կ+_6dǁ\DU#c$&3+Y+&lU}'|2爦4SJM&-m):S]{ýqwx+}P2 d.W)6ncmm,m ib191 qBG|KV@E1aɂ:3jQ!9N,vP>'Sߨ־XԤN]O}&gI}D]\wa% R)i~=>BO͂82ٙ!.g.CX~خe6JlQ*\iTS@H.!y`b FdA@A 4 aJ"cD'( (
Pϱ+߱}`:{uI ,c`#ց]{I|OdE?Xc{< 8b¯37*535.ClU4-B 8۰::paQpڱX'v1e| 2F9#a[ lA{̷Sɥ,s0\; "_ h@ |9 fрW2:pb5 a|'&Gq b{̽D|^'Fa7BMhXt'=o) <_YؗڝW(5ܞdZnQcU!-[j!.z5{%-dp_jI:Pw1 d_hwWеL*D:臕fJ>Y)hץ(Sc +e&Ir2j}S_l_W- TC|)3I]':&ͺ(f^zLd/.XBVJ/)y+nd)˼hYh=w2٬ )vQ,yvi%)YaYYWCoadbτd`Π6AfҊ`u ؋M,hSbE nU/*H,X%%.ls>abAǐSN7=p w %!9kųx)-vbE8{`u,= *̃/ŖŔ fL7=[+"|WhW+BwK' ,:}mDss^R(shRX\)wPCTffU*'EL;mV1$bل-mWC_^!S~\[~ uI}q-v P߻`G)@N9@ΡKհSk km N3<:fjm0ormڹ55K֤Uk|YxWWENѨQ?Jw0%wrf@Rɼe%ǘג3Ly)P@w_wOqץu*N:ڕyw[6~_U%1/;{xb ؅>K= c.Ul&׮VsǭX[-uMu^uY~%U4uyqx"*ʡvlC5ިxxŃJs`Vm\clTf3iwNl׶ݴMnc3w<>]لmqN `TRiyFxs.q|r۵yyo띭}w8>9|nrolʖҵ-ˤe=UΧ䋜o)`"<#“QZ2\b$D+ mk ݾKvxr~Kqf(/]p6Q43` (; e /r*x> ].K< ^9e>gx:,fٌ M`tWDL+p`_+ǐ5|U"wxP w`EĄ+͸EQ"\!dAל8#P ܆Vk=!㼽ay4gTh֩ȑtG] ;z6& ,}sQD%IV%~pYJFii~Nu?V,'ZBsS` 9}yt{\T_b޼1zDw5Q]_Z|#x~sKn)$U9 48U*婄\C"⁒RX?"ZB =zOᨮFgyfG*˒V{3f{OBlMz 4eεFO >pZ`JUD/y:Ľr y̿_ # C{-4k-CF(^ԽfjppQ0f|7\^a3d{wUҕiM դ 0ь]}QNbWT.ŪUV^+1\"h:еg=Փp>j -b oЫ*CH׵Gh(MAcj1:QLtwxBOg tZf݈kVߙ^b]jP!SXIsGN/l7O3y|-0?a LYB6b>@p-3(.7RFvsնD7ó2?YWyĪw6vXhŽ]ٖ eɳјJgg]ȳfPQ%L^`}a`aQ PD0÷Q )Fal7Ls:q&3sޜ0e \[%%I8ù s>pٴi ]ʞQK @ ?IoUWp㠻6DC{=7ff:47BsP u~ڪ`v? lo>mnVGA '&:n1ߒBӡC U(| YO"$=3!Q2 @ׄBz=HfX0IF)_u@wPlP ( PC2hM? tB"A kSRsӚEs@a=2`8Ȩl3q}JCHb >$L$)^>8qZt^wK-uD'3Ÿ2q'vABpaRNH^ɛB~ CXHPCnnDOZu T 52^HF"$W셺=W3uЯGnj6{ΆF.f#W'~#{;֫e=֥?:CןdNvå_…VW.D.rah+i 8Jc=a} Xa2bak7lcwݿfwܳmЗH=_2p5YIr4'jqbjQ3o7>xDxG#G퍼y13K~{ԷofÆ_$TT.nTwR7\v43g7p$I42w7y Y<=Aߎ~m1[b~(h-hHh\`%li<?"ﻝAtWΰ)83Aq^aQYpZӀ4ۥWsN)LjK$4%Ygu 煵Dğ |1SPk_yd`ZT[0VFr2zeN K<׭EhJ3y5YxS}k]|tDP%VHEfuIcQؘo1}c%殺9Us0clƐfӧveٷ͙.J[}FG]z%WPt!A|BT*˗$S^X`EаtP7)r>0Oc m@o6Sm9`ߨIvV8ת\+Urg̬-l/VʣBYNxBKdń3_ ܣگ逋"`~ǸCBGGAӢñG%8XrӜ+wcNg3de7 }|aQ$G}%@~HJQbB'Ry"es8Ba+z|tٰ֠iEU9H.%:!_YW˫mks{H=%Qi/dj )Բb-in({HWFG'5ԗ25e;8a\sI}iqp)2t|b ~VA `T;!V.J亻r'?$ K߱!u="!{KsH_[p"$bP[*( b ݜB~xmuSv%2MYY^aS̃$0(8qKQ[Q&']%3ZZ:WtCY?֠ȺYwrpnvC}V}^8vw֕z&Vk}j15,(-aW¨/U V]uTz>+C4-(lA~*h7#};jdEqmim2Gi9%5\y볿_x,?:_/aa ճ`>GSʹ -]=m]]@^7^/dٿA0Xnb>/!W[cv 幷%ޮB:B:㦉fz~t.tV.=Q7![@$oGx(3͉OF"Ʋ9u5ctmim##?r>o<Y, Q}hPv ec@¤b=%F:ފ] gBgb=3) ΙmU?nqxkzq 7/ޜS'Xc@ v>ʵ sH:D&u9_[sc>oχ`|mq2oTh3q6٬܍~Ivl?ᮝn9~Wc2 Ng9ឋ@e.,x p iq6.a]xj_Ǻ%,e%V/YUKʥ#LyFr6#Y,/<爯1E#T{'trcIICm׀759`w ﮃ^ł_9}PV )x=χ_u1>FH}oM+@ lzx> չle D((`W% 1`A,H@Dņ(< C-O1D!ODQDĂg0;=;{9F 9PY0s C Yҿ#DHNb:D X ACFr<(g3J,Z=X=OZ8 `] h%+"6!j&;@:5ͣ1n@hm }k7jGK(]48Zw }0`W.e@~5Gn+jM :kRsG?:=@ON}DoG=b{}`]$7bu)bѽ5t?+f 5(F?C?b>`hUŗ`RwŮ`0o4H%$"H>)k xCxjth(m0k0QLɼLDcOWI%KrW߀|ExNyn@߅BV5ջ ]x)[,<:t6ᑬt7J+&BZ7pC]h2ehTsE9塡|(T7Tj :U3PŸ@7QMVen)wr{q]yMWՌCkp^øZsƝ{=fKm`f9/c)QDy P+Kz?'\z?#qnU듸c>;sC V}ҮBܾUXfLXD%L3lw`φ1H6G[g\qǜqy,wy"y_sW8-q;-v\#,s:Jvv:;9:wlqfLm|N:h{u A!8bnqm* [u_epKؐ**2,m7֛l1l5)0.7TJ6 W\:dk\^V2Yg`(vF#9. % }#cwJFscS[ŋ6-X f%YZ=_ڽXU9 ֥t'+mZ#PM88>(cEV~O8qT oDѺk6+Y"ʐ-ʑg{fzmS,maeye//L:}?>4sЬD}>͟Po ;`k@xry`A1Zѓ٣L2eit,ET-RBg}[~=h(1:ӑ (X2 d 3lj2|/N&I I?Wbиlqr1_5׸S3Xejf<.iSGCp" 80(ٯ[u^ȉ̘AȈҢ}QXqR9Ӥ'S E ]|j)ǻMk"-&1sT?pjPEq췍Ҽ3NZ,ҿqBj;(v<.@0wlpvL8!f)xy\ԨLȵ" uyGEuqwgfd`.誈i*e60 URUZb2XYK(nQ@M\)GO-hknQ999s{}b<31=uO\u]D1D[~:s[<ס='ˍykP0e P0I(HҜy2s&3.N#56CiuXShvNޠGGp>36o_kE QY|7jdYc?4bIQ4I\tl-4 6)1D")!ΐc/T+b۵ \z/NFŋ~>\3T`'ٔuy%&G,5E^rR!+ea򗤚a6IѶE $}LR¤r'Vaܦ 7w 3wY`%Rf5Q|'&`_ԥ;I 2ۭ^8cGbt8Nşi kܭz a5_b[7 W`=.Z ׆]4T[]Mo:`+@. L p? f' iA̓0 8 ׃S {t{Ȁ>-fn)Eϖ:4@ro9tXr0y TO&`R3`Q19*hZ]nusp2Nm U{0C{2OAy vP7A%PJ^uqW}@w&cN7sG80u p>-*ka{l(H/xArA$upup}DwPA;6yDt3=S-iw8O.ձ]#Zr_`HD)PY^K:_KFn )kp9}5O= G; pKŦ@ ؋+p By:xyDM?} :[KWO21 F.~EG+#ɗK q_po-~#nT]:˪^nb 8K!N>C<O}'iǠd[[k;ϯEf\ wNrgũ!p/394L`""}*/@%Spk6\KÍ8†NQp: jp2`9Nű yy9t>`:G}vm(/cH?5'Ip?P;2z4.c: 'i8ڍVW0.bfzWt[=h/ n{h˸_E zyɓTb5 O7?OEOHhq`t Dg)`Cʘ!]Zv{*vkphsѦŭ!CΉׇ7OZ4gI{Y*w}? A/zPg&2S:Qh MP3}:5<@SnT6hZ4uuqUҠ%YVkNq+5WSHOQZ*HyYITꩤމ&8biޡ'H}1 "'b{d86Gji`6D3-vv]m / %^^%^DR[- & +[v\^'_H {BWG7&3ҿ| )-F{lM16ͱhIbƤ2l}C<@,L\5G$jW3NpZzfyմyQG}*.*D=P̎NQ-xyO |JOi:D'mxZѬSQ7uOg x $>[TujZ*W+F^kSRΌUighUjP yj9n/L]ns!I!X2)!K CmJ,S,HNe'e%9ĕىeܬJiybLR^[(TĻ|~$A& \9 4{IF ǪXnZ㻨1b12h![\npI%\BZ_ +/7+)ەN}?&zAI(^s?dN~7_mSJ<ñ TMAeZ$Sa2s [jfKS\q7K]*Ӵ'Ԥ1n},)F??2 J/W袙h!kS.s(N9]Q;yIq#IlĦ3Ein8U(1} $pGn?cUk(b,% J,v-I.. Eu݊#ʘOʘA'GHotE,9g0@X3}9ݓT84ɬOOZqIP/y_,*ʷ8o{PzN-gߑn1>c ӧ#% iJ-,KRĦwIp^4;D!:gk{Re܋$$ӻ0 Lg6)C8cl7FgaTV?x B\,Il|ଥҨղYeY&rM"<'"*WB[+XIYIoR٢M^s=\wD\C5`0D"83ƹBqfL7JHCvKCviH#iȆe!Ԧ.e.I ^ ̦{~F`8[֘B99c@"u(AxI$ %_2JF_Tf!شzS۴Ne*Kv PrQ? _H \ la d5i!݉tOR r+ZJWޕeE9X 0e,sòb 3КB[m(xuQ!b#IY}XLa[8 l5N /xF6#n7LŖ-lڶ* joAe}u͏Dt##s*g16Jҿ< pnPBUNP6t>2 kgBCfQttR@Z| 01O'06 z? 'Q@86!=Õx-~ 0h兠V>xڸ\[ 9/G0+"<5`#Ha 8iAu#y㼖➼ ŜG;/"WX_B_-'{9ȍN2I{F(;޾^S@y\|N u'^5Mw6'݁t$jV; . ={\\ ry =f 0^-z~I8m|E&w͜>ɤDtح;DM"P2$ydIOK exJVғ;؀DW!-tUU񭸆2Gq?"G@ο\!/"o™89iͦ=zГQ3pkMrpqUFjFgъSm$3‘O"%Cpb.đh8!x ܒNuY"o$[ TY:Sf*/G|6Eр&E :=؝~@JDd j|<\5x]7\uK18 Ψ)ؐ<޷=||E>86pcQgvǡJ? `>e TNXI(ћ`Pl67HCNI6ܒCHrCEIίx̲\fimc?p}a2lEa$&4lLY(6COBao/}\)A55J .{]8..]n r[ۊ(%*XԱmSӦi3δv!mӴt2M3Mil/2f9ߞ>f&mJ`OfO-'_Ʌɍˍ "ܱj}6p/{Vp\qz܊5)hF+" ӚjLjIMs"fÙ!v43gNsCܠ"\4wYSe}~@DPCܦ+;t/m"hUc*7'sMέdFNfXa젶 i{~0ק=hgnK.UEg_  yyBrpeCmnNSgcDǐނA}ۘ~#ۥ悺\noӝQ ~+BY_٤+Ш>BIN1@QD,60aL@ش æ4g"dGٌt*tUVG5U~B$Zʜ5!M^Z{&Mpݵ6W&dw*&]g] ] +"\F5uWep2CiJi zE{RpqbS#uEuSnCw}jwςb_c٘B5Y3xwZ. וywy_sjJ`&FOy]7Gif-PO՟ f"1j=d\?_T䴼n"[n~i~-J#0GLQ;;ZPŽ0wn)j2@eE~W9tYV2s܁wyu65WGyu7HJxZ)st~P@1лoA^nhRqp@P>CfdJ U!#:¤zq65qMMKr)=kJu.ӞF D5-ʞ[ d!st#2ƶc8ia=R|+,a_؉pH0ç] M&)|II74eָLZqhcq=dLO ej=N'$$O`fbI"qH+FB3sH\3oFH28O1p#Mύ!Z-v87 dRLL=e\,'`f< )H\8شhlX|s/#~qxqo n=<9) Ch_$uh ПfIH^6]p) D"\ށX"vŌu+XEJʕA|-p~I|gėPG@pS%gi9i%ҿL/וP1M}SQQB_CRJSNhHER !|dB29>eȲZfǚ550żmw\]繟њ@ҚCeSeSE؈xxu`E D994|Cݬ`@c\ 0^_o !(`$' NRp>ٜ69mV<Z[9ɭ u;yr)ɘ+ƫf0jRӢ P676 }@R;Nl_lL:X;:8 u'F7yۀ܋ouQ`= PSy *_6XAEl<sDw' L7;0x0ZSלV/R"ȭN'w97?=G3sǼBOٌ<݋*%_꺑aуBFqd$$o+%9V)6 *5 Gp-'.o Y]> C+"/NyYG(2ꬢ:lΧq&9[<`_Gz)s 1'#`uQ/Z)ʤN`uSJY1ks4r.f~/Ȣ!ӝ7:WGP3ٌV"uC.b,lN%3_gpqoZ躙Y<8I + ᧾dJQϟ0ԊhI>K\P\͜E140M\ts :k42JC斒e.F` J.$A"gѦV84%^:e^.R/NZ*4؉zzu uawc3vE= 2,wwȍ>6^X㴱;MI(M"pX2 ʏqPze6>WNbOr۱t?63/QmvU揱-xN,+? bIaJ.l?=*q|]?o쵘ݖѨ)nyfQ%*W`U56YQ1 ^GXmnzxj3%Gyg{GFhh:!m3;m}PcjQevlMv`7v9Xgka}9VoE{X^nA+'C %bY(zԿ=}bi}z5 56t38zc?*ð1ӱ#9JYXR<,u*BSPۓ C%(Cg( <'bg$LCRimE/R. aǜ|+W㬰@=ծL?2ԡHWO,TcNiB:[H+~vHZENһ\b͡\l{Jza|7[ +Θ_!90IB|B\`/.> \/E(TL : <&JAigV29Rz)d>rIv!cv RC⎙!c1# !a ؐHĄ ѡIBTh0=4W -mSL '-kzaRYA#[]dK3f H0$h\FFhM04ᘮiBdx0%|09_#M273(Bj+7& "0#2L*8y2T2E$\Ct2_ۮfFmo^R=|yߔ-}ԋRR-)>Ϝ"3*{$efEim4%wW^zQM ʶ&fPndXVL#1Z[W,2Z2cI>&6j!<@ۖh!Y>q4M`,e,x 9*#fT{RclO8MIE,%eF+amڭku!u j[5Vm8պʭVU_Z-mߺC[[7䷦A-Vsy\۾HJ1eRl4^kЯBnfs nԢ:D~aj^)K`eổf+]M"ˁ샓4(-wZ^;ir) 㞓nwF[Zi&sMk.:⽏B8jijpkxju-HN~spRb]05g9#э܆AV xE{M\0pvƎ4Gh 1.::6zIù:bQG, r/ֱ>[#>AVG%h8ٜh[mӝihml҉GccPϡ_ONIt=.9_9%tzuR glf13] &;Jw>%}iBPWf2PWIU̫8rf`Db405nt;xZj~yl ҧp>HKo[ȝkrf>7vߐ@a5쇃L  B,$B&'fѿHi5\Buz}M=żtC:~5V)@C(M44sћ 4棱XׯuSDg-:XsE]>c}X+]`F>/jh   M@'_\h(Ac{)ezO=lK59cnE鄩zj>^TPnBHȅ@B.@!B- !"BAQDTRuκεgzvvnݥ]9o|=y2|k1;nmmW׆p%f.Ōb^pe^wqbpGX}qQ,MB!R}[;q+67Dĕ8.n0np̆vr|'p"~b!MX‘8p1Slӛ+,ejwѽW6\ڔsxiXJ$+܂d'wc.#2-޺[b_77 I}O0vG1QݟP{WH{1jm }=]8c,Pxub'k&j~GIF(}Ls1.è( Qv 0 .E!qbz]BgNtW2)ZXKM於C~ʚ%X$|@5敏)pS=e勔Ǡd#$Jr#K*C@ZԂvenx:) -f$sSk48?&"fE9OO5_{Hcq2Kc^2F9)_<Ay)(ѡ]QVE9*(Mp+Фtl ;|Us^lcQQfU=|ƌsdL3NY)GQF@:,xphRRW€ WzPW [jƙEsjN1Ǩ}|H@1jO'Pݛz49D&N7@9z_ЦG t|4j JQU*;,:7:?L>fԏ /1*Go6Gg!=GrO4\Q|̒մoѽ =?eiѬ_> Tx `5\@mj#5uiBuc:NVefZq1^Tr*L#NKT֬`o+&&uh<͔zSy(CC xIe_3LVe(%dtSV@uʹ[hUIѶ X6# D(ЯvB / ?02xmY/sd?q5iݧg&#?E^`!! rB*d lnFf2SH/!-|H }z+NRi2Bz'6@m W7Dd;灼i06@0{]1K%5edX+aXo/m` ƣP;612@:Xvp {KO"ޣbrϯ.˥*4q~d%dԳճ|$$;G؍=g~Iރs{Ecpnk*>'͓|]%5!qw4V BB%}lN:PBp$aIvg9s~DD$<Ɂ' =Q%_BfjDd{=YpN')|FLN3,19%]`aB.(}INPWʤ8xd8Du:1>J;돟Ч[o pp p&t;1({@}>np/p_b?'v5Q5M+[4[Rjcr}Ǯ{GﱷEsS{^ =9כ `.AFhC+qrCH_i!eWk2[EB ;ɳtUvZ+~~vuGMD]쟀F3A#bÎZ̙m e|??[#(FXI 5hHKS?(4HИ9hb4qR<_Zق5b.@dP+^?jFؤsbguC |h4)ڏ$/{;vk.rrkmOqR-Yۤk#ވ ?;@_e.hza}D Bc>رlÎر;ꔏ<-zUv5ZVJ*T)W x+>hv@Iy _bh4ICgV)B^fUllF-n~TTj{OĎ\봷hh{NC-U'5vk}?UV0īܐe-5LbCfaJh*w\*v:"*p~9.ϔbUnܜoGEچ{hsVDh_wTb-pMD(9IQ&1S9DqpŚjdt/ a44ztc-Mh`yн\g̣:0+*"EPEaeXT7( .ǚb&Zq_c5֥1xXҨZMD? \{0t^>|߂3s1TG9y%41W1~PŌV1V$ٍ6es[2͔-WJ3-WAMJ`?fr1 6 k`T78bEqgO9+h`U9Kq&(%a,pFIJHc0'+ ?:cx#%S3=|K!1'tTN쉽/[P%5)J)Iq$[d-`.s\ŧ<+SJM2ZbSI Qg[)#Si)ZdQ5DJH5ʜ4LiK+Rm9-QtzƦoԘТKj0;1Ue v~ۘ7m]Č~2g V|F2-cY1YVEg56@cehKoPxve G r+^eti)̇ ߶LUvyWMP*սj4ʳrUS99~nբf@ pWKXN/`^ @8)a3/ffl^˹-~**uv4{Wnuٕ-thFt2K` Py;Nn{7M.v77\ĵ7TN(WRjgpG˽_&h'?mM^!A918P~!0qGBAlp.|7ݾ\Gx`K:9:A$'1 G f:GMhI ކ* ]{.CvyH8ZZg8 U|J}'|/Fk~Eo#v{n;tk`3?M—Nñ=]|m--M< W8/t?úB9sIm|y=C魇 ЏF{ok:KkOB<u:=K[Dp\џDlAOЕp@F=+1ɤI *!q|@#q8մNjB)odJOXWGta(V2:h䳣:FGqK]k!*WmWxvjgvBm1<{/H.ΐ}"1++YO䱜LYvNATúuLM&آMG2ӤO<JpW0`6``6` $&!IsM4I&kf]zd=Uuӎv6դQҺN:mkUv޷dz{>I}R/xW%^սŋ7Zʥc:\G&dQqXtS gb"㙏5;e2|+ O =.V%?{ewV,Y ,de#l33a*pN79nek4y g((FsP;."7)R.JŎ].%Yˏg m K(dXѢV 2X4Lq턶GIPݦ2=Ke6ҿ7Q׾H_Ny5K/Ib$SCrM6MNJ)&X:@w8]eos[<7C_kҝ6GYyҾLh_Fͱ 3k6Tmqeioi⧣"D{(Uh:D,xlO}fۯ_\DVyFWf/k\2,'XL5v IM[aS4,d +48/QxKEDd'{VwQi> fѩ6n5zqmIޚNuk>VֶJTzx#f(-Q[仗G~C(7_eJ"(YRZ X;TvPљN3eՔ1[(80EQ`#.x O~S U..HgI*1'k*j;ʃ(`KO>=&z(쭥z MIv Y =DFۤ~&~OF'dDwK렴ĔDPKINA? L!w("d U9@pA҆GI#ydGΈ$ ?KŻ$ }*wJkYEHM%ZcUVQ[cȘ06HD:)y$OyZ'$bcxMćOb_O7xG?#~<Ši1"ѡ5UIJQ٘U!}z I$m8Ms`/68e|/Hu^dD~@cL<0""2 * 5"(Ȧ(( (8* +˩₩1n&DQc%i[5ǦMjԨI44>99=Ǚg}T:++Hϖs''- ŏ;q?>Əq)S&ժt"_u~uyzYWz+TXGO~>/~طb-v7R(=zB>C,N)V|^)P+[]G9DFx!Ngu%yab Qh@#`52yi>ZUƏq@Vf*%cDuX;;M,$ǩW5Ġ1 㱟LVUG$oV*V[rcգ_Ks4g [{/^g A' hEc)hdc)E ZV,""[.v._iswr# kG>>wpelwUSVw JhYG%Vu.ZꚢZL-q"|Y܊TVjZ֤y-*s?RwTcxJ1lD%G(1,V aAي /иrF,؈lCuR#~=;iAo m 1 ǽl09C"J (EӸъQ)5UkTtFF[4b0dǼa1|`!vS\7ya&po K#.M ̣>0dQvMvD}}GEepcĠ`Ԉ\Ƹ5qiFkUظ/MjzbNs5MSTk7IOsf`f{{K9YeIPfRIIHJWzrҒR%SJMJM]j%7)MG`A,W}́z@y>9JTqd2јTI)& `ҧ)1ݮEXgޭX`>x7e8نV7m\30*ǔ.SR3(;[9%ʩVdLE(}t jK4l)w)We 7v2l {Emg6k|m~sn0(z8E BװH~Rr_&,K8p.+*.]tqíAaa= Gw1]_5 ͩPFs([\!k\>ZiJɩm*si-䱎jb`;6{[ Vf6SDEVFr{ 6xh$2.c}cc}ǹ}7TGKH1Ia1y5빑oz v^x2 3#jrK y36 Y+0;g6~K8N[ u?E\vih2@o!ނ18I59͌# </W/RK ե e_&*F;Djǒ7pjY`\ U \eN>aFї2gl MVżuؠu <=w'-]U'mu}r uvxa}k}Ӹ_C<ω <74}tWE/JD3|t*Ш-6KANw}eE|y\Y"qyW(29?9<{=;BDzQDJ^Gt<ΐ))y|X5<\i0w|G'X4HG# |4J=ͫ O[;i$Nb''sqbױsqiRM6Z:umU]K+T(L\Mh B6&B Ć m0ډ3??~:w}~{cc/V0]b -|Q_75O op}$1s4WG :kѡ\i5ϫ~j%?L FX0i*\ъvif/hGɋ*ɒ5Q&>d eEi׸?-Ye,-5jԪJ-ЬyC =ij׌!ƔiM5a<NjĮ1]ר鞒ה0F,Yڬ^FzЧ}c~,lZsLf1;5mnДEami21˘F-Jn\U c>nzRqU Zju~?>./8l>Xz{f,3qږ)Q)&iU֦-,xwnm~LъksxUa WާyEit-<3M2s36{f 5dw*nנݧG=9bVr\Ym(TO5wU:koÇgZI"r=I8ce^FːH_mUPY^g8[R&Au׎*X;EuTo͉[=?kh=Rioޡyd,}TckDָTߖۿG.Ljj7T2|[/iW?ճ@su~NB/ ]m|5j RC%w{jc@霑sE՝GT*^eٻހ_p7ά濏YjN~#?yJ ZUVnTTeOLCS-isCO,| $[[&[=>Vy54ИVA:R#Շ}…rn1*bQe\HnUCe٢CFS]C;'Ḵ{Mb?9WY73hzϣ3N Aۄ%n໣RU_*KT>`RـUA6 j`#e )>s2/]_SIǴ0:tf|0^ B-'F)ՃRuPAED6$dLeHKɘJ 'w([*H^T^r*7C%~(cFgJ D H~hObl3ɘ*QIʤ&*Lժ ըT@aتuL.EL%*Pl܎3% Ce{ˇvLJmϳ?ݿ}" JH%:bv̠RѰ$H@phĈ`ּ>5&ym xyX{g(b5 s/w)1WΣ0JWAJc6ԔG1 #uơK?C"<˚eße.o-q3<{>Mzmx_ShB?ʹ |5[By=g®r'oϳ.0gK2{9 2{2r{ 8|oaׄnZr1xvfK04&{CYi>>椏 ~q>J%?A۹B>zƸ%9j]cF2ur9ACa?/~곟B;i8'U9@mcAg|FW(ćW$ ^~Ea{3ظ!'}q=/XRl $Ip.G&& ҝjKt>oKOlH1ӝS{7$ۘ~S M̫2ґZv>Ϫ@VOS;tF=ğI |ݞpOѩye \0]׹ i"'kL>RXf)'Z:%t,ev+-H|';!.'v5LqTa'&3iB/mt9.hXIdn9L?Ev( ,r5^qOCr1/$v9u&q'-[|c!.yds.3: On1.̓ي U2E|$E/"|,||\q7˺LOgTT2CeO8[S6[.R^/i8:4D# <4(GJ31yJ}P\M曓Tp$:`v [6 jV^?!=8-:qHCh(fSwԫMԡAS4>. Y2a ݩЃj =!vA@{ql5[=0fO53\6;ܠICtgUaɚR{Xi Tkh79|uq 5D,P}JEnGBTaT,5VŶDٜ*e/Hy&)7U9]N%}Ik2*#\gsó֣T= W|$^h)Ub{ Fʳ'+מle'I.;FY)LTc|Pr:#x>3zhL9eHc_#yVR!: qq)ˑLS,yJO-QZZRL#}R\ z@IGeǕ6|W<h5 ћȅL|}^d+ W\QhŔX]tȑ_$4(c,J*t=TO\K%7MEF4 gR]AQg]wEЪ(* -, BmăD3iFUi;1&ͤNkNc̴L56i֣c,d?Y罾}FL+`WJQdv|dȕQ Jv\*C ~;+ιOcqX^8V±`>( *id_+;IFYIJdT'[y*u)ڋ'/ыp| <<_h&q;(@1τ;~$J ~dʼnJ**@0 :3"$ * !yURxP JlL_qÿ~Llu1JXbPt|R.Fz#ìCH Njų#aKgpK-/p PH9ĜE̓}O?/Q_µEgKO F+k+:w%KF.(\/Qu`;ϰ-DMT\~vPBsy&1O _?f4`9VAZM.?Ppxs{Ez3r [d!m\@̳p}jΫ)$C7XlaX?X6N`LM6s6U|RMySpw+TQ"͡|ի^3uK a·A? XWY q/O=r, w}qKCM~'q~g<>,O ڙzb/ku?#|agD:a/Caq0&Xku7F4(8!8G䠿&M sA ";`4"hu&x`x?NsfO8)w /:r΄;M6HhD9pɈH#88rpu\,b%% ~O y.!MwAQj@|ν:+OQ8|H❧I~E?"sphBp;C->Un3o>$}|QX5=:7j ~{=Hj=k? Ux3z]W]Rt+pk>\P\fFi3[GP'^uz|:z:~CE0-{/J'i : A ƸE+Zd$,%ض㷋\DKè!A6]Tyxscu9/pޏ#N[f|a Gb]m;V]a;l/nvS<7v#dr EA+|2;17bۊtf.v#ʎ^DZ=B]F yBz}d%,ã%2vb\lQ*'a{:sυ.#U{~=7QBy5df'ީ~.=$8#`; ۓ=beد~ ?:CZEKo rzSL9q,Ǭ`#vpFHo~:b&'2B". 8p@wtұkuԣj .3HxU32_ Vq G-*3VÑG&ȃceTY 1GT5Ii De=G(\jycm+U5qr ?'L84^zJKXk'/SIF-6X3k,!K.l-HWMbHQuOzU&.UUfRqJL/tBEp |'6\p-^~w[62UcJӔjTM3Te|S**7WUV㖫hjͳUk}Eso*!=pm`cmzk.|q⛃SbUeMRŢ MI*NS5[ֹ*ZS;IyW)7urR٩O+fL9p{HC U |w*_ԖTRST:A575Kslʳ*VDEʞT5#}2.5-cD55,! ¿4`$|e}oJx  b I5AI*;œVYKfnVbUQyUۺuն]ﶹ]n9 d'y^z|*|̍W%Yety-Y*R OGrjU(Ek &-V_vl4~PVg~”߬8Ki̥*PfYI(/TzT) jhQjE'Uo@ɾA%;Ċs2T\*>W?a;Rԃ|ǤJ pϊ|THپx&')ʨLiԪP*JnRbuDŚXFwlU|^U կ+|DUݬmo W TP *1Q <|.HF3ńٴF4P(NiO;JN3X3.kᡖ&lAĵ)0(41{$f[3K7E,^mfv)##ψvl/ dx:4z0^oQ&R1&J ȵ Ny=/亭Mԃ>!g}6blS|s>imd7yp.]6E,`c 5YQ>9fq/r9br9c/[yfg0% .mm,o:HCYk7f-Pl,`'&'ߡOQ!zt~"'(~sbϫ5*]Msv,!{_3hl<&Bh-TlDŽ0 ň2=r?F(8 a:tPuVr4%-|4.F&1BJg蓳q\E?OAr3!pFpvR#<+;<au:Qx\(.A]6}fJ#+{^8i=syS~}=*:+G /P]Wiԟ%.~J~B.i\:ops0^/c_>Q\f 4G5t̻jL?~ʹy -JCxЙOEh47jvP}hũ߄3,ji0)(' L5{ #u̼M`pEWhT՟W<~`;۹v0Ŵi%mx} %rǘ as9jj=7{L`e R5:%.Z;}Q`O#6Zm/u؞{݌VlEݥ Te е/iVқbX1\G.t욱k.l{]Z쇰V+#]Lb Y:1~6ktv 5bׄE g?RX a2)snM?ӳٮ:e05&9(Fd}{\,XH.&=Fڍc~t!셱ۦv,n/f:z43UaKH}$A+oX&fp:9/:jQ6LC8JdRruaĉc;ǗN8NvlDZs:M$m״ K֭bBJAVSV1Dm0؀A h*h6&.ZQPG'e=:3Hì1V*f젗 c%Xz>A4lsGX 㰔gKH ;;Ѩ$:u42to>.& zg=;6%ʯc³x/U|8fwcniL".|5ը\nsL]:Yuv0-WxZ(m٣fA,ǔVr vM{RaG^{jSWKVZliDҸZJJ[;lWʺPɲ1%(n۬mjS" ۧ:\G8N 6 CC7]'caVDY]-vJ~%uJأjw)UԱ@ 1E(llVG!~*h<G W^k[KBzNUy9-粼u7 ;\MZL3v@gi%r1O5m ջ+rW]'OWT]HU+ީJ.Uq}\Kryr{oj'荓@.pm4$x#FE[תסץjWU DJ[զ~UT㟐ۿA |EFpJ偋rPOtk#Z!kR]'D~vy*婩&.W0#gGڅ2j<4)Gh/òRYcm݆]h44O#"YePP\u9rWɨUy}4t'[d"kdlUidJ%#DN *\d ԿEسA,$!=P ˀ91B4B6Lֺ"og4t@ mM@mݍ>T𚱮ib8d6cLll&qc|-0'3/<~w4\|tzFaɪ{Yנ6t-#Hb3ı8VjXCc1dOT 3oce}~z.hE75L\=5-Ch,I5$so%{sIFMı817v0&;XTVfH3׆A!s++z ))"ö[/:@ndwt/ ıv?~ޗ}S) kyR{꣯s"!Rt{^sk^nh Ƃz8K!Lt?I!q8feep#TxplCN.a0UXR|e>oH])a0K$SgX'0ٟq%=y2ղ1@ۏk#VR+{ @^y3xޔT'Y{.o?$ %KE&<{ŋsgW ml}y`}ò{ސ͚:Lm`VKs%O,~ccl:W {ś4썓dŧpO/yC/s /d"oGG,~~ͤyIKLWW^/}_%Կ,jg'ހ Ufyw?6sZ) :2qӺ{Esxq~&̳gcۼ8m~v|;׉8iM鑶뵵)F=Cݠ$@cL ILHCC􏩈C$PP}~{<$% ݜ73 0(_fѯ=MgP^ O߰y ކ!$=~7V!Rd cse e:#h$>+xyK+Dgt*sB?Lm* у_u]S25t,v#Wȑq?>2S{R#aCdC/6k*< 3ϋJ\;-[Cw6Н@wY4:0 Gt7)T 2d V9-hm[=c0g!X=GG xl'[p3=ѲЄqQϰǦ![[-&v؉c'vة`;fL$GS\VY<:ށ(Na |ayjiȓ*ʝʕ3ݔ=$[愬epf(Hicc{SP2(:x$!(*n?/UK/w6$gGL*)r F%O9s* rg}-ckl@%!4 AhߌM-]N9K-uma*$MG+],ljj@iCePPo)CН$PnNS!6J@e4U6]?MS'hu>[w4qu:@zJʱ{-hAz<2Lrr®Y~ΚE~A!ah66@A<0ǀfq&m&А ឦ ]` ta/)q ĮQaE{HYaNaV6 3]Qg6{9d7l[ Pb F *e(P*SS J/Pʥj-2 ʴ:ڱj 3Hm-ɞt;oel?V~YpYKbr5 c̉ջc,NY{&Μ&38]p~ᣴX,k:gHL6}?ѯ' v ?mI[-~x;gr!q68wsΕΒmQQ]·˨#rs[ 7c?}&{vdVĻH"8sIKi&xA;.Gd##h^e~WN0?HH3(qe3~VpNEj'[ٜ;nG<$H9X< WU~H<^W^ef\. euqDINۿ^p᳹ϏU6K<`,D$+5>>ɿJKb&>f- | Ol.>IQAaM2z 2zQ{u΢k~8 p ޿z]uq-l$.%~u9Gem~~|?D~bz":'~BiUh ^VXe]SNڟ&hq48Zj%v؝lj~>^n.NC)u}v!~D_v<mv\pǝ;vd`IЈ"v;;eZu&v;#bl/"Vc(p< 4z"%kЙcp_/;muiG:ў؊ @ENA{;ӱ;arXeQÛ rW+b f8S a@䩾";=}ll>B~ *YoaT1v|*8=ط{Lcz\cQlz+۱ݍ>l`o6 ;s:>GNU QuCt~1lEоkپ Tc ~o~;@VdjYdg:YG-e:5c_ ;~σaWuMC,lr2ژT2c^y;u£)TE G7Y.wmkUh9WJ4fy$;B5ur%X| EΊ}ṗs&o/E̻,HK}ܥx#+iժDb񠂉jO˓˝lSMG;lqf܆i I|HbxSGdQh- ϻ|Iy"QX+3SD~ & y24Xr5 9gϢ)K{caq+X³Yφ$/"\Cedj(fsI>'ݲ=&=#U0?;ӼMvū_nF5#\O&~mXflؒ! ||e6;A+h9/)>O&d\25 r73D V:HJW xmǶAlcoC%K"K+>|pN+=`hiy׀)ޅ~F5}faX5 ZZ" nUƱ3h:Z+neJ;=HYB6BIH@P !Ѻ/NT;նK2x:ɇ0p=!?}f^LRpφ`@Vr@G Aw"0<A!\ŜԪX<71 1 '#hGw_C0" 5m ṫ` ",B",BPGbP !BpS/ t3Ϟߧ"$/0` %:BrXa`F6;XApٕVb\r>i:_PK -:G/Ґ9c+.q|h"|X ~5.5uбFl 0a|x=u04.zE4)x C$Hl- yױ;'jn i\ W8tl-бk؎nA pNlEMlaY6{ר` Y;y80_w97=Ecg@Ҁ= бQR$Ή {P1j` B΃Vݕ Yk`Õ(,7U U+'F|` ^EMB@n/+iQ'B/ paT/D;C!XB"0cr>Q88/l0݊M?xy~n07|cǎ0q)SMs^(d^^2l/WYn_zWl۾ܵ{"ވ־o|#G?>3L6ğ=w>1)BY"D-U5ڂ¢CiTSźƦffpvv]|nܼu}ŗ_o~OD%}y1<\'_ gK"0X8d$ D0QPp)#`@L6-F8n#mO@zH(=&c̾dݽz~x FEyy = % G X'$`(,K?W-=C o"[ ;=Qo;p0ȱ4Ï?!Idr -bXwAWM1 0 z޻}_>xo=z;xɓOkMuDT__ba~CٖsJ:CR Z G#e&\WfHKi h0a@À 4 w|kfdKeUh_ݯAųs94HASe *g)AxӀ n_ToO*HSoTb.W]ޠZA Р%4(ײ3n膆>nE$YL!`*_mԝ/QsР 4y"ySIfuaƹgc,i0,5pCu~S9Ѡriȇ۝+]xWY"Z:ӸdM3^Dv 97V0N6CC4N۝#>1tdBG*@C'ie$5hͥotРUrS!\ʖrz$N:Ҡ#{脆ƒn#Hi КʷkJ A˱)sNy6K"cwgI=q:E+6 Zg Uo-/4CTРa;rV(ՕБu9'_4qbf՚ *ʶ̅ڸ|5ǢT۳,8Ȅ#Eƾt^鎗{<6XjwУ-VZzQQYkF}QLVנϋIh4X$&}49߻w?cW{YE˫}?Q ˱lpWDL|rV\`ƉмVmӰi4l6 m{Pdžg0|ǐ0aV]ց灡F!ʺ[Kn۹l{`?)`oh@lǧ"sf\޼-RtɌ)Nm-në= 5e'#1=0htHh#EAg"F Vh•Ibm0;;6 7`2>A :SvIQĢU]1W B% OXoL[n` `Q/c×hޫF'Jcs_+!DtU3(˗vjYy`xN+1™-x[VJf AƻC),ȗfjkۭTkëK/ck$fLGz(6lj;^i<)7m}Uɰw>&t%4aS&Hsĉe!e;l[԰0ݸ/WioƮOW}/>{cI_ᜲks,p!m,g9@Ov.Rgu6A$Ⱥ[5X=ښWǖͯslwrl$&";$&,aqJ'=ʲ[_vwMæaӰi7X?ښc˖9_ 0tJddD'%x:,&rA>'>\0EEh`NӽGWpkz^`x +Wc"R,Bq&<$Lci7_uA[=kV};Ǘ/ b$fǢ* ˱\PW@i.wEfx΁HmjiHW#-]`0(̩ IHL` HwEĴϙqrxsvB@E͌:yn8~ ^I3mfځ6MmҔK IJ qCwI֣yGﻭѾobKl˖%[^ p(t4uU}\?ɩk3Xb?<1{B1 )ʠ)u e;5+jK״4Œ^S5x{z~q_=a8 ie/ŴxXj(Q@ӨʨVf =[rSPԤtuEhx{~ {/ͩ0/!=k[8P&ڪY V $7yMRULMogn`##4n%ubD@tPf*haTIȚ^ʸ,oe>OUq x -"8g3h.PԗMЬ] U,*WPW2M~K(d+\+x{ڍ^o_=NioYz!pg'ئb Z(e^ik{dEDUۆa}B{_k_ӜB3sޔJ(6y %<$iCPMAcqd"mnf:p~0HA\^0K Ì*QJ 82Eg`*)=P3؏6r[h/w`}o羣=\[u᣻nj:|ͶoZp7ȗ|ImKu:mlB%a50as5ޱwDGI^{Ivx\/$ٝh cD,IFIdB#mZ47"TՁ>m3V?1Yiޯ-:B}Ky/eN(^, юd,A#$9Z6mtoJZmio=aqS5ݾ|OӂSacO0.v8hx'#TQ*LIHLʆt ޜYޖ~0˪a аm=ć“!A)# xB1B 3QFg2R!@ R`, }owYr6[iì+auc71'R 9#lD}qNܱqZӝNUzuuk@zWEAAP I\Bx $F$@BȅpAEVԺ9;m-ʶ?*9M8bɢv:jh"(VV@ߠTei4EJtLpavwk}n䅜4~1+=n*(NU <L;sYINiBx6 _sZfFGܰZ)HB':!TUr_JDot$ H\$\VQ"Fa]|VaG ^j2#(Q6"*r*&!i"$]0 k A]0ݺ4!>DZр/rz[IV-9~`qL45z]ECmdULDD](ՀOICVt^DA$"C V[+{$SL:Q 1hG 5M|CF^kʇZx3UAPi/  n҄di=ۊ~i+zd%C6@>k\OX["d>Еq]iB6gx;iذ% gd9 $*MM//uxUakfR2ȕ\o`*X( 0,OƤAq.<1*; O[T{j8lQƒ .3&Ba:A8/ W=hS g4IC΢/}ڐ:=kJ]* *8l]Kh-nH6j &_ciS 3Ҁir`xaؚDy]Mݧ 1M&o Zr-s.j)kjTAAdGO۸7`pHGܤM$Հ!o?f*wm2~\?h2b۩z2lnʯK @1'TYY0FG)2UhӲ4`^2nK֬f{}Vm&pҁ-ZwZܥ5UUz(ԦMjJ m3GrA A%h4 `Z ЭlVy1>g~ |y؟~uG? ӷmHozޯ'|%:WS 8#^87Ѐ`SӏT]=r{L&u~C*gN{i%8 dp?3 x \aheeh jOy`~RMOU!KrUh>Du38lj,J0pzT~ޡ{&`jmյk˦t˸("R(HɼX&QoAqq˓2,ah6EeX=7eNަ ;63e0uOɧ4]jnH"QRĀvߌ帶la,/1 G#Œaܔ>ehi3~1k<ʞ1tȧiPQ'5D^LRMl)l8q(˛G| 0#xeX+)z 9Ys{xJ1?o'ud^H2kq2,9ʄq00|hahe>o옵fϘ+&;jpQNj$ %h>鵐ifqfb5\Éߊys&``1k{ڦ4vTᢁ/AɩYL"2B5=+ v:̂*;\q`r!=\= ycʚqOZO:ᢉ/),duPbM97Fz\Wjz{Be7&H΋ ( un̬uyP>8Z?]'[E(fjY1)QUoh"^jN^l^$oGs4o-Ҁ28>u9Ƚyhlu^sKO3;(jzIyD. As\5KT1E7w>u>3mu *].NQ!iWcZDX ޲=7B^UtpQ+.hD2-hM;[l'Apd:d;,{OHgpj]<5jT:hCjJ ]QI%d@ [[ߎA;.}߆w[|pRB\G;A-٤}SKUT*K0)!D=eRoh`2xo.cxk{wt#;]ds=c?bv> k6`B:EM{MDZ"VE Ӽgwo2oM{ ireӇŢ#3PS }fj;8wym>3tE`uÅzAQlnwG6{xϫkE7]HH ~5_8ɯs뜀gq+>~?>].Lm`=acܜ>"ˑ~RJaiVUXaS/%(\bxa@ @Յ e nH\tzK?Y)ƶX f#fHvuqҨFą^DJ +a]XH:$?y.d_Y«ѶOo~~ZJ^]rrj[Eۛb.A\Ԓwͽ xYbN8ww`{-CplInF'LǬ/F>-/,zTB^O>{.V~1vtnYHI׽{Bc{C: >gώP:}$%_z^US~nˢeϪq%kҔIe?R˒6^L|,Oxri' ޥ^y/ >9}Ǿ+22AnB:@$dPɈğ?Ǐ۠d~u9;3'ܝd}/Ds;d~>O`?T.@WY4v,dG$xPt2\11 ЧO| @<(1>0nN\x??G )eUMuƥ6-k8b#S͢v횮馐J~Ү*`wo2`i(`!8):W@KD|Ъj){g3Wzǫqdq 1>, Ay-"8YhfNS%o_%B)X7oǶ;LyeT;- DA  p[ZT ͷ4zS>KkL7tDa 3fY`l^{j{~8 &x@ ?= R7 EUne2^dQDLr9I[M#D%@P؆~?VN8 o @A$o @ (pM@/6,qkًxդfu㍼*d %vk\Cn\ӂ9Xgh ?)lń(9 R7DkPPqKf9T$Y?. c(w 5A3xی{6gsv` ;llHklԪa *,ђY.I38aOr791fkpoui6ٶ0 ( KJlK-Xo;_*%/K8 P*cK3\iaY< r|^|ǐk2L=>_USI;İ 6mNH OHT$+U=Td웒rl+Z3! 6?9(zI!73`zѯP^e-'ڜ2a@d#LҖ*1:HFמӼ(/J pEHy,pWt:;7 ^)m.3ȷ '=Zs&6qg 6q[ͷOG$$_py"!hgT6! !E f_+Rl.[buũ@36.}"~'>]W6SL 1f񌒢Su<*qOhfuqi6gAm8%h?w=Oe4Ĕ=1a$P[k匭sH_g7)hv!oFVϷ0&96gtdul`5( _YT8PG]s߉5{4;~elH&{aL0Ejm<,P2|sszl e1- ?N٭s׏oPʝ~w8 JW14Gu'C0VЮ#ԫ%JFWV]R-fE`%la*2 & 7Ym((C U5XB~dgr[7h~ }hč87w*A?:Lڞ64^or]҆Xѝ&jL/RiYvCA)Tu6Ae} {48=?pkbPVg(3]BGiK{hnzicgXeTCP T!١} փNt[>59w#;vމ)/)+F $ev+Ӥ(󻒔.RPtSj]Τ eGrJc(D 5f&P}j-~&swl&n.Yh)YQtвE~Nkbr[iWra;=VCjRic.TڄjP E &P)46_.K{OkVW<>D:Ewa>r:lHd(qm6r[uKT[|ks+AutpP.0Vhaf' ,լR:!]: sep1"@L)FK%tەYݑ@ 29!kZb.zۖ7.nޭY["B>ߝ1cEGC z)?"WWc{5: DUՄ/ jDA?iW7lZ7ʷ;[%NJd&Dr'IY\hR60r-ʺ6WC`}UI$P,1oDAÖ/V:eņ-`,oY/ݱ)|! 1iTܽشDιt^73h0!-/]6(֣5~c#턉ӗR05nl:CLy! a1Q_sOq!)%5#03g!0̃T2^6:ע4C_XW L: ip='>sCa@Ci4kP z#T=saTؽ;`fVg  ;`xN@vvG! R\!pJCPy8Otغ.̾߄?m?.N8BpDt=~8+[Z!H[Ck#`X 0- - "dl2.b" >c @gaЫ\BXK&=ה%?}*_Ŗ͐iŢIbhX<" JFA0(&~> C e Cfpc/شLVbJ-?k.A7_"NDˊǣ%cƒ1;;AͲ^bYgT2Cb!,OK= yЫ7DvZC&3O&L%Hq1|4JYqZy->i':OJ|C> 1d#LĐ3ѫorٔTÛcM'M$cؚr]0IU=uf# ȮZT!΢<0ZOsjӞqkuQj-"eA` @XB$d%!@VI %$lj@AA VG;ߙuzݼ٦$DbRfw9WiQ^cUT-U3f5URmJ*0P 5ṗƝK@ޱ C?  ;61|3$-!xUF1x&(bJfX,tf(FނOg5p}o1(f|Sv/%V})$;͚F.MeםuEmvC'hQCݢYаsh],^trx77n97Lw@,Ddu,B %k{=eե:uS.uܥʐt*ڿB۷/7&V,tOmx} o*<^DAxbyލ0>P,8OkĸDT6.HO:{9F#OV{xAW~%=3ϭ/?ulmWۂ%/=J=:U|?HdeP2дpy7g3w{jd8⇃ȀX <&(Gdl1?Ƞʨ13?3vjjqBn8J:j`G'`21| ;7`&oPh1G a}C )ȁedD#O/6 P{]䈪F (䠀Kc.#KqgKhpu?ŀ׊@ؿtAC}"c_zAW;(v@ہ;\BPn  :w#-ya~ C'z6 UC_ B 9t ;{p?*NN& n nlw p?8_QC< Lq;FVk)+>eRƜ%Y8ωgz4Q0kMa?M47q1콌!} Xu;1pC:b`!7Ey!%x„LiRK33oT-"֋2$+Ill2_;$'I$ʻ厐7Fz, \ GN-M"EǚT`R%~BL&6.dN(&pG~H988l' +]mE P7ȌE2&GrpI/9iγ"Szx2*}L|DjP'^81Nh~ʾ}8K ii1U vp9l Z$N0gy4x2L6AT'f=$7< Kl#&s)' /S՗@ A N*1hb d| Q&O%xΗL(Ɠ+jU) QS4w75}M{Ҁ6D6%h'h ĈADA pm|("F-lTže 'Z88kaVmFwII7 i~~~}FY;A2 Πq@PB ^WfΔT! sF.JsѯzJrИk8W\+e^_4 1b ,oB! APw}A"NUqSJxBrR9aC۴s%Ime]+nnYfSV)) !cHɽ_oCP% I/ ֔J zP*5aniԚ>Z*|a98fkz.7q{ʹ=O@dA (F0aDY0H R'uJP ;-ִWSXmzNf+2~D]nt1k%~fo2 0~Py]܊?K ՉLMeQkj\rU[kתKmVHaыLzqWb1CO@s0 &߷uasQOԑLe-ZyUqR+ Ygԕ[j2ZkkU6NQt.bA&b#VgL{BPz7CF7}V3GvHwVeU+mŲ.5[4my6kR-4UN#rH|jx>A2 91PRo<݂x.NW@Ʋ5΅ʃvz!0$lŜ KHH"N_Ԥy=Hzg04Ay,Ey٬,G} "}bg}OXeeK'!vD _0Yǩo"ȋąs^kJ86׍z99`t2~@2ȓCByvK߿靐E?)ԯ&X׺5\L^sv:F"ed? ƿK \⇻)t{]ue5yn4nq2ueI 1@&d tGeɍRR؞Z`nvb, S!O" Hu rK}*e:.װ~vxcOѥ$Z"oieLMoʲ@[ F{^ ؙΜ.zD{@,D۵rZ ?8rD݁A bfL6lL0V;f`Kdp3% d 7 l+Gq@#[8ko G-x ,=j] bOrT!H4dT2-pSbj'tC>ZMISs?Ç k LDFr$j@#H$C!ױAU&46Aw'(vGUNkp+o5SB!JbD}ӃP*CD}qIE3 aQ*qGt7Z#`&gV[VpV0wEJz@٦ }}/DІ.ݐr%`U 0j(6 pUa/S 1f-u%o/&|E@j R|iA ~9_y" -c>CzϐBT0Bh2@EjpB e(;`uzP/R e@SWI-A+vw>o/e<{g@|˚]b={ǖ lMi24kp/70D'^' RʚBka~mg}#|%#3a&ϰ&5==-:+ZQԣuTD+ʅuBf! H,'$!Ҡ("e(U(Lx@e(λO}s7i /l>BG/`X/Ш[ DՄ.3#6'=0] 3ĉjқ:kci!i{JFӚ0#NI@Z ݀xr 9{"=qH{\v[laSBzYF Hz1|`D>e1̦X 5Q5P7y7@?H @O< qzܻ,\>5F})b_d < y`ۣpnapE?tݦ,p89 ٹi$,~'<=E3ch/qǘcӬ*h䥄gx=?1x~M\!_;_[ 8> yȷ/5 Yt Ac|bIo#e\=;0 cÑ͢GV\_͘>؇:Cɹ>q%y?h] zjPo4L A f~ 'J8=leC5Q QI^M|or=񁬊@vܛ|ܛ,`:jp!ul,Ap#@䐏bv/f<#|`l \QރR܎V^N9OJtQ'i= G,`Ow& iנ8 `ڹ} 3 ѻkJ&DD0 GMIT: wc;rjޑnct3:S ])lG en G `2w, oo~g1Ag[$KiPyRT'5kkCWlǷiYjl|(9Uѱrfr% 503o':M,s&[W8nR)UK]^6a֖ 6X~%dgEl|AWIg)E b K1F|q B̳(V=1mxCY0;̂c&εk\,č `rlLjxcWʴ|Yu6NQaK:|a6.ݮX:ҝbMf*7CIC<\:W{}w/<صSS~ՍuquDPQT(bIl$$,D*0:ŒZ;NZ:nǵZP*2)UdK9}m^|^K.7VzaZjBK5}F_\c<\mzGiafӛ0ܻ=|j|4쳨Ǟ$MW?l{I]voqf"k[եm+UnzZh|:^Eh[m[?QIT"bŋxFR.p\T*m?;1te!WrΉDyjx,k#]!ԳQ>ňX&gk *Y>cȎcd%rQ)#5Ңq+QhG3bwF-!?&H#!EjZQq_qY_iRH #ܰ8΋ŊhM\ sp1nq9fG!~%d͠3Y /RLtFkӡ\Ob ICo2 : Ʃ:KayU4c&ϜBp,4? #G2_%dBR+>a.| sxF=qs@ ݄Y0)։AXISQ-~bOqp?;"s;TR4HH6•%t0 `Hp\"b4GvnM-13Vw_,Q1_@? `g]!gCzztPh á.r=3'CM*${yCBEXtY m Rw26MV/z/钼vH?i3 lhS`¨DFf(Ь\_ܜvCrH1D%3O ;r,jߥh@aEvy7;S0 A1lz, 8HA6 MPnK|bH- z9DWUB𘂠z'~٨]BfoU A %@ǰlr2p`^cI<BW(w8 V)%$uWT5!zJ _6+_(ltrH e&f|U7h2}`t06 cP2A J$7?OCj!L0lSAG~DuAYgV\7?QtR6?I:?K 94d0 \`Qr$TOCl6Vh%o eLpq__ӫڣI7?k~"-ցjWuDd !I 2 hQP(ThI ǭ{{̋yy~y$A'b*37EmJO%\OŚx4C  b'iݑ/f F}KF-%:v22vfAi:Oǡs=_H`0Z:*J?,m: 20% qqChmݨ6foT?'j݆49u NU<*А^ _b`406YAP24]f2e\w|D x~j&TxXp%=6s@4j rѐǓ ) [`bc1` i,p<f;/_ |A;sT!5஘I 7X- eI$->CX?\Ij(cO3 4#76N0 Zd{߽\ml׷m#šC.9 !ƶ˜LV]Q[j6,KeDŽ =<Àd0 x9h@ZjKf{p?pjw˓S?+<ڕߡcSX8Z-PKj~!Bl0{R2Y:=,VGr=/mDP\s`z[k sBfjv,t^<{ j]7wZu@E מVET$xb%Rν)S $"B˸D5ŕhڷxHGz,߾ோ;^5YovYcS%]7+Îj~jrXUPPl,S.)Du2qrgH\&餢aH8, DO7"@@*,XSiy}-z.h umǟѨ1yHJ%e+f% b~jږʑ!K餈tXHFy1_d 9i9%FWa`FN֏oU6>\w1ҧ"6TU"Oe!<32%Q*f<%Ii#b|TȖ 8)GjD́dtm-,_tmkŃ]_t_w]|`eDAmLpfV"tnKR%q)yI㲇%dՈznLHK B@ 6X֬6c7WG0}wv]:֋5-a9AZRNV T#$Jđ%"\hrLǟ7J#rn<[%/sڥY-xg ~5=?Xt,S~gZxB/sI$4IŎ gj/C5z*4 F.!gCȚ0 -Em-xlۀl@З}pƁ}U7ܭ>"Ϳ{IŒ81k5Rji`MK vXQdbF0 v<[_o7l@陣UeEmz]~?hn/$%8vC2]$ow/4WԀWKկh!Ab;,å` tYk24cGfMcݬ?Q }#ف!'Gz6⼆pq^o 7}:Y0y!`XNKg j,eUL9or^!p]/?4$BQ.X=㴞0&+Am;2]>0GzbL;Z hk ,A}kPdk-[me{Vg]1f=Ϝt{jx&{9:jo|}{׉ϾGt~;߁pF:0Yc>:̓|ޖy9ӡ7Fy:-p.]gQMy? q .,* l!!!{ I 7kKGwKU#-X+:uA=zL[8 B|潚|w]=hil*5{.]0wp3GN RqU"֘[>asbOn"){>G6bڸ-Gx}HY|HC4ЄaX(AQ> a@TNq Gq2͓$ߡ(2)*%`8z dE!; qL.}6D3e|4|Es262'aqh/Ȣhf3 2* (\GAi,; <As Ru t:3ALd> 1y J ' JCʀÄF KTaP-!DXK/ldAV'ɺ.g Ivg|[xbd=xM4d'ѡ`1IgB'^9pGCI<ے!ٟ tNf@x&v.Ywg!>Y/yB t&xCȀ. &E [D(@/8nBܖ>BE<C!ρ ُQx /(#hPy#o1&BPPCUꓠ4 ʝ =GBH#3 KGR9 &'}HNJ1&QOn=[}KAݝ <Ϡ#4>(:qLT}å A1(Iy -|v{8TgP^RWhʟk4Owyw:?.)4½a#*}P23L}*QhAd$?ҵj}jzoW ˦QӅQ9g0"7x&XśU@|e渱jGʰs)wtuV+neEc88ᑾx_~aKyrpf.l=tГ|{]Ċ:&N'ؐ=ա#1+mWU]GF&K_ n[nZd(0[mmECSC-_zl/yAo"ؔ-Y#zY[|%+p2\+9TcqK?gK:-;,J/Y_8Z4h 8NJ),9yL~#d+ȷ.ͱLlK2ȟ9( vmpo]_JSMk{As_%Q{k7%γfGpYeM>'( dȾWOz4̣a[4;Yp؛=n[m .ѕ++ۗn)ztAGd9׉+eU|Yy+׾ʾݮ~.'0FfQC5&2%?1Ad袻[~mC?h9|{ɉǪ]]mK:j\]Etm_Wly8yƟ8H%CESf_˖889v!5dl!ҴeFiK4L^XYA@3AZ6]MDj+.;fw9&G7%ƞgTF.8M$, %tIIlb樒I^֥N{:+vxof:4 kRe i"anH^lYXVt/#\Ԉ 5=/%z*"9z&,9j649j>$)j%=֓0{"_B4{YS.uEp@ k%Y5_qOfKf|Pw .F &BWLxYN\;.v% #<{+UͤHߴzrLNM~jK ODdg%222YI„)x䇑 ~d7*a:<:~7ǎ.DDaDrxY~nSћjᮽ&ʷmZ_s2P"wZ~ܙ *d 8ᇧOq#Rgy)~[& `A O_B'=q/n&yd,@؆%`mY`Yn`ug=w4{@7|I:H5 ?BHI t`{R"n>|bf/s/m!?삐OV"xF`'!,ɹ 0z}OX ҂Ag,7{Ɇ_g"D.ǃ QvGlYMtBt"s+]*W5Fh+ !:i__#;?=G+b `>7ҁO=3@$fAb"h%[WWGmtp:f}6aי D @+5zq$X?r'j"Du"֕ S g8@> JdHJ[Q+<: D3q,]bk,d;2{!8?Ds3듀UHXAPAK },N&-*unH2 _x+lƴEwÆ؃Q7Q9/9}pŀw3Wq>&!?{ԯZ{d>@V#֊ArArUU=,7J$6^Z^%s^[%*7!q+C;Q 8/DN&A-d_Ɠ|Ň-֑{@w. …lٲt[R["WQT;KRgIO{[7c! qe#C1$WLhb- #G4g _4egy?YH_κs[+▲%kҞ+o.J{IEeW@ܩj$>đ|)֑6UTN-g7G8/yZ\ИNn}%7,ܫQ=V!Jy27ңv[V-@g_Bidg'=6M%sz_e_- ~6K]nt^7 r 9戞;O?O9$w&8|[ٮ]ٖ2h[ͩ㲦ԷƴwI dgQ@zlZhRjwZOkCf>VEuv$ٳ!}*$\KlWv#Ir8}`ZjMk귚}#ꆵVE}Ƹ|{[)!yDmH@6o<l&} ԭmݣFyN$,P}U.+*wWdS6g4e6d\Kٙ٫NQdsqYUDH$[G dΥ‘2VrG6O]m5n6;^.{vW6g?h䷙6 [ +eyU; jks?լ}0RiN0-1VU0.{$mJ l޲T͡ p<߽Vԫ{58xthWflYWf6nIY\#-lTWO0vZn|Z^03 iMqTU?(˷y{)L|28k݃(7x_h {YGՌF6Z Ě*yeNfSkʦԒ4Sb:ST41L a&.&{S͠|>rǔmݭ%"J};uʍbBf\.1M),,ոLZ^ُĀ>ӐX:)(UƔLV&Bٜ3(CU沧iFuh:'ʿ۝j[W[Ģx=rzSS nW&./fkIiViqUX٬5X9SY׺-CuyTe4\ѪuMBXEAaIXE@0qWzZD REAPAܵEܗ#n=3v cNUԞ,gg|~zy}?ѐf͂1=ŧoA4ӵV+ok2?mW{$QRYk+;.b}˶S"{qIyy%w,>{I@m˶\6E~у*!ݮ3FtmuM原Tյh'ly}OqOj# Ǭ;&a)*>K_X?+w᜜}md}=@V^`O2w  Y٧DN6 u1ֳ.3&sՒ"/jT6慮;TnuÛf=,=sӪo2/ UYeCswFRևD"_IUǧ M%S,\RU\,=㰽CQ>wݩy'G,iY5-yc\vSѬc{SkRNo / Æ/?R>*FGRGCo#zTFtb=tG_]ҡkT%^ 1MmDd+/d/>08g6;>'^:1U>>f6#9(TѰ臝Dw]۽j/qTyÈM{\]ۑފ_q3m,k |VS\1s6zڌ1יӣ vyŴ#>3D]!h`?Utr뮈ӖO}[8:>˼&<ت};hVFByCx]DFvAu:yDgD7#jnfʯӖ"kNkzżr =ZkCO]JOxVcz>Fȵ=U͊t2T8w(C@u752ω.4>/N͈V/y/eTFWfɯfOxdחa3/N׷!oc.܂M |{FD7$/!5Z!Dul+Xvv'_=7-)_3{p~jZxY4C UClw~d5IJAlbY?hGXaD|K#Q;#JÎ7n:Z(3 BHc?d`l.ATVK\_0l_Lj*P5˿C)EpVCԿ.4YEjE( "A% #@#r A("HM׫XVG+VWZ]gߝ/g|g]ך$i VcjD0!D hzG[Cq n@=_\r}As}F} ns[x ϫAy9*Φ9|f9DY@DB(KD*׌F!.mz?2a4;Na1vk ZC狰oR # ~H{/px*ٽ_ LJjٰb׻ͷ=o:~y_#!|\qw| $|ÃQ>P@)wusW`Qn2#5hyR/ף5n3Q-߇/5uM  N :!x\$hB6&P(APo8.S3)mOEHd`\iXf6iK'Ed Rtv阽';' :>|$l*@zg!U 4S V, =vS^jR \g [ͨ.Ǭ="w99)xOHKU|%i t D0^y(ewE&:bh F 0$@@)=Į%Ωs?A şS~+[ovlLqɥgr"2.GRIZYEࡄc|;+#vl6Knsc$SA j)0@7b-ǮȳCcSSfz3%쥓a㹱.#->J ;,3*o&e=d}06ߐp]PW%n 8r r`d0q-=-@Ѝ}M>*g./.qL'꒮O+IX") ]E7!=*nFgfONTF*=ERώ\>fP陕}z;D/*'Dˡ9a~5i(akRe -D}/ =˷Duz|o.5-Bg7߿f6x@ wqo]GSI:mu~nG߶a6޲z1hQoge!̩R^[.*KחkUM/+(L U~P^^Z6j`0pXWwT hu:yMt52-&bEKh}]m[UM6]e_Q*P+K+njQ@ɵgCe"y;B;9S w!!tC}fh@nj ՔUOguUMbW]CVm7ϐNԱu/ D{X[~|pL[V)DBwNc=fh rͲ]5gm[Gn˞YʆܠRzBNQH~T Ș Ht@ĖZYpـp{C |i/CC._-+aNn݉[S;mŴݭՌV"0G)js23^;B|3$toL>,u'{RFj+E^O?dr7 N07]X!@*Bw]Ad Bc _ݤt{+k/7ZT_ks76mDna-r[;~cx|D_|J>KˎEћԂEG->v8T)Nв@]n|;)T{s%35q0Ͷm@yW5;dd&GyS-<D6zvc_֍Yco,dYbjmt"\8\ۅHMkD Ds;^ ,4㹼~ocd 8= TxV{ .\;vhH5mL¯.CwC׏ma3>^gsX~G[BQ(e>*  MCraxayFc xGaw$xKp' l`3vog&_$*BM# |Ʉ@CBZ(( *.\,\ xH` X&c ࠇW!fpU3+l?D"\" Hų: Ix C =q?/8T 籎簝'c??g5|M˾Erb(xS(b DZDhĒT /j!8K"f5SdZm$=m2] { -HEbfy"z} ];ҏ|!iү 9ꏨbD2wa1xd] ԠkyXzLVG'zB9 q h( F|?b2 ?ɜgfn3~_r B,#dX,TzGPA}1a4{W#"f2ς友#; @vȨAKH?0q}5HpvE,UO ɯ)cI n e@t 1W͈1Ҵʀ﫧4OmbEۄ?+[+M:VHiPv}>dj3q]3r57`g0o/iK9XߎM9#sdkQ5nBN y\8 <; ?QB+ y#p!uNxʶ [Ÿ] X&wg<%ݫ:0/<8S6|n:9@틼H뉸Axh|KD~F!ZS4.y} |&t3I l}#fr+Ȧ0k4f,9nD$s& J{jUwQ1k n$o<.x:rVȖQF"vIv$5 Jst0k울 NeNEOU{JX( Z0D] (ަi0E&pJהFߍyǷ ʣl2v2&%ݵI ť3ɵD K%)^U / Es !Bh`/ {o. c2{WTKEV}9{[I rU:]M/6 %}_7[͖7[|ĒC_dD[ :U7JHu!ܪ5*5LNe莖˜=jС&K<\YH)ʨ+d nQnz 1!Y*bRSv10x{J.7[$5; לvU< uSTbt<%7GEϒ׳dYa$8̯~Lđd"412D Xp;O눠kXMaщԭq-5ǷUWFRW%TVzeRkYE;')O'̝{/!s[Y)(J"j& pk0hkZ1i8f .ZU*+{H˔Ԥj<|/_|b +.1]$[=gp{W#vVvYB{>bc'ٸQ9jU#'!@jYR.:S%񫚙'+|*'88|"*;R%S"h5[KLqf`34&w3T1Lz-#6-.Y(l5+ȼ&WdC#- n Va#FpV#ZX+*_ͿE{Wp ``#6ფ!ly +@N{Ss\»JC:՞A=q;mAԣ͈zL(Auy{oq`w0@-vвuq1Q -q/xl#GN *v:s9>Վiq\r@ o/"s;ٿ}52GpsgN kdӻ iWRX0o39jUmW;'2w(tێLݒc} 9. ra ut 4|$@MH3v;b=IQ>as7[MΦ[sf fjvg:`Kږ:duȎ1{\E+WwA'@?@ίXΟH m!f[Bਞ_l쫏^'1)i}g6Ky+wVn|8x8]Mh_ο-3'pC"HvY(9yѡY&/J9hZru3W/~,=A}ny;P gD.~gЗL{(m# a!: 5px7?ՙSa20 f`FP"JQ,X"q%Uc jtE=.Y{uƵG"%( !;O}}'~$~0Ofh#v^R+uBW e{; F;m_ x(6Q}اD֍"j)]5GPps`|(|H?-"")bϏ߈5X/v~nH>6J-߳* .C4'DD8?( А:H>0ZArCOY yJLX R`Ev%,M4/q-T{cDAD 38Ӆ㡽.Cw&]mqm{w'♯E^d֬QSzɫly]jyh'P=9]}GK4wV{Ju#qg|&xBSFӉПHD1v( Cjxm#TFtfNLPɮ+( }߆}fDTDDYaVePYM*X&FM0.59Ѵ1ihKs޼[ą3r { ʏ2hnڒ۪1Sb_ǯ*Ҫ=RDna_Y9sMF"",MB0R߯iPQt &VX) wj+\ټwIl徼Tʜl~Yv)(NBQj& S(Xlaᮔ^;4>#80Pk=uL{Ӽ/xE}ZhBg./c$18%#p0U$MK]O=O>d(NGQb. w`1JD}P:}'ih`A=hcZU4u kbMeՉV9iҊ~-FX_r'N>++D8E; QB`4ԃs5ԃz{vH[Cje-ZEM+c-$u))Y$TzU7 Uxm];xs6pk bJsS 5PH3@/*Ʌ.3rev.+k_ٶ0Ӥ{,wdh9(w辩KpBr_:lEX z.,^.Vô6T~GK5=Z)GvMw[n̳>\Q缮kD{xv;a="zNϤB 4MC rfh a]';m$gxF[bFl6_7 o7䴺)AU輺ɡQA5h8AzvV,Ns!eL83 Gx*NgLбB㐱Um kpooȱ>^AwP~1?OH1Łi=3LL{յ3OǨޥzZtnT!ACӷyFsh"D3\p-Ds8I?DMy`%6U" lBgE b eJ2L^U++fMOe?Y-k7g]ew+bG)F)O+a5Xs\3 )ς@x+܊f֟btRk(j/˔? 'ODT up~ `$lF򙔱xV2eы,?xO{*PuAo_t?_#?%7j`X~|0^@0WANx絔Ahieޞ`og?hΓ|9g|Ht7B|{`'  zh%hp440ppX%B0H1Bo FʗRQ>= X=Q[LɅCy+)hEˉH #[!`|E~\BAYpS8RB7(ˉ ro }bL x`B/Hb͇C<hƠ3̕A#z jAM,H`Z&)&5t>2L$U)}~D^ KK0hȠ ]̝ACo l`rI$! 2A%r|INeJvv :2hOZ1[•XB\RJj٨B: Bw,\'u}GEugqSFA"3u DPAd230 ",BK5ZWcM=hbY-b'su߻}9(zy'V&q_ Nq%]ev^Hihde-r8hQA:'hE"[|}mqBLb?ǖ( zŨ-,rw( e}ow?$kxo%7WCgҋ_w?=߷{'+E;oKQܒ(['e8s21E3fNPxpz]8oW.Z ?Y̬ Y 0/2]7\ g'\e /p@w$@/#@oZP/^z~>+]}A&ݙ;U'Eb;w>3_q)0JƧ(:@38]z~@Iw}҆<4{~ެ>;ܛs\Z&Uٳg7'dY>=x5qχ&G<ޚ~f

    #z}b!\ C a ZdC_E yN68=qh~y&sL?ݢ?`xOn>A]gwd-MwN6]V@A`Wal-pM9G2p:ҋ},b>H.p ,ݨ?$Ev/6߹r{Z6A[K:K7]`'QkԱO/&f~e%<疈JGT؃q=ѱ{#4=]7nmtۯ6lM%YK#٪w͡hOPc8O7cq>_'d$8,d_۝P=>Ұ;.AԵ$lSlEGtmMֈ6eY˩1sC9z:N(#5hWұ0e7gRYp" S'g67c{g7'upKJFu=1Ŭ-![ܪYĕ6/Yn"UVѩ6̥2+yy]7Li :Ƣ8н}I ڍ0۔)oS1ņ,؛m ;s䬞l/^g\Pu1$U)&uMCR.־:acE|sejkQ)Wjvţ3q$2 ÍxAe Z!3|gVglnG^[΢ DY f itMuZ<ʾ$ɱHѩII'ܴI7r/Z52ĉȴI0.x82LcTe} AO)tX6eiʟPj=VٵuZaIBC]U(ReS*,˶I+-K5;w01E]#.BdSc PFF 9Pg?\Nay4;ʛfq+ Fuj,ĚqҘFYdNʊmLXŠKhLX9:RXU[<^H}ݍkW J8 (8g6NZ`jNmzN?f`afnMPEESkٺn]6eyZ(*X )JYYػہΞ;0}'MZB׋ǽ2-c$)nJjG%W?ō'=vpUB`J56<ցYki3d^S`gꪉ~E+߷bz + |NXc.tsȥձK,i)X,1$f=baoy-~KU^)5cFi(ޔmJצJGxiqoMnx$p̆; .X$lhIix^IUDnIcDܵZ"sVIdގ5^u+7r~v'l3`Jy*qEX[Qsl$S}Fna)kֹ9[V̭3ʮ؇-%$}0=5P-gťʁi\&TwWQXJ(W wݣwy2df3]/ӪKR\;-] lI6h )wHp8_\ɞ:P;`yVCNdQ7F׍j)3u{&կro7$1T(c1f`6ɝ.`2Wûùf6hXt$ G<gSFcwAUQ˴2-Z-~ˣQ;"ijro`R?PTY@Ƈ& cO!g|&_$#%;`?;}MCO"h-ݰ} `;+BgDi#3~n`k/b݅ F I'3@9=.ak[,m03Lv^NOй^6Am?tuނvU*3N5?evSO Hflo|oa1:w4;pPA7 -`s̟ɹ2;ك?e[V`'` x@7BdNqL9ćᇡ7\.,P.W/{rg̎ X>̽o,v$'ehB| CG{"$(C iJ0~OzJclr}jO][B 9 <9Sb(T/yf(ў:-TDA@'/R'yN[ߛ3?;nD$_}š-&¸P9U^x<~4^.0#;ߟi%G\ )PaI6Re١Ԫ֏ k괶MM6ѮAbN} :F9UrꧠǕiE`_PKufT :kA+i_ !7!q6Tt-? A$b@k"q$>ǫPZ%vٱDX}ب]ti;֨ڹS+D7Lj:##ݢ{-T3$88t%|t$ˉWӵ ki-Η=>wqڹ\wYsssy6%6{6&]jH`T$>5@| q4Ay@+#Wӝt[ZF⋴dډS5?gcb)+ )yLeKgMi4Hm5M'UvSUX*iIXgk{YjveVc5 Sհ|w cemyUWo5+ o" JbZE( K!@k@E(޸junkn۱vvt;ad?=s9s߰NJbMH k) ^ ك{x s%' 0!n%&,%^JR/5|ϹR3qS։ةPG2{4!xW!s΀e$ kg|¾Ct+J\V卵WI*9}V8=0MTL$[ƒۘI=!CCY=2/.H]r³ זDظTuYc繥ΕAt_fMMtfv<gTF0즎Їyj^]w!S[lϩ mn6gu4Caͤ&s>*Ie#YBCDHYCB>9Ήװ{^.p!g 0e b GP5&0z ޝ,}`k~ I_Zȭusf털\;')Yh?P[xJ$  |s×jߢ7 A R7 `LRʢܺKeM "]`Ȭ3VVs͆v~YQaIH?+)/n(|+)1"4#Ucpу. {F[UQyнŜX[W]_]j6BJj9%m|cqP4*Ht+rޠ5~#0t`aB 8Y0O0{Ͳny\VQS (2UXEj/-唞ה^dޗd3MD1AJ^W%fA=X4By#45Zѫ ޥ~E@C]S_kͭif!azSz;\Yu:\YHUITf"P _]AxkC?4 `Cz'f,@w ;kW j0\Ž-nؾ$mˉuY [uMeW/ة)ZxM* u]xpNA{&q38;p;@57h~D@t[ۛ NDn^>pW BCȃz`uP y2cc}8ܻy3itu` cOx>>ޏ;x}~lFຕ@Cq \֥)bJr:ɣP-g< <ܗ\;JܖᦼUp8^E' 霽:'8^vMm -,U)Q٬jifM~/-߿-4˩ŸS۟*p-lQ犓|P:Ma(UOUϰfRn1MPm6MWf7 l0Ԭ7m\keYb׭Vh %? Z+jslgXgzj~:J[EJ,6PnLW . )lڜk\]n^bԼfy\d\h,7W9aSs\ Nq+H eu-??;w WtX1QcJejtȴ* OY4KTh;7h.?~vP}^P}n#~zБ]N-:3.mKvʺ{:+=TFiXCEqYZX, SvfU6zY_L.4W:~Frǜ !{vziBЏdO%⹷7ubM7gjHwP,,ΏL떢u͌lsdvq);|a\NwYo _G=97Y#Y.{{3~,K`E=^&W{^VocvJ4yRp }بR=9$A_ٍCf =s c;eH~kZLtNr"}zpppc-4CJbe6%%ppj\&#}YI %)֘ꌉ!;_3T#R4b JIOde7 1P,,.V:,UHA@*`-k1Xb]QQD#UѱrԊ:k+ڙs@wŤ,F/(GFWύ8;jSxTQWc(a>_# }xk+$|dm8IZ%BN(If4-yYrR"!1ba\eLBUt|M,9"V6:p kv A>0^舶Kgųf] ޹>-)9;r=$eѹ~Ȝ9aّʰ4$Khz: w=}lIV|(fYb.sFx <%!e3˦˂KQ~-'-Vy[M(Yc^IWؒSڎ]*lH!)6=g;ؖm^!I.I}*$BP# `hKWjlҪP3yU UeXxUYRzVnQyTWW+>j a^c{s2|s@鎭WU[` |7q8P3kH̐ Y I{6+1n2w55w1lmxk:VXX\s;}FZ:K+* <moԪYG]׏[\?Mx,i+q1K6HVȆjdCLN2T+䃶^7τ={tW MDofm]2 kPO  3CwǀPosc6.C}$NKE%q\[Hv l#z,za ˞u?0 &5M:0h`<c=F`ӒrXBz\U3X>"$d382;s `. 00(лв]:!e mv0o E2 N?!kvN}'5) i{M'܋HDrA..iT5/Z\/_\JyC2h/`pB/뭐yO33OW:赦;X_*8kx!v7\[cی@77,]N)KOgͣp4x0mځ=jz/ȏI~"r~T<</qC.נ(++7&F,(,ȲܖEvvrY˂+  ".!xCEh&Fmc6If:i:MSM[vڴ}z<_9y>|e >X6e7pmŕOK\@$ dXqu,xFVe *U-])[kkݵMp={aj1drrr_w~ko7CfC $r"CkKGmoWkqKp/4 nRZ.GRZpP9E;}VC)g~֬(b}Bq}Lq==WΑHH둄CHW ׇG17r}G͛`!:)3aNi(-)>)wfi^Qg2z{88w}Hca kl!Mw07ߟWЧ>(U Qϊ귙.=CӞOQ[2 $<%b޿{?@ωlsc9ʅ49Lføv33 @fkזs5ތF~OF-L/jOJ[>})iNؕND"BWO_zp}b0L &tRݱp@Gt>ի/`wg[]6^g@ێ֬@wV?Ӓt3Fݼ^wKZVw#ơ#"$9p7\G߷`=` ci`@J0C1)Q0󊸞<+ߝ[ВrE 9{NÈaޒWޕ m'2H1D>O1wW9K(D}7 A) iN3X&{m.,5V4 ZE5=!8)Ae_HSGD瘃[xqz~\__z_ΒhlViI]lvcI>Yb9Jl5N-,+̃RaYLPZIXn6iH; \>b';(}-ügyQۼxQ}z ?jXxc^.=.vv)jdҐ0@+w(RV, Ư\2ZBm6^V{Nr1糨{{i'҈ߕ>j@k<ɃȣP]S!> kjX?7vy@E}eaOp}P, (q]ՠƂADET,NPXh{,G$1qu]{Xۏ{@xgygΑڛ%_>`Q2l]f(2C/)멷4y赌A.| b38~Z9P rxë;<+"Q1ír\\p4éUp,2!9V3yLYǻH?RO VF*gS݀cju#`WDak261ZCcIڲ*K%\@]+!=bԝC݉Eݸr6ԯ_ȠAVh6#GdeYPV: S^ jO-Pwm.߃k=?CIl3Yw8ߕF6eل\dikbR5љ&+"CV!V`zmDQ7+|; R@.Wtll]> 7 Lb|II}g'&w!h!y6N(F{;Q׋]# DuOrLhv/C?[7lO 1yI#_ҐWhv<xռmּExD3=桍i<,`!Pqk6@kA? $#dYM6RDJvRK!u/+~xI!쨭PpW;H32$t䐍dur .2i.'WG ƙ5H?2|B>N"u9RkC:k%2SVo>~CG7A8RWm! GzjXjMf|tX@Tjds"@# ~I p'4q7F \hK_hZG9&ۇGx}Lԙ0&He%rM8O_ŠS 8tZ%#R9SThgG8A'5qU˲h%|:bN+qJ'98̃UYê 4jpHՀ&|W2cjAxQeNW^/'7~}6}pV7lGX3`?`8nsWu2:AC=84aT9F@YBz7ˈn.yJ\C;N;tQwðԹGT{$aL敃*|Tx{JHfKi IA3!!Z=k, `;孁Pg} lʎcPR(bdl HVJ TX)Iy'e~LY֐՝FRK03Ov@ol=P4[Gas8OgHy!s!) !!9!5!U!쐣eOKC"#TțY?]8iG,=c~3XP7la(<`G`q8AZc"[eLeʳƕLFEq2ݸS^~EX(\(I< ԝn_>|r8nU =+LXcFie%-7e&2Lt\E)EjZL1S-0FlSϏ8gj1=6 ,Pe s :W|j {Kھ>XX? #e&5E\F3+Pydvf>6#hE ()Tm(O|ǧJx bca@OdFwƒ0XiQGdcNR̎LNNγ]mMV71.!Fh*a`+"}ccbuX2qH &̏(͍͎)K#"q4!SR4VhuGEyafav .D&葨((("0 ̌ (qh]\Q0.cMh4rZ=&Ic\kmm&A;8}}yIirjIjI4j{'JxU?3~F[6a>(ѠԦ*CPnH t$=WV^PVOW5MߡYgMYHc֋*^TZRES.qaQؑlZVudD9TfCi*LiPlJN]Y(_Yeƕjq&˸KΘqZqC&#CT ҏ}mf`69x%RuԘTfbK0ʬcQbBA>dbJxyhI%){rs~0AZy(R+‘R9HLT I\E4L6-U]ު^WY>J"r,JB2`y)PK8]LWA߳H^FB@̯}a瞂hĺ0ǝ"ĸ3ndX宖Ns/nO}M><<P{ ~u@7hYGo ڥŠ$;Fc@G8;#\<өG]  <M hw=n];G;65+P`0^ہN``~ jCp(C!EAeaqC1}C"? 6je6րv1.Ao8]@8B{|a#hB>n~psynu󘈿+27ԝXg&Qs459=@{?0# pZM3lF{p3,?gyٟ!a{(pm>/д.d/`=fC70ԧ'J"H5K\~¿ƍ b^?EnD|B]k4RCIX= (z%-BR&kOm?rw޸p0>&?62j4hGLAIþxq1GxPR*Ǎ+GsMԝPS20l<@?F-5Aո޸5ZxWXwq+0"<⢤MT8UKƱs qW\ %uW7hZpYӉQ\ňv ¸C8? ΍3n&<ĉIdGoN~:G ӊx0n11W&%atrF&0- _NI~GH) ^?`ST!|:lG0V#ӝ84ߛш3۱j웹 Cv`p>ݳ10v%%U'8V? _LHjȹ{<3&̬)8>'$r&cp{T`: 5cgP'vö~ak?|^ .l >/څCք|/@܎FcyG92]ұcq6-.Rlw/# û º]bO~qui;X\/=R}F4XLk6c9 b r&G/Ė$W|ٱVW.jձ*]إ[/vI!-;*ޕb$7SjU=c;3Ҙ?ov$/޸ذ, Xn}+Ra%=W.H Ƅ'NQ?RjjWr^\ Ekp4riӊ 2)I~<'yNGWJVRn0͐/BBl4ԉC6 rUe8T.j* w4eߓ(N*;STu˯lU' j^,^h71nFȘyP"\ֹ-B-΂d,NJ`/( bAAdHyղ֯dۆ4fi,5L}2dZU%3_S11׫=W̽H;Xx:O#c㳪EpFIB81(rБ-6!Q"ګ{dwY>ey&& MCMZMZr4;ej\A+XEq 挄2r˲S/dSYlN)ݹINuVR55I4)I%)RCm|GiJ%i"ߋk$UNjr!ۥɕ,]]EBLHuW ]򲪣r|ը纯UUT U+Wg`/*!mMVXE] k#ݳFz}IEDŽdAyZ8Z1~SIOrYSZU!ϸ\R㻤Ž@H55 IFJd$LEb<[ðu ⽉f`ׂX.omBw{P ޻bh'bh*6FU {'Za'|/^@,%#k& jU8"W-EĪdĢn+ºKuG|qXYĖ,$&niTUk_p "$DԒPJ2UcLUjj:Jϕ:Gr#y<꒕Ȭ g*]FhI#tM#44B3`i M7-a *tfpY Sa*gC~mw@^dQbOE*<7Ps#)7Fay 믐  ̟j_v;\y)`jcmAv3yf.fN5`={e!/b򥈥Rpq/R?- T@iڔʿ4A~kS>jmVҾU^#_WOjYQx?Vv&gR\)"K/ʥk%O<Xp1Pom$5qQ cXFTޕe) SM4PIYhx>]B IϕR)51JjIb۶21 ocR P RCk(b Wovm7) ĚzrjE oTK;$]++>v۽ c~ǏZZ}-ͥbbjW#0Gi%oFɺUh$/5?(G ~ŏc0$~b9EQ:_|F^}I;l 5wKa MchJV0E:\:Ǣt%B{ KuL/gds2y4]!T=AOI.?H+XMXPܥq>gA*KczM#c/v?>>~_zNo:ptp0JSjc &C0&51II1/gה_q0ބ10fXP+` -6ПPOls&\wV6= 0a&~j [Z=W^u_:Rtzme.4+k4xƠF )O ίu/`@hȉ+f7r}!>w7%,gҹYn!Kktv> KP_ ٤4*3ZzCǪljjm3S/`R _Z- N!Mhon6\[b6R\wϑc*=Vc=?jCZyF+n{>@NZ5/bF*#r#7i{YQǍԨ+ƌG(HyNd7xg{=Ê6Wvg"7*l.an ZЭ跔=Js'jvLbR53fg̈YaT2c?5b/ScSbM#I̯\%gˌ\}2|))enE1>=*U)@=Da)fn$[IcuhuX&L;by7q3qFzWhD{o͌ qڽ]iVK4+>B =5#>4`%ۆ)6ZSmD{d[1ѶИ`+UXj,m\Fλݳ 1g$b>ã0{ KHe K"I&L2IfLB&$$C!"ITBR, @!(}cVVc] B_Hg3s9|/>X$E ҐcU8E5IsT@U-wdґ G@2#Xa:Ŏ;BGYn;[ycq9.YK$_mqg.j]L,kc acSUU TE<',ݩӣgFsQ3Lw[,q+'+yN+fNj?g8IK+MuJ,Ty]̈Qif3ȝ;KnEFk\mǽvo[Vr_\GF9ƱlMԀxꉧ,'r`fE8;J9SU3GK= qɔϛ\o@^o1Mfg<`9={4cVy+󱕒c}fCV?8+/n-xBRgT7\c_-or}'w*Q?_n0#b&w[I^+Z\xm&}$=o%PF0 7f|>xhA,BeVVQ2#*RzTVj|&)ԥ`- V(!x lc 4o2cؒbی-͸"ۈ+c/bO~o&j`C5o(]k(FӜUʥWj֪"ͬFnԴnM=ɵ(zۚ\16&gJm<h|Pu<شZՃ4>\3 i7ѴxMmthJcuC2Mjӄ6ii|qkzCz[Qk`mD#hl#Yy&-)tS4s!&E:TKXܗ.S p8.jkaR3нAWe4ހRbc‡/L>e~>g|A`fNmh5@8 q P`%:X>qBx_]}%~1%ޅ&V#7B%B70vޯեh>g^}~$%zEs`@}xËWbCaADA z,EL Fe;{v0-[nrt#Lqjh8Чm>GulꖀiEP0'oeX׈?L0?gpjJU^lbPx;w@x#F7b;&"awQ\r㑗#G~-QXM7gQ;O-SQp2"G#q$*q`i9-2 v/kΘV#cak6X.#/a86`Cj~c>11_Cqy,$Ȱ31;VcJlYi$+6%90HAOPKL=PISA&ze?Z#tI %UoW9R2yWP~XaJy;RU496*pz9֧1ڌv d?}ѓݙCM7!Y'KG=2%|'>KL!rl/碗͙s1  ec [Do=9 V8PxWtS9ڕ{QNUy^ g#?¡3m>K,;&Ygɸl`\*cїyŸ_΂j ([ OaZ p6¥jJ4 ꚰ>ªzB$a-@Sf4(cCO# 1.aV-EWQ|řh/΃D*.m^4aS$E0 u3J$a"?JE>Nśi^t!:Q%,r\pVhЬEv6VZk`n&AaԾ& EQH5咸Oͫz4KI='=駛qfܚ%piQ)CSU6UhԕêӣAgF&}F F@NZOIB[%*%qX'{j}񻋿UZLXj`-P:FC#j -1tBo Ag}Bcr:#w#K V5HA 5Їf$&"p|wHdꓰb | .&7P[M`븪oR#$32R$uHYjA\, iD"*cDAtH8MENLm]'{LwXǿ *pʐ%DqEׁ /BEyjMl`֓&8֪16Mn?H}~~=~_Y⦎%( )Dn(/WS:`ʖ@Φ%r2mKʡ|2LhLcx, W<$Rk3`\r2#s͡jgQ[ ٙek,3ƛvsZ+*Pb[嵅OΗg S3`VjeSˌ[ۑCh(u:.:.Xn0g<̙+[F_sa SyH1g`^.@Us$z 4fp'Eg  m=E'{xK4bX94s퉮j#MCd;srÎ]ر;ر;h(|Ful]pwr߇ {)5՜ ݌>4Ap&B4hΏ{Hc_N`G#I#ůKuX4`;1'-cٮqux-tɞ%CR[1Y~ւ}8694.HbU(Mm™&>v ~fتnc8!;ݪu.4@W 9| -Mywt{>Sӆ#I? {YrU nGL_M%݁{ց 0=&&OVۃAcYp drXw@0C̄9P eP`~aY̍;ټ' K==⭇܁uޔ_8 l4r9 scxƎi )>s]u ~˯| | \K68ش +/cHgi? ؂.c*Zkl7ң49Y}]ZZ9flAMOŢ:#WϚdDeo{g)Q~hAN^Z0UiEUUnE&herU|w+Wrm]w?<5nk0I!vßWÕ_n/*}cJ;U *4X;<1*J,{T\,POfӌov?)E]C)!*mU2a.mTAE5k)7Vy~7L9ɚ?^3kz7P2VkJ6Ҥ3J &=UjE7%gaz<+Ŗ7fn^ jzM9X=G(eJSF,BW(5x&k\A yOcB+%FXZ(.EQ6XU<+Ė|l%69i!]552{+w >4)l&MPjX&и /SJ ج%E҈ CE#\ 06C9,{rhaVᮚɑJ쭉}5 5D㢒46jR'ktQ@#bjxFOCcNjH, Q-נ\ZZ#mPGjRRc<5>_b5&.R)qo%jd%ŧix|4,ޤ5^JأNh`'P-?<*?we1 $n "" 2,0QNHAA(MqZ5q4զM6mzĸ&ƚXa9}"ΡlX?m _fK1SU@Y(/ir'+; ,E&C2 2UZJRSdHPR>%RBNJO %$HrT#,g= 3 boeȜ6RpeE+#=ItsjLJ26(\MG'qA;Nw(4bS:F @QU5IVcwU^56=VS5Y!r>><ekzP )iluOy-P0yڇ>+7{4>]5o_pS$l7SO7=ϡ~z&j"9Ff3A(h ܚ4K}i~i<[dZ8ZL-P kygຈmlyh*^/|3Xs"kĚC 7IÚ%%m,1ϵxXE# C N01ҾCP"p8iFjz͚ 5Jc{Jj}?@?6f p뤰;YT':ppzybFcI7xZZ+ow^BmxBP\wFzw>{pbGs֏ ŜC9VM(gU8@xuq?Nx;AEquO/Nj[9WuqN ?%wŗ̺75f/NLN>V 1,vb{%Ө;[|;xR>prWJU}s_DщTMNΨ@Ə7𣋼:~ŏZu[8}D|E Bm'|.85go-/(t"шZ:s_lخ|l6bsll29=قldsۮ"G'#$:D b]Pr\l.`w,$ ϓTVWUD̮r]9 ];A1B9 (hr4*Ѩf,Ry ZDd+G#r"pvrԾѿ+`ܵ^ Gk4:ıT-TiL\Yn0˰/2,3,31 $vcxKʭk7V괪*RRU~V.Q*Jc;R{;G: rq+YȦcn:JFd)ﱰG}VuWj폴~UeZr6f_T=/F|Tg"S8%S[K8]ͱlsrx[}嘽,Lr fÄce.vLg2=&> 1,wvi9Tk%?k5t2Y$Dq"nG9orj8`!E8\IL&1B(iK{SH9#3jJCq'_vDSyʝi%SK,'r %pj6iLU1݌2ьQfL4-0`tݙ2y_ d2LG_d}>'.zi{XdU˜jɌf*3Y&w/4i'p y-0ws_c=om)]Ɲo6t=&ezX✖.kߜ,ȔjٯZ͛ٗs J*fwa=V|En+x O=ūt?*%o^ΒVGaֺ"tQޓDY3%R=V =xz1{GN]a92k=c`~53tRLrH[(m $`H#Ϸ\_!9 }ue1ӿH4)$(~I$ =5XE_Z#_t ^}Wt,RT$k$S @:;I Th$9")Obp/ yvOL\Mb&&+#rrC|ǥĠp!ҮZjBVCq$Y!6BLaCSl aTdo1'"lgqHLݢէ9(Ji+"J_1uBp:ع DSbsMa}aܰnBywx fkf?T#VJ٭aH=Aa+\89JI_4)ҟMDZYXI׃(ORS_US[Ƕ\[U\=%=@vP5,O8"Y=%]6mzI0H_)K0l>.wR )ZL-vj5!/Cp'V54Xք,(z۩g C|D' z "9&5xZpT% -vz'57` BcㆎS}&Tyi0(:5 : HtRwKc)j<)^xrS긭Mz[95YiGcݲ9S OkI7e.5ӍB 2{2ceey(Kk]XXXv]`9DPEEE-}3Ѫ68ƨǚ&5UcըʹMG϶&iLL9l?qg~e}yG㣍 Y&FaV[O?r&4ݑ Cƹߢԩ~?pҪ 'Ki.g]l穋 LhݷS c)+C7`?vj $ur.{gxhV.37kznP7I7M;*D2f;y6U+6S|}.UXzM|;]jsrE5zH]< t9}" v@ Z3a,tS|=t}M>|\sOzO BVȆB}24FQG@Ǘ9 3ЕbA\Ru!u>}p?^0zݣ׽C.RC('~n>_~fb/%||% x6Otk?Sn)qG>H^WBԟqsϨسu8Mhl6uF*ާVc%>V2e e摍ϯilJfSQM49αͷhf x%{Z1p"ơ4-6o,P 4jИTmnq_x y-5+8{wn}W0zh%KdhVWfvWc!޽LӺR8MI~Tc&X[Us<1=/gjjb(Gˋla5øph?YCif5iJ_&U}M (Q]HѸ8C5 TFU[UWo<=ߏ2pFqZ#-93#gjҔ>(BlQmp֘~+~e_UC,dlְڪ!T~q >O ИUEktI"2¡9*PJ4@);҃i Vgd(͑TG9du(9YgdrF wAF2:)SB}ځγ$:P^ ,g3_Yٲ5(ۢt%ggWk̮*\Jp5fuoQeEຬ(]EgS WgђL1P%PR]e`Yr#+sY<9Sg<);S2Ez7+;y*sIaSDn[X,4&rP ^pV,o4 P!2WdU薡PE#Q4NE -ZE'̃>8dY]p9dj FEvǮ"!fRzD1j56$HӚ1ZsL5Gڴ4uSv~LLJ=}}}J,}Zҗe-=/kMي\!iZP[OhgJ(~ԏ0h.CM& lXoӈ`]~񣇋5顸{ ٽ ]k4N>brK $B])f:[`ki8`ogg~rr9H};¥{Ev$9P,z)YJ\BcU?t-=7L0cQq-)8ť|?ct$`]9sMxB@w~DŽ q` a2B5XXQfezE|^&WT_?xNDH x&@QGՠoLNי1]e ?>Ǐ?Y>c2D|oI9d 88>//w@<)3̤NL ?>ď0՝?{wgROyN9%x%cd5^ \{%e3)/&.lخlln应wEVوvϓ:^<@G!.b(?hDcy2ĶuDڄ]?Sm+_qх*?J&v%} (nC D:\fkmt*t3zs7]/Rk3ɰZ嶥jm\Lʤ"iʒUTZM8K[T`T}wj9ME$QnrvJˤ3i ƗR-gE)v8T:Lũ#5-u&0PiєA^MԬI_ ەO_ kx G҂Ҋ. `+ܔ9"T0k#Qi*7eyG(AuY`w]`e]]6xM0xD⠉hhԦ:M4=$ΤvI۴;^37{y{wiJ>SMM,)O"t]-)n~]6pDo}=׿%؃ M|!.oNP9M1#U3&_,UVSSE嶶i] u.XwzHb=xpgيlAS!|(^UEUY\QYM29m^a,-<ٗоY.e|9)-0pvӍ*-M0 &]*pĪ̑,Wi*-5,TRŮ:&5UW27j{/h*u]9rFc3e.KFB|P e;GYli*.RQyVe(S^&OF{f)ӡQ]FV>L+y>FG*^3T͑[|oF׸[SQ5SӨ|3kWo2|ەۯ4 \UZ FoTQ=Fh.& B }/P06Fk[yoHCY2uLRz` XmJ-ocB2)f(= &rkxȧPPɡf%*!BCC݊S\bo+6znڌ5]0Pp]W>mĤX6&*%p¹J[4,p(.ܨ0/6&|f,4b96Dx5ƌ@=|mA{D'Rb45AC[R@pDT#EMaP<0iBSBca<}P{{$7eh6ugrј?v6ʜMncښ 0mx9c8GXfH⽓1[s)V)m)nql( ".ɣ=åM$wc:<_O&(ӧ &⩒iX tSK(kRˆpp [eg%yt2'9drcN/8&s-[ֳji'7UjCm^0}ƛnr ]"W4y&걙ztG7B=V6,Ԣ\1ovaM]QD:Ro ig3tt:~͍[`+<(f"$#I̯e'{5N1bhof=Cc@~ Wad 0*r޸ΞqM&:$fϼɀ$`8dA>ؠAd,-=qB~#M][}wuO|ʯ~g ryAXzEa N n2.SY4yy]C4b9eh'{̻Ja,#tZ\S Z!}5}L>U3 xG;h^ms{V3]8 Je|INS4hTO[}?#ĞIybZg)W*7eƻjArz}}Fwㄧ ShGV4\ԭ~b&Tb n_}ث ѫmM-v,ϵ'`| />g,ƒ8B-^T*G_L|7{٢mӉ9:w [iɨ Fܤ`< W;k,ExNT2yg?fۈ_FtA7 Z9#9NZ֓I:Y' \9yv2È?#KBh&t0UjgBF5׏p6XfƱZtR'e]o;v.p8qNlp9&MNv-mvJWrT+[v h5[@QZXA\1&  !:'}<_+Qˌ7ъFQ4$M,c]OxaYEVW^eN{{J;Q>!ctM:^FݏNtf6R;Iha:fmMEQڣ^C")RQXs< uM!}*FjT tj"W5=dƲ7k 7,jpV7PE,+s_ܴb%4J{JCvJ.Ym\)Weʘ&+W*ߩd~ Z4ZHAD˜*@#S4hтk"6P:MAZ]ƴwRIJfa X<1ٔ0974dnҀ9~sb}kIieF˜, WrFAeY*]YzJ9ZM `<ĉeȖ" X-V(b ֣.:c 7(hߡ} Skj.7-}G\ܓ<:B %CYXc)O/;Qb-SOYrʫYVI6+^UrD~ǤZ[ث&xFNjr;^Ɋphƒ8$:j0.kQmRD5jq*P mZjj9Ffy|Bu|A5WU| qOPٍQ4` z=^0>Eü^P.Z\jvW]/ۧv5GT_?(g\.&9=A6Á23|43 #7964U_.4&ab1۸zsVW6t-ribAL"c+ǶDObIcN9TYPs46LJs;0c`w^@nwUf 5V1!a5&9f6Ԍn&لnތc+Z$_-xIL|1yyuz8c}:`?GgisyǼO& ٷ=0ۃ>9g4OdNi8)<|.[O+q8O^aX\ūÚRZ#u}g\"ӿ30+FHaW MqM7-:uqSu_qp?a`ә>^h^; s͌I.1_^ }C~w=ue|>r,!݆!ytVLX1K/W[e2c̦Z^ً@'~yy_=ͫ O8v?;NDZ%n;7zImvݺ6[E֪] Bҁ( ʠ*kT.T`@`m2sQS>w{y+8!hn X_8wp@7)82}]c^e?̫ <߅o79|NFb-COoc\#Wp9/|^u¹|<~KhM^#80lO1|e.c_ރw&2؉_3/^P'=C%i/pZO~|1!} "zARBLy,|>Osyx4c$csyy0;G{ W=hϳ<*wnY6e6NY]adZyN8K΋eT/·df }?edNaA{)tg"֣=Sqy9 ܯ3 OTnawj~ m+'`6@;vahO0y$>f{;{n2ŵSϰހa<_֜B~c7EE]TK2/{]B?J5hgI8Zrs̜y)u&x"<参 ǃ-hQt1"Eo2KnB>@6X=GRff}6CKc@ ZbKI~hyYE6VhF~ , )Y}E SQiT]ILkiS;,>Dj+>V%%9ib"6N&!ˀXf2ez˪cU99Ty:;QޣeJZV2V˼jQAE-\RrU!u9Eqs mohn0*]7t ԵTiU)CjꔴXSҤZnicƄYEyj0*h|JTgX>E:'?'n݅=XK۳|ý49u_;-s)(WaQV+TѦhEJgάkpnr*[yZsrW^Ru pSngN5pwoc-u@[>C )VB.1WljtU(r+RW:긂 ܫuonkUsJ5_#9ϫ}*s:Ļv|OA?tbqo&FxL y,j+P[ZޠMyu7)6U>(g9|'e="|Ruޜp-ĺV|e:^$Z"s~|KwF@܁rҪ d%"#O Y_%xQgTxSJ}NBw3h&hڡ{Pa\a.UF|rFBt-:'kt,=2GWY!D.5Wi8{ 5سly Cc~;7zUSr cN1d5OQy|Lj78SEc*hct U 9?ހڣس^V>7CPǵIrag+Z eʖʚpȒp1xiE%ɔL%I$1ILY( IpB'Po+h{$qh~\r߅uc H^"sʢ!Sʩ%) S jV `V4NaRRRl֝Iّ]hϠņd6h3//LH/S/9t5Hu i࿏{vw #A<0sqsc,1|rl7mF1f#+>i<>]s 4y%lH*P_rsN0+8t&ܧH()^b)e`.ɐц?:񣛜M'х.;q+\̎ ')'/aS} 0Pc7!g y?QK9d% .5u!}0klVO)^ތ6)`{`5=0XI}ƏuaK}u\kWr@nzcyGY<D^%-ći 5M7a&X90Os6&j>괟d+V곟Xl!y}K؟6Sϟ!s=U|R</hr뭁H(!0 XBqaC*5?+8plI>| L}_cuM;9`ͅ9x7Z}k -Ǹ(dD\,FN?#'')3q˥|xCulb3׻Z>W]yD&Qp$ U\0!Vs%q@ϓs ,QTg~)!ĻeW9IнFsƺFA}pC&k@&0@>r0u-ys% kTmr YWIe{1܏-S׀CX5ȵG6(пјwUH}r:~\eM! s?ΣijOZ{$U1#1J ]v[>_mpmu })'hЗQ* 8fy >=w$) t9Ïa|$љY;6f_)b 5خǶ&b{gbx O)5dt;mG/Q'*;)p XUf{&wѺИPn.NVjd ~E=Kx:\?דDtO`X >-`U2K@ +\gQQH/5?z ORz^Nl߰Ǩc~g;h$i= lYnl WP:l7`{'!n-n_%sŃtAzDwm_P|ت]>`tdJa7#u:IDQR-4,MGZYmbÛV͵ǐy,G1C,E'9 'F;5:KQL䥅L>f2H6lq~+93=\ssAOǾٚ5E^8BpDJ8jnV_D*P#ĸc7|8V3pw1wG6Q)pd‘GnRx੅M؟Ld4HR1è:g(UW^1DwAýxK#>6s13:mԐ`ˀ)𔰓U W\ᚈxC v~nw]Al}`hi`, Y̹ßI)&;U^U'4$yRTRLPij݊.V8uS_T(u)G9%RQ}_ι_m>@xV˻JP=Eՙa2åL2TYrEm5WEA{kߢ^y䶝UrF8Xʸ HjFJޗO4:Vđ,Y**U" 3X|WF\~96>>`b:ɧ20ہZyVJ)7WJI*vf*RRY N@9U*!k]S˵HNZe^+쮳9ALk=YHI;cQ5WR> BK?/M< *ϗ'';A׃AA?L pew^g;n ,$$\ T D8V^Bq2ZN6#2Ң >q;9oyn'[鐫%g+{ ]Il% e VP!d"Z "8<<]LpUq-@ԉhܛ_! c> ) $Uc֥ &Sa (Hw#)<as29C>q-JFP .~1͵~L{ MHi 4 -JG ll+@5c`*o >40E70cۭ +W/pdK,1!a7fF3bll m B&xjG؄;@ |Ro.<)> ?:cm9&'4^6O/3JκsܺbCg\o3@jiy\g6^g8 9@/}o1DNQCbwD&AzkN# -:Ncnx_`='d62!'#FW?r&eRR;㚅ͬU[uv ~&v6IZFPI`%XEnE㮥>yl>7 ,f=F5\3KTh\5!%>擤!s`1kD- ^[csX|0>CMlFY} s$A n–Z5d||X`5?b ߝ%b&&ϰo_`aM<~H{xy71RX:8{WYIň%@#_&A%WL$u8xWXQ>8B>0uUJ &^pKd|G|FwUV3]6KD_PLpoFp*%U~/N )ڇGNvSO8jQv85mjd+~Ɵ9D!Mǧ);Lr+jOU,U*.V_ZUbRmP*Q!y7^'q~&>@^gUcS;Y))*wzT0TjĞb{*tT(Ѭj-JsHOU/Zl( "Tr O%*HV^SSSI++5W0*7BMJ3:eK1VmlxRS4Na\ɑJb1ǐ' A9J@!%7=A9$e(ϗP&#}cr?8ʫ o6$,fwI6l~vIHBH&@J$ $AkJJJRZjŢXZdZQt:0VvږaV;0/۽=s}o |ӷCyd}Ke+{y3݌slP⼫`M2|ey*(!Uh+ت`>9ò7RN-F 6`|KZi |A|lr :)wr(4KP Q~RpBpNˆ/B׏܆hDѲ!|PNc%|a#hpJF0ߕKżbxLq㤸Sn~ǐ Ѐ (%]%\a8g|JHlHOq4di X65|ՆoexhE0W!Д./n{y೎Y|< scZlŏv0 :rEE0&u٦0k@3nIk%e o!ya|HsvB!'KA#KYd>`]*Y Ճr\tuL-1GlkKx_ o8I/9kA!h.\c 2ꄯMS~w9Xeqrđ&fNjn q/X6,ao=puV?&kyGC&g3dL(9!Qjgky?ۇG>-})wžk) !#6ko,c\ɊA(fC~yCv&ړ{OK߹F*JyW=烀% qe#3pH\΀j<9y{@&/|N:gT "bc|'ku4Jg-_-__߃?[mrrz{ҿrGQ -@t%"}v̨N|StLF8$P3\PEM/3y^b}"3ɹ#LV92l+C 3l3ԑ)eX%x?<>j* -tL4&qXsh^x_xONjqJtR{L(P&˽v+p^Z!3Ne8qY Squ sa(C2M~] {mf{Lm5:'`6?)=|w|_APŎL籟.Li$_y=Bz?"kzDZ}p_!B%}'] J)(<5kn tb#BŅ9!:NwpCȎn$|_)nSV"xfO*xlyxH%k7xxSspd;I쯇o9r8+[@ ԁ8cҕhC|E\ ;{Tn6ٻ4wX܍*ɝyyrgEo/(3?do%3$$`BH@ Ud(Q"EED TPM(Ȗ-Z""Kw;=4s3_sg}ߖ6(M@rAk&Ь%vJ^ ; KgM@ úZ|u9I<9v,}l+팸 O8:C_ mk<b$J.!)A-A&.~OHtOHZsѴѪŭ$NVi%M["VxsRܢx:{u>4 gqYBzt}N}.ѽzDEģgq%+ġ~ŢNy}d8/aKbrg*bNE} A3A "M~K[4[<~M [[%VA[rNj?]༆ey|1G@(h`*]S@K!M>b Gs)4 N_(| SC,u%7$ђ4ų `6уv-dwCaآļb1a11NSaȤϋ8!5F -ESw':{U!<\>y0?*>⎤3&C̙",\&57K?GӚ 4JC96g! -dSCI23!rm3A{Z"%I cfqdZ$-*<|6xcw!^"0Å("qkNƤ5!96"M%3 Cso#:24s4/%RLZJkM[f ESjhrٓ\gRGB %xPEWjI],-VI^T0 GFZќ^IRkA#VN,c.'ZϕW:g/\hL1$iRUkkuRG[bNڱEAΚh˺نTIn}Rɻ@S3$( 4[f\" h /\4DSGwΆ݊+yލCF3gDv6gTT a[>u 4UFhF4B@?hI| baX_MbHДt%0>BS .z$f*|ըj@8:FF0'YPyH`pΪ4M LH9:KFf T{|jHh#OS4puddq'qm*i~RRB7 ೛%ljۛ ZoIq@>CRM}jP~D&%x8(D笚|9*Ŭvh֘Ws/wQqN2,AI5F(SB)RV$M׍luxUyd./(ǘn+ДЬB݆j>׍l[ {T38؂r99<cBvOUdN[`(Y9y 6izEqe-Z^Yqխ暶U_WݱSv7٫w8vC 1r17~¤Snco5{gκos~?.x?.|EO<䩥˖xz3ϮZ k^Z~Ɨ7ymn߱s[{o{>Og𑯎~}Ϝ=ϟ.\JJ]IYWQdg+/RKrR+kAݔ7) nƃe-ʅpa2b"NLUV܉w+3~ܘP~< y G+K<Ô+/b:&e+8fMy[sP>| T}<:I?().^J>\ͻ%~__b2lvǣaO G8#1<\|LҚ7?3' t뮶;f[mq&O6}ƽfϝ7 [xe+.j[ȤƟILiLT*Nhj~o'G~83gϞ;w?]pƶehbm֮-;zW_5a3Жj꩛sӲZdOG. 54PCC 54PCC w +u%^]/9 _ԏ-~kwe ܝVOѢ'~HPG VFծ=Ͽ1}C&|ww>/n{y /Mٵzo.Ѳ{_d'װ6Rk]/޳̾{ttp|gvzffgi;fiNд2/AoF)TLP. E["-)'-߶g/yޘᎀN"_ )Uka"a>W~ՓĢ ?J!&vezzc-̣>$CkSFkCe\ )j3J-)|ʉ鱶d(;nZs>w'C71}fDmX4 >J6 3O;F.^hW}F]υ {E"f(/c`4Ѭ7h^!}w>85KhʙHAΡ 5.-F\ |{Gq{im=/ZtUppdK*8+#KD0 3{717@AoPa.57d0~DK2qH9yBCOmHLU1tՔl2"8dX2, K%e( %6@du= םj^A\ˎ*$,!2.KGv[=ZP $A=]+#zw^ XaF*bZ*SRD &^\4h`C͏@v~'x<\վAGi]8^@(WI<}IXA-edYÍDVa( e Zg:8QCHz,I^PQ%xI-02,탡2Ad jk.s[g-HQ$Xi}yJ&E|Ne2@ZFO AF# zw/؄9Ќވ0RYeJ($9J;C`M􂜇}0 P/69&Il됼:y*LYT~?W@lͥt%SYBfaC*Pn;ӑW_ހ}]M*'(bN)%O2qbzȅ)}^KgX $'mo N_u_q y[r=P"fUdVp=!:qܴ.K``ZJ s^9z5-&i *ઑ9u(I#ObDQHnƖP VKE2jVwn '\G&ǼԲ֠m0NJ\ɰuUH Z %4Y@" 5Pog1ip`䉳ߦq/g(MEɱZ,T"V $KM2TjA})Ï9;g/(Ė>Ȝf+Z5b m @92v~ jl̆q'G%7B#~̇ސt& n磛#XB. xю?S]1a^‹w>LpP8:H"Ol@H4@D E> Eߧh x 3{ =p~@bIb)y`o%65~) }OztGr( yĆ_ x {L|@mT+5s7*Nȁ3GR]xڅ)|9x^d\ \dyb.pPdmkkm_"8'q*)c{&B?P|5 b} \hXL35j1|%/h`?b!4У( ^@9 Sa6r%'Pb 5A(=)|FIS|F!'AcIn#V4jBkn    {C.bE aԞ=ag*"tVP*GhHLf)sUQ͚H%PY~5[y6V!zgkhpwK Ly}\DM3 3]WdkijJ$#d=U$yA 7B-P%P_6`ׅ3$}N+5AIL62U`#%yWFSE E[V\Ks2[nh`ԱCbx. 蕽|Đ Cű-NV>ߊ=jVy& ޠe=ή0ۯk@G *h\̰O^LA m(U42fwO'pxa0|YPa47(t%1 Ο;\0^vwvgԟg oB l;e^cdUd ~Pb07(PfG?^;87rzĸ~_|)8Wх5SSrITdC!b@ As/=s߼tD[gnyQAA$LN'3 c8FBm}.)u#=g4b4~noUc[Q8ܾMYBy͇fBHOI8t8:K |H{ȠLdT}ūoݸ\7gMӫ[e`Gp=ϡnO"@EdYs@\4yjlflV/o.=ٴBj:*n%}lNmg[SeJKn/T^P@QQQ@@PA/],YinmyRؽ e[ C>yE@)H;a#ssEfKϖ?,?_V3|~lEfAx8䌃XH?Br_({Jh3[^^0_S 3`p^AkOot7$磐dmÐА P+6/ f^ Gp1~)$T1|RB.玦TMI千.YoA777SCkCɦC ?#4wPO?Nfo0qZbNYLn~(VbOKmkZeu8MB ̵Օ??#Ȏ,kNDs:#:'>V\9Oe t̚R2'L掞8Nhmנ镡1!?mb#~GPqEfV 7UO$K.R+ \f9\cLu%uMXyߪoNhkGWF 5Ak(9ԁZ$)93Ε4RfiNb]6*MZInAӉW2v -&Ġڷ6[ɐiuL Ye>3YK@e6rU/"4 SjbMCb"5b@ ЇIUBEI*w:SW.+Է[i>9Eߔ+UKr7Se@p^*6A3x!Y!,!*y-vANEM&ACӫ cuԆnvC$e^X_/.bm[rĠyb g \)ƴ&} IQRVmz;hgyn46,bhغ ;WB;b>tv/KWt:A`jNmg\}CEŴ6NΠ7Ҥ}4E111t"/,1AaO\4FٴC AJd2ȫ(8Um3R HaD ذ$[n+A? cSΙ# ͺvUD֜Zא )F/ (F;Lj! 14! rfס^w Y]1|6QHUU5gvŹ=utsԚSͳe׈2xRKFEMP81H|ZOgk15zq4`.dzXFPV(3Kd&Z/asbP+KVPBCǎOf{~4]=|RPIZ-` I-|M)z圾ЂxME os?߻IocA?c)ґ]쉗RtIZbAe򺣕EHE)B)2*AW8/1/o\#=s#ǒF#l{t%/NՓ % ʸtM~aB4|}MG 5 qtl^44ra8((>' ) 1tIDIz*5+ @};XpgӻG\^km'#!_ٴ_DTXrR,-s4A8K`qqY9UX[~M6®QUA۠]tw \ԮUGg<6`**9{".#u 7 '8́j');m{k&h ho''DX_A!NW1K)LY[FfZy on: L㆚ P 5ۗ@+@t =  򆶤YIVS~AƷST*e\W;^^'cF:/n΢wt@s[\d FPEt$H3>eOsJ0)/(kh@>Ӭn fvhN1{vgo'h9_ >u6EgZcin=aqWu7h4(N~ ="l'h0SFI721MGMl/ 4d`B mȠ84'<@sgV;5Q9Ts ny*rQ(FRiھ,&#{ZsJgK|ݶ ۷6@|ftgB&'=TeH[H}U{˚6˪Zs}I/S9K 'h߷ T7P1!O I=;scPcƪQm%WY.(IM7ـ Ad|LgGX"nJVx+9J%rS}Zd5LV%THdHm/6%^AfdP] ={}-*[[|SJј*9(#6a"[R)|Υt9Xe*\of{{8 o@eЅ փos{Ȑ選 WtEi2ȕMe*icAfa<&ZOki*QCP:gZd`"/zn߽ʯ'mytSqф̚t^s4{g%wQ5g,lmEڬJgd୛ m?gz݋NP*|3I;! RʼnC䖸 5mkey_goMi#֊NǺ_ъbZԊ"( D K! ـ!!@BB "ua(nXQHU^h?ܿʪ@-g{~o٦w7iaݯ'^ EQG TŶȟEa|1Hx]&]-E"͚`^?eܡaWtLmqu~)~?;I _)OԄi_hY=A.3+ݑq+rnW-n/^2禰gl}'=$qϛ2 &[ftC2.L?+N z*)ShSes s )[+,vU%8Zyps T z;v6ُ.3 -#0l&. . x D ꞏ|ces`"5^Y{JOD'"E5 2p!8 ;Bq(7n\v{>F+,Uyv.5ก .I- vA@Dl 8p}!:.uXb,15(`ռbYȅK2Uw ׇ _ yC7^R@qX;o;w]8|6s\FV[a4,/Aqp[k=;\ȯ f+7@!H26:C6 pm mlO +8{q\ׁD!P@rԣag BdIR&ԉqMYϢhc.ƓޜG{a87Nso5dX݋`J#o;A ccx⇿LNHVvL7(I sU g F3LcL.SITS󞞹's1{.bXܛ|mz^y3-9D^鮒d L4T ~m8d{嘳@_h[;V'(PJIki)%F'\WTonuwWIeU'z+fjol/c}I6K m+ΨU\f\VnFeoÅwE*I> fh:֥ݰ˧֚.n&(袔[1Z(>D$h $k+{*%KGuS[o{d<5mq|셽qj??gpzB9(RF/oeV[׃ZE-M]5rI@.i' 7crk]:_Szp؜q봋sd 2hD Ӌ>n_ҧʫW\[1(\Ax*P& e~Qaўw5rr,` )||dh2e"1!>LpW1CBzmM><\( WyPzѿdf--hxomP1^R#g¦iIIVD6)/D~R:.rЬ,N/TV ֭],[y )u߃'/RBHoƒHgyQ0 L=ED] DI(>݂]+Q[W_M[f1-cC\ÿDa)5:6`5\,+E$Eq$rV7z߂jZ'W ۄpN%4X`E3@D߅P Y &( Nv /Wm FI,ˣF9"/*J~ ?*\8 'C| X d}51RL܁i k|s'Z8B$1"۳ uod8:;r8 8DC+D8CN@;)/S~l ZCDݑA5"*{&#Ud8p@>3Nl ]80 {`ma CVsX@s`5Qd3c&?.n)TC*בֿ$\)/]N`,w[V6/SW1F@g JUTC4$"1 Bg $*AHP ' `jAj9H7Z $d;A, Q^jC>Ak6*8$l(|g<59jCo_j@nEDioK 0W, `{kX3_*yBJZJEZ n $/F.D>y;5?5HoCIH>,1Sd{jC<, 5ujS h5<:y)!j@Ky4@ݿ4$ig>3MM$" q:y;*CaӇ,.1ҽ?'qL.\|Q&$NzEseP ONM%O_P@<Ґֻ<eh@!Z jYu&o5C\D.S^GU$ͅ`̐n1p=pS4\:9x Va dYˤ~Hg/$'gK'd= iN%  t=n?e5&S74!i HCJ9@ї^\Z;=z4C1O#  FI%>aOЀ~9x,&i<4А͛lG68`H7K ANmtaLs)JM .QY2%Y")u"bb] q@$8@50Eڿp0̿bv9YCu7DrV,8dI6Rr<ĵH i(ߴ׵G>trÛV/,أO[C0L]5SI =V E fKt XBkUSjOL~Wi_  鶅 ^9k>=퓝#ۃ͑QqMzZA(VIXb>Yʙ,uIr- RzV]@j 齒B  dW'`#g! ƽßo=UǹC;|{Z#"[o]MIjRE6') oTWV(VX5֝U[++h(\Aj|l>m/^7K>fs,ؐ'NYJdXʮK2_f dzh F<ߪ!zA=P4;A%r@Esw]kЙM? _p8#,-:Ym+jQYņ<YK G*hez}I$jp^ixo\GJ9t[ivO=W{y{7QUmqYd ݄/4s b)R$Ī8V}pKsةUu_8t#Tov4t_6=sꋈw]`X ҊsdU4a|S$$E#LMaT! LdN)σM~kcK_o}vvo8~,x8<⻘4j̒byƉ_Qy"Z4`k0mz{i4hހ&ǁqܵy 9O"n|@F 'gR> ݈6fYM\d2m&Vp73pp,gnWA8vx2 5c/OOp>D962{"1>m"S^gEYQϸĐ҂z6 v|yl@jGr j`bCo+ o=]ޜ3a93WgC3"㧉1ԸkxI!/)uJTm sE7Cj?EXzf?l^s_);xًٳa'fKf`cfKʂCgy!$a~W}g{e(]P;# Q7la 8 ` xd( yBr&gp>;~`}juBR*G!lu))6<קix A;/CԞPH i =H1= bPz\Qoh<-^(eCUM-tZ':-"%,X8.λی-~^m灴\qJw֮ udO~q|2PG( ac>lpNj2[Ke8vGEłH "BH)$${$!H*(( X.l"(3{9W?p.Y{ofF0  dI_}Rb/Wx"xW]^ <|s8ųq0}<Nb8!cVx} sw[1,b]YHLelQuҬ`ZdY-IVQ¾guDY@< ␋ _x:[9pˣ!5Q-e'& 㤞-Pq$FDk (ܚ|9ǻenkxn1P` #|"Ȝ̍j]w{MEP[[L('1F:רgKjJPER+.4ŧ3M7XN-Kt_>kDP"ga^c'qqK>˞^=SgsוGŜ*OJ)*JS9ZSXW tr]:ǐ,ȣ+*,4EC>UuӜХKe"[q`/j, IgMk@76޺|tŦڳ1ѥ|~y M_La˭LQM-àͥXL$}9UBȹn&;t$=)Y-0epD`anj{9vZkgoaGQudC g Kv1[F?!RS zdH4%Y0aN8u)H d9s8g[lIJŴ\u]I㚚G1hhjsP+]ռ\}xn~sDr^G{TAPI =uz:ڬ5>tk7ͤ﾿Mw/uk zDs7u#)y)6YAO9e;푷T֦P{@!AsSwZCRs?U O>Rj&;ߨ}M[Z W}^w76MUK,Ց=8RAbyڰsԁ ֫ uTqZV"D '(`A^Iyݿ20duQ琯UPi5w["I^OjfƵ<ɕawY9*y;zZ]k^wiT4لݔxwg 10dBVI۰ՙ!NG|=xa~iw\p7?W^)m,m*m+w4XƋk/ TA!GRpn .q.qst [q3c>VFn=RRL{ e"Ibw:C/8hΎCBh$3b\PpJx֮CyK2c~q_vdrT]ptInpF"Sp},t66!l"FMl I3Ff9X!AN{H+RƍQgSwy*wlHڨTG= cpsdX"[,G\Z $7@t6KH  /im>MKH{ D6XkBA2]d (["O@~bN{@i 9Z F?x9 ϙaciǩF9ݬۜ{1HH@?!i&Ri#g.Qi@sTnBz!<y1v> eJ ?Vެ#Cbhw/e=(}z,~L:%|HŞcdA@6}GnRŨj=˱j*n_J2}rcf32gҎ$MIٔqQ~7לG᜻ vfOJsE]Y*HPkPNaZnZweCI~T~%c"7*kHNT$Kc͢ܧEw/ sGxvt'g@B6$7ϰ&5oT#G:i)E edY$oH I؄ld)C@+R>}/Ç}07H^0iƟRO~O$,i,K0)QN|BȢ`z@`/A\$%%!5v_k]7t 7S.lt9nF f=ް|܁Uǵ6[B!ڐgiznT[$߮sڀxoʐ8dgWos0f3iApZ@bڢ2Mq? n.a~DX+"UP"  "d&R0 w~T]sJ/)h k0x QbF-&*E jQQDkbaĠ8?R9Q_3kỵ@` ܦO_,Z_t86]aɿe?'#a dëcV[L:@?}b#o$`W{tC 5@; A_m@%kPkj~CfeK0Jjn@A((g4:I?|kXyI*x $%Rtfffjj3Q*jS$bZ\uab'q(\} Tp0zSs( 2A hBmT‰c4탱!]~^qD:M"!#M)Xag 'd&n,D#fa$ȋO0K!G>(B'j m6s+.\I9MD*9N` (7Fx!$fツ^kx]2_K,GT-t@B{,Z_]$$)\Na8XE= Ìzs\2Nl숄^ RMVBd2ePڠfV*`u}P\\cB瀿<JmQۂG0ց~Ӯ^ 7Pz<^O$B°7yi}>Ǎhc'|>B':8<WC𼍑2M0,؀oNXׂ䣮wvkʯHH|;čWH^: 4.x#=h1, -GrR:" `q'yq~=@$= &j ڐw c!o& 0 *r4bY0CFsȒ,!M[t=NU")3`^D`0~폰I𱤡-YI|89H  #D:#eDN3afȎ\E%+!_q U7xw܎ pN}!kH) 'c-uh&W؁/1("B`p7Yke9⬐˶ETxATGLroc?KO~K RSS?{i_c#:u6ᐼJUOjoSp 'r"Rqΐ1QcM#)'XkF/%mG{~B?d%KD.vX5u3Qi`slBE|q볢0H+z/}+K~?Hcd`v1n O ?dh=L-6kq=NƙLR =d,GJfb *`[%ƶh>U^ذl`=>ԧٝG0odg>$nޤ__I\2s)pFn[lu4v?5c5vwֿ) eG+ ˙%5oDH:2p~LξɹΧ}⏰O2t.KC 7:y, TzdVG[ʾ=VF9Pis.)/w"`8P#~}bC:1J"n!ȼ=sSbMsF])c~.SPnrn(W%3ʓNcžQ[=T ,BBxyɮht9e5hp[Ԋ˓vja̭J9jsT}vi.|oC#$ $Z-2dAC.q {'i:&C D,{ږ^;2(+r9gJ' *GUYs[eu-FvAdOse}N6V i(&A;}_;c䰛p­s}Vt*YIVe˯W6嵨~[TsdJiujejekq@H[2YD,%!iAGF>s:jh[i~W#J:Q#gW *J-eٍŻuMj*UgirYB?uz@Q5B];sk:~#-4)A)ԡZ I - *HDP]@?8*~Yѳ}ssv: {:,{~'ŠBzjZƲԨ3Y i%9ՙp0W(D/D)췉y@4!M?a{g6-—KR.~4qj4h^\:Y]_^+(.*N/gd)ŒԌ 񟱒@:mCySn~c+ǯx`q_8ΩZAd ёTaiEfFajŜ¾#E%/KiG $I;8-88 w>\/~e*=3rpAO^?۶[cK8!)LjFU^Y\Q/?//[>_LRZbٛ$8I rԫ⾶oý؁{q{nvh䘽a^k]gOhV0qјĴdL^M0TWõO5Ki1oxU+q@6\ET)I}QFˣ{*޴!ݖ17ݶa LjiO$3#>$Mr,ȅ#q)MU2Cu6d7m\N ;~8I jTDfl\o,jo: {FeDzS{“zbb#; WקjHjNgv̦_ -8$t%diF4;$ݑFSh䑎T^Ŗ86_p& ׄ!q쁊D$߁!c`V761=/{5JqP)^^ >;JBf6gdtmB᱃F՜ACƾ,ǴPOhޝ6wBš(whñԉ9mb%~cPI _}8-ۤН /οQKrk{5.T@%uR=w1щXE_R^K>KC  />/iE%FoZgaAլ HkU-ɫV"WCW9FHՔ}B[Z~Z/9})gOrveNfB82GuRbE| |#5lYwT [`wi} ӣ}x={ɏPcMeC0cy^a[I2ކP_B-7:=P&\hΆ80dWPh' bpN't/}hc{6m@]Ĭ Pet7||ϔA$י T%OX黀 >]4.#`\5ƨ LϬ_9,P-R ,?S@5"Ib-adVp'EBP>0O]QM^[TzYuQ Ɛ9! I@ @ A@(rUZPE *Ȱw;9]笇ظm@X  o#D/a$vla,|Fk~`We0;xۜ{!p .B i+n hkrx[6?nǹQ'q-[FÖ8āh=н0 D Aq1O#pR=%~h@m 3@ ҵ@ez$|e[IZ؀ ws*CfV=zG%v?&W0e 'waO"w6x   Æ9~/ϸnl$;C8 T7[ځ|فa䮛 N!F#{4i!U!_ٔ>oA%W;9-<þ$`ODfhl<%n|TQG wN::>r~u.;Zm`.W&<AȍԄȾD WA3JJb;D|IoKIyO%^H>J3*dS 4p1:?,y7s_pyP]yW~1GyFԦЎ'XGE6&Z,L( %;Hi{I 2+F,,jB&)&:Wn*J.eW{;i4IsȒVIT|(0g-$Z.UíN2 +TU% iC nE>rSiT՟՟y z ߾C7u27ۉZ5/[|ٲ[WjʙN[QM*jmU-M-4cR<7U>42_%di> 35LA,ty ]lrxOgwۂ_;CO=aCAֶ2fS<֨ThX%B}0/D=$קUR:U)?RHӎ1thkuNf Gkf,ںkyWS:s-=hUR*S Y#0deIU+*"0$N%ǘq5@:g6t1f_Km^t?p#`:Ἇ7R4ԲwVW e2raS(hr+F$!ˬeHP<3Sd[X15@ cbfq݉Ř]K0W4t QrFVXY_aS֗pRK qb\&-O+=JA]4_8̗-,qi O 1P= f<^g̍Kv;φڻp;iSe #ݓ &ZJMr`:/2O_^*yNi7.*a^\W<ƴH=;af~.al̽s0W0}Wu8/l_t.ҳWl:j~IH (" ;BBB@aG(Vԩ#.uSw ,0EtVEq=c= bʎϼO;~|}&GPnj'Stbo~~́ՍҴ껥? eUe>6 S9Gg?2ɃVEnvK7rk. ^H /usYq[[ 7sbMFuaӶ9Y k U E┆u9&NMit tIn3 .3^9w^SǰVՑ|פЭ37\X%XQ"L{~:ܮԶʬymdmC9TeZl7$chI-if+ qA3$MScCV{n Э _}#/zq|εIJŕJ*-A#HOr{kJxBrA7좑ܭt~NZn2I##rߒwYQ۞7<{7ú{cDfknNS2KR2LH.$ &zrX艨Y? ^3C \?bY>.Ԉd#hݐky˰qooLlqd)jMlp-:2{-O)zIH8!J"HX>--t?x9g#0 ee:@i5 !e96̰p\C8hՇ" `Bʂ,R)!*Bk ;[s@\#/x7){4<ǃ.fqq!cBE1"ST,B%W"=U^jo3v+w)n࿡) >HmW] f{!և‡ T\cMPCj1de6C!>Q DHhu«)5QʤL @JM 0&jTًH CָCR6 Qrq Zلq鄏 gLQغ|AACil2} fl)2HϠY_8!e+<8!vCKľāIp1\h"G$wH .HpAFڕLw8(XahS-^dltf',rw6&`FQ4%oL::!Az;׈Qɛo%Cҹ7I{-GK+ D4ȝP*yLa5%B )mP1oL6Ɍ=~iޫԆ&7rGQo1IyCWW/dclLg)BCBt!ő,:飔c}!"LPƷ:̚ bϱMr6s_`kcN8MEqkKXܪ {H @ b B-@"D@D A^VPֶ^u9ߞ3)1 ŌyD>$ < {>p}n0 1[ n32+$lw} b:XۯQ)GrU`0kq&LxO1 Q f#@O[ a#O}kTm=0}ډ}Bs"oףX}i$6hWѰV0+ s~1e9XET2K^Q9A~E6fLH@S@V@((C^ d!o5n3aNKǪ]/wt9Q~uqXN-Q1J8yj^NB03S?*?g$z$ {s羚P?llew]O~{GI=VkȉŔe&tir:NfQJ8Y9$Q)JRR^y:m<$HX|\^ԯsv-K.O}ݪ7rV>VANfgf1ӋjMOu^>/NPJԷ$?IIeH\@\k X :G.5yionm>KWg(Kɔ4m#%']ƍϬfeiƴ$:7<-3.#%. lb$ zob0[=\&:7;/j;E W_v_U*є(ɧB-K^P•Djω^H;)ʋ*|˔|J d!"5.7 Eg0ri={vUM-ݥ :gv_cĄr5-4uFxb$[ܒ\A?LQGl2d=iv'poٻǩѕ7w6qiD>YP|]>&Q*4qw mWB-PA;?k#t{h5h0phUw-uHy^/;,1 0idҌ,M6ɍOhoht#$1a-0 pF;0r]m3`fouw)^lw+{/J#E] J ˒B'Q:*(v#-3>xJ÷!a m̝`,߷A ,hrO-i~%s0ɇv9-t9(ax@!p`9 l ,n0aߌ@v;(ݎ[G%];1MοÞOʾ\O%(wƁfdlfGmrޟ~n^BL Ѿ"4 I\dLCpfbc!Ń5RlOh0P¡Ej9Nh8b#MN dBBgCbrDd9CVI;hdFo۸O@p꣞Sc>k ᳐ENCP@^ᆌE gސlB@|<:S!RԨ/Do/G [1|l hf;U:A=*$(j='os背f2N/d~~C]'^OaM)^Rq|m$ y$ rȋACrr]3CS2.TkyP~@ȏA~6dJ |:a9z[ gBƄ>c8i80 :W=79>Эvc4ۂnAPAu,lȗQ!ѿ)^E*T'* d#d@G4LX( aaV4D{%1K튢|O"Ə y%~Gnwv?DsdڐT ߳`F5}E=z&L`dcn= '\y0.+\2lZb憘gMOsN=ɪHӞK{*+y&O\0TAtLi/vNuĂ 7ucżsJ>?.\0s}Իym9,?-wnog\?]}oD|$u0R.`ḁS.͇=/4[/[$ɿ(*n)1wƎKNw=Rv<[7E)F$z".5,uL,Xo?T48˽oҐ17*fhiHRRF酒EMs-3ԧL4'L]c;ڣϳۋd)!J~r}EoY{|_y"űo2ksk._YB|Ʋ.͊c/5K9P|>wϹ%VKedZ n)J{4/#/x b}_`߻FܽR|~vLU k,WTy|zߜ_);qc2i@Vs%dM}Q ỐOsڷYVp3? c> tF}i1\Ci`mrkU{*7iw<狹_(o3~n9h2m:oHXնf>L0?I8XUA)ғ`B(@( =jjA@P((2눸zQ 3{f{vV|>_NnMF*1&8xxot~ |NΌъXQuTY٭9.}|gWF>UVJoO&51/'&Tſ NZ 4D#/C +2TVkrRtLgve뢰%1Gz ;ryr)R~1)ܿ>YƪLa&KEļus->TC"{٘p#W7 ưnA:hO6zIw VzE':J 9U*%IeQ'Nt=h/L@ TP![ ד`]tk 5]Rwҗ]&ok7BۣKq-IM79'LWBȎr0yL, M1e?0Y~rD#CdUV&z 0_@]=hxVr⸁pD0`ƿޟ:esZdjJh*dAC1b)VO(P T{kn~x{oeFgC5='ݼios~)wC,D쏹k5t$9ǐ27zO17ml36E|blՋ6<ȕ~WNP0- 7HP5 #(^C}lgIqLO΅sd?8{ &`V`ǘ9f32g̠촋#:JR%n+Wq gC5(~/r!Z Ɯ% Ygf~,"/|&x6dtmGUnߣCnO6p`sY9P@ -HUY.B )RyLR7*71[hP),SOeNwen6sew,~^p\O;Cde.|-{2!aYb3V]5+ꊟJJMWӌV}(>o;6kb6ە/a+~*p<@k:> }Kې_|4kC:(r:k!T 5C^pZ>}w <H[_Hh \~:L:IvMQ" ְD{P9Jڍrw2Iu|u &9+m8)@ g)kHE vȜ` dYTg;Av5&@ $$$6!)67,E*n8RA[EQ}k=ťӊ֭Uq3_ۙx;}srpig0 bɴA$ ZH2E1ʴ JYec'6PT9I~(“Fp [83ؘǰ' hiCzu%icj&v&ON߃Ѓ`O B Jπ_.xzǂ6p0~b8A\4uxg3O>C|x\-,@0Yï {M;H3)W;=%wÔ/x0ȃ&|<BDd,H:τ(} Xict `)$- ?&^[?i >Th\H>D,Ku YB !2m@½V"i EknٓWB_ a5W\R'H#ݘZXc!F](#,2S֛CMʆd͔dpG#vg&W߉ڏux!px.S lH`8G!ք`gr{Qv4bgمD)t01&*4 _c3fE;v7{^u~%;4 sI\I>{7s~c RV$4ePͦ24/-!E: >&ϵ|3So"j9O=w60G=/xߑI;vM +3 " D[1`@*&D3>+ߤL_$~YT|?V.z}nFs#b{=bwUywTyߥ7 Q> v&c a$7d;3,!7#ўL {g c NrPչ_Khȹ&v߅s1D <}Ip"Xˀ|ăs8Ép'4!ιtl2K %dsobɬ&?$3K/w)|*Ι=G :Pui㟈&LUw( dx CIb̀B R2 {ƛ*WI5GneЌeJѢDNgN˽^w05@rp_Rhhɠj_Ȁ꘴?lww}Kop[b RH~6[EFVQlƯuNV+.Y*Ns:v(@Vo?,r=K%};;RC ˷W$VCؓ/M7&wУ(#Qo̩G06NX'3ZȖgaks&%C>_GT䷫I+&gRVRZfQ6Qmv>-"{9צU[^RZ*^Q$ސA.o9wGxS#VD5\j}\?!m,DPSO;!oQzR[3~:7SohUڻR]\Qo+/]%n.Y'k*ڤXUMYW0Y?U)aDm{gՕAP_&(TLf ֦CnAlMU``|wBmMIs2<;?n-_[])XSY/^],+oWԖ~*鏬(9]VrUQ.rՕT iiQohjl5M]_M:4:-p|澮PѶ[ZT.kzlXпBаliTW}" aQ(B @ؑm( ""PYdY(.Pjg: eLm 8ȢTEgǙ3=a>~s>9 f?pfތ=!)B3&w7- :íT~!3߷1 ײ~\btqjZVWdx,ҬdX]~at,^}w:}.();x{Br%FeTA:']Hx uE:L8˄M|j2$Z%&EŹ;itK+L)(I:Z㟘75Qp|o~ ^BD&}8jh]π4\nAG byэWYoP|&lmM95qU;U.puVnTUy?? Ȫ=us5:r%j8Of.-lHa^ttz/Z}=b]Zfx;:ƞ͒G7(=:}B|BF:V7^:{Nx ۵W:?yO^T]ׇ^+F5);ǃѽAOﺴ4BU\ۘ{>ȔF|rT^^v=#}#mJ6'jEVH]r.;hHO2pl`)0?cWh-KjэcǚD&Zn*Q<ZG+2[2Q)'9O7^F;uߑ.> g_E̟d+?Āp{߆L3Npp~; ơwRL2,X kmb>E !T*ք!>8^LI dlT,q*+N׶>~LW4Ӡg3s'|:Dx ꩻDa5`ɿ | 9_!(_SW"y֑vuc8aڽ԰ye=c\\tm2YYpO'-Ba -|P&(Pq%HђBW=iS `0F3 1 Ә+{XY2kY|uS}.?@O߁2``Aa1 5ZJTP+Л^4#h=up,08XKt0Ļ75LU$;x #0%uj}4d/K P@領F)AWJ) (t" EƖF;R!"Jh`8 AhyǵHsq'*.Zoω7r,ɖXD8(g?E,A660G \ QJ\ Y ?ψrȝ/j:YB(>\E t1QB0MƲ!Y 2Pͺ(EQK  gijoĨ;Kx,(RfBҜ0SD% ݂5%;_t߸Jkx(i ZG>b9 z }z3u< qz:O|I~`V=.h` t "7N qQTM ֆAH$2Ī3l5c ~[>`-c5Sfe~ #Ɯ}Ƃٽ0L ƋBF cJjH}1@LIJQz#r߇zVM#ٳn'AF}xiۻnzЂL0X^W6!``U8ߐ4|3-5.!q1GQkG,7[޸VNa{rq&ՐΗB;_ y4F=֧#TX`̿6M{W"H7Bd]沼5_g.}ʎ{fk"f|*k1vWZ~޴b.#" t?>1>Kg O.Qx,ŀ^ `38xػ,|+>Iq峜DKm%8opks=}2f43J>}pIʡ^~3G 4@x{;W`2@_VBkcڝ"_!y٦9zw,;XýȒp>IdY>--\!BEݔjoBWhN(Ԏ}ɂWD+,z*2m;xBa"4hR~/C);ªțfc?]Y뫊]H[@Y1BVaߩSGB:j&Z4y/#g$H;$l61ױTW*H}$B%M\(/,  nOY xb - ;jp:DUʎ0\2ckY,ͦ&k\wUEbʊ:NFQ _r*(VP~[|(B0l5g>BU t]*щvutc%߱0Nkc,l 3R5n^Ԑb]P+uHd|&3e#30x=KvHU=ki;m_0ptn.>'n_Zm}W >H[4@oƤ`GLspf=1Ze_LRs`6`a(nQ5 S≧3JӾU_zk;qolcoLc9a91 5Rk)C:{PWX\(nvW(hdDzs۵ DQ:8[3.O08ĵ}ݶYٶG1ֻ5]ƶD\"ΡKԼ */= 0"&\Bֿl!:!#ԑufq:': ;#;?:kXݵ&$F$W1h|~z=}?o ? n3b{˜XFӛ̊VsBs>)Kk ~ׅBy yV?Kv@x D? ŸBG0qLd0%c Mc{ xgu7v+{u+}u+5=Jp_A_F9\w`l7@0JttTa4F1ned8 Y8ebIeI 52@cs'?25P@` EԔP\ 1l1 l01LF>ba c/jwJRC,Hn!? ۞ Fz0EYf&\TOal>/r* QD('UDE 尢O\\DEM‘0Qn ƹ28_BX=&P3] ^"°O,ߨ[VeJڤRUUG/Tgs⧪+⇪n}Sq[{jVwF@/eP`}\zi=u\Ԏc|p<˘VtS~U*~QV@do}v5uv^ͥQw5y2FkHWɕ(az{tGh.R0#3{g$inuD;nݽov%n=N7coK;bOIǶˮޖ]}$7,5ƣSk<:OQ0-mH8&dհd'dXdYIgs3e]~õ5NW7HL4\rA׭][ަm}^9U3~fݐ{S"6&d2:HF'6Q7t̓МЏyTbq~t[]͙i{iBέ\ΥM8t2&}w }q_GRJEϺ4tKGtfmL,+zwNq›NxLdnG*/ZN~I[rMuG5}qIK@H &8L 5! !!!!B-D~AumγMzvqwHEӒԫ|90;Fzx5OռQQBgC$kP|sKMgZ;> .=w$e@q<;eb6tU!ŕFQ=.Q#U"tGr VMN|D#2fo+) ;i Dokvpa2v\wz`Ys:P{qG2ˬ6d:jH343!ZHXi\:!$;D0ut [8?^|WA ,9ai;d8У;^rji&F7Q*Vnbh CJdCJ,K!).R.>H_RT DQ!5({AW ie$J/wwT_ZIqae-Q(w=|kXhރk}twKfWvzoUk%ƜyTe7J| EueTam5-hftd֌W RUXz͏eDL5;hϴ`!@[+{} =L۪_9w^ܧHr+pڸVwU9)ECi6P3&4l6m&lbB&fBdk*=[\?ޅ;`@Iv0ʾd Qџ\ЛvPԓ}-bnV>h0-<m[8u{\r#?Z_#3M/eȐoewbp8S-]YrNaUy,"_M|Tn'UԐZ\&MaSހׯT%=Oz{U z9gIO1!E41C׹z. 銌hԯ-iw {=k aC,CRDNئC GgsYg./1n_nߘ7wϰ_d8"i46[4X\3 嵄 37%_A_Kn^ ,Pisc ?\tN B`hDSEoOu̐&ʡ'Q <8H1&FTȅ?G|&^(=7ت.Dv6ltvS!{lOVԆ!aP~`*ZpӄqtL:R͠LCsR|I_EuqӱKO,^&?u-Ñ#w6N/►*♺2jw*vEME4ʈhn ҵПƃL##Z3p'S2'2h,ď,Q ;mu\ݾU{w+igaˣlnsۧ.eqhWD>F` }lAmstl6+Bx4sPzd\yʔŊBb5e Λ]7w+w_cmBKI"KPUvv4 t6L睦y 4,B8,pc7p5aFrD{̔=jG|Ŗ6\!uSiz)kN>*ve]]q+aMLtX*b72):F[\B:qP?Y@PU/bT5?F37Y-gUYv|cVtbYk25X2}ۗiӾ-K;4NҌA^6 ru КHW?w|98/#j8g> ̛fM -KeKr?R|Z9E5.fo/u$F!@n ulJA@5@XYE#.`ǕxZjUlF2Z;sng.8g>>&u)1$~*2~DV]Y$ILLl'bopk9@[D;W"!+H^૫QMmG3ӭ>ȵNSWV:u.NLVH:.K$Mr"91{b7P.8EC`{Jm]inTśXVʳmʲ Y咢LSAFK^z:kQ;kiZ2E]\PUnZV=CU`QJV$YNM.)g')v'(ds8]ٚ&3&I*`Fga4g˨}=@W:Eh[+WV(DVerی"ԒJiRqì5Mh'*o{DZŜb'z|K}jpvAH{WALl:O^UniCan4:vfGɖiY6I55تzv++ETuQOL9DU0DR/i?WS (l BԶ۠rW~>oJn 㬖u1D mRUjNaug}Pw5n|5s U3b7%zCt}v=7[*J5PjfqoEz S:BL6ψiO3ڐk6V*Y%]ֺ)s`7΁-Zòf&^l'~L !߽PvK'^e.=ӑ#Af'OЏl=R-4+Y֕mYdbkufM_Osak%[F~mf6zP(hU((H"y5E/_fm7A|oi=3zj,}{=e{g7>=f>yz LwoTy#@D rH!O.tȇe|5r B<x GhfL{'0yZLA8 c< 0~2$o򏁜a?O@ ([@z0F=7 30z ׋z9cQ- 7ö́1j5򄌑+)?W $ïK(ۍ(0` 2=1 |f Lmo08qLJ8 ƍcGg 0H YG6.Yc \#'+q/?dyTSWK¾!/@ Z*,ʾHHXHԸТH݊8mک^ENw~NrOqw¢)^/  (sHlCbh&M+_<8 xKKO>Nctf' HbGZdAC6=|Gwѽ8}|K ߑgcr?F>c!SMA\=iOcOg#SbH5|'.5ԘhZz%BSE]3QDtNSEWG-M1fO u?Ax}aM>nۓO_)S 'zRC*}I d+d[}̛~?$'7$XW<.z60s;pv*f} τp-#k ,IK`btV`Y.u˻w52U&߽6]n|ѳMF<My~ozIfC޿Ʀ76# uAmaG-Ƽ\%9Zy%FjM0=favo}OOՀ)c>׬>~?l\Ȃ aha߿~}'l_o0'([wb+`50@hlFcYqeF9 3Йۨ(֑p< u}ֽK{BVq nn +ogY^G-=w[k [?0^c_t-ě:aqFbe=-V؝՜vkNo o ;(61'ǖg;͑ض9 |/=7SЅt>LCO!l/5ѓc;)մ31bGں=[n[cm\L_&X'\=* 1X쁭! Bwa0$Ѱѡ 1&T,ҨCh-Mfٚޔ$7'V94%6ҫVovZC,nKCQQ}܈.K]¸c]"0Iiq' AR :Jm(sEM^ğPи(Ƭ Ų\+gjm܂RZW#P BUQVW-Su=#*x!Tc' jLޤQ8' •\CT" RFM.P1ha, 1(6-+Id,[MQPSV ;ej׻ȵe=nRq4ew8],X\iP%28Z2 XNvQSK髂X& Z:\Q-Qg9(J+FuiWIcn(YS üIwypB{a4ܷ 7XFU7(oE42 ÍbrkS,klEV^UM*U.$W4 *\ݢXq04k 3!A4(ADqpU֩Z X+ 8"8KlUk+.D:Zu}_Z?y}r?hL1+'Ǫ$;LZ_3~Pec^:A?iÖ8g~&h;+Wƒ&^I>7AR9{u{d*`}¬4=f15x/j\jѫ;|v G X{EwϔLmn5l%$ ݓWoy?8lJeҁ529ega:__qڋQq=C89NwnXۚ]2xuj8QgCls4و<+al܃?/ b, q=0DGcr504/"~}Ts{re.r,EvܜGD7H}zQI;q-ri9Ѩ }>mmV ⭱5^tWtSYNq̟Xbߢm_6*m管;k莿+gs' v}8 .B- 8Dz 6PF mmmmdۤ}hͮnyc!xP:շn+9 d ;H΢l@@ѺEA0[TV%=вdKƠE4++JDuIU>%Kſ+ T Oﳿ&3{_3[_ wE .R.uCKɗ"ۡrAWY E ] ombj/e?fSXhf? rh^U?mwfpػ>pbP٭P؋!vI/3xG@S` j'hjXU5@¨#񥤆5kxk ^]zB/Hf,d~Љٖ@ˀO&|P:t^;5o @] a\:$dwXNR]% RJ:RpUu~ߜ%Hx]/dϦ{̽j  ~O9^D.Ue纍.O<Otc BTPwk`w%襸MtlVGKf#d<3#Kwa5,Ն!]jr}va2v7Ἰ}[S-.R\ @!nӀ\!Cu~a/ZlEY`<7"{n\$n q͸Ah?J ŀ2EyuŹf)4S6b*B:Ul| 2ۚ0#Zŭ i4UT$wT9Si̴MuڴWUb*PԅJc~ 2W :b\Qq}nE%󖱢2YQjPYl:e^Ɯ>iNs8ar8.hvO820|aj|tmGF8BF"xY;ը&(n1PO|3Bq"zQt8/ǃKGhE2 jĪ}Eb'{c\jl!B!$K@$6Ibر@ 8X$vl'Y&vL=i&I:Mm433{y9^+b?uUL$L(8/~?b.JϤTJ> FI l |,Ki #ޖ_LAYB e"dDG_ŞvQe sIZOKB/yȝK3kFJ0}n3уL̽{T rr\9fW 9eAEEr5 z!֜l=+;ŝ\2S cr0GY)kXP!JEBlkdJ'+RRD.0ԓy 5LG aŸ!5Gh@h@DЯ$a!0\̜/񨻍(#AḦ́˚Ͱy 4iӥr)uQL6WlzUDQs=\,+ {xw/\: >ulG<>G~=<𞍄r98 z1iVIEזƌ3SZPJ\ܞx"Y|RԡZoS'&>Ij){|K !hQ< 0*A-3^d hE0cJ!MaCqG_NU{ʭ.m#۩mv4BfFԤYJh,]KNi~ɦ[ڴ߮%'7؇1wޭF0w]żcX[kN&U&VfG TF4nQZjFn5r]FЮk/'Yt6~5F_EO,g50_.|\}DW1y+Zu/iВFEtUG9*=QYhc5T4sm&Z@bbR&# ӏJ!@xU&K>zpu6͘79o=Flc 61]ASF4[˩ ʪnX;恸Jqʟp|&]ՇȗIz"CG/_p3u8mx 8لǚyRHG"^mQlFY]gTֵL}qq:46ZGHX#*qe_k%xa>}g6ּ::1wvw ҠMζm֖Ljn=LnhG; -Zeijg[nAYè>_b9Qe5^Rs|^b;Gxa}x&ּ+?1s [ܮhhw~{I6W*֕IvFVvǘ:˩ zy{-ns[ [gDdZ$E,,siJg|XHCBr<(ds r뀯hf'07!_R:WƊ2B_}(VM* 6U M Me0?;ϋgnse@@Ӏs%`-TMP^q7W;AT(ĉff>XxkU@c^_ c?\p/0Qz:Ue@+ n:ԤnZpC͐7݀3!/o)ca؉?DZڏCKswُM>0U﵀&Ѓ)yocܤQ}E.>o9G윸x~Q`:ϞWXx}ͼ{~⦆5i`M󞬉"CFQl`.~ <_ @]Q }Fi ͦIٴ66*TL';1E;w<;A&W E8>UQ1=H?y,NxdJ<2uQ-R.iOeEBvWjz/+/ x=K{+~rK NX2Z*L-!Kel%]ϒ%#/X |* })v\UlSl}Mbc#?4esZ 4tU\q/Q]}IEcdOΔﰦ)[+ZW(7[sUͪ #s5oPtU]*60>kt&T Q?wQ=F*Nm %4N)h"/_WfWdkr6hvج o"nYo̠6ABmАc̿B$Q~<)p0EaWHiCxڰܰъ_({NV ^ ]dLk$d>=H(aAha^S}ZO#=vn4ݛjfWpj/s'Ϡ?FJ׀7GbCdr#H91Pf蛤^'Ygi3lz2 h8;8R}J_#6{܎~f췏l:lvژȉ醕1aRVtYFtbaʅ&-jiّ" )+G7Niq4%CrcG ;ғ=FYcP'pFnXoEF|O v"-6Q͠hfLΈIM=ߐe41zWCR[c@a [5{砚}>)8 |`BV `)-,5!Z>ʔULM7]?1nݗbWq\>r{c ;ғm|/#Y.h=?goÌX<5/e GAkТ!#@ Az@TBt]OZa]-3umn~L _|?~i扫t$))2k89ǹ0ՒJT2k7gk[=LڃYSL^&3iH$%QS{ Krٻ>5`:d1UKkR$iAzc~97⚣[XVu'4i^ԛ4#uNpK J?sYIjeC?14LӱظP\!?kԜsr2\ VAZwmꔌ5I^Z Iz-Y/(bkی8(bq1;¬Ay¤c> xc&;b|G:1SYQ1#:As9|ҩw X=|}鄓2v q~ x́GO4=ˠ5½ PBEE(z<(O=޷z]ɸ-w "N! t;< Ji7N}7PHI2$9CԿp;7qBƝIS0"@!tIeKo4pe" WX0/#tpL.#?o05w1cbzx;~~ 3 'MJpT,=/^`Q|9Y0y\t$o>r|O~|F!Dϵg/PdcE]cAnArKĂܑlX Y,?`/G|b‡hEE>{F)[6SDϣ̘.c x6o>&w -C}1<%ă=&YEyCp m49q42,&$ Ud=LZțNr qO?/ z%qx:)$D-"d% d+APg?u1q xk%w~AE?4tN"|G҉Xy8&>y;uvQ ?uR8ۃo>?pnA+r7Fx@qnT\9C41$[1jlf4h:Ӆ/u<;HT}Pem:X5$p 1$$"B"QmCToB ~ZC j]FҊ6\lU~\_qQBYOU"1J F* !zR}/&4w|kuWa\QƗ#.hVs|يs=1|Nw'#k"uqb  $?-2zp۸%Wb7;>ŹU8ӽz4Þ8ٳ'z^m8k:CO`kO]"_ǘ٧1-O$.E&꟏Q8ÑEOg`f_BfOr2lav lpxixm71Fd7w_AB> ' ]8\Q|L|4h9'6{6`ٮf:S-ڙLv.Yݤ]nnbkwrܺ!g5CV_>T-ÈG<&w銿6ZY=[|0,Ga_pҍ6 [tyجs0t%zmu:vMl*[/m[bJ,ѝ.ݒ6x,m!J? O?$[FM|@380J]b^q!ވ,fM\2]WʶUqKiB}YHҤ_%o5OW̸񢔐O%RkL!jy{Io$('ClH&$%IfQmNpM$2BZ P )Ҟs=n#ڌ2tŪh1hNMaf3sRNaC1,36 K5e0j*` G11E٫`Sg~+Ofz^b)K29sG1sӐc 7_k*ԘҐi:A/OL_LoWUPQ*L,Ch>rp:>iBzeE6l r1M4侍>'d[PJYҪ2 ܤW(6uy8ƓuE^W(6ҜN`g!XK- 5?OY=1#?ov` UyvΟ-R%(ZBe“LUQݭqZ>8,;9,?y™'ʝQxɷTd8GڳX@~*P`ڢQ3a6=$fb+ rٲWZPėX}5 + .ka][׫m]NVM_jUTXE gܤ:![G-^]4:u&rDiי; ^Q%k}j_ooUwj,\ub3^wY Gr`C3}Qye1LȸfnowKlE~F/zGn)\)\*ܮ6,x2Js KtNRS*4~$'j+텒x|Q䋃7q2 t7畖Kr!Yw]Q{;TiޣTďQV"_ <3:S P4vNO~%npUFQ9FXҘlRir* J$?IRF*ErVe*IXPT!*E9!{:;)`Tҝui />aB0H1șldBLf(5\ZO N$I2Cp0]<^PU T$ QSo&7h"i4L#UOs: {\?a0G!=p:c 066)a ~nL>\yTƟ3, ʦ0 '-Dk$F5O465"eE 8Hpj%&*.TӨ(1> &y{E 9^٬IθI&9]hBm^]u KY+ǢVwdX'!-'Y00g#YT:Gaf)r /lV&TƜҘXe\*T%R=PC_7f1&yeVr dia=H>}BR8Ο,$}oɽX{c?&ؾc~RĬvywR@Դ`5GQk׋WI%0PCi4K+MA/@t Cc4b嘆HG;rX/usRغv)XHk}/q ;z8x@Mi3_pz"©G3*ViDhe B*"r8*Ǣk$T͆U[U}VRS0\$1θلyY&7Vlc<.=c6$z =08WO] Թԩy$&ߓBwp_F;~v[.vB-ӎxJd"%"SB ԩN 5j{q|˿C?N?D_/b"Od fRg>u p6Q)\s;SU[whWp}+\D ZBӅ9 H^!M?Ө3m&SǎXC56sjnݸX|8%:Uj- @oX ^zXHo2L77Z3X Ȧ 󸮥F5*phf,Nc'Y@*o1zuAS;hvcGbl ^;CQ T6`sQl n?Jp!! "',Y<8}hHBmcj"G:rĦ lZDB4zT픊51n(T{GUHOic{WT^o}kd4hg7Pih2X8 PbxņiuBT#'Ib9/a2a"axFq-ENcEv:Y=k=ן@|U߶^pĦXcBIXcզQ74QZek!0}$-3-rPe*S,1mU,65*N*$Pf)盅"JCơqs5>}{`%v,iȵ2j/e[&IK-Ŗ兖 y%[a)-%yjťeyޙ{D K] qDpFf`fD 5.Kq-5zXTkĜ4mz5m<96ij4Iۓd1w= |zemA6G#ulI1kLslJFɄU&3-X,VUZMI[a(wcm<+1Vl y+6"SH"?7wg:xuH?6#<MXmE%4X2EZ S,7{2 ۼZ[b~^*6o]BeKa?LK^Ze}%s4kahEI٦*t۲mPj+KlbͫqֵRul:lsm/ԬkCzu]˸9Dq-빮l-#QW eʔ$#JLHY"8Xr]+~)W$/U~Q)ʅpEy'<[!܃Yż1t7|ۊQBRu&T@j:\L5IRԀXݭRYVxO^YՐLܢߗuJ@o/K} J#Pdc:9pHG#KPX&.q5َ,Gjo2;uq.,q3l>P/^0GO4l^\NGV G3 w><\$丌X]9bCJLWcqҦ6H&gltҥ^եOuo4gH꣰+y|'{X[rzTB^i$1qO➉ŞdyRaX,|!S$.TFO&ͽNkpoRrFIII>KB^ޠgS@-H zdW BVHX+' ; <)XTeʆUVU(ebNL,n{OKqޫ)ڸʐ6'.S8\>84ʕ] \n ~OFo }HYSs >Ź͚پSX[hbBڟ8tf`5 |?` 4HnX< S1?$0o.f0fi8Ycc 1Qu@fύC PD3I&s[1efƌhLoiqڪ3fL ((@Lс<؈ =x*)`|W ~KwQ{s+=o^[6 Q1LLjۢ0m,&MSX<`*30`1FkZن;aX"FԎG=a֐Qe۩BFT'%`^ v>ۣ0} ڣ1}t'i;w,ǠF ؂ h?[?CAzdݛX'$b_f1G Dqrº+RW,] Lz?]  |d8paD8vs 0CK77[7E. ̹3_oI}^3vi=EWGA a:-Dr:0 3G_l]BG>Z{#=`7ԧ__ DQԾI@!j{r aCя =aOpaȞߓ{G{]E Ybj٬5{#|Dc1=GO>g`|C x/y=dO4 rjbE 20*;o!"\>'ug_KH2kDT} ** EZnnhYDQA@B"2bM01rRV&NRV8ff\*5qܢo~T{=缤O~ld!Hu'3enDٍ^ӉYDdd"d3AvtS"oq?xW?" ~ 1 1tKlF3`'5ڨqssg#>mj O9z<&ȿ?eg7N&qdOT@EꬤF5j8s#5P{8g;V!}i_2:2G;C5ķQĝL%_AԌ3sӨCBjF%5jH-_'QB//} Moq~$7 /DÁc 9}r]*|=c\| urQDUԨF-5>V9wd4o鋫˴wi0Z"6;ٙ0eG'\;kBq5JN&gͣp y]U Dh9YВSG|kwqlCyeÆo$O^17x Ұ,\p9bu,ǙU85|z6S 9G#qGF^Qߠ1] sh!ȓx吻|!+ȍpy~.)DpM1lt-C[ :jtmD6toO$xm}qoc<6WL7OfRߛ70L.Ot%wW􎝆|=^ }`M.Ůk:-ScJ O9Sylv M=D+4xB y4O3 : ]&s6L*gsf2ϴaR4{bW*UY[Q?kP7S+}]s_\uS^})ZO.;v{{bs%}4h1' >VlDoj|P[*BoηUX3P>G\=X6rޏb|Y,yP<\{-]~tS\ `*aRJ=ʔXLAҎ"eBY$,W ˔br+-]--YtI#e?!,CG߈.10vƲ1-Zָ. Uc6C}PUT:(PLy!E_H^X 1cx@k[Hb[fB+:q#1&.헥{Rh2q<3I+s#kvxa>Y=DlvBP&-~,"d%ĞXVjI 5bԴc1ZiCvZ3\o1\r{y{lb>Kz 4&Vq.]#4"!RhX0&>'dӀ~M}̽5G%]3G%>4G%VhdeT>` 38E<gTJ&;iHbR48%LSh@jT6Q}Ҧ+:mҲ+3m),)tUShShdArCc#˰ Jsz2gکOzguStV_ٱ ώS ˙МSPE9kS+c͹,2L/RXݲ|އB}0 f8*]A , V@a Zƨe|,3mIU7"ue<-\GacFgWA+%r:!-;klql}Q3dcMW2UP #[yL@^RE7_W?7Hq؃R)`+5okTg/S |!adg,@PՑXuw\ xº2s/)kS ܍>iޖloaHa1~R=Ci}_CP o,^Ç<OXI-A GhFoz<^ÒsdwT2GvNI8Eag0?:Ǚg hrM@-H| -/:'֣?<ŕldllj֟%hMFg&9GEq\#dG(+t|+e`؛=vEHrsh@:st4CjQNFi-9c֋]DNg:ЙCGaoA:N:K(gJm5b>i-mP՝ U|ǴUl';cWC(NzM=~WO2|u{7W ?w1ԄZY?T}40VEq*  zM f*7h+;8WYEYy!GsC+-)%)a_ڸŵ7+x(0fl#Yik͊P- %,@=# ^+eOiJWZxR#2Q>_ h- ZE%Hy!@$ $BТmN!Zҭ͵{3nu;֞vNZ!~>Ͻ`͊O= S&',V iw$uLs0^5K>[R)G{Z 6g-=Xaڌ pŸQ ?|mX o^:"YDX\f!U<ຒX`d?|lΞH)EkӰ:;9:rLh)GCN9u]Q-\ʰQjIY̡TP/*IT80Tf?گ>8b팣E5yhV-O j Q`e<y [v&*w _4#2]Y&H4cO79rZM;렂Ʊ _39j&c6.N:tpQæ_/'EYQZ' ̆wd%["G+ ?Xu ;i& }60(Ӱ9lT4 a+,ƕ(5`1h(.z^ɊLC2iTVh#HEc[LyB~'Z$[s8ܦIpf bLa5eXX0QRBq*[`4(0zOd:yc/"ɴfIJG=L+s3Y&Pa0JR si:K0PTf̊ʽЗ?]yX}"z\loTBe(me-rA/{"z`-]c,Mb{,KQhO|+UA[YJ*WC][:l1pYUD+~g9 ۀ}M.G}\fN*KqC0TGB_]"hj!&y5*P9P:+ZlW3 tu WH=*gDjUO!wIBFs/QwZǀ'Ɇ5y0(Ȑ_{8CAGL V;V߈Vx2oR/#{Z$y HDGqKU(=C$ s[*e^ Oo*2}QHGZ"țRڔM*,o6`YUHj"ѿK['?m$4CB$!s;ڹ'Zg[#cR3 Hi@R$bi,i]G[Xܪ ;u Fl 11man% ¼"EB̺1q}~ux@s `3]9 ;v#%L-[Jpmcr60%&^JI$"|HL8x˥^ȩI @@ \*""^b2T@W=j>gmt]36v[NvݦsT|?D~;K NH#H3i#ϑm%1|I1G,Cy|G3y~g_2)ѐ,O"ƯgFCldbOajWL#>[_0o69aOƒ #5 &$dP/:jTι_72~w1N.~vp:kߤ0ڍ>$%qαRgrragaoj^ԓ24jZ}\ q>)tvpgp//^_ğYopjG708=]O͙xԓ3I<87+]Jjbf@FRcn)C\vV{k4Wy? C~9wyD)B8%3/ DQU^jM]c:ut='ye&I-`SGch"x^Qy1H}^Y:9?"56qj66LubTZGKB<kW)hVub]X1eG;Kf ?6I:E1g ~s7ڧmFeV 5f4`Up>V.X6!QҌ%!X50<_EugxG|Lw d*g> Iǚl)X>#"BTGP\,SEsajTϭCFTmCyTG@Y̋ Qo O}؂ձ!|u iKd煕Q~X=u1cQl2jPkDe qN̏,Q⟠Hn (D •>.SL >{Hh%kS'F$ $Ơ\*C4 z$d(OB܉(L\|2dp:F87`O9Ia0x'29gIigk譞>'B>e`H![BIFlp&9H*F^r%K))w"KyUȢ(S`n:ظv``볎>VG-}+Ǣ$t(#R#O J٪\Rݰ#+F¤QsDyUd _Ads6x:ْ>(}T"O :X5)hĄLm6KaLAn6tۑ? HcNAF?V'.w/Zd=F.V}0,9ԋa+`ԧ!Ð }Bk\q=LH5|Q4@A4 " F'Aj?xK1#MQ2gLdL3a Yt ZL ԙyH!RU2d"9k^>d{|~y0Bc{?wp$(^ J5ři h!Pۢf"Ֆ UlRHqCSyRHoԾsGBl$va3#{/u+9Tq/𹼜<\z1:EPyCYH΋D# rGd $:m:!qAEk-b]g|A#% 9i?wQ{">9*VK!G%=B$A ;y @TQ `NQ#"7#xŠO!m9B!H@+9Κ~/;9_ 4s]QQXWeueߑE=-fQ(̸ `T 0q8QU bզAlVMM`L6{bCRc4how{ xmcb-fJM`PW`ŘՔhSE(4\ldZR[_yE`oجloCkiYΧ6B}3UXO|)uF(6VvЫ dά<Ȇ3D$ͭlJabWS2mzAWڄf_0'xni]' )vba'luPC!d|R[Yp156v)40wIwvjQ:jXG .@Z.Z}-Kbna14,ttN_tb\KK34@ o/uahj!j6pENc$\bq'-%r?= kgY,zA&Z@q.IX4iÁ=9]lix3o'3#MF{- ~FK.wuNĐv>Q@$M1p2 u1(}\4׸7qț|m‘3}ldIƿ>_{[4Ү)yWP(]%| [6]?>FC#c61qF./l~ `0)`(bJzآ~d|isY;}/\pedZ AwH0Ŵ}k1˰_}- :55u]|gu N|OCx̹7T} c ضa{.0.S0I v͏C8 Zנ"ZIJa/`߈"ih~1/Ƕ \M?Ч<~b*-a8k7刦NƠABc")}gcfcߪ^N*ȎSD2P-T+nKK_ϡ1L4ʓIg#?EhXrc;YvO^Ö}51%;JUhi#:cFg1v՜\; keҧ.]:6k 8qW:Dy{+ePvw9] ƧimZqGiV9hsV8s\ eJ]*TNuA2_T=z6k\FXᷔaw͆SUnZ=̣U9R%3EiI *4,\JhX|~C9>5fSvVfspN_FъpuQ7N :sh@ h;3bʹvK| ]9ʎ UVd21fF ֌=Vi)=MSf)kRb5)\c7+9Zh|qݚy};`sآ#;EXojz\kj|MV*%~&unS5Qr5.at_7W=hTbF&6jdѳm/uT@T@S2 0 ]`H䲨1 `y ^K$Zf*hY)=Zֶɶv:k%ִܓ?>y}}˚ƎL}%q4bb\9\0 -_EUG$+7ª\eGڔYQ5ʌ5EJTjl,5?NܛEra #NJ + q-z )?zrX͎1*#&U,*-ήԸjYR)JNإ2%Șx~)S'FNm[q88GE9^2LTaJ3D)Ր KB JI,Pr\%ͪiV'4˸Q2ː.;?^b911AaaV٦0QG%#dɔ$cRf%*1D ɏȐRfřE[*| w)<@75؇~gא2jSHdd8̓d4*yS~NC@SKoޖt/*zXlȤ,bI&XP,cR4QE *(M+NTV) M-Ճ%4CJɧ䔼K>yC6&35‹JzYQ΅Zz-X҉oya+>J+)5I0=hD{&3SV$_VqOjlyʳ<˳:8e ,\~Zʹ4\SHl2y1!P&JOոJ? HyU%ʳ*UcQe{"n[FVKիUF.wZVmhȠF΅ǩzr@LI1Z(7T:B(GFe遺 : ;лX_mQg?ߎI~%g#=Rb|J cʥUÛйM\\k1>$mIgiໝSQ;vMG'$]0P`C@uQN w+ }|7[ًO FwJ]#y‘PRa#> eԥ8 t4v71qzjiW|?-/҃ ܏WO1xNA^SIAN$'gR,Yhmy׵u/`ͅ35b%Ұ>Z ҅\Opn!p8>c"5ec,ýKKf+ų`ߐoO!|z-Kp\uCѫ 7RnWosܦHv;; PeP hfh(MEyEևb7:󺮢gKp>5HCax$q`\,?Yu !yaMZ`{!`{9)E h̏Qh;:.iofp^'Ѻ7/}J3G~1`9U~ YKm@k6Ӣ?ڵzGOѺѩlS$8AQn<r_ w_pYX;|r"𓈏4-"el ֱc X:V;؎t^*ׅ5h 9$ V,a߆};plՐZ| -]ɳ|kyF;lݘ؀m@an_L b -M&kk^5SWUv6ҤjTiViӤݴnUNC}>}}.Wy%z"Y/_{Ob> ۻ3>wiJ>EOOUE79𓣛}!\+q~F6e;K 0"WҽMyޑ`HRsxx/Yڱ8]c~9Xze TtOҢQB|c29wxz8-RLSγhqyi'Ooi=lff1s c`4!F?јop4Vc:Wy,=|`oŔ>1 D`1*u`6ƎjLza"ΏnhF0pC LÒ{_CI"%M{MlbdK II%LTc,QcI%:0,APڀ!i H;0 EldcdsI^EwUtmDG{+3wLYfV 37C;1"##) /ՆCГք6tw`z?:GϘ2іyYwz ^ω?9B x6`uKlfiq'L) (R‚@v9NgѦUGޜ!4LE4?-xo s@Ïy uQD\, Nݿ{xmtMc[:oCxhdj2q@FV Z' ܨ-ZKg1TΡRav+(7~@N<_&-7p%~X Rud h,LGz}jTpp2Ԣ؄ c;E즣(3@y6uX-/>K%"Y=r`wps:T:&9&*Mp a7Qn.CŅRKlm$iXga]GQ"}opKcm*q-$ RG7u2VP֊&E&.wm  li&IX9㡭BS5uv۠T!ӆ^(Fp Ho"!R䳈=%.p$[;xuwIE덂99r(ݬ«CׂL:|Hu!7 yI$lBr ҖHlI-_Y̷۴?77s, ijڥZ Qn Y8H!ůܟd2pAф;! !3 OqF_|g|AfY㼓#VA}FK=J} io{eD$ B D !L`0!J{e7#bɽ6ɼ0Xa,L|qzJ] PSSm$;8D'!b8 -,FI> d0 1y7ȹf{5"Iq[\9 N98|_%~ / .) ._\Z!,8 ]u'0B(5wN FO3朜>dPg\Ҥ}jCtrt\\ȯkK8D??8{=<<wrx\O &5y vh}q- t=! P Fj0ؔf/TdV [=v]Ku_}K7펝ץc+ XASZQvg+tB-l7?ckncgX>Ntho+|+{n* ^k踂?t\B{lum29wtt"w71pyG\Vx塿ۏa ]x: k %^i optDoq>!;p(cv;i w|-88,~>^rlWaد@Z=ZAXf8Z_m&:-D`kwp~ >szK?"'f)X~vcN^F[4Eт&tl2!'*,42"^q6Q{rר_\1a#bP Gcᨆc2pӜ.E(>;Fvf|¤n 3a#<H#8 -F"t)rkKwմZ)eZTmjKU}*{lVEσF]W=x2undy>уnOun5W&h{j3T힫qCU1Bc=U9^= y.RJxh~Km**qtS>TgYB*ݨ|V {Sy+0w^s6|;:rT ?Oj_J#5ߤb*ꛪa}sTw+?\Co50hv++2*3䞲B#u jv}L$7TA* 2 (F588CB)7P9!_) t21RJq%ڕP xwۜC^_IqD7g C"De(#<[JԈ DNRRl٣Zw}L֘Kp(щCp`<9k6d>FBr<̨~J2(-*F)QJ+):C ׀r%j"ǭٴ]&gzWq2}61dZʧyo":*1CБTCd3F*'k\T%b.TLje΁kJJ%QC|`jK0~a||}XzfXd/,`0oJfJj_5 TE WQ%+@3T.¤Vބ;@_Kڕ|VKH F7D`aLr_hvȣ<ȣ<iFF6wh es-**12eXI3b3Ism2Q"@zcn^NVN$)I&O/\T ypײ ~ h x Nw 8 ^o7h:9ϚQL3xm\|pZ+>V4X9np 9 %pb]79E|Fk.=tqߣp_ ~ @z! 8d %8b&qO, 7G;[s}F7}#8>oDX׏xobE.!}F'W\G8?#} y 7{//x8xB/?xxd!]ʥ?8 Jqq`2ϓ9cʚv鷈uXi<^^G~_['228}@-1/i z]@"b#v91::f)d̲%8 )=`A}`7x#vL*%x[fEA>Nlb=Ӊe2į~xVav];aA-63ڧamFnf:iyZG1cW6!~>gbE,C %F3QVXn8ױwl=>t 3mIB6wh=X)p1b8{V e5YЕ(އq#%Y/>`ݍ.F($ p< G 68jȣZӴ<G UJ\ #J7á{6h^b{?v[!{8v !J$D @2-Dʂ xPW`k,@9GY?[ԟ0G^m8rК.5~a_\0A O:YT W*N.gd m$VM{Mn+rޓ+}GXo|/DA]U9fy;kfTW5-hr,lSNCݚ;d\%X mh#aǕ~Iww[~8:ZڲE7*HFyb=41\ T3f(8NYɲGNWfd2"+Y&YMj:.,/>R+цhkknтҖQ|k9T(ƛm,S/My2̣d3[n\F)%fĬШW#^wh 8ӂ%mhY y>̠NSikTF.+_l# 0 3ΰl (0.D4Dwq;hc9&٬i&VLlkXSi&=iZcܲUt=}kPqQE! 0ԩ,+7lFSNx1WUJ3nUK)rF7r+%.^nrn-d߂Y?=N#_<&0ҧzs+&OQA1#RVg&),3]Y *հI2dPp<(0C9?(8/<39AM֪lTi&Sy?;pMgq$*rkPHnȐP`~˿̖qHA>+#C *B ܼg9G0s%\*(EYX'btTP%b_qq-OI,WWҍ1 %gحNՠ:iV4x i|U<}/!㤀A+ Р2|ˌ)h`yr\rYX 0嘵rCƠSW.jB豉4/Ɩd%ӛ BM 0wE=\.BD."c'1!Mdb61;-s8KpG`O+yɇK*a@ȡ3$x \ \5\t5\@5д!`u+-M_M;"88Lum6{&P\ U jbibh[6ҋP@/DG=lyC2D-\X:` XX G} 35ã9p5XᲈXĢ.$ml||<[\ nm 5ʡ]ֱ@!H]/Y@ & VxҏVx£%RZq.|j&UL+q4+ZOX9HfF$|6K[w+(݋ < d۹xvzю(8r965]@:r;zgAK2>Ab{婋t} A===uh $߽V3u"o%9KɓFbvI9V#= u̐ǜc@E?eb(Ea.^zCU>_Z>QA\%!Կ_p55AGy1~ [/ g?>q&8Ǣ%Yzq]9@"g 57<Ǽ=f/΀w9Q|P5Xr*.S 8yP  q-M\׸p@E~_).: x~B>G"QUmpA0ҷx̯5c=U K+<.$;?1?R>@k?eέx ^?ni 53|5ezqA#_L ^.{8 3w𗿂8#=C=:n$2y?t,Y?8VrEr?أ8G:rXD^]M2m~A馲.= ݠ&救GZq+YȑLLV8DDRKX%_"6cvv'iP6Դl_+u:~G-rE.9ϢB1į DWc Ğ2 YNy: ߰Z_j%yWx=19v-{E'{Cf$Ilq1 BjrԑdkL76`0`n&&`CbH'@B(HB[Fi.K@%Ye (mfi6AZN]5mӺ}m6MӦM۪}ؤjڥ4G.S =z?y99『w f 8$7el{W('ߡܿ$xqÖ8 1Ua#f<ߦg3q;cX5#Df= MSw)h5졅p$v1iL.x 8K)gYBDim` $]v>NK<n'2LY%u )tY='e*\v/q~J M5+ɢmIښQ{rڒ˵9%M)aES՜USj61m"Z~D XR(j ?R/1~ b:m:r"8+GS IږVQQU6`(VBZ7֫޸Qu6Mݪ5Ri3)yNU VUX>T3SKH'bCطEȓ&K"L3TgU\Z_5ZjUm(hک_Qgͪ,mUb{Sg;>S#{Lo&Yg{(C$;I!Qk,ekͭ*[*m媰W^2GJ[SI39OQsWޜw6 %33IcuKZ~vlF9{IW3SNʜ.*8'"WH>涩 Gyv?ʬQ5` Q)-[J&RVj0vƟ^Dw;X҃][K> 'dMSfI,kr@ ʨVz(PRB=J )1N0uR;HfbM ~f/w_ዾKax e"'q!a$|:xĞqbMG#a{i{sp mx AY2`͐ѐb: }0q8k]A(nbL4n"LvLavL"&i0bK4A<&?åC){1ǎJw ցJ9>c;cܘɋ9.?7FҳB_Hx| :;_ U:G;0\|Hv,bb,R(2 $y{8G^~;?oسEi㗩WH*_%p p Ǎ' 67%X,e 2X&8ҫ>_{Ŵna"r܄*_a |n]M>gVcB~PW Iʊt9c/ggTW6\ۏ_ݛ¸oo=^I/G!R6\{tƟ6%inmzK4IIKKEZ.E\1AAȠ ás)`e2q2&sӝYiOs~/<Yz,GG>ۇ;h {mf5*c?,ks51#ꋚ b԰>_8?@}^Gnx7u6v̀/b@2(CAw6ڦq-gҿu7g8?R<7{{BGeER?.jK?wvT=:uч踂89,C%tz gz@{཮kz _>/߈M_p귪һܷtɜGG8qyqa6WqR6K'Hz0v]_p|ܟ>ݛ,::)tGsc88#8Zܬ}d/ _R@m!B#_y \b3e'"֯MzGek=:Bt5JR=pt±nѽ(\Sݰt*O.r?b̘C"f'Q~mmIG<4vPAo ɠy#ynsmEo  8,OUB$P]*,Od_ 2\G{?vX-s^tSsd+\x )c:h_P ~/k$?fOyF>OqmrѺ!.sSc>;\䱧"p᪇pMdptvZf^w@dG\ȝ -a4uAL&cjHָA9ʂ͞*P}LHuª4Z59_'`K0\RE-U$Fp+mw_ղqlI&&cqjHNQ8:CjU`b+4$JUI $5ȗ4VH%oUqOxBwU`BSDEOƮGm%#P1i(bPɨ!Ft94y4T*Rjܤbs *HW5r[)\ʱ\zD $#F#ϯw泥8!7#kȍߚ YJ-*RBK UX):Jn[rm3/T}RcxGi3-»[1nŌsJnFS'R*U`cO۞<{r%r9|I );AY㕙>MNgҝ+*{^2^5MlkH=Sl-~@ Fy24+iSө̌\P 93*=+4L={l]< K7#L_O̔zvx75RxeXՅ|vꤖz P#6(e3Ǣ49\#L*Yek.{LddHO*sLIs#>|o #c`;3 mcHrSCn|Ĥ* Y|vY ke,K)EmJ.+x U\Qy|;rȻ chiCG3#t27^RL%VJe,u) % VRYH 2*ۡXxK^n"/˴2K-pg]9]m jF_-CF2֓b$&@>*-JLx_b}^ SH~gCcc ~cUEp>4q*=NsaXFh11+)`bA MhPi`0MA C?Vj)x6{LzӐأVBV7q7 $K%l\xa0t\x ǸcBHuhcC걓zCON0yy@0"dF\1RkRivHMdM4pġӄ&45GoLLk.Khhh k\ni)![ 9<h#;?;: 6+Xy#tp 30hs1 ; 9tG7&4nrхU]Gy,AUEpܳ:^J<a<2h6ƺ gGI'M/uE賏FG.Y'ṿ; 1pa0p{Lߐ {%W@Ca!WқO c *r1@_RqpfLtLRl`ut^o$6hVӐq -8.sfp>rFqخR+_W.0Y āt0Rοgjs;pH}A#GGs"^@ aG>|Tp!X4T |pƲ~kg88K8G<N]zS'u/ >z:=E;N*ңn<7U#` :._ORܠԍp/h=k!G!^7YJgz\hDt*bn 6^ 489x,؋h2GM>:p6Nv4#ԥY EfUR0we mXu8# teDt2!Ue/Z"\B.j(fmV]O{ jȭ7\~t \χc9)2xYŮC-Z@泳R\ ,F}9(48ĵ5xW:EiU5YJϨ.&j$ n1 BxS(fYjC(i>'{ogG;k}+l$n9C5rxxK;\p%'/\p k4\5hr#{#PN. idgqedY1@3zMaL$?r2C&X5>ȡ1A.%jTPFgiD a!w+'tCV:7)5C)O( 1|!OwDt.Xm)1PANO!ǁ紆*dT^Ur .eGxGRZySbdSd{< =ZQ1]!2YQ,jLN\r,rNi 9~LSeLȄ*W*,qB=9  NUHO]pwL,Xߕ|VLl)f9#'CNPEyLxl2{2yeLJUD0(-U3Ui0v|:ɮL1v ͥB+tr)D]᥊y ݸ~0)\*ָɽû{Xfmհ2V|ߵ=růĔTMT۩jEZWj^vqq*B˄ΆKZ[µo5c[_U`8,G bK^2ٓ:hh5i|1/jZVXA>ך_,N7Ѧ _\[=_iu`xD@yy_2%ʹx>r؏{Թr`jf>+Te$9 `cU: I ~%ٱ/袁/h _s)qqlK3[j ML_>7\;ֲc4QkTT((kx[w ሕKk4U@{.J1P╢4 ŗqE`ƎUn\ɼEi]l'${.yǵ1Ja} !Ϛ:mfG3m4I3]4E35q^'$;i츎[u r@ 1T<ȸѹm a-߉MKvǀz(j-|BL9~3p.Q3 xԭGn߶dN;|ܛ}6'Ѷ$3'qR<%&4S|qJ~DzR>ދx/9f |ʸ'yj= kâٱ ]0!,ڣp~ӳq0rN<Qٗc;ޥ`|<\^\e>PF<?WOcq|xiorM_a{ u| =&RK忚6W$dv}*1?X߶i{#_\Y3Nmc} 6>|d)];__/9Գ 3%OlOI' 3d,mB=E;bW8{; ,g_^U*IltBtl x( $/g :{'iv6l`gv;8hûCQO)͠s'I=. \x)9)#+yJ9ۉxs'5ۆ Tx>)3tSI/ WB)t~-vk~ƻFvNZMsEp]z>Dk;ddI8,ybi|ENbWVf{crVրco5(Xe1/sSG j+GYvꎣ7b%8pTȊ*J3LJY–ٲ_h9 ukTz.?.7i<%oD,!`R8\)`. .jȥHB@H1%폎@TXb/&f:.cK4#1wsb=8|LfҖxxCCxt $2N(mt 5&j0T?CpmG2aEh9K(U/0q&{@AkX = =Y&zfͺ uЭ>HV^iPfPwމЋlxH9,4ٲ5f` ,x808!qM٠)]I l"10BTI##P$Ccba܍2Sc5#&F&G;Τ-gҖH#D >[3F5b( Ab${izз9&l^}p"F;b2!{asE D&x#8j$,byb!p,dLY]ّ!1CHlp\q .U%NLH-Rdch ^@D3Hvgxq|Dp*жhcHu}67jʌ ?R#3I3< PS,> ؞Eq\=-R'6;9IAzɆٜI6|XdA,@W־+Y?[ړC iBF-(ӊ -A[(oq@j ȡ^s8j$,AE$h~?Xhڊ>ǁ-•a0|!St+R)5D@*zmahCFnlV7qm͐pnyQњ+{O#Ok R>5y]Nbs0 ;P^84~EJcil)%dtUY#Wq€rFtGz](9dj_8`]భKJ7HKwsؗ1TT..(rۮѵ}4f>z{ϟࣵLAϻsƌzfzkfL(քC ~h?j}CJ3E%/c_TVJ*pT_xEy\_^Hڨ;Wi YA"ҭ[l!Iv^يR9$Vd2nqy>=/<y;s+Nw $ ӟmWy0\*c<0gלuN@B! +G[Yu?R|^rrH/坑,~$K]Kn`l=Z5[7q|gUnr"~F8ߛ-cY đ\ೖ-K1Es)`[>zyH]PF(볫ܤ;dqFV Lk-zPߔJK{wWy~P'C8d,ߴ. :J@7 dzqF@` V" 6X ##  ZeWŔԃN~a~qfu#E".lйy.?Xϊ ;m HK=`(tu4G!gn_:^!B@zhCLZ8l$@ + @ e!OAx C8~ⷎNs]=/I֣3ѡM*{q6ljK~!}9Ym!!_7Hlް(Qppj`0GXs,D`+/xGF@ҚSШ s=t##URuMT?|zq+[:sMnִ䂹33o\P7.B *OEtO1o,N4GO\ٞ~pc݌)GR0XQAl(f4 M)h@<׹L"]NJYsr,'%hݹv  ݆/U)|JnPW x kFEQ`0|=t[ 1x}fpc3A&ŽpJ ~ 7%1,۰PRND,^HU0uf>7웻ñ]zQZVq6 S d`0XA#GVJ[(9 RWvHo^0x3 bx p`+gQ(^1ױ>9ږ騬*^x#qb ,Y2aHwcVMOb/f=-ȁ/} - `=瀾}k) 4`" C!)p3:mu@XoQv ngn3w:s+*qBV- M$NreO{}v R` 83JyMO4)XZGyQj{DM {_πY ̸Ӻ|)weUefᨈ.A]]dciI~\w<8/t Pg+e >*7E`S# 3\GHpχHn aKS[K 5uk;mɶcVރ iEHD_+߾U\'9GVXJ¬9M<~̨փI+qijL9%A0pcF"((`77Q#'q h[:-H,n#*Z_YXO =Vy!pLYzY*K;x2}{"w7er"Iw:GSy\V[<6'Rչn%:溬'5mDtbZL\&$ ܾ~vן{}߻<%E&gINDHJ"NƄdD] Q!c@ d *>7 8PW% \ h`3^l:93cM|;egA :܂8XJ[7XI|0|N7w[{EkvcJȬi%J-Q#u|FBѵ<~ԠVTw|_JvV{J,͓ɯ)l/` R|Vxfm 96pL1c3Y0ߜ,/NP[@Qt+eKTe9ۏ-p Ȯ|BpW$ %IHO޿y:~0?_(gD,rE}KcШ+)J_*=I,?!4l=Å[Pծ=Ğ [ }g OZO$o!xL=5dbBC) Oմ>RIr\r"#;@V2[kclzi5a#*Xm?;62.#:ĉ֙Li_8L+ endstream endobj 140 0 obj <> endobj 145 0 obj <> endobj 146 0 obj [1.0 1.0 1.0 1.0] endobj 147 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 85.1999969 0 0 85.1999969 69.0297852 45.1300812 cm /Im0 Do Q endstream endobj 148 0 obj <> endobj 150 0 obj <>/Filter/FlateDecode/Height 355/Intent/RelativeColorimetric/Length 16185/Name/X/Subtype/Image/Type/XObject/Width 355>>stream Hy<]g/څ}K>R!j!)C!)"EP,ɾ-;g9w/2OzfJ_O?f5oeܲ,;ֿ#50kA-C^g-JE~zjj }**D$C%BZJCKKGOx==-- &QW? VlYX98P(ŃBqp0312jzR%d^.O@Pl\< ZHPPYh*dp2@ m-z#VTLBJZFV_,8(v#Z ʂ8/`^?A|גKu&Y&^[gݪTWUQRn: ?/ʎ"R28E-ZںF&6vH_W[Sm&I1,p}fCImA^P^A VLRYE}>_G~}{]mh*g Qa/fÏLeCA^qi9e5vNn{|O:M$iÉӧN"x{v`cindİSJPp8U 7mc@5|0"2 *:mw:p艐3Q'$&̾_PPP0`%?/7nvfƍ q1v:0VW$EP׬YU&(Uz&m\z ;pJ9%*WUU׀\z\񨼴0n֭R !۽a6#]- 2"2\er/2_5m3k]{ MHJ~+;Gk7>knimkko>4?kR[aYɽ7Ӏ~/vxͲ@28WhM5I?stܥ¿=y`pp]m-͍uk Đ8XhAe4?7+=D^g'ZP`N^!*:y#.]Iu'ae֎}C#cS ӟ.NNN  ζg 쌴sgO<] 2X!^NPe W28:FP`2V?i/-~ۙ4۷o^OOёW/zsS}]Ң磈^nfZ*Ң~.L(Aa Y% ]KWO_s!9=36>oy?4<:>>|; unn,/){+r|txH7 #JA#+$"SZ:{GC߼w {H/JIɼUĆ<𲧳Y}MEYQnfzg[ C N+ȃb]Ȱ|hQi5c+'w#AaQ$ʺƖ;:>9 pɶ/ 4t~zjr|dhEwƺy@9>*,舏J@F9#&YCXhD\R\?4x߼]`/e4t`_OG {;-)."S]uŕLe4 ٹ;DSTFzM]EEgrAa{;[*ˊr2R/{ؚ-kSnwp9Up%y<>(7V/JO=j쑑S^X90r*x#KB#SnW?_] ARy}uyq͔!>Y+[j>S|hxbx#+/^WRQrp<_LR|TWQw+Bɣ>n<j))#$IIUXVw`xlc<96<46@ BdjˤȤ! O9nAv=ljӳ=nh~3~~]-UWLp$/"f#8%ٻ%]ST^T2ʯ r,2ɺˡ`Hén5up:&1Nڦ)RBeRrwk9143_dXaxQcQ6ٱ?4bZvg`B |(#U frD>gkcXȰ@Y95 vz p5^yMS{'wNyQ=$0sE^jŃ\$`M~Sq3t,XEHtse)dC*#RԐFRXH 5dK=KDȒ%[m~g|>Qd>k'wߋ7j: ~, A.ͻ{hEJ2m#3;7NLFEȂz:("Nn*x %ȿCnk.ɹ}#1;3CMEZ~έcOXI+qŏۺM}J_?*Jv鬻!gh54ѝcfݪmds1ֽʺY%]ϞVd$]v3YJd#hd[= Jȩ: _whlrh0!>o)ItWq33Ҕ,PI ;'soHLJvquc f0@O[}Uaf" 47 qՃ6[В`fQpd`x|zAE]+0X9 o7Z[3cRʟ M0Cl@ڇ9)Qz;UѣZX)% &]gw Ov{Wߎjg&#IAJ0~.8l_(c^vzNͯl)C  ʼ(caK%# ; `J[xsa񪫱2?5 06Ua[vͷK֮cV5#~10*RCG@WSU~jdTGYJuڥ kױr J)=Qƒ7ow{$q \cdI*2wLAJsa0K&k+Hp0.YA! elOJΫ\@D( 7V&Evʉs_tÂx&/")ت%'(0_kdi5M{%7r_u֗e%\j K˵ - M*| Owԕ޻}@ 1H7?c,&T;y]YZ?: cMgt4UYtCc'7s^\78V9a1׽5 %q ,&ٻ]Xz6%>ۉgiQ팵9(=yP &Dil>GJ_3<FA>̼n~|fM||WDF}o73U24~f|ESENg+`(c8u`U-6>~=<[%oz*q\1ϱS@RIa <$#WO.LBXLN#(n :ȵH_[̓Q$hnEtLg1?)1tDx>^>O8rp717+#Et1OXR^U3ĂC dUތ\= hC@Bov+$>k jrn +=̌ $hˋm@ #'*ZzS8uteh{mѝ7۽2"dK;~STR2$a*b%e&""2"SQ(Heri~7m{~X`~{ƃ&' &* ^AL1e*"+*@ȮpOXܵǵ͓׆o[ki>Mî]g%zQIkCbDE}e~zRdOKt S12ۮ 7rԵMn`sŅXH`STӷp >~NIMd ~h{x6شn]wC0*i{{#.7kcMem L'$9UyGHUu-&}L@5xZbrgV5^Ǡo]Ccrq^vOz$hϟPe_ l9TIlLK}^\ wk|Vq>2`b-Nm M,"a7<:uĄn}YsTuVژ01ZI'Q"uuQf@wmLXmWDͼzlcxmƅysjA/sKwK_4#`&fҠ\SH۸VGG/ isjc0A;z|\>1\ ?`c8섥kYC<H/6BnǑKm T Ia/"$16b1 _.6b12"ؘWPBqwWeC61dΖĈd-} fci362T())ҷp[fqİ1xbC9-ӛ%/.0fl?|f7?bAe;k#R|(;ضSgx<F6.˾r"x 91 TuDl`vT?x؍'=+HOs]aedHlM<[j3&h%Z*2Qvj\# x\AxPw~ogK}m .;PvF좒?+hO6<'Go'Ng=*^\ve7|AW_ T#@cVIAĂ?ђnU~nb{$L J<P/;6 %@{=i*0w /#Dܴm欒 ]&lHꢌ 018'װ`r[""7 1cO@Iql|5qbhw`LdR>#piBܣ U;r+pN-x`5Z2"|ߍN  j\|#9QUp+့L91~I1WHr\#M)w`_()[^ГeuE a5;>e%k`_ʲ/ 3(^^XG1BaXv/S#Øb2@Cd91$cQ~Q'TljX;"qAƳ1b^aicSs+ps(&2x4I'U5wڱ?&bEqCEnjl%Z¼ Y\hI589 o^W܌߿\WUVgA)tx˶ qwsMqfjニGбupq(=m/ݹLjm'4l;<<(r4>l5@2n;3agn㶣 +ɾ$nm3lF淰-1.晰Nf:*2"X@LA`+vh}Gc}yqوv 7F}}Zr51'@aJΓw8MhP]Qyf@x(pq,w:\HaĐ٤:l =}# *}E ŖՋR Qlk$b$)8a41r/-)aMK7 "0=Zs]Ufj'GU AnyD]o9ڸy Wn8 Rj+JD&/|bSls93% Qۑ + xN nWW4tbf"ʳ/dc4g*bgplZ.lT6 cpbZ^! ,Q̹u5>K pyȨ9nUP݄/{S-G-n|bJB`T]p+>l@'2gxyP~!tb~Qz}#2h˃{3"v}b}pb9z >1U1afRzKry8<>!tTD~b5Jj C/:!'.'*tڒ,'u7}!kY"Y ,`J$c,2D(2 -TT,5Xl},ٲgޟ=OOxsRPH<%&.qxƠ!PJ<@g(%q3xƁc%ΟNbR jBJV@|(1zQ89 J57ܶБ8wJ yI@b)$6p39J"z[s#XW],N#*?2)J*=@[鯢_tzխC,$iʉ8UOE4YUVO_O@Y]ev5?g ]ei', I2*z[]OET BYutCsG>Nf:JR"|Qb#I+Z8]ˮ| %fؗсPヲkVH NHٜ|"RJ:fN>^7cgcIFL$x~WXrӽ!YeM]_ƠDwyv/沅#$mpΏPb}o/zvnKqsPK,pf#7t@pQ!'6w!sp_zg x&?Yj$n9 ;).3w,$}_WC6Msȩo5(HT&Eo7TE`RL;ƄotShqQ]iSwڮo;%)$NAhbwß]ѳk-mGxQ^$BA]k ~ؤCwn-=|w9Oۍ1_o?A4JbCO8묔&qF1%W1avȎ~m; q9-)MSePٺi8 GAmk L eRLܽYk=A}]1hiʮ'] ꤐQ^g}AZqC{,ʮճU&sωPls(WҍԘx5W$DܹqBM'ɫi ژ.erBos9x?IZiK`mLD٥Dzk+J qO9y.]q?(rL<, &1`;z_ZT<`í{|z:Vt G s}˭:"F>cUi@8޲c1*bk:ƹWǨg8+6XCVLpɘ PROr"Ճ@x[d {IɯhAˍ "iO84"#$f=cTFǽH>cS EM:j52FT> l\}sQ_p"Vr' >Z  BZI̎>y PLj*1$ x8/j LGI51PVQמ#S s]/@>w7D[( |!qy=c2:*ƌE =tjf42޸iv)]~:~6 @1BN]E!߹ژ^>Bce݃bդt"=aLy6@}x7142 /7bŰj [ 0?@Ă1б׏#<`ڋaZcu- +f!\w7Wގ"vj1KuغzG!ݘ6+t2bUp9%5\nV]@sENrjX\VlXr:*H9N fה=C$Uy3ǿ LgP{ 8=aL!V(79쬇rO"c8y Z{.\Kmhポny:ZkKl[f3R69MJ+(od ALkv킗5ȯg2ݼUDFi~OpLzae{Ɯy0(mE)+Bx1+vٺe e<3q޽{f~nf|gƓb1+,y`iZ, 8h 9lX_  V.X#YcCq|F9',нkvjl0!loa!b)c(K cSc~j}YAz!lGy7 lB g9%,)19^Wz?-&^ !c{HLҺޑI X<N@ d}x@wਔ{jzo̔n.ɽys^5¬ |HnTt/CE9]mh^vIy 䜢0,豤ğaA^.zrKx+(F6 d֯,~>R03!ӑ%V01cYUG<φ&dFon~2C@cg<Mv`A3qnkG3ش_`HPwR~#:XV!&Ŀ: ㍛v(it r 20d /0$48d 3NDG}Nr$;w{w)) dCîgִv 2;C^`9֚O57BnmRc0,Devj;{Sr}AZ3d<>jcUqnJ49L_CAZdRS ,DtM&" ԢB^@B='Y$_1+598tl@@X]{,\΅ƦW5P{Ӡ20hqz/8/-6mDбam"$&i`fs?82)60:9؂ 3#Ԗd'E{13 4;2>K{7+qŕ=C-%d0舡Ը+An$ЭmJ|*i'!'f?n죏O;b+u6U?J'|3RŠo` /rȗcnݥնG&2+鉑~Zkm [+H$_P'!m;#_}آkwPiun޻C#D AT()AeC&PEP¡ԣJ&.,ͬs/ Af\}-xnrlDqnz)[a-eY1pZ# c,xEO8xF/m餏nd!wԖgވ p8a(%n[m$Xo|->xǜeu]16wxZW0@)k J !8H.2Xd]4 I{m2 QI/}Ezb2J&W iTZEB| :FMK _t41P+c\-$Uc\F$q =ʟCkoV'EsV [Y+V J+ٺzE&e0ʛ5ʟC^^Ia^fFҢ"+ Q\e`pxµ(r=k2˗]r5=)SWǵ{TTdc_XBolnөξy2/\ɱގڲBy37@ICsۢpv1=^IyeM&r$@.,mU^ZP1exbiSuɃĈ0"cn^ ,*B")wT7=~99 +32o2 |W xR_Y|?f<94 ,F{mF1n'ȁJuuz#SOļwv0(5SbIW\|{و'yRlmÒ֎^ 86Y{. p8Z'CY}<*zrZ2Hhtbjv_`g4-݅u~}j~vjbtu+1˾^8{K!R!'p"K!ԑK{׳nҪ^h 3 AW \Օ%ީ!zog{sCNKN %|lcnQSP6D$dtP&XkG7opxD܍5- 鹅%gǁރ? \ݥŅQoKcMB{#|-9P`SAEqa~p!PXܼ2*zN: '&ʤT6WK+ By)Wkخ,/ZM%E[7h5A#Ąv0d!1)yca|FOI-5m]ã/ ^WVA^o f]\x9:QyUm㓶ݽ}ãcS33 s~@/GG}۞4V?SRbŠ ՘eC@E%e!e47 ʝ h*<}E}`pphhdyhhppƺʲGrPRc'^ pu6: 23bC )em}ãfN8ONKLΦ=WV7=inik{Ӷ'M u5({\L{G;MIM"^OQC}m5+) fb*( T5ǰ'pxDRdl|r*%#3WqIiYyEEE%GҒEsegfPRc#Iī>xI1!*le i*jZz(4h0")":.!)&%NfVN{ Tf99Yw2n&'p=7bX%oO7^4JOKM倜4ӗ_deA1Iyy ?BPHhw$rdtLll\I&5;}태>s=hogceanfzePSQR rsR}0<`4E%e e6>1=nŚ,)1ePUAr}L<>h9 }PMCS[G(Sw-Ptl^(Μ *]t_y=r>\n""};w_똝sI|S@]ZGҺ@,Y(t/6!3 3:iR|$$&H0+Ѕ@X;gҖ:#dv;έ4R/=yeZ]Kk=ZF ro2IoЧo endstream endobj 149 0 obj <> endobj 138 0 obj <> endobj 15 0 obj <> endobj 137 0 obj <> endobj 135 0 obj <> endobj 119 0 obj <>stream Hҁ 01~^b3x8|* l*@\ *@\ . l. q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ #q  \ #Xfd`eF :)׀ýV endstream endobj 108 0 obj [/Indexed 14 0 R 1 152 0 R] endobj 151 0 obj <>/Filter/FlateDecode/Height 129/Intent/RelativeColorimetric/Length 1963/Name/X/Subtype/Image/Type/XObject/Width 524>>stream HK$N^u(:ZE[ \hv u!Mŝ d+A,q 7E(.$ T4YHHS|I~2A3PTjZSvVN[ JJju:eeTH8N7d` ekT Rp:o1+`(rAiĐNAg犅%RYk`]yTR\(f1S0&H7[ms}]MUT$ï1dTszTPZYkilnmz,hkmnV=oAn{^{d LNʸ? biuCr\&eۑa`fmi. wCK-48'n힞s߶+ryZPiFQmߍNxf >#-{f=֦ZI4'4VXmfYw耭RQcȼk2W5ZΉ_@^]X[ XUlnZH]17y}gw/ e{;^XwsMI{yTZ R]mp|vQ^<8<:#p u]^IIJɹN{$Vbѓj`nrLoA5sʻ{gڒwi0G҂굥wxz~yc4~~q 8m,OvZ^27^X E/DX,{uy~= n,G~o[DZU2y J&.-v9_c?/J 6)1$.¡myO_BJ HaJ ?¡/{GH{n!ZH]'S-|C Z hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ-R-$ˮBO-[CB10: 'ZpX*J&.-s ]}#Q*H$EʲWgѣʂ{,{'K`ypocy~zQ,7w؝Sޥ݃H,?;FvזSN{\4jVz&;h,XEO"߃;ɡ2QЪ[ȗZm㳋q86GN+ixwvPHYv?9w7הM /2T\pN`ڪL8ƪbSJi)F?xK+ X%+KĖ[0ǐyׂFo&gv;?~¼wn31nmD^C-(J>>v=.{zjr|9`b,o7GCXZb9#.=Ivd94Y[Kl]? o*Mn[,غMjqs0SAie R[YZ`ǂ҂JX$UT՛- 6B*MӨZAg犅%RYk`]yTR\(f)Ơ3S^(։b~)tRHǠpZ!#O,^iԏRHŐAqZVSVLM)(1drPj% `Bo)  A<>stream endstream endobj 136 0 obj <> endobj 153 0 obj <> endobj 154 0 obj [1.0 1.0 1.0 1.0] endobj 155 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 125.7599945 0 0 30.9600067 50.8657227 420.6415558 cm /Im0 Do Q endstream endobj 156 0 obj <> endobj 157 0 obj <>/Filter/FlateDecode/Height 129/Intent/RelativeColorimetric/Length 1963/Name/X/Subtype/Image/Type/XObject/Width 524>>stream HK$N^u(:ZE[ \hv u!Mŝ d+A,q 7E(.$ T4YHHS|I~2A3PTjZSvVN[ JJju:eeTH8N7d` ekT Rp:o1+`(rAiĐNAg犅%RYk`]yTR\(f1S0&H7[ms}]MUT$ï1dTszTPZYkilnmz,hkmnV=oAn{^{d LNʸ? biuCr\&eۑa`fmi. wCK-48'n힞s߶+ryZPiFQmߍNxf >#-{f=֦ZI4'4VXmfYw耭RQcȼk2W5ZΉ_@^]X[ XUlnZH]17y}gw/ e{;^XwsMI{yTZ R]mp|vQ^<8<:#p u]^IIJɹN{$Vbѓj`nrLoA5sʻ{gڒwi0G҂굥wxz~yc4~~q 8m,OvZ^27^X E/DX,{uy~= n,G~o[DZU2y J&.-v9_c?/J 6)1$.¡myO_BJ HaJ ?¡/{GH{n!ZH]'S-|C Z hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ hZ-R-$ˮBO-[CB10: 'ZpX*J&.-s ]}#Q*H$EʲWgѣʂ{,{'K`ypocy~zQ,7w؝Sޥ݃H,?;FvזSN{\4jVz&;h,XEO"߃;ɡ2QЪ[ȗZm㳋q86GN+ixwvPHYv?9w7הM /2T\pN`ڪL8ƪbSJi)F?xK+ X%+KĖ[0ǐyׂFo&gv;?~¼wn31nmD^C-(J>>v=.{zjr|9`b,o7GCXZb9#.=Ivd94Y[Kl]? o*Mn[,غMjqs0SAie R[YZ`ǂ҂JX$UT՛- 6B*MӨZAg犅%RYk`]yTR\(f)Ơ3S^(։b~)tRHǠpZ!#O,^iԏRHŐAqZVSVLM)(1drPj% `Bo)  A<> endobj 116 0 obj <>stream Hұ @K ''P|xb V` 6` L+Xr \A2 ,\Ar +(WP\Ar +(WP\Ar +(WP\Ar +(WP\Ar +(WP\Ar +(WP\Ar +(WP\Ar +(WP\Ar +(WP\Ar +(WP\Ar +(WP\Ar +(WP\Ar +(WP ؂p +q1 \A+[pr1c sz; [Ma=-;WMN endstream endobj 158 0 obj <>/Filter/FlateDecode/Height 146/Intent/RelativeColorimetric/Length 2258/Name/X/Subtype/Image/Type/XObject/Width 534>>stream HOq. -!r¶:Қ̘a qLh~%d3@ɖ薱 `X"QMHLRJK9ߖqvr~8' C(zmdP"aS3aTT,6p!4PfYT06XTLBPq9l&u"q$V&Chb1be3,QpY"!QJРR&nQ˥b!ZE0 H"W(S5Y 234/<4 d>hi2E ; doKU)db>:;J$ݔ{_NONXbUL@**+63jfSGWR`xM96:X^Ԫ`ʴuMf?K }j7I*8"%[{ߡڣO[3jm9a`WJ9,OHdJvW١#NYuvl@w6̗ /ҿUA-Ɋ%g;}KvnkogW }omW'DH6?F_f:zꬭq:&nw?n^xRŠ.8oOLz<\cׯؾ@b^UP+[:/^sO<|>zM뺊-R UF^IUckץw?<۵Z+uepA=LIcuS[kܼQ=}ZJ2TUd&Kmpd|;;/3ӓ[=mM{*\[ŎRsfw=9ſ,|^vhLUKU1d·(B‚oqms!U8wU?*xNl.ݱq5@~TULQU XӁ*`ł?PŨU"T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T_ lF(Xi*^3PU@ ?TawU @UGv*lTϏ,oazGϷ[LFCU&Kmpd|;;鱗g']zښ3U2gmcuS[kܼQ=}ZJ2T2!T#2J[.ݸswfvovԽ߮R_Y(cU+[:/^sO<|>zM뺊-RT 61= );g=+o-/о #",,HҴ减[@onۣ}?iy7?g"Z"<͏Q䗙:kw\1/ک$oVsiU,$>IUTV^QYUm25@gԄͦ&es,*ަQ"馤l>h,`ܻ睷4rzrL,kdrxxe:+G3v z_ޖR,F*I erjZ BAff畛F5QE Z"S$$*UIT-xT,qQ"1XLA&ƈE\b &Í BBP(6AF<"TTl6Bhff IA!65s* J4 $ֶ{x 0a) endstream endobj 134 0 obj <> endobj 159 0 obj <> endobj 160 0 obj [1.0 1.0 1.0 1.0] endobj 161 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 128.1600037 0 0 35.0399933 49.699707 417.495163 cm /Im0 Do Q endstream endobj 162 0 obj <> endobj 163 0 obj <>/Filter/FlateDecode/Height 146/Intent/RelativeColorimetric/Length 2258/Name/X/Subtype/Image/Type/XObject/Width 534>>stream HOq. -!r¶:Қ̘a qLh~%d3@ɖ薱 `X"QMHLRJK9ߖqvr~8' C(zmdP"aS3aTT,6p!4PfYT06XTLBPq9l&u"q$V&Chb1be3,QpY"!QJРR&nQ˥b!ZE0 H"W(S5Y 234/<4 d>hi2E ; doKU)db>:;J$ݔ{_NONXbUL@**+63jfSGWR`xM96:X^Ԫ`ʴuMf?K }j7I*8"%[{ߡڣO[3jm9a`WJ9,OHdJvW١#NYuvl@w6̗ /ҿUA-Ɋ%g;}KvnkogW }omW'DH6?F_f:zꬭq:&nw?n^xRŠ.8oOLz<\cׯؾ@b^UP+[:/^sO<|>zM뺊-R UF^IUckץw?<۵Z+uepA=LIcuS[kܼQ=}ZJ2TUd&Kmpd|;;/3ӓ[=mM{*\[ŎRsfw=9ſ,|^vhLUKU1d·(B‚oqms!U8wU?*xNl.ݱq5@~TULQU XӁ*`ł?PŨU"T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T$T_ lF(Xi*^3PU@ ?TawU @UGv*lTϏ,oazGϷ[LFCU&Kmpd|;;鱗g']zښ3U2gmcuS[kܼQ=}ZJ2T2!T#2J[.ݸswfvovԽ߮R_Y(cU+[:/^sO<|>zM뺊-RT 61= );g=+o-/о #",,HҴ减[@onۣ}?iy7?g"Z"<͏Q䗙:kw\1/ک$oVsiU,$>IUTV^QYUm25@gԄͦ&es,*ަQ"馤l>h,`ܻ睷4rzrL,kdrxxe:+G3v z_ޖR,F*I erjZ BAff畛F5QE Z"S$$*UIT-xT,qQ"1XLA&ƈE\b &Í BBP(6AF<"TTl6Bhff IA!65s* J4 $ֶ{x 0a) endstream endobj 132 0 obj <> endobj 129 0 obj <> endobj 131 0 obj <>stream HY@Q/mԪP XJ=v\5 y*qN~qT8G8S8_ʸ%nxV~v^m" y[Zzw%mgt:+8X!T>D52oP $T#FM}Bzxut_P=%G^*Tmk C{}UBıgzhtߣP=o$!x ;FH B:!B:.| +k}z(l|~V%Y{|N>"yG|1_|/p[8Y8W8U8S8Q88KJCB֧'.ixR&J!n }{ Fm y?ugpغ3;cиm>`jA.l endstream endobj 164 0 obj <>/Filter/FlateDecode/Height 217/Intent/RelativeColorimetric/Length 9883/Name/X/Subtype/Image/Type/XObject/Width 225>>stream Hy8so`c-;Yb9"eIBY t%IIIIC#˜R{Ad/9sw6.ep-y>|#5Y@4:K—Tj#d  LL,K1112$I#K!l 2׺1 LJFcFrqr@&T2m#IpÇDDėFLLT&'(\F8(^4 KJ)(*)~e%E9iIqa!`geYFw;apҲ*ںz[ MpVÇBrҌk!GNLJFAIUS[h^[;{{űkcminH[- MR8!4ոnڨR3EQPVjj=x@|a}{lvm7ڢ #)Dd  uRjZzͭ88<p"84hkmHOKMQVJ+`A4_u L̬:yᏟ 9p%)zjjZ:H HKMt%!\A~mL tԕQŅD՛T*O@X\FY88 u+nn^~A!@p]X{7VVzՄvq"0F6".PhA-&6\=gf-IEeUuM-/˚ʊ'Eo''^ {F}-Ui1,?6H)w8u.Zʫktv] 0Hh{X_[!^QaA<\͌4d%IDx"O[c=Nn>Qq)wr Eu-m]=G@F7C}=Ďڊ2JIFvhIIDxܬ\tpSx&ٺxĔ%5mϞ =11 2x;1z|P_A~0?r!ݦ:"3)o=# RxvG\Lʺ6bO695ffv-BH3o&b[S]eY0^O8qs#]5@E"Xɫ>pTn/knsϿy4so(rAޝ Q!ζƺrX~Nv򜮀.NR^  ?w%#QymS{W) >|-Y|351rdb/ńxؚj+ˈ 9gc Ɖ`%Tua{$^NaIUC+&gHI4 BB%DLO#豭qQgyfEi^.vxҳDJ}H>Aэچ.GBIUc{O0E7j#;,(IHqYgK]ţ;dCO]^A3O[T79p2G(W@@} a8P_Wk}eqAvZ™_7Kc卢D)y< ʨX9M$:ٹźI5΁}| jnk۩Ovk Es.c OO&P40;>]XBL\H >ʻ (Ǐd˘֏ŏۨ+(2>v~1=*'_w֗?+1rb3SLN]ohլ{+HKua8im!;bd&I,`a\>C$6m6tRZ'u4oiĉΦGy7 qٮKSVe|0ʺm\|&/i!>YvI8~L_MVTm9mTbfswhl{'[j*EGr )>~Yu G3״t x==K""8Q=k{L|.y"\Dp,@ ir:{6q}{-H~bSeݴpC{w~,-AXiY9RVTD'G?Hɱڒ˧Ͷ0܈.[_-y3D"(qj|q᭤x9~g.+¿BO~~2JFƧїG&Jb~s!N 5DQ@u{g嬂 `<߼Gg`w.᝭4+}6O%(,ox Ɠ\ hB0{jKe\ u& ٿk&{pl:+,bY~fB߿/=tYd%KE%e"Ң(E D%dR<[}M4}xyι\>x~6~_faEC{O?2$Seyia^p-hm1&eV6|C2}ZGOO (/bg3Bt?1s|0*!ߏ_,b[= z6VeO]a1 1qK툏:op4T0{Ӕfa83O| G/V Cp4X&k=0)B϶HLqz^__DDHO ;Y_UJZF GD^3 '= u7[,7uA KHVpKa TaO[]Y C;LuŦl4h8-]^PN |8aOkmik[iBW^ifvVlZ>Z!wN:o5Pm#G-d!TÇj~zjI)(2kSZC5|XZ u6V۠֐a$K)(H@I==/K޶tS "Z)0m qMZAXxDu7ع,nz7jpBhُ]t\t2#D *ids,XNR`4ŤP?]f+ N9(=)kn4M` cnn7֒]y2q Bh‹ⷭ=祉󰣡UJ/gK}UI>SPFcA贂&T7%/.fQWQ{݄#+{Nw}DU k\Use 0Br&;@R‡!铈VaIrzTj>(L@*䃄 {ZB|o7֔dr +lw#Qvi}{%ETગ_A.Q7>}#6Z &Ҭ n^+rE<Ü0.px0mT頃Gw.fg Gt\@ t"U VaMQzt >B`e!XIZO*bw 9pp QZ(O*a[/>½[xƭ3b@ PYcu'8|c?6B;5k #h9eM][D|UJ"\̴cktt E,]fae%=lN} 8I,_JxQ;_0@| {Z R_9`#/4NR Y(|絨?@l ޼9m8 QA1:x.$1 2/%w;-d34 [|v-cO` %QFZ[x±4)TXq領L,#0@lj,N=fXjhs|hrB !Hj`3=vmЖKC 5?ZP,D#mw˖5:нbI%ub{4_= pvlSG\(Q f ߿|ht =\R끇M#5b(:c@ࡄR(FC}Y_@;+iDm]E R_kH~u!_ʖ")tH%=t9F.!:5M_~m[kd}V-}GJANxSO5\΅6p GW0 )it05rh )z HAUC+8ݟ×8#堭1P (X ^XCDeM B.b]@vFDKA$ImzKEh̞E1B +6=s3eY$CoKU~J[C.z 1~S`+8evK¾Mz 𘔼^y̠G]e)=FJۥT"zOoH1^fF(lt,jAlt Ŕe ׀h \9I!>-W) s2bMW`,3T*k-Z+Rr:栉ƽ(Cw@E{8'p@PQ bMt?<mijKn;֩IP[|BVycω3^dp‰Ȝ N&( XBn?D_p9{+1LX@1O:lЖgJz @oHc =(%z#̈́DIt\GN}"vcUyC1 <+aTWIMYgH\ $:M!&fx6L>ft̝NŽ(k"(@okU~]#6*b#d9zƒJzZ*sZ+r1ӿҳhm}ZtfI}'Q9I!>-W) s20sY4L_J]㟄j9?AXx%ՍvGִ )&qd#@ZfqUCۣ>ͯn  J+L5a 90q*r {[I2 MDjN#uIĒSXiՖ}>*C8R"8pJ WMNP_T}aꩋA"k",S(Y"M2Ȕ"DXJRtTƐ5#D'$;}.M=ӹsu߁=(1`EY1Amm yI (%E_QGHׄ1_ bR͘ZbjxQ^bTPfWpd0!5c meEcI 5PbC"' 24SKlpn[:k |Y ~1Lce@`(@pȎI󬋕F=BU^{T؍C`KUݰ?ʹ։d9Gf׃4bM+ #\()]fTojv;4[F Ds[oW/>tps n2Q\"nM q4 δt@t$6X' w 13.z;e6O!+vAфt=bQ1[PcJz{n'b $Dy,'ú|l1úRdWPt&#H8y7)Is|$ Xk7ք1RdČv5V%9[+Op٬Cp "_Xr^Ec8=/R0#}-5E/yEy>- vngմLSwc$5 4 &!R'_|Rn9s`~M$pOs51=q۝s8)N"ӉU{6gЄyPsBWQRbr H*Z I.oS„n,gg#tJBЅr6n)IM]Фt(!jо̘Sʹ$?S2sK(hvJ L/}3h˲ܤ0_) &!ڶG9ӤՋ'i]ߵe8 %'~Ԃ&zLRt Ԗ<}ROYjx#p9 7 ݇NV7 +Bob,7WkͰ"WDn>Һ>:;C zT`&[aQSHάް\`!7_GK=y%̔IYWI)<V]APOsmIVu?}|LS&Ufs-u   ?Z_|3Xk4|Ƥ IiYgUR }"|S".x9(IOИKPRa.[9(PrEAZS"7(դ:x]H/ohQJTFRN{qΟ3);8CU}Kӗ * J<_%1#6HsjyUt¥Wp֪uD8DMg "! 78/0(JF.!O*8! x| U% Ah۸_#_?)S|UO3θۛ($}GHJQ-(B*_?{v〙$A#,cj~I$ 𿏋5Dg*'t 8'|"*f< OHޡwQa/?z8@>1W6Q'*g~ >.gptb {7>ۭ&'% fVBNBp=AAy}s5j m#xO^[E CKk_wYDRim(̈ß}EB uMm]}FT5 M"."՞odž|Y>BYO+_ DT,/5: BQ 9qN/޼C(,k6!R0ގ{,zt/2Tw& Dd:zc!V7b"~7D\u]yafbxQA[K)hX:_]TEB k#W=K ;wNmeYQC!zu}sc>Awk{F@~; R@xB+Yw#x2R[@>yWUet>&5T#bxНo[|n]>f{0g ȇ.cfUjdu?8"𸴊 |:T<ԝʉYn8:U`~KJd+NDZAclbyMc[7PH7:PY+7=F[xؙz~)@t pJ[ v=WL{!DFiPgzp%p(P(UVE4JiH TVhX"*49cggwhٙ'oyi_W[s+g󳅻/I8} p@cju غGW\~NBi*gC}Kk+K NLٱ)p  ^޾j1[ڹߛ~tzY <?a*o |-;cC}A}dVS}[>_c nS zڐ脔Ob>{;ۚ(7Kg[: ^8~DDM,ŶqIL=x}qbg or@6+X[UVw4 W{+:5c"DJ4XںcArIm]FHD NA6&S962a˝5_^JBtZFpf>DSVv+!b.\Q/׊?MNJ ;_Ϳ`bib=hjSـdfHxp"R"OqabbJzg.V;+)_J @!*h ~Wߨ(+"G$hGt?҄n~Aͼ-qtQbX̎>qZ|ރݽCãcϕ995J&lb\؞vwpncsG}{?/;+SE\hVC ރ^ mJDmەz d>0Vݬk&P >P(bK]o+.ޤ-7t<*Bs|H UvΞk#bwg<干5 ֶvygwOAbB18S$kK%'H9l#tHeZVy(:{|Uɍ爐 r[[dkoH^,-)Nef :t6E qH1Xfl̓joаHkV=\lJ& ƒ9/tH}2@2Mؖ˸<+=<|o>mr{w%̄ #XD7SQ !IT465Šc͵lx6\kLF5 0Zn^FudR!ÈbaF CR`1Z(nF(S'(D"!FtMC".]&p(K -E F%̄Ru`E`Pۻ{5 9!)l(흳D ͂2?E+N+@ endstream endobj 130 0 obj <> endobj 165 0 obj <> endobj 166 0 obj [1.0 1.0 1.0 1.0] endobj 167 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 54 0 0 52.0800018 84.5776367 313.3604279 cm /Im0 Do Q endstream endobj 168 0 obj <> endobj 169 0 obj <>/Filter/FlateDecode/Height 217/Intent/RelativeColorimetric/Length 9883/Name/X/Subtype/Image/Type/XObject/Width 225>>stream Hy8so`c-;Yb9"eIBY t%IIIIC#˜R{Ad/9sw6.ep-y>|#5Y@4:K—Tj#d  LL,K1112$I#K!l 2׺1 LJFcFrqr@&T2m#IpÇDDėFLLT&'(\F8(^4 KJ)(*)~e%E9iIqa!`geYFw;apҲ*ںz[ MpVÇBrҌk!GNLJFAIUS[h^[;{{űkcminH[- MR8!4ոnڨR3EQPVjj=x@|a}{lvm7ڢ #)Dd  uRjZzͭ88<p"84hkmHOKMQVJ+`A4_u L̬:yᏟ 9p%)zjjZ:H HKMt%!\A~mL tԕQŅD՛T*O@X\FY88 u+nn^~A!@p]X{7VVzՄvq"0F6".PhA-&6\=gf-IEeUuM-/˚ʊ'Eo''^ {F}-Ui1,?6H)w8u.Zʫktv] 0Hh{X_[!^QaA<\͌4d%IDx"O[c=Nn>Qq)wr Eu-m]=G@F7C}=Ďڊ2JIFvhIIDxܬ\tpSx&ٺxĔ%5mϞ =11 2x;1z|P_A~0?r!ݦ:"3)o=# RxvG\Lʺ6bO695ffv-BH3o&b[S]eY0^O8qs#]5@E"Xɫ>pTn/knsϿy4so(rAޝ Q!ζƺrX~Nv򜮀.NR^  ?w%#QymS{W) >|-Y|351rdb/ńxؚj+ˈ 9gc Ɖ`%Tua{$^NaIUC+&gHI4 BB%DLO#豭qQgyfEi^.vxҳDJ}H>Aэچ.GBIUc{O0E7j#;,(IHqYgK]ţ;dCO]^A3O[T79p2G(W@@} a8P_Wk}eqAvZ™_7Kc卢D)y< ʨX9M$:ٹźI5΁}| jnk۩Ovk Es.c OO&P40;>]XBL\H >ʻ (Ǐd˘֏ŏۨ+(2>v~1=*'_w֗?+1rb3SLN]ohլ{+HKua8im!;bd&I,`a\>C$6m6tRZ'u4oiĉΦGy7 qٮKSVe|0ʺm\|&/i!>YvI8~L_MVTm9mTbfswhl{'[j*EGr )>~Yu G3״t x==K""8Q=k{L|.y"\Dp,@ ir:{6q}{-H~bSeݴpC{w~,-AXiY9RVTD'G?Hɱڒ˧Ͷ0܈.[_-y3D"(qj|q᭤x9~g.+¿BO~~2JFƧїG&Jb~s!N 5DQ@u{g嬂 `<߼Gg`w.᝭4+}6O%(,ox Ɠ\ hB0{jKe\ u& ٿk&{pl:+,bY~fB߿/=tYd%KE%e"Ң(E D%dR<[}M4}xyι\>x~6~_faEC{O?2$Seyia^p-hm1&eV6|C2}ZGOO (/bg3Bt?1s|0*!ߏ_,b[= z6VeO]a1 1qK툏:op4T0{Ӕfa83O| G/V Cp4X&k=0)B϶HLqz^__DDHO ;Y_UJZF GD^3 '= u7[,7uA KHVpKa TaO[]Y C;LuŦl4h8-]^PN |8aOkmik[iBW^ifvVlZ>Z!wN:o5Pm#G-d!TÇj~zjI)(2kSZC5|XZ u6V۠֐a$K)(H@I==/K޶tS "Z)0m qMZAXxDu7ع,nz7jpBhُ]t\t2#D *ids,XNR`4ŤP?]f+ N9(=)kn4M` cnn7֒]y2q Bh‹ⷭ=祉󰣡UJ/gK}UI>SPFcA贂&T7%/.fQWQ{݄#+{Nw}DU k\Use 0Br&;@R‡!铈VaIrzTj>(L@*䃄 {ZB|o7֔dr +lw#Qvi}{%ETગ_A.Q7>}#6Z &Ҭ n^+rE<Ü0.px0mT頃Gw.fg Gt\@ t"U VaMQzt >B`e!XIZO*bw 9pp QZ(O*a[/>½[xƭ3b@ PYcu'8|c?6B;5k #h9eM][D|UJ"\̴cktt E,]fae%=lN} 8I,_JxQ;_0@| {Z R_9`#/4NR Y(|絨?@l ޼9m8 QA1:x.$1 2/%w;-d34 [|v-cO` %QFZ[x±4)TXq領L,#0@lj,N=fXjhs|hrB !Hj`3=vmЖKC 5?ZP,D#mw˖5:нbI%ub{4_= pvlSG\(Q f ߿|ht =\R끇M#5b(:c@ࡄR(FC}Y_@;+iDm]E R_kH~u!_ʖ")tH%=t9F.!:5M_~m[kd}V-}GJANxSO5\΅6p GW0 )it05rh )z HAUC+8ݟ×8#堭1P (X ^XCDeM B.b]@vFDKA$ImzKEh̞E1B +6=s3eY$CoKU~J[C.z 1~S`+8evK¾Mz 𘔼^y̠G]e)=FJۥT"zOoH1^fF(lt,jAlt Ŕe ׀h \9I!>-W) s2bMW`,3T*k-Z+Rr:栉ƽ(Cw@E{8'p@PQ bMt?<mijKn;֩IP[|BVycω3^dp‰Ȝ N&( XBn?D_p9{+1LX@1O:lЖgJz @oHc =(%z#̈́DIt\GN}"vcUyC1 <+aTWIMYgH\ $:M!&fx6L>ft̝NŽ(k"(@okU~]#6*b#d9zƒJzZ*sZ+r1ӿҳhm}ZtfI}'Q9I!>-W) s20sY4L_J]㟄j9?AXx%ՍvGִ )&qd#@ZfqUCۣ>ͯn  J+L5a 90q*r {[I2 MDjN#uIĒSXiՖ}>*C8R"8pJ WMNP_T}aꩋA"k",S(Y"M2Ȕ"DXJRtTƐ5#D'$;}.M=ӹsu߁=(1`EY1Amm yI (%E_QGHׄ1_ bR͘ZbjxQ^bTPfWpd0!5c meEcI 5PbC"' 24SKlpn[:k |Y ~1Lce@`(@pȎI󬋕F=BU^{T؍C`KUݰ?ʹ։d9Gf׃4bM+ #\()]fTojv;4[F Ds[oW/>tps n2Q\"nM q4 δt@t$6X' w 13.z;e6O!+vAфt=bQ1[PcJz{n'b $Dy,'ú|l1úRdWPt&#H8y7)Is|$ Xk7ք1RdČv5V%9[+Op٬Cp "_Xr^Ec8=/R0#}-5E/yEy>- vngմLSwc$5 4 &!R'_|Rn9s`~M$pOs51=q۝s8)N"ӉU{6gЄyPsBWQRbr H*Z I.oS„n,gg#tJBЅr6n)IM]Фt(!jо̘Sʹ$?S2sK(hvJ L/}3h˲ܤ0_) &!ڶG9ӤՋ'i]ߵe8 %'~Ԃ&zLRt Ԗ<}ROYjx#p9 7 ݇NV7 +Bob,7WkͰ"WDn>Һ>:;C zT`&[aQSHάް\`!7_GK=y%̔IYWI)<V]APOsmIVu?}|LS&Ufs-u   ?Z_|3Xk4|Ƥ IiYgUR }"|S".x9(IOИKPRa.[9(PrEAZS"7(դ:x]H/ohQJTFRN{qΟ3);8CU}Kӗ * J<_%1#6HsjyUt¥Wp֪uD8DMg "! 78/0(JF.!O*8! x| U% Ah۸_#_?)S|UO3θۛ($}GHJQ-(B*_?{v〙$A#,cj~I$ 𿏋5Dg*'t 8'|"*f< OHޡwQa/?z8@>1W6Q'*g~ >.gptb {7>ۭ&'% fVBNBp=AAy}s5j m#xO^[E CKk_wYDRim(̈ß}EB uMm]}FT5 M"."՞odž|Y>BYO+_ DT,/5: BQ 9qN/޼C(,k6!R0ގ{,zt/2Tw& Dd:zc!V7b"~7D\u]yafbxQA[K)hX:_]TEB k#W=K ;wNmeYQC!zu}sc>Awk{F@~; R@xB+Yw#x2R[@>yWUet>&5T#bxНo[|n]>f{0g ȇ.cfUjdu?8"𸴊 |:T<ԝʉYn8:U`~KJd+NDZAclbyMc[7PH7:PY+7=F[xؙz~)@t pJ[ v=WL{!DFiPgzp%p(P(UVE4JiH TVhX"*49cggwhٙ'oyi_W[s+g󳅻/I8} p@cju غGW\~NBi*gC}Kk+K NLٱ)p  ^޾j1[ڹߛ~tzY <?a*o |-;cC}A}dVS}[>_c nS zڐ脔Ob>{;ۚ(7Kg[: ^8~DDM,ŶqIL=x}qbg or@6+X[UVw4 W{+:5c"DJ4XںcArIm]FHD NA6&S962a˝5_^JBtZFpf>DSVv+!b.\Q/׊?MNJ ;_Ϳ`bib=hjSـdfHxp"R"OqabbJzg.V;+)_J @!*h ~Wߨ(+"G$hGt?҄n~Aͼ-qtQbX̎>qZ|ރݽCãcϕ995J&lb\؞vwpncsG}{?/;+SE\hVC ރ^ mJDmەz d>0Vݬk&P >P(bK]o+.ޤ-7t<*Bs|H UvΞk#bwg<干5 ֶvygwOAbB18S$kK%'H9l#tHeZVy(:{|Uɍ爐 r[[dkoH^,-)Nef :t6E qH1Xfl̓joаHkV=\lJ& ƒ9/tH}2@2Mؖ˸<+=<|o>mr{w%̄ #XD7SQ !IT465Šc͵lx6\kLF5 0Zn^FudR!ÈbaF CR`1Z(nF(S'(D"!FtMC".]&p(K -E F%̄Ru`E`Pۻ{5 9!)l(흳D ͂2?E+N+@ endstream endobj 128 0 obj <> endobj 126 0 obj <> endobj 127 0 obj <> endobj 170 0 obj <> endobj 171 0 obj [1.0 1.0 1.0 1.0] endobj 172 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 125.7599945 0 0 30.9600067 480.4335937 282.6806183 cm /Im0 Do Q endstream endobj 173 0 obj <> endobj 124 0 obj <> endobj 125 0 obj <> endobj 174 0 obj <> endobj 175 0 obj [1.0 1.0 1.0 1.0] endobj 176 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 128.1600037 0 0 35.0399933 479.2675781 279.5342255 cm /Im0 Do Q endstream endobj 177 0 obj <> endobj 122 0 obj <> endobj 123 0 obj <> endobj 178 0 obj <> endobj 179 0 obj [1.0 1.0 1.0 1.0] endobj 180 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 125.7599945 0 0 30.9600067 480.4335937 360.3368683 cm /Im0 Do Q endstream endobj 181 0 obj <> endobj 120 0 obj <> endobj 121 0 obj <> endobj 182 0 obj <> endobj 183 0 obj [1.0 1.0 1.0 1.0] endobj 184 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 128.1600037 0 0 35.0399933 479.2675781 357.1904755 cm /Im0 Do Q endstream endobj 185 0 obj <> endobj 117 0 obj <> endobj 118 0 obj <> endobj 186 0 obj <> endobj 187 0 obj [1.0 1.0 1.0 1.0] endobj 188 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 125.7599945 0 0 30.9600067 480.4335937 321.6034698 cm /Im0 Do Q endstream endobj 189 0 obj <> endobj 114 0 obj <> endobj 115 0 obj <> endobj 190 0 obj <> endobj 191 0 obj [1.0 1.0 1.0 1.0] endobj 192 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 128.1600037 0 0 35.0399933 479.2675781 318.457077 cm /Im0 Do Q endstream endobj 193 0 obj <> endobj 111 0 obj <> endobj 113 0 obj <>stream Hҁ 01~^b3x8|* l*@\ *@\ . l. q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . q@\ . dd2.ps@\ #q dd2#ˌ ,32[AC=E8p endstream endobj 194 0 obj <>/Filter/FlateDecode/Height 228/Intent/RelativeColorimetric/Length 2235/Name/X/Subtype/Image/Type/XObject/Width 524>>stream HK[ZM(QwEQ1q ((J D)ԁԊ3A,"LG 1Q"S*"[փ%?f >Ĥ$OY]>_RPCP#x dh^HM.SPKbY,Ѡ!tdٚfT:ḚYͦdS0Sӕ<{Ah]a=/7[IO5>Ŧ؋J***hQYQVRdQl1$?v{9s3Z{y|p?9{mLMG{\5vjDB#SZv=r"0\X\ ^CBŅ|``Yfz}- W76B67VCىo$זќWV5߬nmw#{hnxg{kM0j-0<-,zw|h]xo0=6E{ww}=bwt*pt|}Vb'G[޶[ :U)t4ycӳOЮӓ핅(T_j O}Ӌk;ǧWЮˋfZj,N.onn"u뫋ҌvfCxZ__w7BΧo?ڌDcnmRcF6Csm$Qm4Vmc4[xpt{?t‘+8-hmZWA A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A ߶k殅Zϩ-D[ o!0s H4'ͺ-Ģ_Z͆6vcq5hT<~}y;~BKGofi=|prvqu}ss^_]חf|-ln^\?>=v]^-N7;h`U MX`ae{ho{e!049 AZH2XʆѩVI,Z}o-F{* ![ zw|h]xo0=6E{ww}=挼֮! fuk{'DCew;[oPWkmY^Y [nI;21 -olVmn,#NWuI-Ex#SZv=r"0\X\ ^CBŅ|``YfzѪk\#ɩ+ht`j?2)+VNZPbG3OwhxtlC|!oY8'IhJUK\ή^o_ hm*WRMruI$mYRGMi脖u{ܭG=fN_he;kZkY6۱ӛ,6%^TRVQpVAۜʊ"{bį-|hNMWsOul%=l!/1$4[F<)Jf-j6%} I:hJ1[,Kg70D%͕e! wZ7Ʊocp Ulae..a 9H3nByQ98?,y]* endstream endobj 112 0 obj <> endobj 195 0 obj <> endobj 196 0 obj [1.0 1.0 1.0 1.0] endobj 197 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 125.7599945 0 0 54.7200012 478.4755859 527.9645691 cm /Im0 Do Q endstream endobj 198 0 obj <> endobj 199 0 obj <>/Filter/FlateDecode/Height 228/Intent/RelativeColorimetric/Length 2235/Name/X/Subtype/Image/Type/XObject/Width 524>>stream HK[ZM(QwEQ1q ((J D)ԁԊ3A,"LG 1Q"S*"[փ%?f >Ĥ$OY]>_RPCP#x dh^HM.SPKbY,Ѡ!tdٚfT:ḚYͦdS0Sӕ<{Ah]a=/7[IO5>Ŧ؋J***hQYQVRdQl1$?v{9s3Z{y|p?9{mLMG{\5vjDB#SZv=r"0\X\ ^CBŅ|``Yfz}- W76B67VCىo$זќWV5߬nmw#{hnxg{kM0j-0<-,zw|h]xo0=6E{ww}=bwt*pt|}Vb'G[޶[ :U)t4ycӳOЮӓ핅(T_j O}Ӌk;ǧWЮˋfZj,N.onn"u뫋ҌvfCxZ__w7BΧo?ڌDcnmRcF6Csm$Qm4Vmc4[xpt{?t‘+8-hmZWA A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A A ߶k殅Zϩ-D[ o!0s H4'ͺ-Ģ_Z͆6vcq5hT<~}y;~BKGofi=|prvqu}ss^_]חf|-ln^\?>=v]^-N7;h`U MX`ae{ho{e!049 AZH2XʆѩVI,Z}o-F{* ![ zw|h]xo0=6E{ww}=挼֮! fuk{'DCew;[oPWkmY^Y [nI;21 -olVmn,#NWuI-Ex#SZv=r"0\X\ ^CBŅ|``YfzѪk\#ɩ+ht`j?2)+VNZPbG3OwhxtlC|!oY8'IhJUK\ή^o_ hm*WRMruI$mYRGMi脖u{ܭG=fN_he;kZkY6۱ӛ,6%^TRVQpVAۜʊ"{bį-|hNMWsOul%=l!/1$4[F<)Jf-j6%} I:hJ1[,Kg70D%͕e! wZ7Ʊocp Ulae..a 9H3nByQ98?,y]* endstream endobj 107 0 obj <> endobj 110 0 obj <>stream H1014YXh9<($,R0X % RPVPV0XAYAYAIAYAY`eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeK6t/MQR`O"\Ar +(WP\Ar +(WP\A+قp +-Ws1[plf ;}-7H endstream endobj 200 0 obj <>/Filter/FlateDecode/Height 721/Intent/RelativeColorimetric/Length 3810/Name/X/Subtype/Image/Type/XObject/Width 534>>stream Huq8W90zDfs$9F٦MDuCڤM4`eKs*jZT6 ln 08M9xqs9N$^Z7j/n笳+J361bȂ° 3LQ(PZVV^^^PuYYi0ΰ( MW]UU]]Q]]5b ʪj'34M?nlMUeEYId1E1u&Mn2aJή?,ohvfϙP0gf|~fYŘg7?c.y!ůi.]8nS6|d٨L~E,[bJW_o~_9mYRňڳΘշiiiYK1+Lexw/nʬϝ0fTȓˢ*J+7Ntܺ;7nL۴~~K/?k9U%VQ^Up uw}[nkmmN1+Lx-M~˿<,NRS8cn[ٱ󱶶]o7躥M_,Na#JG8mnÏ>{wPyfv -|j*NoQ6z\ÅZsx};;;P /<~m7.k;mOOH]ZU;izw룻]ߠuwk^lwm\E.hWI?&ϼt }䉎}uΡCo϶g]8_niقY?y ^xOӼ▍[۳;O1+L{//?s˝_6{JUm^fӶ=zEu籭oiΔbʖͭm= Q"WȢho{߶i͊9Sk*TŮB?<'ĉc=۶mjY<_V`PŇ=ݝm[V.m.[8{zU05REV0Z5}ek7l{O:uIǿ?vK4QEq뷾᯿MSK}s}zUK͚vqY$5zHf[-u8#G|EN+m҂ИU1jPtԆzs}~w(\-<V1ʱ9#UaL`l9Z{nsbw;=tLW6=} fT+Y9y%g4Ϳwooխյw[_߹uӿִy5EgXM :lݶ#ةtHyMW\ZY,<;_ؓO߰sY0ᎍU+?**r+bSn^toCVmk_G~owͿ`Ud3'$R:~RM} -^tJg[[pnϵ/*VEϪyEeN:iy 67߮ |ۭt}+UOzkW1&;_\6>6fF}ìJxS_jS*DqvUdQTZQ2uZuk/EŏYEK*'LUMRfMr|EYIa~x(s")*[ZV(3DecK"_Fq&pn^A$RXX QX)  p2C0P(';+hb4^Ca P> endobj 201 0 obj <> endobj 202 0 obj [1.0 1.0 1.0 1.0] endobj 203 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 128.1600037 0 0 173.0399933 477.3105469 410.5786591 cm /Im0 Do Q endstream endobj 204 0 obj <> endobj 205 0 obj <>/Filter/FlateDecode/Height 721/Intent/RelativeColorimetric/Length 3810/Name/X/Subtype/Image/Type/XObject/Width 534>>stream Huq8W90zDfs$9F٦MDuCڤM4`eKs*jZT6 ln 08M9xqs9N$^Z7j/n笳+J361bȂ° 3LQ(PZVV^^^PuYYi0ΰ( MW]UU]]Q]]5b ʪj'34M?nlMUeEYId1E1u&Mn2aJή?,ohvfϙP0gf|~fYŘg7?c.y!ůi.]8nS6|d٨L~E,[bJW_o~_9mYRňڳΘշiiiYK1+Lexw/nʬϝ0fTȓˢ*J+7Ntܺ;7nL۴~~K/?k9U%VQ^Up uw}[nkmmN1+Lx-M~˿<,NRS8cn[ٱ󱶶]o7躥M_,Na#JG8mnÏ>{wPyfv -|j*NoQ6z\ÅZsx};;;P /<~m7.k;mOOH]ZU;izw룻]ߠuwk^lwm\E.hWI?&ϼt }䉎}uΡCo϶g]8_niقY?y ^xOӼ▍[۳;O1+L{//?s˝_6{JUm^fӶ=zEu籭oiΔbʖͭm= Q"WȢho{߶i͊9Sk*TŮB?<'ĉc=۶mjY<_V`PŇ=ݝm[V.m.[8{zU05REV0Z5}ek7l{O:uIǿ?vK4QEq뷾᯿MSK}s}zUK͚vqY$5zHf[-u8#G|EN+m҂ИU1jPtԆzs}~w(\-<V1ʱ9#UaL`l9Z{nsbw;=tLW6=} fT+Y9y%g4Ϳwooխյw[_߹uӿִy5EgXM :lݶ#ةtHyMW\ZY,<;_ؓO߰sY0ᎍU+?**r+bSn^toCVmk_G~owͿ`Ud3'$R:~RM} -^tJg[[pnϵ/*VEϪyEeN:iy 67߮ |ۭt}+UOzkW1&;_\6>6fF}ìJxS_jS*DqvUdQTZQ2uZuk/EŏYEK*'LUMRfMr|EYIa~x(s")*[ZV(3DecK"_Fq&pn^A$RXX QX)  p2C0P(';+hb4^Ca P> endobj 105 0 obj <> endobj 104 0 obj <> endobj 103 0 obj <> endobj 102 0 obj <> endobj 101 0 obj <> endobj 100 0 obj <> endobj 97 0 obj <> endobj 99 0 obj <>stream H @ Q^!*I2n\'Z%ܣzj{>3dX1v}a)L X$T8 ΂E[DpT DC X# SJ`P ,x&@E vCU# }I0@ LЯ ~ǀI0@ Pzn0%6Hp@oM%@8A& t BReH@,f@,Wzd2p`x@R8*I2TQ8*Ge 2pV!5B@!/@QˏheG|ʀːM0dCdC !`8#dB iW C5! aG#va@%# 1GD: FBG@DEX;Ja* =!Z"Ex @ pV 'EA2P3*=Uz%?z7#T endstream endobj 206 0 obj <>/Filter/FlateDecode/Height 184/Intent/RelativeColorimetric/Length 8535/Name/X/Subtype/Image/Type/XObject/Width 196>>stream Hy8;̌md_+])KT*R*t*!ka"r=o\[kޟwRPefۗ[f_BB8-ќ[y`Z::zz!D}9fӃəYXYPaeeafz A*N~ `z6;bqb0\hv@wЃo BB‹#$H 0@@ثoT ,(4'7_(,*.)-+'0 rҒDA^agcaQ!QI9EM[44u @_OW[Sc&E9I1a"2hI >D%eTԶjo721miemckw>6VLkoUSU(%&Le~2f+` I)im76stw>xyyzhgcinl(+)BDPAT=#3+;':ی-y<~f.aaNx:[6ݡYiDžfcfjO64754Ksc$$&%%‡䤤Ą+1E.FZjJBx80’r*:,\z9}91%fzFfVv)77>5)';+3#fZʯc"N?rH_s /5@pbҊZ}<(\̕ۿgW<yT>(/+)*=FJB܅_f;tE |XεWhq`b2Jz;-l@xTܯog K*k럿hjn&[@Z[[y}muUEIQ~;\<pgԕeňXdm>WOwVV54)m]== }==]mrSC}uU٣φ{pcbU(0@@X ,KQ00vr;˩qI]C3o`phxdddl.@_owG;~Nus޵ *|4iii`fK@qE7̥wUR휂FJgߟ#s?ǻH}OVF*dVP #z >5`Ԓ A-c(^6VdJ{L_MNC >{$a?ce<(nhE12,?3->"rVEOl+>i)emcף!QHEUZ^"Xr O']=DGUZOP (n3?t<,Zf~ymS[Br_P"?~xz\F, fxly"Mң' I B~iqs. 84^6Y8Aq;~>r1fFcS3+3P_"/#c6ZJp@8ӱV=t kZyw3Sݔ'E7ٛHy@ (cj?MRquc{, (NO 7Ք޺p\Ou*#J98GYx)`TwWM r"Xs; hARE30*)#3JVU0xˆʂK!=3;VPBY+bJfaՋ6G%8اƧEYףC}\`b/#10xq%3GKײ6 yko O59i1>z*XvXJ% e 5#ְyOҍPo's%q<7n xY9D{]rZ'GZJIi!^DXܪd8m\Xt>y^?QfQuKJ*8־zuDo )a^ ƍ[@RNu˾a / ;? @ S`HDWKuѫ;~ֆjrhd ]_x[;0.0E |lz zɤL; z4=)@=X,9G3 ғHa6HZmOD$gԵB4'92:kA@7Ml$Il2n㧩"UR%D iB#YDs J)n[x_;66 Q[uz R=<] >v.$!\JҬ&% \|S*[Y~)Q?h-Uoծ%V3Jv[{^ ҈^z/LYD;41 TyGLӿ%6g2JhlF0Vyz@7F5W`J]V'>LGQ "Lϣ{oTX$5xEfC;FpD 4SfͳG:s9YUuM.=~]ь]0uT>s[PR qXomu~QBamfN5C/⇝'f{?=C37ʅԅ@HucsFfQBaba V2M.o8|׏o_p4^D 5p*:N$V{C )>Լ#5ɮ3s+Bw+sl lZ:?KBPG]=acr j08j"?Pu6{e ڀz̋wD-)a(E)WbD>W rBm:ZG<4va^a^,=4պHM\|f)0Ty)wŤBR3 YXkV=_g8c4ڞU0jM]V >h` X -Lp\`捯!1c #0zy!s hАf`I.0<5fj(&h|Jx{߇`{k[×l%5 y\(WC_gûA^t͕ 9g`j6}uԾ_~GKA 05̜+&Vӷmxj@Pv>&4,Q1ut'9ˡ&MJq8`fR4|7[HFY;4)Ӂ kMP9A)E4r` bM FPK7v~:4́jySaPC=ZV+Y⠘]"p/>pVD:] {xA%b/;Z 7ZfC;LL5R"ͼ\Ŵ5L@0FEZ\L$RT&%O#\‰qRq@BEl,L(J]MeُnuQY*;&ҫh{xF [*RV>n0fZ(F:DDw4d&;f Zi(a\;:'&F iNZ7*J%-U5ud 4틿nLRnq%25#j ң{۬X h"8;z$ e9O8W,>Fmm h6K ِt}4=**O FT%Gvu\+:nW)tid+ay\gXvi`̐f01soУ`~~?)o+- Ay4p7kZ|Ց "SqH]5l|70joy[h*}po7>៯HD#押;V ,&J"}`lb?f&n5,8M]}P\lpM7gN5>j@O 7NnEmoRgKeAJo,k>R X{ĤI ƶI|)5E鷯y9YV[" vsYRm(v h(v=nO]qR1P7s Oʫ {ӡcw4VS;%u`UEgʹ*"=&!9;I4: ߿} 2R|EĖmjƶ.>!qE5.^wZkK&ܸg璃^Is!17>M7IrK7 EY˃6Y{G&W-@{Ӛ~+Ht>¶O56q l &FScGSB>"2IaVj\5_gՔA +v'M]Cc# ? MRZO~H ;kӃ` [%pR?QHPإgh(.G&ƕZ]Ljѻ{YC2"$ƅ <Nk#5WZ˾|èRK=zB*PИ\ʚfR+=ry#cgt r=]VRsCMb\NzJBLh%o7T2ր,P(#~w?8VBG%O+k-:bl͠wP_Zu5OKgONoO ue(5ZX(8 YEDaj=CbjzV~aQYEUum}cS Di}NҨ0?+=5_/wSX+SD( iBx_=`WWg/aq0;Q9eZZZHY-RQEI%PX"HPaI&DDD58K 3WEϕxn6{F&}s+n2x|z~y}{{{}y~z|I&.ώ[ordlҕ(PQlUZ7-#:L^'.OOⱝzpinnkZ\ ~U@tO(2}rOzKP8ٍvw77ph%4N&SL$B+2%x190"W4:Cfu {>r  VŅYvvmMNRKäeZS *W:`u:}؄gj;=s 6d4j |.N ゴzd`8@$JP]koZ]갷ZjCeFcR@/ZXdsaG~DSV5ufb1jULSR,\6lBN 4 ȥ@T/DR+*TRT*R 0T*F|? Qr @vV;#ȃht|%20 Ee 0f14(/ A@4@a2rAu&OO0"Hk.!KQJ<=6_C)¤W?B")&!~#>gdg>|`> endobj 207 0 obj <> endobj 208 0 obj [1.0 1.0 1.0 1.0] endobj 209 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 47.0399933 0 0 44.1600037 90.2841797 524.3868713 cm /Im0 Do Q endstream endobj 210 0 obj <> endobj 211 0 obj <>/Filter/FlateDecode/Height 184/Intent/RelativeColorimetric/Length 8535/Name/X/Subtype/Image/Type/XObject/Width 196>>stream Hy8;̌md_+])KT*R*t*!ka"r=o\[kޟwRPefۗ[f_BB8-ќ[y`Z::zz!D}9fӃəYXYPaeeafz A*N~ `z6;bqb0\hv@wЃo BB‹#$H 0@@ثoT ,(4'7_(,*.)-+'0 rҒDA^agcaQ!QI9EM[44u @_OW[Sc&E9I1a"2hI >D%eTԶjo721miemckw>6VLkoUSU(%&Le~2f+` I)im76stw>xyyzhgcinl(+)BDPAT=#3+;':ی-y<~f.aaNx:[6ݡYiDžfcfjO64754Ksc$$&%%‡䤤Ą+1E.FZjJBx80’r*:,\z9}91%fzFfVv)77>5)';+3#fZʯc"N?rH_s /5@pbҊZ}<(\̕ۿgW<yT>(/+)*=FJB܅_f;tE |XεWhq`b2Jz;-l@xTܯog K*k럿hjn&[@Z[[y}muUEIQ~;\<pgԕeňXdm>WOwVV54)m]== }==]mrSC}uU٣φ{pcbU(0@@X ,KQ00vr;˩qI]C3o`phxdddl.@_owG;~Nus޵ *|4iii`fK@qE7̥wUR휂FJgߟ#s?ǻH}OVF*dVP #z >5`Ԓ A-c(^6VdJ{L_MNC >{$a?ce<(nhE12,?3->"rVEOl+>i)emcף!QHEUZ^"Xr O']=DGUZOP (n3?t<,Zf~ymS[Br_P"?~xz\F, fxly"Mң' I B~iqs. 84^6Y8Aq;~>r1fFcS3+3P_"/#c6ZJp@8ӱV=t kZyw3Sݔ'E7ٛHy@ (cj?MRquc{, (NO 7Ք޺p\Ou*#J98GYx)`TwWM r"Xs; hARE30*)#3JVU0xˆʂK!=3;VPBY+bJfaՋ6G%8اƧEYףC}\`b/#10xq%3GKײ6 yko O59i1>z*XvXJ% e 5#ְyOҍPo's%q<7n xY9D{]rZ'GZJIi!^DXܪd8m\Xt>y^?QfQuKJ*8־zuDo )a^ ƍ[@RNu˾a / ;? @ S`HDWKuѫ;~ֆjrhd ]_x[;0.0E |lz zɤL; z4=)@=X,9G3 ғHa6HZmOD$gԵB4'92:kA@7Ml$Il2n㧩"UR%D iB#YDs J)n[x_;66 Q[uz R=<] >v.$!\JҬ&% \|S*[Y~)Q?h-Uoծ%V3Jv[{^ ҈^z/LYD;41 TyGLӿ%6g2JhlF0Vyz@7F5W`J]V'>LGQ "Lϣ{oTX$5xEfC;FpD 4SfͳG:s9YUuM.=~]ь]0uT>s[PR qXomu~QBamfN5C/⇝'f{?=C37ʅԅ@HucsFfQBaba V2M.o8|׏o_p4^D 5p*:N$V{C )>Լ#5ɮ3s+Bw+sl lZ:?KBPG]=acr j08j"?Pu6{e ڀz̋wD-)a(E)WbD>W rBm:ZG<4va^a^,=4պHM\|f)0Ty)wŤBR3 YXkV=_g8c4ڞU0jM]V >h` X -Lp\`捯!1c #0zy!s hАf`I.0<5fj(&h|Jx{߇`{k[×l%5 y\(WC_gûA^t͕ 9g`j6}uԾ_~GKA 05̜+&Vӷmxj@Pv>&4,Q1ut'9ˡ&MJq8`fR4|7[HFY;4)Ӂ kMP9A)E4r` bM FPK7v~:4́jySaPC=ZV+Y⠘]"p/>pVD:] {xA%b/;Z 7ZfC;LL5R"ͼ\Ŵ5L@0FEZ\L$RT&%O#\‰qRq@BEl,L(J]MeُnuQY*;&ҫh{xF [*RV>n0fZ(F:DDw4d&;f Zi(a\;:'&F iNZ7*J%-U5ud 4틿nLRnq%25#j ң{۬X h"8;z$ e9O8W,>Fmm h6K ِt}4=**O FT%Gvu\+:nW)tid+ay\gXvi`̐f01soУ`~~?)o+- Ay4p7kZ|Ց "SqH]5l|70joy[h*}po7>៯HD#押;V ,&J"}`lb?f&n5,8M]}P\lpM7gN5>j@O 7NnEmoRgKeAJo,k>R X{ĤI ƶI|)5E鷯y9YV[" vsYRm(v h(v=nO]qR1P7s Oʫ {ӡcw4VS;%u`UEgʹ*"=&!9;I4: ߿} 2R|EĖmjƶ.>!qE5.^wZkK&ܸg璃^Is!17>M7IrK7 EY˃6Y{G&W-@{Ӛ~+Ht>¶O56q l &FScGSB>"2IaVj\5_gՔA +v'M]Cc# ? MRZO~H ;kӃ` [%pR?QHPإgh(.G&ƕZ]Ljѻ{YC2"$ƅ <Nk#5WZ˾|èRK=zB*PИ\ʚfR+=ry#cgt r=]VRsCMb\NzJBLh%o7T2ր,P(#~w?8VBG%O+k-:bl͠wP_Zu5OKgONoO ue(5ZX(8 YEDaj=CbjzV~aQYEUum}cS Di}NҨ0?+=5_/wSX+SD( iBx_=`WWg/aq0;Q9eZZZHY-RQEI%PX"HPaI&DDD58K 3WEϕxn6{F&}s+n2x|z~y}{{{}y~z|I&.ώ[ordlҕ(PQlUZ7-#:L^'.OOⱝzpinnkZ\ ~U@tO(2}rOzKP8ٍvw77ph%4N&SL$B+2%x190"W4:Cfu {>r  VŅYvvmMNRKäeZS *W:`u:}؄gj;=s 6d4j |.N ゴzd`8@$JP]koZ]갷ZjCeFcR@/ZXdsaG~DSV5ufb1jULSR,\6lBN 4 ȥ@T/DR+*TRT*R 0T*F|? Qr @vV;#ȃht|%20 Ee 0f14(/ A@4@a2rAu&OO0"Hk.!KQJ<=6_C)¤W?B")&!~#>gdg>|`> endobj 95 0 obj <> endobj 94 0 obj <> endobj 92 0 obj <> endobj 81 0 obj <>stream H0rKwi~^jHoYW2ZF NTI.cbQnaFnw.6^n4Oi\o1JSJԖv(MRUP4Ni8yʥeKJTF(MR]곴GCim^%K=K;d4;ҍ'N8p

    >/Filter/FlateDecode/Height 314/Intent/RelativeColorimetric/Length 14340/Name/X/Subtype/Image/Type/XObject/Width 314>>stream Hy8so}3 &cDLNi.rLJNh\7M8NB27YS]RnϽϹ]Tff^|՗|'B8%`| f I3 :/X@HHHx1b.QQ1qq 4cqq1QQ0MC3HJIa84X9Yi)I o#U #f@b J*j"Qcu5UeEVVFJR 7_jhMZFW#h,&R J54Ч蒵IDuU%4ާԠj) FYg@]bbJ[naiemckkNlmmVX,-5^lITã|R~@ě`bU42lJN.nwƁngb!(ce|b"Ÿ$r%ؠkXy%Ib`dB[zFo-![2!A^n%=nΎWY[L (:$ u<7\<,A"Q̭Vurexn  @t )eb[fȟ}NkWYS$ NV7o'Q<~:1†z+/0g_4+.>ĩ3I)iCA^$=}ñ1D3Wm,hTB'S(n&lJxodjnC_7:pɩ.]ɺsVn^^^[nܸyi)gNncnjO&J;Oh݄D%e6"%4K;n["OLJ;qZ;?gWTB&(}Ǣy93/OKN<{0*"lkz;KE(M^Y}!Y׸0|B"%Yٹ++kuM]}#.򺩉}P_~ɝӒN% a<}Buey((l9y~ (MWߠНQq )ٹwKFnsk[{GgWwOSH[Atwuv4=TK_fFzJbB\t t1Eœɡ\o&ܵudrzFVN~Q S]mi}G ϧ9hk6Us%E9Y'b2lM ȚjJ8th瘝 NYK5;ysfU>m<}b#_唠Gc/ ?xm}PYVR}¹ģH4[K"C˷i(H ]o*-2UB£̛lPkljmal^~o79ի?CzₛwPTxFgeKx9\z/nr xW@O[aIaCS+TW؛7'~&O9B AZrJ /჻nk-UU}=n"∛ֹl6p6JNAqq' <COLP ( /u>nn\I? 䱞niJ]!nۡ SIYp#.gw"⵼쪚ƖϠkh)dP&Q7(tӮƚ*ݼkϝˁ]6ot^eevxYI1y(FF^chjim.VT7@ۀmؤd|#k(-̹#1/k3C FTx֮'U&h/E1GySm{gPozqk9컹Y瓎y\jMPJuÀ,8E5qPB҅k?=on6lM}=z7<)c=+Hj%i FVAUShwc.x umߤ%켡gO>W}܉=78/7TUňͺIEnT i L٬uߴ-б5:{yu_+^ocE9gEyQDi0A!7R+W]G.(WUopW6WAdh8WZr[v! '#]bNw {UuM0}$)xt4s޸p6~x3|&evbb+)Aېf=*.1=+6Ƿ{ CvuU㢶3 uJXYR;tREĥqxMU[wN^-"}2:i$K!eTBH(k1C Dd L-4giAc"sY\c_3{>mҽܞ:y?#coO\/ʍ/tPh#^p:*/!" nb:ƵFn9O:2a vʋrR#=ٛh)Iwb.ʾz톭*M.<&j co$pO{S 0&̗p温.F~nX<+8y6mۥon;(AF^iuck7p{3o4 ;pEwkcui^ƃȠ+nZR[y(X̩H b2Z9~5.IQE}Kgq:qbB"~㒳tL,v△^QB^Msށ YH5#DZFnPU7גYw}5$EYӥeG`vE-CݯŦ~cXÄ :sXJ.>Xn|.Tsq#젤cx98.=86 0t޽iyu7󴹎z6CGi% bӈ]8 &W%)écݩÚ܊U[d5m} 0tM)Q~Nk©V7,K[^Cz#}| ,qJ_Ej,[VHy%u̝BeԂVP $^0, UZ&،-H nB`{-yQl3+%"Ҫ6np+HSCDWs峔n6j2fQMkGн;.12TVyjp)8>>]e8uŏ=N޷SlyEz`XQJۍ;*2~B:hui~?7TdI^ظW\ O-kd#~:˟&ByUdaUue5ⲊFოWPM ` ~E}xKVwF`Ye#~ꠚT@^1}IzGϰ9$JVv𼖁_8%.IbZAZLFx+|%/<J˲-" zp0yq\,뮖?(KN[r{Xi!;Bp|vi}{(zub(~h˧L䶬Ok${T+CuK/t`F7֚h?7}'14-rZ.!5v1j,ʟ&{> &0f2H[E|z(w[#5i5rúM|Z\{Ug L>{(+AJ?&>$;^fˈ}-[91m3>W{ Im&D^?{LWq/t9&|NkY9'?ln"DS6)cr 2 >5K0ӓT2M)!1B$Fk9kw߰qyzy?~ނ%zt;NzrWrbAʁ/_A"a + ʂ0_-*:c2!SE_@pmK7%-6=8||?d͚zu*krQi)B$lyeaL:/ ; Lq:&=w="DϧKFhN4 ֹzb_2@tWmYZKQj΄`B|9em><H9*}9Χke$?b lIy }+"S<{Z)E~Ɗ_<7}R! VZNߟfb_[j͗'DrjNND$+o˱>\y)O;[M&$/1ܰHX͇xE/G~72~ ~ (x>nշRˑ뵷0##$^P,V}9^w=`zn甲Z{};@LaA􃺞3DJR0[ecS-`?l9( s:I1# CpGWX~w6!Ux-N8RJ[`~tij'#t+ѬX7{_(-5#^Un38WJ6+<Һ6VWU`8ּ'樹B; ݓ<\+V^X)~smFj/fUf/hRՏsRЮ;m ?WuԹ fU6ǽr7+]`D[ lWڨs>cBUUToV(] nDt.9cԹ-)a`pX Ů;6"xn?n&]8 Ws49-[c\M H+)X{퍉rMTLZy'~A%^\AZ\ytNVX8Brk8sZP2k[{qrDbF\f+}5Y ,͚'a`șp k{]i3>۬uV6;¯5t 1GjD\x%Fj[ُ#r%B C W$T%_:n ^(i8\NP'K*S/9h+-:'L8cxddZAU3^!,i*@@g N&Źҋu͝ `xi.L tw2],=t9AkZ{0]Ok k rns0)bAwnLtM^1ŵm(NʱLj{%5p%@kMI_b~DUpkQiJZiQN5T%:W8Dr O@DQH ǫUt^@ K 핛AQ]KģBw/c%flsVDG|sMo`A1Vj{aDLDG+'Q~eCģBG>NhA@L إ'տMyZů6.~aYnA7vLGVm]XRdtzћFjsoң]K_=:|[nDxzG(jti?]h؊\·p;t>0&t%GҬpG'(fkaUcXUyxt=y9a^%6k[}Yð'hƛÏ6 㠗>DX]`MWNF7GNB+4LMgT7v|u\QX?u4V3B'1X!4Jŕ%VW’W2yaWN20T\m?Jwda=]O~ a퉫4JFUjаRqGq>+kq\QXZʞYQX +W6;جҚO麻4+6nU`UԂʆv2󡽡 5gcڇJ;{&伪m/#0Zj_$z4Ptr\j2Ç7O;d#;X +lׅ Óa#i:S"|N1\-6aE:wt#h:oKn\ߡl.ڬ Ma[$ab_;7Ք^>2m0Bq)$w~Iwozص~Y3 Vet%:Q/,CuQ惮U6v|+)*&Ypo'kՒCd[v YLhf:d9Jb_8: >@;BRh)TL`tڠ<}DCj(>q;o.&A1o>t堒{۹N | LJ}}#Shh:r+SnҒY?Hb"ڙrB.D!@~tˡᕟr`6C[,&?.W6@OWN!AtrNrf rdVh2Z6}#!Avw͇2+!@ĕ4:s g!tX3co%eat&{Oy%z0l},9y&röe+۝N+xFf*F- Ňo9t   - A6/süO5єYmxL7 Jm]J}+䕉MeSmeV XsWZϭE q>ul.dÿHR_dRL  P4) ܯsE7Z:D侬ilgXmoy`Q]Zd4s«2>xaV_aPۻƕtp]Pe u=gwkR!D[rEJGMJ:;]h)oz rbþag-RZme)BXlZ^n,T [fjE^Zl-櫵T<ߪ2s(/3υkIĒ6(vac5V]9􃍩2sef|#V8ny%uc+{cA Z:Gc'ŊakI A\쭌uԕL7J+iXnKIyRJbGC2G)}Jس@[Mqӿ;F'YvfI* ;{VVRv{:5;9ښ#+9:ΐekbev75{VVBv}dž݌Իa~V&6Q4uwbX ;'?߹ Nv"7c(j-8;9&QkS=MEf͔mB'lJ[|FħVշ•$7Xpfk}Uinz|7V+I";pcT믳v򗍌.obYp/3zrUmG:h,؅:v\ Iz\XImjމ%xC޾AzhI1a{4P^7UMѡE'ǦI`}.Iq{=62ykՐ)WNne7U830ݱŐr&7 E›>䭆Iڽ!OyaknN *Fbk~ 9ŤꆖNvoO&h[FBuߏj(`"8H.V[jg/݈F,ۻ}ã'&aqsY- դQ7fVb8Xn0v3g[~qKħ<~Z d71M6ZmUy!1-ﵷ6[pT) 6he:6܏%D} )mG7o@xhG54m&H%?Js܎8Â3Y)wJ_:򊪚F{ݼ.^ZEz7&TٸNĭ('ALxqw-t5U屢Ja ,9XwZ,lw=pfd|rV36&`} P4!`c3ۚ_=Jtᴗ^[KUZfERi";XwJjZv?!$<&1XPJoqxA~BOj#A>ik&c"]:wuv #pS'#n-V1Xn AqIh]l.P0<:!~8 >' 1CF<.VWM.+=).24袟o23ݤwM|̖[L{-?!Vt<(5 ͭ]l`~( BAd @] 5RiG &;yuÖ kt4UnҼZYN^aUF曾wtv;edQvfw׏ȼ:o 3q{(/;~ rY[kk(.CݤCn4u֘nlt4݊ML$)&:phH3kh[ZZ[0mAB55TW={Vq<ݖPNKRAiNr`MFː5MيuBkmB&qLdT61jϹ^om}=}qi;zܻ.۫Fs `ScL-p܈XyieO+7YoS3 +ۻӳW?A友ӓ/NԸ덵Rg,i[z,>}Mэ`x|!, /W-ϭΞ[;{χNz|q~}yqnzr|xf}n7UKp2 ^ӛ1 <Ĕ4ThJZsË[Gw}lbjzv~qiyuum8./-NOM{;l-/̵ }Si)`mscDq$qf‹ueS3Kcp>22::vё`Owg{d(*,'1$l_=</cĄP'riz8X_675XOrb\T8F(]l27&ꯖ"HBO"G\,_kKt CT]]sjPQוhqu>" E8n6ߙ۵x쇔 J+&&r$#KTEFF0"+IRq(J@=d0 ᆒ|B'I%4$=E3 E3ґ49,K3! q4Gqya?2*Z'HHqH>ォ֣B#! Cj~FG7~4`z <ɀFP?SsŢH? 0=O)2ʌ@G5w,HC= 2F5ח޿~[-p endstream endobj 93 0 obj <> endobj 213 0 obj <> endobj 214 0 obj [1.0 1.0 1.0 1.0] endobj 215 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 75.3600006 0 0 75.3600006 262.3686523 83.7913666 cm /Im0 Do Q endstream endobj 216 0 obj <> endobj 217 0 obj <>/Filter/FlateDecode/Height 314/Intent/RelativeColorimetric/Length 14340/Name/X/Subtype/Image/Type/XObject/Width 314>>stream Hy8so}3 &cDLNi.rLJNh\7M8NB27YS]RnϽϹ]Tff^|՗|'B8%`| f I3 :/X@HHHx1b.QQ1qq 4cqq1QQ0MC3HJIa84X9Yi)I o#U #f@b J*j"Qcu5UeEVVFJR 7_jhMZFW#h,&R J54Ч蒵IDuU%4ާԠj) FYg@]bbJ[naiemckkNlmmVX,-5^lITã|R~@ě`bU42lJN.nwƁngb!(ce|b"Ÿ$r%ؠkXy%Ib`dB[zFo-![2!A^n%=nΎWY[L (:$ u<7\<,A"Q̭Vurexn  @t )eb[fȟ}NkWYS$ NV7o'Q<~:1†z+/0g_4+.>ĩ3I)iCA^$=}ñ1D3Wm,hTB'S(n&lJxodjnC_7:pɩ.]ɺsVn^^^[nܸyi)gNncnjO&J;Oh݄D%e6"%4K;n["OLJ;qZ;?gWTB&(}Ǣy93/OKN<{0*"lkz;KE(M^Y}!Y׸0|B"%Yٹ++kuM]}#.򺩉}P_~ɝӒN% a<}Buey((l9y~ (MWߠНQq )ٹwKFnsk[{GgWwOSH[Atwuv4=TK_fFzJbB\t t1Eœɡ\o&ܵudrzFVN~Q S]mi}G ϧ9hk6Us%E9Y'b2lM ȚjJ8th瘝 NYK5;ysfU>m<}b#_唠Gc/ ?xm}PYVR}¹ģH4[K"C˷i(H ]o*-2UB£̛lPkljmal^~o79ի?CzₛwPTxFgeKx9\z/nr xW@O[aIaCS+TW؛7'~&O9B AZrJ /჻nk-UU}=n"∛ֹl6p6JNAqq' <COLP ( /u>nn\I? 䱞niJ]!nۡ SIYp#.gw"⵼쪚ƖϠkh)dP&Q7(tӮƚ*ݼkϝˁ]6ot^eevxYI1y(FF^chjim.VT7@ۀmؤd|#k(-̹#1/k3C FTx֮'U&h/E1GySm{gPozqk9컹Y瓎y\jMPJuÀ,8E5qPB҅k?=on6lM}=z7<)c=+Hj%i FVAUShwc.x umߤ%켡gO>W}܉=78/7TUňͺIEnT i L٬uߴ-б5:{yu_+^ocE9gEyQDi0A!7R+W]G.(WUopW6WAdh8WZr[v! '#]bNw {UuM0}$)xt4s޸p6~x3|&evbb+)Aېf=*.1=+6Ƿ{ CvuU㢶3 uJXYR;tREĥqxMU[wN^-"}2:i$K!eTBH(k1C Dd L-4giAc"sY\c_3{>mҽܞ:y?#coO\/ʍ/tPh#^p:*/!" nb:ƵFn9O:2a vʋrR#=ٛh)Iwb.ʾz톭*M.<&j co$pO{S 0&̗p温.F~nX<+8y6mۥon;(AF^iuck7p{3o4 ;pEwkcui^ƃȠ+nZR[y(X̩H b2Z9~5.IQE}Kgq:qbB"~㒳tL,v△^QB^Msށ YH5#DZFnPU7גYw}5$EYӥeG`vE-CݯŦ~cXÄ :sXJ.>Xn|.Tsq#젤cx98.=86 0t޽iyu7󴹎z6CGi% bӈ]8 &W%)écݩÚ܊U[d5m} 0tM)Q~Nk©V7,K[^Cz#}| ,qJ_Ej,[VHy%u̝BeԂVP $^0, UZ&،-H nB`{-yQl3+%"Ҫ6np+HSCDWs峔n6j2fQMkGн;.12TVyjp)8>>]e8uŏ=N޷SlyEz`XQJۍ;*2~B:hui~?7TdI^ظW\ O-kd#~:˟&ByUdaUue5ⲊFოWPM ` ~E}xKVwF`Ye#~ꠚT@^1}IzGϰ9$JVv𼖁_8%.IbZAZLFx+|%/<J˲-" zp0yq\,뮖?(KN[r{Xi!;Bp|vi}{(zub(~h˧L䶬Ok${T+CuK/t`F7֚h?7}'14-rZ.!5v1j,ʟ&{> &0f2H[E|z(w[#5i5rúM|Z\{Ug L>{(+AJ?&>$;^fˈ}-[91m3>W{ Im&D^?{LWq/t9&|NkY9'?ln"DS6)cr 2 >5K0ӓT2M)!1B$Fk9kw߰qyzy?~ނ%zt;NzrWrbAʁ/_A"a + ʂ0_-*:c2!SE_@pmK7%-6=8||?d͚zu*krQi)B$lyeaL:/ ; Lq:&=w="DϧKFhN4 ֹzb_2@tWmYZKQj΄`B|9em><H9*}9Χke$?b lIy }+"S<{Z)E~Ɗ_<7}R! VZNߟfb_[j͗'DrjNND$+o˱>\y)O;[M&$/1ܰHX͇xE/G~72~ ~ (x>nշRˑ뵷0##$^P,V}9^w=`zn甲Z{};@LaA􃺞3DJR0[ecS-`?l9( s:I1# CpGWX~w6!Ux-N8RJ[`~tij'#t+ѬX7{_(-5#^Un38WJ6+<Һ6VWU`8ּ'樹B; ݓ<\+V^X)~smFj/fUf/hRՏsRЮ;m ?WuԹ fU6ǽr7+]`D[ lWڨs>cBUUToV(] nDt.9cԹ-)a`pX Ů;6"xn?n&]8 Ws49-[c\M H+)X{퍉rMTLZy'~A%^\AZ\ytNVX8Brk8sZP2k[{qrDbF\f+}5Y ,͚'a`șp k{]i3>۬uV6;¯5t 1GjD\x%Fj[ُ#r%B C W$T%_:n ^(i8\NP'K*S/9h+-:'L8cxddZAU3^!,i*@@g N&Źҋu͝ `xi.L tw2],=t9AkZ{0]Ok k rns0)bAwnLtM^1ŵm(NʱLj{%5p%@kMI_b~DUpkQiJZiQN5T%:W8Dr O@DQH ǫUt^@ K 핛AQ]KģBw/c%flsVDG|sMo`A1Vj{aDLDG+'Q~eCģBG>NhA@L إ'տMyZů6.~aYnA7vLGVm]XRdtzћFjsoң]K_=:|[nDxzG(jti?]h؊\·p;t>0&t%GҬpG'(fkaUcXUyxt=y9a^%6k[}Yð'hƛÏ6 㠗>DX]`MWNF7GNB+4LMgT7v|u\QX?u4V3B'1X!4Jŕ%VW’W2yaWN20T\m?Jwda=]O~ a퉫4JFUjаRqGq>+kq\QXZʞYQX +W6;جҚO麻4+6nU`UԂʆv2󡽡 5gcڇJ;{&伪m/#0Zj_$z4Ptr\j2Ç7O;d#;X +lׅ Óa#i:S"|N1\-6aE:wt#h:oKn\ߡl.ڬ Ma[$ab_;7Ք^>2m0Bq)$w~Iwozص~Y3 Vet%:Q/,CuQ惮U6v|+)*&Ypo'kՒCd[v YLhf:d9Jb_8: >@;BRh)TL`tڠ<}DCj(>q;o.&A1o>t堒{۹N | LJ}}#Shh:r+SnҒY?Hb"ڙrB.D!@~tˡᕟr`6C[,&?.W6@OWN!AtrNrf rdVh2Z6}#!Avw͇2+!@ĕ4:s g!tX3co%eat&{Oy%z0l},9y&röe+۝N+xFf*F- Ňo9t   - A6/süO5єYmxL7 Jm]J}+䕉MeSmeV XsWZϭE q>ul.dÿHR_dRL  P4) ܯsE7Z:D侬ilgXmoy`Q]Zd4s«2>xaV_aPۻƕtp]Pe u=gwkR!D[rEJGMJ:;]h)oz rbþag-RZme)BXlZ^n,T [fjE^Zl-櫵T<ߪ2s(/3υkIĒ6(vac5V]9􃍩2sef|#V8ny%uc+{cA Z:Gc'ŊakI A\쭌uԕL7J+iXnKIyRJbGC2G)}Jس@[Mqӿ;F'YvfI* ;{VVRv{:5;9ښ#+9:ΐekbev75{VVBv}dž݌Իa~V&6Q4uwbX ;'?߹ Nv"7c(j-8;9&QkS=MEf͔mB'lJ[|FħVշ•$7Xpfk}Uinz|7V+I";pcT믳v򗍌.obYp/3zrUmG:h,؅:v\ Iz\XImjމ%xC޾AzhI1a{4P^7UMѡE'ǦI`}.Iq{=62ykՐ)WNne7U830ݱŐr&7 E›>䭆Iڽ!OyaknN *Fbk~ 9ŤꆖNvoO&h[FBuߏj(`"8H.V[jg/݈F,ۻ}ã'&aqsY- դQ7fVb8Xn0v3g[~qKħ<~Z d71M6ZmUy!1-ﵷ6[pT) 6he:6܏%D} )mG7o@xhG54m&H%?Js܎8Â3Y)wJ_:򊪚F{ݼ.^ZEz7&TٸNĭ('ALxqw-t5U屢Ja ,9XwZ,lw=pfd|rV36&`} P4!`c3ۚ_=Jtᴗ^[KUZfERi";XwJjZv?!$<&1XPJoqxA~BOj#A>ik&c"]:wuv #pS'#n-V1Xn AqIh]l.P0<:!~8 >' 1CF<.VWM.+=).24袟o23ݤwM|̖[L{-?!Vt<(5 ͭ]l`~( BAd @] 5RiG &;yuÖ kt4UnҼZYN^aUF曾wtv;edQvfw׏ȼ:o 3q{(/;~ rY[kk(.CݤCn4u֘nlt4݊ML$)&:phH3kh[ZZ[0mAB55TW={Vq<ݖPNKRAiNr`MFː5MيuBkmB&qLdT61jϹ^om}=}qi;zܻ.۫Fs `ScL-p܈XyieO+7YoS3 +ۻӳW?A友ӓ/NԸ덵Rg,i[z,>}Mэ`x|!, /W-ϭΞ[;{χNz|q~}yqnzr|xf}n7UKp2 ^ӛ1 <Ĕ4ThJZsË[Gw}lbjzv~qiyuum8./-NOM{;l-/̵ }Si)`mscDq$qf‹ueS3Kcp>22::vё`Owg{d(*,'1$l_=</cĄP'riz8X_675XOrb\T8F(]l27&ꯖ"HBO"G\,_kKt CT]]sjPQוhqu>" E8n6ߙ۵x쇔 J+&&r$#KTEFF0"+IRq(J@=d0 ᆒ|B'I%4$=E3 E3ґ49,K3! q4Gqya?2*Z'HHqH>ォ֣B#! Cj~FG7~4`z <ɀFP?SsŢH? 0=O)2ʌ@G5w,HC= 2F5ח޿~[-p endstream endobj 91 0 obj <> endobj 89 0 obj <> endobj 90 0 obj <> endobj 218 0 obj <> endobj 219 0 obj [1.0 1.0 1.0 1.0] endobj 220 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 75.3600006 0 0 75.3600006 168.5 83.7913666 cm /Im0 Do Q endstream endobj 221 0 obj <> endobj 87 0 obj <> endobj 88 0 obj <> endobj 222 0 obj <> endobj 223 0 obj [1.0 1.0 1.0 1.0] endobj 224 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 75.3600006 0 0 75.3600006 262.3686523 190.7913666 cm /Im0 Do Q endstream endobj 225 0 obj <> endobj 85 0 obj <> endobj 86 0 obj <> endobj 226 0 obj <> endobj 227 0 obj [1.0 1.0 1.0 1.0] endobj 228 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 75.3600006 0 0 75.3600006 168.5 190.7913666 cm /Im0 Do Q endstream endobj 229 0 obj <> endobj 82 0 obj <> endobj 84 0 obj <>stream H0rKwi~^jHoYW2ZF NTI.cbQnaFnw.6^n4Oi\o1JSJԖv(MRUP4Ni8yʥeKJTF(MR]곴GCim^%K=K;d4;ҍ'N8p

    >/Filter/FlateDecode/Height 314/Intent/RelativeColorimetric/Length 14325/Name/X/Subtype/Image/Type/XObject/Width 314>>stream HgTSiuF:&HFiR0(DX*2"*Q@E)PPf"#ࠣޛe=gC_?ϛ||͗,'[aJ( `| Ρ 3 3 _̲D1x~=l_aBGbD1;+0Da/K4 2cb(.n^>~4X" ͅB >x~1@f  `E!bX!A r8>r:Pxx1X^BJF(B"m(${=\vZ[l3ۢNR$@zh^n<ڹ0 u 6> 蚄4QI[doC#DFQ?/Jt4y$`XHp^O9Yn3!klİB>޺ukxHݘ6^JNN1|oaz0"s"sS.\IGt̩?G wG[K톺du13!ulM&*jiP,vzv(2x|Wo/(((|/m[7_ɺt1%)TḻCܜ(:ZJDi{\aiIAn_Sc"xxZdx@-rr|dh^Ԅ_q2p|\,L+y WKSn? Wr˫65'ɖzS4iuyqՋgc=MnR#`xQl,+v/8^VIr Ku miۼўw5UϾt$`,^ͳbĤu(Og(,}wpxlesjAxcÃOL9ۊ(-;_ZE%Uz29杲g-]n`ͽć712Aqy27ޢ*/)*ȇb_q# &Q9y0XD6avdJ֭>£@ڇ%SOQwIDÉܲ T8FQ7q ;yAmC{nm1xS6Ud%Gؘk*Ɉ oB;P8^6;sn=mlc3-£ -a񭌤(j8[ _P*dÝcӳ k{i}&yls@O]IFBD݂LFo,nCK3ށIeMd /죐 28Zcvb.ɽ|嚍 j{l]G' ãoc7:<@"ߍ':jkVu;&&O^AuwhZ: STYL}\1Z_Ys'&䒇QA$ %s*;RN6._~MxPVr;&qay] D61W;3=5+X-^jg|cRڻ^~7pg ;PE_W{#z3Pk& a~nLixYEYGϫ Zy&Q]OgK]yafR5,K281I{͎] q;L *Ȏa%7URb}܏S*%.pO`n2"ZZQ~aqw>ă: ?wPz'D\9dOSIf ;C!p8qk_*)/gEP'z hI9&wlF<@tEpڜ -%hf74uԖ{U]/"NRY/kfuԑ7cQ"=Z;JŒp풗d: HUTR~!ˑE4_ /QckzxyG8V Q I)X8y_O!BRA pg>XdO12)ɻs#le(-w CaV4ڡv?bm(t]b׷QfqRg"/ q!ޮ6 BGN;$. NŒ:&`07[mnm&n>aEA̩Dbu6Wg%5t4v؞ O*j'nr}q鴝GG lb5~hpZk9I A~9i%kZ Xa6؁'(% A9L+mER'nr=V[{T/p q" prKjn!tmu蠜| t88n1W֍͙71]):AE n޷s J)j#pI9 /䉿E})/q4aC$iXKQJT}oH6 ]}Asܧn6F "K/r|28MhA9I q6T_/~݄ZnQշp ςGy1}beKVb%k<ޤ{ȉY\&A%!fŇx9[n^# yP=fEU-],]uqf\qA$Xz,݄VM=oIt]-UEV%p.jٺ_򠲩\,npfRa v&[7q򮔐F&+o%We0}MDYSKaGAEbhbnY=gh}{qޭpz*Vc߰-:rlKEEm>8c rwC=`Ğpfv&d-SŤAy#ˀ؀sLAkrPe mܯ\O- =E}a> nc&v+$2z.>U#CmOqSA5ʵ\DRȊSi ,4'!h$)kyfUBN\qMAP-QF5;c u3&X^ YUѷrJ#فa޿Ks= dyC^;]M+oOka|O]gcPlv^8xVU7:qf&9Z]V\OG5$x9W6.~SR ZYჟiQ^UK٦tozļgXოW/ܻz;;ٜʢGA5/ɼ ~5>g "Cޑw HԬ,8y^rػCZĔaЃ2*Kg WJ%?_k+:ES[yA`z9nQM{*f \(?n$`rVФ殁PJi'u7Ok${ؤjw>n" qvj[Ob KGm$uO\]@jeL;X^_=o~{v)4F# .+>X^Gԕ&yڛhMCm>k궯@Pߣ/ZfKG]5M_%1@yZBpد)/!K7l夶Z~;ҿOIp_G=1##?˱+w{͔ѯe尟1I$]p?Nuh7D*8TiBIs55]R9[^:ԫ8XN}9ܦZ %Lj>+*S'b:yb[rQiyiB$O &sDFNI;3W4!JB.'kW,Au/6tz PѦ ."K ԧML/ih߇')G/tͼij~9Zi)ɍ&&)_hddj'_+OW_k纯C9DvKp>tٗ>]GS50| ix0A\xD򝲺6Z%zMpDWu0!)xٚ-BcK~9ӽ~XUx=р )fimk^ZkKo3LI +J/L7.DsD?$qq̕^sg4r}xhɕ3Gv8jD?XB$%S_Fjm7lGnxvQ5 )/LAG;B챢jo=)B!]NĎsXg/j)h P}k=ϕ8ֹK K*F@/G|:8^|_:WjyO`~wēGw#8/7DcͺQ[z" !bG<  pbەlVx %5-4RSUlRJk#sЎH(8WJbB^Xi~Թ6sZoP KͪݶàYKxt?VJ@fg:ܮUB6#?ѼYv-aG C ]UbyetoV(]+Fwb(Lt &)7t046wXslr7ٚΞ0ip#39m~乾j-IOn'+B}s g[(\ /zpAVc"w9*!9|˵{ "Y彴P /VAUMZqrksZMP]Z"W;.{B]y!,MojK7pkkMI>m*pV5%v[}ïֵu1GҜD\ Ԅ9KzE:4Hv %XR|NeKah'BjXRz!` Ys )sFNDW6b(’|te=0 \'vNHйΦ@wZS@C q㜐 u6W( VnkEDpuSԹ 2r:Lu^Eܖ7([t=Κ,s&9089Qqر95aDD8ソۈs.9gpE΅ \&׊rɹb율H犇u@H|fq t/?Bj3C`a*Xscvn΍UعjT 1F!nBݭl;',\ ֹ`\@7]ȹ`qY es9q9`uC9αbaDE:Wt=c:ǐʹrd\+nz;W߽j.tbj+Mf:`GPtN윐s3<\W.䜢 􂪦^@Φ@wZI2d5,L˯l :XybC0nx)N{.U<{9;t<7ѝ͚>Yع&MS~߹e/zss}=/n'ntI8f`f7<1=vT}mu9 0qfӗJjZp֚˧}6ۚNH01"Utlcq1 an;.{B]yarjX"* p9n9{K"¹焜'@Xb?"c%PnJ.Gs% qn% t?24X2s5[%d?mJ Uk[섰[,=SA9%zVm<rɌ=q"=Uy!(!DNYiy"U\֋<ݬ:r222xWiL" 5i)[$Õd# Ō$[I DBXSFu3:y|?o)30\r/z h.μ9+}:nxz\Jc-WHzf2'kx"zDJ8^"RX7r5&]7rn @K(D ~s}&\gٱh{DP1< +VأL7 ZH|0"&"a0}}&\{}EH{  &Dϊ_ozuc3^&޼xt D;'"-nAc7vXKf'į#A'7c3G;4T׃zt)ydtdKfzu&!ѵ|tEc js!&V~͑7#1Y%oǦ+9ZߖdmG t+k{CeV*zsģJq/pX[J=tVo9` j>-z J:Iܵ;east-BzɝtFUC{PHHmG+W6]K~VFa-{vΜ$lJu8t+IV;u0 kO\%1hNW4"LJU qEal-} dEacV2AY%Ս]麻X]n,tU52|;5|hO}@9YUAwIϐW5eCsͫSfKWamG\fVї>)z|/刱 Wخ Y{'FtʞD8[#6+Ӱ"vT~.t#h:oK_ٵN~\Y M](fhW;4RZ16e:0BqF~Ť7=OٸZL`)lJ1b?G3*CCӁ~*|Ж{>2BpҦvBrbB/~xmoFb>ʍvcn{LYk$#ЎP W\+T8ZI"#x-$(&ڙT`7(~&~NV[4 t(l0mvOJÀ|LŷXZl:W Ks:MM֩[,&?)"jhӐ dKi3a9Pn¤І5,F$?}UC6yVd9-Ya~WtLlbm*ʌf^IwKʁAa O:0p(~O ԥC8 Eu|mcWG/rD\q:}*Q[/ q6w5Mh)}\EYUXax1, ~ai5 ;1|uēk`< @ztxI@^Y9%dWŤ*Nq\_]o%B5aWG=9($ɷ]MtWW?X:"TO^yFhu,%;xrw/4׃?_i ((mUO #62ԁ?tl98g )~'4>=kQ{hr>U1A^J(sLzR)2!0J$E93UB xx‘gf3[ܗ m,Xm /sSm6J 5d5 -N{%^!tX8W@ frLfVI&OW'*+8&=%#CXK rܪ-/ OnXt%C2jXB:JozEq!>xrt/Z먓oWNGw*X4&rgJl2v(mґ5Ֆ32î:[nR:̂&7"YCX*gf5#\VBґC%D4m]# Gtf%Dښkȉzq 4{?"qKW{ 4W rp#HO@] &eL.;1 g%/auXst|BH:~ҍ. g£kMb=GHl! l(G gJLhtI#.%1q4C}=#r9L<9͌zfȝ_L>CWY`'>̌e~2/EDpqr-*iEMK7wx݌ލ s[j*&G](\Pr*74#6 1GA')tsz7)BdÝMQJs^p@qFg70oŤU JncMKuu6CȗHtr*kںx] zD,4uIAЦv4QJ/zn_"'B8NZFNyF$d\x$nTn7\q]zkdE}ΑYdBXlz j3X{8a3;Lk(-eU 7s.Q\cdK7Um,a$ujk*%&G߸s+#UKJFpl Z.'" It&N"* ͤג "OZh*.-j8s/W3۹×u0 ijL(cN3=MZpXd[9|/)TCNPYqM :6א n;ת*B;pcWn;ͨ׍._orYp+ nyrje9bm':h,إjZlz֍}I06xuѨEOc~rgcQKMq)T17UH69:FO\ M%;;!RxSv|tN!Scïrwma7U03s吻sK&;C›~6䭆i!xcmN *Fb'm`ncG?ȄRuCKgO(O&h[fBMߏm ՕIX$vpdd+^Pݜ1|xSذ8jRInFb@,6VY' nEKU57[:r^|gjhHiMkzSqjVU킏MUťJXAcwHeWhbewyBHGYyϑvt&/}RClAn-Tri{3,ZV E[ 6@UVV^Y][;L UT,7!p4au2 n/s3ƄΟtw޳L_[]Y^+ΓuN#S^71{ov'=!&T6 l=̶פSD]:dgmfN< 7Kh'NAEC{}.8O I !yl.o?!|Q`h(eCښeČW.tumen $ Zzuv:{)(42.ܢrru⏌|G| !CPJ&tAo$wM|I͗Y"Js5?!Nt<(5 ͭ]=AP@30Q@tu67PHe}'pqv;mRW^I>he1;Ylo@`o_\NFڙݽno"n2lfjL`-$0fij$Du~ /SPQ`e!ﳀw'26)5XPRAP.V/a>A~2lgЛrEI1+5)68`}JE9ٙWhgNz,<%5:6{Hv+<:aZ1%ZHu0X=l6o ?ٽ=.fG[+XG,+'f=up=6&:jP {:ܐ;U\t:vKoGD&d>-(*|YU]SD2`NkjzYYQZT83%KqqmPv]YՓSCۣt!i'PsmVs(:ɮE1Yl"jЧm/>_]]5|amlZEEnQ /][sx?욙sv~qyu}O6xp;b1[A<=sMэ`xC"0YZm/ξщ9ãSn|zr|txyogkme=731:tZF]"M$-Ë<̜L-«ltO#Ǐ 0,%p Cc|j!D7~ `4 > endobj 231 0 obj <> endobj 232 0 obj [1.0 1.0 1.0 1.0] endobj 233 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 75.3600006 0 0 75.3600006 262.3686523 300.7913666 cm /Im0 Do Q endstream endobj 234 0 obj <> endobj 235 0 obj <>/Filter/FlateDecode/Height 314/Intent/RelativeColorimetric/Length 14325/Name/X/Subtype/Image/Type/XObject/Width 314>>stream HgTSiuF:&HFiR0(DX*2"*Q@E)PPf"#ࠣޛe=gC_?ϛ||͗,'[aJ( `| Ρ 3 3 _̲D1x~=l_aBGbD1;+0Da/K4 2cb(.n^>~4X" ͅB >x~1@f  `E!bX!A r8>r:Pxx1X^BJF(B"m(${=\vZ[l3ۢNR$@zh^n<ڹ0 u 6> 蚄4QI[doC#DFQ?/Jt4y$`XHp^O9Yn3!klİB>޺ukxHݘ6^JNN1|oaz0"s"sS.\IGt̩?G wG[K톺du13!ulM&*jiP,vzv(2x|Wo/(((|/m[7_ɺt1%)TḻCܜ(:ZJDi{\aiIAn_Sc"xxZdx@-rr|dh^Ԅ_q2p|\,L+y WKSn? Wr˫65'ɖzS4iuyqՋgc=MnR#`xQl,+v/8^VIr Ku miۼўw5UϾt$`,^ͳbĤu(Og(,}wpxlesjAxcÃOL9ۊ(-;_ZE%Uz29杲g-]n`ͽć712Aqy27ޢ*/)*ȇb_q# &Q9y0XD6avdJ֭>£@ڇ%SOQwIDÉܲ T8FQ7q ;yAmC{nm1xS6Ud%Gؘk*Ɉ oB;P8^6;sn=mlc3-£ -a񭌤(j8[ _P*dÝcӳ k{i}&yls@O]IFBD݂LFo,nCK3ށIeMd /죐 28Zcvb.ɽ|嚍 j{l]G' ãoc7:<@"ߍ':jkVu;&&O^AuwhZ: STYL}\1Z_Ys'&䒇QA$ %s*;RN6._~MxPVr;&qay] D61W;3=5+X-^jg|cRڻ^~7pg ;PE_W{#z3Pk& a~nLixYEYGϫ Zy&Q]OgK]yafR5,K281I{͎] q;L *Ȏa%7URb}܏S*%.pO`n2"ZZQ~aqw>ă: ?wPz'D\9dOSIf ;C!p8qk_*)/gEP'z hI9&wlF<@tEpڜ -%hf74uԖ{U]/"NRY/kfuԑ7cQ"=Z;JŒp풗d: HUTR~!ˑE4_ /QckzxyG8V Q I)X8y_O!BRA pg>XdO12)ɻs#le(-w CaV4ڡv?bm(t]b׷QfqRg"/ q!ޮ6 BGN;$. NŒ:&`07[mnm&n>aEA̩Dbu6Wg%5t4v؞ O*j'nr}q鴝GG lb5~hpZk9I A~9i%kZ Xa6؁'(% A9L+mER'nr=V[{T/p q" prKjn!tmu蠜| t88n1W֍͙71]):AE n޷s J)j#pI9 /䉿E})/q4aC$iXKQJT}oH6 ]}Asܧn6F "K/r|28MhA9I q6T_/~݄ZnQշp ςGy1}beKVb%k<ޤ{ȉY\&A%!fŇx9[n^# yP=fEU-],]uqf\qA$Xz,݄VM=oIt]-UEV%p.jٺ_򠲩\,npfRa v&[7q򮔐F&+o%We0}MDYSKaGAEbhbnY=gh}{qޭpz*Vc߰-:rlKEEm>8c rwC=`Ğpfv&d-SŤAy#ˀ؀sLAkrPe mܯ\O- =E}a> nc&v+$2z.>U#CmOqSA5ʵ\DRȊSi ,4'!h$)kyfUBN\qMAP-QF5;c u3&X^ YUѷrJ#فa޿Ks= dyC^;]M+oOka|O]gcPlv^8xVU7:qf&9Z]V\OG5$x9W6.~SR ZYჟiQ^UK٦tozļgXოW/ܻz;;ٜʢGA5/ɼ ~5>g "Cޑw HԬ,8y^rػCZĔaЃ2*Kg WJ%?_k+:ES[yA`z9nQM{*f \(?n$`rVФ殁PJi'u7Ok${ؤjw>n" qvj[Ob KGm$uO\]@jeL;X^_=o~{v)4F# .+>X^Gԕ&yڛhMCm>k궯@Pߣ/ZfKG]5M_%1@yZBpد)/!K7l夶Z~;ҿOIp_G=1##?˱+w{͔ѯe尟1I$]p?Nuh7D*8TiBIs55]R9[^:ԫ8XN}9ܦZ %Lj>+*S'b:yb[rQiyiB$O &sDFNI;3W4!JB.'kW,Au/6tz PѦ ."K ԧML/ih߇')G/tͼij~9Zi)ɍ&&)_hddj'_+OW_k纯C9DvKp>tٗ>]GS50| ix0A\xD򝲺6Z%zMpDWu0!)xٚ-BcK~9ӽ~XUx=р )fimk^ZkKo3LI +J/L7.DsD?$qq̕^sg4r}xhɕ3Gv8jD?XB$%S_Fjm7lGnxvQ5 )/LAG;B챢jo=)B!]NĎsXg/j)h P}k=ϕ8ֹK K*F@/G|:8^|_:WjyO`~wēGw#8/7DcͺQ[z" !bG<  pbەlVx %5-4RSUlRJk#sЎH(8WJbB^Xi~Թ6sZoP KͪݶàYKxt?VJ@fg:ܮUB6#?ѼYv-aG C ]UbyetoV(]+Fwb(Lt &)7t046wXslr7ٚΞ0ip#39m~乾j-IOn'+B}s g[(\ /zpAVc"w9*!9|˵{ "Y彴P /VAUMZqrksZMP]Z"W;.{B]y!,MojK7pkkMI>m*pV5%v[}ïֵu1GҜD\ Ԅ9KzE:4Hv %XR|NeKah'BjXRz!` Ys )sFNDW6b(’|te=0 \'vNHйΦ@wZS@C q㜐 u6W( VnkEDpuSԹ 2r:Lu^Eܖ7([t=Κ,s&9089Qqر95aDD8ソۈs.9gpE΅ \&׊rɹb율H犇u@H|fq t/?Bj3C`a*Xscvn΍UعjT 1F!nBݭl;',\ ֹ`\@7]ȹ`qY es9q9`uC9αbaDE:Wt=c:ǐʹrd\+nz;W߽j.tbj+Mf:`GPtN윐s3<\W.䜢 􂪦^@Φ@wZI2d5,L˯l :XybC0nx)N{.U<{9;t<7ѝ͚>Yع&MS~߹e/zss}=/n'ntI8f`f7<1=vT}mu9 0qfӗJjZp֚˧}6ۚNH01"Utlcq1 an;.{B]yarjX"* p9n9{K"¹焜'@Xb?"c%PnJ.Gs% qn% t?24X2s5[%d?mJ Uk[섰[,=SA9%zVm<rɌ=q"=Uy!(!DNYiy"U\֋<ݬ:r222xWiL" 5i)[$Õd# Ō$[I DBXSFu3:y|?o)30\r/z h.μ9+}:nxz\Jc-WHzf2'kx"zDJ8^"RX7r5&]7rn @K(D ~s}&\gٱh{DP1< +VأL7 ZH|0"&"a0}}&\{}EH{  &Dϊ_ozuc3^&޼xt D;'"-nAc7vXKf'į#A'7c3G;4T׃zt)ydtdKfzu&!ѵ|tEc js!&V~͑7#1Y%oǦ+9ZߖdmG t+k{CeV*zsģJq/pX[J=tVo9` j>-z J:Iܵ;east-BzɝtFUC{PHHmG+W6]K~VFa-{vΜ$lJu8t+IV;u0 kO\%1hNW4"LJU qEal-} dEacV2AY%Ս]麻X]n,tU52|;5|hO}@9YUAwIϐW5eCsͫSfKWamG\fVї>)z|/刱 Wخ Y{'FtʞD8[#6+Ӱ"vT~.t#h:oK_ٵN~\Y M](fhW;4RZ16e:0BqF~Ť7=OٸZL`)lJ1b?G3*CCӁ~*|Ж{>2BpҦvBrbB/~xmoFb>ʍvcn{LYk$#ЎP W\+T8ZI"#x-$(&ڙT`7(~&~NV[4 t(l0mvOJÀ|LŷXZl:W Ks:MM֩[,&?)"jhӐ dKi3a9Pn¤І5,F$?}UC6yVd9-Ya~WtLlbm*ʌf^IwKʁAa O:0p(~O ԥC8 Eu|mcWG/rD\q:}*Q[/ q6w5Mh)}\EYUXax1, ~ai5 ;1|uēk`< @ztxI@^Y9%dWŤ*Nq\_]o%B5aWG=9($ɷ]MtWW?X:"TO^yFhu,%;xrw/4׃?_i ((mUO #62ԁ?tl98g )~'4>=kQ{hr>U1A^J(sLzR)2!0J$E93UB xx‘gf3[ܗ m,Xm /sSm6J 5d5 -N{%^!tX8W@ frLfVI&OW'*+8&=%#CXK rܪ-/ OnXt%C2jXB:JozEq!>xrt/Z먓oWNGw*X4&rgJl2v(mґ5Ֆ32î:[nR:̂&7"YCX*gf5#\VBґC%D4m]# Gtf%Dښkȉzq 4{?"qKW{ 4W rp#HO@] &eL.;1 g%/auXst|BH:~ҍ. g£kMb=GHl! l(G gJLhtI#.%1q4C}=#r9L<9͌zfȝ_L>CWY`'>̌e~2/EDpqr-*iEMK7wx݌ލ s[j*&G](\Pr*74#6 1GA')tsz7)BdÝMQJs^p@qFg70oŤU JncMKuu6CȗHtr*kںx] zD,4uIAЦv4QJ/zn_"'B8NZFNyF$d\x$nTn7\q]zkdE}ΑYdBXlz j3X{8a3;Lk(-eU 7s.Q\cdK7Um,a$ujk*%&G߸s+#UKJFpl Z.'" It&N"* ͤג "OZh*.-j8s/W3۹×u0 ijL(cN3=MZpXd[9|/)TCNPYqM :6א n;ת*B;pcWn;ͨ׍._orYp+ nyrje9bm':h,إjZlz֍}I06xuѨEOc~rgcQKMq)T17UH69:FO\ M%;;!RxSv|tN!Scïrwma7U03s吻sK&;C›~6䭆i!xcmN *Fb'm`ncG?ȄRuCKgO(O&h[fBMߏm ՕIX$vpdd+^Pݜ1|xSذ8jRInFb@,6VY' nEKU57[:r^|gjhHiMkzSqjVU킏MUťJXAcwHeWhbewyBHGYyϑvt&/}RClAn-Tri{3,ZV E[ 6@UVV^Y][;L UT,7!p4au2 n/s3ƄΟtw޳L_[]Y^+ΓuN#S^71{ov'=!&T6 l=̶פSD]:dgmfN< 7Kh'NAEC{}.8O I !yl.o?!|Q`h(eCښeČW.tumen $ Zzuv:{)(42.ܢrru⏌|G| !CPJ&tAo$wM|I͗Y"Js5?!Nt<(5 ͭ]=AP@30Q@tu67PHe}'pqv;mRW^I>he1;Ylo@`o_\NFڙݽno"n2lfjL`-$0fij$Du~ /SPQ`e!ﳀw'26)5XPRAP.V/a>A~2lgЛrEI1+5)68`}JE9ٙWhgNz,<%5:6{Hv+<:aZ1%ZHu0X=l6o ?ٽ=.fG[+XG,+'f=up=6&:jP {:ܐ;U\t:vKoGD&d>-(*|YU]SD2`NkjzYYQZT83%KqqmPv]YՓSCۣt!i'PsmVs(:ɮE1Yl"jЧm/>_]]5|amlZEEnQ /][sx?욙sv~qyu}O6xp;b1[A<=sMэ`xC"0YZm/ξщ9ãSn|zr|txyogkme=731:tZF]"M$-Ë<̜L-«ltO#Ǐ 0,%p Cc|j!D7~ `4 > endobj 80 0 obj <> endobj 236 0 obj <> endobj 237 0 obj [1.0 1.0 1.0 1.0] endobj 238 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 75.3600006 0 0 75.3600006 168.5 300.7913666 cm /Im0 Do Q endstream endobj 239 0 obj <> endobj 77 0 obj <> endobj 76 0 obj <>stream H r1/E8,үn5/X񑦐oYʙ>k/v=y{myc1ʹo KC,.ZZcaiړ$KJ^v)/ zWڦ4SiҐ-JJ#v. Sj^girJV(M6^ZTiVKK&l+TSZQYiȧ>-K)MS^ˊҦUf=)M4+K۾X\Ki奁?K;K+06JC#NGSKh.RKi.RKi.RKi.RKi.RKj.RKj.RKj.RKj.ƒKi.RKi.ƒKi,RKj.RKj.RKj.ƒKi,ƒKj,RKj.ƒKj,Ki,RKj.ƒKj,ƒKj,ƒKj,ƒKj,ƒKj.RKj,Kj,ƒKj,Kj,RKj,ƒKj,ƒKj,ƒKj,ƒKj,ƒKj,ƒKj,:Kj,:Kj,ƒKj,:Kj,:KjCjCk*::KjZ/Kj,~JKj$5ՙK:zERSKK j듚JZ.Njkiֺ6.Mj,.Lj'i˒KjgiEZ뒤jֺ ZNjvi'GZ멤z"zNj~iz!iz FnZ&jFZ樵noRZfnnZ nBZjnZʤ~NZ*Z능ZZꥵhg-ERh['k+ZiZϖKk [ֿ0i9AͭQ魭a鍭qmқZGIoh:uVz' v4R4Poiե}_Jc,-4Ҵ?J*zVdMiՋ, J^.y[4CiҔJ;6F*)Xi`Gi|}&Ju-6Tm4hi`3JcMk/-UPRQiB)K.-iiU]YYki?J30wq~Og2t&әLg4dFMe8N$ٿow endstream endobj 240 0 obj <>/Filter/FlateDecode/Height 363/Intent/RelativeColorimetric/Length 16883/Name/X/Subtype/Image/Type/XObject/Width 363>>stream Hy>~~F#)Y(32T򑖖@ȡ8 >__K̨2'70 ۨQRVUیŪ/,vFa {4f0ܼYcT԰Z8]}Cx㍷ⴵ4j*J . y9,^] l\ˢRYk`753qwZ[ tq[5\VJBm\{y=3YPXLRF~.Kkήn=}^rڋh @=#3K'"EDE"1&&:*"p*$(!N6f&Fz:[*yn~Ev7<`4 Nx>Oo߀PBx1l9҅i陙Y@ˬH ޞ\l,v&9iqAXnu_K:&eiϣ؄$҅Lr܂›[E%%%Kݢ,,ȽML@JJ <ph))Jr@m&ƯhPgqiyEU mfK;}^B$Rj9z~!*TjMueŔTRR<1pc#ݭRbP, 6Zh̆8m8'BOG&.f^Ʀ-m] ΎMTUVȿv%")16t O=߅P@m>nU^mzY9Ĥ6#SK{wOа踤trNUԺO[;zzG@^, 5<<48qvrzJblqܜlM t4U1"ڴ#XhPhI9&~/`BTlbJk;gC#cS3 /? 9==591>6:2<4Z]yRCN#%#xb8Zm@65`ިm~4r`._S 1$y_`~0ݹׯ^@r {;Zj*Jc|4!ﰻ9^Ok,W6zC_XB7u9N!%#;fɝJ70bl|r 1o޾}Ww>os؋ζ憺{eE/%'9h -&Ȼڰщ-NkG~'È?ϼZpjpxt|rz{wz@t(LOt6ST̻AJ8CDOK # v}ê^+(&O LB p.*|XpdttUȨ1@ ۟6rߦg'G:dP('!ϽZ`e1ONοں 6O2ӔaDi8$'jv.95x:砫ݮ8 eyIՁ +--,!12q8HIL%Rn{X78269rUDW3SϺvuEIaN&)>2^Wsu`#B+㶛ۻ=qO%ՏəWtfT?DzӸr@wn]NM!8heWZ +5[8>~*:\JVψgc;ܓc{;>[Zx5\lXN \4AqYEmxKGNIWT7w>9#űz#Cݠ(׳H> |[f`u-ݽb3rn74:1gd^h%h./#?qbk(Ir2&{>=vRP62wGʺN){P|dl 뼈~V|y6M]QVLL"HvWb`f+0,.RnQ9\g/h%jWvO[#nI g#;Zl$- &"+)A`;d5twX9t=)+. пjAvnU()ˁEb2:i$K C!KlEdy$*<$P[е7$k ]:Iecr,ǚ|9G3y z?XxDUHۺs'B = `t6Cة NnPlze4_R=[hrWkD7UDxXJJ+ LLGqeh8B/q^ ^L5eŅY`5qy}+[vwGcuI~f߃ϻ;uEƛ韇j&o/x^Bb MPYij53R5GTz pi>!?عWR;46e@ {5V&]lt-g:Vz9l|UsuqΝ k]r87֨zpoR00wx-1ÉwˬhŞu=˻fsXWGLDp# ыyf(]`~731PFʸ G TD`VVgRr=24y#T8;E 8Qۥs빐{yu,!Tfz:xCڒܔ@o'&>i'!=YML/7ýy1>ΖpaTP7wz'Ǧ.)6ם eB|]h}Nghq‹xvNq͋!8ӟ>oR_lgF_6CϹX\r(i^@Zӈ򰤶2g!`8@n,H|zY#%mWZ;dUφ$d=izHϳ$7D^z""v (~3<9aZ g/Jso]w>8 

    {*Z)>iߒ\@C63]C(yة@PfOMn-ʾ4DEV_#$mhӵ[ B7% dHok㌸ :[|ѡ'oX⃧uP،EXNTE]rEN#Sj^aieS3YO~occ `yP;4` ;btZAU+icP^5d>5m܉mId/`d O#ynU?g =>fyrj)-=lyiN#u!JRB<_o||5s KC=41~mI.L&kʋ pCA306 5Ow2?Vncߩ\0~INE4DH" C JĖ!(*C[L)IJ !c<B6:٧<Vk}? `8Kco-ƲQmB'6daPе{M j<8:ꊳZ@ Y#<z4)qnww5@3B|p J(nu hCkBS9@H`!KE`Qsp#()D\C+~r粏6R|\L7#8꙳-7<1m燩ycq!$=6ͫ~Yc8W@|)/7!>z>HXyjF5L7# ։"rvzRNX+ʼ}LW 4#s5D!I= <)FAh|Rw?: hFl32ԤNJm{\̃`| [0 /}py،5U[󿚜 @|m{ɪefdQ(؅G} ,'9mzUifŲ;DV4w"|ԌE.x9lQc\Hkx<,1aBj\=v3!|QC3;1-)5lL ~Ԃz>6tN1fiOÄOCNd>cgMIM訩f1g2Pq KcqS%\A),d??DkKwX\Z~`KQg+V-=C {x12P#u~hxTX1&^>h ,FjjCNxϱF(EtteӮo"!@Mq~6w[ՊJGm 5dQ!v8]?*E.>Nf4b9-{@Rd x1-Ha*AH5wL̓R"qXigD d(D`$AZWލA1@`&$ i`WևPpik.W +%`PYg$%gX1)d N^auC;O_^7"~0o Bl2m:"ڦ{}Sr+~a8Di(Ɍ >kY`6{ۙh0i.moXTϚ+(qס̒ <^lL2!j{%<+oB`4֣u[+S5je:fU F`0 PJ 9lc21T)[^X90Pp0i)Nmo)GDT49rqIz Hk&RbvyKzZ:Z!͜}#R? T3"e9Ъ'2XS6kjIܪ Le N[Ys}k؊2F{Yd1XGS[8g&ST'"tջyUmȪ kBCIC;7J p"@5Ku-VP@X4=s񘝡qu>zU58PT0XU妄tܲjbЋӧ=5hEl9;u HGqİ(a ^4`etm0b("lEKXݎZ|1qWHp"܊*p=BŧwB=m7I Vtxkd{[+']>`"/؋dO- bNl+6vc[q? ֋:`{t< \A)MOV@^-HvBWq  $)OԊL`W +p=N 8a" *' "a>PAګsSOYKn'Ssc^G͋{~N[1{jV6A^@7|%^)W5 lj5Bv^a-&_p۶VqɂqbMjWW!כH& r҃b=Z;; ={ӮE=M$[P)N!K:QZ$ĐYDh9q}?3s'u_wp\Fq];t=,uao:O]e@ ,0V5Aź*C6 `:V! Y P1e`\W&j^a)U_xrNyD}/]m5OcN\?G^l0j=c[W߰$QcI//y0_uwG۬sֆ:x@=OpܬEfۏ^݇Oppt,~/y8Yh)Ic6Eef,XԼf*D}c^{7.TbG-"FXٝ 7 bm᳘+'w_ l(`,޻t b, #rYXÿm & cq SYzWoڡqM"m^DPh{^cYv"j[c=U)CkY - ˌqvJmVEm/ * l*A] u?$ʹ=;7[9PA0qO߹N1lOY濙opރSHA"dyh_Ә+'woX1]\R5>p.,W<}-bl*?NRIj_.P{Q\a/G?.{з۬slt|E_pVt/V4{4HUw"[qӊ_u/%f6ttò"hUxW8p[qxemK2hUS*ȩQY8SvVDd~!Ka)kȚ U 0_W;ȔAUMbrHY"gahU]9mh+qCQOh4kVg ԠJcӋj[;|9Kjd fYfp,nZ~U 5P[ ?#sq?:[k cѬ/HWu`|S{n%f,,q *J謴w:l1TdꞐt 0=itohъǘ2 Kz}6 Zԗf', (Rnyc| 52z0[s7_׵8΀Mi ?0~a6bVls2(f)v/LumLQ9kbg/Z 1< 5| )+xsOG:*OPTZUtp3Ǚ~TD޶vi^ܥ^|GT;eZ7CcƛZt1³=Qww4#O 4$Fc^qzGVE|fĦqԬVbV Y~5WEܻ@}cF>1O ޵P{YQJEkO(!]@BKGy~xN>bm^=8'G}I1P``_ v7yjjhx+kwbwp_=mOrPSbBmʖXؿ^/In ɇka5Guc!7Rxb :BƚO\~îCd?[qnbG]qJۑ}ky&j4PXaa\r/XG=H=oGML 7ݡ\t1]FbͶp~dk !Oi~6gʥ,s&j48(|u̎YڀUkE6fȢlt,|p ~q%HCh(͈r標F(z8Bum7s7Uj}TݽApD:D&,8.IEu]s!e ݃X|W1v&ѿX-? aBbBz^`}e_=h()c;2S>+$x\P D2k3VT **B5ZCy)Jnw@[UNs>ݛHb w WȌ~upg} o>RӿXVȥ蔢^k*rCϟ!neM]sG+q%;i[l}0>֔ 1:q>41\Roy9EAR4hGb$1kfdF:>3^0#e +D@RaW6`Lz )ၮP{5 aTf}q%;34& 0䈋nˋsQjX!ETkzzBVY2LF&~Lof:jxhk[=0L#3FӶW,lVfѲǶA{KQ * 2$>x縕ֵ9q, 5j|J?;RT 5X,+!pɁfѠ\bQ5gN{X 4ռƊp?7[ӝ$X֬\bUZQRDGּGĨKf+1̨dzu- )BhBVi=T>faMѼ1A^G$.F@Y!MƝn~aw1 kV޺} oVHF ~2uڻG$3 kD>F=L HSe'z.dȬ[ IB^Wq_Kp2q|@C[8zF5EK]d%ΙP@![ Nyf?12i wLw).BcHiT4 - 7YSI7$E۴J # |봌 {5(OyeDH>>f#( Y[}zFI !Q^P8qS#XWݶe ;o{ёގ\bti'V)ԼOY IhDc kgOr4TB5 k!mA|jx4;)21K#uPP&` ƎޡѷϞq$ڒ^N XG%շ?}bzw7W?HtBB< T8ȤҺWof8R$(N&$;DZ~q7lzu6>*H炛V)ϱ~mwKxX?;Ddong,'!3ͤDZ4pp O+l"LMr1r1ݼveUލpvJAECǙ^x#)*ɮMeŀM?i<r[ڞz^nX3DHj8#!1;VɈE5?9q68*) 6<^i3f2[FPE4hԎ/]I L"HS af#()s;Mr^i][Vj k@WKuqVG.k``qOQ2i^X؁U3Vz^<;5Fi+K ~AM^NMtšF!y5w;FM/شCqahaxP%/&of7$*R=8 `/hv$F=qs]WMAJx/:V=od( ]ZAy= =E/ǐ RSL_S券nvf<1"gaEeO[9y"2*{b/]-y=-ΫCuKdöBGO50GkdVߠ[^?r2;,# Ƈ['"z$;ؽ(#s_^%H4 #O\h8*)˹_iz73pQҸp#ؔܒ^$~?Ҏ|[Xh45AGy޼}J^Jd.V8n\VS^۱8d6+Ӻf=|~'Vb䑱O 38׸t97TfGx6tNUQFb@${f~Gk߶w}2]rVA h:{F)ދ}"2ͳ3ӓc#vgpNMxFq͓ `[-?Q*B)-=> |ĊR{fv 8fNN3a!c< z;IuT _O&5T~冫c@ٛlcaіSQ7u!F% vLP89 w 12ƐgAyr|tIji d gwL N)J ɊλUf F62lcfCU<{冥O`HDLBrFnM+#u2ǀ''@yK sғC lev9u9i1<;:h473]Y!h;=zFx]]7açg|vnn#/f~~&vu4V s2b"B}81zI[㸼?/JMYѬ6 ~ar]11vtqqVT56x/||SA~€GGȃ=]֦ڪrbQ^VZR|L+3NvFYAo;CW+46hCGjAڪ:WM-l]<B^KL/"UV645::C0l'45TrBlTϼܝM ΩSwsXΫ %ڰD2r mߕN5jz7Ehě؄ye5uM-mm$R;>$R[7փpey)ca~Nfzrbۨ #}Mc238,FfqQH62r'5 k?\=| /C#ƽWPTL,)++D_JEy9Yi)Io"_x[&FZ'JK8BycOڿ m=!nu-Kf9=Dq|4+=@ADNE/5Bf~oP9ԩ@Yfо7>֖k-m޻˯߾ n)x|ڛWK؈w,YLLJ9w616 DT[IEMCsK{go?矿XZ^Y][ ͍ՕŅ';ZκrGqрehLtA# X`ݘ!\d+)mt><^ fg&FGz;u7T9SnvV@%"s9canT谜$&p,b[c&9u rzp(ș, "$082rh󯣝yCpr<{(FqЖ)߈F7@2|3H͡:d'"0IL!IPW~F h??*. endstream endobj 78 0 obj <> endobj 241 0 obj <> endobj 242 0 obj [1.0 1.0 1.0 1.0] endobj 243 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 87.1199951 0 0 87.1199951 342.5 390.0313721 cm /Im0 Do Q endstream endobj 244 0 obj <> endobj 245 0 obj <>/Filter/FlateDecode/Height 363/Intent/RelativeColorimetric/Length 16883/Name/X/Subtype/Image/Type/XObject/Width 363>>stream Hy>~~F#)Y(32T򑖖@ȡ8 >__K̨2'70 ۨQRVUیŪ/,vFa {4f0ܼYcT԰Z8]}Cx㍷ⴵ4j*J . y9,^] l\ˢRYk`753qwZ[ tq[5\VJBm\{y=3YPXLRF~.Kkήn=}^rڋh @=#3K'"EDE"1&&:*"p*$(!N6f&Fz:[*yn~Ev7<`4 Nx>Oo߀PBx1l9҅i陙Y@ˬH ޞ\l,v&9iqAXnu_K:&eiϣ؄$҅Lr܂›[E%%%Kݢ,,ȽML@JJ <ph))Jr@m&ƯhPgqiyEU mfK;}^B$Rj9z~!*TjMueŔTRR<1pc#ݭRbP, 6Zh̆8m8'BOG&.f^Ʀ-m] ΎMTUVȿv%")16t O=߅P@m>nU^mzY9Ĥ6#SK{wOа踤trNUԺO[;zzG@^, 5<<48qvrzJblqܜlM t4U1"ڴ#XhPhI9&~/`BTlbJk;gC#cS3 /? 9==591>6:2<4Z]yRCN#%#xb8Zm@65`ިm~4r`._S 1$y_`~0ݹׯ^@r {;Zj*Jc|4!ﰻ9^Ok,W6zC_XB7u9N!%#;fɝJ70bl|r 1o޾}Ww>os؋ζ憺{eE/%'9h -&Ȼڰщ-NkG~'È?ϼZpjpxt|rz{wz@t(LOt6ST̻AJ8CDOK # v}ê^+(&O LB p.*|XpdttUȨ1@ ۟6rߦg'G:dP('!ϽZ`e1ONοں 6O2ӔaDi8$'jv.95x:砫ݮ8 eyIՁ +--,!12q8HIL%Rn{X78269rUDW3SϺvuEIaN&)>2^Wsu`#B+㶛ۻ=qO%ՏəWtfT?DzӸr@wn]NM!8heWZ +5[8>~*:\JVψgc;ܓc{;>[Zx5\lXN \4AqYEmxKGNIWT7w>9#űz#Cݠ(׳H> |[f`u-ݽb3rn74:1gd^h%h./#?qbk(Ir2&{>=vRP62wGʺN){P|dl 뼈~V|y6M]QVLL"HvWb`f+0,.RnQ9\g/h%jWvO[#nI g#;Zl$- &"+)A`;d5twX9t=)+. пjAvnU()ˁEb2:i$K C!KlEdy$*<$P[е7$k ]:Iecr,ǚ|9G3y z?XxDUHۺs'B = `t6Cة NnPlze4_R=[hrWkD7UDxXJJ+ LLGqeh8B/q^ ^L5eŅY`5qy}+[vwGcuI~f߃ϻ;uEƛ韇j&o/x^Bb MPYij53R5GTz pi>!?عWR;46e@ {5V&]lt-g:Vz9l|UsuqΝ k]r87֨zpoR00wx-1ÉwˬhŞu=˻fsXWGLDp# ыyf(]`~731PFʸ G TD`VVgRr=24y#T8;E 8Qۥs빐{yu,!Tfz:xCڒܔ@o'&>i'!=YML/7ýy1>ΖpaTP7wz'Ǧ.)6ם eB|]h}Nghq‹xvNq͋!8ӟ>oR_lgF_6CϹX\r(i^@Zӈ򰤶2g!`8@n,H|zY#%mWZ;dUφ$d=izHϳ$7D^z""v (~3<9aZ g/Jso]w>8 

    {*Z)>iߒ\@C63]C(yة@PfOMn-ʾ4DEV_#$mhӵ[ B7% dHok㌸ :[|ѡ'oX⃧uP،EXNTE]rEN#Sj^aieS3YO~occ `yP;4` ;btZAU+icP^5d>5m܉mId/`d O#ynU?g =>fyrj)-=lyiN#u!JRB<_o||5s KC=41~mI.L&kʋ pCA306 5Ow2?Vncߩ\0~INE4DH" C JĖ!(*C[L)IJ !c<B6:٧<Vk}? `8Kco-ƲQmB'6daPе{M j<8:ꊳZ@ Y#<z4)qnww5@3B|p J(nu hCkBS9@H`!KE`Qsp#()D\C+~r粏6R|\L7#8꙳-7<1m燩ycq!$=6ͫ~Yc8W@|)/7!>z>HXyjF5L7# ։"rvzRNX+ʼ}LW 4#s5D!I= <)FAh|Rw?: hFl32ԤNJm{\̃`| [0 /}py،5U[󿚜 @|m{ɪefdQ(؅G} ,'9mzUifŲ;DV4w"|ԌE.x9lQc\Hkx<,1aBj\=v3!|QC3;1-)5lL ~Ԃz>6tN1fiOÄOCNd>cgMIM訩f1g2Pq KcqS%\A),d??DkKwX\Z~`KQg+V-=C {x12P#u~hxTX1&^>h ,FjjCNxϱF(EtteӮo"!@Mq~6w[ՊJGm 5dQ!v8]?*E.>Nf4b9-{@Rd x1-Ha*AH5wL̓R"qXigD d(D`$AZWލA1@`&$ i`WևPpik.W +%`PYg$%gX1)d N^auC;O_^7"~0o Bl2m:"ڦ{}Sr+~a8Di(Ɍ >kY`6{ۙh0i.moXTϚ+(qס̒ <^lL2!j{%<+oB`4֣u[+S5je:fU F`0 PJ 9lc21T)[^X90Pp0i)Nmo)GDT49rqIz Hk&RbvyKzZ:Z!͜}#R? T3"e9Ъ'2XS6kjIܪ Le N[Ys}k؊2F{Yd1XGS[8g&ST'"tջyUmȪ kBCIC;7J p"@5Ku-VP@X4=s񘝡qu>zU58PT0XU妄tܲjbЋӧ=5hEl9;u HGqİ(a ^4`etm0b("lEKXݎZ|1qWHp"܊*p=BŧwB=m7I Vtxkd{[+']>`"/؋dO- bNl+6vc[q? ֋:`{t< \A)MOV@^-HvBWq  $)OԊL`W +p=N 8a" *' "a>PAګsSOYKn'Ssc^G͋{~N[1{jV6A^@7|%^)W5 lj5Bv^a-&_p۶VqɂqbMjWW!כH& r҃b=Z;; ={ӮE=M$[P)N!K:QZ$ĐYDh9q}?3s'u_wp\Fq];t=,uao:O]e@ ,0V5Aź*C6 `:V! Y P1e`\W&j^a)U_xrNyD}/]m5OcN\?G^l0j=c[W߰$QcI//y0_uwG۬sֆ:x@=OpܬEfۏ^݇Oppt,~/y8Yh)Ic6Eef,XԼf*D}c^{7.TbG-"FXٝ 7 bm᳘+'w_ l(`,޻t b, #rYXÿm & cq SYzWoڡqM"m^DPh{^cYv"j[c=U)CkY - ˌqvJmVEm/ * l*A] u?$ʹ=;7[9PA0qO߹N1lOY濙opރSHA"dyh_Ә+'woX1]\R5>p.,W<}-bl*?NRIj_.P{Q\a/G?.{з۬slt|E_pVt/V4{4HUw"[qӊ_u/%f6ttò"hUxW8p[qxemK2hUS*ȩQY8SvVDd~!Ka)kȚ U 0_W;ȔAUMbrHY"gahU]9mh+qCQOh4kVg ԠJcӋj[;|9Kjd fYfp,nZ~U 5P[ ?#sq?:[k cѬ/HWu`|S{n%f,,q *J謴w:l1TdꞐt 0=itohъǘ2 Kz}6 Zԗf', (Rnyc| 52z0[s7_׵8΀Mi ?0~a6bVls2(f)v/LumLQ9kbg/Z 1< 5| )+xsOG:*OPTZUtp3Ǚ~TD޶vi^ܥ^|GT;eZ7CcƛZt1³=Qww4#O 4$Fc^qzGVE|fĦqԬVbV Y~5WEܻ@}cF>1O ޵P{YQJEkO(!]@BKGy~xN>bm^=8'G}I1P``_ v7yjjhx+kwbwp_=mOrPSbBmʖXؿ^/In ɇka5Guc!7Rxb :BƚO\~îCd?[qnbG]qJۑ}ky&j4PXaa\r/XG=H=oGML 7ݡ\t1]FbͶp~dk !Oi~6gʥ,s&j48(|u̎YڀUkE6fȢlt,|p ~q%HCh(͈r標F(z8Bum7s7Uj}TݽApD:D&,8.IEu]s!e ݃X|W1v&ѿX-? aBbBz^`}e_=h()c;2S>+$x\P D2k3VT **B5ZCy)Jnw@[UNs>ݛHb w WȌ~upg} o>RӿXVȥ蔢^k*rCϟ!neM]sG+q%;i[l}0>֔ 1:q>41\Roy9EAR4hGb$1kfdF:>3^0#e +D@RaW6`Lz )ၮP{5 aTf}q%;34& 0䈋nˋsQjX!ETkzzBVY2LF&~Lof:jxhk[=0L#3FӶW,lVfѲǶA{KQ * 2$>x縕ֵ9q, 5j|J?;RT 5X,+!pɁfѠ\bQ5gN{X 4ռƊp?7[ӝ$X֬\bUZQRDGּGĨKf+1̨dzu- )BhBVi=T>faMѼ1A^G$.F@Y!MƝn~aw1 kV޺} oVHF ~2uڻG$3 kD>F=L HSe'z.dȬ[ IB^Wq_Kp2q|@C[8zF5EK]d%ΙP@![ Nyf?12i wLw).BcHiT4 - 7YSI7$E۴J # |봌 {5(OyeDH>>f#( Y[}zFI !Q^P8qS#XWݶe ;o{ёގ\bti'V)ԼOY IhDc kgOr4TB5 k!mA|jx4;)21K#uPP&` ƎޡѷϞq$ڒ^N XG%շ?}bzw7W?HtBB< T8ȤҺWof8R$(N&$;DZ~q7lzu6>*H炛V)ϱ~mwKxX?;Ddong,'!3ͤDZ4pp O+l"LMr1r1ݼveUލpvJAECǙ^x#)*ɮMeŀM?i<r[ڞz^nX3DHj8#!1;VɈE5?9q68*) 6<^i3f2[FPE4hԎ/]I L"HS af#()s;Mr^i][Vj k@WKuqVG.k``qOQ2i^X؁U3Vz^<;5Fi+K ~AM^NMtšF!y5w;FM/شCqahaxP%/&of7$*R=8 `/hv$F=qs]WMAJx/:V=od( ]ZAy= =E/ǐ RSL_S券nvf<1"gaEeO[9y"2*{b/]-y=-ΫCuKdöBGO50GkdVߠ[^?r2;,# Ƈ['"z$;ؽ(#s_^%H4 #O\h8*)˹_iz73pQҸp#ؔܒ^$~?Ҏ|[Xh45AGy޼}J^Jd.V8n\VS^۱8d6+Ӻf=|~'Vb䑱O 38׸t97TfGx6tNUQFb@${f~Gk߶w}2]rVA h:{F)ދ}"2ͳ3ӓc#vgpNMxFq͓ `[-?Q*B)-=> |ĊR{fv 8fNN3a!c< z;IuT _O&5T~冫c@ٛlcaіSQ7u!F% vLP89 w 12ƐgAyr|tIji d gwL N)J ɊλUf F62lcfCU<{冥O`HDLBrFnM+#u2ǀ''@yK sғC lev9u9i1<;:h473]Y!h;=zFx]]7açg|vnn#/f~~&vu4V s2b"B}81zI[㸼?/JMYѬ6 ~ar]11vtqqVT56x/||SA~€GGȃ=]֦ڪrbQ^VZR|L+3NvFYAo;CW+46hCGjAڪ:WM-l]<B^KL/"UV645::C0l'45TrBlTϼܝM ΩSwsXΫ %ڰD2r mߕN5jz7Ehě؄ye5uM-mm$R;>$R[7փpey)ca~Nfzrbۨ #}Mc238,FfqQH62r'5 k?\=| /C#ƽWPTL,)++D_JEy9Yi)Io"_x[&FZ'JK8BycOڿ m=!nu-Kf9=Dq|4+=@ADNE/5Bf~oP9ԩ@Yfо7>֖k-m޻˯߾ n)x|ڛWK؈w,YLLJ9w616 DT[IEMCsK{go?矿XZ^Y][ ͍ՕŅ';ZκrGqрehLtA# X`ݘ!\d+)mt><^ fg&FGz;u7T9SnvV@%"s9canT谜$&p,b[c&9u rzp(ș, "$082rh󯣝yCpr<{(FqЖ)߈F7@2|3H͡:d'"0IL!IPW~F h??*. endstream endobj 74 0 obj <> endobj 75 0 obj <> endobj 246 0 obj <> endobj 247 0 obj [1.0 1.0 1.0 1.0] endobj 248 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 87.1199951 0 0 87.1199951 208.5 390.0313721 cm /Im0 Do Q endstream endobj 249 0 obj <> endobj 73 0 obj <> endobj 72 0 obj <> endobj 68 0 obj <> endobj 71 0 obj <>stream H \7P/1 {HP|sC_>u&o[>:/oЗ_Ao?> C} +}aQ8:AMA :GA۠C|@߳:[9[cF?: -ckt?Wl}@7Љ~OP#=tNGɢNuE,dQ':YɢNuE, %Q':YɢNE,dQ(:YɢNE, %Q':YɢPuE$dQ(:YɢPu(D, %Q':YJNE$dQ(:YJNE$dQ(BIɢPu(D$dQ(:YJPu(D$dQ(BIɢPE$ %Q'BIJN(D, %Q(BIɢP(D, %Q(BIɢP(D$dQ(BIJPE$ %Q(BIJPE$ %Q(BIJP(D$dQ(BIJP(D$ %Q(BIJP(D$ %Q(BIJP(D$ %Q(BIJP(ģR B9JP(D$ %Q(BI<*(C$ %Q(BIJPJ)< P(BIJPJ)< P(BIJQ)G %Q(Gr(D$Ԟu*Ԟ %Q(*' BI< Ut*. %)TS9TvpSvP(#*;ԩPc*E;ةPBI U)NbU)҉N" U)ΩN U)N U)NbU)…N"\ U.u*zBUj*NZzJtSVRTu*7;Uj۝ PZa@B0"TҩR TF\:Uj*5NglJ2S&ީPsU&tS:UjI 5ڬPkZJ 5SکP UQ&w ;Uj*5NoMJݵSnZ֩R,Tve;U*u ЩR *uԩR'a*u ةR'*uܩRљ:}с~[>cct/;E>dSt'KDGy>eCt[Cy~f+ts6Bgt6Ag8htpt`g_OǟFo @_~}r-E} }B7-C}C'.@_< d#_ND_;}4яNA:pE9}@B߸?D߷ u_DF?B~}>0(]M> AO q~aUGD$R$"HD2)O'#Z r endstream endobj 250 0 obj <>/Filter/FlateDecode/Height 426/Intent/RelativeColorimetric/Length 19007/Name/X/Subtype/Image/Type/XObject/Width 425>>stream H{4y}6))ܙ\bݯ>ENSډ""[k]K8Cfr]\b J*Z:zF&&bbbldhĤ$b"BQҭ,T%*u(ZBZFNAYMcZ98:>[-;9:Xa-1 5e9i 0ŹV-dZ}t%:(/4WV7˜clp=;@9x{oOww8'{[+)H_W[SMY^j j-c1>*J[Q@H+ia,lqή޾!ga#"HKD8F<r:s0h)Hբbmʴ0*qeT4bamp>~3bb~LNy$1 3##-zJ 1.F|| s3ӓc#C}G uV^VjblTX(2QW{ ]feBK˫j;s"$~NqEU-DYHDBt޼y1 -Č fg` bVU߹G 9v򀾖4Ԋk'2ވp <~sޛeV#HH! | >e7xA.5=9Ջ`Tf\Vof"4D 1y  p0 Gz(O_Ha^.VڪrRB V۳WQ}Qw dR[AФH}<`t h`6UrEbQ[s}{ Lp5,(!BLb͂R?4:#Z ZcpbtZLKJVN@6 \Mr*چf87 bTOYK*kJsH{Z+XsӓtʒY?G|pf*rpYm*[*ˉ?WT?ncTdHc1Z=97=`g;s#5y)1A&@OX,p7!L&.^'F^q~]Sk'K`A%J},X,jb֦%o\<{DWe[*d거+„:I 9GVQHXXKno./IM }\J^+XYGcι&5=˽ƖS렴pΖ{'F;*5~?5ya|պQ ;^le) &S T(֖2)Pe) fʰmDDQ%PY Jpy7޹ɛO8>(HJQqI\J^EbA-fs;{phn 83PGAv{ͪ_  *W;sMy)qa*8S7q/l\RF^1Gi+faުae$ŝ L_CINMպqRT5yD`D,tpOy+1ҫгZY g׽*6"&6U 'w-_|16vΛ_ZŎ^adm %p.fJ?& ;Y7/e^S2?pT[9dں~|30NuM}ÜG UT8}MuŹ`>;<=`$T=|#ļƾ?p~cyÉ;L8UDz8ZiI#GX\'pNfvnާ&]^bp|zn`Z{47=>3QD@j&8'OþLH+S ?{8;Lյ/>G,+TFkR jvn>Ǝ `ay/j3X(N=Aq3US8@-B ahy/4B& ^'!  0UC=hBU!<'4ܣZ]NGSzY2 8P FdmHA ?3ZzsfE/78K3=-u/PHn6Zʲx#uo!Qw߰"`{E9A 8^`E1aGMuTŗ#$T<u|FaA z$iQea^NPBJA"DLj^EcKC;KnKBa ,(p>UnDz)&|Fz4~ׁPxoW))@I)[=s6(|RPjd~lhh|&'P$Q\^ =Bz x%*f$gN(+ t4*/ʐ5/"fd?l.H|Y(o og&#Noaf'$TbNIm j2'%9L)K 3Wжpv0 jB[C-脙o4q8t*tHPB(-%!M tvth\U lL^ * 8S eדw1PEGG\PBQLXP؁BD]Kag3>ttt |7A3:;XP@-!3烠oN7V5W3H g4Tw%:PU~æ./ߎ=~ቹ:kjYgݽp=; x4{ G&0}t GJASWJv?{3}sm U쥃O@SW7 9-sz0_WK0{y8h h"&]-nAfNCpGk. o?7> ŹzF%/R0!3!cKwROi՜V 7 QmNGCK:qwZrc+N=dM^f& LB@`lzQ4>>BikUQzl{#U2Vv1`{"5 "5 4]O|BLJBqY/npЅ5hbμtx5\lK漂[>wģ#LJ?>q|g[$yiPtB4;UR5@|>$i$RWfr(:6'ϥV S>RUv$ NVs- g҆ndt 'Js|-hP7੸6dt 'ڪ3NIѱB mֆj|w*]+|bF2TtbK,Pkk:NB+X&GuܾXMZk)Y+k9U_˳Ja[HŬucH\:f] >h^+EJl]Ԃ#1~/HWq)"JtWhBvI}sRsWtPݦK|NDRs:WZ>ᶤJJ(T=u9{/0[PDB1"szPDT- % +P,/{`B-JE-_ (_Z!*2_)pQb[ICrR|1PPtRJe_ tخ*%bqo׶ۨ+Hy+ז(f,CAޭGǨ̉__4R䥸Rfy9ϧP QV]s&'BǮ~ʼ]4 Ř`"=﬽wl$(XkMXҍ#ӨP Ia8ᣗ"{@LzgcSPXWSc[Rn脛;z&5B1.XƇzrB(57mP VgSa@%oT6nURO>ڐGo}I0t{C) \kn5wnނ9*2;z 0_끡/PJ+x]w9q j\8%:7?n JMO ?n.-oG5iJtbU\@%JDU7TCGL'WE:jγVBSqC= N(Ia_}e4̜|“*ܜN։p'3 Eهo[@LƭPiVFLEه/6"`Fm*͹|x'R ξ)U}H%L'FZS"}?\}D7Z# /w){ZwG&gHBnQ3|Ld Re !F#_dJAUk?" %c{M/y}q;oI7B!r>D>M-}k9-yqI(ak7 !,ۼJfF>!iuS'|/G8K }YVq٤eDLƭND>f`_g,6ZŲ-_iD>ozb4;>蠭'l+b2켂ƎgnV!158Z*o&v4Ve_4w=Vc`# t<,J:j)+ǹ'{`\qD>fcPw̸@w+y( )Do1D>foȓfL /|NRdѰcSD15HMzxX-%dܰ21]L1] I嚤H苫5/C4W01͊Ud Xep7syZEO cϾnqV}-yd'38淼2s-##5w|~%%ssԏOAPg}y~/?|HE'/ZzQUS(>Q qF{ү#E[|Uյ' G .)muETVkYJW8Q|KL,t4)z&N!1HK*6DoQMEcX|ZaeS7^Q.´5էQ)pԴ vf'jفGjrjvƯv -GJ:w-{ErJkB|H m59 ':~Σo;Bq;޾xtq&zpƥT4t xƇ* RB,j%\|Où3O #5 'i_5!k {7Lv5_feG2;NjvfBH-Q+k "ZrM(RYZ3n jB'  ѧgb_Љ[Қl(O v6S>% Us-VX32[ &&Fz* Ӯ:{ˆTDQht{x1}h on۳ՌNS#e / 21{Bf_+8+12f(Rqj>D %1yX82}n;HG, Dͻ|Ρi| wm^K>i8ƥju#ݍ RB}m\Ie:ч "F>s( o <\^3X$3>}nORεvG"2ܖ>\ L8jgNךsBuaIM[92` jJފ tg2 DkZ8Db\}. yd0, w6؏gzHWI]c+G(H0^:he \tzĦW4v  8Ƈ+SbC޽rw` 8}+ |ŧU5㽋m*J[x(wm]|ï. |^uwN2Jm{ v5޻h-jvͳn3ӨK7sL, (@QVnqfB9]x Q {sYjB1 J,bkO(EiݰebShyr(=>-jDN''uL`QȀ&F߽~rJwɢto_5<>EjO ޵y.Y׉Kw04>5E!B2;=5>㻗Nx9ZhE)1ڴcyv ~4kO;>ϻslMFkUI -6`ػD&fs[qVnqVbd=@T( y@}:?܌N#CJW>EMu~ug\[;yĦ7t>GΆo'k:'Di[9z_>dEߋGw.{:Zh("g_im@ 0%?"!]}ˎdF__K5;3!FQ*MQ}( o(i_[S}}HQ5q K-h5iKt5Tƅ8|I%(i邚:~:H-qh ]胢5Q# lśʬ! LȥJdȰ\20h0%4ˤF*M2T\jF\r=TXG8yZ?k %*;ۄߡ_շ#E~5^_vC~N&&J}/Б"=DkfŅ{MQѷMU󌆎NYͭPk}wOQRʺnGܸk)ᮧoDf,%*G-H*hLʹ'/:R$(8w$Ev1Vs#',.zԶ~tvۨp8lX-'.`.3Tf(:R$QZsSap.sw;49y-T7:R$?QD]qsQ(I L{ )`^7 @k|F y ?もj`zM} _ӃOzB9皖|ux|.b$_[U|t-4q.":& 0 /YjhGf/6PuɇOc߶ >R3|A0 Hi׋GJp2K>p!#1o9N`mG#%8eۼY%u-/8H-u%Yڝ|S7oȉªƮq>o|0Dlk-5w 8SVū7Jq4yy}YNґW󵳒>y ;d6FNp:JКk2΄l!?+[gޘʆA )1{[5;I)9JRH'8B%Zw~K8l$5+0-S7 S' T0/;#u9E|VWHluuUs"T\T6u+ V(Q"`J9`:Qg'pϳL%fp :q,9I8 L%:T%*TBkVuBQ`|vi] m 4;"3*W ꄬgȉKJq*XƁ/t"PM)++XZywWR[FQ8P}ԻW#s5_,X ?"!~~`yکvoА38ˬ\otJq Pn~4rJ̨^)` Z&[z7tT)+nDK9S*ATn{/?nGLB?.Ͻg.nΨ0t}eG/)f>vk3:~ʹ+ T)dPy)1ۭ tCWZcU\ی*Ł \[lF 9 TJ3[}1)R P)1vR(#*ďa(_Q(9J -%*YT{h5 z|YMQ&QsW _r}/QX}&cPJu .vucAbS&&`Wc팸TJ|7|Z{G0E/? #~9C5 ET l)SG7v!`W0|Zu3tPjrK[E%]SBAΦj>Bks5)*J_ujjK)j8xM-|1M|IeAj~omE9o*Uz["KEΖjnIvBdVKU>fPR yE?Q4 =V/'h{T]q6+sݕ2KX,^)I`"EgcVsuWK\('k=fU 6n U7Ӏ;b[5 ~j!?E\V*z҇'I4?,@MV*J}GK发O;tx\d؎9Rp}0>kp Uxjl(3nWQsF0E_aG0 j mϿX_Z$=4X>5R)+֚9|I;|BIe}֮OEB[`dBvqM?S,3>@7`r9㧸xOhm6xu ~X a||ד|kQ UYgi3_?@A,L;.u*$&+# ")nht^BLj#Aֆ"<(BbJ7{\ȹ[3}f7\w7BLJbB7 ⣀OSė9KQQ;}|F*0>ge~6xE!gުjG>fPu+3p [L)~Zv?%])E>Lym镤=팵> 5sܹܲG-=O1O{Z^<Jh|lS\"`[n89~2xMHBc4Ίq1$=fmDvj$FDkBT*1$;5Zk*E4"auđ:J6~>vc^qYW+CIPP7G=@fJHjA8x\w号1Rm儾bR^!1sʚ:L -@u6\ 7T۞Ww (Gׅ~% p7V~.0zu_#]^_xB|SSBZ8"1>jFBik5m5sRŀ™)،P(Ȟ1u0s4sP+jp _N.mhg b+Y V{Civ` -6s^RZ8{+o Y`gsyލPog ]7s^RLY^'Qh ſeE$z4 )7sRLڸ#3P@@NP$FHe&Fm wl8z[꘦G@t stzA~TGj)v&Ǥ@TqIoJȊ^}[?T?!͂pVDL[ig>H<EZnY#~BR_cYn K(;A!fJT &r`w4UDž "!!Pr J*w ImUNp?S\Z c[<1"!Bw >Z]t%#!5q%udbfaCHjpbTf&F{ؙj)IHBcEbt I*nF5+)*LGy7Db>@ 'kEZ^NԬ /Gs]9" (NNU‰IU?ZN 'Nt7Db-) T 9q|8!PPӳtPHj]sROMABЁ%%/$>rWSuNwJHjli(![Z IjVIq9"&8mLkpZPR41؅NV1 J 崸0;bTQiCRN˥^ R,zŅqv'K6 UHwƦ埂6T<_XtZ!8%ENʤT6N?CHx>?5TIL"`RNČ63S F<a5Vd$FÉW`d)QY*<>}T_OJ r5=#ޫ}38>#8C2O3=k_t'^RF'kL`juX촸p98qHIȫbSEvyzloScvfbXBJNE'rJ&3T{8SkC9%3r8qH䔵OzD'JkvGS{{gw?-#%Fx2RÉb$(NVIʅQETˈ!7:Ӎ,NT>qBH9".}Lуx!6oOo=nQ^ z8Z?"{PRzG (/P9NP-s,< 0O@#ޑ:z &9!:xmIymW਄O&~ۃG=&w0IO v1V| %OVIʙ~:whb:8 K"f&z[+ׯ 5d*'@ ʟ * uFSpj/ZQV2Rb,<ɈUNo τD]]\X  עBΜ=Γ~iA*-#k/_ߦ׷tQKNϦ-_^r6B PdXφ^J"ܧ3QwTL@91KrIIB- +=O4?S׏"!S+QF'`T[oanrΩJ&."#WSP{؟ COYp[y%5!Sשl=; kJn8de kd@(뛿v&82>\X^sgܩ/TqBZoBrZ|de@<?` ']a1ɤ6/+TLK{8Fr9imoj$dSعY\mS+S7V0qmXZp;5E'/l8Is/% 9<1;U| ńӳafh=7.?rD>&)5Qyխi -]`TUL/σqji}s/9i(q-8u Q 8*ck|?K/ija2X [zC2im)J'kcpN28Q>:q}3 O?FL$Zir (kJ$oc\IN-bm8G%.ehipa:L}~*/NOb\'~>'4SU|W XEj1!*,ZF[hz>pb#}&*JuOX7Q;*%|\)SWL34:;skEiA^Rz0H`hF@Hqi+,K(JXA@!$RPޜsOyswgߓ.U+VkeL+HZWe%zi59Ǐ=XwoXR3*$:9 z75\[oc>,3!LU!8i(H =;m:\T2\*};21,s}+>=Ǒ[yy%d zktRT~.moeU><]ZUb n02x:͌Ud[o۬zO)U4ΎYH,,s=Zm,2sl^wo5Oz '[oz:UUT!Q >kl ȪՖXq*7ٯ Q! Vo=_DJ^P=|ɔܒzC&gSwCp+KrS)d_G<`R߆icVOU%PMmܮߊIzBij{;<6۾ꓬ䘈[lLƬkmv?!=Wݴ9 W_k KUs}/y ]쭱(56=N JEĂG9 eG_ k#J=/O$y:,L Umg3m[:U?Hw4Z.,w 'Imāx`J5ŏ𸳺?(J۴s`VೂOH꒳nLRF^Iꦁ{593\ZZq ,1f&]uCd?/KVX#uœB| `֩S76 &Zc O3߳fjVB,"`ĚKAFUXJH( C; EWJBVI kI B)qЪ3bk# !E4i|tAlon.Jq ;`ETw ݴ`֨W;kf}:MJ\TVU؂` MN3{.jBl!=0bNO "H-UeEӓbufgT~2*`' H*l.9yB"cRKO V}`Tkzvn,!^,0 mǿ$Zb.zDj{J-NM "y8]G+J7ݡL0{ Y@P+QSJ:"'Sj[;Cc3s E2}_\tu67pr;"P2P:%% ʄl4X&$[dA ̬ ^7C"'eUV75ut3FF'gf%?/">3Sc#Cޞʲ촤!7\ fl%1!cL}S7ockhuBcx{'k#e-m{zit0"6992iIgxpAhkyXJ s3%EEdbQ /n6]?RT526 nPDGtsv Z&4UdŅ>.nU֭`yxD y-2Ëx~^tLOI)R222@9?dfd>JI)>&P@_ 2wcpF[C"pstiWi#BKtwqv'MM0HjJ2R8ϑ*}Q֬3(&!-+}Zcř[]ݯz]!\ }못3=5TSsBݮҗZ'1P-%#:g`9wgnimpY.\Z[LϟFii)+@#PcG[CUXh :)+ t hfA u?٩( 9 3)VT秈Mąնng3ɓ}x߼5* 6!^;$U5豾Fq;E]Zv?^CߢzոzY|fMh$CT1Ao /aOtjlI{ʹ"yY3MIѭ)}~Z-y6Z4""CR;pV ^&bH·)CE`,dh6уddv@x΁wsz=M4>K endstream endobj 70 0 obj <> endobj 251 0 obj <> endobj 252 0 obj [1.0 1.0 1.0 1.0] endobj 253 0 obj <>/ProcSet[/PDF/ImageB]/XObject<>>>/Subtype/Form>>stream q /GS0 gs 102 0 0 102.2399902 268 482.5402832 cm /Im0 Do Q endstream endobj 254 0 obj <> endobj 255 0 obj <>/Filter/FlateDecode/Height 426/Intent/RelativeColorimetric/Length 19007/Name/X/Subtype/Image/Type/XObject/Width 425>>stream H{4y}6))ܙ\bݯ>ENSډ""[k]K8Cfr]\b J*Z:zF&&bbbldhĤ$b"BQҭ,T%*u(ZBZFNAYMcZ98:>[-;9:Xa-1 5e9i 0ŹV-dZ}t%:(/4WV7˜clp=;@9x{oOww8'{[+)H_W[SMY^j j-c1>*J[Q@H+ia,lqή޾!ga#"HKD8F<r:s0h)Hբbmʴ0*qeT4bamp>~3bb~LNy$1 3##-zJ 1.F|| s3ӓc#C}G uV^VjblTX(2QW{ ]feBK˫j;s"$~NqEU-DYHDBt޼y1 -Č fg` bVU߹G 9v򀾖4Ԋk'2ވp <~sޛeV#HH! | >e7xA.5=9Ջ`Tf\Vof"4D 1y  p0 Gz(O_Ha^.VڪrRB V۳WQ}Qw dR[AФH}<`t h`6UrEbQ[s}{ Lp5,(!BLb͂R?4:#Z ZcpbtZLKJVN@6 \Mr*چf87 bTOYK*kJsH{Z+XsӓtʒY?G|pf*rpYm*[*ˉ?WT?ncTdHc1Z=97=`g;s#5y)1A&@OX,p7!L&.^'F^q~]Sk'K`A%J},X,jb֦%o\<{DWe[*d거+„:I 9GVQHXXKno./IM }\J^+XYGcι&5=˽ƖS렴pΖ{'F;*5~?5ya|պQ ;^le) &S T(֖2)Pe) fʰmDDQ%PY Jpy7޹ɛO8>(HJQqI\J^EbA-fs;{phn 83PGAv{ͪ_  *W;sMy)qa*8S7q/l\RF^1Gi+faުae$ŝ L_CINMպqRT5yD`D,tpOy+1ҫгZY g׽*6"&6U 'w-_|16vΛ_ZŎ^adm %p.fJ?& ;Y7/e^S2?pT[9dں~|30NuM}ÜG UT8}MuŹ`>;<=`$T=|#ļƾ?p~cyÉ;L8UDz8ZiI#GX\'pNfvnާ&]^bp|zn`Z{47=>3QD@j&8'OþLH+S ?{8;Lյ/>G,+TFkR jvn>Ǝ `ay/j3X(N=Aq3US8@-B ahy/4B& ^'!  0UC=hBU!<'4ܣZ]NGSzY2 8P FdmHA ?3ZzsfE/78K3=-u/PHn6Zʲx#uo!Qw߰"`{E9A 8^`E1aGMuTŗ#$T<u|FaA z$iQea^NPBJA"DLj^EcKC;KnKBa ,(p>UnDz)&|Fz4~ׁPxoW))@I)[=s6(|RPjd~lhh|&'P$Q\^ =Bz x%*f$gN(+ t4*/ʐ5/"fd?l.H|Y(o og&#Noaf'$TbNIm j2'%9L)K 3Wжpv0 jB[C-脙o4q8t*tHPB(-%!M tvth\U lL^ * 8S eדw1PEGG\PBQLXP؁BD]Kag3>ttt |7A3:;XP@-!3烠oN7V5W3H g4Tw%:PU~æ./ߎ=~ቹ:kjYgݽp=; x4{ G&0}t GJASWJv?{3}sm U쥃O@SW7 9-sz0_WK0{y8h h"&]-nAfNCpGk. o?7> ŹzF%/R0!3!cKwROi՜V 7 QmNGCK:qwZrc+N=dM^f& LB@`lzQ4>>BikUQzl{#U2Vv1`{"5 "5 4]O|BLJBqY/npЅ5hbμtx5\lK漂[>wģ#LJ?>q|g[$yiPtB4;UR5@|>$i$RWfr(:6'ϥV S>RUv$ NVs- g҆ndt 'Js|-hP7੸6dt 'ڪ3NIѱB mֆj|w*]+|bF2TtbK,Pkk:NB+X&GuܾXMZk)Y+k9U_˳Ja[HŬucH\:f] >h^+EJl]Ԃ#1~/HWq)"JtWhBvI}sRsWtPݦK|NDRs:WZ>ᶤJJ(T=u9{/0[PDB1"szPDT- % +P,/{`B-JE-_ (_Z!*2_)pQb[ICrR|1PPtRJe_ tخ*%bqo׶ۨ+Hy+ז(f,CAޭGǨ̉__4R䥸Rfy9ϧP QV]s&'BǮ~ʼ]4 Ř`"=﬽wl$(XkMXҍ#ӨP Ia8ᣗ"{@LzgcSPXWSc[Rn脛;z&5B1.XƇzrB(57mP VgSa@%oT6nURO>ڐGo}I0t{C) \kn5wnނ9*2;z 0_끡/PJ+x]w9q j\8%:7?n JMO ?n.-oG5iJtbU\@%JDU7TCGL'WE:jγVBSqC= N(Ia_}e4̜|“*ܜN։p'3 Eهo[@LƭPiVFLEه/6"`Fm*͹|x'R ξ)U}H%L'FZS"}?\}D7Z# /w){ZwG&gHBnQ3|Ld Re !F#_dJAUk?" %c{M/y}q;oI7B!r>D>M-}k9-yqI(ak7 !,ۼJfF>!iuS'|/G8K }YVq٤eDLƭND>f`_g,6ZŲ-_iD>ozb4;>蠭'l+b2켂ƎgnV!158Z*o&v4Ve_4w=Vc`# t<,J:j)+ǹ'{`\qD>fcPw̸@w+y( )Do1D>foȓfL /|NRdѰcSD15HMzxX-%dܰ21]L1] I嚤H苫5/C4W01͊Ud Xep7syZEO cϾnqV}-yd'38淼2s-##5w|~%%ssԏOAPg}y~/?|HE'/ZzQUS(>Q qF{ү#E[|Uյ' G .)muETVkYJW8Q|KL,t4)z&N!1HK*6DoQMEcX|ZaeS7^Q.´5էQ)pԴ vf'jفGjrjvƯv -GJ:w-{ErJkB|H m59 ':~Σo;Bq;޾xtq&zpƥT4t xƇ* RB,j%\|Où3O #5 'i_5!k {7Lv5_feG2;NjvfBH-Q+k "ZrM(RYZ3n jB'  ѧgb_Љ[Қl(O v6S>% Us-VX32[ &&Fz* Ӯ:{ˆTDQht{x1}h on۳ՌNS#e / 21{Bf_+8+12f(Rqj>D %1yX82}n;HG, Dͻ|Ρi| wm^K>i8ƥju#ݍ RB}m\Ie:ч "F>s( o <\^3X$3>}nORεvG"2ܖ>\ L8jgNךsBuaIM[92` jJފ tg2 DkZ8Db\}. yd0, w6؏gzHWI]c+G(H0^:he \tzĦW4v  8Ƈ+SbC޽rw` 8}+ |ŧU5㽋m*J[x(wm]|ï. |^uwN2Jm{ v5޻h-jvͳn3ӨK7sL, (@QVnqfB9]x Q {sYjB1 J,bkO(EiݰebShyr(=>-jDN''uL`QȀ&F߽~rJwɢto_5<>EjO ޵y.Y׉Kw04>5E!B2;=5>㻗Nx9ZhE)1ڴcyv ~4kO;>ϻslMFkUI -6`ػD&fs[qVnqVbd=@T( y@}:?܌N#CJW>EMu~ug\[;yĦ7t>GΆo'k:'Di[9z_>dEߋGw.{:Zh("g_im@ 0%?"!]}ˎdF__K5;3!FQ*MQ}( o(i_[S}}HQ5q K-h5iKt5Tƅ8|I%(i邚:~:H-qh ]胢5Q# lśʬ! LȥJdȰ\20h0%4ˤF*M2T\jF\r=TXG8yZ?k %*;ۄߡ_շ#E~5^_vC~N&&J}/Б"=DkfŅ{MQѷMU󌆎NYͭPk}wOQRʺnGܸk)ᮧoDf,%*G-H*hLʹ'/:R$(8w$Ev1Vs#',.zԶ~tvۨp8lX-'.`.3Tf(:R$QZsSap.sw;49y-T7:R$?QD]qsQ(I L{ )`^7 @k|F y ?もj`zM} _ӃOzB9皖|ux|.b$_[U|t-4q.":& 0 /YjhGf/6PuɇOc߶ >R3|A0 Hi׋GJp2K>p!#1o9N`mG#%8eۼY%u-/8H-u%Yڝ|S7oȉªƮq>o|0Dlk-5w 8SVū7Jq4yy}YNґW󵳒>y ;d6FNp:JКk2΄l!?+[gޘʆA )1{[5;I)9JRH'8B%Zw~K8l$5+0-S7 S' T0/;#u9E|VWHluuUs"T\T6u+ V(Q"`J9`:Qg'pϳL%fp :q,9I8 L%:T%*TBkVuBQ`|vi] m 4;"3*W ꄬgȉKJq*XƁ/t"PM)++XZywWR[FQ8P}ԻW#s5_,X ?"!~~`yکvoА38ˬ\otJq Pn~4rJ̨^)` Z&[z7tT)+nDK9S*ATn{/?nGLB?.Ͻg.nΨ0t}eG/)f>vk3:~ʹ+ T)dPy)1ۭ tCWZcU\ی*Ł \[lF 9 TJ3[}1)R P)1vR(#*ďa(_Q(9J -%*YT{h5 z|YMQ&QsW _r}/QX}&cPJu .vucAbS&&`Wc팸TJ|7|Z{G0E/? #~9C5 ET l)SG7v!`W0|Zu3tPjrK[E%]SBAΦj>Bks5)*J_ujjK)j8xM-|1M|IeAj~omE9o*Uz["KEΖjnIvBdVKU>fPR yE?Q4 =V/'h{T]q6+sݕ2KX,^)I`"EgcVsuWK\('k=fU 6n U7Ӏ;b[5 ~j!?E\V*z҇'I4?,@MV*J}GK发O;tx\d؎9Rp}0>kp Uxjl(3nWQsF0E_aG0 j mϿX_Z$=4X>5R)+֚9|I;|BIe}֮OEB[`dBvqM?S,3>@7`r9㧸xOhm6xu ~X a||ד|kQ UYgi3_?@A,L;.u*$&+# ")nht^BLj#Aֆ"<(BbJ7{\ȹ[3}f7\w7BLJbB7 ⣀OSė9KQQ;}|F*0>ge~6xE!gުjG>fPu+3p [L)~Zv?%])E>Lym镤=팵> 5sܹܲG-=O1O{Z^<Jh|lS\"`[n89~2xMHBc4Ίq1$=fmDvj$FDkBT*1$;5Zk*E4"auđ:J6~>vc^qYW+CIPP7G=@fJHjA8x\w号1Rm儾bR^!1sʚ:L -@u6\ 7T۞Ww (Gׅ~% p7V~.0zu_#]^_xB|SSBZ8"1>jFBik5m5sRŀ™)،P(Ȟ1u0s4sP+jp _N.mhg b+Y V{Civ` -6s^RZ8{+o Y`gsyލPog ]7s^RLY^'Qh ſeE$z4 )7sRLڸ#3P@@NP$FHe&Fm wl8z[꘦G@t stzA~TGj)v&Ǥ@TqIoJȊ^}[?T?!͂pVDL[ig>H<EZnY#~BR_cYn K(;A!fJT &r`w4UDž "!!Pr J*w ImUNp?S\Z c[<1"!Bw >Z]t%#!5q%udbfaCHjpbTf&F{ؙj)IHBcEbt I*nF5+)*LGy7Db>@ 'kEZ^NԬ /Gs]9" (NNU‰IU?ZN 'Nt7Db-) T 9q|8!PPӳtPHj]sROMABЁ%%/$>rWSuNwJHjli(![Z IjVIq9"&8mLkpZPR41؅NV1 J 崸0;bTQiCRN˥^ R,zŅqv'K6 UHwƦ埂6T<_XtZ!8%ENʤT6N?CHx>?5TIL"`RNČ63S F<a5Vd$FÉW`d)QY*<>}T_OJ r5=#ޫ}38>#8C2O3=k_t'^RF'kL`juX촸p98qHIȫbSEvyzloScvfbXBJNE'rJ&3T{8SkC9%3r8qH䔵OzD'JkvGS{{gw?-#%Fx2RÉb$(NVIʅQETˈ!7:Ӎ,NT>qBH9".}Lуx!6oOo=nQ^ z8Z?"{PRzG (/P9NP-s,< 0O@#ޑ:z &9!:xmIymW਄O&~ۃG=&w0IO v1V| %OVIʙ~:whb:8 K"f&z[+ׯ 5d*'@ ʟ * uFSpj/ZQV2Rb,<ɈUNo τD]]\X  עBΜ=Γ~iA*-#k/_ߦ׷tQKNϦ-_^r6B PdXφ^J"ܧ3QwTL@91KrIIB- +=O4?S׏"!S+QF'`T[oanrΩJ&."#WSP{؟ COYp[y%5!Sשl=; kJn8de kd@(뛿v&82>\X^sgܩ/TqBZoBrZ|de@<?` ']a1ɤ6/+TLK{8Fr9imoj$dSعY\mS+S7V0qmXZp;5E'/l8Is/% 9<1;U| ńӳafh=7.?rD>&)5Qyխi -]`TUL/σqji}s/9i(q-8u Q 8*ck|?K/ija2X [zC2im)J'kcpN28Q>:q}3 O?FL$Zir (kJ$oc\IN-bm8G%.ehipa:L}~*/NOb\'~>'4SU|W XEj1!*,ZF[hz>pb#}&*JuOX7Q;*%|\)SWL34:;skEiA^Rz0H`hF@Hqi+,K(JXA@!$RPޜsOyswgߓ.U+VkeL+HZWe%zi59Ǐ=XwoXR3*$:9 z75\[oc>,3!LU!8i(H =;m:\T2\*};21,s}+>=Ǒ[yy%d zktRT~.moeU><]ZUb n02x:͌Ud[o۬zO)U4ΎYH,,s=Zm,2sl^wo5Oz '[oz:UUT!Q >kl ȪՖXq*7ٯ Q! Vo=_DJ^P=|ɔܒzC&gSwCp+KrS)d_G<`R߆icVOU%PMmܮߊIzBij{;<6۾ꓬ䘈[lLƬkmv?!=Wݴ9 W_k KUs}/y ]쭱(56=N JEĂG9 eG_ k#J=/O$y:,L Umg3m[:U?Hw4Z.,w 'Imāx`J5ŏ𸳺?(J۴s`VೂOH꒳nLRF^Iꦁ{593\ZZq ,1f&]uCd?/KVX#uœB| `֩S76 &Zc O3߳fjVB,"`ĚKAFUXJH( C; EWJBVI kI B)qЪ3bk# !E4i|tAlon.Jq ;`ETw ݴ`֨W;kf}:MJ\TVU؂` MN3{.jBl!=0bNO "H-UeEӓbufgT~2*`' H*l.9yB"cRKO V}`Tkzvn,!^,0 mǿ$Zb.zDj{J-NM "y8]G+J7ݡL0{ Y@P+QSJ:"'Sj[;Cc3s E2}_\tu67pr;"P2P:%% ʄl4X&$[dA ̬ ^7C"'eUV75ut3FF'gf%?/">3Sc#Cޞʲ촤!7\ fl%1!cL}S7ockhuBcx{'k#e-m{zit0"6992iIgxpAhkyXJ s3%EEdbQ /n6]?RT526 nPDGtsv Z&4UdŅ>.nU֭`yxD y-2Ëx~^tLOI)R222@9?dfd>JI)>&P@_ 2wcpF[C"pstiWi#BKtwqv'MM0HjJ2R8ϑ*}Q֬3(&!-+}Zcř[]ݯz]!\ }못3=5TSsBݮҗZ'1P-%#:g`9wgnimpY.\Z[LϟFii)+@#PcG[CUXh :)+ t hfA u?٩( 9 3)VT秈Mąնng3ɓ}x߼5* 6!^;$U5豾Fq;E]Zv?^CߢzոzY|fMh$CT1Ao /aOtjlI{ʹ"yY3MIѭ)}~Z-y6Z4""CR;pV ^&bH·)CE`,dh6уddv@x΁wsz=M4>K endstream endobj 67 0 obj <> endobj 66 0 obj <> endobj 8 0 obj <> endobj 256 0 obj [/View/Design] endobj 257 0 obj <>>> endobj 7 0 obj <> endobj 5 0 obj <> endobj 6 0 obj <> endobj 261 0 obj <> endobj 262 0 obj <>stream H|UkTSW%70@.((,B(~TbRETEVTkQkt;91fݵ:qޗlA"B?p,kSӣ:0]f RL Dӿb/vzN,&u9]U|U5(9ooUŅ4\LaAmV9:}AɅffr!8&O/*q2۹B.3kw6P~>ܯis5]鬖3O:6ñlU1`efQԺl^[tM?"JR  |IA@D 1A@ >X`,6(l*mN\,hDud1 FUv9HuɠVWYg(0R0M!}-0G <eZw*I (h|GP %a2,$JP[ޥ lJIzXoՆ>zAݘШJʀX c{ߌ+0 {}f`TxpIE;]#aQO&$L͂2i}ck}7s7'>!uw@!B4DoGr6E!,,9 y? ̄CI bVOpNۜռA;P > hOii<){#@ _z qf>{' }ʏFRfmXH 552CqQ"-̯5`%a* 8߂R=-4֟'ԉ}(I0.i2i+ /J W5g/-vL hUqPWeZ>?\<`Il޽鸊A]];} s'OZ.\`7_Ľ\2>\GJJ|UK`WzE apJh(ϛ!P;{jGymz6:=jr{!OyLn G Rax#.'4ySkEU_8,FPYqU⩥&>j*s<>mxs)G~;80'1?0)JD2䎒QvHC" *(}sc1ynKFNc[xJrq-a-.MUɉӉSC=c1nɛNx0'nदcNf?#rcBZJb6fn?EvMG~k-xSڍ+7URI|<2|4 Kq~̪Ék,Vsž5k(J\y` x}]҂ D>~Qd{6_7WhWABT! RSTyJF-j$\Hu,nf[0K@2e3bϺ7 ]Krf9$'t D[|B9r<З$c)d[ET0nZ]Q0?h1DlVs'iUw4qȱMo~W*h7ၝuh-H͌XQ,<$Z 2~@蠱MtՆ(=XXߠp:s`/)+fGgJ(J"eJK KPY)- "!?!Wcf,ϓ+Zj '͓QU&WwW`};_ôfGN5m?~xM=OObHM?mϴ?%8~xM^1SnAk> endobj 263 0 obj <>stream H|mLSgp%ĵl Np! Qa!D^JA[-(@CE eU2"F䥐`f6öd4<{tƟ^'&xUS`"B=1r3 "7c$SXVn0 NȪ )iak`RObdUA< W$D ݳ7B^= j,@78gj+zO?2Z^ ]4#.R.+*}iY<,1hy1 5O` o!]My $MƐGH >01A΄w)hBh [|o>ECp_")6ɐG 73|6pCb~DǥAYߍΑ'clI(旺H e4KOV銵S7;%!=;YV@zP竗N%?|SS#['I|3s]^x]5lP.og07:J'sӦVkc M2QH!g'g84L֗ x͎B3DR33R/NB( W xu_Re~r_ܺ߯U֜ܯ}MՁ0Y @$D[*A֐ XeE(#`QnG]( # V0"Žak zY{T25s鳿V<93$\H2U1R8 dY hRRv$X~Yo-sebgO]qu(~hA]2z}]O兯H璏vVӮwD>H/RdS$9 ym%(V?[! C9g0`9a%2c~e83;]-|ת3|r;?IVpg E-.E :f-=VwN3Ň7ݪ^R\ [˾!3J6sy c;Q-:&m0u>W endstream endobj 258 0 obj [264 0 R] endobj 259 0 obj <>stream H\_k0|{\Jb0` b_̕Pq;ZIu`0V{W2 ڨpWzwn&VU?bp~.ak^mw7~pD@@]!zk?"mulcߍ!>FMg+"D<5TM<5Cų.}BHQyR#HϤ3DjH.$RP\&$$PRX:TRC{P> endobj 265 0 obj <> endobj 266 0 obj <> endobj 267 0 obj <>stream Hj0 endstream endobj 268 0 obj <>stream H|UmPTUݽk܄݈5`c]W-#q%0pRbԅ]dvqw 0YPp[+]m?">#4ǚƴilzv֩Gμs9y3sIBA$X|E~Rvb4mLa$E⤸f/M%b<䉓kU$!)3JZG9UArfVd^f2[gV7:Ŵ pMyQ:u14;ebf7:ͦd1Lpvlw]bQ*9Wj+^̝ML8DYjFL5xn4ˍ-+-vDU\*%9q!bP)UZvّ4/,M،ID2B!tbb)EdDpd!v+Cډ9#Slx-_Qw@\-[P!ğǧcP)*r-z41h3 5~yP~WP&신MP~|?X- rjx+P!ENmSoV~ *c:j=:aϮz~zs WPg Ln@Wϵ;@'OKQXeV aePuJhG~X8;a0.^fxRhFkH! &2| je_7K8&_Ș@׃_# xImz쉅xiQz:ٷ a \CwMwz}].R|5렉> sQh[`QK5HQ2W_Zo> endobj 17 0 obj <> endobj 18 0 obj <> endobj 19 0 obj <> endobj 20 0 obj <> endobj 13 0 obj <> endobj 269 0 obj <> endobj 270 0 obj <>stream %!PS-Adobe-3.0 %%Creator: Adobe Illustrator(R) 14.0 %%AI8_CreatorVersion: 14.0.0 %%For: (GV) () %%Title: (statusdelivery.ai) %%CreationDate: 1/28/2010 4:32 PM %%Canvassize: 16383 %%BoundingBox: 49 45 608 585 %%HiResBoundingBox: 49.6997 45.1299 607.4277 584.7803 %%DocumentProcessColors: Cyan Magenta Yellow Black %AI5_FileFormat 10.0 %AI12_BuildNumber: 367 %AI3_ColorUsage: Color %AI7_ImageSettings: 0 %%CMYKProcessColor: 1 1 1 1 ([Registration]) %AI3_Cropmarks: 0 0 792 612 %AI3_TemplateBox: 396.5 305.5 396.5 305.5 %AI3_TileBox: 0 0 792 612 %AI3_DocumentPreview: None %AI5_ArtSize: 14400 14400 %AI5_RulerUnits: 2 %AI9_ColorModel: 2 %AI5_ArtFlags: 0 0 0 1 0 0 1 0 0 %AI5_TargetResolution: 800 %AI5_NumLayers: 1 %AI9_OpenToView: -86 770 1 1166 654 18 1 0 69 109 0 0 0 0 1 0 1 1 0 %AI5_OpenViewLayers: 7 %%PageOrigin:0 0 %AI7_GridSettings: 72 8 72 8 1 0 0.8 0.8 0.8 0.9 0.9 0.9 %AI9_Flatten: 1 %AI12_CMSettings: 00.MS %%EndComments endstream endobj 271 0 obj <>stream %%BoundingBox: 49 45 608 585 %%HiResBoundingBox: 49.6997 45.1299 607.4277 584.7803 %AI7_Thumbnail: 128 124 8 %%BeginData: 15771 Hex Bytes %0000330000660000990000CC0033000033330033660033990033CC0033FF %0066000066330066660066990066CC0066FF009900009933009966009999 %0099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF3300FF66 %00FF9900FFCC3300003300333300663300993300CC3300FF333300333333 %3333663333993333CC3333FF3366003366333366663366993366CC3366FF %3399003399333399663399993399CC3399FF33CC0033CC3333CC6633CC99 %33CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF660000660033 %6600666600996600CC6600FF6633006633336633666633996633CC6633FF %6666006666336666666666996666CC6666FF669900669933669966669999 %6699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066FF33 %66FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF %9933009933339933669933999933CC9933FF996600996633996666996699 %9966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC33 %99CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFF %CC0000CC0033CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399 %CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933 %CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFF %CCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0033FF0066FF0099FF00CC %FF3300FF3333FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699 %FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33 %FFCC66FFCC99FFCCCCFFCCFFFFFF33FFFF66FFFF99FFFFCC110000001100 %000011111111220000002200000022222222440000004400000044444444 %550000005500000055555555770000007700000077777777880000008800 %000088888888AA000000AA000000AAAAAAAABB000000BB000000BBBBBBBB %DD000000DD000000DDDDDDDDEE000000EE000000EEEEEEEE0000000000FF %00FF0000FFFFFF0000FF00FFFFFF00FFFFFF %524C45FD0EFFA8FD2BFFA8FD057DA8FD18FFCAFD09FFFD07A8FFFD13A8FD %10FF52FD29FF7753527D7DA87D7D527752FD16FFCACAFD07FFFD08A8527D %A8FFA8FFA8FFA8FFA8FFA8FFFD06A87DFD0EFFA87DFD27FFA8527DA8FFA8 %CFA8FFA8FFA87D52A8FD14FFCAFD08FFA8A8FFA8FFAEFF52277D7D527D7D %7D52FD067DFFA8FFAEFFA8A8FD0EFFA852FD26FFA828FD0DA8527DFD13FF %CAA8FD07FFA8A8A8FFA8FFA8A827527D2727277D27FD0452217DA8FFA8FF %A8FF7DFD0EFFA8A8A8FD24FFA852CFA8CFA8FFA8CFA8FFA8CFA8FFA8FF52 %A8FD12FFCFFD08FFA8A8FFA8FFAEFF7D5227A82752F85252A84B52765227 %FFA8FFAEFFA8A8FD0BFFA8CAA1CFFFFFA8A8A8FD21FF52FD11A852FD12FF %CACAFD07FFA8AEA8FFA8FFAEA7277DA87D527D7D7DA87D52A8527DA8FFA8 %FFA8FF7DFD0DFFBC9AC299C3FFA8FD20FF7D7DA8CAA8FFA8CAA8FFA8CAA8 %FFA8CAA8FFA87D52FD11FFCAFD08FFA8A8FFA8FFA8FFA8FD0FFFA8FFA8FF %A8A8A8FD0AFFA8CF99C29999A0FFA8A8FD1FFF527DFD12A852A8FD10FFCA %CAFD07FFA8A8A8FFA8FF522727A8A8AEFD07A8FFA8A87DFFA8FFA8FF7DFD %0AFFA8FF99C3C3C9C3C9C9FFA8FD1EFFA852FFA8A8A8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FF77A8FD10FFCAFD08FFA8A8FFAEFFFF52272727AE52A852 %7D527DFD0452277DFFFFA8FFAEA8CBFD0AFFA8BB93BB9A9999C2A8A9FD1E %FF7D7DA853287DA8A8A2A8A8A8A2A8A1FD06A87D77FD19FFA8A8A8FFA8FF %27275252A8527D27277D52522727275227FFA8FFA8FF7DFD0BFFCACAC9A1 %C9C9C9CAAFFD1FFF7D7DCA2877A87D287D52527D5252537D7D535352A8A8 %52A8FD17FFA87DA8FFA8FFFF5252FF27FD055227FD0752A8FFAEFFA8A8FD %0BFFA8FD06FFA9A8FD1FFF52A8A8A22852A8527D5228A8527D527D525352 %52A1CA2727527D5252527D527D5252527D527D527D527D527D527D522727 %FFA8A8A8FFFD04A87DA87DFFFD05A8AEA8A8A8AEA8AEA8A87DFD0EFFA8A8 %A8FFA8FD20FF7D7DFF7D7D28A8287D2852537D537D52537D7E287DA85252 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF7D7DFD1AA8A1A1 %FD0EFFA852FD23FF7D7DA87D52A8A87D77A27D7D7DA2A85353CA537DA8A7 %52FD10FFCFCFFD07FF7DFD04A8CAA8FFA8CFA8CAA8CAA8CFA8CAA8CFA8CA %A8A8A8CAA8A87DFD0EFFA87DFD23FFA852FFA8FFA8FFA8FFA8FFA8FFA8FF %CFFFA8FFCAFF52A8FD19FFA8A8FFA8FFA87D767D7DA87DA8A8A8767DA87D %52FFA8FFA8CAA8FFA8A1FD0FFF52FD24FF527DFD12A852A8FD12FFA8A8A2 %A8A87DFF52A17D7D27A827F821FD04274B2727274BF827FD08A87DFD0EFF %A852A8FD23FF7D7DA8CFA8FFA8CFA8FFA8CFA8FFA8CFA8FFA87D52FD11FF %CFFF7DA87DFD04A87D7DA87D7DA852275227525227277D5252275227FFA8 %FFA8CFA8FFA8A8FD0EFF7D057DFD24FF52FD11A852FD14FFA8FD06FF7DA8 %A8CFFD18A87DFD0EFFA87DFD25FFA852A8A8CAA8FFA8CAA8FFA8CAA8FFA8 %CF52A8FD12FFCAFF7DFD06FFA8A8CFA8FFA8CFA8FFA8CFA8FFA8CAA8FFA8 %CFA8FFA8CAA8FFA8CAA8A8FD36FFA827FD0DA8527DFD14FFCAA8A8FD05FF %7DFD1BA87DFD0CFFA0C9FD2AFF527DA8FFA8FFA8FFA8FFA87D52A8FD14FF %CAFF7DFD06FFA8A8FFA8FFA8CFA8FFA8CFA8FFA8CFA8FFA8CFA8FFA8CFA8 %FFA8FFA8A8FD0BFFA7C39AC29AC29AFD27FF7D28527D7DA87D7D522E52FD %18FFA8A8FD05FF7DFD04A8CAA8CAA8CAA8FFA8FFA8A8A8CFA8A8A8FFFD06 %A87DFD0CFFA0C2C2C299C2FD27FF7D52A8FD057DA85252FD16FFCFFF7DFD %06FFA8A8FFA8FFA87D7D7DA8A852A8767D7DA87D7D7D527DFFA8A8A8FFA8 %A1FD09FF7DA884A17CA87DA87DA87EA8A8FD22FF5252FD09FF5252FD17FF %7DA8FFA8FFA8FF7D7DA87D52A8F827275227F87D27F8272727F84CF82727 %2752A8A8A87DFD08FFA87D527D527D527D527D527D527DFD18FFA8FD08FF %5252FD0BFF7D52FD08FFA8FD0DFFA8FFA87DA8A87D7D52A85277A1522752 %275227A8525227FD05524B2727A7A8FFA8A8FD08FFA8527D5252527D5252 %527D52527DFD15FF7D7D527D5253527DA8FF525252FD0DFF525252FFFFA2 %527D527D527D7DFD0AFF7DA87DA8FFFFFF7DA8A8FFFD0CA8A1A8A1FD09A8 %7DFD08FFA853527D527D527D527D527D527DFD14FF527D7DA8A8FFA8A852 %7D7D5327A8FD0EFF2752A85252A8A8CAA8A87D7D52A8FD06FFCFFFA8FFA8 %FD04FFA8A8CFA8FFA8CFA8FFA8CFA8FFA8CFA8FFA8CFA8FFA8CFA8FFA8CA %A8A8FD09FFFD0D7DFD12FFA8527DA8CAFD05A8CAA85252FD11FF52527DCF %FD05A8CAA8A8287DFD05FFCFCA7DA8A8A8FFFFFF7DFD1BA87DFD08FFA8FD %0C7DA8FD12FF52A8A8FFA8CFA8FFA8CFA8FFA87D7DFD0FFF7D7DA8FFA8FF %A8CFA8FFA8CFA8FF52A8FD06FFA8FF7DA8FFFFFFA8A8FFA8FFA8CFA8FFA8 %CFA8FFA8CFA8FFA8CFA8FFA8CFA8FFA8FFA8A8FD09FF527D527D527D527D %527D527D7DFD11FF52A8CFFD0AA8CAA8527DFD0DFFA852A8FFFD0CA852FD %04FFCFCA7DA8A8A8FFFFFF7DFD08A8CAFD12A87DFFFF7DA87DA8A2A8A8A8 %7DA27DA87DA27DA87DA27DA8A2A87DA8A2A87DFD09FF7D7D777DA8FFA8FF %A8CAA8FFA8FFA8FFA853FD0DFF7D537D7DFFA8FFA8FFA8FFA8FFCAFFCF7D %7DFD05FF7DFF7DA8FFFFFFA8A7FFA8FFA8CAFD05A8CAA8FFA8CAA8FFA8CA %A8FFA8CAA8FFA8A1FFFD06A8525252FFA8FFA8FFA8AEA8FFA8FFA8FFFD06 %A87DFD08FF7D7D2828777D7D537DA87D52A8527D52A852A8FD0BFFAF527D %28527DA8777D7DA852FD067D52A8FD04FFFD04A8FFFFFF7DA8A8A87DA827 %27275252FD11A87DFFFFA8FFA8FFA82752527DA87D7D7DA87D527DA8527D %52A8FD04FFA8A8FD08FF52A82853527D525228A85277285252287DA77DFD %0BFF7D7D7D2E527D53532853A8535252525328A87DA8FFFFCFFF7DFF7DA8 %A87DA8FD047D527D4C272727A8A8CFA8FFA8CFA8FFA8CFA8FFA8CFA8FFA8 %A8FFA8FFA8FFA8A827272752525227527D5252522752272727FFA8FFA8FF %2752A1A87DA87DA8277DA828287D525352525253527D2853287D7D7DFD0B %FF7D7D7D285377525253287D2877525352527D7D7DFD04FFA8FFA8A8FF7D %FF7DFD05A8527D7D7D52FD11A87DFFFFA8FFA8FFA8272727FD045227FD04 %5227274B2752FFFFA8FFFF5227FD057D772852FFA8CFCFA8A8CAFD0AA877 %FD0BFF7DA1FD05A8FFFD05A8CFA2CAA8CA7DA8FFFFCAFF7DFF7DCBA8A8FF %A8A8CFA8FFA8FFA8FFA8FFA8FFA8CAA8FFA8CAA8FFA8CAA8FFA8CAA8A8FF %A8FFA8FFA8FF52527DA852A87D7D527D527D7D5252A852FFA8FFA8FF52FD %07FF7D77A8CF7D53A8FFA8CFA8FFA8CFA8FFA8A87D7DFD0BFF7D7DCF7D52 %52A87DA8A8A87DA8A8A87DA8A87D7DFD04FFA8FFA8A8FF7DFF7DFD1BA87D %FFA87DFD04A8FFA8A8A8FFA8A8A8FFA8FFA8A8A8CFA8FFA8A8A7A87D7DFD %08FF52FFA82E527E537E7D7D537D7DA27DA8A87D7DFD0BFF7D7DCFA8287D %525353534C77537D52537DFF52A8FFFFCAFF7DFFA8FFFFA8FFA8A8FFA8FF %A8CFA8FFA8CFA8FFA8CFA8FFA8CFA8FFA8CFA8FFA8FFA8A8FFA17D527D76 %7D527D767D527D522727527D7D527D767D527D767D527D7DFD08FF7D7DFF %7752525352284C535252285253FF52A8FD0CFF52A87D53287D525328527D %772853287DA852A8FD04FFA8FF7D7DFF77FF7DFD04A8CFA8A8A8CFA8A8A8 %CFA8A8A8CFA8A8A8CFA8A8A8CFA8A87DFD0EFF5227A8FD15FF7D7DA87D52 %7D527D52537DA8287D527DA77DFD0DFF7D7DA852A8A87E7DA253A8FD047D %A87D7DFFFFFFCFFF7DFFA8FFFFA8FFA87DA87DA87DA87DA87DA87DA87DA8 %7DA87DA87DA87DA87DA87DA87D7DFD0EFFA852FD17FF527DFD05A8CFFD07 %A8527DFD0DFFA852A8FFA8CAA8CAA8FFA8A8A8CFA8A84CFD06FF7DA87D7D %A852FFA8FD1C7DFD0EFF7D7DFD18FF52A8CFFFA8FFA8FFA8FFA8FFA87D7D %FD0FFF7D52A8FFA8CFA8FFA8CFA8FFA8A852A8FD06FFA8FFA8FFA8FD2DFF %A852FD18FFA8527DA8CAA8CAA8A8A8FFA85252FD11FF7D527DFFA8A8A8CA %FD04A852A8FD07FF7DFD04A87DFD2CFF7D7DFD1AFF52527DFD05A85252A8 %FD13FFA85252FD05A87D5252FD09FFA8FF7DFFA8A8FD2CFFA252FD1AFF52 %277D527D527D525227FD15FF7D7D527D527D527D52A8FD07FFCFCA7DFD04 %A87DFD2CFF7D7DFD19FF777DFD08FFA952A8FD12FF7DFD09FFA8A8FD08FF %A8FF7DA9A8A8FFFFA8A8A8FFFFFFA8FFFFFFA8FFFFFFA8A9A8A8A8A9A8A8 %A8A9FD04A8FD0EFFA852FD18FF527DFD0AFFA852A8FD10FF7DFD0BFF7DA8 %FD05FFCFCAFD04A8FFA8FFA8A8A87D527D277DA85252AEA85276FFA8FFA8 %FFA8FFA8FFA8FFA8A87DA8FD0DFF7D7DFD16FF7D7D7DFD0CFFA8527DA8FD %0BFFA8A87DFD0DFFA8A8A8FD05FF7DFF7DA8A87DFFFFA8FF525227522752 %277DFF5200A8527D7D7D52A87D7D527D52A8FFA8A8FD0DFFA852FD15FFA8 %2727FD0EFF7D057DFD0BFFA852A8FD0EFF7D7DFD05FFFD04A8FF5252A8A8 %A852FD0427F8FFFFFF7D27272752FD042752F8524B2727FFA7A8FD0DFF7D %7DFD11FFA8FD047D277DFD0EFFA85252FD047DFD07FFA8A8FD10FFA8FFFF %FFCFFF7DFF7DA8FFA852A8A8FF5252277D2752277DFFA85227F852272752 %27525227522752FFA8A8FD0DFFA852FD0FFFA853527D7DA87DA852527DFD %0BFF7D527DA87DA87D7D527DFD1DFFA8FFA8A8FD04FFA8AE7D7D7DA87DA8 %5252FF7D527D7D767D7DA77DA87D7D7DA87DFFA8A8FD0DFF7D7DFD0EFFA8 %537DFFA8FFA8CFA8FFA8527DFD09FF7D52A8FFA8CFA8FFA8FF767DFD1AFF %CAFF7DFF7DA8FD04FF7DA87DFD07A87DFD07A87DA8A7FD05A87D7DA8FD0A %FFA8A8A8FFA8FFA8FD0CFF527DFFFD07A8FFA8527DFD07FF7D52A8A8A8CF %A8CFA8CAA8FF7652FD1AFFCAA8FFA8A8FFFFFFA8A8FD1B7DFD0AFFA8FD07 %FFA8FD0AFF7D7DFF7D7DA8FFA8A8A8CAA8FF7D7DFD07FF7DA8A24C537DA8 %7D7EA27D7EFF52A8FD18FFCAFF7DFFA8FD2AFFA8A8CAFD07FFA8FD09FF52 %A8A8532852525328A85253A2FF52A8FD05FF7D52FF7D5228525228287D28 %53A8A852FD1AFFA8FF7D7DFFFFFFFD0DA8FFA8FFA8FFFD0AA8A1FD0AFFA0 %C2C3FD07FFA8FD07FFA27DA8CF2852525328287D5328A8CA7D52FD05FF53 %A8A8A828535253525352527DFFA87DA8FD0DFFA8FD09FFCFFF7DFFA8FD05 %FF7DFFA8AEA8FFA8FFA87D7DA8FD04527D52A8FFA8FFA8AEA8FFA8A8A8FD %08FFA8C399C2A0C29AC9A0C2CF7D52A87DA87DA87D5276CFA87D53A8527E %527D537DA8CA7D7DFD04FFA87DFD07A8FFA8A8A8FFA8CA52A8FD08FF7DA8 %7DA87DA8FD0BFF7DA87D7DFFFFFFCAA8A8FFA8FFA8FFA8FFF84C27F85227 %5252F8A8A8FFA8FFA8FFA8FF7DA8FD09FFA0C399C3A0C2C3C9BCCA5252FD %057D5227A8CAA877A8A8FFA8FFA8FFFD04A852FD05FF7DCAA853777D7D7D %537D7DA853A8A87D7DFD19FFA8FFFD04A852A8A8FFAEFFA8FFAEFFA85227 %2727FF27FF272752FFA8FFAEFFA8FFFFA8A8FD08FFA8C399C39AC2A0C299 %C2A8A8FD07FF7D52FF5252525352284C7753284CCF7D77FD05FF7DA8A852 %7D4C7727525253282877FF52A8FD19FF7DA8FF7DA87D52A8A8A8FFA8FFA8 %FFA8A8FD04277D277D272727A8FFA8FFA8FFA8FFA8A8FD09FFA8FD05FFCA %FD0BFFA87DA87E28535253285353534C53A87D7DFD05FF52A8A853527D53 %5328A85253527DA87DA8FD19FFA8FD07FFA8FFAEFFFFFFA8FFA87D7DA87D %A87DA87DFF52FFFFFFA8FFFFFFAFA8A8FD09FFA8A8FD07FFA8FD09FF52A8 %FD077DA8527D53A852A8FD05FFA852FF7DFD0AA87D52FD18FFCFCA7DA8FD %05FFA8FD0A7DA87DA87DA87DA87DA8FD087D52A8FD0BFFA8FD04FFA8A8FD %0AFFA852FFCFFFA8FFCFFFA8FFCAFF777DFD07FF7D7DFFA8FFA8FFA8FFA8 %FFA8FF52A8FD1AFFA8FD07FF7D7D7DA87D7D7DA87D7D7DA87D7D7DA87D7D %7DA87D7D7DA87D7D7DA8FD0DFFA87D52FFA8FD0CFF7D52FFFD07A8FF7D52 %A8FD07FFA8527DFFFD05A8CAA8A8527DFD19FFCFCFA8A8FD30FF7D7DFD0F %FF7D52A8A8FFA8FFA8A85252FD0AFFA87D7DA8A8FFA8FFA8A8527DFD1CFF %7DFD07FFA8FFA8A8A8FFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8A8A8FD0DFFA852FD10FFA8527D527D527D270552FD0BFFA8FD04527D52 %52277DFD1DFFA8A8FD05FFA8A8A8AFA87D2752A8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8A8A8FF7DA8FD0DFF7D7DFD12FFA97D277DA8FF7D527DFD09 %FFA8A8FFFFA8A87DA8A8FFA8A8FD1AFFCFFF7DFD07FFA8FFAFFF52522752 %527DA8FD0B7D52A8AEFFFFA8A8FD0DFFA852FD14FF52A8FD04FF527DFD07 %FFA8A2A8FD09FF7DA8FD1BFFA852FF52A87D28A8A8A8FFFF522752272727 %52522727272127277D277D272752FFA8FFA8A8FD0DFF7D7DFD14FF52FD06 %FF527DFD05FFA8A8FD0CFFA8A8FD18FFCAFFFFA8FFA8FFA852FFA8FFA8FF %52FD042752277D525227277D5227275252277DFFFFA8A8A8FD0DFFA252FD %13FFA853A8FD05FFA8527DFFFFFF7D7DA8FFFFFFFD07A8FFFFFF7D7DA8FD %17FFCAFD07FFA8A8A8FFA8A8527D7DA87DA852A87D7D52A8527D527D7DA8 %7DFFA8FFA8A8FD0DFF7D7DFD14FF52FD08FF527DFFFFA87DFD04FFA9A8FF %7DFF7DFD05FF7DFD17FFCAFD09FF7DA87DFD07A8A7FD0DA87DA87D7DA8FD %0DFFA852FD14FF7DA8FD08FF527DFD2BFFCFFD08FFA87DA87DA87DA87DA8 %7DA87DA87DA87DA87DA87DA87DA87DA87DA87DFD0EFF7D7DFD14FF52FD0A %FF527DFD29FFCFFD33FFA852FD13FF7D2752FD07FFA8FFFF527DFD29FFCF %FD32FF7D7DFD14FF52FD0CFF527DFD5BFFA852FD11FFA8A8FD047DA8FD09 %FFA85277A8FFFFA8FD047DA8FD1EFFCFFD32FF7D7DFD10FF7D5252A17DA1 %767D52FD09FFA82752FF527D7DA17D7D52527DFD4FFFA252FD0FFF527DA8 %CAA8CAA8CAA87D52A8FD07FFA87D7D527DA8CFA8A8A8FFA15252FD1AFFCF %A8FD32FF7D7DFD0EFF527DA8FFA8FFA8FFA8FFA8A852FD09FF52A8A8FFA8 %FFA8FFA8FFA87D7DFD4DFFA852FD0DFF7D52A8CF7D7D7DFD04A8CFA87D52 %FD07FF52A8A8A853A8A8A87DFD04A8527DFD18FFCFCAFD32FF7D7DFD0DFF %7DA8FFA8535253525352527DFFA852A2FD05FF7D7DA8FF7D527D53285352 %53A8FF7D7DFD4CFFA852FD0CFFA852FFA8A85352527D285228A8A8CF7D7D %FD05FF7D7DA8A8A8287D5252285228A8A8A852A8FD4BFF7D7DFD0CFF7D7D %A8FFCAA27DA87E7D77A8A8FFA8A852FD05FF52A8A8FFA87DA8A87D7D7DA8 %A8FFA87DA8FD17FFCAFD33FFA852FD0CFF7D7DFF7D77A1FD04A8CFA8A87D %A8A77DFD04FFA87DA8A8537DFD05A8CAA8A8A8CF52A8FD4BFF7D7DFD0CFF %7D7DA853527D52772853527D2853A8A852FD05FF52A8A852535377522877 %5353287ECF7D7DFD17FFCAFD33FFA252FD0CFFA852CF775252532828527D %285228A87D7DFD05FF7D7DCF5252527727285353285252FF52A8FD18FFCA %FD32FF7D7DFD0DFF7DA8A27DA87DA87D7EA27D7D7DA87DA8FD05FF7D7DA8 %7D7DA87D7E53A87D7D77A8A77DFD18FFCAFD33FFA852FD0DFF7D52A8FFA8 %CFA8FFA8A8A8FFCF7D52FD07FF52A8A8CAA8FFA8CFA8FFA8CFA8527DFD19 %FFCFFD32FF7D7DFD0EFF527DA8FFA8CAA8FFA8CAA8A852FD08FFA852A8A8 %CAA8FFA8CAA8FFA87D52FD4DFFA852FD0FFF527DA8FFA8A8A8FFA87D28A8 %FD09FFA852A1A8CAA8A8A8CFA85252FD1BFFCFFD32FF7D7DFD10FF7D7D7D %A8A8A87D52007DFD0CFF52527DA87D7D52527DFD4FFFA852FD11FFA8A852 %2728A87D7D27A8FD0AFFA87D7D7D527D7D7D77A8FD1CFFCFFD32FF7D7DFD %14FF28A8FFFFFFA852A8FD08FFA8A8FD08FFA8A8FD4EFFA252FD13FFA87D %A8FD04FFA827A8FD06FFA8A8FD0AFFA8A8FD19FFCFA8FD32FF7D7DFD14FF %52FD06FFCB52A8FD04FFA8A8FD0CFFA8A8FD4CFFA852FD13FFA87DA8FD06 %FFA852A8FFFF7D7DFD04FF7DA8A87DA87DA8FFFFFFA87DA8FD17FFCAFD32 %FF7D7DFD14FF52FD08FFA852A8FFA8A8FD05FFA8FFA8FFA8FD05FF7DFD17 %FFCFFD33FFA852FD13FFA852A8FD08FFA828A8FD5DFF7D7DFD13FFA8277D %FD09FFA852FD29FFCAFD33FFA852FD13FFA82753FD0AFFA827A8FD06FFA8 %FFA8FD52FF7D7DFD11FF7D527D777D527DA8FD08FFA852A8FFFFA87D527D %527D52A8FD1CFFCAFD33FFA852FD0FFF7D5252A7A8CAA8A852527DFD08FF %A805527D527DFD05A85252A8FD1BFFCAFD32FF7D7DFD0EFFA852A8FFA8FF %A8FFA8FFA87D7DFD07FFA87D277DA8FFA8FFA8FFA8FF7D52A8FD19FFCAFD %33FF5227A8FD0CFFA852A1FFA8A8A8FFA8CFA8CAA8527DFD07FF7D7DFD05 %A8CFA8FFA8CF7D52FD1AFFCFFD32FF5252A8FD0CFF7D7DFFA87D7DA853A8 %7DA8A8FFA852FD06FFA852A8FFA853A8A2537E77FFA8FF527DFD18FFCFFD %42FF52A8A8CF527D5352285253A8A8FF527DFD05FF7D76CAA8A8287E4C7D %27527DCAA8A852FD19FFCAFD30FFA8A87DA87DFD0BFF7D7DA8CFA87D527D %527D5253A8FFA8A852FD05FF52A8A8FFA8535277535328A8A8CFA87DA8FD %47FFA85252527D7D7D527D7DFD08FF7D7DFFA8A8A87EA2A8A8CAA8A8A8CA %A17DFD04FFA87DA8A8A8CAA27DFD07A8CF52A8FD17FFCFCFFD2DFF7D52A8 %A8FFA8FFA8CF7D7D7DFD07FF7D7DA8FFA87E287D77A87D7DA8CAA8A852FD %05FF52A8A8CFA853537D7DA877A8A8FFA87D7DFD45FF7D52FD0AA87D52FD %06FFA852CAA8CF772852287D5252CAA8A87D7DFD05FF7D7DA8A8A8285328 %5253527DA8A8FF52A8FD17FFCFA8FD2BFF7D77FFCAFFA8CFA8FFA8CFA8FF %CA7D7DFD05FFA87DA8FFA87D52A8287D527DA8FFA87D7DFD05FF7D7DA8FF %A8537D7D525328A8A8FFA87DFD18FFCFFD2BFFA852FFA87D53FD08A8CFA8 %52A8FD05FF527DFD06A87DA87DA8A8A852FD06FFA852CFA8A8A8CFA8A87D %FD04A87D52FD19FFCAFD2AFF52A1A8A82852527E525352CA52537EFF7D7D %FD06FF52CAA8FFA8CAA8FFA8CFA8FF527DFD07FF7D52FFA8CFA8FFA8CFA8 %FFA8A852FD19FFCFFD2AFFA852A8A87D282852525228527D53527DA8A852 %FD06FF7D52FD08A8CA5253FD09FF5252FFFD08A852A8FD44FFA852FFA8FF %53FD047D7E537E777EA8FFA87DA8FD06FFA8527DA8FFA8FFA8A8527DFD0B %FF7D52A8A8FFA8FFA87D52A8FD1AFFCAFD2AFF7D7DA8A8A8FFA8CFA8FFCF %FFA8FFCFA8A8FF52A8FD08FF527D527D527D52A8FD0DFFA85252527D5252 %7DFD47FFA852FFA8A8287D7DA87D7D7DA8527DA8CFA87DA8FD0BFFA8A8A8 %FD11FFA8A8A8FD1FFFCAFD2AFFA876A8A8A8534C52A8532753282828A8A8 %A852A8FD42FFCAFD2AFF52A8A8CF5253527752FD0553A8FFA87DFD42FFCA %FD2BFF7D76FFFD04A87DFD05A87DA8A87D52FD43FFCFFD2BFF52A8FFA8CF %A8FFA8CFA8FFA8CFA8A852FD70FF7D52A8CAFD0AA8527DFD43FFCFCAFD2C %FF7D52A8FFA8FFA8CFA8FFA8A852A8FD73FF7D5252A8A8FFA8A87D7D527E %FD76FF7D527D527D527D7DFD7AFFA8A8A8FD70FFFF %%EndData endstream endobj 272 0 obj <>stream 6IM"&\޳,P~8҄ʣm. !G&z=YwB}qRƭW^%-]۷f(N.o]Y?4[3;kCݪAF'Ab ӭ~+n>F:* }%UUGTqҗo u N۫B:zzy)F)QVzu)Þ%~64Ks?b7a*ۦ tۇΣFRT,<Ά*7P6 4qF.=[}7ε:Gj {f :qL.2FR/`0痹KZ᝻Lƥprɵtqg"f *˙ quT)_Yg;OtQ齸Aܵu])a:U': V]y[mCv[)*E+v{/~M8tzZǃymU|rnz ҳ}-!t<͒Ƴ*5,:6.繾9(f=×7UV޳r{S9( qat{8.98 \%._\`}fd.{JVER\5,c&ϡwIEs=S}ƴ*ȶ;sLVZ!cuyw= ] ܂C]8t<}i9QΞ~Vk߻QPg z^{oYWq#(m7E1\3ףTgMN[M-pӫNv5ްkۻ6C6pB|D8&.Slo/hȑVφ)mIWMGR&z;x3Okm}gᩇOa7T ,*͵wΗwoN0T=b+В9G'Eo>GVwphؙZGZ2YF0ܕ,k5cL'vf˪NM+HD2L/a߁)yqZ"TVB f^3_"SxT6ˢBEt["6y<6v͒>hhF~Qh*?YZ6wַ%E) qfbn^ 3r zKc۔W3N b\\EJ5tT /eţ@9¾,ҖӨP)s>XbI, iB3_)3hpiUԒc?WYSaݝ(;ol4;FnCVxuh$6U?Jc__׉#oMGG!\8O.m8MݿVֿXL#qMR|TO-둟BݧI~Wpm՞a6?ދ`|k1;H@֛h{oq=)V0 ^leU Цܣ AJ!e- MN#aNcT ,h=$&O "˵)*f1"5;e56;0'w9QK4ޓ]mh-u[XKY.PI5p!"p/2i'Ǜ,v M,{9K"V[Nai6-GXVizae`Ɵېj7\BJdu𠬙$G!f!j${' 5aV.W~twh[ho6Q!"ɎA2Q;`OR mGZgRY[Tli?pihiRWbY/)smʴ1]gرN ߹+ ft`^ V)*t3  4"_:|mps(#6D7hiǪZAsͷI/V?C8B=F^8߱ ~`}К"斓Ӥo! "6bL&Ý`N{BOӡ~_M},M8Q"]fgOt"4 bSt)58ihܭ^3Da؋PhMEQ Ѓq#gy@Q'Q"oNN#eRz V7g:]x ;8#k;u_ ]i7?f.gm|% #r^E>mLgipfTp4@'*5"߳@nx#<Š )QPcLm@@X981m LGlE|^"][7Q9KϩHiݭYɚ{g̨{hi\GM=췍[-MaJQg$b,&.i@1xob^\|c;?RtDعWx[~ܠ/qvGl!*oTg'OI]Q^UTaf^lLk1 PBbx#PvƉ}>* d?ߺ{?QdԷa.aeE"fnI_ 0r O)ܔle&H$~Q;BvQ7XW1w@+)"F'U,?' =.k³xygGL^kY=iqAR]oJ<ͬ)F3-mU;/$/8\^fbDw#Wn첒ĚKu-7WA!&7*.rJQi#pU/=8~GN.~໙?$?  dO5bU-y?^xC 7Q)_gT+=+||/*V4r+ OP=_/K _&~"&fJ0!Ipmu % ~HQSu+ $م>23 <ۢ\L-]qH>bڍ'b"Q' '_e '&$7_׀]B&PaeT@N:PE$1Ԫ0W*@6z$ql~㔹_oowZV"&-&Z܁x`خr~vFT(jLQclw34 왞v"hۜ޷!_WRƝ,q zUʲ"yk1{cY fP7OO2-=!F|#l-nCqF9G{v$ !fU'OgsM4CU~eAu㒵A˛$ jc`a3e[wz؅zr?rܨ*fm([[kq± KSyᒵ41N48x/}~l"w}C9S3F皝ުZ~.mȔsQC5/{N^ߊ=A.Pw-[/KnaRL 98Fm.U"syN܊̕BM/TYeXnꇱԇRdn/8SY\!g^c+L1m!➧ oY=ONVL빍W%%! 9?ɳx33x.eusnnO>S5X:_ P&˥(?R0qy_ՑNnzr==|iKd#U꒹,A)ggqi[hki(5y?3ܴ0KWc!`\ޠڛ}n*סRo&e}g- }9=&m3VUӟMH+N: $\MLZmgE-mG*HR[N ];[9O YiEGE|;Ed>TPgTyfa[{}N݁n7 )sUf)XTHeќrsߙ^2;SԶRY P=L-5tF}XL:nۖ_@^}*Jmމv"0ЂnM f61 nt(-hͶn5cb5Wm 4J(*MUv[s i{U~y$ 4#e3k=xRxM\,9fs: zp-ׂUtR om;7vvӡ#LHrYRwS%Љh1x^@ =sC{<kzYq,=;{e1'5|m Ugt ֧Zqjw+m^+l7+jR${lMPS| /ozd 5Նds?~ !1m2(m0@pɲ{FYLy;4v ڦQ櫡Yh4"SN̲r:9vYj׊KO#-} T$D;^^]dE? "WޣG'FJTLZ t[,cx4nҬ[%^ZĚ'C>;t xpBa8aY8|SkwHQ3rȗl|¼sI--{M60oV\e<2 ҺQD|or0y#d$$db|gm7É}üϐQ:nsM/4744Nb.du0vڿkX>l?創_,F|JiiԿ2ذgu!&!G B8hp ׆ϿZƫP('uP7Տ0@TdeR.^_ SSӦǮA uQՆb0O4+q)<:1I(^>^xr/\[3VFJ8;3.ߟNs\AUG~lrMj _]ФJA^la_? )} !Sk J9!F?>J/]OaoSy2 rX_Cq?E"eqb;.rASԎ˪?1iYq/\Ni:l[jيP'@F4 kX6q!Wlqn_f1@R& 3:  U'~7+m(J{^P85q%EQh?8Bq\(ψi}·!޳T@ 㨀:_?r/cjMb]׀$&FIk@;q@?<2HPLeKӇ6#Mح9WG@iw!n~vRL0= QnYג]_RF> clڀi0 ɀ!l0$1 x|vtFs]˛*?6[YZ&c/4sO%UI"Y7r[c[n:{iZpjw5`'Z;͌]7̲eoXq_= #΋?EL$~b'9G=ן76 ~c 3gkU  x.(!;GM%>: j~XG08ҕϊv0/oxx45@B90bM .?'/x-1' ;ۼ͂Ph]UK8\dϟDiNDI`oxbo뤁`Q((5JR@i`8PḫVy!21`Frcw8ѡqq[dN\ſ߿%|p6=n~@ b}7݁~@w@C>Mz*g7q/]NosV|']u{t2Ǜz\uuVğ<oKB!ވ(_<~xC(ٝ\Olc[ZXVrgI,W{"ͲwN̬:E3wBLOCdԿ_r7'e ﰛpeK%~u?KeQJ}BA-~M!e}ό\( &717Gr}KѮ;g5M:#kM4Ԥ!,Ϲkyc* j Zc|z#nv/k1Pvm_?M2 ИP8)S2" rjk[~eZ܁M7zn8+ݤjX%j9&=Cť,^~Z u::Ue^^<\oʴ?E5ڧ07xԠU3,UV5Xϧ='~<_.ٯz5r ;TǯhU{~7Cvʹ{}9fColAB:p]YUrNJ6,ܥPCjS>Y){9_J#CaJc.կ=)%L#n*нgH:~K\[<}² U@ZٗS75n))w{yJA4C7: r<@CqU9jޚysܧxC{ RTMlgUy'mB$@-q=DS1>|KR4e d70ݮyvYZSi3s8U}q9m9~ۡx{W2)V\ӟ%~. `NMtD-1 9|T*eݻ (5CKQ/qn!KH:hgi=0\+nĉeezi=za2\}NGՙTmǛcZ=,ވqAbn0UbU.2- \UX+LJŭlfUW?띙#tQDkpՅo7qLcS߯d_CԽr^:wE;|l~Azkdc ihFtk)4WQ>pW(u4[]bt{Dkg+Q0ÖIxepjB}g^ףSq*{VN[ y1rz|Yts}sASKw䛗OdUx~#ES]m"M̃9Fm/tSuKEP!cL3hE~} -%5ʇ )Ef3]waVUuQ+ ̯ Йu_ Z{c2TH˛\wgo_[D t6l~]CbEn5SP:*N+39ϥ ^ c%}lt1/ 0gfݠfcF9R~o#b(ٚf[HAةg ~TƳ(#[Yg+cKji*H;tI~_X,d)7!>=kAgg[(q7~i]UbnkZu/v00+&NSK1La:ʕ/ ,PXo2x3_mMߡ)+X-OP[h̑&5M)Py>p+7nZW5-WkSYs\I\n2P?uitِ+ɞ5އۓ 6?mzT?s^2t< k@rOF!kVa tpڻW-RNlN>F2ȼv5Pޑs k_{ߨrI-i/ـ:V@$E?[XuyB/ JWQczw x_!)}M5TAS͝tŦ$R܈9Nc;NO6bO(>ґa@&H>Gƨ?.d՗c2x[^Uyĥb;1^#@LVDl-@<[[9۱K/ 3nJ[i%jgG,k᱊teBP+FMʻ(>&f #*xf1;@q(- [n.yv NjC'2?tui~/ v1aUp5TX!ߌ[WV}!"`OoC~ stvc+1m@$eW/nPQSvi<HGeU.!QzyZ Bt:QlJd1^f)@f>ɇ>ʔ.,K /[Nb1;ƅt=Wh&rŁѳ%*nbm _R"ePqn~;ίbvgDArxƘ\߰x^'pă)(FUl }>qGn9]+n͟f4$I`trqFh*W,r@," %bV@isvF|'=P%onA9Qqvz'8,3ou3 >W|3ko /6PR(P`$7/X3.)eZ!A&L],;U⻙ߤ\b-+@ֆ@'p8b|V@!':W\*783IݞOw{;~R{vw IDXo*۴<{VR"E+CHo ߚHw+CWܡbf=f oUp,"zΟ5OP>}8!sL |Cmxdáx,=~ʙOJZ $.СiHrlq\ }S_g?jx(Pp rr_Z_p^OӋOXf?ngbѼH]#b5>n$n+m#~]uuG{wc4}oWD ci_^do=8`o5XGlkS^oYZ 5CM4MJ:Ye7Q6,0 5hâP#M\?T r"WC}y u>d4Ϯʙ ͐ҍ[+,$#^:O3 fMNuR 7 {դ:ϴ1`L9s۷el٘wsfZRWw茓t֧~GjYG+xFkG*9>q%ԪW?4pڇʤMi|rlt'8,'& :ig1kl] ;bg5(Sف&Ae(}TӐr:#60*{ Τߢk".2t<ڂ\jzZً:"7j r'ZvS=[QX&PP:"XPjEů?ܾ;׊e۞rtrRMBt,#{{كU:&lm&Bhfb&96sp3L"T/@4/yů?h|Y׼3^o{S|puY.!`pv%Sn5(,vbQM\_):Sq>MӘ-_KH_2fp FFZع|Yg嵢AǁYZr]&֨\uvS Vbu4P(;qm)l]4jIZ ibՅr6sNaVGC?μTƫ*_E;MՙfteþeFߢbru5 tNZA:ԻwlvtvRF5E& OZ Q]μ+r26ml͒JiًVC.&Heв̀3G5ЙZm*Tl9[-d;"ihC|#a.tCKXFY̲5QU]S]lˡLthrFVc3?r})_Ә% oԠ~9lZU@N]*^Ro],fbiS{<(^IdO,euV)C}!ݷ 4'ᕥ֯b6^gn R` _+ɈҏZ->tơKE4@BkckBkKzl6k^.%agB9^4NRm5Ww*ui+fi9ۣXѱsr6-= i-2!)d:GVR\Grkm6kMDBR=%a%Z|-jJBv92U{T-lNUg zU^qsv=ZTbCb r(Y`65T(㖫>T53{J W#+4B))xr=LS۬wIqRFڷġ kXDFfYAբLXvIvX(u9eBpd )frha Ƌי\ڈRv1jLa.> 6a"PPTEjh@r*RbZ:T OJېWǭ"=" kCgG[JJ7bѝړd:zA.x'M"@S'u6Z #hE<sRi:f&q5*T\͗]X`#BT 1^QrȼXB 2TFn4>#sݬuSFp r4GZUzɑ\"r>@г,f3gʄ8sF{w78~f-i qiE [U( @]^$5Y]f-!검]ܻܭhsVztDjTrDMQ#:k[2 W'w0띩qHیS+<UI WiOD%olߞ X=Cp`ɚzlwV]GXM=&|ԣb-1Ř7E B0`zE0G洫' }y1TH40OW "ʨ#EWd#0fnQ]iGrW@܎ujT7;? bIz4ʿ"N=ZaDGgeŀT*Ըv,͒D%kfTL|"ɖ3<f{@{ZKUnb^IdFTɘ :ȌLУFMcA1Sztj:n^6Dm5i#jGTX:7"sܥ{/tz]kLdv[T%,Q4摩VEpR1DF!:EMgcF4]A="!DSeMަ5ɆU&Qߚ辌h-,FoQ,Xc" O7\DjYHhDKpu1YxKPF=25:lxdc֎&%"u!Xz!K|,Z { גnm&;ƚXp 4_;YMWVM\Pwjw$5ঘ$=W>'k"}-]Nqx$EH?fc9_:5 sX~n[wO=:Czlca VYb䛈[!)UgmCyɛ zfj(Ks­--'nb+mlLل5!5zS11l}E̎cƱ'UKTHʏ')T`p{ 7)fuDŽIA{X!SǍ#K4 b D4rDl6͕ou06Ū2TWw+K~uyDzB2)-H5 zo=`E=oezO{O.,ΝE9f,Xpj&}|*P fV-W8[Ӄ<"N"kƾ| .EFۑE 몥Gp04g1]6eb U6CRjF޿ 5y|{״AxqT0}q »O3"ݻS;KQd.: h1cr<&_Z5nG%bՠLX\+hjGaԒɼcȌIyB;f_c=H(wdsjnRܜ-g3;#Fu`>l:(kK!;o4fc]s XM:knu;k, E~+ȋe\lrծ)WflNToUip8kS^aT캇VӷNp&5ǚs9 Eti@,lYسʲ`MVCNn>S`?}XZ/7rQ yq#0Oթdéi&O%_SkszU*BNιQ Le`nUi| l Vn1&.Y#nr|13ҫJDq\ͣ>i"q+b\^ _6<cœɦHvj5TEj//xv׫~^jP>l-~kukZGΨu^i1q28$hq_r@0kjQ,,-Szf[8Q cV iEqJq5ZPjR7n2 wZ95LNuLqcl.,}F_ҁu6f6rKcaqJ}f֙Hĉ{[i"`a}=a YwEv-`PWUKھe@qh+HuZCuziv䁋%&;, dNheRkҖF(!8X0og2&}}ۜf@X]O4# Iӌ+ O3U|]JxHS#G?2d#G?2d&S1K&Clte?2d$=tl/PFs(Z_g?2dU(`N]rgܯy=gi~ :tdɆ"V D! Cm! [*UrN#?2Udl դ78]@%CkOGmFp5ؙf#?U vZo2@SٍȠ+՚*oPULzms9vp1,vh 1Hږ0ՔpC5d2b$ݴ5~R=W|_m#JWk)$@Dҭ6 r=iUYa ɿykboL=\{!eJc Pڀ(i!N63Q)EƜj& UQ9DmVP[gr(ZƁ4Hע#G\vYhX3'd_bcV;XҌET|2kͩ@#HJZ+f DaRw5TRv~OFAVE5&4,Vd1UػQ)l:T>{ %&{SYļUtY‡VsM‡L 1dNweߺ=, vb91sRdD6+j"!5 sP*nq5(e-!mNt?.PUc}8TfViE3 V!25P07}udgݴOSm4TvC]tn ٙ2s?׶m6JĿ洓dʸk,\T; yLP/NٛLyTz(:ǚ}[Xd@t⯻MOɬԡ;=tZ&l I.G3uqjR+hi1YmjT%ʣBm@98%ӢP&'+z` ~ˊK]`l)wxHs:M)(:L6D`}9,ҙڣ -!y-|[%$!U^c0Yl̿Le1,U΅ń(' iFOu&crl/}w 4G[Lˑ)(UJN!tԈE @i oB({2T]I953w@3 36V&} toF#ۓ.0,i$yyHiy(wӽxV'V{ŢZO9sa;t\̫՜kR_;zOao@@n"J(*1+V33!ÒH':No,͉ēh-RPT`mVEZЍ3an珦$6&D*}EW4hّWa<.O,|9ȀTK\D8 7K%FAѮ4J[@ڴf{lQF@DVZ}CQ1޸}6h#3Ϣʘ ]cn7z+ݠ@(eƒ^Ƞ9^IDɮG?O­ˌ֘dg@ Z͈& (Yl#`S% Q*Zb͗ƒݫw>4"S} ɽڝ.ܠf=1`(-T^PaE8miSk1h1m[ :,CZzKZc&X?CT ݭ Ët2."\$)0m)؉/"(G?2$Cg_۸=l8J*pȴ+%eEj>̌ƷGaA+D jA1;7Q+6(uJ 0A[[LKrFʃ±zٝ9]g$طSr 7.ʒ/ѳBɃvAqZ;Yk%ƽTJU57cԫvBEbo}g4+]D Iýejۑ7)ki@6zx<Ս7m?2W2[f5 UnCf!!ӷ-Bb m`-ji(/v7F̂^þdeXF9pT Hukb (nh:+J㩴fDbWMOe>DKlAŞXFXZMqoNP=UzB 1PIEªV9-Qo➪eBrAٴ`ȣиrFaF9[+6X R\ߡT+wE4 ĢDuwl-dlYոjԟf3} 0u@mQǼVfOB?t3ja:=+$i=1?OҺj. u"'03V^4Wv8֔SN}*th,P.a%.ϨTҬ # ) Yi JČ آ(^j,!i(Ut-",PЇr|gcY3 mZNHmpU i+C&ϥ_\=ȀGխoٍZT 'ԏ%*f` }iDυovD¼V솀QF&HDnĹR6-ORH]~=^f5sM|%Lx"x١{NGcqYd =vx*\(%'\> &بL ac+' mieA[ PU]2<| ;@qʐ?B#1o=T~xDq<4 *Lܮ\I5ҿc?I'JDƝɦ=uIp3CeUӫ8$}sME-cBV9wLosMfďƁ|ȣm:y\&YiHxaз2p=hWmfn I#OCk8]4غbc@,4"t:=ݑu1MFެw|ˎ}OTU\L5<9KcyAt O"PPv;S]G`'>(2f7 c,w3"bh88h"pTR7aS @>nq^+؀'4+^d^qQdj^0gc-}Ah_ S->j6ܩ\v뼼{Bd<.qXqUƳf9χb3GKf=}O!OsS4|Amscmow7[VKNl<dDBۦ?r?saFG۽GChXuc]AHZֵ$\&6&r" X|-7,I:Q>.gPƭX%~Xhte-#u!5Bf ⥢j/Vr36:K¹̿LZOEfV?U޳]JБI;u9"Cfq$$Ķbn>Ȍ^,(pA`tpU+R\Y["r:V+׈ˣA4qȑ9(cs0 3$vCʉ/˛Fyvieb$l^;d76U1,rHr4(Gib՘%G9+ vHTOϋ3< >G]A5mzNc2t&]d2߭4ɨ#70BmUN=E,/EShڔ6mK+4- a-,-||\Xo9D-𕱙i)4jWйFtSERtLzᶡܷZ7m'BCh*z#Gd.oʇG5sV;E&֭DF@2q9uFui9k 3t4#xJ3IyS@PMmmEPcmJ`N1]a50}:)dE $i^e{K P rY^rJ1JaA(id4O ?2dgɨMEM&!uw9t=Ŝ#"f쓔3nɇ3ڳq7fbqڸ2A7!"> n1Q !@L4_ $)ڈ!J3Nl\ngkj0.qPW=ۄ'3o7x)< v+55V.x]}Bkx:Iޑ-bLyz~vPDѵ*`*iq'x0jKaLV^#Ť;YUqȌNR@5LgWKM|9yS`CٱZ6UD>ɿzA##S^zkK~X*JiuO.Q ^%n#69hl;$Ks)!duk6K]9.Z+fk+4{*;Ѳ/yd1Y̺b; pԨJ/7IㅳtM kbiJN Oj%#Gak [ʹo߅W~6 ]+*^!E:$ev[Ŷmvzi?ZֳQkٚ!]HKր4yA)0+8YRk=p&W<Αs_w]š)/Ę5nD̮I<*&1K)O?2dLFR,ޗ871gVFw-{29R葌NjYew9S(P 踻 辣r" \Phʻ{|lZ;W=F/W&BPsGOxtieG_U:.8K5sGH Fd{O.֓? g>A}B踽W/ɚ}bwamݹ8ɘ"_ ^p2[l˓s~?@)H-anZ6WI=ql?A'W>SGlGAxJ6?qO:GԦ"½+761_BL=3ԇ|2 4}/~zgǽV I©R~^'K/<H-8)gf7fru؇ |&cAB_zpZn' dH -Wyyzt<O00Dc4MYGC GM:`w ;>z9QK<8h`?k,R~A:Bgg03 Nٻ!QdGsxA+-0eb4̵c_ckrlY:Տu8C%P*~I24ˌN Mn2h5,HR00h\s8Wh|8T)`7;M"[L%?W_O]D=?M4[.Z{YL杶=4UV|Je%)M-TYA+⽽[U k~O>܆=.=ji8_z\rc:, /l;jbz5ӎȡLWn[餞e Ӧdg-2¸B>اǣW0 *TL 4H:z·O{{guKZ1\H)R)m`ekR.B5vKUK}?J٣ξzv+;%?MFZo1Z_h3ʍ(Y9ut/<[Ѓ[JDs_C37qV@Ap;s؝tf<MG>L"hg8G(>:+3-+GCObMT&+r{{I^//)UK/$S"#?C&vzz$]4GJI K'}g/>D =3KA/Ik{cڣ}iu0vѺ-yIsu}"Dt]La7GݴolYعS/.6>kI iJAFA`4-9=Ȉw<+ ;)c?Mj̯7Ή;KS\>F`50c/HBj2XFn}zU\{ćD5Bf}BFp4$3>|}y>j>g9 7hB2AN *e$Kfs;sgH^$}QINt9(Z.r/L1ɝtM5 ^$y'/!$M洪9EܩG3M̯N`Aư MDȷI|;}'\j6&W?P9k,HZKDKo?=OTrwF;_sc3WQćԼ3:p|^fB;ձzm[≮)%4՚)A xᛌsHCTE !]`(2chp>,{>OEYZBXy6OEC?Qa`ı|_ 0bn*Ǜp>q,s>cϛ|6X=91Zh"H>;XqP%uxW X%~]0AC^geH-BֈwFx>I龑xmh1D v2I; TKZ|ɽ 1F޾7\@/'sy~_x޼i/܌z {5&:)ވ\d bk^aՊtbJ_Y]^<~O7_X\n=egH8ܱbI+͐zްFUI{ Pܣ2"K8"7_|쯰R7_,6J7(2AB3Hga:R}#I_ wۥlj҅RQ==o)IOal`/Q3/) 1Ow0QcDNp~--"b Yo*x/&pLśȼynq?YiD} V 2OR0̻7 %̃Z]>ʥYd277aC ZkB[/n-*$^'?:^/~)*o}iE bĖF7n (y\UDs#%XC.N t݌7א~&}XXcBwL܏v5+TUxv6p&1ݐ\8&.#X{?_T`ݠǜ8ҹM}SOS5[dkɶR}$>|$kYޖZ%FHjB[yw!cadFIB5 '! VBS _"ǘ zZc)MIIpBJq< O)%L\)t$ NGID]s$aYp]uRzUPF6mMmfZt(2rxeZ$Ka^*{v}JSbh1,NPB/C1;{h^g~KigvGj2TVTwJ=MTch"HK u3"싩S-;fSJrKkAY-E:Ad4è~u l/Vaڼ:FEnQ-p8¨~`?2DVTh샡:>Qpw'~P}_1݉wkE}{#+>HO`<[}חQ}Ћ}0$ݛ>Ǚ9`]P}>t |ß`sH`M}I)b3_07>a}$!^WK>Xwᣟ.["P}p-` XyCϛ}0vo`>E{>4!}tmѧ i2E;CP (׌5Jܸ'r|C~XOiALC#ؼn PFuKi}HBV O،FUPh2A.7 lOh8K ]w]`\H&^bh<o| cЫPxÎ]H&_G9z=G>9Y}D Ω <8 QBU>4<; GH޼}D<{B L>)|_"Y.nNlCJ *}4Ԫ$5I$u~#)JSM ,9g Hl38F7Vr_ctkhߧh_+= Ak!q̓ r5hLˉM36E4 ũ!pۨT 0{] } BZ KJR% @L]#o!/p9?oë!b1"FPd;<H܏q?Kr?rIi{g#RO,}ñ+qq}6R)/وa7~UOrv|v!@粮!h߁l-H0T@]  } Z1R cCvXZ&},8Q ُw`†p&@wccM Kf]uxH0zg][ wڞy*οٞBX*SR5D414"Qcl4ƻXWq\0! t{A=$3M]BV-)? /ճoüf"! ޶10W$t< -H}Fm[PP7 -b4)MWٯ[d=7lᴖpA<{_P<5 -s$#nLxEzAd,p2RZƥ+uQ}ONtby%PN/}ȜW&]U+z:ȣjnP Dܕ˂T|,Du¡Ť4T#VLuT.ZjbS{.{L{xNKjҬ3g QG?1vEg[v47JyNj|q >FE$|!ҭBl weAL'Xs+ǐ>ԊۭDphDljGbn%řWbCkxI1`w~h1\pWHz]On5S@4ՍH}L;i!O\Qk֍| %5oX$HkixjuQ2nUZOㄢ ;n΁,->jfh|̒w#C¢"^K '&VW_{NIaAaq+3xDz=S|T7g"A~'~ p>x~ɸVc0OUQquZӋw ii䗘ȑ?4Gflb~P9L{(Umbxݞ;_Bo R` R!RB1BBL$?-p<%ȑ_YүAa4#޸8J+ ^/Rb_|DBaG^b?X݅dBaG(*b_/uBt+mn Ie~/ W;mkY~]2}^n@\Pӡ DV+EpUcDZ #^aASP{^H_P2콂?/CrΛ<|cC!ū> \P|@w3,xs<_il uS^k+]<~ d_-x(r䡃SP Ўvu3孂R :YPf[C_<o IyuC.<$/xV;zNp_ʃ[( ! rq~+ࡸܾYP<G |w_<]7Tǂ|=SnN' [wګ%(!qv#xx4^Cq!󁂇8{<|FVVo" >RݏǺxkŃlqz_ԤpdCdاyaKSz^6,&{?%vn6+Yc^mp φ9;9R ~Y>12IR~wX@{~u/Euwt.]4y::AtF~}5ʑ[Z(uXSos~AJl^!q뢸hXO( ki1֙vMV̜ZS o$hSdB׍i&c Mvפh.;?7iQ^TEQڭv3(]ܖZրw-M^UFgU{uoEz f~ȪZvA)(iMSh jiOLg*:Pv@͡.RTOyvg50ޯ!E~63G#ƌMŽNw'壚K5Nm_uS@Ӫzcc%鰂%#h8 =AEc ‰ӺI$aJ)fmM0ܷXO}F5iT͊N2`לQ19n3/"~o1IyI6Huưv0PgK hĜ݌Cڤ\F&T@f1rj(iZn[pIJC)W?*~ {R] ~ɆrdQe˲$Kr{.r{nϝ@6Y)J 6KOZ&t^B( i~shF͒-3{&a9~=g )c6H9Y2A~4,W[xt(e>Xy0&x~pHjN7_d_0ڄI^(#hKl Lg >!+0?:ޞI9EC> *C Ȃ9bz5 j]AJal0*jx|P>4C2Bby-fv|BRꢠWOFl6$QkrhhqfCġ٦L.u֨+s} %>tH(x> TWϙ 2xE%nֲPmmrbA9sp!8q9 RMީiHM$t25U(ovB dl ä-.Z+B`(® $C&{c{[8St gfO6x`}|Za!1#t$tF7rN4VTᚽ)/=Jvyz5o`r>,)Ѫ6ir*t4Aئъ1}iK9r&5IjߦGl٪A\pQY !5 5F|m{tcEuhy4E9[Ykn&Llh*_m h/X`K2f_CRCkg!^ڹZ%7øK^žWRd؋qVUP}I6&:OuPFh%F 5Imb&~ç'aXzq2mRC桊K̃17:@恾r $ (' -Cr:I&&AƔ"MȞ>gڒz\ʧEr)犍j>2*hbuPdٌR}^{HY!̤WqnY/[RDaaTEV.`,^FfQ C;&d0Cz%_ˆ^*̪7Rf@v7bGjh} ij8&4ZSz썽$h-n?exlf )co~ycAŤQ lb Z : QZesQ>0r!>9LH; qDXO$XCemLXPR6Xj4BCzDH|'g *dJ`F;+v@ʣb퍢L'?TT7MyNT!A,E•Zʒ+d YC?.&}IsC}n_*J"V7'N*#7:IpfWuc6`a\iU[[ɒzjbOEw/c;Xإ/ 0+U&J/ΓcQ Г/j؜SLFoExZz縐I" TkjǨ0!+ n"K=}OW!N T4Ew*'bVE}R&Ua9/oUʀ SHDR԰P}..9Lpcz ":]!l*SSC ZPGr "d鯶7ߺh*mh\#:4[!Jv?T |6Єk\ewumNDEbAd,p3/q>-<-^sbZ8-E(z%DHUil ~85U1r^F\AfbbY(o ,#:Z7V{W'G"\nd"N gD֎-z&"Lh u,q;v1+BC ԞQGm\.BMjIk@jQ]ڵM [7Pt `z.=ٳ}1>E67!yj0B c5oTn4PNU6Io^ڜ*ٓ*U^cAQf'oI27v4;{CCXd%]7 ~pg]2M5I|"4|>Ќ5~.2?7gcj$|ZIVqUM m4vfY3a&͸a9ϘJ$8KR?hQiwlP%-J ϙBh'l1Y{oU[3@b|Eܔ5R宱iDj[e䰩N*%@tcTBaw$HRxS5hex 65!f>䨌XE5ms I:@{7f|.U奈G Rc-邐h vo P:^ ]&2eS)B ۵w1C+Iggd(5j:!" t!g~nmi%x \c-&0DNSJG;\~(=yc>ns?oF?60+)B<'T<'f%Jˮ %ۀe:OkC^6ٶTWYSϘj#SLvł QӰ>PJRpdĜ@X'^#?X ճl gmabka-"gGX.NʂkTj)gIfa_F6z"*&@j~zԡ5n n$6xYE6%ꜰGdZ|aZ` R7KtÞ*V͇6^׃Y>^n]'6s8\O/t[4$KfDkS{87N;1Q&)r +3=0O,9M*!D2! HrC-9ӇȂ^2(q u "8^0՗ĞN}7F$o`@Kn i[)0 E XPyyWA5O~C2__"/GFKHP`-D^zkk%r$j}D<-O_b)/A֛0DI&G^̯2N"|,Pq'yLjﺋ;Qxέ L۵8P0OmL>&YutuC5\bprlm7rś}mjE͘1x\T|{*+CŭZ)U>樥זO9| |66Yd C+VB6Z7Qf 2Rhos?VTyݣ!A8mS;<-VY@%i#vȌ8-j} aב*-]$S(-V>1 <FjS~^^A5eS)4 }$4qVe"$L͝aLcKK:W,9ptpt٫yTMD3f3:Mo&sf |6D*J:E[^l-sdmhk{uH$EGHvR8*%ƷioU砍.۠y Ur\k0֙ ҔA|[O37W6PXߎ5iۑ$ה>ц"вY(BG`eR QKGb{p글چfU-%tP >u%0sJ%R^Ei: )sTšBJ#)a>4rHA~sBJ ̀y"(pٕ\[!bd캧u}\&>6XjiO F*~ׁFnx6ɰ"SOj[Ma.21O [hR-fK,9l&Z;i6]r}%^N"1fe(X@Y|K)]GLИ7a<$**GBurT-s~Ao`×}͜y`Iַ 9T1 6E8uJwYPt)W ̛Cٻ\N.O-Ab N -~Fga ^5b`%voM96I>䟵QsvE&pmf;a󻱩31|j Gwd .Nq X4 b+3ɨi#:*mcW&~Wɞ%ӜʂzB#O9H>Б4A.o+V?w-W5*@M^yV/ 3C2'1N$u( Jx'݄"}Fޤ>\pQbgREIC$61<4̉[̜o:2J6"p W璑"GTRxХ YxХ-kO"JZSTWN2腩ř+c ,6i`ARbK[ wuFJݖ"UkYdc X4xi694c&Xa3~v\djxLav}b7q<]ī.kQ$4?N|,E1lz?0N1Y},T\AdOoOZ馱|<.i:HLHd7C^$c8YR"?U'8Yyڜ5MNEVM(ݼ1RWV¯7,-kth&U'<&BC'7 ؠ\%`۸bh\I'!-Fr~l/qk؆Bru'*ZP17ѫZWw 㵜kW=yEVeۖ>tL:zs~hTF4C܈[pF@g BtkϩcB󆶴k'VvioONN]yO;MUVNN| SVr[p^kʼݹA juhgOhg"g&mH=޳Rs.wCW`Q%Xb-,cZ,^WFR+FӉsZ$4) x׬Fb490ںj4vV38tPpCLD'Z2'u}>YD,Q!M7X2^4aD*`3gYu<, ICo\QckԜNz!B72+v.=0Q0e"kxbSٍWփ&-DNtqЄɂ(.{ ۂT9kp:d&%{Fg+8Tp+"(xr(Ǭ59[-;x\_1QיG +l@߀)[mܮd:W"lnl䙭 2-_pXEtm֗́"p6lr"tru|Z"1y k^?3n3"y ^:A#.Uʿ*p9&}XMvplM_TD69t^#؋bc#ZI[S6\;'BzWĸ-D`"om+ 6 ඍJ/묮,@d#mII߲/9u5nhdZ|'r4MD'F}L٫ l$|,a:4!Dp}ʀs8iiVPniP0C$VYX ^nkPo-X^XcZ#_IJRSϻfRȩ s-QWT%(7w`,&fo)u((I2`(}ͯ]jS6^lWlGWp4tk8I񖹾"2gtA@BcUZ#@q ͕[$St@*$EH۪G U@;00kU`v^*hL{n+1lGb,ހ5-9"Xa64ZdfߒfU#&b$6V>E{pd9)Qd/L_go .985C%칚+KAUSxj-9 4 C)殜|Ds(lJNUy8T*o|*=aUk*59T0AlݱJ,P ([<\] νy2xd/)WEm sU致d)HWE&؈<=\t΃#Lmm+)9r`Lg]Ό;舦F1f۬$JcZһREWavZ47Łea67jrHe _C@_Y8ZW$֛b;AJdX"taA@J+$)!l\$H`ETS:m"JN%V=9EzϪyԆ$t'9%P ޔcrh=΅RxȐ"壩L`OKINV6NRG")9yIKaCK)2c x@)Hy AefVj' Ķ@ Y0SX;%GCƞ)5njҌjKF;+Jղjn×\+KfO%X{*z9s,&.~ȷÇVjtgTcO[o їܞLJFڰ҃oԺS̾iAncڦ.k9e(¼":M*}D`>.οxRMRUe!Ope-b2sQpgHC;FI0b.^)[ dbk}3U0 &z v}59ѓd9]Q4v.*!3Jl fʊǪApr!V"9%Ucct2DrwW84jJlu#ήTcO!7Ǜۥ4 Y]n>V]s=qS:̂n0u !)R-3wfјJ;H+Ng !nȴiKȞFê>CMS|= Nı]zG*zGZ4)Xg ֍%wē{a"Ρ(֒ D@ G8RFҨmBo وVB,g.c%V2XG K 4?OKJ#Ha Q>Jlq.wb&|hԄ}m&\/[wʜCy>bow;e ]&ZTmI{<"yTI=19sp 7 y9V% B3V=ux6Qpt)19ƫf%^̎Qr$eEՍԽuA׌ءyۇb$KXⱘj7jUyO!ܭd-%ucBxc &׭hy-/zPjj-@ j5ESx(t]Z:O;Dw=8I>WZ;Eg(s,}ʨr$S%ΣQ{ϴ|?3T''.C{Dӕ/'m(3I$9-4r9-$v%<)Lu=aSZ>^"QZaV:2c Yo YW32"tL¬5F"RX7粑h6DqY%rsIDc瓌<)Չ~RĀBWr:II2h`է6۞j.LOT _UK4eW; \|Eܚp'M``i{vT~]fq>@ ?L|V2W0E"iD ʴr5YveuI(-&.rtH}.w$m[3G^|Ȫ-9&@IlO`˱Z<ӵT6rgŶ葸n )7g>#kJX#z . +$ s[*kGJӟ|+c3"M-A/5E>(5#L9ILœoZ)5Uq#&r-fī뀿VFFt´AFt9e*}cVk-]oUa!eGX_,k֣7Zq ~K_9hWX[qG&֜݁"aSѸL{J鎇719dWSKmcrې:SQe:\SmooxVohuwHŰj̘1+2vfl|OswQJ 'sk_ʌˬ%lp_'ogǹ⪼w1M$J:φlT%!'$[C!N鏤P=Ae_ƛ|/IóX߮+Q@s$VJ!I/P*H}~) "Ad8K^3V_Uwrc=a?$/&H]`f({s6e7Sj"{s6󡘞 7Ihҍ\&ܦ H NlC QޤofĆn+ӃZNrҘ'%U6{R~$ي&Tǃ^ V":Ը:2Oq%Ut qcw hwsD;G"Kb*m!MvA϶,~VrXEr.эT'.d/bD9e̒n7j=rH3Wf]H81KH"o׷eEa ?,$Y\xZ 0eMD elh0׆mhRjJS5TrIbH9OI3-Րaij[]k11ʘ}X/J{ =n3iX*@< d薕&X:"'ѻ2rCHxT6ws8O^(_jTUN7˝L7bzH7g WJ}*sse&.H}: (SDҰt+#=Z:b1pQ7dqp#5j?O-3{qv#P|C-=G2-8Z۬je Ÿ7J6C0mj;xldDmLa>'p JԚ(D5`Id5M0|l ipt*M6;l]ih4qb(-ff\H!Y&ĈL*/ Yu6Rw]iL]GzOS" * t+Ċ'QT]Ɋ 7Sr#YZCeϪɞe*vZ4`*Y&HǚS'eN 837w׃³Lxjn0l$^'X۔PY퉤/NјSiKW+tzlYj5p[EADl@{VG兀P/i=[$b𐴒"fHs3nх_[(RUfXYzNb\STgWY{K*_@7jcrsm\0wτ5Ͱm"k-EXHAMicӛuuxQ_QJ+{?V-=Qid1M/D!6e}N'6GV&@-$K"45fhX. 1A~2`$jkƛ=ƻ%)RԺ }RBQK+Sb6,Hq 'JHI/!cg#Mb~`+PnS+A'G~CPuRh8TNFܣ;1= [{RJJ~ֲ 9H(MQ'6[2T-ᤅlþr9;ԤJ`RIGti^qO0qtgηrUmE]XW(6ZOk>);3X"YC%DojQ4^6=v9QZo 2 wB5hy=-5o~ZRaM  l ,%:ĀTJY.qC,>Sc^oK/qTu{Y;}`g!bX4VK-aҔ0.\Y+(GmAؘ[hy D#DY 6)Ϊ$<"$4j!ITd̓kbGVV6Fߜ=U:mB%tk_߀ek+ަ ȧ%;y>mt-TENM%j>V%f-//"7UG{v{4:1 :{ܧ>` }|"Ja_Nԑz=Q%ˀ)^A[y,FVOV\^`@m S\:ܪj̧r// GxiN|YU'0 DSB?nU UdvYxB)}I,)8&ۦMZ,>uXzԋ)S桊K̃17: rpc8 I':IKLbo0Wa_kbΓYʧ$J%y&بG?g IXN `g݊#+<Kn߳˪ +J:/fیmڛ])fYUhH N|EMS \ӾrKveg2x8Li\Y,M!.qj1k/m2gJ*We "k*t 4YBReIQxmTRTWη\-1%!6&1 m\bJdϸ26)\rSґbnjSMKL%ő9[b 9 O=\.KƖWWc{O[["_xP5oqƢf-C#˓s{O-"zZc"Avu>_$5o:^z>^<^\n ?8=]+NuyO᧩",X_e/7PV;fu nufNm4NNJ3wMtY|./Xyh nt90<95%=l0bL`kߊ~38/}Y0Py=w‡iuk"yUnrc  dM9/Оd@7.%y&AC\V+8ŤĻV7eJ0uMg+'P>%V7( -΄Iż$.Hp i l?rԖQ]rv⟧<]1o:Z)$Q^JjN:U;Ŷ WG0Kx.R.pd\zර$=t w0pQԺB=`J7l+NTQ?s<'^tp"k @L؏ D4&@`@9R.P [/vEXI\9􍽒e&N@@KGɂQh9< :/8AJ/HA8΄ 8gw ꋠ(ڡ/rIy)~jdF@A;DMIԲɍx,%k h@}4=$Hn%5G}M{aMr C)>)$8h\AhXq "=.jЋKrW$lb^צ|!pS)=-tI ^i]TA9[ecO.ȑi\j8xg|q82mM#J/-s F"'w6 7l0(ةMCIio"&b٦I}MiӤ%ii$9.B; Rܗ`zlN`Z3 llz}l`Cd5 ב67^?{z}FCb׈ldZ }HZ }H ׂ l0c=Cg@}qh= $þ4k 20c Vyh=  {zN0|2`[Ȁz`oZ!녭<; [uh=o z0JCb``+ > Ca8 oLC`@ LC`@+ C9`@Kzh?2`-0c@{`<Ȁm ? ƃ h02`-0d@{`ǀxA ƃ h02`-0d@{`ǀxm ? ƃ h 01`-0d@[`<ȀxmA ƃ h 02`-0c@[`<Ȁxm A ƃ h 01`-0d@[`<Ȁxm A ƃ h02`-0d@[`<Ȁxm A ƃ h 02`Z0d@[`<Ȁxm B{1`A ƃ h 0 ߍ}2`-0d@[`<Ȁ0ہ A.Cwd`oƃ h 0rZ'{+0d@;gh 썐}0ۀ A ߙ :~o `<Ȁv3`7zπVƃ hZVB ``A Ba`A ZĀVZŀVƃ h=Ə0ޯ5 lV`<Ȁv~e`A x3`Y ځCk0ZπYن mh= Y€yh=7 l4l0`UĀFVZ83 [uh=o z0X-Cyd`7 |aoZ':W r}h= d}eh= }mh= (a_Z;zZ}P׃lZ }>^{7zF~CbwkZXz l]`pCdZ+llzl^`Z5lh3^w~hM p_66К60c ll50`ZlС5=1`Z{lС51`Z߾ lZlКNF`iw+lzhM[`Zf6zh`#1 sl:ol~0^c?llI?~ilaDe>05yEPBT7cPbȇ>~?<~}CUARDPC>8P;蠃NpM;8J}Pt!vđGu$>#8C>D,?q$r"nzGs'X/.))M%%O8c4ܜ8!rzGj'K &PA9l2O:8#G;c;Pi .Ou? "jK[L?=Xqx"vG ȕTSǚ[;Tޞ]p-*+JN8#$?ͱ}XrwUm;G'f/,..XƁ?,..շ%D)Gr8bWQiwVCm;vM.>uٟ9}ϝsg>S}#]ma^YQ|#aF=;嫏l=O~9~ ͋/.qK/o~;>+SZ">g;CtT >N*5h{?~Y~W\t7x7ci7x_uq=S279s{[pW?z ,O+~Ef[i۾ktz/֥W~kon펻կon7߽⋾r٧8= p T9=R3}T_auBMG>sλ[ko-oO붟p ϞaQ7:!zG/73>Ջ~y}=3ϽAw_{8˟}m@_3O>}wg%|O,M h 8?W$7w꧿xe߿'}Ï= /ƛoo? 㿒^?% ³O=}wx7r'{j=)!rqE'763Wx˝>SϽo W#+|,[oʋ=ÿ֛Kv̎yS Gvn0ypǜXfvpz_=ȓ"voA 0D$ۀ#sϮe~Ph <@zF:;T@;_=3Ͽ,bJQ<7H`Iq|?^(<=wYN<{ʧwm^xovt-T,I\H?sO=rݷxe|;;|xXč#SJ}ȶE=\S")7?2>.g?1?p[N<0B(=УO,z[O_}/d`ֆ] t_y@wȳ /߸꺟=zkN#.#ݽKz*[Gȇ ?`C> w |_욟q#OB'(/?C~ -n2=h !ǜdkbGN7w/x%^{O>6pVRtSdtԵ>/'w>س/D;oS뮼sy j ߱JW}̿sѕzoz?ކ@$*Ko睾TۑACAWlv:N~p?o0% N_}ɇُ.;̕Zp1]u_<˗~mzN~g_ygOos9LW 5t.+=] E/",;o~y'g]c =r׮{7ߡ˷Q"'S/9ΐ\|P_{/xoѓQ'?e_9c1- ['J{ӝ>@td Lˣ]ף-Rw>o-")HS`_pg K׎!gѩ_;!yL?7^uوCmMR)3/ݿd_黈ᶐtчkYB?xرE~ˈK2?_oCwg-;7\gǢ(o 9*@G| S{>9T/h~|o\׮MH0|?SǻCNcނ0[}M;~wn㡧_fOw^{~7?ɏ @MH't?f`מ{?g. Jf} @?;`^䋛?֫<M l;"xqvgq-y74Qm?g h hj9wɯkoi#]ş(a9)JЃ8 o䗮>[mD Q>~~pY > \HP Pcc|D)Gʯ66@tUEm5E?oxi`.=Wza{$P@S7?_7~Cp|ng/ 4U:x%>Yg/9.!]S}{?M!C$Wy &g.ɟw6+DW~@Ud_Bi g\[{M╃Jҷ_{[]h0 n ƻRJ7_zޟ~%AAԁǕ;°^}O Y {Pk/i&] ჎FjLU-qПW""3`[[Br?֢#^dAP~3(TG[g %ǨGmu]S?~?o! it=7_X ZYo3${Om%iWgGZ U-(QIl! %4?÷]-3R-X:q j o5#AJ)H/?X(/=/EM(;V)e? =[޹մ  Oŏ 5+?tDȘc_<KIl)$~-ʿW0Fe_O~ouZLtԘV vGU-# cnyd$e}:J!z}߿۶_b(ň';=e Al*FRF7AϭFTʀC_q@U~!2E-싯q*D~|1^cNnz> zJ[Az_*GzBDL% ;Pv_ܖ| oW';k-'% 2 0!5gLw" Υ]~OmY"o9ޮ?J ?Orԟx/>{NUާ^ݲM% %Gg@7[)"+~  ,5#|{nJ&_y/Cp" Bf_P{߻MޛݥBtKN$lx>s7k bdϺ>ݱ~:5]p5 oM?ta$ϾM|=i>弫~v3[͞>Mȱs/̖2* @TU\@p@E @E\sGMS4ps[4+ l\,-%L,7ܟYXl\=Ay'+:\e)Pw iwGߏg-P=GwWuWAW{[8*.}YtOӯmh=7)NϿQ߽{Gu˔DDQ'9TU[>j%?i[RiQ꧵5{ 8_ZO1ԅoK>??ц:Ǝ{g;gs'EGߡ(G=0mw$[Lk>gCsQ/MXdYdrϛ:ʽ1s^s^rˁSVqޮ#{=WuP<~^l1{Õ U[5ugO7BFFMdVpsઅoоiU>OvTk8guKLT~>7#ɾgU=&)7<plL'kopRy*Hu˹yxLZ{7P{[jC8+kGN߼rcqsho[e3w hCsڙny788)3FЬ}6 Ӷ{tU;@]V@D! wo&5`Ȟ_;-鹶=bM7%i7Aϝ}K4KK?˯A5?}u7|{4lRw}}L3M>Usb<*y޲_ĝp{+7cChMq˺iBU*=g'Yi};ûY>c&߼cQ?f,^7d8oOyN4n]{swP7u[ s&Mݽ ' MZ?4~ޯ ϛ|8md-4J>Wf[n4œuy3XGyx>NR_3GNpel1K7&zѿR4Te麝i(o-uXRk߷+"&g}{6>֛)br=}5l̅+r3%w!}{7iX gy[J soScA1+E&R찬wW//g}6~cŢ*VвS$6z̼;oLxohj 3rlNO|ߛV惢?WkiڷxS׽WmнGmakoWϩ*{7ս hJ .×*w5,Oy'ެsAnCxVy>#'l/;?{ѰKYy ׍>ׯg|w5w~,|﯋o+?MV^3/q#2QHoޟ^~/WyhXpK4+=ټ曔w oene^@v^;mgD?/b-QqN۶>>nh-{hߊ,4zVo]_e#xEl7o rw'Գ]Jf\zXb>!6nOyc}j%ꗧt gO÷nݢթ!33OB;G U=9hԤw}Ef};7~͂838|WXɥ_<ӥ?lܹ%\̵.8gkIۅ5Z#ϟB*'b5폿X]ܻY:y".NyoÞTS.Nv^Xڗ'Wm i{)_\e%*;Q:q]'߹o?{o]ݭ?Bl5J43f/Xj=uKFꔃw+gYMnZE]7euQBֽk4ȜiW=x9XfOȦ~𹊫C~Dnռ5= n줷W۲kc2ukXaStWY]quO+^0 uQUbuOV8v⛳7WKvejי7Kӭ.g]_=/w+U uѩkU>A%ƍ0㽹-Y-bNe=wR*2*KEXY`Z*˩J/~?7hגߙ#ָuܻ?'OgdbsI,?8}iݵ}/?~'V7iGӵmxuYX4M^$|5?oܺcҏ=~3dd9}G߻{֍z7_,;i1_t[6o_^ugK?Eݞ*8Y/|[wܽ:.rPZۻ{玭7+-bf>e؄.yw*剽3%wRf=ߠ-:v{6fHW'xgG)~xw+Wi6oݺm6۶nݼiu?Y򻥋43_ڸھuX:^+W,]^vg>Ktt*Wgf:tP\ҋNYs,\|+Wjj}_.Z2wάoN<ő_ݹ]&=U yyy*VƦ-[ܣπAF{u”鯿5~ ^^ժVZBrN*G,wuhר,R2be˕w uusXKre֦]NbE()XXXYJ,LL,M%Gϻvbe*S^Ux@4ke~:jת'KJ1}'e>X~5z/ʡS*ﰨNbTU#Uup?C5+ ք|}Cr?O`_ֈ&0T>~A!9-)IIK&7~+>'d?'e'?/DT'?HyB|GBBmGP}U]Tש]F@(1* }IG*}.}2E>bm)4 KH < ip|\?֝_n9)apڷC؄Q1}Ď{Vb]_P',.>N~(K$U7L|FĎQWV9GjW(熿vh 9xhlc-cg_ ZQĶjFCJ_B}BC~>~gՁ)/r}gO2wZZg)O/[*}7MHxGݜB!B! vWzȞȮ(X 0SsbL̉30f `N\+91Ssa\̉ s0 `.\+0Wsb\̅ s0 `.\+0Wsa\̅ s0 `.\+0Wsa\̅ s0 `.\+0Wsa\̅ s0 `.\+0Wsa\̅ s0 `.\+0Wsa\̅ s0 `.\+0Wsa\̅ s0 `.\+0Wsa\̅ s0 `.\+0Wsa\̅ s0 `.\+0Wsa\̅ s0 `.\+0Wsa\̅ s0 `.\+0Wsa\̅ s0 `.\+0Wsa\̅ s0 `.\+0Wsw`*< `.\+0WsL\a0Ws05S0W s0Ag f ` s0)-@~u`'-g f %$kفB!B!B,4HQʃh?{Cŋ/!R-5m Jۗ&E#Xٕ4F>艒K;QIci{/.(iWQ\Υ[%R4Zѥʱ]IPAU[^5IшڽjKڦptPݳV4~(OSg˖)ŽMQbU:>Z lMl?žl[ݭ\ endstream endobj 273 0 obj <>stream %+V0YD]G՛٣3m#7UCxҪ Ukj׭O ÆHHLLL"q \tȦ:]:*YcJ C#:IS' &ȡ=LSZ}]a^'U~㦼5y),Xr2f&OzM|<+"\@<}s|t֓jŲŋ;ynAuf} )VC*" _q۶oAl;۷oۺyúK?9_'}<ܜKgϛŊەqqڦO”9_nر[8‡k|uHZU\Bץ 56[ Vlu3gm'33o_I}ۄuwq4tvthԢ g}㎃'2]uz.][R?{g\lf jT(c+<5{f^k[ϵkW|]k=.-+J<+£O|']vMbq߫<ۆo?4{+ዐbO/L+5bzʭ\[o߾Cl9߾u߿.:yã튀^ Sʸtƭ;wϝ;_t&mʔ#zF{9MCW8cA̬k7o񈶸}ZVơm''Yɹt~]Ztnަ)B޽}ZV{Gܷ+vvL #+Lߞ`FbwEҌwV]Q$te mWMAWܽtH+.t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACW8t1]ACWܽtE f$DmWdoH`t+nE"JWdH53]A[]q-+ɉ#+ݼ}ܽ{kY+?앐oWG1=eփg.}۷o!qoߺ_Nؼ|~^nN% ]Q/_BUv%?CW\=Y_s/e]&K S|aJOO?Ll9J;ۮ-k[8!#jU)琫+J9R+ qo|oںmĶ}7[tux͹tɜ_y4k'7,j򕩩'5uՊe}w"WPя/~ܔf}4/e [8S}Ixg9+J904S /4uz bI>mk/ڿ4a]BT mծ[ Dl9'& 2莑M5uD_:8WVa`6tի7)ճGgF6nT[2ٳ+w,[gf-ZGF&ȈVaO6k*{֪װ?i|}^b2Ki }[8Vp^ӫ&)PWTyBi Ҏ*r.\*77׊.UJ(Q}i2*3)"qrRq,moWqSh/"/Dcۗ&E#Xٕ*YB=.(.ZC4)"[sl9)RD E%&Bo~IT=R9xEtꗘrhrrШÆ|/>`MZ| P> }C'iz SO@Z<*@퓃*~{j_jm@ Tg}d{( =}цr֥jg<4㖱3PiUcG ۪]tu _V>U T *E@ քhw//[*m/?$DlWQ7OB!~.*X.f(`VY<f `Vy¼LaVy¼La^0/S`^0/S) ` 0y¼La^0/S) ` 0y¼La^0/S) ` 0y¼La^0/S) ` 0y¼La^0/S) ` 0y¼La^0/S) ` 0y¼La^0/S) ` 0y¼La^0/S) ` 0y¼La^0/S) ` 0y¼La^0/S) ` 0y¼La^0/S) ` 0y¼La^0/S) ` 0y¼L{"rżLa^0/Sf`Ly(tV`fhzYQgfǝM!n=B!;HQ6xܷ!mPx%J$q6Q+FMP޾4lgWJ;{2*''gbqrRqtS=PҮʹKEW7JZѥʱ]<͠mU nUkxxz$/OU*U9im+8:U۠?v4䘻=QɥvC-ZGD&Ȉ-7`W",rQϯIE՛rzֹCT&~<*J,^,g[p,릮;|>:IC7g^_T*T׼mtLW&N!Ak뱀P _MX'`rrUש]Ĩ yxz4?y8Fa I$ 0VXXB?u¢a#պ~1Q礄qϫ(Oh?oXThcFm;YuS~QU8_n¢4.cW(2!6k\Li;Fy@ cm>]m_}stR#?/DtOP&T|TD|nrSV} Fԭ E~nh \\ͽ g*gܯ<80~9oA# gt,FOQ_4iOCc?n8i->U c^ |`؋@>b},{1^ e`/؋@>a {1> c/؇2| 0^ c/؇@>b {1> e`/؋@>bc؋2| {1^ | {1Xbc/؋@>bc/؇@>b {1^ | {1^ | {1^ | {1^ | {1^ | {1^ | {1ȕ_d&(jٯ bc/؋@>bc/؋@.S(`/؋@>bc/؋@ c/؋@>ba"-z~`؋@>bQ"5-y~`K؋@>bq"-x~` ؋@>bfu5c/kkd^X#b "{M`M *ք+^X5`/ #"{`؋@Œ%*^/X"bKVd, {1'3@vd,^?f)]R" %Ev-@Kz 5d](Lٵ`]( 5d-]'(Hٵ`m]/(5sȮ5Gv=` ]CxuaK]Kxe]Wxٵa]_xE!k (Ev ?E- )]sȭ(Gv@Q# "9},M8 @B@@LG1`ȃEq`E`G1`[ȣG`G1`݈y"8n|},X'b> )>)>,)>,)>,e!" D~dDv/X^dE,7{@ =`^P0Ev0/b;̃XdCl'{ !=ۍ`):kG^dHэC:B ݋@QEH~ݗ@QAQݫ"a#g[BFv֎sEv/֊!=X:B +{TȈ,!#d#Ē"| !PX>OB5Fy !>E-GBHQ !E9?#B##a]!9,!#e XNd`&=쿄w7BF^_BHރ2B_dOB5/kF!%#ّBG~K!XB!B!Lg1~T"%GZ䪭Jۗ?Oğ*WWW[eT*'''#D* +Д(*+msr]*Ttuus\]+Vp)_(й,{]}yUjRk=٢eƉoղœMk5WK]Ύ2*gW[r.ݪTխ]Ph'ZEFiױSn={8zѭK<٪E&!~ԭ%,̺n.UU2JyEjmɖQm;tڣW~2,~D(MH1<~ؐӫGgڷiޢis:ޞ5\ơtѫrN}ঔv=faOԭg~'3&M:}zr{<})&yܘ qCyw.>%iXRfWrEӿܪV%Ԥy>ӵg9$gy)),̉))~g)^7:1unӺ响2{{Wq6?c(-5j7 l<<}~Q/:qo;ù) }7~|UF?u~it°LM} E}ZEuֻON~-b+VYvܽ:.r8'oJK;x`o{wܱu~w|H7OzeLЁM5^W?{mel*Z?3$a쫓g3^b?oܲ}}9zĩg222EPFƙӧN?z$=ݶiD,ߚ>QݭCTfJ=Wqu)R\Ɗl!J;TͣV}ߠ-E}M7a[>^e֬۰e9vԙ?|& y[ǨM|VIci;*nF2E{ hTnM71>eyܣT3Ji._￯^{}տ,Qs8}☨ݿnݰK\8w'KlO5UrE*rVU]_<]1CE}gI?{c'OgqEmm{"7Gz_U*}yCuVx'~a7y'y‹ /t jFnu|g`@W-=~L܅K+(]S\(R翮d]gG-Y)Əاk>=B9m+VU7y=7g[dR}?(_gW7Omg*y*um;g]d EAf=MHgz6j-z?7늮ykTZ[g};n4u?,zG35t@t_dØVlQ~-{`؉?k7ث9]}}[MC4s*ݱim1q1=ګ2Tr׸e.}1n[jv._}jW)U7o2k7 ];ﻷ_ߜ86>WkG8+>e{r njTFVZhaJս5sCFOxsΧK_}ߡ]|FЧsfe7Z pUϺ~M#=(i,Ie4Ͽ7 ;p!~E^jY3^Mس}F lvpPwֶG𗦽u[<Xق }ȯȷs|`+ݩ/%KnduѹB­;2f;sX].i"9k}ћ% Aȕ\Č\YwSsu0(mtl«6ȩ߰W7 fMׯhzUŌlqW=urښ&;彔ũl-Dk)Vș'۶nŗsߞ0rPB|W9Xf$׹tIƇ/i˞.)V` )Eṯ6t<ԯG ⪧lknPss iLy7ev.6aq+)+8u?&$ nԠf5WfanS]ǿYi3FN=W U,+6d5^J)Vo]\ˎߏ9#,J"kKyۺx)ΑժVN!KBWmz 9ifʒ6N;yAia/]#P9؁Rk1ݢթ!62/zJ ]za{0vڬϾ[çu!C#d-?.]ޔуzkPϣRNW!QBy5 nL߸_U=p,eC ˮރE[(u;~^*I,tDYWwF#1yߴG{-|R|o䫗/ޮ _}uYBTXBW[4[lw/q>I5.F.zŚoRޙ&] QR B=hY~nַIdGbe ߟw<$,X1{m/reJpeA-;zC'n1lbH7wzjVÚgN ݒmX|a!ٛşbCsx%oVA=*ns*ѹ„gmX;IȮԣG:M1Y;sd}:+ÛS8W#$MzoB'm8WŕӷgNH9"Pk]ᆡ]cGNي_:5oj|!;}II1]"5{Ea<\Zp뮱^r4kvCV.zgOˊfMU[Rc]ˋ]"u~Oiɮ9良B`~SFj\|DUs}R/R7_D7!kW}>{b>.*\RXm7=6p˝=S@ԸKDp}ur^Q%.^α\%u. 9s&F]^>M_̞22sxPne rÄcY;njpRa۸N''*NuWg298VвSɳ?VMV8{xE&%_۽CǺ{m%Lh\]'Wl\{m\]SAn}Oxﳕb8sf+?`+׻mF5ŗ 3Vǵg<v) ؐ"H XAD,`E4`/ AvEF슊bAP,9{ M:s!j^k=LknV<l?~1Y#\̤c>R_4AEZܦ,\X|RfGXw9ݵznEÄzgsayw2aQ?{$٨+w3э&Zںx^O^G1>|C[jkVchhclKyڽg:Ach?|ݫ'.HmnyaA/G_?qgo>~g^?N9p(ݴZroյԷqo짯?|Ssِ{WR._j9N:=~#&ak-b3W:FN/a`+np Q[WuBVԁˉqz51=)P1.脻ԁ2Q,o7q(2TpWlJ`7pc2 m*wFhv3tOc2ƕp5oѬ_n?z) ,)\t6^foӚ>QQMYkO'y]'ˢh6 _3gU-Wynbkmb%*>0x#^dHڭ)TØ٫&n*bv5fp= *hxى&%=WlXPxSt]m T*TH'q9v{ch?sNC'<'Z>2&?w\A㛝۴ێ\MlXR/ٶx ۾Ɏ^Of&aǯB"'a+`JՉ]z:y_O⬧8Uiv٥}B\'l,Ob*{1㣶,fJ }n։Q3V\ME|MSa uBQT'6NTb3E:Q:Q)JQ(%>)?}'P'@a8kG?r)'Rd$ztnGo4KW wOA4 D܇Ӵ]Q'q/؎%;6-;u0nS73Q'*W)߿j@s6x>/XY]In~ pIglX=Ċ4.U鋀v0sdDZ{ R|P8\iUѮ9z\w~ԉܴ+;Nw7ЪbLKq 5=:m8p&wT :ݿ~bYYU(kt22>.upc줳7.v*sS1-ӗJqǩBڇR bQ)>AJ1P'*MbL'RۑұX)fK|1& ~2XꚂ4^%?g!1rNQߠpT\uQJ᐀IC{뫷T,3 i?ay}*PxMžm 5UJ+ֵ4z}nf={w*A7ya+=j<|Pjg9{ƃg ߶TA>˺yjߚD㟶*v8xT8;;| #N5AnW o~׵} EHv.v;ߴt!w4uA6buvu }ύܓKŏ E7kWE[^:~G3H1ѹWW3iKvD_@Q-D#G.qRG=oWHq%zǒi#l 5%6⦭mudf@Q p/ȸo}3w 4fZlՆdjq"|57ыmo/0Um1EP]x,vp) ՀEІztj+1œB.y3[uশG)m^8ީ`LBL2%P<Wnj{ŨI%czWæm?@_C:|x0҇{NR'7qܴcۃיg.*ZF}ݦ/y<'?}WCbB>,CtHO|?>SG{c.={p䅔ǘڪAwUܮאMֺڪ{\eɛYx|+JOm۪T ާ{b-#G T*Q F pT*xLnPRB/6E*>RB1jJǴ/wգw&#E~b\%*~/y?EYe*DQӳro&.; Tw0.:DşN#1Z ,JSX^iH߰Kw>JT;yo(ƕwW,XŸ TS\1J&TGvϢGM>Tt rw<›Ag>R\i*i}R|v(8c47:Q ?ʪ[I!R WIC諗Y>uudl?h ˸pư"Уs;Jsmidd;kG?SJ}&*6æFO%^ٽ˸fX˯Œ>Nb\>|+x|mۛ{.7hx_ovOn9q:!z=^w-#55{W1jEJ?X".0wxۑt4& ˸\ Ȑ? 5RmP=fJAo4{;_\2./5s9Zuiߢ%?)յ89ixwe\dӓݣԋ\%dWhJFc< ˸"Nv7O߰`sn5D6cfCNxXC1=M޵ܿ2͎_Ƥ6;'0$|2e\"g9`a͎_Ƥlqܜ5᱉X#Z9iWcv5fprىq#-'gc.: kݪfLJ{9y]e\816\45oѬ-Qncxc-|V+T^MT42v/qyqzb\ڹ^N'zJ˘?ex+q9.o "m7fW|{c"޻v].2˸RH.⭋EL[Xƕhػj2>e\ƒ'K,c2W]h".nII"CV} Ņ[ hO޽gqU_ŖsIYO|³xb|/}#?C!SʋXrz+w!zѝafҳkh7omj`gn{ur3:)`p;6ͫ /ܺZ9rױK;JutDz[vDeܨy&qkTpzݓq{/bcݺҷe,.G-u>)++r;zݹ-A=vֲX[kY?o͞ wї_}?L%n{hU^ĢeRy??ZOdNʄ-9)T^'^ƒbSҞo9 ;rv@ߑWnҰNcq S2rJn'7xyL˸2AG:q K 9쎾Onrcyr)zyIV#2KEgn>#}ĕc+$\!~tJ\72MԸL1@<o9tFzN{9,BL.#}M .\hmd5{%9%qr4a2B|dNJ^Fmj^&eLJ0>i=i9  :ѻ.4ά=tԸL1G*]z Ǧr,W1gd"ݷ1`=4U2?5oh%&w''10%<CO252&\i9nVfۣ̠-O~b I3ۖN-k!w?(4QVdn?l5aœR~ >¤'n窹]Bܤ&weFMUI9pS\Ę޿L?{#YB\DʱVnJ Bc~\*Aʥz lլ 1⿉ʱQ1Cz38Iǵ٩Wo^4ӱAZǘj&Cf-z{bt+^=y0t'Z6˄(Ƥln:~Qgo퓬qkm]6kn-O/+4t?bջEF7Y:8cvv+1Zi[tmcdt})h .ɸu'߽B, 1Zwsod9_ $2} &詮6&ʌ hmsz}1oX\a2_<!cpnZsDDphi_PpDٍ17qκ})fߦ@26憉ҍuMmX?.Ac;FrlDph>0Qz|ģ˼WOGZ0}s_sazd:nW髩  Q"73C;Vdc:&JXy+ =3['OV8tJ 7XHwӓ%'_;u|>&U7…1VUlj3փq`A q~%b.3}pocdbLƽO,4xʹG}, YT$Hxc{7/5~TȴVou(@ *j {1~#M/@ZH||[N#7,roMrF3 5UnͲ0HA&Q  Rs2o'ܹ6`v;mل=|lԤe톎N;+ESR/ܶjTO'[2nDME1Vh҂oN| q/aHe߽~>z_Ȳ=M;kRjwl"+l}%9C,]zh I2 >{c#2"hXcac^רa^q(ҭ/E YA&-a2Iݺ玵ugiÏ; 217Xta?t["_#/K8'ε+LmtlF1m84{Qq B2`2HpKQzr©Ϟ4TOM VDEkNƽ X@̅BG  W# /bl[5l@ON\@Ń)j:==}-~TBRdFh&ۂI o^6ٮG7ZiBEMOMvbf6~F٫wZ/Ȣճ'?}mnf]H"\#+FV]_'[iٹ|%Y@ALFfݺ|*jOc\[qE itlG∋ d!o;}GD-  eܾz.6'tVSi(2\=bѲfg^B^),2.O_P%ǙwEmZJ^&5H+䨧ھcVo{TY9~uԈ{h۽[dV͔j?gN^IJ{ 8Ғ>c}ϡ{iU&Њ~hDU{n6#/X)P̹iمAf7] pvZs16-[0kE7\^(p!ldeBDƞO8_J8v=!fLʈTa!/a둅լ@Sf ?w _ K?0=ef]T%,sł.d2Z5n3xةsݲ7J"ȟ¢\Q\߿jp2 -k͙:v` /a!G YqsvZ,톸v+dy_//'޺6h8!vFک4'З0hSԴwr6o14O_~/庎8 yޣ̻IW߲vi^N=Mhj䧆?H lմW?'wo-c_M(94pӜԛ gc"IMvw˴zqN*07׸rf  GƜMM%\{a"_~u/Yh.]zwhܼIKZ kEkBv}{sQ*F-(%}T;?qtͫ-)FAbM$;'pĝK(iϵ,Ӝd_:so~(ԅ7S(xt0\@)naxI}C(+-F¹B,^?ypGbQD ;35;#?fJ ^}/8΢@Wio"EWo_<'7- o۸9ƏrTL)dS]G6;`ٺаQ14ʷ8߾3]4.%%k޾~iݾy%;,^8c8w6=訷S$F]qnc& \~ S'~KV7d9qGZDH|4Y痢&]OpwWesM5̱_o C=F)EAnܺEo{)X+"Xܙ+\<{Y|iIiKCNg[.d*x,/.W$]bp_# -ׯ^x4/qvVƽދgODG}o .:mU[X)AnVCGfӰ_ XjCνI'$LN! t>t wŠ_yC#[y9>L|31! ;C7Z0o/Æ4E*RSE?V)|hTIf.=m 1nҴ 3bOxԻ=Jx&|GdHzgOu@Ѝ.9m8_+3=[Y 00BY*mԴhl9 u2}ւ?=lŜ8}.k7n%ܹq~&!KiwNy=ʥsOuྰ!V-cS=݆:#5QSMEBLFT/H('Q60nW$3,^fcȶ]>{/'\ILvpO$\xܙq1ǎ3"|׶kV,?w됁v}zQ\h)Pe5MV\=&^|պ![wG<uXظ9}hT{ڱ5dU˃4XY$M~\h)Fu;zL,jv:cΜ `ђnuaaE_ ۹c׭YlI u$Ç:keaMOz;Q|( d(+)QӠa66moАG3o ,]|ŊXbK~?wS&z>|Al{[jQUVWQnܴy V4;ZX0x눑ƌ?ii'AiSL5}lz[ZwHJE +' X(7h؈*)ںz] ͺ[0e7#=J0rۈa.Nؠ&)|x5l \fER4ZjӮFnf=zkgo߯v}mmaafblحkg]۵iҒo|y(¬HqfJt=@kHwo`hdllR cc#Cztu4ipUjִb#qx=<Y&M+hڪM[j M-eA"۶MkU-7kDݢ""0sE,g.ΤpH7BZ**$J\h АD,^8 p9@sCظq&e@H"˅Ɩp" HsA4"KBǖ.[!HFƚv)+Zź<Ŀߔ 9A[׫cFWTж`I<LECu Csawu rnBaldahn>&C #?h]\BP}M Hݐ ͌ 0NQė̍ [%u&FE¯?V6w\7z\ύޙF؂L*S #cSf)*V+***tW>b8kCnx)*YVP$Zh{V#0ڞ .mO_?ݬf4nƤic}o׺ԭgΘoI;w:o.7XZ{Mi=琱~<y'C3k;WȐ7.sfz7{?ooW_iq8f_/9 F?>on_K[J}& zB1W* bI>=D0W\_@~[?-5i >$ _t/,M-L- Q712%ojb~] /ZpD;Z9{Edbd`B*ҍ[ =e ߰BXi@˃*z ѳ@ z ѳ@ z ѳ@5_$k'DIXH(@P@}ºAֵ ʞPX0(?@ea] e6AXR`] H|@X] am XJº~@X_BV PB `_XyM@zC(jBYPsXce]@`w!~P^ !@^a yVM BX3UX-a,z?AF{BX ;i~z@ Pa7 !z?@)a V^BJ9P`!! 5 !--Xel @Ma!-ՅڅʇTkB(_PYXU| @E^B`6! @IXI!1"% ~M ^{BXzAaU!>D ^[BXكօ@a . !!>X!Oz@! aF @^B(0a. PzM@ZB! z @`{!XBAP9J`k!FA:B(͂u~!PuB,j9BYs !(XBeQPsXBeYP}XBAP=X BAPuX BIPyX BQP1s!,:!,(ֹB>Us!HPBX$(u. '! C!Xyu!V<:B+:B+<:B:B<:B,:Bk:Bk,:BkOYuѣ  cB(=7/B= BPe !PB(|ѣ  U~B+kX?~!•5?B*XB !Px ֱB(x@!Bu<  G!:&B+TXB!{ @!da!:!A!qBXJc!6X Ba)ºWZa7!u4:vBNiu ֝B!}eqBXc !5XBa))š++š+˰-+1ª+/3ª+O5+o7+9ŠWXBa3c!| Q:BK ` !E"AiXB!`!ga!gAŰʣʓʃzʃʲ!ʢv`G!EA:B(KڇuN!PuB4 Bi=s !(?XBIP9J ! `Ca Pz-@ƒB! u!BkBY 5!,!)>X!z@a] kBB ;^KBXك.D ^cBXzAaU5!/~MXA!,Kİ^B()%a&! @y^B`F!)ZʗTkB(P]X]l @Ma!) !-mXil@]zmC[B(]P߰^BV^Ba kX0@HBaPa7 l@#B6 M/iށ֭H;n@` # !j os zA. B (= ! <+"z޻ʺzC(kjBiPJPZPzPBV ֕فu=.  _9V$Cʂum+T ʏPX0({@]A5 _k! dXHuKPXTuIPYXcuk4 뿐 ]Aj zu7_'&o1A2?R#|__rK2cS,A|v~lРA ~sV,aL"N(A4?4; ظq&AO)М&N&K9I͚+hZ**-[(5o֔I/n{!YC>"9"'Eq~TT[iۮVPo߮m֪*⌑|te[4S>jH7QfJ-UZUؤl uҤkJERt{5Vb$q䨹RKVmڵH̢U>6}]_[>zXv#먭Ѿ]V*-lq{(YgOu͸kivԜ֮߀N.FQ#G8x@?;[k1sSn];jkqjD2\U.K$BYu;#=}#S ^} tprv9k)SMϟ0C?_O:ex]kH_f]kUdk& Yj'l$;ZXo?k,]|ŊYbK~?wS&8_>V;v [OBC2Hf;eU$]n施m vr1k4Y,Zlu7nݾcgX=pIvܱ}k֬\dQyL1ip?ޖtiTID*(KHSjBM Hv3~/ZbM![wڳwȨǢŝ( ZlcG"߻g׎!֯YtgN?cPǁvdkd2҂FUIh/PiY~$_9 XbƐ;wbN9w+׮n~Zb╄/;s2.߻{֐kW,cI^N쭭̍;h*WًADkub`ڃ!L9?`񊵛Bwwh$^t;ݴ3 Y/ܿvN\?O}}wnZbqӧxvwBvVS.KoĈ6ӏ $dhnea'IҬ,]!d}F=udIPݴY>z$y9=|u?#n*I5bEo `IP~6Vj#f"YjU<.K=m 1nҴ 3asLN~?+g_|͛w_~/?{e,;~.#:| |gK̜6iǰ!l{rj `[In&ŦJʭ4EYrKVmݹ`T3 7ede?wӧSQ>}wo=8;+#NĄ3'aՒ~S~$ʕZkeVGޣHl t4ڪmE@EcLf7aȰ_}f_b}讈cqgi?xCrO|?oDwq9$[9OɊ?w,2bWeؐ=Luȶjބc,Ly*mԵ -z;xxM'Yڰ%lS$e">CЄ|¼фq"zUll.Y Neɕ/zmT (&fۘ~WY9{<}%u>[H2AbJ$n0.H%|iݾy5t &qshciҵf;QTּ z9?mvu,e>!;ST0q%Ӈo_03-EufO?AWi<};`,e=};$sT /.[E"eGYi4WG ^x?VNt76+yIUQhkjli;h>uAWoLRTW\]8}(|ۆ}=4C;ijQԪ0ω Zks 7R$TgI*;YJp.]:sC_+SmEg`;vڛ4w>fߖ۲PגD*2IE7OI|C{[یc;2RE{ цKS?'q>Vlܱ/*\Ԍ'ed$z3\lԾWԏKU:V4AF(M Z&ɔwN]:t/Qod\y0M՜c;0VZrSnփ\GOHw>19ԼDyV^ x>*pѮͻu[b#z?5͉4#`UȞX.M9:&IQ*hR'dUI8'SmUT%O4둓S;-=c+;.v(l&qIn[nU@bm^oH';+c=vT%XFDg5t :7m ;F$jԔBrztlΟ6nmw]-O3E"Ȭլ@ڜ˷ ?MRw"]D[f]G6?vjFIs :yƝ'O Mz$΍'CI񳽕Qg6ͤxS=NJu-lFxM_@Sk)_R8T)t?څXڪL`c^UtrSqSNd;?gN 0$MT}zEt2UDر>h=ɦJg )(6WQ&}-r)wIO:DWsVutߖUɦVSiNTR)ѢF']FOtSXd\;YO4]D)L9z$΍ȰMKMҿQ'-nf:t5vp?㏵w;4'n4QD Ҫ2n?fws6ڡr3E)SD -=}-ۼ'[iٹ/ވVEnvڭ'l^6ҰcV♂u *_)BϤ~k8~Zj&W$ipgO2S?~`ۚ?zq3?~SlҎLNLdSrU:FTտIOE޴d_lLN?ii=u]C.c-\%"lH.i[՟dMeic]['Fŵ'2푲ggۄO؍Jܞvз4L{IL|B?I(|"TO2SLfQN}-;Ux'rh`uWo;^vNd֓WW/݈9mǺ4HnT4O|{d{Ј36>+Qw4QDWg$':!hzw@3ō|{2qڹyJOPy*r3ذIԼf,T9S#~jފú"OeP:SUu|jF:m[q$t00jz}1ogDB|ur̾MG92FIoD堑S6{3Uh>|ᝄ؈MzpK-E :HX#ĺ=їn?᳼>puۗ#E? :P(@=Ѹe;]Ӿ q7_Anp ]kٸ5(m>Ncg.yF:2EGg".9֩v])EQӃA1$0U~{1(M5؊8MNP,a_lB*S8P&[ ur"bvMl]&[v, :RxȺ}Xy\lM:kذ77o7mPyDU((l>@'\c/3Lu'ŏN~$AF6C'[ԇTum^=L||yico-tPhBVKIKd^ g$R{:Xcoܓ-ُ s 0~F/Ivr?F؛kQ'QF u}ݶݦn8{33[ݚe<%pY綺by=8MK݇}wi;ɩOO|y]-[H삒8㫹3%ٷl[7fEךDt.w:,\s- I$ib/quADE^Cy19I$9#w;W "B48$tbDI'2O,RLzݭi4ۥ[X[!>H.ѭ5ޔbOIGh~85o=D"({QꝢ EO{h՘R̄{]AG3|'6#GBf;0ުO)nBu2WD#^L)ل28ڗ'<-A4UiN=E=м:SJ>#{'/kYlD$z֔bPm u"ײ[d{7Tu/ {J*Rc)Uս}L(#| K'>S]zDWrZKKիC~P a$`B)I)EVy; :_nֆV(LZlJUhhQ$׭^zcWi{ck sFS/z97թ:̍NbS>ȧ,Ni'\nVC=wG%{ ,qS񽄨CsæÄR)i֭~Ԡ:_A߯ԇ8S۹{A즷ReN 68}#3%N&e~6,v]es.&L)z7>r2_w{ieB6v_ⷘP3e\ٷvn:$ww%H\lX6IqGHBw/Hz%uv2njl 41 =$ճ BoK}Ql7(}1vɢrE,NZϜN|Xn3k37 HRzWRuL^mX#ӂQBăǶMIR>,'ň{:$=1ס{o'&*)N%T"ti6Jm\Ի{O{FM_=2np* 1t+}Qv:Sk{1WGOs8*}!|9_OEbW&T*ElvT23{^9ۍOP2Kd:p _$O*W{ 4Ľ{r4C[Yp>U[g7̟ث}󋊼>B?eJ֣Oݹxtk~R?BmԲo i8S󾴄;WK:lT#XI.Cx oڿvYdKM'2UbzanCy;}db>@~Ӗl(UY^&2M(Kl:t>9Y'i"7MMa>գģ7 .';|NuW\/qgytv|;Q is82iC{3?wmBMqRܝ ciw3jκx,޿nwKJQm^l/A ߯Y+E7}n &9ڶiι wp3?q\Nsz$˦s!Nw;sF1G!t>~-qYXNiO3F#٫,ď޳DzW(;sLZt؏~8O̱l~ݫm/ J6}H|s}?P>f|0QFjQk/&Ƿo8H6?0̍[d]&G<0&17:x79x{aţ[~p[ߝ'[-S>F M *O|}7h QYGX$4/=˲#7/~pn~+vۆ%h*ws+s4L\u<+Ưdϟܵr;h_F{IpOx3GYScWI ΐcPQRҨ8[}{I4>ڲ/FūFe݈GoH~b3Y>Ŝ2S:u# 缊=>Ϻq3F+Q7?ըNdnGᨏ7sC橰( RQj"4JMF(5&BDhRQj"4JMTF\9G @;|Rwx3x ISHxOs}xRV@ԓx\@̳4POsQ?O J!>+ }3T`OLF>-'s p.&} F~}ѧ&p),}4 EIg~~6Ṕv#}G DQlg~"~ơPq*} C9g5~V T% %x?)<ٲd*bceus}qxlqL$q'}udKOc!M',1o}`I<ߢ&dϟS{sYFiBd i8CܹDZ‰_ىe y2w[R|5ҷ,Ql"$>ۇEJb(L_DHg-kNtS{ǍSakfs77|NbR<k -H1w:N[G/HZQ*NsW'{ѦCsXT,D%ڴțnwHO[ޡyJ.QOvW$u"œ.QN巻Es;~ZжH./)E=J4 ߠ%JH ^BT E?D.RN*qDEb]G(n;)w?z}*o ?wp-Rz&\n=z~ާ:1G?{).L>Dnn'~V1T$^=ͺu&|Iӭx _l6ýnr];v{aORpNwhQc'S/`|K|T"Wzr U&y<=/T#v+>HvΗr)yl?s3QO۔d<ave|]Q i_R㴄]=}#n=[qBb#[9_Y38a%^g&Ɔ;睯^o_/ѭrE~ݱA;e>dg=FzJ2E&O"&9!Ѷ],z,N`J)O̩%v5ne>ZqZQKreDR3N(Uij$&JMTFu+|Ck'IX/zw2N)G،1֒(aMDE hJ8a(w]=2>$&D&}AJ|eRD vJ8ף N }6#3wߥۏ]HŔRd*7±K}̌*|lB5]X,R$Pa{9l6/RwhetJ!վJ#P|Gd7UPM>7b y&~7Sn[nJKyZZ{W4')(7Va[~J/jcծSiWW %R$ =rvV}zkK>RuР$UkBNOfOԕɱZwDY3w=5)ERRQ:{~~[$߿yЖidU Un8>`.[$6~۵:`u*T& %vSN+FD%Ѽ8/#tx)vUCR::ȋt׋^+b9^ܱ^UsB)E?)ڪgze":D-& &>ζNgA5)Nv]-[HDZK oٶo |] NZ$^DZThN= ՜P^fkF"zٛ Yȼy6Ds7{f[tꋿ}ӐA};5,Iމ?Fsz7U[ᔒEɋ6SCjSMk q &n>HIbvI4WR4w1wpČ'03'n`ޥF\qJե6V͚:U-/ .DX9˓$  k坢 ][~I).Q;o\IީyN1y}7Ag,q&&s@ėx&b˒Yvk_$Q: "w;+g&Ʒ*`&̙DmL(6Omc$?gQSr']ѫ*]ɳܔQ{-_bF~͓ר0~zڣ縇Xuqw ?J~*$>wb|3ZPDcj~+=w332z&y7ݾӑ_㚞Ik~b[gy?:^u&~l+6EcE͏$.޸?:tHwo\Yn}Ws{HLЩ |/Jw]+gAG (=D?t#b{ד 1Ȋ th13CN^JG>"Y/ 1 MjGY8nfWSOwD4scYP #q?˶\I)(FʋSqANʕ;)=H(vnfH;O Xz06H }J=<`si͔$:EEn\fNO Vr`ޭ*Di`v3=g嶈ST9)TĶsG[1(;H6[wtp:t*:UV }z퓫ewHAlHHAʫ\|h`>qUܩV[``asRӧ :;-ۧw_gs#N]WZ)w\R*+YOzǿmb'YWb Oe;=gEhxDž%oeb7%3.E*>锃[K-Ì'l]>A8}RTwK^6p#A ZdӋi7.ۼ,k=LteP_]W= UTs_OtV;e1yNDk*$N飻Ў?Μ:ڴsLjHh~d4nU[d nRiBsӉY)$ߺjqN,Լ$ Ot)ʬ߷^qSoLA'Y4ULDI/:k߹ό]bb"Pu 1oOã.$f?!]cVOS/DoilD]I1BQBոE;CK;Sؼ7̕ :HSVqm"aN+g"n^1oX';Kv- {yKP5#';t'ɤz5?5^ʼngt:c]} yYu,vp$b˾Wn=?u]ʼn;߾r:r"<E;P$PkޮS [Gw%wv'!JZ^810ε;/ qwѩ]sb{<I{1~Ƃy70V^ݛGl^`z귦iOmlO&6/nVr;-'tRV.Ny9i=~`Yߍ֟Udnރ\<[qg_ʖ*h&vqzȣM+>{%;=Lp>(m|I%ƝѬ1'R]iB:{FJq/_V+~z%:^|NaEm~xYجZ#&WBRٓwm]|ѬܝꑵI{D%kwjjuU9M{N֫¢~WlVi/O2=Rh^u)=v):mVhܴkiЦ6Q[ոiv{Hq&AP^I(i዗^amvk_i{yv/ =ʹvG֥)xKҭ7L~qL)J:B\!=z's&]?{hmI{9|m/I6QV-0mMmv5{A7lu%ڬdj={^TLmӳE_lHh^,."={ ;36SQa;nXptq1756oyjipdZt.mGz5kkB4\|[w3s=/xVXe i12sҠ/ ?y~7sIfE|i^cIz[w6 [IwRgyt)ѳE/0y}?3^Ꝥ[Wğ'=<|`/!kV.Y4g4oOwǁm.u]+&Sqozej֫ᮣǓf^7jo`Dbac0uY/Y~Sϻ?xѤaq/]Izz eE/%^~5ʥqA';|p]?nZby|xyvu`kc!1fd^KL WthZŸ̬҆a.n|Κǥ+m}2ȣ߷{ϡ7 ^DžsgN2$KiO.ڵ]ҩu c+-۴ק͒XX7^͚`q~ZaӖm?sמ={)^ػgϮm˦ ~ZbisfM5mӷZ[Hh۷i\Wa.Kb4jҴy+ڬLDfdj 8t$|5gXzupOz˂X8ά}MS,E 1mDOѣ\ `LlbŰ#l}5TU1*mYt Sv[b3^6}lvnnjHcƌvw9iطea&٣H-)mTuUZLuhعq"ܒt_+H,ͥQ];vЎH:ʚ.UWuVFu6oѪuv :u!31%iŒH"Sҟ.F :kۺUMuI$tb Z"Nبqݦ͚hٺ 혾Aǎ> iڴn٢yM7^GHݡI:-.::ּESei@'m3 MEթS:dEvAÏ C_#4I)#k3hØ}D_0aD;Ķ=R)0egK= BT:APOAUO^&LЩ6H`3Ory¬ot֩+ҳSTo8BdG~!ZI/$bKDTo"?:u%fzfDoߦ&bDO"XYI--L,,H~aN]T*1_[~9osS ^5_!J/?"{//Rwy}[tuXM޾ fbS b&[[ %GoaN]WVz]깍b~-"iN]suC:wJoгo*ӗOO[S&L?_w.lgjz=]~ם~^[/ޞhbsg3o9@FbKK)MTL:u]J4v-M,LM-#51ٌ͙~he""NIM,zRBl\4#\"Z1L,LIM$fb梅 dM$R+?|E BdLր ene-I,&VR+ yO"1JKSeًRȔ}URr̜hj]_g_̫EWW4 *]z$&3|4wvҤ3&Я.;n. 8E\.7T =ROҲ#BLBfdXr#]lF_iFXʍ+S:#sݴtPI c$3ًIً:+&iq.6ƹ8pÜN1hb3+`"e0DdōS2fd֐bs3sIًf&0'Isr80'=YXUD$^w`jbjIjtSAG QJDdвR~QBJf *,諠Tb^JKҲEd}TT_g.~N̂tOlA * d+/(~"d8^h&[D&&{QRoZ-koW4PJ/*U}i8DZYM/ (j`$"[{i +fr2 ͥlZjebI.Ău 0245%]d%?L&"͆",U{d/~N`%23cc7K/*(qE YE$"Zl^EuR_W4VF@]o˱z^:r/[HM5zcؿߔX)a]b))Ȥ6S$Eb"G.Fbeɤ.%^.E0\zMiP,3ًtޥbD+wE/%Ӥ\z/3kp册ƾDTݱ_vj8$RZBn2H̥W:?\ *pˮtؓJ@^q@bn^~%(aGQn%_T.HJ J˯Ų+A]1Ü}n-(;Г0/Yļhat^ܯ{7WvK:u{3eP=7:ǐF̽y 4ӓZX!$ՓЁdN%ɕr;?"Y,nOHCғ^zfb u 9AAA<r  a9APyq Gu H4߾ @UU|A1O APۃ|ŷ6AoOm*U,= %=s'F|u9@T{N9 &,n (APY='{P H@6y@mA(4E|%T?R'=_R=O $D=/"$$=:ħć+*=Pw H{|I@2@ScMj"/UG|[h;)=>r "=. E=i{isigi_L=Y{<{{T{T^{R=N [|p)@x@ \ aq>{ ?{z^|@߽7w\_)G|y@/{ L߽h6PwT3?U_|]@U=h'Pw y#>/{7T^|>*+^@wh=Tm5MP]I|j6@Mt]_-4Y|jM5FwM@h%( Mߵe ⻆l]|T:*QG]3P5(k|NV$k|F7 kB@6 ,kBB& 4(kBC& D$kBEH d kBG-?i>jP |@@@=WOWz^ > ^ |x54x5x5j>i>j>jP | | |@@@@@=WWOWWWz^ ^ ^ ^ > ^ ^ ^ ^ ^ ^ ME5(  ^ ^ ^ ^ S`WWW~}@@@TF|Fv |*+_'h3j>j>j6U߯x5 Mukmu6aS@[~ @@Ԗ~@@Ԧ~/Զ~?@(C|'4%hj6 4e*{uGU}:aJ^@]Q~@%7j>j6|:z  :zuuUn*]|T]GP&"Bw=@h)&* @m⻾PSE|j6Z@u6]o**UAw2h?|}Ow/"!P/ PϋrO@'.fGծ'@3j_|Y@߽hrwgV|~@_z/@@c L 1pH{R=V3;CiKiS\={j={{*{>XT }($,1=6,9=Ff {m@Pu@[1 &A)3)K|m4R3j=P7 /=P o=2$4='$T=7.{{* H@@9@mA,5I|7 i{TX=*s Ash'UO|]@T{{> T';P/ _|. O|  jGog&}!# ۫oD -}N B!An7A5~  ]!   Hu>%_FHV̿|Zy $T_bbF^s_HL}믿b!1UW8oDb՘Էnzut4hаbѩ_^]Ru\QˤʤȴL4lԸnf͛hѲbhѼyM7j؀-98)83QT:PU5fJܲU:tةbuhס]6Z2%gMlx"y*7j۴ymH :ujT$HI"LMzwbɀM͛6n$7;1eufL acf-Z")[ӯ{`?>6,%=II[h۸!1rfySgY4jBr;=CRdSM[;CGts=fGy3mg'ǡlXKLI ݤQujl[iߩs7u6f܄I>S,?oƴ)>&Tp+W, \pl铽'=eЁv}-$=;w"E3UMM[ӠeZ2ex)3=Qkׇl ٻ(L.{ٵC_bi1{(aCِrw3ءm+:j mZ/unahO). \z͆M?~HQ'cbccO}2#9tӆ5.;wh] nJW1\;tԬ]G6s6~Sg_t%7n*^H~j•KΟ=u;B7 ^piޞ# c%%۠=6q5hsԹ3IE6:{2Yd嚐_8tSg\My;9y~ffF)ɷo&^wToKhȚK2i쨿ԘVn# Hyν1iEA+nڶ+,o1g_~{Y9=/(xJ襂'defKs+v,"l׶5+nmM ۶FȀnVܦ`c\&dۮ"NO~+95-#+'O (~YRKJ^(z^ ɣ9Yiɷ'ğ;3tCΜ2u ^R;pC $|צV%wMȀDvcZz֝a/]KLjLKo޾}TPw޾}Wٙi)I7Ɵ=q4|V:cC_Kqw#vh+ +6hFXdq״Y oܶ;s̩s>%Sw_7џ?H1Y\Y9Sn$\<}[7Z2)ܝ٘ڱ}fVl΢YhgйԺg Soܾ`32>+(,*.yZ?_'U5L_=ʹrS'aU\ƍ6ؐH5 n֪}] \asky\Iӊ&㛔쌻dp:kM</Iw#6-t5،GB`h,7ȉ Yۺ+IZOh~I3[e:}Ne*Όpo^3y{?ͫGvl ^2glL:Ռb!׿1ng]bm7e!;ŽDxVjzüϋK^U5z3~/~E{'0x/"cnf>.` ,b{3VEal2/:7t~nӜ뿩Lnv%'͘fN_OJz_:b nRm$/>Lp>hyb0kݬN]ֲ_y1]EGM1oн^drPnpCaֽkbb/_@GCoiwV'a4bҝ{Zu?mnmD ;UQՖ m9/e}'w؎Xv]Ebe s,M ۷hBOEd"Ѷcws[GwxnB֤xwiˋ+E,AFR™_ö&=b: ̶$zw`Ֆ_&$g<,x3iAJVlEdߥA>;Ubmc\#ydHz%heyaFr_öZ0پWO-FT^æm lL|3tH i)6#3#v 9KMt֑VzĦ;XE£.$~.[ b\S:Dݴ|ю[fDjӦ]'~~˷!ouҜdMs؉·/>΢{6M90&-;t3{٦ԥդsv`L}pרEn\h\NzR=N "(Iךd&*5t݆wwTʝ"͉e%u1U֮4_jR_g4Pmج]gI?˶JgӐQ ڛZec%ךy 593h˴=J =Xez_}J̈́֝z70$,Қ *J$2[H5KذD|RVWZ$ ]0ѹK *-q1O ) >~v擢l2P+1[Op'ܮui̳w73?'+Wd/sJA3 #2lxͅ]\ĴGw--y/ 1ԦgG6zsS>ĶNf-v{C*-u^VROs܋|>0M'>$-r0jjӗoܔWVl_=ɼ}]kL`R/􌉆3CG_N)(Qi^?q_Vv#1YnI36uz@=fj޽*|x$6թ]lIj6g]^}4P]%ܽv֥39ٱu-/Q%gq o^>M?飙V1&:C=Ԗ(RkzD{-KcZ52Ĩ{#1ige1YO[h({5>6rMW1KvZӥ1+;l+6l[yv#|u9%V.2qKcѓl_hcbܓf>&I:ǵl9 8}eBdal5xoЖ3鏵بYqFbח[3$[>\'/\M5kZ\iN&M,Q62r>a_~]Rkׯ'زdhb!4`XhΣZ+Mkͦ4-Υؐldj2RHb7b >>vfť$b!+gCEú^頮ۈl^zz'6i}9/̻́Yl^FXЙy/;5c!Q{{_ӎ׫f& Ŷ.dG!C&b׌dݎ;}L!V2aDߎ_I6/I\@q8Z쁍&IX=#dMl~7,j"GY1%ٷl[7fE>6u05t+vD^LyZQV1ZL>ζNU|'s8$]>>leIHcFc5 z>K)+le${ |nc$5-psٛ&wW oL׼jÚ=zӠ' {̄J{T́F Ұ j3A"DmPOs& k4oh6>*Q[LwBa3uޙԘ}wlH%D,IYP2L}-CȞ%=!iB u{{Ek4HVI=Sjk1{5Mj^*X0eQGuzR%#F#l=x>-C]i+}e׶*UK^#],tI].̾Iol ԔhSV24iѦgofPW(X߹|'f`QX㭄C[8WRh׭0H)Nb{I1;W0ADa;{ɶDZSua3QMp0AGGu$ַ. ]\I&%Hr̮JDaGtGȏjE$A7ޡs9;0$'i]L KOv0aٿF~TWDr_;-{^iiroaRmqAJ|Y;$ޔФeGc;a'aRmqܴs7.jԽršDuf;r:2>AkGuE{+~8ن9r*'iT7еpsi̕rG 39f& k683U/qȏj${iq-k&;ػN_3&9ӆO^ԽyilZ5̺[aEq9zȏj 뗏3.EoamPnXzMΧ=gߟI"W a]NX3w5- /aUȺ$ Zl5{ocWF$j>́Dfju.=&,6Blۻ5f $Vhˡz b.$:<;ۓՓB/y]6b5j[mu= ^X3bjV#k.X?˹qzϚ/0tCPG,q"W 0lIkX]}_dvny,%jL_5`j)J[nZ9Ԫj@x,յ]|[~#Eݶ*V˃ȾL-$qq `{BYMHD| >LsT2;s~.X];̤;>ߙKXG ̓c–.sɫէRI3?st~FԾb X][wkS7r8CqA~e/j))"XmN5hVםyk*X]b`u}Vכ &,&z0lq"U '{VQq^]{UHC_µE˸b.%Uxs*w7IIH{ nĝUE7j[Sғh_Whga(яʏ'&ӝN`?J0~nQz܁ѩ_(~W6GBDϘ׉'1OOؗ Y|N66'ϞHQ=kgbԉ^fw0M>Pn>oX\YT1Yx"t$zCQ)pOe;ޭ{l^Q 2ACzX240ѧҫ]b7»IG7}ޱlh[/VEV3&>xg:H=+[&*} S;/o, }ޘD4gmk,72-r.NYHDuo"*:34o?zGH]5E@H~Euc΄5Ӭ'_uVt #q[˼o\ڬu^@TOL~ᭋ1皵ZC[zy;x{jɏ7E7DǼJ9Q? 6W]Qy ?:Һצ9kBݫh~|mRkVN.{&doWqs w)e^{m=|xJ#6q}go2;Fu%b4]>cŌ1dQ,c-ZgI1oݿi$>̢XhyJr;ƺbѡf^R̮`-Ћ\!Χf1x^VtMǟŏ&^3wBMt:~+w*?W٩bfuLFM ؼLJVA Vݹz=`KOBY 2X"`e,#vM,)J9sQv&:U{E'!pÞO2&KⱰXucB Kq[Y5I=NHUO?ʎ5=V`gϹkʘ9b11kۖNwdUElf?ڛYD? k⚹?6NLC͎uZg}1-R*6>^gDf6WFC-*|tetuݱIlF8>r`M^ WF R"NĬ5(zc}&-{9z ;z1=5"ǁ\X|M,=#i@pp쥯EﲩP+Oʪt-r-9@ǝH#l>QQMDdHʵ~$Bva["{[y~ficZSUk"5-*m:YF|PT&ߕK#dk#b.-R5yg.#=v!MQwq^3NF]@hX6Fڭ4BZjY;- =x6% i 'r[b"-<ά ݼH!>imd3sN  ^K#$>#:n1{tTk@XM#q .fv#'-X~"1=Yk4$޻v@=Ik֨jwSWРf]߸'WU]E)s5g# {gSr5ӧYo_ںԏ{4bYGeDWso?4hޞ,$7>zi"lY0͍4i 1޹[=5SDu69p_N|XH$,Ibs\O ^s? W=z5 -\-*&zFN>l6"Ѥx0#~zҙML`cպ9գ?6jҢ 쾎c&Y)" Y`*"p?1A;)co_9r`O.̂(EEDy]{ vd<+yKSD-2t7Ņ%9e|ov=iLQ(NsE l vݽܲ;̼i49iv-y$;#%'8ؘu@b]4$DTtff2b.\IQd lh;(Lr&z֠Ɲ&EΤMŅn];_8Zik5SPL% 5 m= XmﱳWR01z|_/a{vlբ)HRvu9=; xsXiw<, MRҶsߔ($q+٘!kc6Ծjr(KN"lqVnؾp+7n~^Bq[v*Hr\<}lN݆tCa#[u?,[qD:y4Hjf& 吁r>.zM'$"N7c+Nn}MF;ֽE/J^}AZ͹,Ϸo&_<}mW,1Ѿ2b" endstream endobj 274 0 obj <>stream ݪ]'xmmJH]՛?%.ʆu޾&A}θy5#{ö^2Ƥ#YʋT\d7lLb]Ǯ5nҌKVmޱP̙ -6L&.W fy&>F ]>cZZ^.E8ju37dϬߖݺ3q;)%-#AgwE~%L015i&6<}pm͘7`]{?L&v0)lj&ab11q >HKIx$yc3Np1_O3.5Zz'GFS}i#UT[1o)~\5,rt̩>z^y't:c;1Sw11ͫbr9YɉOȡlzwնerg41ҳ79ҩe{g 7s;ٹPɄ&߽N5Ço^*yYYy9Ywo^KJ{a5Bv;|䩳 HMϸ{~փqٳg?~p?nƭד$'.G#db~ABnٱ{&yϘ5oeAoںm{<}DظSNŞ9q,}{##v#d+`iS&zvrdX?bF*-[kع@#]ǎ8Ň+VYaӖ?v *"<|g؎?BlڰvueϞ=sÆmen۩f[J 9bFqqfUՉd{k7p0Qczx:wYs--`+WQXtIo >m꤉\;߯^Nkn"1 3Rv706mk?p#Fvu;n篓N7ߌ>S'9~/c\F90?^VFz;inڼYƊ3+vlն]:]t ,zϮCvr9u7VcƸA˺~ej-4+5jӏ 3+R2~ӹk7=Cc3sKggoߟ]?[bpO+KsSc#]:iwh߮-2f 83o݀rf*-T[n^N]u׍MD26624۵s'kkۦjsevsYD 'BUzmڶ#wQ[RoڢJMhbY&Nꈱf771qX܄B\WUSk)*gpJcj23˥ oD~3=zޠ!zc{߱S SV:˰ 3:N,3׉be ff@MY7^]4&OU Vm<}{ t:~l/O^5 _mN4262dM׷hh8k?hyy9O[<߼-m/, Ġō!LOׯ oO>~^'Oup9$12acĈknz1&U"#",4MЂ/M揓OR i>4527 =ߩ AAA՝\w  a9AЗ;SAb񝏲AP}ܓ -sM -E k;w6Amߙ/ !*_|g K|g>#lAPu ?@$]= j'bAP,Pl X| Ae$A.A $ E|/P@,%@, Q|_ D HHA|@ > @P}Y@ց <Au!y%gyj#Ej"E*gE* A| IyT =)=@O|f@! P{ |HH<H>HH6H ='-  aHX{3 k-߯9@6O|{dn ߯+@/_S|IO|~ T;(PkPL @kI/k߯| H,_ 9 Y|{UEŷPQ|{5AķPSI|{ AķP[]| Bŷ MQ|{ FŷPȓ y^@]"Ckd]|,o>E7(=>%,oOdA|{|# #BPŷ' 4(=!D !o/@I|{BF:B,@@NAVlწY 9 Y rdd54d5j>i>j | |  @ @ @ @6@VANAVAVlნწწY 9 Y Y Y rdd5d5d54d5d5d5d5j>j>j>j>i>j>j>j>j>j>j | | | | | | | | | | | | | | | lߟ @ @ @ @ @|M|^ @ @ @ @T$?7,j>j>j>dd5d5d5d5d5d5d5d5d5d5 d5d5ڈڊd5d5id5d5iid5_u%.' lR|m /Ե@@V5S_YY ¦> J}dd5  YoPķ d"}"4o?@hU|BB BFVķO'"}5oU'*}BŷPȺy>@]"/G+M| u/_S|՝~mT>P @+_o#_w+_l/g l YHx{& 0/g/Y${{f ${v${u $?{u${_=[ɿ1@G|@)Pu Y}.gPM U&gE+gEj*gEj+gy)yJ|6յqdo=ė}d[| d Hh{AB_@, u=@]A&6$ A$.{A+(ߛP ^ A5߻@1 Hz՝A'߁lA;p Hx;0 Hvw^3A9UO| @>CT=; APw3AY@V|<AT;d !,A&s AT{a  AAAA$C"ɅDfoł結oF]=beE3~??T?!% X9 6RRjܸr"DIQÆ kbgLL37VnҴJjj-˗j*͚6QnZN'3?S0Q.S[nӶf{:kkۦzKr87px Eb94UiڲUkbVG]t'cc#C]:t"nRJ&"p|fm&T:1=XE^}˺~w]x{bZ $P$Q"7iFg61ҺÆ9u/5f#s<֦1\[K-fM7j𓂻]O X[kةUv o&XofL:ycF;:d]Vz];ulѺ*np+ے>PmImoln۶N\ǎ7kEK ^V,_hY~ޓƏu4tpV:Vk!1 ۴o}nFBlban(q^}f̚0`56n cGDbѿ㏐-׭ ^$`Y3|&{s8Ȯ5Nvmpn6[ϭ5t[9y9 ^)䏰Ȩ9GbOƜ8}@Tdx!֯Zpi=] wnO9ucGv:u30Ay8g-XvS]Q;u|¥+I׮]O!!)׮%']p쩸cGFEimOrw8 n- 6I&>7flhnݷ;y+Vo 㱧^H|Fj;w3ge=xM#g޽s;=+Ξ=~!WXp y}+S]궪J%9m@+5QQmE}i;pH }g- \fSGǝ9xZJꭌs=/xZXXD ?~unƭԔkWϟ;~dЍuC+3]mZh$tC2-۴50;p_< Xzc=cNKLv3=܇=Qo~.ׯ_,~iㇹe߼xTLȰK3}7g6=LthǍ6WHF'r32:0t/^3|RrJ{Y9yd1?߽ߩ񹘏~?߾ljy9Yn$'?q8*b+M6Q7v􃜙u;&9ZkjZw9u%BwE>.؜ Qg/K^1 ~?/Gw|$`URG23R.=vpO+ϛ1e [kS.4[ț\DVSwxLa[g.&16=.(|^\ kGj_XKPөo^?/,x._8u]!WuH- uI4Sn$7fKB]cvc' X>{6[>.(z2212qSljdrI'}&Hέk-:u=hVSFnkdg023.[ugԑۙ4"̺L'W8L8KH<{p֍d훂8yH?kSNȇt1N7c+Nc ڸ=P̙Wofd<*(zQf*z\"K2IYl){ɇLv|#gtw~#N1隭t6sqɛ?ߋm\_Nf憻~򹓇l0{˰e̦=oj JM["Fs1ъv9{z=dfv7%/ eIMN8ul߮-Nt-5i#dS#ͅt&iw3i?eM;F]Hgi,i7Mofۯ_>81#B.[jvkUb\BZYeێVvC]& \u~ן,E%憛MUqQ~^Vͤ#[:׋m]GUJ ۿmu5g5!c^N}?IыWdG,a7GneMwl>l&ݴ5ԛȖaFf N6M&t49|.hIjf_K?uKM2ʨk$)·U;Hvhhw35`/VDv&Gv2n|jEbuK5AZ4a 67JMU[ku5"!:qƢc/\M[nRţw5;2tMF m][rCYґVoga3dio s>9^n>M.g٥]jvB!'lomUjS%vHks)suT2^2$t=t˚~B+NB_h;[:N}UHdK7d?)FmnvnfZavwDC%9&bӮCq~0Rs^9sl϶53uu$laHViɌ1ݾx`wRo^`@a6KAFcڂO_IV%o3-$~]\( 1 5iPf[߄5 ihY:4+p}؁؄4ITb?yA^&I[M؂ & Cb~!ZbRe3͙Ǥk{u~kvA1m`yua/f>,#/YiN~I 1Q 5֡us<zԬeF8k馈gog?)Mi1aaf;>ɾv[&Z=}WSG)VIH ;ҲbI$Fo\5ra'8=o^Cy+n}~ R*YK~fU:oMpbϖs& eLGtCe Z9yLX[zOiV`x[R(351v߶S>檇j[m}Ξ3l8zڝ&n Vq r'$e8nz]gf>1׿oj/.߽~$+RlT^kfi ;~#~7Ǵ&c~v}+fOY^3-O#O$ܸD^ű ֕f9`kvKH/~:ͷu) Em]1q^n;OV3禤| ,w>{"8- ;-DĺES]*|hЋԼ%['gzQ~[^Y^-JM8sI#릆|􌉖n>^[XB=io_<AslI i!-GOYt-eO8YqFחc"Ο84G8>'-XK5 45NM޾cO!MjPYA;^L)|A͊O2ٿe+Bj1echVpl~A_iL-VFTZێ`=G/=Yq0'DȊtoܠ+#M'iAa4>؞NsqM6x7ܶg!dټ,zLb㚉s=mAi5&6kmdD6/i|ʈ4>˻x,,Y5=cRj׉d">J^*)ȺqvL;!{5Ol޶iKB/ݡ+ z3{ꮌt>E%{R(+f#uaNCK| 0Rv=2cE? >J2޻n6_DEyҢQD+cnEgep>fisG/~J]D+#3Fm\TukQS6?{_;{LꦩZ憺{OZcLp N%O·9`~&:5֢6w 9x>5z@,|f{8}LuZ<殉e<?uh;V(!lnEgI i9(z_sŸŏ&X=gu7RB|tn֎{-|rR Y=)!th7hmow`ȡ i|@~r7$Mk+ҭxBkZ9x^M$1LikQTjH)[OPW(qZ wlٵJծҦHmcw$ERW,n on$'-5*=մ 8MZiٛم1J4w.[6мs7K'cfqr#tJ$7DmX0zaݾ ~锬Щ+;/e\:}kS;|ad^+m}t[t"=PW"f߿zu=~yVm#w~A{ ueb}p84nc:7E4}Ic/8ҫTt?/ϼjָ!=m^HF*m:[ ;34=nODߛgidۤ\߫jvQl{ąW6='w[9mYJFvQ1w]襌G/hPW*z9{ڳ{!tHdQ\4=fQ vb%>1!UkNf(=u{%>ز {Rq~НʝEM*/AH}cZzQgn0*I0߱|N5TzX.˜rjڹt5W \~efpɫ%X,:tx+ր)ΕTk&?u9kRsS؅1^RΕ~n+LQ~؎^-:j.fa|]}Ly+L2{5Q1 BGBW D _xQ1ZbuIw\?L: rxkd>zdS9Ӷ]{8LG/7Ui9θc&^b_jq[ؿT[\yz _9I7%4iwy؉{ؿT[.&7 Z3u&QݰYgOyPQ]^g$FNua k5tz-ܸ\Z.sQ-17*LٹbIGš6Lx\#" B=rZ3{`Új&WI,`11uy^z VnXsQm1x5{N<`bKhX֢pӁiyϙ.k3HXk]M$8O]})KDu ąuvEaVʄu͚=ĶjՅI֡s*mZ9z.h|'qX|~fM*C^o!G*FbÚ^=k@N~v sivH۷azfn`M cG/ὺ#{Yn`{fγ7GTHW{d/71gTZ e60y/WO_=lbYؼ]wnUYS-T뢙Nwrz}G,~ VZH.ݷ~!]ڔ]{Ab|Ҳ)f]|V͋&#Ezټ 18+WpauĬ/aN4[(IVryΦbX b;5*[Am9sk b9Vf]4c) Ob wXk,.28*=[͞8{/ؕ( QiZ?cE֒ l2<,&V/#kG g?/8KwHB=̺9w8bVF䥝ۿaxK"cZ~.W}vQե /bS I\ޟPjzkO?Sܟ0չ\d5fw)/M <飛T>{P'*- șy YgIRww=) [j׀tT@򟩌wА$VŹc:x /w&p5? "QVJJVkFJe3R&!Qb![(*M2cw~sN'Ҧzy{?yr\9gu+grS{\>p㰚.쭭;^_l"lb)-nw2LJ՟nj}nL9*pnF_"cfޠ0YwK[Eu7eILDe| RcQ}̉&&eC uW2U]GC>&ky?&/@tMD~yUTm4N#k9y {I*Y^/T"jh^km!'A~mƘUFGM U)z8Λ4^jgo BcTb/,)c@DQ㓦O۰5Ŷ~ZƸLdsbeݮ>^MӸ;VXLU!bVQjKwӋ_6eݾpQ^RnKf۸~iƈv7G?ʭ@DQ3&QS$5F19 kG,FQW>lo6QQbXkQcΘBbCYwK+^kI"[Y󞩍ˮW6ʺ]qLȡ-`cZRʓ9n }eݞE]q].6ZxD~?1O^} ʺ=1:qlVy:ZZ@Dy(v*gܽn4bRhC_[QpBE-ݙeD5 !_{Q(ꑝپUֻ[/eVQJk4KbmEݪ]v\|j(0Ə.5{Y/˹.\T9ucMQ+\Q-ek/M+ƗcykǛϓeYwU'up9]W?Ǐw Յiw/{EŮ55M<~;5:'"ڲȳ>s'FQNB&^?z<@gl%'W?z`y2BNr#cCgl%'KnKgOTge=\BAo'#:x[KX(+, FtuYsk1Y):cVi-[Duܽvn5Qmjy!BbkKPOe)b^dYOnE!,"kMǎ߿[EpgX\t9\C%>Ghg=Z:E.^'.|"-Quwr 'vcc+ʨLc;ڃF'\M~OduABhhdb! Bv3YE˦EMv’z3)8xFh(4=53Ze\!pS/{Mu3ڼ?xnDx9G6_!h#󞷏S~[_UD|=Yr>Ȳ&#mdlx M~^F5/zM8&Jׂ;ˉD|^Q7sp).5Muޓnc/yMyo^Wfݱz" جǩ%jY8{zICxi#Ǽřy5QYFlh5~H*.Xs:c^MIVrl]LVcukQ\FfxYcG.h?ah gٮ J@#x^]ufG #=Vd&Y؅g<5yƄǼ'w#nq2Qwh'3l 4>Y\] ^3꺊mt=%i|A50C &׬0yZh&>.g>C-7 MʢN:!*v$#ᣗƭ8R1tڼ/ &nq ǼИ7EM>zi#d{MӤә1'v1Oc̨^>>ZPL{3t}3tl7s< i;^Op޴8C^7U>K ߻XǼϽ!3bbߙ*>kzUQ0&f'KE^Z{k625mcmy~zBtNVx1Ѿ>͑1zmx"/->‰={w반ȊWM>EA9zה<#n7GKA^5F'64 ֐W]<`?/W P:k4_osBT|Zދ o20GUY"\oonpׇF󵥣c!7?) 5zTf&ߺO.vV^X8z_]9rlGA؋ܶvh;zOWo?,D͑ /EhDS^;_PLZQpޒ;|N]QP^C(KsR8|fg뙓Hy;g^J)hNcdXt|Zn 񮩮8k~6:ZM"7JxzPQɱg٬p{{K6D>2tE᳤[WNDOyx7NyHACE$ƨXlDܣ 4a3BfG} 鄘>;֢ysq:p(9 >;=H3YDxTv"hy;ld4䑛0RiUѣ6&ɱf,pܸ7rôj\(l%]Qr?24vuqCnh5cǁeS*Pa)B2nGr0*G^4dH؆sts486ꎸ(f$dI><`$qRDC4&\y(0< vaf"L,TW]tZkM& Sf "|(%U'ۭ\ĭĴܒW8E84FhY^z?)_/7g3m%YdCBDxh%m3ga=/#Re6i4;,MKq6{DxHQןntǾcg?."Rw"E8>,٣{ၨ/c,'!"A1"fH)Y| r+kY)fEavj|앳ᒞ1YSAFƒ)bƓƤvk>R$#8F(zxn\hIQ4yp^x0 "h(*) qvg/Ot?jI=3$'!6L`ЀI}C#eƩM02[h}g[\^{mvQA,zt7 2z:EM//5bP=䚒&H<1xÚ͞^(yAi%*mڭi?U5Jg"/ڵ4Z[y`F;&C9V7~O}7I&.z$=n6|(-@3'|82Bz weG)BFxM}cv+\9|ƭx\%8Hz|&|,+Bx7&©<7Yf2ZH;+;ZRJfY:uxPh*g95߾g+f̴ϙOܺqv_8Ǵ\-bF62{'/v~ں7v&]fms# sGqQW/aŒfZt13e܅KWluة#o?HFn}b7ÿh:4LV榆BT舰ybYF5!NHbd*k϶u\駽NNJM-(aod889lb8LzM&T9Ϙ>MQSB-6E4]& m#Uf6vNoA®\w?!񓴌9yE% zu ˗/U>z4%91.r9҅S~>vn۴n2s>J!oij4#Qi)GNz&$Ro7=xqꓴgs I#3OR%=Vl/=sۦl,̦>K# Fc}R8H :aW>:zʵ"coݹ !1)T')''%&4ҕ7"cbcco޼yzĕaBOؑCyFUsfLPV`"fi=VI=dEKWEok>9zG*A~ 8~!xyؼqݚKm-͞f4eRp> d"Fin"8LX -[CgdCY,\i6luOv}Aݽm[\׻^|άiSuԔƌbg=zxU m=)F3fϙoz풥W^nW׍\]7_vK\l|i'j++E,*"f/0{h Jʪj:z'Mjhdlbj:2xD=- uUJK"\F?a37C-0DPh( )irc*(Gkhh2<^QaѲRF&4L3a3Z-v3G .,"*&6B|(I京>LHpQ1;s]WD؍N8,LB HDDacl2Qˬbo~{7p}0%oa\X 6wB0 ǎ#˱ud;KÄǤrDzFXNxNNw _oX 8;SQ@ =?t~>9Zv;ݜ\mS2Xϧ*5MI/nMO!*"5V~>MuMe-)M Mem-5M﨩+jhiJij+ikJii)i/tUUZOC[CYMWZ _-w5uX&Z7ob=7||oqOl@vh3 UWT֓ϧ.OS6YI'eZ =o(ߗ f]7@;uK\?T \݌W.s[u\_5!emWz8Rps]\J Rc 98гu,v8?c \ֹHkPStBU)SW''+uji Msc/dUPӕ/)zl~sI)B䊪jD\cr׆NW0MCOieNFf6QB-F&; 5m]來6(_I5+Wt-LkvLp" Gs* E@\T&,֢wuwuQ?}/jxUT@koWDP /jj *}΂nO8:j/\ =vQ7jUTw3 n~p DGp7.pz'jO&pzI 'j7 =P-?t2^r9݁@ @@w ;qs9dtr/9݁@ @@w ;qs9t2^r9݁@ @@w ;s9dtr9݁@@w  ;s9tr9݁@@w  ;s9tr9݁@@w ;s9tr9݁@@w ;s9tr9݁@@w ;sН/'@O9݁@@w ;s9t#QΈ5@@w ;s9NgE]Տ;@@w ;sН #3@W@@w+7@gQ:trGOsН-zRT?~>~N@@wzKT?/Lo~~6}!# tD7KQ\MZT?_x *DsP%7P-?DN@o8ET=$QD N՞@/8UT=dQ E?p7"}{Q j.QT{w7npΆ.G8jL&΂W8j^@-"}xMT @«w^7DZT,Ek@j[T. EꜨ~΋ ꚨ~ :Q>կ#mYQzPPxP߈xPߊx 5u^D~8CTgzLQ]@@-w:n]^q@-8=Du)@T Q]g@ 5}Eu@oaQ][@jOT'|- PgDu@w*k: 5~#@յ k`qwZT<;@T5Dzĩzm-zĭzDQ^_dWzo P_u@@}%&^dԗzm P_ˀ@@#N@ YM@ z,$@ 꼁L@Tl<@ 0@ @ 拢q_; [ŰoC J1=# CC~? ojit 7p ~ !m~A!I1MÞ! *(4lh>LHpd  aY}T?P=C!DG݆dee$G!&"JBJfqU544?xqcd%G DY{5t P4 )Y J*jں'O542615LMM NTW0VNVJo{4eAԆ MSTVҝ8iYf-X,Z-^z\ӌ &ORWV!*<  4mքE%GQPV՟j{6RCgO?vЁ}~eŜ&Su4e$Gcm0M\Bfք)&3Z.s\qˎ{;q2lHoDFEdט߮]v>G}ݹcFv,42AKUq82e^n/~ȴK7bn}0񓴌yHE rg=Kv̍KO۹mӺl̝SU) o J{=vg0ASy,*`oe;c ?d_}]-*ₜgOyOڳ}*{yӧE+9;?m-[/0rO3s K+k MoaӐg ːMGl -ZȽ̧nG^ twqZl1p;8O C)δu^uab%=-*yj-QjشV=H7^Tf>I{-̉_v{lXjD䝄ذ!#n@!Q  gY.Ya߀Kq )9ڐmMo޵4]0޻7M{c%E^>NMUTrY6+"R'Q%_JޏfS5D:8T4α]}2,*iniffQmgb>;.g(-g1V6dSbJE?>dўm#jϒn]9~}ba`2J:=}O_YX -8Rd[Yε`?/W ݾ8] W/kwS8x2ZKsR9Ҵc3m䔢JT b4DauĞMNVz:qL69q!*>-eS98֊EaWwմ>͸iVNF'6kcžkzUQo޷ݸΛ$f#MhovuEc:sf\xlⳢ:ajO:)2P*LHQs`Y*WD\}uUqf͋l֡&Vظ724;zUgXް4n46Ή4.5Ӻzd]2i7~oPQ)EKl\8a\=W?'> ['-:ǭCm!" c3j!6n6di(%:t`A<' ,1Vmj\m\kPݻX[ARd犎䆍1u1nuh8 `n9Vb_&AN\Nm%뽎]Fs\k6u1'v.7U]~!YYk=B2h`:4?KpkɪŅQ?<2a7 +i`u Q!~kϚ,#lO.Xsfb]eaz`*JP pV,ܽO]$r8uuile59~ꨭ=yvJ΋&rJֽo~UzJM P艨#BwIfK:vQvwhbao-y| vfTdm9!"R;,Z̒mߎu5,NpuP}]Mvx4`G:}xP],!2w&(ݱ 1ɡX{ixqunKpw47PʩH(ٹ *-vw)` nkU@XRAwϣp[qУkl*H t2֪ g5 4j-bṫ<-4|hBCZ4g(@?m}ns&Ѥh.ZxrWw`u)Ï{, r=J*%CaȨCS]tFB^q{} rȨk.xp^P׍#F6^y,VJ^Ez)"޼Fw]+\tU2qHb4HQF줨sZyx`כ$fڮ}ʽ4ZqDԑ5}KfOT"D#v=hY~B#WiV)ŽjZ}CP{Xt=YKWVzҬ7tw;YM=ebxxܓL1kUӻw,3n԰.$T&[Cv|b1kyNrLhB1&q: |/]g`v}cMqƃ~5AQ+ k"j() u)7Cv2dKoXfa2y=b_kK3~ v[j'NFZq͔F{~r%f8#\Ū?p'DՒ#o^=8W_˕X BeGK#J3\ ZP˕hLT2?ɛ# )rQFrbwetVX=-S>i\\]\hS>trbweZl]Z㋕\+j;MK=( f˶+k>Eu2 Pϖ+>`,,<W<Ί\AðTe|Q1DtͺA7rxb1kNҍlL4FirLM>!ՍX˵ 56gL?:sL/Nzqm3/Vrm-N݉1|t9TU;"^Խ닕XֽȊ@stAGLs8w:$}:"$L5G}ts7Qᙄ+\\Rz/m+p@9bGN$ţ_:v45> bt_d%D\e9ݧk7"jF.{N4RK[OLtn]9XLmZ7yOLs<s̠9MKf-ζ{Ӣs]ڝ#A\Fs<.@V9BDW?0<^3:f !H[ݵf꧳0A[>y~JP( >ox R G"gԛ!Ehˏ9JXYKݡAEą0[]Ż% X-n޿tlEj%9~E[h vhb \i9jͮ_'>D‰^K4WvNVu) V0Gfs=_"#Z&ZcSGLT?dc{i#:vs ܗ$w-]qzfGԾ!sݝpb:n$XB%sЮ38ccXCkJZKΑ^'=cNz,59|R",j`ߒr*a(a1T$XL[JdQ <5sl5V%Gu6Ra;g"#NG=HE"(s&6x~p+ps}ŮgcR j~AEhWSbz.6mupN96_\k1K >l9m=Zε\ajm}ɹsmeq%;خs7S aJ䖿0fG=Zܓvs;r q/>K3=r S uj6}+|Ν τ:Dj:\jG^pKj?]x/S{jLv=_T$'a{ӗD7?-?9Mg 񧕐?c Sc |B:VC7f;l=-Ԣ~%CFkXB|VG? 0+,u#9nUCse+!? vLu9d >L+:LsLu-c6Yϵh| A|a ;m?z.>_g>g}nM:?]'?э4fS>OU V{gX3ՙϰf:t:̠gS>:y=I=I=I>8{/}{ :}/'=྆5{iR om.̮ܿ.3SͦV^}tot%b79 kڬx-!w{^d?g"S/锧;o C;0#xPSv'S+.#\F:fŢc􇊜䨳ܖ7R-m9=QY5CUG}?ܿs#uY!|0/1_%D"03;EGN&S;nM"; rk-N:w@k [dcah'QAJU4ʥ޾x|Eu$44jvFOgWZEf?grӺ WE=]a=I!ۖ[iȋIH(N_v[)Є_+M~e =EI=t䈢#vʓ,uxת:"WdY{z{`IkZ:{:{#!QG\cMI`W-4(C#vhڬ<:Y#ByrlQ56v֡P^Qup).&:fȡ$RW;3}WV *n`~ huqhD!w-耻9ſbE+M𬸺<5ŭg W.0gUҺ R0cZϣbK_ҫK0K4\8v JR"Cf֡*(&2nn]6aE&DpPg.w`޿ W7i;y寚i`ɶY\]1.L =u鼩; C\UuK44wpֱPslOu6pT6pܴ7 <6)˭k1.+)6<`&G cm,%R:&Nr؍oc8x?TTZI [wf27[f\͋8+S]%iQVj%duΟY]}8gl a&#d- 5Z45T LS!$Y'4Bf|u\]Nı1ǡ2n19ֱY:l&suqjq=9uhMT5?rI}s]%9qmXQX݇#숈ûW1a}d\kp?4:!=\aiokCq/ke69q!*>-eW fohxYuĞMhجCsc!7V>+RQo.I#n7GK2V*gI\XcX/(&m8o>cPseq 1}v]2P[QZ 8uR SںB%UĊEei!P+y Zl6USAJcX7hXɳ7>|-.9XFqwzjmyafrܵࣻ79[Ϛ>VRt>44TDB^u4+ AbO*;DzjnKAMT:U۱ 9ZYxB#dUʎ3#|c\EQvЀ7U=rpԱu Q20[}IyeuDqĒ%*QpueyI#q_@KQF|~}maw IS4cd١& uɂK}xqIĄύ#C-s5\WZUf;撥ʻ4/==ؙLP*!p?碑QS k^7Xy ;_הf܏x@[IE R` bOwٶ[W73X񮹾8[bo9]X74uO H)Ymw3В} -Ԍ7t[x ?u+.9yZ}G\yYl#ޱ 3A3Gy1YSAFڕXcE%ǨYA O _԰#lݷ&pOnFy#=1RR[C+Xᑲ[,Y70ĴE #mCsȟ`^%}4eG JJeYGX($u f/tpy7 y,zd" ;u79yaYUm}[[}lUn Q'ߋt ΋LTG AGqىJ)iMn 7σ'/GMz_ZuFyLXV[UV< v%ۊͧOR% #V,.;dGHQ1et>g/E=L+.yx=imdTg=Mu/^Z2Q#5T8ȸ0ˎoHqjگqyn?N,} Ә}`VSQZᝨ+=7qk:ISINRlP9H}XCQɏ76Z|_N {/IfnQY%2e^{,:0kXlkDUf>I{==7_8dxypC C˻(ĥ4&ͲXr9䧙k01cضezLcU[n#/=s:%V5o(roX̸CލSTכ:}:q:jT\|g9/*kj_745}G؇cпL| [=#L{ \DR"TRDi**[TS,Dha4 aK3Ae((kȒRd7̌{N I>#?m{Ϳ{\é;[xQ|z;K;rSWYkL~V]<Ȣ=qj Ixgd_|H=#H#$2>HhH={3"OX:?chmnbЊM\y%gBi'}GF̎_F۱7ȉL.{?#pN"3{aὂ|.G -1n$).vu[s)B-ώΊzskn޾C"IxKWMJޕrhzfܽ_X ŋWxA"{R;yׯL?z eWrKIlaC|=9tlgZFiY]S5,lz1-vޢ ~ڶ+%S_{w?(,*zHxT@QQw nߺy=ʥ٧O?kO/;-",xnm-eٜSˍBsVh[3wq' :1zV$ݴu/K?u:܅K^q<-97o޸q=Ke>~_vlݴ6awqGO 5boe.+4|QhIv&yy 4eEV>i]{R9vdF3Yل_f9qı#Rھ%i-5}ʤ^$6뎦Fr-Se̍tђ#[9z7hxȸI13hʄu6'o߹{OJiiiB~_jʞ;'o޴.1aE=i\A~{{:YKM iyJ  T^R7>};~rs.^rkmذq!I ֭M~e͉1-z ЎTM[)kX^I[3sK[GW^> 0)rǰ̋_`ay,7g1S#'M 9lw/WG[Ks67B"+!llȨCýf)M(V ?j5hK3Jjëw_Ay^jթ)MMWFf% P^MtZ10l̼KWW=<<=ӣGw7׮.v6Vڵ54hJOIF $=U5u.>=i{NV6RH6VۛTKRtl<|xTH5l۪uqۊ0662iJW[Fs5Rj*DOdkNlSZZ-IdiB+IMң+GڬY3 "#и*4lH"lԸ"whhbL J"$V\`;24hP'40@Y111"jJ@OU^vAQ!*TUUU%nTU$,N9976R$5y/`^J~O*ussL(Fjaoe+Zٓ޾#ȳpq>h`maW~)s$OVJ޿opZXYۖ䏐?UtJޫ_s9HI$${yXv6VRg$SUPĬ$+mkW,*M* $ѐ?TpӬa<&A?nQ=BGEN 1M,{,bm3^訉~ƄX #Hߡ-xȐ ^NAGFDL 2--|b8yN6temu$!!~~9bBH1w{碖Bׄk\:gg!cBݼS2%tTHw>ҥƖo= { E*R3[?AS:JlsvS5'ёt^*;^=E ꫇ֽ P7$@%$@9`+$@q` a,`!zuu XW@]zmB;PW^s%BZ/ Ok*(2(uC @}!`]+Zc)_*5uBX O`]֖ 뚄º!ka]֗@|9Y :@ذ/E LX(BE(7Be('Be(Be(Be(6BuzPŀu@(t[X_"` X u !u !,/]X_OałځuV.4X_?a5uVOP}X_3aULP5X_' *֎bX_a އ5֮<n<n;֏buQBX yC(X !d`1BuBPBX ֙BK:Ob*$Xg !T\ !:?r̰B*+s* ʥ:/rL B*3*| Pa P**s KEu&BahB(L Y@": pUXg! –5_?PA.zP A.PA PA PA.zP A.PA PA.zP A.PA PA.zP A.PA.zP A.PA.zP A.PA.PA PA.PA.PA PA.PA.PA.PA.zP A.PA.PA.PA.PA.PA.PA.PA.PA.PA.PA.PA.PA.P~ Bk` 6D ]9E ]9E ]9E ݏAXX?G! B}B(t B}B(t !5:~BX B}B(t B~BXk !}B(t B~BX?B"PACae_V&P_Vdm@aEAnm@aY֯ Bˊ>!u!ԺkB*P5_P_#PܢAn}uBi}Bi}B% XfD ]V~Bq֯B(YC":UXg! T`P* KEu&BaB( @:r, B* s* ʥ2:3r B*3* P`#Pq s*BuBRBX ֹBC:_![!l sQlBXֹCG1:{a(vX![!AyX_a އ5֮bX_a*~jNš k!z^ j!`} jBXva}=!u !u  X l`}!-?B(B   뺀P(ņu}@u@u@lu@,u@a TT`]S*@ذ/Y :@|9K nXu-ua] IkK*u}BPX)5ºf!|  @m!|Wu \5+ 뚇ְ^PH^Px^P@Y`V z@z-A!z}A6 kP^ ڄo'+Do>a!z -fu/~ $^L^H~`ބ~PJ)(xQ }F /Mg<3.0_4lذQF+n$R#֨1͌DFk@bhdlܶ %mZ"6WkT<=/nc#qFBlF_bԶY{NV6R{H6VۛkBC<=!ƧFJMM M_blb֡٥{^ѣkWg;Nfڒ7+IOX))Z:-l;wqٳ_Ay^jթi[C}=]>=Ƃ ylִt ۚvstqէߗ~5:4lI#"" Q2'O2j@p 47hi'AcL[ mRkmM-u;7`1a_3#vyq ,,ǂq̞9#fj !# hKӓiBg dְ,6}v,. 2bȩ̜=/~ђu6lDHA޸aú߯Yl¸93EO?v԰@}<\kP'+7[3s7}G~=cּ~톤-~ޔ}rԔ=wnO޼i]bʥ;3&zҸpu275#ᑞH O6"f{``PI]de7'ؽw߁Gˎ&.n艡I³2ogZ^r2ir36uv# ;ouI;?kzFֹ9W˿]p¢Ge.**|pn[7^t>GRvmi]EbE%9ZPSE[4$&֎<}E̎_zmRGNd9wʵnĊ?~/^^>gO?**N~93ӏHٕv1aC|=9ZHEds#Mi'{o$e ؛vDf˹7nݹ'$~{AC^$>.~Xx 3OIۻce$>;v25j-ZYv#Hn\{kcdfaץG_a'NĶ3 ڵIhONz_ ۷A{R\m.RwΏ:q0?o.u[ʲS[K]}Vݼ9}HlOd{3Zg$4[ؿKGI\~$Ϳ{\$uϝ9nDt\᳓妢JskѵחFM<1C3\˻sohh$3Yd$+C# G{ɻs.N <#w (u-lƯi_ݜcޙdۢy  q5M6l3K6nO9u1TooJSVid'Oo/Ixr/fNyKfD٦qM&ݕ48 Fhn3%sьsoܾW$$^|dݻ}_a;Y7j?0Jbjڛ/qO_WPX+OKh^<-.,˽p_[ 6z\_o7KS #/Ԧj-t L:u3tl_v/Od]O9mR+;YjOY?IitoxoC'^YPH(rGmݼN޳%qɷSÆfDNYRv bJ NϨCF.JHu =͂wrJ“e'YOHvWΦܕ(6j/=]l:i+Hq+UEUC[o@6xV ZoݭNse7xt ndU'x;Z3$3 MZ2$ekS~=}mouL+<Džw_A[aGډWn{Dr1rّYѽ[WΞH۱ae\̄=]lKt`bڄsn} )GO_QP?2ʭLvdξ~I%d&.5:;qk+kqk1p>`踯ذ<~o [2*?}63>ٲjc&b~3c`jw ߒyBJG.gKMN\߫ b4hp\3`ۭoo>yQPg-ٿ%{SwnX1wj`o7;s#jM}NplF;:t?4,f_ιys~*@ny#;~ݻ%a A_phK ,=KLwl'/b"kwdҲv>=m%Gyủ͎zEl΃o}Fd޽qŜ}ژ萳; U3i>c[˷H}_DeW9%Ѳ+}5X5jAZoq*`oHXL[SOv+y=deg O߿-qь ۛt  0cяNJF)(dxnɒ3dP\8cݒI}z8t4j٬^kʨCby(3'c2x+A3Gvo\>;b@48G##f/߸h֕B^rJVӢ;&YOѕ 78jʤ!3[ Eb{4WύtTѕ/$zͩ'_/x\Ɋ%ލ 7%ϫ+_ȔykKp^׊J7/ϻxjք)>ulPWϿI{֥Si=94V-\rکKau/ . RtN]PG֣#ca{ G{\XPDq_szVȨkLy{w0nN7r-ژJ]zS\d뚹Q#}m[D3oؤ+7#§\N߲zN6Zm]~?blgȄI\ItdK|Ծ+gOժ*\#νf,ٸąltSNܧ.Qt%cl̢uq+W݃ 'n\2c\?V[}`w={cr@p^=Mî}-M T5[!c5je-~ɟUYZW]>#q~/ȔV59:xN_ś  mkLbi>hSi-!aw**!hto߼xtJ+Ȕܑ:OkuNYKcYa yF &8ϋ/l\2=Þfvۄɑ-O?MRp\tןt)v`7Oqu#  g_#cUp{=A.FٿEǭU5-=ǮؒyU'dJ\L۲"v|`OZj5_N;:4K h"oLٸ8fow[Oؚp{dv7!1KxrM]ց-g.΄=::y_x_Y5ܛL\tkk;vƲbI]&$wճxضkUJr-H#%n"ّȡL^Hݸh~.xG_p{9ck&fsQHw&Γ_誫Tlޱ7rk$`HȊT;듃gImd:p̌[Y\$;9enq~mr׏$Ǘ\k3^C&ǑzhOiKY5S6M?1KТۗY`6Tr%EWwpcZi}tcBC =S;A&>s+qu9 .c=Mn]S-y+7/ Ѕ?1vL=&|Ţ኎^O~NxIlLd`s#,ڸDXr{37}7*2]߸Uۏ^R%';|ןW1٘T6#|mI.8~]J{O_ ]8r$rXoGdc@$>/z\^ыM֯> Gt0tF󡝭g ݒr,ݟ=.;q@OzǤAt>x8)-w%p3lLҒLf=G|prGV7?zQ[gtF,Wn1̇\n>D>#͏B^b?8fCg ֒㋎^ڲ4&K/WbvMJHKdF93av+?Yb>5疄?GܿJtCrtMVXm9|C爇޲.NWdAw [rtmDp|>k/mAϬm:8{F&Y)際wҶ:+p%e,z!*[/nf%-`FG'fy ůľXeUq#˧%6:>Y N\[/Vn}ȾҰFh4)n] ٓg KRMiVF';zmistO"-W/} XQ:+X?spn[FG?D%[9={9}W¬qѽCc7\ nm$nGѕ뚐Ck䂍vsosFGwtE@4>̱l>t#9َb#5=c_/C++!Gיc}v2x|@+wG{WO÷ݭ Mpzܧ`ZyV|CT1~|^ITy%\ʨssϟl~|&yޣb%S ?7}ws_j8C_o58ep_emN;G!n%c["5|w[oPǾN}GN[y|oR>5d[ҼU;^øgM$G|ˇ+U{GLt_T}UǾ_ 3sprÏ ӯpň( ߗ?) PN|DZ|}ktzs=Kpێ[thtWY OU{[tmm=P'-f{X˩.}eTW!ާ?B3I\3ITgG~{Tg/}C~;TgC!~f94[Qߊ\j`29>s?Y&_\Fom='Mܝ~@3O^9K܉#Xtp?73uÂa۷,)%O:~茸H爒plǚYamur*Ɍn3ɱT[ṫ?-zT?-paE3#0|N)~=?\NÇo;#$ Y`|c"hqs.>ڧGlFls9)ۘhՌrد|zt8f 1!W_r/ɖUܭ?69ޞۘ{'+:yɑ-ESFu&燏辐mLBgvDE'+G~y_~l>Ȋۘ8솯JKnߦI$-?:Ek"+::^srukfOG >@]QёIKiёNWXT7ZrzT.$Z>|\r}ф솥=\|BrzLJ<2Xiyڵh_vO:=H/ ʩ \kiٸ|,rz}-Ûg7N^rU.9ywp=4b^΄ۑNNQý;wzɋNg7KνGv&Y,1֯ԤUK/:m;<ۄ=(v&IMٜ\r|ёe.ڐ" dҒ>nmu^%$j6 a3Ő¼sG^mېkK[_4VӒtrNv&t[C[,stGHJuJeDllsk"J+fδfӏ$y٘l͝z4Wa|9rzuخĸa}DqUN勎ߙXu2k:$.)~){Y\p%#ul#)Sttgbf0>vVr/ʯU>mլ z:vЯ֎lѩi!zhtZt?ΏѯEǃ<6ՠׁcboJBsH~'sJƾ%ߌ.mGCKCBcAfJ>EPoΫO]:jv^Nx~qEǝ$,5?q'^ qM¼dҵҘ?J$I}G,ް7=lu|{vɔdv%[jw0 5p|-iW V'Hp\+eE@2WkVIBW2_#&l?M&ku&G6$'̍v!sk_&6n>S}|ރdW'Nir.CzV#ӗlLI_k"f>/ʿqPt\GyCڒνO]yƕ;5%d߼"v^%U>eёkl;h5妄`q!mkLB6$MADGϯٸ}92z~#=~%ˏW]>#q~/l}ʆlёiuv=FO[Lt #:.8:VonѴ~=HSSת,MH3w 7cƽ'.+Xpb%3x9&IёVkdѹ˓R/*Dt o]LOMZNCFdqNUWafܼ%ADWܥS6=iwW+iu Su9?_L٣(?-D JC *N 3[wQslMPuܨ>f5DGA%$e\VJ0/:x`6J;K<|C%$Pw 9!nJ;R%zW1Gv S+\m48?Oǎ:t~qCiFre{qZNvDUQG'/>:`*atrK ˩Q+ OARYt:Fw[ǑZ\'Vu|[W&:2asȖJ8Njs#iW$s?~'ɡ O]̻囷Rfǵ}oGG6'lٗ~ƽT|ލ G#\Ⱦ.zͩ'_/x\)|6<TpͫF}\W&: 2i;EdN(T2fۛrNT/Gp82b򍻏7be+'9{#z[pes3|Ru;f\ʻOl*bvV*iq.eܱnI>=/8YtM(qt>S[R=$-.}E3& nonKO\It:ރMfsʱ쫷 KNѲ#VjR6?m`oWz N]Su3}FGYq̜{dNq[Vpd<{9ѣt13QoZgFMյYu99vi'_#3_ Uv|E kOm_$vH?V՛6j%Q-[peЄ ˺r~I)Fv\nJֱ- OE-7JazFvnރC]qס peK_\=)*~1Ю+N fgnBI*4u%m]x6_vdYgF&>/9qqlD.6%T~^ёKOvʸpN/YV]P_xRxCV͏lI[ #ۨ)ivݼGG^cY=|ܒe,7}{ynqсnI# U;!sB[.=}G3զ]OͿ_앬1Ȯ$?߼zV|??Ƀ6Mߞ.JU#w#GG愊c~DZűd>&Ivܸ,ԋGnM\<+j~-L5TŬrWQۮ|GL[dzrn)Aver# fN?_3aoخԒ%3Vҩ{A#f.\@+ywdٺﲹ ,1zPN:d2]ёKfVkcsi-{:=Yvc#=w+{$.va{v"3J-[tPpty& -zٷ%Uxe7 {!hJbG~Ѣ7دT9%eGMgFOrԣ?{+.Wn?."v!}71ymHSvŴtM:u3tlTN=q;=}j?؁__6*48y6t6B D/&m]<}5b\Ԍo9pzܼ;{[^㓅&O,r;y$uOc߳mGBUi [ݵdoؘ1s[~_3\UJHB+I$5 kϝ9nWwu[v>yR?}7\|4?Y2+!͌ W/?-~nK2ONݹesgDYQ6]f-u۶ASc/MXO;t<#k7o}@j7>?.@a FB{Fj7]qm}_O3̿W7GksV\n-Y>:mL;vv8"4"fv2ގiGNdr+|X) ?({w se8wmY예q#IuwloRMj)|vtV4֓[;vI[zmRG3\¢GOH/^$*} ٓGEϻ~5|vf)֮^Jb Ѧc;:-/7Jn͒욪olfaֳఈi-OXӶ])iG:}ҕ7o.{AaQC£ ܿ[pW.>}]~Z|ѼiaCtlkޘ,St*]nEڰ; oȨЉg}"a;~I=pXY.\|57onɡyƍW/_p.cc릵 +==zb!$6w{+sY5}ˍ@EK ȴ5 {``PHؤ)g-ZI[ړvȱ'32O&-2̌'9g-IXlQܬS&"Yw45җl*cn գwACMEKW&$۴9y{RROKK;PRS޹=yu +.̘IBʜĦ]RnJLSZxMHxu:y8l񓣧͈pkO\nÆIr/6nذnmkV.[0nNiѓǏ5,зO7EvHljMJ^ƲHښ[:z4ldHIScf̜=g^\| ˳`A||ܼ9gΈ9iBha}{y8ZָʭY7I{BƆM49""%""b acCF ?^]45C}=-MylB)RQˇ׬F -]iNVR{'n^,@f͚Uy\UɌƭP&`CaA'GgFCcj%4hP !ɰ>+LܡA?X?YŤaʊGxpQ#TUz " PU駪b-qb%Oca-wv7ȉɖ!qJ$aTUȩN":Y99I&*~*NI\7 z?ǪIP!ِg[tL,"z >"bYXfn$nQ% i/>FґCg[đ!An 'dZ^eGC/[p IҭoQXzFO !ƒC8|bĴ_KkZGsZ ]R[Xsk?Ceo 7#w"OGȔQ!ݽHHL[7-腶JŖ!u!ԺkB*P5_P_#PܢAn}uBi}Bi}B% XfD ]V~Bq֯B(YC":UXg! T`P* KEu&BaB( @:r, B* s* ʥ2:3r B*3* P`#Pq s*BuBRBX ֹBC:_![!l sQlBXֹCG1:{a(vX![!AyX_a އ5֮bX_a*~jNš k!z^ j!`} jBXva}=!u !u  X l`}!-?B(B   뺀P(ņu}@u@u@lu@,u@a TT`]S*@ذ/Y :@|9K nXu-ua] IkK*u}BPX)5ºf!|  @m!|Wu \5+ 뚇ְ^PH^Px^P@Y`V z@z-A!z}A6 kP^ ڄo'+Do>a!z -fu/~ $^L^H~`ބ~PJ)(xQ }F /Mg<3.0_4lذQF+@Ehh43X3-ZjiikTVH$?R\||~IAIJkԘfF"#nc 1426n[F6[jkToތט>y~au I4fKm]6ڛwJmC*dРvKM W}_'HsKM:t;:tuu<==ztws`kc٩DO]MV|$=aW[cZhjCC3sŵg>|| <߻Wl,i|mZh T ã#A5-=}ö,]\{x_a#F 0q䈈HB vDIƆ ӯWWG;km tH필'AcL[ mRkmM-u;7`1a_3#vyq ,,ǂq̞9#fJc ׫{RKsӶ[3A6ŦoԮգwCF9/Z|՚~\nÆI27lX65+-^oF=bH~=\]kP'+7[3s7}G~=cּ~yEumm&7FP(UDB`\"  "PJoқ4AFMosf(VT̜YOƒgZg`&8Q7cbccz}s3:Ƶ~ n;7^jhLm5E9IQdy?{1ۏ\ܣP+66ٰy#'_y3.νGґ2J{05A۷b_wA][7X[QWGօlaQ (lb[w=sv)"2֝Red<.*.))--C*gq~nvfZjrȈK!AgOxڻy5-A)M' o8xlRa*7I ںȶ6;=|ïFMJI{\TRVQY)RcjTW?LKIf2OW[Ua*<>l/(".-9l>c#n'<)(*)D5JIp>WG{fF35Ee3&8NBf<Um΅^_XR^Y3rE˗^^zYcCݓ۱WBG9_ei2OoD2ڒ> o%')M7X`ne1d)ȵҊZd6l+d֛oo͛ _t>GVW!RG_E;pTY)Q1 ء\ |#,*%1s%68>xT -!153ZSs 2 {;_u u߂~[ !ZPUf&& O=q*sfKz={6a欹?-]iyEeUȵΗ42d?Gl 5/r(/3~|`OwͶLiy79e-yu20Zݔ꺧PuMe>n "{ՉjӺ򢼌Ν붵V !DG1l(+;r /4^j|3v S ekL>i $cE;2U^aNZRB grs\`dB<Çp|$eo;)%5 ۺ\R޳üʒ̔1!g}=8ۯX$+9Ney`nTYo9s6Zُ˪뛞!ۈb6w2yS}uYa[Oqw`m>OWc81#V٨#xF IOQ1d:x][TʭͶ=m&z45yϞT?Hx=NV̙6EZLx4kYԩ8QG S1\vaIyŕdmG^c]jQϝts[PGENj HFRmSp+1IQKxɚͮφHx_ZUN<[pd܎ صec}-Icǐ)"7*8~!Q)3癯w[M6@2,<Դ Og&'b|L)Ң8eQٱD NDRVYܽ#Kml2kԴȻ[~^NdAQi Kl*yx7*<MLWAeK"ܼ&LQ]`iL /_6w/;Z* rw\t!*;iQA>QرdQfdbqljWb3 kot ;-5WΟ8ieYqau,q4ȩΜEy[R|v9AdZFD5w=|*y5doXq6ZTvw#Cd4PN u8 hM62]?L(ongFcܡeW{)}jC91!~q(qs-l?wVJNɓ-X7,rܡEeWSp-ݒ3' 0BUHlʌ\Nr ˌgN4POVp癰̢̂ڪ(W-Ob.{mZ.'!{alsCo&e4uc_?-I ;biC~a19D6!vuɱb>9 a\xlrny]3bړ>v('R:8>]ƥPe-fNYLp\FQxÁS1n\Orvj=Icxo7a,r"J3Zmw"&ꟳqK =uaYǍǵnHMՙ|OptR Ĺ7CNjem|nO[e[cUAJL/Jhq}ۨ#6cDe -:z>2#- 0m#/:lmM ޽Pvل kbrlݬR8º|D]eoꈅWHRAߥ"Gzlqz%: kUɚs,7Ʀ>ƱJ3RqjlF9x_:QE ܊4n1Rieރvf2߰;(ԫt: 9"gaݟ;[Ѩ?j |uùzu馽'oՠCҁ)樫)J?oZr_ݯ8FV3]zBtr~9hhuhkD_ӯ8x%\u<q/C9Zs_;u:Q\_QtyuIFK6V\C1^{=)H> t<.H:U u.EŧԷ&$QuҩNxŻh4Pyx! S_fqJt:(^bR"J-0Lʳ:w*¼\}84jCS.sFjFIg" ,ӵn>a 0գ.t`<8~9MҘ|!8&HuOsNVdbB̟vNN9N*k(Lu[k:KQR糋 ʇ|"2jn9-1YgM9,FU?qжese6i-X@|Ekvz/pX1$V1W6ń؂J,ٴ&"^#li*=3 c ^jW͇E-Xr{RW(¯.}YLP>[dW4sb1Nw/BZL>C#V{F&u5x1q9iZL>h%1Y _$qP |mh1 mg2SAADU#Kp./U']?_1DFU.GcJ8p h1I >2Be>G|Y#zSpJBT3b.yH*">gaD>phńsەlVyE6B3|Ԓ#^3C,ґx2UI~:?#+-ʻoەLVԬ+=E[\IHڢhҭ@te$+n O9yxZzGӕd&e3L%tej;Zl:]V2/|f?YxNeެ]Zucw+4R@\a)Zdbk+h6o 1JhȔBNOV,FDxl4T 4/G0^Иg^h׶`/gyD?#w]>ᷳ+:8YT};g-c/yw$2۰/"1IsNoV]t6?O@{T :bcnCQh'!_٧S,50DںHuq"\sx'f%%q}| :hטS.b/i̾s#; r;}51!b=w,4}xFPJIgaR 8={4:: yTBls?昃otj+M#܃'i.X;$>)Fu;+qt}7"ĥ9 cU硣u&3sT99RѵqoS|wf`?"^i/~}G>AtD# ,!r.K >q(^NҜzlZ"z |/ɩ|uޥn1se/)3L''͝ @tp_o: !cĤkqoד [7l"µ ;ǃr T\#3\KYJsDƸD]f1_i~q\1[M_߂h%"\3CG\çqZƫ]ήlz" /'Nz%R"ZŹb\?~ϥ9,%b%WOw{:G^b%\;ln E]MoRSZRWB,txD~K$׹6psmũQ+ID8G,[F?+%-%*lVS1\G% cB8֞"s|ҹ28y/Ks\o1sNeեK\fʈ s_+pk}8VתOέP_ϭp]Z \{_}>ׄ?_!>O}}B| ^o>z+1}5~OW)}{>||G$OXxp> 37}QwMg |䇟<38* ?S.9>s_̟5VB0:%L}g ]K o%tCkBཅFkɨZgE&8zm1}y| +C5 }J&σ+S{2"7? ѥ>/yrq!>3"+fK}x^o횄/n __mw^/__gq0ޮU#x? T]s}L={:xtAC}{~|χ`|&,L$98祐Ky_ϟ1skK_Y S_9=s7|s?T?Jw6Fg.êF[_Kʯ܌ I,5Rծ"L>a 23̇Y}֚T>ڬu7IsZEȇGiʎ-r\7ȮhsDW>ܻrz(/0lF1 eįbc?iӕqݱf^.PFHZn>p&"15*9|~O"I)ۺ \L8:\r!3!g̩FFVlۡ endstream endobj 275 0 obj <>stream 0+:\r8% 爩L׺^9ȒkG+ɝK'7)}.v_T%'Wt̒C+IU 3@dos`1J"6l"-|`h/X|mÏ9K_w+ɘxU-OEs.^w޷Y?-&cCG]l"wf\rAXhq˹~a {N'dԣ?>>4`%g6Qoٮ[`p# $SB<>.H:jfYNY|O4tz䔢#eK}iFB wєs1Ntּs*9d3!6?:nx:4&c.5h3ሐ`l$iq>nUȢ=^Vcβ-.*oḧ́T܏ضb\rdѡ׸x{ڙ*Mƻ\_K뛯}<$aWQ< ݳgC5q#>ݢ0i[+wޯ/N"9X- ϰX{(fvZ$U %QW;S]/HzL&-~" $jݯdU.=ɽќ,EIϢ@!ax?^6_ij#Ws_;{i _L:_M׺M+%W(W Rn{Zk2Q×cd9K7=~+ +]/0Ϋ5Ei'm^6WKN//9舓+_EZIQG Ζ,WgLB/C' *zfvGG&U>m#\ʼQA(WU*^EG$PZnt M}\M:Y#\P_(WWII+W~jBQrh!IwX*aٯʺ&6.G*{:ZYG6:4q1A;7*Yt_QʪZ[J^5uW3[*rw0T;*iCF k]7$&^)HFńoZ6W[^\p׷*iW>aӍ8}֣B"%hc6HG.>x:ZHFEXϯ&*5:hdv<)N}t'~Bҳ>̃FN@%8`aa^; А{ahi^;(21,-#ñalM94i!iu’ 棔 N)uLs}P:̟ )Ca1D&(0ںD\ZXm\n͐ZP\/Ш6r4JYW:8W1kiK =uaY(FM_ziNIz&Mazvj=I=)!6Y/<6%ͭ6.?%6@}XCugq9 C3ܺŅnkf!'Ni1> Ikc.ƥu=K37Ҕ±MՏY',{:I~6qلol :ܰ:qωV%%/9/|:S˫ :D\V"8]M?01׺֡C+q+4UZpvןgCWolUGs:9 q.,&9Y7ǰ#Fܟo^?-I $z[ӡ7Kj:_uq;ۚjJn@`:9<U\9̆S7(uf6NNGK/j@9T U"Op1{`:C|~TPQ;ѩ/ۛ* ݾa: [ݽJ-EÎѱ?NE#,7֕@o+M 48u|rz6y_IBi vlWYu ;XTv ѩE>s2YŅ8u#x&)ϘoеԼ&caԦԄkA̟*B-KYpoQL-z{)}j' q`Ϊ?5fM7>u/X;7FՖ݋ ;{O ƍ5|.+}޺yFHʩ/[r7(">%(;hYQkn.I=~hC8ºrU>d١ %ÛWd߿bteYqa\CtHPa7UzSnlYQ;ۚ뫊S{1^Z~Y/ Z"e5ei/dgi./Fѱ:sVmu䅈[2 +jZ:|{0 'ڼlѩ<(qy:\| u{?,NjjghQ7b=Cz'2^k*H qsQ cvdsVmr={rRmx74K\>{u*3T'KP۩]E+$&3UKR;=K(({;"g޼z֎\AFR\D_8-]5UFLe*mXT1?FBrwJᑶ=k[cMyaVrP.LLW'2NXb4uzO@xԝݓVo(z/_z(Ny t5&@}2Uvh)O3\nf=.oji$ ʼz[GTo٩wo^:mEӔфc#ElŸDf1YSAdT5>o{(7i[W5W=B] :Ӻ&sfK BXqWouy5Ovt2&^6ܚVg܉|\W՘*#>E*Yp,dcH>qJ?oJ|],}{YnO sҒ.!vlZ8>,VpanT,\zvwO߳!1wqV4r;Y/0QE7}b;X1i9eYs[SWS3s K+=mz02} {FyӺ' WCO=df<=myo;z1#,:AVQ}!jZ-.COI/*|Rj0[@@Z{RYZr?va[ZY,=SSINZl,+"ZnxI)XZ82JdT½MϞ!_^!HϞ?kzZ_S]Ax;6 >W QMK-;Eĥ4g-wD;w')5=+qqYEՓƦfd`[[;RGoڐeM O*ʊe&݉8u hohDqB7!D"F'*5IAU[w"6;=|{~܂'u O{ P_W 7+a۱Qp`obi RMG۩^޾x6MI^FRQn6eGu7-*R0SϜ>q/t޶q+KEtu4T&ODՆl-F 0oIrSU5uf56Xjv.n{8y/u䈧!8nݴvR 㹆t4UM >a۰t(n1 avOEC{L-,W\c~_88lۺy:5+WXZ.Z0Po vMBTDHi]ʭ[dԒ7Z@'3yL=Y,\lwlR%f&6ԛ8e vMX`4ߨ]ѧz˼ ';^LbIrS4uf7042 ugPSV"7i cH׆ƶ"rO'%-#+7EAQIYEE}(+)*LBpwFkH1B3q%$&|HRRbǍ75Ԑ.ŋ # ((! A…05!=ܹ>@#G|Hvne3lѡSCHH" !a8ѵ. m a!026 vb!,kjE]@*ii6Vqs-%;Wbցk!737Vn.EET5/ЉHKBEYSIE\Q!% Kvrs)h*SVRPTF%%EU -* ꊪJZ&5^h+)ktߨ0d_!oQDw=?%S]w_; Aƨ3RTVT*kkGqnNǑ˜KK|_+!BRg.mASzeH[1/}5^~9֛Lup2Xiͫvk3nsX$ݹN[\j++>aLke\-EmXϷiM)GYƟIlFIY\%%3N?(>|׈/WmvAI׷8Ͻ|ޝW$ nbV(ӂ>F7j3~gvf(Bc}4ݾ~|%h@-C*9u/ՕT55/'a _ g7o2MqT[*N JQmq-UMM4LrQ=A @ u=gP@`0 h ElSTVCTvt|Q 8Q<h Du0@ JT&Q݇<hDu0@= ]Du/ĮwbQ+>@&{/ բlQ] sJT6 _s@" 5nk"S>5 Eu@lQ]0X8OTP3DuՀ- X=Eu]+#`u@-{v^^]`w@-5Eu]+ utD~SQ#w4@+oT@#gt@+_H@#Wz_Q|a@#G> D@}~2@_.3PEsꛨ~6@ây/PoQ|00HQ<0pE *}`p4Q7)gDP EtվZQT{ kA7Q' D.tڰEw*}`GQj`OIT{#V@XYT{=`UQ E' V~@OXIT{=a%QDV@oՏtf9988sqp0;0;0`@w`@w` 9݁9݁'ss3Ntttf99988ssssqp0;0;0;0;0;0`@w`@w`@w`@w`@w`@w`@w`@w` |LT/S0;0;0;0;0;0;0;[苨9999N_EDTWtttKEFTg/ttkE ";@_9݁9VQ}>E)`@wKT?џ|sНՏ'!@O`@wJT?.@9hQlCT?F8sНՏd0Ec3lQx,Տt*Q Տ΀jQ7 =ްz* +j/'&^z+jo.aQ j`OMT{3 vվE6t>ETkB7Q'Etվ]T pjQ3ioNվ08pE , RT? ޢ/ЇE@~~6@}_}~2@_'7D~0' ~^  ~~ ~S ~Q#w@Ԋ5Du] utڢ>`W@% {vޢ~CT* VDoQ]_@5 uT`@)@/@#@!g;!g +8GTi *Q=`@ V0@ @ QQ}XTL{ mwX#·v;N!w~aС?݌~ 9>4k#G#Ba1+ =B?z$(( 0f4?(d  aY}T?PWJq Y7^L\BRJj$%%)!.6~XaAF",> FT2xQq 'MQPTRVQQ}O**J Sde$Ƌ CY{5t /aST5uf7042 ugPU:e$i)qQ>^F!i^møFR- 4v<6MNAYMsYFs/61XbtٲhٲK,Lϛm;coX!ѨFp y8Z 3YAIMSg 2_jlö7ٯ]mdt4Ք&L@e}Ӷт&MUј6SfKYky.7}<yz9K/9y=nl\gr fϜ2UNf=L(?2l8YAECgἅK۰eמ}^>'Ov6 H _?Gڿg v/1]8p䉒 ~Jcm$MDTRFNQM{EfKmovQSg^q=2*fLlll\/cnFGݸvRA>m6K3('#)*C3Gv/LKMNq)$iC{w9oYfŢ9kFw|ի?^qӫW/;;_t4?kl{RU^Rx;6J9<,MMS"#9hZwT['Cm$ ̭춸>l?VTZQ]L{چ-{z?o߾ydȾ"^ȼc]YNW*+%*<; ocEd5f^d D%$fVRkjnAa`n[c ?d_Ks*d^੣w;nXex,ܴbcIgyqqc&LVҜ5k6mw?53Rr*:ns;~#>)=,=kBM|;nvIHY c N1c%&)j/Yٰ 2KZrzG^{ nڂ̔ۑ>Yb8Ib2eY:\pF/$*=E}<.  )ȶ^摅I]Wϛ>EZ,*;X"RqH*|_pD\Rz~iu; m]1{yWt+"it'KH'(*`ݞ'_WodN?xUݏ|6K4& i){"v(':d?E#nJ)y E;jRp[2$1!taжF MR1rw嗣H-TKD'.BN޹a,B|܃fa Uɪ.w<YTYpT[eiuqVRE/MV$:qrz6y:E*Y$ѲLن"7֕@o+M 4%F򏕔00Y;j|j^Y3 ,ZpȲ[WQ }M 5˺n4 MW; v';}PU~/2'3AqFf6NNG(BJt* h%F:lkn5e2nS!щYՍDq]]MIvmgu=mntͤ쒚Nqbtge91a~Y u=~/,&9Vq=VBî<796|P'a= M-kF@,#T{GD}y^ʠXGLJ¡˸rl1̉y)q{XV8|8UͱqR6=ak-'|ai6Ζ4.6#KLZuux|Brfظp¸48'ޱ['!֡X:|duSg9pNR3Ytt 6u(a=mL ' B_ё2*r8p*4&ݍiZNNtZm:It W&ENDZiBND{\s6u18\EKn8̟lvx"zC9jR})ڇ}yH &5ogb*m#Fٯ(_9X-&/N9GP<蛯}<$!UZ*ٯ(v+wφ R7/+!AJ'π뉹8W۫X_ߢ~yՋfL@%Nd ػ O(ko2;O߼t_t 6tz:cO^bkMajLtcl$snwnVyCK=E뫶9#=-'%IF2;8a!)*'ZB}֙|fKX[ RkSU^ҍOx3s1Jn:!qiȐh/H?nD>~ |+g'U5C⏎gQپjΔȒ[߰q$ʲ\:wCՉcx*99d#a +LC6zݥ'8GܸIso;5Iujx]LȒPݮ㡷)I%Ut e nkMg)J |v1A0ODFm-%U=)(7ڶl ކ? OhN+9fѵ֗ 9jx:ކ?[Xi%][QSDkbę- U~f1alr Vm KQt/[\ե/ r 6~ҝvN,9鵽"Sh1SBɧ2bwJ`5;zD/&V4'Cǝ#$&v}2^$T? ӡm-&a>Lf*H|2#|jdW܂EųkX+&|~`hplZ n]"2-&iGQFh爏6+qd5[~]IHW8*spF7%iWYEd,w8-903"r|x*Xh|ZrdkF|1WE:oWF*YCtd%וUySxɊuT|~x˙+ )Q[V]?dz<4!)"O3Bvh2lnt#x=sm/S,Vnij7 (EJ6oZydhfǿ/> Us!;]ʹapk]jpkH}`50ˊ qhDkMȠ~\"jH8Zr9pn_Akm#>/ͺ~,rsmuoGyZ%ŸGt7UaXB%z!q\K9UX|!s:G}x6ƒGw/jVIUCם! a(1T!LGJdQP<5s5dž^XM㜉p '%mD19 Q4Ln;y~1 mD'tm#7[w2>I]o0 ~nڜmRi" ε\fB>f{TBf 8Fs% m\YuMs˽G۪ >ny:l:뫆K߇hNA~ǔ;8;8T.!Z etKX%pVuVzsLʆk TdHyvklsX}} {O+r|΄+Jh:^ yjr?vNl,uvEpVDnEJKԅkZڿB2lusm.횂7+}O8GLB=Sڏͺ(I zڝ$׷·AB)D1=BzN^N_d &e9bJ l1U@|`69~.5YãXw?P=*,Wx-td5٣>.Fj2]Ç `v%Y+H~`Ӳ9h?A=5v > :*䞗:G 9t`?c]еS?ov#ɈN+膊i۬9JnH^rnu5+k;oPth0!4t/갉pȅyE|s~?M&6.ێD'=(ACm j8̴%?Jݜ~S"۟$ȍEEAjBJkh{}/AG5O$EY#r]9VINЛmwH̝ܲ|2o-y)C')u=XA'.fhDJ4E&r#rd 3cѺne6dšHrĄްd"rdСEBFeNd9G{9[dz\WC\$ 5NtdMljJn ڱv hcz!h2)4 "Wk/Ͽ{HȈ NȑG&8llr ArkQ\~47jؠI> @㰢ެwrV6^_>y9=܎ f7O$B :r2Qf$I\s}EAZܩ^ΖݛH8O&96*f+h˼~vׅ3t'Hvk" :!1I,ݸ'$v ꫵw.ldu$W^O0qDՂ?2֐@!V;c2Pe 6T>θ}wzRD{~AGlʓڻ kMf:Ƚi)F}Y=t=mFV^O^I{ZRGywcQ_2D\76AGlR(_mF&? KìCEQzBd[Wd_3pP2vr ub`aanŨ;lm_uR;}`ain9W]LUDENP*.1'C,]=lul"(1mi\ :{;"cQqw7pD<}y;t-.̧iNC( >egJ*QŽ{@%yIwz8/=E}A}h4TTR^utk >gf?F=\vdwΆlp>IU^Rtv`ݿ2bXe]n{;vpUc#mu]Q# cέ7PhUcU6a)K&*p EicwyZdn(#1\h`6~K+Ois0$L2Pt{G:wde޹rfduia}Ƒ֡5^{F\ST^Ӏf;Vvij)/I~1^5K-LUpMu3q3K*^HY|Cۖu%uw57VE%Ti0t3iS1fU7Pؾ0SGv "SPoiSPS5h!QWn碔}-ޱ|# 7Db\c]e9IQaAzCX+!3AsLe^{Ou& ڊ'ywϝj 2f*;NJIS3mĔ%}[3.pRc"oss^8XOeꩴfju(cQ%1yIُJ+)#mCscߞWf&] bg9c(SiTuDF!%k8G^Bco!6B}=ۻ2#~{ [YaNЀ^k~c,/E3%vءj>tB ~GN x-> W/jQߺz? NRG1SҚ2bJwL/.~5x=o6vTp g)IgÑo]~47WS%"Z*)"SvXEI&s8xtL-O+k_46#JZ]# ܈ۉN=|[n 8Q|}G YGpq)9%Mӹ?.sqsSbR2r Kʫ(޳}i,SUp{#!~>83$'%>LT. 8RP)+8;&v+6x؉ =.F5۾N a5TۚmJ}2஭]8tDyT Rʥ@T$4&϶\/ $\;YyQ7j~McؾfyL\{E㼬;cE۾yM*p7,VCލSTכ6c2[w<y!.)9=aᓲgu/_5yK؇ 7Ka˰gioߴ4zY_WIì䤸 av_|,#} %7-pmE=%:OO Ij{ȾפvYFzֈb's2\G>gv6sOUSI%.6ZfDIF,lr;;%Fr:^YEeM]waVceg/_TV'Hrvoܟ"ýb0Q i9E5ݩfVWoD:x3%=3;QQIY f55!T=+/+)zr316&:"!dՎͦ+ɏ%6|Y !T$e+kΘgq~=pNEOJVK%1FȼWSQ/%&NI0QaQq'%H,j\F>|Ё;yywq_h=o4}-UEd(;x0M۪5 !POP104=n5?=wnܸuB t5UǏM6­UTQ џbd:szE9_uUbAjD ٪5NԯVZ*C_tM6Zdln;[z*NпF]8jں]5-=%(X4 ZمЕFQZMl? qԗE%t@ '3 U ZzSt& 'Ew&@ 3Dw-%@ oZ$@ z@yπ@ @zCt15 ѝ<DwnP@y@zKt-ѝcw1Et@*s=@ ^ݹ> 7m;'-s@1/ P_8Dwlǀ@=%c:U@/q _@Xt$ q Ew@_?s@ qtb/@q;@)x ox^;Nq` qL];Ct0~D~~_~~} ~]h gE @zFtt D@}~ ~2@]ݯ_c3ݯ = SгڊDޅE ,oW;} n~>@/ =^.;`S n?N${aE,7U/n-xMtoK+x^pf͢n_`(=YpfM 7n/`. =pfCt@@@:Ӂ?uu5~LLj998s0s0qP`:P`:Pttt@@@@:Ӂ:Ӂ:Ӂ:Ӂ?uuuuu5~LLLLLLLLj99999999999999999999999999D : ':Ӂ:Ӂ:Ӂ:Ӂ:Ӂ:Ӂ:Lt??+9| P`:P`:P`:P`:]K~_999NwE_"3@w:Ӂ:R׈ttVt? 3t9NO=zRt,uӢ7D 9No ӛg@oBt7P`:}%N?+I_3_@%n:E@`6 =pf- 7n/`&&Ypf7no`. nMxMto3x ^ݾހEp7L>0Etw4'EpLݾ0]t z3oo~ݾ7E. Rtg}*_zPugu,_PD~2@ݯeukNt~t gD@zVt;uԻ~7u~Է~D~_+_`: qL];iSt0w^;NW5@)xon G'c Ew|݀CtDw@_oۀ@Xt! Ǣ;&: _ @1 ] POXzCt5z[t8@}-c@ Ew&ѝUt@"s=@ ^ݹp? SDw.dѝ_6DwP@y@mJt& Kѝ6/EwP@π@y@ I@ ݵ@ݵ@# 4DMP@ 7j@ @ Z}Y̸T,{ف6j5[Վ[)gaȝׯ_#0'<$ WHӰgc. lȿDwEچ\#" &o2[w<lKJNzX Z}C#2 {;_۪)>[ !Q=)| ;vp֍.lϘVj$w\囀 m5i3Xu_@HĹwҳW!^5yMCQ!>t$Gl  J^^Uy㼬;cExuZd9H_C;.(p|ST3eyݖE]L qiEu koPæ@¿޽Aqރ[ Ny׆vs'!$Ň47; (4Llu}Vv+6?8llRJFn!6d[뷭Lвv $cu32E^anFJRSq$ ņ ]EF(aVms8 Woe?lc]>r2iq~Vڭyc 3JDk07RzδYطpu2e}MEIakŽpYj=PGe4QȕnڊLABť&jMad t-92nPmӶr=yE]eYQ^ݤ+gNݷ}S&Iu)2w¢e5 L/t70,̼Upi>1 (iSoĞ9qy|S EѢƒ.Ka,u1U,_Iw?)ol&ílkkx͍8i n\ Ӻ ̍TǏ9\b&&)7Q{,k՞;} TԠ4­lkcx(ikMs`YGis%*.9qyVm']Kc]EQvJޮvLjuqc۹z8~6!e*j (%c)v7MQga:¸F(X,[vzz^I 4Ha3n}e]qnofpqؗ兙DheGqgfHĕ QO%2}â2%ǝ>LobX6nǮc㒳*"z*XT*sRFd=0nӦAWSr+pwko_TGlqgiOpT|j.qv ҇ }brbۓ.,vOԔuq¨9K+M'J\X}eMY^ZH ;,]w&1-M#y7p(vغڲVp"֑ s"/Lce]#.uƒzo7p6.0'Y焭#6ǭCm!RM,=va>|8KKmTGQZl~}ۃyD@HDr(xڸ֡D{d]9 =Ncen;EƧq֡$*hxB=7Pڔv}渚m\R#t?M]~!Yً]}"Rr`:4?Lzz;)c% .#}Ojџi?JrNI5.7%."uI2Dб۪JaogW50VKr{,(C]l#P[5t;~惢hbq,Zo_ ;DK.rFɫ3 7*?w]ˋʢCz8`DO:0nQ ʟ7}]s}EkgvnXj>YEXþ.p"*5~՛w lM O.1S_ :+u$7xʟF|Co^Ֆ=L ߺT_wlBLrhV7pwθ}wz_= )nd੸r18lYTw5V}I $:f")ef#EWw.:!_tľ:|xmk|D](jx"G(u_]nCeUX"WZH&iqPDH7 =H)Z~/oeSXRGk{LR&D#4Wn=|:꫌U,2_*q-*pˏZhԿ{A =H`z)縯27Wp<=MQv@!QL9 s*KT)ɺy.hڅ3tF&AL$h{P2'nhgxbQZYla6m:j"QЙpsKk2urĄްdTw&D2?"^!R)'2#WZktk2!VYK6w̝2>hIQ-|H"$*:rw@Oj_DGН=bm>^;q Quf.ް5|r|HV~vF`B?8t8Z&Y)AW[rP %iM_nW-U尨ȿ{ fhpSo޾糐cݫky9͟Nb )auG;,tx{MeZ[SM &?g٦W=jÐmcuS?{ve0A]|SC^:w &Fjh0G|-1)/,6Q7~s"{&f;G4X8o J*mzI9h>C0L:[LUa -Ir f _T\ ٵ t#2σ |8E4d$DD=BG|6Yjϱsrt$!Ep?=b!G|&]d=c^ !VOW2Y 9zrdC^ >T٬EZYqӐc;}W}>]ΊuFqx?GRQ8=tKP~R'Ȥ:>X"Ҭ8]?]YL<4G./LvWj ֚؍Hj>OVVVxu}[Pg*dekI[O~iTK4;+>s ÖӔGM !bc5LmVꮅiWBw5+6#7 tf-8ߓJצ' </!ij"9xeO_*}e5e߈:E-j4w>rV.ZZ"'b*#\.+*9j5!:b)s+#Kk#:NùM4郞͵9R3w:u#ik/2 ݈&6WQc 9(sB[p8)jsm,>PVQcI~ ]JڈKݽji#['%"cT m\w\N+SXR]F txc3)GC8q;TS{c5s&9b^',#(lFO0]09 ߿zеu|l'uͿ *4 w3O-2ksI9CW&8Vs s9S %\Εd&ڷq9pVN+K,ȕ$33smE9CG$> `b1ӃĈ\w}/8Tܗ Rs_.9;D;{k; g%kg%p>p>g3ax}u>Ձ:| o@ N+P|>ԑ:,|~u9gg6 /: n?'lAG鬯.+?|:>SrpR5"Tr:뻆j%a,a~<Z[ɋnqr(2)-ԪξSƒ!#g׳" au=~56=jʵ#9n䗪+Rz%ɝ;y5rA\LZ[].q*.yRjh lՅϵk >܄B\rN[GtPוl/7~Ͽ&C]XM30t=arV1^ װf+װ,tptJ]n:9Եk!>UAݓdܓ.ޓx'꽗~_{}.ck~_Cfu^p6[=>jsjNS{C"{ov0Yð]!S+GYK!-4NW|HA^Сd$stU&rk!&O6}@4 kظl;-|>%3Ӓد@+ts^NoK 7'{:B tF+"e 6klQ_\%CP >F:VCI]nK P_\%#UNs_%/T($W}h\%+ ڦ6Ǧ<,}E0źiڲ)[W٘j+s~CDg.r <VPY]$:uL}%i!_u(_eU&/ݰ3̵D`u8;޿v&h熥@"<+owb_W7coE/Z`ɶ(?@thBNyˁ0%qeuqv`*rBeRJ.Wspeuq$_ vY0]WH8Cn٨KDĥ0:q5)q;̞,#>eQ$ƪN1[{ԇոWO]og>EuNrmCnKLoXd|j^Ym#[GX[yli; G\*:%kY8 JH}8KK `a9uKHM6tֵD{TuuӦ=щKe* 兙Dh績0uno/(nh~Ke겂7.9XqXد;-aI*vTrwLE%aڵa>-Lt8:Aa1F\OAŮ{3uĥğ 9j7H[q88ub4/rٲ7tGOkEam!PZQA{,29AZ4TLj =v_LJ+G= ;pW'] `RbCqPQIyIӭ6MXvpUؑ 8SgM8z$UyIѡήځu?pȈQcuM~Xu_pd팂*vqwoTUd܎ ޷u&cG2:@2ZVy [TQ@W,D5V] jp8º &.>yw2ɰCM ߑyjtA7GŇ ]GZZ,.v*Kx=qzZNQyMX)Kw؉('ň{,0W%7UC*vJچ/{7"ei ;o[^Ug܎;sP[I8PS BD̰ZGO\UXVUB;cۖ¬bNeV3 LBOC}B@PXTRNYxD]R*wx(p/P߼rpEsu$Eh mce)3z }،#"+ku6d[s#nO o'^:qm-*pcGD=)dť4 L{ϡ_NMJ-,){vQ\{OVWU^lZ`;lp2Q0H}CPT'j[ۭc'.&J{ׄj^{l:1;ilPmkBUW>{v+RɠntY\ZQ*H%*wQWҘd-Þ}e}]'ғ.D;}5(߸U:B\rdS<} >̻~'-3'ϪQ5"^b dkdZ#gOUWMQNj$7"Rnhq5'Ͳ]02{e5u/^!{[?{gPS,Eԭ(n,I5e_!e c_2A(kH+YlYgwyι[P=~/S<ٞs72޳'-\KM:xpmEipbצncu7OTxhh'21v?0x"1o 1HNMq#+7{EKJ(/zXaQ;snd\JOM>q8n_l̆ˉmxyvp05lH|,)xfή]z  1ʨxs问^ɻUp¢Dˊ(**NW/;{xbܾmZd~~liLT! YI6545wq#6j\ ~^a=>v*카^K'}''';;+Ƶ/;|غ!j fO7jXb$S߾FUKR%gš٫OZd_6lߵ@|㧒Τ;NtA&ns)gN?`߮1~YbɂY'ǫ3ڢ$$iq%- |9 ,_q]{?x ߻+v֍Q/Y8gͣ9MO40MKxՈy$L[Y\\= 8l SgD]xGoظyD1Rwl޼qCuW,]`nČƌ60WwOWe+Sm6n2Iv]yD:twРQcOdlȠaգ[{g1uP_OGKSjPM&5hHKעUk+K7ݼzyׯׯow/n=:8ًZjA]jѰAm 2*5Nz{:M6753omecҾk'wOýk.v6VL4kûVnƋ3=U5u>=⟱I-mb%X[Y6oĘxǙZꚠmś'qOZC FZںM637HF"fMjk5hFBMEƫV{h5T'6֩HZZe iIL+uM)lsf.'1PAjWXFYTA21 t6DUſkWAaA*=UUUUEUUD?b{k'y순lbGkJL?ɚA?UUFhi/&ss-{Idc#9:^v`9G{K;Gq1kkK;+9+#L~짔!_$=g&eʽ=bGq^ꓝqwu'?E4YUi⫪$2k){ۚ, @U{qA:Rxb2]!_%S2gLڸ6j\ȐЩ"gɱ!\\Ɖ5-MQ!#Dڊ\ iPB'4uY[J>C7s BƚkWkk)aZx_ƽ;dlPא) B!ߗ~;Eo bDXsE99Kv 1*DZ7 ֙{ФQÂ:ytԏ|m,"8aHL3Y~G$f+9Uub}2n|fX_+UN>.P5@uT-b}MU T^zxE@͠b=fPVP(X `(X{ `_| T _Q=Bk?K" (XP Y(*־ Ek_D+b@w Yyk_B7 LIyk/E lX@9A82::qeu tPB5 @9A82::sPPBu tP@@9A9A(s::s::qeu tPBu tPBu tP@@@@j@@9A9A9A9A9A9A9A9A9A9A9A9A9XTsu tPBu tPBu tPBu t>%_Cek@9A9ЩX'| # _@@|X/|_" @Ηy@e>w s::_+*z 1PBzPU)k@@YCeAu:zm@A59Щ)^'@9I^+@9i^/@`!k(sJ PXk^?@ȃX{6 a"Ob@țX(֞/a b @ȻXPlE}(.">Ek"gBQ7bb@1Pt E}/BkO|!D _U}BklQeklP6P(XY .b}=U X_@U,P5@:ʉu|PZ z>*ub}V'(b+@ E Es $d/A5%9P &:jՔX&@}|mTb jR[ :G끐 H1ĺV( Ck<AuAM X&3AaAAAA}j}PLN%,,'aP}K]e>qzFڵkשSn"3PYMTիڠA D֯WOx'4jL8@fZZ:H[[Kq#M j@ @>>X/&Tj$ԥˈc:zM W cc#CAMti7l@E4X/zF\"X[WȨYK֖V6b{m,[lablhLOWymk$9E&f,X]wtyzzwr֦MVf&"}>u5Ii^muUPkӤ5αm޽uvwmҜ׬V#|U <X7lޢKG{oaG7~BhD0ۡƇ 4tP_;wtqlբR0mHmkMby 6b:uǿ!S~1{-.E"#̟;{i&32pp=:wAƼEs{ڍ} OnHl72me)vpѭgC8G.Yrͺ_~ݰq-[b$ooټyVXh?M2z}zv lej/1Nm!$܈m mzF-̭;xtwPਐSf̚hʵ7lپsJHLL<\F݄Cqݵ-Yj٢fL2*pP_^<:8Y0#摚WGORꨨ6"ŭWALJ1oѲQngC8|6%t 諾?r&#qݹmˆ͋)|A}:[[hi6TUiz$t[ۺ?lԸf-Xr1m׾ GN>z¥+o%ʓyR&8pp߮m[~]rɂY?5_.<+sSæ:$ꑊBHWAٕ6 08tj%+6=pdrJ+7nf*s~aQeD/**N׮\LKI>I1jSC\m-͌Ii)dʛڱOfG._!&v_I)/^~3;qAO>+)yN葒gO<.~XTxέׯ\ OiԳԱ?eD#Bb q1 ̫S6]:oCzwH֠ ݷr7U[V֎wI+cv=z1f{ĵg_zMM#I,#61I|}_%ݻ{3bqcWFFL ק؂;9+p6lD|3qr֧qS,Yqē)2n+.Ԩi,If ߿9޼"RN&߱isM1зG;}F ׭-/pui43neg ?[eWܱ+$ڈm_&5U` gԽ/He^IK>{ fL5˽M+f:jm+-pZMZZ9QFM`Eo{Hx-"m}i'qOb^[.LطU3ÃpsniTKCnaEݨo\>wFnAabl_#<).,ͼ|>;6^>zo7W6-D\d*/>Q550im׾jÎߏ$]Om\+Լ?I{3ܴf̰kmbHܥ,TQhh736x4aUcMNȺu!nUm{mgܴ:rF~=\ʹ5H,käG;j&fVNLp-pʛ4i_L9~0v?̰Icʉu4#oھC7,eG\SPHTnd[9#I[t'ųl^6gr`n[.KN.2k4ZڸxdXĒ}ӯ[5VjĻ?I>&]|d_LԒtie.'aek#lޝp:-#Uo&- ;#tk"rs45$o[Ƿzj=(xbNuF[5qG{v֕s'b7;e̠>]ډIؑj:cYDU]i=Bg.Ycg/e擾H~Ȼ/ϼt/8oOv&tc|65ha{ИWoޓtz݇ķ?J}+Oͻ~!)q ŦH6 vz5vc`hϿ;qjvAxȷ2ޑ>9-wWI^:+lxqS˕յX:v=`6=rRJ^yˍol|zGU<楳Gm]pژAmLU2aDUCǠKf,^=k\oIûNF/ ڎ*k:jW [v0"|ΊtvY2Kٿu쑽W͛<^#z5'@=c NO[62r>|'F%-wdB)GQ􃻣esRHQq7ٳϐq9:P҅[E$)w$eiݼkò C};Ŏ}u 8{ŦGRdyFŗ;eIH=˪13!{kl:4U3q#'_sky/h޺~*nۺSGhJZcqƑ*je۱giw']y i=I͋ .%'^2cn憺5f8]Cs{Xij$SIk 9 KYxUBՔ#{6.?¨f3f# w!#m{45##4xaG3~oY9;thǚNfGWn"z~gTvf1x֐ue : IO2U}dgIK߶v^xSԸNAnOUSݸҌ%nC͟۹+c\gߠIm?|9nq+.q2I2GrM`p귮q~'-ڑp ĕoRϻz61F;eM<{5ca'_H(Hx4Hs(5.%#6U(q2Iē;XGFjrRvN K e;j]Q5ud8Ѩxu qAqq SJ%).ΗuSo]O]GdC{XD3QrH[]A+{>i+ck@eK9LDHG^oBfe~Z[AWVwÒobgݿz Nծ1 ENtcfٌ[zhZuS"])u*_WIAc~#Z`G;1ճq[MaOJ]mMrdq}'/yDHq_KĆE]mLru#KAvKIڪ 摲⬣ 6+خy\,jZX=1Fm3N`I}UĘ. Ծ<_%j*v> $ne۲tpN-b4f$W0z/)r\wquBRE/ ҳ=M8_IѾJrYkbݼK6]RIKݛiGc󕶇MLltkk"'@u|#Sݡe?$6Ӭ%JۃakW'.wrn0/=+ʻrz4_ۑP ۯ6jff?&b#iYfŕOhrtYcJ:ru:8e8.WZx񥎌&SⷒڇM4>Iܩ[s9yI5+u|^&uAm-D\,iN#ؖxUA*d+vdǪ#C]:t݃a~Oz!U*|}M'վg7 rdдpĚc?}%ܾ*^<{ݜ]Z6kY; n"!6{LxRInfjB̲iý]gd"iХoȜ'/ \V\)ytw67ɤt"4mYL¹L%hdK;}匑ğ5АVn&e+JxIŷ3l E'J$۸b,h&0±?ۚU:]$̝ߓ3n IM ۼxҐ.+tdSCn?W8]T;w4!;K'v5ִI%+T*GB.r3הd"+LH߲dО.VZ,glͅs )D"t2ɻDYc~t誫Tblұr!CL$RIxZښ듍ħ{Imdձψ+wSLI&ݮ5};4vpf]OX@5rJx*]ܦȰݝZ6Ӭ9C? i#|S+ ܋bWѧ'i走wܼIP*G% kg`B.-4q)XKԺ]aSF^Uݳ#ۖO ݎNßL)율*VYN*.5i!&M>1H`sC&-r{O0$A [\~ f׉+cIv_>{-Lܬ`Qn$SpƸw]8ث'w3`2q`7Gd0s| #+w[T$a4L+`iS[:(, ?_>*?z޸Oz#=h2yiLbZNzLcLzG|0Y-ϨO^Qґ#F+z-]HC&4`"+.-~5 G/*IVk7d@?(iȕFsp$뀉S/Ix{7SH7d]żJG<Ȼxl 'kRs  #]F쮒1خsP.Y+yJ>;}hݳ6k5l*s=YK5"Sy97տy/] Z"}U%Tz2 hIo>P#` S2RIkf x4/IL)*QdkIQNzb̒A4_hgV_BٓU//]9=H2[Zm:/ڝǯ*{r;ג׵ah;~82>u#sIz6}I^|;LIs[O\BGD1?ͭۛteWܹ/jhн!؍u'yb+mX0ݺrs ٴN\%|67)}::m]>TEsc!Sm;z1!/DwӸ-{XiEOtd:swܣ6~N~ /~z3YX lCvJi=0\"B $ Y˅Y87p mMs{̱h%mǶ/:ӻ2;U;O\Q*"SVϠf坣{n=r&Ay B"".:EDӁode;f/LgD5ײ/m-BZau#ksk\kMLbb}r"+-q:ۙ[teܜ#h5ׇyn[:yp7~*sZt:mŎoD-εeӲc ? "SWqs$;׊/;-EUe;%ZV~cFHɼ~f 7йҁ>+%FV t+seD+)J0?ĿQLs <~sSR^s"͏d~9G  \N(}HzsB#7'^ ~c2>O~glr'5lڊXpy}>xR;Kp! ? opݗ7Н3GKJ?FX"ݱO ӧ C.~[HOM5K61:{>+96է9&,$mHsGLT}UO=_ عJ[d/\"JUewElMSJUڀ,U%S&ӛ(tW<=pU;O[t(tW,}'aq~J>+E'\Fz>a~n<a-UeúlKTM:eWۏ6t 7/z|x|ߣi14]M:çqHPΠÃKcWޚ`J^ԥcde :IyQn-jNh#.?T}Di8gݬ`N6: U޿T6]JzD[#~ޚp>lMuepSt0hÅ[%e.~ 9(&yGZ4m#D!.۱rpm>$=t}~`dAGCnđ~%G}D}FXEyUC9INZ1SOoM`w+]IC$[LÙ>^2zq% :I=̿|rc~t~?HL{ _DJt;)CHAt&N~kUy6,G7^OY7{lݲVuEAGmhБJWHn6cr[~2W"TιoGa虫c].$ ׿/>JCTO^O>]%AcPXt#ǿI;Gm^Akl=e1 2D)d"vx#};MT>SmԬC~D>y)D)&Dtw6'\C:0jӾK6)dB?$,6պ.*rFfvdR4 =<+̽xb9!}8lFv9._%j5L&']M?߾v&:ocPsBeROÝ|GN_-M%h\%ȎU3GaעfO$y7辫Kasn]%}`ˑCSA'ٱ:~2C$.~.?=/.u}\m>o")tt21zIvJF;۷c+ϚH!i&.w$_j]9ׅaCz 5ψiKsF0w S,iO'irO$[9v;v֚#i dʹkfdn9.踝eF%+a:ȽzZ{U緇Ҡ#; =SiK7LθD/r%neBO'[2}\q; c"VmOLQ:YG\"[Gv$_I X:MWE $vE B*g&6ށmR'duqdO &zX+r:5}ٖE&Bon波q[Maߒ_uVi-polĪm )o?VtdKض*bl"*_u$_5t۴4aGgr]B0Qyus' jGyPfSS/s<=i|[}nV= Çjc5I٠#)uvç.HD6m°3l6.:ݎ952W%ծGJcg3m9t6XAXG夃[ќHZGJeKOW(]IYICFUP$qNU{gvUAX'3CV?ЫU9GJ]H{pܵf=Ut$=-8};ЈLrURh0u6oݎŷq); fkVUݡuKw>?*61嚂['3ZJbl> ;Z ;xMZ{X+kب|<E>Ĺн/nTEqwR|=- uh[ꇬ5 :N36]Ul䶉F# ~u4a(uRp¨W uMZˬ:źV8UKkmDzue#6/(uqo^8vUqU9}:śOo߼x|jȸ ۑpJG_b;^9+oN~(rW*uq*)}9u8RƕunOUB iox RRW#ƕu>asWb=GR#NߒL%%^Ք#{6.?ݡ挓Xkhn{Kw&$bfli>}@J\r%3fonKw5b\u:VpݶS7nrKN޼#2֍Sq-:WGV"4Nb]}u3]eԌܻŤJN~[pH=˪13Q_{:յMv:!bن]g.$=_rv|Eԛ$ڰ,bPmLשGN6tta鋢ǝJw4;7I˻v*n{Ԣchټiҧ]OWQڲhʕ\E㢂+)GmY5o~^vFzծi8뾭@SWҦ]WЈѱNaG;7޾.~]ٴj6PmZ,-vm=pM)n>&4eYywi*y\x;r1kN3ù -q20XG[l[ػz םҮ}?e72~kiwlv/W{ cRHo MqNHP2֥ϐٺș;ޕOg^[]ַ]|x_̺3CɩM Sf:j556gsEo?pyw%F!I}{x7/cG/3y>]ډ͍j8SK26 SKG? 6ظ)Ļ;E>Ի2Le ˩'b7?-d=\-M >S* ;Rlz;|Œ՛wşHt#W\\xԓvm^9c<ڐ 'GNjiXص=`dX[vǟHx-OJ^J͓VnŅ7.lY8"l.,Lu#㸌aGlQ6}Od]qΤgd}ًWW敱ʾv1D~=9xowW6-DMHKNҰSiԨs'Q0xՒ:)wm]ܽ|0mk7؟x2BͼļRd %ÙVm%Ķy73.L=vO=ǞZ*IT95zW;=m}L\;ԋ7I?~=j?Rψi^>D͌펉^h֔qpw%WO};]s]z =qk7n,)̜;?yūל}?O*!j3ϟ=)~pN~NՋIn߸vazwss[H|W^rWzc]-y>nrQyGO_v3֝$^qR +%1)wnܼv9=Qbۦ #1Яg&M8wthm֭wQfG 9x<)s/nGOxocoegO?([Ϲt<bۊBG%ŮMKc}o2^PS[Odbn˧`Dbbb>~)FVn; ?&<'zQ^PI qâ{wnfȸ|pؘܾ k&hcajTFUY]}5R,m]:5bQ۹/./]wνEE=PTTxޝ[y9Yׯ^J?wĸ};rzuqmkkҘfCO7*mjhjneF?lԸ*j{~?|Tsi/_v#3+;;''(O*NNNvvVkW/_L;w6ԱٱuCԪ̞>n԰67{+sIէ}󍪖qIKϨ51WAA'M`Ɋlپk߁ģO%II=w>-LݴRS$:~41]c6vŒO4(WgbE #}IIT}$KZxzu4mf 2426n^ E͚6jP5%WR$kN lS@VR&\jObj *9*xFM2T\oo`mba'-=)kUKf g!}&LM+Zd~POb}ZS HQeb>$lU׮v!ÂBUUzX\ǫXz,N"976b(~5y 5YډmȿvNGl-lDbkK[щlD6NNb;فa*6Vb'{;'YdGvDו&g+~#k;{Kk;k[ُ+=BJJZmYt#1b/JL8;MVUk&8Z޶&$PU^zd,KL})+rmɠO0QF :U,9:DkWkx8:*dȂ~B[kมA\: 5tbP褠A݃?kbKf!BD6bk6]}h%j8~A]C Zz|_y\ϲ܊+62:9+Knh/ꡪbֳuәĕ+K۲FLݷ%ޖK,K[k;;Y9KԣܗWxP$ŎVV9r?Kv̷}y,'{K;Rg,'kK{'q%;Xʾƒ~VKJr]*_ʷUmș9و,t {?%V$Dv֖N6/aVv1=ݲY >b;+{`ےs%ݍ,Ae?>F"oĄR7k`h'vracDž \>]AqyMUUhWP̝5w9jLmBYΤLM5,W@w?9~HrDu%Kw$Bc^dH?d;)I("o8mE7 'j%I'  HйKAw bX1b-ֱ܀XuL u;:HXbOBR\!)XNJ2ɯXdž2ɏX@^ȃX_{y!b}}rB^< y" /A@NȋX_.9!b}-b??U}򋲉@Q(Bk!T(.Bk?#EkPdEkπpQ4 Ek bP>Yʇ/@y7yk"bRXxA> @y@.P2  A> @y@>P2@.P2@.P2@.P2@.P2@> @y @>  2@> @y @>  2@>  2@>  2@>  2@> y<sA>  2X5 ʈ9PS |@Fe<n>G>g 2TkjZ>5PU QUbTX/:zM|)%K@>Sb>><5%2 i^/XJ  EȋX@yW('*־C<(X_@(X{o@x(XPGMPWx]@Q6/*־C ?@X_}&y ob}}rA:<5y & Q)@țX__\Gy b}@:H~:6 Hq:V b;BXǒ" [KSNbH^:Ny,1=;nZd)G TYIih75lŽ{~ÂڴmcΜKt%zfVvvNN.Q^^ONNvvV+ϝ9}<حY`}{Y0lEմZMhk#'Lh;=u6Y9 /,*z@}݂9Y72.$O< k~;}!?loOVEVQ{z)c"VQUoDdڮgc`yYcI/[O<'z!}Ǐ>([s崳'ȈIcLW!R'o!$A-])ްmos3n޾[/_~?{/ݿ}Jٓwl\ddvM t5_.h"iAW0u6awޥ̙{_tK_>bR||ӏ덅O=[^z=gr+'Dr~8UN= wvg_~Lb/>dK^~np}t|k7ݥh߃㟮·ז'!Q#p_~ٳnFݻk~%Pq]uѣNhyjoPzbAQ"*^>x!%Q<;m.Ԩ8wv]z|=sywG(oƴK'9nA$?d`TQT|%Sg?wI>-= я(JQҧx`u5vpz&r׭bcH7R ov ,>kg> G+*~8OƋ{!]۵ڡQ ~̉HZMv.tѿO~xVDO,^;o1z۶ jWGDz=W˩m}z}%!,\O~U8oO梧&ݫSAת#b̳;뀣FxM.N.J"ӧ+}fqݥgs6;m[/'6SRدq)3,9Kg?Uޛ|i6{5:}rZ4a{&z/C(3hMÏaFuLZ ܶ!p5;7YPzeuZ:i⸡v-j٤Au{\ 9ʛ^~!=: s~չ' )j%yq۸Yޞv0g_}O>OaAA,s3G?pfl'IO_^ǟPv[<^|q}{teuiSiZ{Ȑq?xrђӗ~%'1qCٷ:)= $Mڻcιj^\G+7!d"y7>q-W3{'mJ~D$!i;b75gXbp+Ws#hjM?x"2i^йГ/2s3l 2ϰzgN䡽;4W?|YgNC<WrÆ;Ug?pr(9 ۶h۵.ǟݏ6\ K?~6O׶-Qzv렁'Nm~mCBH_%^}fmxODzjj۝1o牅K>؀CAi'3F^󶿪'"= 6ۧ1']0G{=m҆=6ܣOcz״Q;Wz9+7en&e!Ӧ4W9vޮҫ!9 ]8Ǟ_}N*?{i'bͷQwE7Cx ~b?,S{ocԺiZI:[7oۭ W3g47{x5g#2S^]+LR/ѫRbtܻA,SItȰ.'_y{ *&=xէz:iVV*u?rp]yNC 9VtgjV*]x]sWte: %ue뺋 Gi_V*9 wV)+iHODRy_>~!Nj/sVV]yו4'L׳GVS:Vfv;qʝs^\RTZYSt€ۧuՏF̺Aǜ?/UTZo</>e{e]+\ï9˧?kV*ẽ\yCӗꊻ/ШY~Guu̺ ܆HeZc㊯K:}uW|;J/JVL.c_ZlS:%~'^x^Z|er9l9Hx=nЁ[mW.}ۣ1'_|_y;{ "}K^E]Po5^Y/W^\yz*eJcRҰӯCϾQ)/+DE= #ۦi敺Sk֝;iiג%/15Z62:a:|􅒧 %ef_4e☣zm^ɵ!YK}G3^\z ''>y ݎ}aJ| k>iZn[dTynJA{T|(=e)Vcw_{_vjѤ^p =sYsUcH^ wc{_c9e "k;/rM^J| _/r3;SU/ԫüJ~ y>OMcbc;q]^JN-;Kp6ׇu^v>Q{/m o#ÿ߁6WNQND.ZO_SI/}:]LeW3_Sʔҵ^{tE=/\%+S~FѢGߟ;i# JTbn& 5k㯘)E'DT2_GV]<()STV.wwRcud4kʾmҹ_kJR:pǞzM[^+OYеV;0+ח}%K^||ִ 00yVZKʾoJ[3zY#vk|-U*{_v_+C?27/_۷RTQr}3znokrUr·̚SyNDiX=7]zjrEظ_V+SML߇R]M>5!>) }yڳy߰>݊v.ά~wy>}? JWr1|/WL-a,N:r |oC}+{)>%_?^wvX5`|iy ӳgN&Mj٤^'">'z=ԋ ocK(ku`g^~sVe>z~WU|Aq6M;t¹WMg.Z߇2E0-noUK-:tUo!Y#(9{sfLctojסImJڡxєwW\9%|h?vд񺿿]ijݺYޞqnEoߏ,=7UտޭwWR\9ݩM.O{GTKTм}Jbpz]<|NJߟoEpYc3o2ؤA&[=6H1g]~Ý\~d-+I\Og0'c[nz1cϺ;ӛ=_ezv+{ơeo}k {e`77j^xۯ:V?Oނ}C3i?w+g{Rxo+ ۴K'B&ouL *>)оfrQ|Ew.;$oK̹)[r?̀ӷdDG _+1ߑyd^_#wOti3N̥n^GqU7Ͽ1W;njGU?c_~{n&Ҥy^}{Z|;yWr7Q~(wy~(S/xZо=퐗!'֭om] ѻ3w֙һXu_lDZOV}i8qx=.ܥY-b(ssOwh׾}fpiXv>_uU> Zۙ;ު)N_)SѪ`ϮwpQrwJr኏?-9Jo{}[v}-~ua.J{JNBG(Tr*;~սבG;}xS x~~Y%wZuϩS;fvů.Z܇3whɣJ[퓓P#bʏ"symQ;_w輧,|7xm[}%72ߟ޼,qK[pS7MN5e$WBZ SQ=^y2G5N;%o>nyz ?4|qzӵ ~k}͜~5N,ZқLw[u'}ң{'zEWLnmyѹ^Kdnwy?H{[27{<=3M⢳O=x%+~C/Yz?(tcG4܋.1{|xμ.zW_{}7|s_{-Lo8ޜ箙q_/'>v@ٝ܊ Y/sEB^}A;Gctosoz߽˯,6->ңE;sz o}ӝv%/m=]ԫC{:{}nۙo{WyѬέvkSPXԾ{өSΝ;ug=;/*,h[7]u?jU0w2pwZF͜}fsS[y.-wnӎMsn5H>95kTZrkFqTSv_kаQ㭷v&;6m47w&m֍5lPWukʩQPH@j$RNr07HS~ש|g?g>Js%IbHTMz5IRfՓjrC^ߛ$=X2SCO>_WtlױWHǕT?#/L~;G^OH^v;&?(PЦ0}m< iE4?Ucr-[ endstream endobj 276 0 obj <>stream qx"S9ە=|Z?=OQ??|;:옱FX<.wNJer;0vTnɕ:!r[2x=s[vt-쑛_ԶMvɣGW}C3My)dži6yb'l &ɦb) ɦb+X &Ůb+Xl Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X Ůb+X aMaW?vmv]bW@ ߷) ]a[?]ĐmSZ]a[٬린-l )l `m~ꦰ/21m6lZ~-.ПADDDDDDDDDf3ٔn}lZnd odKDgR$HU*UV&Fs]VI7b"Ė[UVfNNZeIZ95kTZeˤk>]زJ9֫߰QƲiQ խSZ-ZZ=NF4١iwM#;6kd ֪Qu5j)E7ib[M!mvݹ[7SZZ5KQZw.]w!w֥>{e&֬eZl-T`v/ڻ=o~G&~Gqo[PR |j9)c}>bnj3V6$gxL =o{ܾa[mQdy'O/! HFieͷμc֬dcϬYw>c֮Uɢ%dͷ^wmvt==2sƝsuegx>yMծV77ۼJzMvx@rӬ^E$s3矝}Mֶ6uW$/ͩ{rESo=WXtٲdβeKKͽ=o:jRFY+2ca֝>+dcΒW{lu>eHwۡ~*ZQQ.p=ʲ>ddcʕ~%/ͿW~hֹ rl رkLȂ7h_~%OWڳywoְV_B6e;v&1%|W_7Ɯ W9}hVޯ)_Õ_~FoO?\;&#بvٿ,kEL:keW~MRw|rsgM9Q~o+-z+ӊ HҊϖ/[4wԉcV8y/V+6X.dZ֢yZ!ݷi+^w״cBJF+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aBh F+$VH0Z!aB|MڊE:qVHI2Xhbĩw[$byҊYYZ1wѲL+bwI+V._p)9bVҊ+[w~K>~u/\K++>ʬ.p_B6|_|1٭pFފ~;漰Oo[٘o_3'3oׂժEY+Zю]>gGG++מϫ'8K~kY~GLև~e}rggO?^ޒ+?S9U/ZӰiN{e~gg+=6?2޻Pf3O/S5䋦>{ deoƝe˖.y{?ztlդ^ZUjkKf͞.Z%٘/>mŧwx-[B+^wmvt==2sƝsuegx>yMծV$5m,oGv_|;fͺK6̚u3_K:qhnZ5SWҹYIˢ.:.z)SN&s3{e&%XTQV7ib[M!mvݹ[7SVsoM6q'4cMmܠnURފUȩ]^5M#5lPnժlf)JjŖ[UVfNNZeIZ95kTZe-Rd^D^$ŨRjժdHrTj-N2D"-I5r&d'خ]䗔EK~ZQH_r*)[ M?SоCO>EQ6#Em :{}T۲Ug~䧗=|jܲx}N2&;:옱FX<.wNJer;0vTnU=!r[2x=s[vtAyVp/r/Q @b׭^B n Vp3@v?Vp3@ nodo7}H$IO}\{w0`@yAdL!B0yyyg97w`0.BܼAbeEEAy5` 9!%Æ'#%Ç )N7)$M=lƖ=29tp1N$9t܄WTV)(rq%E玡er J.O]5Y7ΝWsysou]u夫R89K'V\;vu/Q6xQk\[1D~N~g_ 'Sn]d=4655Uv쬵ug6\}l_zy* ҋۼz̰1B^)ϵ}q`G!egھܓ޳`֔Eyl!YV~U K‡T2zBu풆شm;wޣlm;okۼ鉇VO]2(- ,2r\+=Ć6?其^6< T9p-'K'][[w[|j37>쬵ug6x˹oL}_s>SO$ fOHyG۽_Š-W3nEs>_~'y$ӿLݸvkW6^@ӏ5.;ٵ6N}W~~Bjʂ+Wߝ:צ^kg.ɦ֯XzsKOWӄ%taubƩٵpjjJMc}'VhHX\vo}~|zG{oIg\'=Y;I3f̜|+V`q鷃ػމN\ٻfkN}c+Wv99ug0:U3fN>czrZ䪙3SEw~oaƔ+ǜ9蟓+&Ϝw4655U6"1tʬ?W=ׇ|[^g[Ձ_m ۿCl@nbIZnW~ut<߿|gۆ.y%U;dę7ոom󓟿Ues3ޟ';U]9rH9X0dU-;w޽Gݻv[?~iN.M]EGd?~WT7ﶵmU–܆'O-7}衉 ͡Os۲ϯWM* M??ڻصF >$GM9ﶻV6~Gxsn'~+G/9Kũ!b9_xWoMcSSZes3x|on|G/YEgӂa4y7zu/Qh;?׷0cʕcC坹5{W?'p\5Ysܤ쯦fޜfԉcGٗpCG.r)U UVOW}a<*U~~_g?He,-lJwoK?3*x>Ӫ+VVU137oJ|"Q035~B;z-=#+|-=;p[8o9}_n\;w+V/k| u~{ukR^ ) 6\}w;\?{E'ήt'?[b,=^MN_٫V**+K?ү V_zRrU5߾W;M)s?ryݜ3xY|S/$7NLS*&O^9-7i3O=~TTTyuVaw q_guTŌcmj-ImAϗc?[nRKV.[ {V.{^( [ {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^(\x+ ( {r{Vp/!7u+o[}z+oco7 S7o߾ $Is맾R .S_=;0 G_<v 2 !ܼ|e{;0DzN!XBn^ADQQĠ‚]0DaÓJ' ssC &GSZ6 e{cJnj:8QpzPXTU:nS+*UVOz¸QɒsE%TY}ݬΫI\ͼ7κrUK rBD+S;ºE([nsXzyI"?/ U7.XU k*; 5 YdA USg^]:ꪊn[pE[scyuPqըp s {-O>ᙍZ[Uvںi3zuw^;4Y\`ᐑ*,X'6<-/mU–7?G׭X0r!l!wP յK~bmwܵ{ݻvؾm'nXR[=atɠS[Hacn[6lދ/wW}{_~mr]=fXl!hxY y^8p㐲xmߏ_l{Y0kJs,+qGڼ}zpg:;y}/m#X^<؊u 7<~H׻޺sk/o~cŧPl!xy-;;]GWvh;owǖg[h^#BU/wOtww( Ǐ9|`667.MK7؉zzN<Wۦ%7]| -m;:=Lc>ngG֖=o[0,` ow[XlC[^ ¡` ϶}-B-ds'{[xd :-(f قlAa0[P-(f قlAa0[P-(f قlAa0[P-(f قlAa0[P-(f قlAa0[P-(f قlAa0[P-(f قlAa0[P-(f قlAa0[P-(f قlAa0[P-(v[hOo-ZZ-tn[[hl'{-tvn{-4oj۵`gױ=8ypMB/wOtww( Ǐ9|`667.BEk{fˎoNcǕ;Λoݱ,Wyc+5_;t#]*{:C VM[W,+qGڼ}zpg:;y}/m#X^,pv KZpσO>ƁutxςYSJİ1W_7-6ŗ^޻o{{Ƃgv/6oh3,{j ?wP յK~bmwܵ{ݻvؾm'nXR[=atɠS0pqsXڶ*[kk{aģV,S9ngSP,tmmny lMԓ-Iₜp *CG]Uq-u+57({kn~l#߰*54qĪj,Y~Ϫ5MMMkm㚆U,_t!(tbŵsj/XXhes-\0vεK//I?"N0\jReun;&es58IW./)*8lNnaQIrT WO)RvWYQ> JG%K ss©1$M=lƖ=29tp)C~axHɰe{a%CLw rr %% ` 1א3077///_^,I/)c藞CP< _N!=z A/~3PoܸyW]vYοL];mim˚7& *Q.QP}_`jj~3RӪWWߘQ]^1-55;oDV,OUVL<}ԽU,N:LWqMzz$I$Iʶ};>!={Å7c]p[ wwww) pWq)mDԛmDqWqCo p1MBqn }˟>oI$I$I3RԷzmrG 9EGL"ܼ|:7w`0.Bl"g`^~DX}Ġܜ`\G_(R2tx29B}dr!ʼnܜYGW<49rҲWo445# )*x,zGQXatbqGH j•׿?xqݻ(۽{s뷟/6-ɥ3fJFOy]F=?+_U6<~?} c^Օ#41t̤oWZl+xC::_mkyp657.bDqV5X6ɓǻ:;nܴ=WP*>/Xx}w[kKӒ.-njz=V'J`[Bg]š=VI=[mYk:U(3PfV̬BY2 ef*U(3PfV̬BY2 ef*U(3PfV̬BY2 ef*U(3PfV̬BY2 ef*U(3PfV̬BY2 ef*U(3PfV̬BY2 ef*U(3PfV̬BY2 ef*U(3PfV̬BY2 ef*U(3PfV̬BY2 ef*U(3PfV̬BY2 ef*U(3PfV̬BY2 efdwz{4YNջ﷊֭{ӫ越>Qz*"Vwf:cMMKjsm*:wEwdO΃wqqMEVQySۮ:v'Gu`n||͢ycy篢f7~w+=g7o񝧿t_܊ɢ*%V̭KOşw;yWGcNy VQV>{ڷ_t~w]]*:۷<ꏶ?=gMI KC*K̺}/;~}ηuv_~/[zA{_*>&tJ?z8qH]GW{ۿmx|қgN]R80\EJFOy]F=?+_U6<~?} c^Օ#CF^Y5os{޽kdq~U4MMw5|O~n[Ve{mm/ly[mxy'r:z9-k|kOo޴Ώ];l?@ҫX8xD٤nkezK7(k~/>V}rǮ/rTRT+&Ϝw4655U63QQ>ufUʊgTӧVL1}TgNL{UӦҏTϨ )?nӧs剂 \ug.>X]=yFi>ۙgNl}_FoW}Gl~߂wS0:s1bjeiU3L{)QD ;}w+w,?o9 OIV2qܕ׮lX5k\vkS׭mHz',X۸rݩpmj _:vlol7?{5M8~_/gnX~.HF'Xy @IUל~W_]\n7% ̭s>^ws5gqi*Nߨ:2,;IM /~H¿Wʫ{_KS:ӯפ*fL >|Я֒$)o| ?CKV.[nRK^(nR( {^K^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( {^( { ˅{{^( @nr/Q^p3QK 7?Vp3@{+ ;}#!@$I:~+E2s3߿rsl"c  W<˹Ip%J++*J *,MS/L)6<l/>dHq0?71N 1xhr1ecP7tɡ獡w E%Q&\=J]eE'+,)*'[^ww]J~9r΂}bsB[Vekmm/ly~sxt݊s*ǍR8rP]'6mn۶}]([۽k6oz%F =6/e{oooUX߷^ -/c%N򊆗N|{;:);8xŶ|/;g ɲz^7wvl^ҶO=jeɳ[+N[װ~_~wt;7o[16Y|G\Q9oњǞٲcouqeoǎv{wly5U^18-T,nl}9zDwww=q|acs⚪-ܴySۮ;9lı΃wmjnZrŷڶGY0vvnkmy-lvSN vGe-[8Ի՟- l«-B6w'WmA3قlAa0[P-(f قlAa0[P-(f قlAa0[P-(f قlAa0[P-(f قlAa0[P-(f قlA08ß]pH}Dda.QK55lDY] Is4W.ŸΤ*Զq5ⴖ[x{3K>c֗e* D ] JQB@.%t( D ] JQB@.%t( D ] JQB@.%t( D ] JQB@.%t(y.t] Fν> Bmcԅ`(qs/؅. -BGB?e0l뗺x@/\}_*1hrta.9}֝}}}Ĉ;nvw:_Vp]<ڍ[.?yx߮M `: J};?wj?ԴWZHƘ.)+ozlWu\'FMOO=ֺw)hsp&'g.*Yu/]u3]]W:/_jDJe&O8ck676~p:::.#F:ϟ;{֦kϟ;#Q0 tI!tOqo'On;CӧN?hm޽W^IO2 u!<2nZ3~ &8rP;Ji"Çf&g+.][qަ}[Zcy{կ--l)]~r,\VZۼu] ĸihصs;e 'RX8ǵ|ͺM:bHֿɷ~ݚⅮ956R!~jǼ%+JW#lu銒s G?U_h 1r /qfG[/ RBLis3;N=snzZtk|t),d e iI3SRg'5%yF4kB`U2DD 1q񉓭)Z''c,Chlc-DEZ̦1U !,d2β&7al2L*U>yζTI* A. 0d"A5 A<+Xr⪕P%]9V1I<<⋏|$5y5uվJc۳ż:8WM3K5kŹ扳|{+s+?_쭪XR1pȧkv^Ft8=3n: fE~ٚ*_+7xkߑسo:"=8o7>`O؜ ؃%vzۋkkIO m /W?YT\%LzkL [zslnCtvW=#Ҳr2Rzճh;ъ!᱙'W<}qM:bHgֿ_k XH))O?WoH7w? OOa#[afg?]{u[ZZ#G:nxnoγL.C!H5Ŷ|ޖw}@(1z#`Ӟ_~aEaNztnpE'Nnk;CӧNk+ݖlڛBLQIsr>߹Gg~/ttt\$Ft/Ň?;_vǧ H 32<}-'v^J+닿l;MoU,MOTZ3=k?x'g>uo?N~O~ӫާ̝eъhk=oӟ}_^|fQ(BъTGA7~į>[?޼|kdž/!~12YT'Ftw~cci#:2Cs+޾>woܴЙjQMKk[m]=wR1xZrYs攘ZqTjQ{t]h 47Ԗۊ3W_0KDj=]m%㷢3Vӊ ץVd(V\8C+p˭x:ZAB+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:C+:Vt9XK+@h{P+j[[G+&DVHh+ j1rOjEoOׅ@sCmy}[Z{Z?rYsV/\~{gՅTkyt+Ee;pݾ~bHg?wϟ|768RPhktowWFwso^c|{1HܰwOosbk?<ڋ,S裏(&'g.z~O|vs===7gWO-;#!T<ȣ&!aFƂ|?+]]Wu}mͺIJ+&.}οsW_H _h?vK+ ]OhE%nկ~?:8u 1vN:y{_\u[5&"lүۂ'}{{[y >M{^9I +LHt՝[oonii9@ 7ᵺ9ϖ2%vDnExd씔§ڎ7lh$OÛ^_ǧK0R1"$<6Ӗ/nظ_[[[G 7ּ?>U4>kKEVHZb^PgV իeE ݙϰEf!aRf\ -!OQQa~ןΚ4PKR)I)ȴ;.2tسl7+kSc"Gb!6:-iFrJL21,&=6%1.:,bt+Z""V21bNN"#LacK1Pаps%JcIta6Kz!d2dbD:&SxXԉG՝^.bHՐA&H-s\(!WL*1dDrTTU U,iyґ+k~-R/XbF%.ٝ9n{(pسr] cwYK U[KNqO-˙k7:貹ⴻ]#dهx?R0;W#:^~?\@gG^K{ػFHt%%roKULz/"`l.S~SWVm1[:nwʧa=Bʣ{DߢdI$Դ딯Ow㑮ŖJM!؏߹z xl0 a`+^Z @ [< ^Z @ {ha+^Z @ {ha/-^Z @ {ha/-^Z @ {ha/-^Z @ {ha/-^Z @ {ha/-^Z @ {ha/-^Z @ {ha/-^Z @ {ha/-^Z @ {ha/-^Z @ {ha/-^Z @ {ha/-^Z @ {ha/-Ș|կu `,^Z @x{ `/- l Ll LL_t+Xԭf`bsc{#!B!$2QYGD} AHHHhhh1~,`!TU d2β.UB*HM0-QBtt 1~H$aҘ*""VbXbSب2`bӒf$$FOjJiքX2 *DF['ʹ;.b8̹iӭё#0P%:~8+陿hqAab,^4̘%NB!OM㘗_dErb䔭.]Q8cNx!",de!2*q-,.)_no3ҹo_xkh0Lz4,$LXʷyqаk}U:fMOaJB,1yťk5۴osKb̴4״wO21CbRHxdܴ4g~IUM[>%FM pփMwW;ӦEu).m8vmgQvͻʋ=IQ.QH1w5Z?8q # ϝ=AkS5ϝ( G əJms0pK;tu]%LWו˗?:8g뺒Eɓ#`M/^~cg?z:1jzz]ֽׯXlOw!cMuv6:~7{?'MW/;~ijRBD̔՛v;|k7zoݾC۷zo\|}6.tΜ1 2#;o޺s1ҙ{SG7ˊ\.,)mhp{Ĩ{ʅӁ%w%> G }w>h 4> Gtt]@ {.\8s]k< uj _%_b]*u@c݃pQB?]0r]H] C D ] JQB@.%t( D ] JQB@.%t( D ] JQB@.%t( D ] JQB@.%t( D ] JQB@.%t( D ] JQB@.%t( D ] JQB@.%t( D ] JQB@.%t( D ] JQB@.%t( D ] J r肑sOBǃP"u+` vKBKchꑺO {.tuЅ Wzzo@ {{\8hn EeGNwv߼un___?1b3{έݝo,\i׾'_v;ĸ}ƵO޷kB.cRΦC]}fĸys58R1f hk};k==׉Q}gݾ~b{5:܅əJms0pK;tu]%LWו˗?:8g뺒EɓGt$$Θ;͍M8{|ĈΞqHL]xdR)*>)S\۶5pSǏZwo{ғL!*H]/߱`GĨ >zi|gڴ.YbKokܳi斖Ęiii޿imkKe$[c,aJ_* V6oݾsWCC#1nvܾutBǬ ௔12*q-,.)_no3ҹo_xkhTcC"B<1/xyɊe)[]dyq<ǜB1paDOge8=-.(,ZB‚ŋ{ĩі˂ԅ0SdtuzrZL"ƎaϜ<i @,BluZҌԙIMI4͚+XFUa BL\|du 1zɉq1BdĘ*f2["bDG Q),tL2m 7fs1zl2M[ :J t-a u A&H{"*yP OZZaMUAe]`Y W]!y,,.m,Yr['rҍlW#p{l1K|I]!j`rزvɱ]A<>lAnqe>A<+Xr⪕P%]9V1I8rؑevH/|X6bsm9s]RK9. 'ҫq'wykoxWW){i,qtZŷtCr|Po\NK͝-Pt{8_-rgn{Hl_=Kޤ/>?s E;/]^+csϤzjεee9r{tydM|;.!NJW&OtHz1C̵rr<_{A{~9|>enwDާf3& Vk}i]һ]Qo(ߵIꈃ:t=^Wu=^Wu=^WuUwȮ:7Ց.=tpwmVwu?\C/fTtu~=y𯸫?=gUgmDWGrl*xҁ*zWV\[[WӻQOZV+֮mz{"$ endstream endobj 277 0 obj <>stream %AI12_CompressedDataxiɕ Cvt73C "2WӔJPI=Rdvno> _͛?}@1\xW_o?||/7?/.~ ן㧛O?}Ϸ~y {"`]~qⷿKn^}ܾwn_^r8)Owg\R\w9Rt\JpGLy W_~~{o?y>bͻ_| \7r}s?{߿o^g^g۾7n_sW7ׯ(t1o~5o_yox c"&77z o?}!~Ҿ< n}MfBxۛ.߾ +M/rÄֿRx%lN_yVFb׼.BW~75,*<~kPaͷ+W÷`A-lO~q-Q~_rH эcq c&y&?Y"Dq2[>_j7:]d27/(O簂vnpwv |7ᅰ_ P|xėOۋϷ#l/F?4W? ~Wd| oo?/_7>}ǛwwϿ-AB&ƍGÐ~I鏟W7{~ vsnqoz$(8x?~#/?p%>y7Sǘ_՟n߾~ܞs/o޼{w?|hy݂aIz=tg?<݇[vo?b %?a{y77 z{7ԣ܃~wO ~{6M3ߍ_f|']x۟fon}~ŏUsͯhjnO>5}?hn{V-w>>4wo :|-c7[5_z<]s /}z^.^k`|[?>wl?AO~Ϥp#0o߽|sI}J؁)n~ 2zN ?oa5| o|ݟ?}s~'PO };Pp{')_ Ęw}A`\ߟon| >/~{UWIho>}02_߼u};o \ ?Ցy]}A=iQ_{b=ly_sp+HQoGnG0^y\YNH7[q]{?@f߃s;ܼ3p}E2/n-pm?\0Ac6Z32nmq F'.EeWm]Aۻkw ~B{,w.>r]o_A0@g]!)ĐB?M؆] p= iSLi;i?]^EG|~dHCV`64r;=pl@H>:v#G/~òW}u}ͫQPRYk/yq櫟e79;=̠!"#$%#'()̪+,-./pkY?~4+*$@4SJmūv  Q ])I*imv*5L(NA y4 Z%o6UC.L _8Tr)eS`/װ,F,xuiAyS6nv~s fɄ IG{}~=- wh޳{{}ϭOHϩ۸Mۼ-vm5lq`00v]k #,`xk*_jwuu4&C_v_kdHpIE1oWyDkmn0kԵ8kiY:7ݬ]6wm]IKi4'^GV3I~,HHJIa'+ B^y-j+$JXOV(4\Fy:kW+W^@qjiȂ4/M(m඿Ɵ+i;i} iYZi^nbBqFr cTM дnu1\P4^G/S^oNx`xu]K$.'̗~k^]""(t{Ō5xơmS`BĶ"J$ۏ.}O(4tdKtӅ %ם@KԊ$BvBeAV!YJ&<!?Bv`ْAK+.WG(YihKJwdias9ml51N Mn *2ȣB $:  (F B(P\&q A"9`Ap$cƺ;2ccj[*,T&.EU\y9DukLQ$[fIk[U׀}׮Fc3t6k:4D6@6 ǫ/]0vΤ?H$=qK )""`19RRh@|M <}H00W"AN ;nNz^pmÎ@$ 0)UdX哛xZk?riWءuyh=Szh=. rx֊ZI8̷^k+4]e .Ɓv3Ag \@g:mm kP/@.дTkw73l>]s@3LKŋONā㮦v&36*0[o^^|za\@~}R~X3wdԾ'W׻-Ug"C'2E\aCfDF@&vj$ 9'b[bDl@DH/]4&"XHDuOt{? ĈKD W -@x/BZ'!LRBN7DJQO$ ^'$Dcרڏu l\/s Ĝ__ӧ[\k`x7t*o+җ.~H3w^+BReFA~~= w0÷?LkܱΜ?{wŷqVV0,>L+L&ptZ0Wc]/Vȅ*P4+\Wa#i:쳛7QmƲhyY]jW[m sSg;'_PElHƆ=6qO7}K+`1Bk \jlHO(ߐɛIcSIo@a$#صWԭh*&&X`Z;Gg,>i_4+4':FkڅY^ZQd/ZQNZFv5$ %$\#ƿqw8&ң_=HkvuGRa1df}yd[Guqsw5u8> mH/h_; 6Ҋ4HҢ-ןX=ѵ4׸"Vp#c2_z'>X䧒},ƭ4Y{d9vbX~қn*M6`ڀ#DjCw%[$/kWD3Ikld(eHGл 4Ӗ_1޻uSHg^Ts}rϛHl,ҿmV[Yh$QTmkӬz 5`l?mu5(%brb*L!FM`%;RZC=Q\4=_g|"N>7^#jH ߾K <~Ji?#ܘSo*=/.7.صfʚVYؖ$ʈ*!=a:H@%dI}&oF2𷒅3O;sL- "ؑN,,TP}-eƊ}?h+=}2/Kq é 0|?Ǝxx2I)\Zk8ݨM$^HZvgc+2CU#ɌM WT5K{O@"MxdsNpg'Xy,hIh'v\D4Hj:KFܼHHA!7$krWgӴ*xU]7}[7([U{&mӗ5?3m~\#SL %'*EW{ok=U5i432m>\q kF%3 59x05 K*52?,ŹX6lhe[yXa@U+vJ$Q,JIik?k~܁kmpSniѓw) 0t!1Z(G)ƍǶ#Nߚ" 4]hQI48ؓ<0{!)ALE-ǪŬ;Zh]1Ь%'P٭k,H.AҩwH/ !tߗc{,O=C0tqR ܻ=bJ%`\ƪ|Q; 2*fƻI5';>x<~Q\u-vi,g%g5gDѸZ#~,ZZ 0RU"ZTMBkG?*roұú8f]2 `Կ֫6j[*iVڶTMV;5`l+i5Q6: @n\ZS[2R5DS\ i:\CutJvAQt bXaQr99Ψldյ!iMe.WК]8=ftRRR~ҬV#GZaQR>Zr:bW{Lۻ`|8l9`߇$&V֤qZ:̘Шn^Fie啖-۝G?`Ez4eDMYGSOj w?&GHiOkp3#G߆ 0?珚$gz +Zj,_lfQߝROD\;dyTB /m㥸[sz] ?θiq)qtK| ܰpL,O47Mt∃V9; r.:{Yq)/~X *sMx5`|8q+nUYiO8x(GX~҆)36'l5d rn'kjS5F]Ԥ@:)} Ĕ┾zF_==?;#1 Os30(Ѿ;ʙ'f eL+4!4Nn9=HhvRC_ٔ!#6Y-X;bsP5ϔmMeRHGOhMHpVa嘊 z&*Gç<|=vy=x,vG49 AAj E5rC)~1i 0x嬭TvF+M0CV۶zg(#Gǁ.@;p\uXCY?4ͬXd18TFg=¿ 0wϙ"6sOfS/YˢR-6˲}`@Bm)r!]@8E=">FvT*1&p^s1i'PN٬u, sJޯgnK] |elJE -O>Jk)&Kcqk*hN.3-=Wh m-Z-״>@Y,6QC-m>v/'cv}91Ut>@id*JrWzR~%+is(HyT-Gr9|큓%+Tj\aZ9U"rJU9lv4^!7lYV3cvYsuq'`\-D-c<Мw- 1`LM["0크cy"=铃߳<:3eyq¡C|z\7rGNAMe*9B>},ǧ3xwؑ@M=Nѫsrt&I>;q:|ioXܖhteY)\Ha]J[&iIbZT n(Z I]**&BNy"Ovk=A"Gn׭sLj=XRANb9_mai:=s[c<èn=l]96gvgqN;{_=ӃZ]hX[JθFQuύ h^c9S|/YK}KݫտzFZ#dmq0B~?<Rg^C5Q)ˑWjgsJIw=A0s4izǫd 0U68\`ٖr@}]FXkAY֎ԡy1?&8E' {NW8 {8B$9> |wE >z0̮ ]Ikucl< ϹolYB]G.0UW/snGqQ~GqQ~x8yt}ҙE[èFUhko;36bbU?椳N$ue8L+u%Sٮ!PƘhg\M:j'f<iޗ3o{5~?}̿m!|x m AV':Pr%y\WȯffN'&~od~u2]l;_ hӝg ng/tO c} V5,V2/qusw:;ݡnZ_iHfaJsm3USnbf\`0Lʷ+0oW+z̛ض|y8wfhsOVURR^9h`R0=M FobӖ&z21Hs ٓ) Iڑ'jK:GG*r$ƑT{T]QZ!qIrkQsw㹟Gl#x4hGvGKm9PgGݵZkǹqnxssV"G=.BD1)6K`b)&ׇ!RWj߳vB cYLyen\Lfٷ};{8w<_cRa9tL:2!GηLvbC;m9oh,BQ"y,sEz3$S!XgHDC~ϐN%S!7=`$h&gc}N'b;a}^|5W?}rwyȷxXuCUӭ,B_{Ղ^mЋ,؅hՕ:\#+^0^19uIT'aSg1#uU:=QܴՉ航pf+m;#m^d̗Y.KlqǷQE]8x34z=_[Gf&G:&L.@]@y֍4&)&G섩t )Mmٷ:Mq{0C{,9$7 >7K4ŀpfXꄔ˱= ^7ѯ}Y-q+ qSo3_4'\Zy5sG$kˈs='sZO>ޱ>zp`.5wd@IXFQ"2 (p1 (P&%8p+ e<[Qx-fNⵘD 8ѨWS9GEhFԁ #Eڐ=gsqñU(Ts X%rr G[m$nG ,tWVc^sjpF2%7ODmږVm6TSvp/K`sON tQl8RQ}DFaPV՛h vEӔ%cErƐ\(p~\:8fK7ⴺF  J-^"CӉxэg4y򊥔}"UT(: CCW<.ԕS}e&Lө@3 5p3l( +Y` kSAvbI0}[ܢ&`f1Uʮ*WYe[)E/jro'N۠6؊< vMIbZbzuާMHqKzEXRw<;1}CMKaMy e6X 此7KQ-]]Kp9wsC;\A7AQXKV=n&$ ==L?NKYҺh,I5]XEqH6XLIwW}y?/DH v:=ϿY4B4Fwxfy*ȯ[hpCA-Բgb.SĀT{qY ҄~*fN'K hm7Hh3r ,|Aͅܥzƪ6͝!%Ishl*^V˥Z,8|Jz@?^Ix46Z8?`Brx;fe#Dp&p]I]kU+pP噏 bPΕDMӦT ѶV82`p$[ .6 ONΨ3xA 75u؟sz:u)G9 hпӼ9K_N*oZdz,f髙hf<.<*s Z\RimY`Pr)l-W&Q1m+.d5jZg1Kʦ؎k8*lje>eO7;!+G|v&ΨofΈn\+K]䚂ڻY%m,9I[{:_c6j=gϫ9,OjO_脃0NBuwKw78/kq ^(IA#ƇxɼI[ФM'JoSɡZ/v\76Qҡ}UJif5$GIs$zqUDGowt/}*[KԄHMHK̢Vmض-%'5-wq]SRgV:%l#FJ| =Rö8; 0q۹1>9hLvP09Ǹ;m_#~j#u\];0RV1/ij0qWi뎴Gڊk3@|g3]Mߞ}re=TyrkΨs+-ڈZA+4Ԋ*O'~m-'VS2k}ͼk YֽUDWZ9a0qF|[zi?E8Ԕ0ɍ6+6sgѽM{I̜)}r@|YH;nX; /'eϹWŗ5RqW⼗1f4bFv__}=ج?WL'ku5 >ͪɜ}s<57j槾'!Z{chWS:jG.O./T*3y.vqMm=u!"9v麋W!2Jc&zV_Z7"1¬do_طM:+ܾ׳sT +ȦexSRz},E.4|"O`<,e0yDzeRl,L˿ufT:#Ցu΂Ԫv$&A7fEKaJ3gLvH벧+V "%̗,'Nuw߹@uDgDhex8A$mE]0}~~NΚ3|>LJN^mZ; C @l?N#s"s^ס1\9%/ ^&M7GxږjTȖزQ>vjґ3zT VѪT]#h,7D2O;\7>FT0H8rʌOU[̽nӻz7rEsX}KK!ҜF˲H(ٟR{sREua$RV,F׼ӊ׹;k^yM`{kպD/34`בq+YM/#uӡ<ޣb嬸%E/I?ws8sVarU۾>Tr TPS5L +'u t:ª`a@ :wiS֌Rޯ }Gv&"7۽z\ŮV>Sia*)hJZ5N)00k]WozQoP̣U0VowYqwzݦ%Urv Otyze˃,wV*V1ޣ>]pF9!`'='ν1N{zY<]Qrd;R.aeC5s#C6HjtSyPs[9̃F?|weNY?B.eÿ4zFhvW=/z-γ4sp[؁XsZ 0Y~ac$c#ڵ}4ϕZXwUj*8&f0QT_~6Wp+8IH3KّGr1L,NL; 7K!c;-d7w^ٳ<}7Ͽӧ^݅|E}HrrtˀEiXtQe ǟ}~ES9$0(vL*U`w;WREDAɿT__MB'~(l`}wIpɄac3a b hBJ/zb q@j30^0*%,c{!7˒@' ፥\tQ?.;PT!F(fcAqzوAZQTFKh@c$btI;gL iɡ$h)tȘfDwڀJ 0 }TK;zVRLŕvX]JSftNzAGI Lh0stV{Ey(2k\E|I@z{ҋqf u!"]Q@ߪs׹uv.PB#xu\QDu1LG?"Ѯu,Gq FQr(QIlc@q(cQIhƾƀhau(P&@_2uL%b[q(QotE%]YE]%wQW2(o-k1pU 7P ubtLE=vxB ~o)(eٲ]U$w %+Qru(+QBID="{ѲpL D{G,|-#2ԋر#?-0'󡫳OO9w>op߶>pY~&/O|1ة@ r>/1HHV w؛l#n:~r܃23!q|4R23 0 gY:@WLR+mW&`'+LW:d/.χg`[A0T8~e}tP_: )wfz1h.5[LC|`ê/?ſ7V.~޹ a~W:%2D2>Kh z o+[̘!.rUG)@L0@wPfiRj9*$o0.݀I t2഑q}sF6%d6 uDۦ_Ÿ/8,?S ByFNoŭѡ0 UAeIID %I%g;!x* .\$7-mŎx*#FwbCt9LΏ&^ˁcxn(.qå 52䘈Iጂ..7p ,(0*U1L޺0`=n1(!yW$Z_)cA2M2 N F8Nԗ=)ȳe@Ua ]=\aqAП:AIQg/vi*ʹ{ LTmҗ./ r!mEsWzǁfyP;aGxy`YHxzI|HIm!~MĵV27fR:~LѠ*h(7SP̌BSPs!xbi`5ut;_t@ _tC X7@ ; of'nt/>-r_Y_KVwZ.ePUL= h(`qV945x+5]# CnBgA͘ɋZWb =s0ŨYF\¨=&Ton`i!=!|bn.yĢ"\tzP$4H2V=Y Σ%XU$P884,0O@1 ^ ]CT  }(97Xf CzŐXƬ}`+%I%ײu$`9 p#FW1wFGQǡ9YmM</G,J¤}k^3}t#jG@G` sY%iN*IzXYf 8XĸV~BM | OgV~NXRai }ܩ=:*` yxȄn!,o/Ҙ0֡d`t:!qrT|p^$Ed\1P: GFFؠb1u!p )17dVɈdBaP8IE$oN>8|wQ@feg@YvQ9|t Q!SPBGǂiQ.p21H=GNIʘ%@YI@\^71 I[} "f݇4]vZCpQ0ry*QsuXʁ&'@g054#]p2IF&& VhHTŪ¹ (8"o@tS-#LˋAdwI%Z1L:AD `ˮJPtnl9F E4~bY0d2_VAĐCAOlVKl+Nz1W4b\bH&D41A6EMHývmd.8Qyc,l}Pa65p0`{J$#pCtdBoTR(ERw&MB%h0p@ b]8)"--8Y/txPx!5yKZjo⬆9v2 Ɠ82߄&7O0og)/kvbn h"t^X ˋ_ܖ6RU5O9P/4UNU݈$1̒I<08CT<`AC*u J**cq\ՖUD,#c|2m*MF,0LA/ӄ XgEED@tb2[!o(1T1e'9a4ʐ!`Jڅh\X<ʫ_:| TxlA3@+ҞGkĺ Ȇ#)LpQИJȡ.Cԅn",c]E(몌Xz63 Ej*+cտ k9Qh֎xYyI0B?ɥeRAD/tΔ*;bbZr @U]DܶY:lx"XȄpc"W?:ҋMfrV(>؄"8P0\ ˀ]"Lfd,$k$X"0@7In6a^9uC{ɮGeY0X5F82[i%LobOouLX)SbM:v*=텂%3j6&A;yRRw6*E̎ƖJ(ctc6́K3 0+} >f[ QW*,b֏9b=DIhv,ncx)*!9sE6x3yhFZ>9RoEQ:>?C 3dseuOɓʬߏ&*vGЅ)ȡ[gOFD M6,MtZ~2Lybʨkd`j1L*seIri:֐l:it􎁺;׍Tox<[tO.)]d ќѼ$6i bhql0(/EH"F0Z ^R]EUPw'|%z /,udIU_^jt?€cH5#Zsf#%IbH,vRfV']'ѳ)$ԤU 7"$e }MT1gz|^R81ñz&f 0>KGu>rc$0R8V8 L u bо\iOO30n X,?hhd: :SR_Cࢾ[?fUz;!V"}°-@u "֢"fIarŲFxILi/NjwbgB"j B GY%:ŐG@*Bl)s,D\_<(Yle n AHPF"6,om?^lSGOyܤ&ePŷ1_CuuQ Q}sɄ`sG>.VKD.bǂ0_/afoGfe Xk5ֆR41ҕjm+j`u)_/5T6RrU.s${ O`P'0S"C S&kpod ɚխ'`őJGPR8j,`J|T9LTVFXf2)! zQz]xw2 yjb`Hot$ ZvBӜ=g7c}auEJ.]pWQYi-(T@ľ6o=ZqErmXL v9@*WYN3 [T{c 7{[ ha>' 9*\+GpvF9Ї'IrĘ6dY}F;dY u+iyZJ`沭jpӿjM'B8ZX[>DJ28-F5D+9EG^dVx/aѢUCdi1vOVf !pҸ&p5W#q9C`ȡƎ͸)VFڳM|['uYGb$fN PC_CC3$d|diRWP4ĉEohgLRי-kzzXT N*Xdҳtvp\DvoC]*D[AnINK>[s0I[3 TEӒ9)hĬַ.F+!%IfA(EXXȁ 4XIhL0l:J5h|X(.YKQ<0X49• V,`sq-Cx`Kȣm*z]b`H[i/ DH&X/8T-Q H]&%d3]Ѽ@d}Ӌ ֞@I*{ф|{ځħ9=-Ljĩ=+i!x tV%Ti.GD@T NtZ%XnDfmDdDawИM/VA?q!L&ZAhE9KGK+r~,C`@NZ>(/O+8E铯u$Ӏ |ˉO0_jgqQ5#T"Dgs&"a%W!HlgLr||r2)gG3\ >K( Us0oҴN<^͘C\1O8D0$Af(ॺ6a? =KY3Jp3a$w`]R&˱q/uO9|,Øk'Ҧ$@2 vtj8 +/@L GN!'x;ɇ'/RȲ@z 9NqIE8,ᣅK (EG\֩:Â2m.zS Hc݌Fa̝|.7P{/"3A #甐Rh˒a2Q.Cgx+sAX6> ,F9ssǞU𲤽Cβ)O'h6 ☥M1Zt#²jaQt|2iEzct3*PL`UC A jqa| FAx*oY@LE^C04{QGI +**t1ҷ0$"i",&{ɩ9:G9sѥWTbł/@àS)|:&Sd51*)#-T}EdqE.h8QI2JHIJ.9} ,N,?cRn/ 6FhpKCԩj6SB/1;(e}%d) Ac!ENV3o&:<(t^?*mSf% r4Lz8oMt4ڀޘLy؇$U%_*4h{N<*t @$a4Y:r"wK(a`׫$Q D&уx^-{ˑaݯRCx*N)r%!dLC>@`ïp1efelƔĄg5aZF-(f0 E5t,|Jdɰy`8hݓ4ЪW ovuԚ,cR :'zz,~x!w-\T,Z4r\$vFy % e1Pz&(U?!8"Y%#SvNZC^΂ L"%JQ~|)jYnflJce,*pq, r9DNIJ,L*9QIJ7oy:$VErOUFV0ruyI(AJQE@W(U<5>r4_RB9$“ `S!EXD{ ޵9&]xI$?c733|̡(ڿbPn[CT%TUd.\IcJ@m'g??Gt,ԚFggʡ񑄔iHJ%(M"&6l0>Jkʙ[!/J9o?%- B %%Ay*)KmzIz V Ř$X֏2 L1RKFhU{,{FyerX$=J%!9P^FD/ `D7BdZ A|Br69#*h[!p 2Oa{!'f^.Vy5ͩIb^SHpF4q"(pnga.}g'.!{^V,_jM䘤0,W/ FnhTBȬbO0YZ?#=_3 4ٴM.N0B9mXܞ!yǖu $ W*Fc~,>81rMFQ~b+4j[23l'K+pOAO(̡2n龺2 9qD^Zo^(lRS+QGg8ʼnWtz}ݰQi*4:IրtН@1јJ7LlX(x1ve2b9e69O/όuVYب!$fG+:)JיtS_b*x$q,;WE\L/aRș(O!(.8;I3S$cg' P ѐ-/m/) Q03?Jo%ciEN/G7T=^OhQ0w>XPا x6ޙHo0_Ѯ =⋬XW @2)yC*O!%]x)+֔h(f*>,rv/6SAc&'FoYUFy$_!XViERc^;StOY-~fĚzc-H8.n1UJO(vN!u2Ĕ-JBV_+ ^R8+5^ɜ0}ey)hrsHXyh/9sE_W_P LmxdO=9.06d҂,Z.E0Fd&v09uqB>Mw+ T|%2i`zz@/Zi+9.ZmL0dQY(P5~!jUi[U nNp~bde#W^)DF`z<eu256l1 "~zkE?^,Xrج$*zHUYL+F= ydCh˽Ťq ]W!(:M>Y6RyBBK-"{;l,HE,.qĨ}q'#EvCegȠL/Nm(S?1ak;Mlxb4)C -Sn [iyzq><|2&p~srqLޡ}2GFRڗǃn$[J<',/2Q8Elrf;%(޹PNu KH6' ԥzleudu^/ L#VsE3\w~6FP~I&7th,'=ũDV刭3ydW`qLve^ժ=1H+OX'/fɜrOԟXHpI=]%cyB'3 L 8Wv%0/C@2JIx(kշ?1V]gtm('=5bD>ӑ|{B7Gx| YP@.%3(׆ʞdc1:&cyB38";d.^m}_x.@sR dldDqUF@]M2YPX6ڪ1ׇ"g"Lgd0O}b8=RB< 1bTpY *HҼ6)RZjri!R_pĒ`4!_vT+A沱&vW͹`Չ?6|m,*3cJPe\ l؜zS^N%V<qTv&GJ'ڏW {_8?dLn+8"QJl,O 1Lw턈_ff։1Y21~V.כ tvTfHG{f^*yo^HƱK<^];wfĨW=H2ԨT11r)v< &G?#$ Ӌ:sWgQv e6d>JTK]$ O&x=8⭺4]85ګtHOAO8Zex&[i}ώ7r!Fgd Myy0:\g~*5m-AƁ6f m<dr]~/mdB֮1& kd!&!; Z +ѧB>]Fc/]qG{9fFhb'p(BTx"np6'@&MP]ls$#Ig {|kGfJ| .c\OM$ۉݧDԦ}Vv]~Bjc2}8*]2[1rxͦKxĥYddCR"*O{yr0.QqĞIr}leɨ&F~?KELR00Jl,OE\l ^z"leǤrJAYZ/Py1m+ 1c8T+~B'۔{ՋяzA{2 XxJm 2ddTS3^5mR,AʵI֥2XޡK&|!^<KnhDQe 8,MN+c)3JOu YY"ldGpU+B=+珧%.'+>vIi]1<ɽG8XXNEйM6?1^kcQL jحt \OGud3}ҙO6TKA+)jxn3ؕʳ̺Ss9g-1R^'3MlR[S,O$rq ' :C>&W 6WEӔϩsqӕqKb:5Fs/BG@4[L9{" ޷Q@Kj=6_ Ra$*iO F{H #I:j'25)RKT 䫥L?WRZ `sbse$vZ3757Ͱ˙T$v:hLI_Iv7-r>Jlfq6>F\>Lk3Mv]N$m&ϥ -.͔MK2t3mz.Ǻ۞wK9F*\L{3~}|hLo7S( 4e 4 ZMiYBSaC-h+I4$'f)XTMy9-FSc=J)i JtM"(M9mKSeK-ӔӘi 4kԚ8 9,ό:4PCDhNq)O2S=jI$5Ŕf2M-=OPTSRjN%VՔUje5d4f^u*Ѱ؜ٌpY[lD<)dk 5U"rMym]S򮩏k)5֫J nTؔ5l Ҋ- ƖcSr^d%.ٔ׬l\607,"M9 ;kUA9ЦhStN)wFSQmK6Y\M95٦lSvFж!~۔ɝm 6zNDrs3B-qU`rS)<<#}ptSz"^=t*Zڳj3M9ޔvo)7Bm:MfIbW%zR0xpٰWZ.4K449U~),(D)U,jI\J]jͲ5@6"IUYޤ.RJͲ)*sXU[]jlQMf]9 %né4ii.PjpJգ%Ŕڕef :Uڢ~ԴUyB\RmO a5KfjjU_Z`aSr2~=pYU\SnNͮ)}א[W)5Zb~-ݿ%}@?i ng?l$65MƖ_ꅖߊJ`SQ. ]~UuO4/fT>jI$S2hECiEҩ RSiI,ŝ]6;TUY*C ͫyVSwkN5 kʚfkjۜ`ی[K~Ÿ9uuM9-y^SoNTD>h 6U g$ zkgDMm%!DՋ+=ɦLe[Ӳ~9#l(pu6=*s-}і#:+TYm걮o(65agd[jM]9ۆmSwNH;ϻ"lZʀ2uJrժIQN3Njf%*Ok̪fKkffSZmLdmiB޷i媽6p&㰙8zlGO,Yug+Ct.{ROimޟ);V[6uקVy͌f.r3ky.ŹΜ^f]d7קzWiLVRz#{}.}&/An_^~:U`NL`FyQ0'hT?h$UZ *K4(-5nzJݱFc!ÌdQljT_6u2MζV眰gS:'.T"mj86tSDVk[BUئ~lCa^vm6zg| B$8 59NzJ+%`n5eMe9fuSzvT^'=n|I7˒,zSC4|_'_)ɯ*·qToj [M}S}IF݁"͊sU54+;4k@/1.ѬC.ZѬp*1[5U`QclLf5!:#͊$ʗLjEQPiV[ieY[ĥҪ .$Ӫ:TR6ͺ73r4k4+Si +*4W]hQ Ҵ\RJWEuU\a T^\qf%fͪvyf%fͬvf5]sEf 5-ON v+jtWz\7q$7eH}XoS]_[ T\/9XM%yÆFbSMbʀ+"EpIJJMͥLS-QO<5+SԴQsRYVKkNT k5dgZvGմ*nW˗5UIMJpsqMzẪ-9r^khUZZs z‰aCqN@e\/&Mm9!ɶsbeK#sNPl*6BV-Y]U ԦV&ª kSuD ;'0TmֶkJ>߬ߋ-=_JiHw"n\A-_B z$ޡeDk8pqgCkb ڥ,,zş߶a …[{aWk|AԮkM_lEjrl/fOJrUbkdz7JWÔMFE8zR.FnȼnV_+I,u|CuG7(ٹ׿oͷxuq_}_ysο8>?}.lޠkhҿ.y{Ѵ/~nwոg|O_x&^]hWm=yw&5KBմkn>c=w.ϯ7+yN*?mܰ.j7wwgkԪoy/.Ϸ哛vTq]6Mٙcp7ӷnn&,ݶ5u_{E^zּnOl=zẸޓz9m]uըoOnn7oxǎo^ma}s~W86CǡߞF7o!mQD/Sn /@/$=O.ݪI{ڧnӴ= F?77DG@y][qdy͋xmb8}ss77[ovoѢ-7;۷Z.e w?J5/l^p@<(զ-t#{F/|2{kl_yӳoNvu73ۗz\sgݺ'7oN?{&]xFu4j?#UgF=\\}ߤ˵햽/9r_96~2լ|i 3i-vm1Fk?iNT _^b]ǛݏWw[faGrxt~9u/}ך'=:-D ԀmA*uq~Nnn.ߞGW7=}sn64~-ZfݼE <݂Qnn{'=-[=t`K,GOV-oaۼIb'/>/kެ[x{}A!W dz_Vu[&d6 po͛o[pO~<>l+4c]otkW~ϗgWw:y/7@)Z.V&ݸnf~׽ڷ8݇]p&)uL?fVm&ݾ|G{mޤ}&H|C+~;[.w7>o[Gaаj;͝-\ݵ_6q;nqv)l޸Pq V.Uîo7_O][OW߳F^^\nRtvzy7[4rcWM='аPysqw~,w쪍;.uC;mٸv^_\VyCγGڢI{ Hڗ5hҾR_i %$jӻ_/|_Fc$mѤ}۬vkG}I$12ekz-־3t==FM7oQ2|lU|ۯ6V܆y'?Ibo$T  o pv!a!|Ծ{BT—rpឯLJe e2xXe>z% ėxzJۼ'ۚz˿^~xec&ݢߜ *cm8-jD]v{~uNaOՏ-#tWYP_n"_x7_\_lqt{݋m>]5PMz3t6nA59;WMнr%~wnoYe֎=Y4>moDn~y濼[0:n=œ<{zlTyMZdw,lޢ-ֿvm1Fk?iڳ}vmÓ}܋?rnۋp~|܄>$~[?-:]v8絇G<jp ߞCÁ@~_@ixo.eض]tl{v5A&7=6>ør_7Gg޼,n|{w} #ӻ6ۆq'Y#+(W?ܞ_.\pn|us{z`lOxh{|6~e|, Cջ5EU'o՜t72k;̪eǀՋ Ƅ_)_`/?' ,O9#z_ *>;,蛙fm;ww@tӽ &E:@tt:N|r,6q}&i{} /NIÃ:_A8<:z7{m ߛ}=ބ0f|j{֋^|h;l{/Ѷd>ȳ}[ <7?S*v-H4 w撊f~fњpE>lѢ{\xoܞn+.׊_b IG$m> !h܇HxnЍ٪I[82;c6oҿ.l Mifx?Z-_ƍ?@2HKd#/<<9Hf $sdX?xY$ ƻmhF酌ߞݝ^b ܾi7I>)j7Mك-o7wwgkI~q4]5u wW mX߳&vǛˡ}w}w~}Eؼt۞n,OXI m3bxڵyݤ-ݵɋZM<}4 Y ݙ/WSa_wݻnI<~R,mѠ=!uOM7:h{NmÎޙ w8ie{=C ˗C(zE!= ^;4:O/}C04eg7ݼXtK!=ĢXEԓFjҞģӍGiƣus-C0ѧ]ag;M=zҺ.c]ݯ݃>~٣AK 짯bn.p.<ݮp|}۵jO?W{m9j'Hժ}RkIٳX#*$+%ڱmmض7gxh\;]WGu,߸m&.q/8s|߮Z9MOηӻ6~t?W9,7j p*>=8ev{uˎ]\0N;cf uZc,?ya]ڏ]82O|sdw^=V<_"ߤ}]^3{ڴ/<<dm5J]ͺ-Z1i݋mosw5NZv[=Uo{_^b]ǛݏWT-:lƏYfwۋ~Kɑ}2;\m2OZtV,sޱ/oד?ו -/O7"&j6==9}ѳ\NQ_4ܟ&Or_G>j"RR7nD}igWݺWylw#O^l~H^vKXvrӎWWӝ~-)|\SP}i{|A﷧oߤ#>oc ʑ6~.{=z~[ HgToJe:z^):J?\\׿+W\w1op{zufӋn_dr??_;Dۿvu7oՑ?V~qcmX\N[Q^㕰*őΘ@;oq^yE/e."apB,_|OwJ-Bqp,_XA[T8;x0_quoձ1~x?o#6ZG ;zl)j_Q|! rx+\[o]n&p&Np2J'ϰ/4ՎAC~jc_ ΢=>q˴s?8N6h_ L?A֍a}U/`P:43V㼗 0>6Nu܋/&mmYfBgdw ..&`TٸڎXYJ/;e';-+^tGi!uU.zo>:{{x3~qhҽUq+JgѽLgou}]8*] K-{.9JGA%vxwO/|?},߾{771Ç5~u].xg^z{b^:k L_n_};ιq-t)oyM߼ok=M"y q{M\Bs X6Y<`[,ҪqtX7Vz?:-;Ri7\7`[^KU:GEq<atC:IWձsSStⒼԱ3 [UtC ~2EiӃO[SVopIYez k3s_v/+8Ne>{kn2ܷcG|fg\zѿy/s Z|Vo秗 u߾U.xqG'g?\omk㗻qn~c2/ejwauwN^/}~/9#]ZaP8G'3{C\f\c$90"x0y Ⓚja6BdwvXpm a%@m;HA2v^qf]b  *#Wmq&10]zӏZz3K"KFDA"-pڌe/b^OoC \ 5N#5$/yUgFm̍=;$괊ט&0|Cw"bIY=~42JKdX#Z{3^/^"dŁKq8 롱Jk`,N7Aq"xQ kKo#O[bvX#(M&ލ8YԌOD],,ѶW parSƘ# ҡEU JbAR$L9;U[9j{b ȣȦK8#F=+).qyײMqERx*M :fJqA˙ir8Cu81 N%L+J} `nM$M@l>0עOۗՠa!^<xu޼3Հا[~~P*>PJy1pXt D_68w;a0[İhїSp8XBxbqY\Xߛ4A;^{f1qZ?-T SC c?n6;Yli6Dq.߸k,f)Rq3IKxTqG9qOgv!M6ig6z7v>+/Ŷ =Z1 1b"㮐(Ʉ1Iq;o9>V1LiVDoۙW~Rko$t ܷɫv>NN `=qd;?CGgi͌k'7aXi1=3u+ {q4ΐܹDNsqT Hz=4 P{9юɠ]<}W"ѝZlsC<0!+P4S/zc}բЌ@|'rGw T1޹>r Y9%P4Ž&Lxnip:a‘?3+[ giI cs82SZ]zeW˓c_, .Nxېey(ѵo_%'h$5&1kSqJ$ΰbљ WPHȨ2u3L#oMVy7O&ƸT'{[kd aIS7T[(ry2ǭ29@ˈ?d:5LP<~tH8!}x8Yb^gƩCe-tqnV4zkbzK.Wk&qՆl!TPw%iC-֓'w,, ?~PƸnE"yſ!VRStg:0uF.*6`K@tp$=Sal"?C[*!#m 0\e ]N6n.:,Y[M;Ya44l_qa+5;L=͸$ 0ѽ%3 HzkPO}UǶnJճ SO+oj\ÌL\jfV20.3 "Ucoqů,ߋ73nבFz8\&;W ;de55F$>ٰ3:HV7FaμbμW:2~Vtn@@dw2'=xit!} NZJ^\35&2sʯ:P)m=FOpcR`eT!0Cnqߑ3Z:Lfہ%unzNݻLmy7V^OP%Ĥ.q*1Vl7m8I6 6v~|3$nO]t flveVU\G{!h *O-v]->XOϞ}i%-UM[O .%𞙛C94&=3,X3S#{9F2t:S#Wz;S6>'A'19Eҷ j :,1rUAȢ$8+80}nWA=w!``aJB!E#;-4ପ[F-8t4% ZRUDZ":dVKv 2sH9%e^>HP& p&`y;3˛ij %ba\BU΄^Ĕ5bZhK&~hskF`|McZ(3ڰrYCܕǐIehzS x 21<*3Ntd:9*_dϵ%פΧUׯ|qоr^ *JW/«y0^~UA]?rZf=3((s~ޓ}Bz JPĈ!q K¢⪳^>.]xs:SK~3Q^ҼvjzYv(3wj̼7li$9BP. j 1RǐH G e0Dy7pV ,Şoݨq =F\zHi|S#oXkRy,M^Dry}?hqU+1b\'7\'zt,Uo aO:L2 8`}5b:):vhpk$ğLl%᱃ "J.wEwa$$u(%d끱RmztHE~3(=I1xA%}"vy|ę)>gu[xc1) 9p!ckv ؆2'[$hA9oDݻt#vJWiz)[\$m*<Dgvk kxJD}d8NtsԎʳ 2u;TAsa>8 BL7$U07Rt$/Hg$deIM:Y~+;G #қāq;Y֙E,ڸoqj$4!;t5enAnpl0vaocw qt':88)1|6<;ZJ+t_q>L~^3-ۮT\#k]޷ ,ZX O$aLձ{/Mv/O 㞛/􋗮[?zV8bqK+q"c`~NV3G\^R5xDu*(3dz{|NrcDB@' mJ[&H 2+wc ߌI8ͼ8;ȱlyUuiݍ "?2|=b8>j'k{cV?Eζ̈M);~xSZOZQG^еT]ӽg=BqUw2f-[.Lnnuռi*aDm{r̶V.ï&TZ@7G{n1@n'/S1|&戃C0dXBS o.?uT@tѷ g5P'nc6L鈧!87<4~: 9#R4v7`.uH }q>(n8:t@0ć!,ViMIJ>E?5RO :2H+ad$3P8P;!4 ujaFsYnjGB- 5czf: JՎPȞȂ!|{;ĉEpy`(PR2X...T,&9L`x2wuZ#cD!+`_b^0ϻ˓OBqq2upqۂBIڌq9p$z/< FZe\WS8KoZӓnomGRI3`)RJԔD=s'TpN(iS권lAWFJub `!X>QS-K@D>-q+HssJ+AFn˫pۉ(?T8mR rP8 w.(wBΡ|V8 GR[n_ѵ\%;($Ո loS(J8f\Sr$B-=գq+krpW*MllX1$*0QE~wj+0B*Er>h7*DB%9BŰ .VBVjzf^ë8ϴ{܇":{٢ݘ~P^*XrIBK "] 'ؤja<+׍c2n=}'TCq_3/mIxN~-,h!f{ϢuB|H>-FIQ|( j34@o"'uq5BmYAH5Bwa-fxtWjAk§#O$uzYDi^0e53gB'Pv,r)<_ʫq .:FxL1{:^Cg}ƣw/^zm^ũڡD ~a 3J){"keރk(br$岖~˚'`YԘnӉi2z9\}ws)W;;ݯq_:BdxՎC[c!%mG k$O`Y/,5փ>`SLqV 0=C,6Eq1!‚L]JNSZP!F-x;*H.T0.n`JA 8{kT- GJ7qkxd@k;) kk=7e4vƷvх(&m/MH"E̼bc/7QҋMc( //5ErSH.6 ՜$UNt ~TATK ]1o"F6"E3+|-aIH&=4enasfkhnGxh T,RqY?@y\BO^d{Qi/(WZ> x ,SߢNrzUϟ?;;{wS\HM?^+ffՇXrVQp-{ȥ:R8E/qFlg? b7Jb8cGz 8uYI ͒"ıYG?tL VG1:ӮA,VIx tP;OEJ{y*&DxS(IˆxS@x$Cv5՘lK$sV(S\5K:>ZװܶOԩ$N 5БGdq5R }QcM͂shbJ\d,!ˡa62wD\ @f RKVZK4շ ֬[G@Ҧ|';v‡PNz+.<;`E>X{IeQlONL@(,cSHeM'\'މR҉p/vދ i>IM>}X.rmĽ2}OV&ۆ}Xtm'Uiʼn#-2:TR94Tڈ_?RsArcqu[:QKG(.,jt<83:Uq]=O ,:,Rp%~sVhЮ ~kGhxG͈z%Brd?EYm^!<7.ib40RQ\S@5p<>l" lNA=𱃐oo +7FNnLGc]>+FhiF"OrQ;#RLiX%^p@Ykok.٧NI6"K57T(jqp~qfOSxPDj@< UL-F ʞ^]tZTRd3NR Co:u=H=2*: V}`I2PqaLC*K>pqՋc83}zy)302:}-,'[aatӷѬsp@|Fzj3T/[b]J釈vbAW"Ϭ:=bAN&' "81WoM.h'w*I&~RRP0aDY&̮=Wi+߰o/9d"d!WAIO^%h#v)׎ a1"\;m @i-'$MI:*hn/a]!!f9&e?8Wf|'Ό9 wnĉE85$m*B\°?Ã!+&uUO$Ⱦ$ߕ*qm>$480ɸ C  8(1N^.),wy#*3<\,,1SƛNE{c`XU(0I$8nr_Fu$\3`DMf,"XeɊ@ t!IBMf+5'f 6g8#M/7FZǐSq fhl$5\lTqucNy$|t#QKuX2B80XS{'kKYA|#3 #!C@NJ y -EAF ftX ́p|#B*1; 7erw\=TDO^aEZp8*[5w$g5#\Gd|ߧcG2i\Ekp (XTAT%3j VF@Ԩz؉M0ƴ^O30 VG|x?^c%5>սN ҒXA,h9) KǕ{{>0z/vnցSL" 2z:J")=AbH\2]mp,&$$10zX}t֗ȝ,H,90(KI>Kq5$$)S23JDC}^R6#N:9EiB 7فkGwRKS|5Ll Ѽz1/>wrLH,[]2M6Q % =16@?"Oi9!dMTߒQStR,HG(*A5"W# +J-+U.| =B+Qe\?dVov@ `G^H߱{"&U2e<}.vB\2̓-uј15%JiGpBpjOԚsKʂC,WYZQwL< 8J#t>IJ|6R`JT|P"He,Q^0$f^8ǀstMx3"7)k! y 'ȲHЇ?Ѝ铬4qc # Ń%Q'8OsԤr! 8&w  J }VΠpPLA {d r xU8&5T=q%mBRɁ4p&<ф۽O/0NAcuRpYͥ#F1$#a@nN J@8aa~6!-g-؈MD$^ة &T^͍ 27pVP=a~ҁI(vwbI)|8 ƞU3o4N 1^R%78/!~+!upeP _.R{%{X, p!~,,[~[]X`VjO K ;n o0Oę`T 0` iԉ,XDpTQ ARi[Z ;$TruK\Wc rA cDŽjzN+Z'2h+p^@hf4ubˡ14Ƥ8< r =bO!޸؇-f]?_џE%-SI Y'i&2;I<d}Xt6)xADpedP5$o4a?&[_q3¦wFVlgH e'4;Ol1'*灎\< Z\TZu:m;=OcgEŌ!/X)0db'i~1KY-l d_Lg%qecq&ZD\}%NTxH]!'xs]h>)^-E3pdTѴ|% *%w\GYL텮焌5y~$\+ywF)໥3ǔ]k;u?4˛ۋ7onn==i!K2'U9b4+mCگN4N(z&IbGfTʺR`I0 *p^tx-0Fdqq`@I1Z,\9+%N`>a$BkJ0g- ]xU/XI%pPH|FXb2n j˲# ܇,9oBaӸaƣR.dA1O`A.`R7as9j 䜐?葠6ܐ|"I&`@fILBflw!&,5aI/8e;`@ qKr RtUfmA njZɯDлZEh Vsgc1)+ؙx\g ,YPT]v@3šUdq -SAJ}Zz:& ,v!ZYXy>wBxpZ L6&:l`̀O$k0l01?9>/LvQ ^<902(dAlA<8-6(CZEYX!jA$<DmV8:Բ3=K.ڊ.ƒH&%+iEWlNG˛Cf 7$ DҧVj9`ȐfnƉf4AC4a:Ř1 ?xoxvQ#3_ca}jO>HH0Cpg/Y XD Wi2$<,+0¶1yzl1~ G&eh႑$> OI>g :jy4T!tGqXpgqS<%$Wh $?M0 u;f-`lZ`EY%VfmXRqt s6nx['&DngE>Yv cJawLZt-eR%p&7b7\== Vp6|'q{g<.T njhuL oLqq/ {uHKzF9I)0.8^`|KB{*pl`4F#agˉۗw~{-;*Ȅ%Fr1ԓ@l `I<5F3@'0d:UCq ! @MiU%8$$RGEr^˦iq!aVV±9O^"Ool<ޡcI$9TW)uhuAIZУrZd~C!  dϔߞwW:%;Yv3bFzV-l-l$0 dS;IBL&Y8Zѿ Z}{q[A%KVIr\} F3?KtLf2aptτ"r(8R{~N$ ZP%kx(¸"N- 8C  ΄AGO( 6]{>i$`ɰ|Q,$p{u z;Ͼ)Q 1'L!UzSm!o{Z:'2Y.Uwt1ne;ſ#.'RLJ >-?=W䣱R~ $Rm르,& 0"ncY3F4MV@Ad\S%EIO^ ƀV8r!_{Or6ZdtxϨ[)Npg5% SG4J6Gx:Zɲ5>sAnm<j k##Dc!D0vj>7a:F UQ利" FD]}y0OhF0"X:4@V cer%q [D9V&pa TX9=yX6#,~(ZfLLœ-(NKѯ%!U`0cU2 `g=w5`Ijv,jX6֥ F 24#`a\-;*In0%G\{ 1V'6pJ@x:!. i B&rųLD. &!U7ǠAPcM^lH)jQ ~&#> H'#<0|-7偆ɼ&fE`T (S3(qTY@/.n#sa`Px@DAy* >W])'Ep2P1G,D`~[49@}D-l^i@B?oډM<0RE9+Y+˘y[*<Nu "+x`ЕZVS*x@K(f>帕".HшbSC0v&ӢdZ N-_ }!_Z)y™K. 䂱qФF v{7&'UŇM=gWF̲+'+A %i2 9U&7f0 B*0`Wtu bzdGL.E1 M J㠃0Xd̬DOj[Oq##PlC.ULHEv iJbS*Zj\*xr!?" {1pX(_McKȤp v>R$1` bd8/-J2?bMi\ P ru81B݅<:iOL &2.L@ 6ŐSaɖ>(g*ʭ7#>b&^$ h*!\ON.c B9yEgpr6˙aV,~4d + h8C{,vzH L$9h@Y' \\ Okͻ%%Nr%]GZ}K<-ʈ ?jբ >x[rDSYC"( x\Y6P$Z14hv9x-IE9#޿X t4=W'lؑڇpZLjp %V"xErEv=KY/ Xs ⚣0[HV袂(N'R\\ayl:BGn8DX-WD&Shmu2|((FXqzgч( 'xgQאERkI@s xG"(LLx13(Y7g)S4/r#$Lcȇئ:XD^d+j! hKE`NVy'4oH-g{ -O .ހٔIDWPօ`ǂ' %&БtTA*jFߙe.\09>_RuT"1"DAvEђ̶Y9ȎO/SnL0wt* "HG)2\⪄Xy'&PŪCnONS= %GOIF"'E](1d$v:k*ЂL(Tn o߶Y0I a B^@]6HG#+pS Fm8> TA_dܚ9~\8H˅4.09ԵZWa#ctDb0 P&y(Ο] l"}J2|ZBx.Pi<ʇNwI;=c|s tEF^(Af@RZ  Ws7=ŞS55#7t: Hž BȝpL4GMƪ JF~NHq_La"E1{^mI% (x->F!x4'ӡl3kASIn 5Ӵ)EGDHB}HC)sSF\8I[VTVΧ+N敐`wEųd%[DoWX \a4' Dhy(P|L6c}A[TdԘO,LΦDZ93 r#(3OlYEK#-)H+"X:sl r"ؐ63ѹgUb4K%"y-DH_@ @cP3sdGn86JL}b&.ډGZIGiْ^&m,[ =Ojc1;h4N<.!]hBJ[)Xiil3uL2p'O.Ov pJŎye8-*,t=:RBkqR$eo@bLs6{K@ۑހ:hBDY*pI֗< &/Ah0YQ6H8a:='̃z@ s萃`Ix A7T xv|@ 7hS|)LO|ь;a$XmEW$6'=@'>Ԉ2)OV{BNߔn4#@X-j:c] <b !` $K ϲbڠ(U(zFZ50wl+ "cGJDJd{+}GTi4zAp%"Ȅ]0aUIrJjLڃfPsV렓'评GYX' &<2RUoc ?TIpeJ)Ld(UoЬ9Dɡ3ay9<; RF&P]pQFWȾdhfEpd,q!ӁZ*tE@⦂:P*Dllo<[S#)p 1D%-1ПLy͎l$^-KHNp1'N$D͒ W-[oQJ bm1/)0+Y? xX4i~"f̑tu-zŸ "yI|0k".kdSB(ШRP$d]ŏ+! |A>X5p-,P"2—X<*K$Iq5CQPv*r>MU|YY Z+bX@PdQB.n;ƚoʘT ʹD|o^ۀ*]%Ē"Wx>a0¹\mL elQ~1E "j= t]R&(՛CLY۬β3Rxk,fP,x j"0#z(+ѥԕJ`2Em,0fAҧnAÝjֈ@qB%A;F&]b)sF}1bn3B0 6hxz#T(ɩR"k,&Sdg|cJ0$&%lUGKMD }ekʟMw8PO(k!#SW@Gm2HrElۘ clƾ+7e}WY+l }vOoςD K^NVvjD .% {!=zK݀!$ё-:48A*n(6-eߎ ^ 2 LM}0"ITĦW8<Yb@=@3'&%\ ( wXJuE@쉒U-rD9#!;œTZ]$4c[҉@du0- H<<Nd.a151br&T:SYυ endstream endobj 278 0 obj <>stream  .N'ߏk8) e6hR4,Xڬ͜ RpI抔P |4 TB WWN2VK-At}B%HKdVPW3w؅r?!@ RںmAN1 oRG !]RɎ맫qD2҉QrsHU&EH M >K!0sV>CV"tP.eْ$ALd,_Z1bsiIz줈q9JW ffV(څ9  G/"bHCMIDZ7PbaDAMXznU(Ӏ aA@XG ȼb=Hr;4  juI u/,ZMY=|lsE"',@/d $kwTwGSdh!K@^ZHҗCVƶXDWR(L`4>d`s0 ;BCkfm2J x1pFy_"\*Ld磉-WɉIy DB N":.&&<%1G=uds? F2ڨ^Svu\jPFi>JpC`L23L}pѠ„ˤVd.I؉P#il)2#iGRĹf+|$NraFq*'^"dw7`v T*-IYC*"RENbϐ4 GHgkӤ*hD`*k.{Iy"BzNXx߫'`QV@ls 7-05 H:2T,@c@ƄD7s71?wM0dZBƂ7T%%=PόrHy K(t,OU>3!E8O(kp `Yf@|j,LD'J?KȞQ#d@.&cG|d8, aj(N; c9!D?Ij2z4kH/aiʊܜ|_4Oڕ*|w0NO1FMY1уgm,Ze2M3egŔpc3{*Q qlJ|k"&D4$SI#RV@6z>rcQXp0d4jء@$@}UU^%`KfKj_K?}.7ZHX5ԇP]1!8ɺ" ā ^_%n{œ2nT# #P*K\>Kyi z a "A'IeY V,cUb+8=`ꁷ5/-bl -V-|]W6e9dƚ.(NSB1>10[z> vEE8;d ǡ@9Ƒ%KqF :mőB5tITXR_S2(wWk‡Ȅ "0vC}Ӌ|s# :Xu!Oׂ\k> ;H2e؀lpGL! ABp'yB%OTPYg]WP,e rg>FOҍNM2C$XsV#$ב%<1ej*.dpg,`HPShk%Pr+I*y &Jq̶i'<Fqk'>)̧t!mib/3n kƀA؂xcBŐ' 4;)Dt TZOyos6቞uC ?` U7(4 }aj%6AilEV%8߈DUGq$͓\TB5 d>3WbFBc(2M;@28HiK¹) (n T >*'p8rJ"y#VߠHv d(bX/ !sHG&Δq1OTi)15_t bj$p]dɆ#6$Ē-UˤvZFO:R-@a&=K?C!sF${0klfRܑ^LAYpCIa l ĒxC!khb4@[ mu V*_f[9vxº._RkO IgNPKdָQt&&n/۸$_xvr^aR: gOPDDQ>ɶT.* #ǒPS`]3:fnZ1-H>P#@CP#FAzGf3lr'H1#& cV(р'F mZ`qPmacfIIrǒRN@LdDZ9M 'sV$v/Pee8D%Ak[MJ92 9qt"M\ ġF@ lB>X^THH԰ؑZf^sPۗ)1Q|ed㈬[@VcZˑ/L ;*%hRF s #D(HJ)!y줢 +Or382v5SI4"M"E2Aa)P(Zu)BK ?|.@˖,?PTǑ#IT6⺤>0DKʲ"zC>;YE4K*k͉6sU5[ я-٩دPs=dIWkpe,6;6Tu)MCEA & .EM֢I|Bu E|@f ,aFl"a*SjYt` \YR `#?!J##LʤonybO "GFk%X8S687:έk7v4wzs/jIg1hG?P9m_3ցd!u+m4jg53">]n;l5zeFqik{ó~U^=t[Nh6{.'%J>6RϔD N\MC#WrFA^(n5eg@ XҀ ;A9L1%wms "'(-)Ms$t/y8tb¸+<⑬pVV o#sJ1xXWx+ s5$@Xk b¢d[ *D\ |Җr[YQq6z=NBg)Sg2M͐LKbXЅFA VvM(y]+Ioԙ5u wRJ"0I@$43.cXRb[~<[kbق! d'[&<-%iIA,zB'QY Np$uRNQlDO!+i pdY[pI i!Nx+mLp8ze5Hsr`Ⓩg'k1g1GtàBBE+$mbua\^eO0NRDfL.ԏd,mJiG?z֐-|bGY\6Ye2GQF1.E-vAwjjY/-uzG-c75wh渫%w sZx_|?K$n䠹16W j`TK^4,<~nh0]-.ҩSbʏE{A<R2-GQj{ lfe-}lIqL嚒 i|%jdv{l@)h>K%̆\4uM`JZKHlM`OW%bJkAL]pir:0fD1wi?@4D- 55!k8[S: J ǨFD fld+&L/B ˈ8' :YS郐P8c?Zǚ'/Nf#)״%!6&yL"㑟[ P貖Hs`U!!"eb͢W cn4*Jd\Ihr/b,ɫ-|1|Ib눪D!5l &*i`S^aJ $@yLG 8Ay#0#?;ij^*i@ jKW*r1dz:2?r3mI~Crӥ&M@po{-XH\iVtÆ9pZp ](!GԪ 6|AQѹ/M <7y"H/%CqMbK.XhäpelAM/B ѻv=ZFHWvU2jBKc.[O\o^ZujhW%w[^uW>9>mtRO|A=]v_k˵~m`xw忍Yb iͮ+1sf0`+s/~[ZՏKKve]:L* -8 t8H [/ Y:Z稒F:Oih)4B nɆHC[F`!{3Xj 6%w]Rf}7H}EqYC&/6VCd)A 6d#2!('7*)jȩ@G\+$4ҒPQ™XK @KR(9܄\\V"v%PtKf&>TI=#奬,gD 5u{K-hq YwiDQU@-'$%^B?„"IƼJaJ&Z#Fd*?wRD,P,ﲪK&IWl>901[Nr2pn![N&UaL/ G1Q8E> aƨL3H>X%A$/DJdŹ3 )r$ْ=w)lM<掶Xi.ƵA\"7B*\II@&b. El6 FWnujaYm@& IwBO)4j0 F'146j+V=m8ق( dUI1m8d7}K;\oȑ0'B@ ׭« AMm3QɍH R̦4S%l Hx[K& lBXYeJ.;a<.^A$)reIgE ,#2c~0Vz:y[}j$:a9JZh=Uq2 fI  ŠHۄ!mPʎ|)x W%ޓWU(\[j\%MkcO4ϊOS.+xĐ-IM|@. T5Y8aMז'՜M)IS!I]"gz_U/fűGeEbӊ ^Ju枈}2H!Zj%GY%TD.Ȃ}&9GYi[K $JE#aBdF[3It1LmJf70!mxYISU @X~2iǬW;0'b$yѴp}2ej.:c]As fdH\;ӯ6dg/fU?1bIZ}dG3>\DiTb@ƾL()8E Ip>Ckp33Tscwortl@ICHg7󳥌 +9ѥpץ&KRO.vz ~n$9fr'ʡHIXsd^-X[vX]$:km%]OGgg!$CJK0-$euY+ma- ׬MaA$s*BUhW]ha)}[NnJH.^t8gkI)ZXRL!RJ?rNa`5лF'9mu] @WgP RIHC+:bufgЫ4b2[~[~ ?le2(',JPaH/Ѫ2@>Fo[+PR2YTIjĨǂs\ <"M۱ ֱtߨ:-e01O&Q@\&K0B_2MaW -Αvϖ|<9=-&#\^:˅Ba« +YIW/.@0ި:-ג9Fg̒=~._"hS?n$'0 f>E j5hC(hA BV?G*uA!@64j$ɔD"9$ 2d2 ZB d',ɖ5;Q(V!'.'p$3)aJIh&Tm 팘5$)v7W"_`-*613 k~1 ;eGzÃ5UGj*R%͘0_BLQ9u~ :,bz(~hV!=d!VJYF˯D*daIUg( rU&"3s\HJ^1Kc>IX&ͩ^,"J*Yc-b0q FpGZnO\xL2Sb-X'f`/͜G dkOS" {k! I|J1NK!]O kNXˇ GO JF4qBkD@/wy&spPi'`O.oNuKױ/:*%Z^~_%w>_;<ՖT[O!w_;{eo_*%w &}ÔELN` n6A C\\5Qpm | #fo# :d3aDy: |\EF%Oj{gO2d4Z:l6\L(9ˈQ.}:F02br6c4ϲKQ.w_Jmg/o̞s*6g%M9leX2Wzx88FG9-2J0`xȑ:WJxޥ͕bJ;f_> +/[:2GVcs F?yQ}pU-7 » 8Q9%S7xn~ihVTwl!5XVy(;%+%j架;icxn^Z\Z*f2KiKE Ȝ|nk7q>H>&K^FOvѥKsn|K gso6se:ō;j~z";Fct}5)P-7N bL ^èJfmuxC#6̐ASJ]"wjZiUY7rwڪ}>_)w_ji3v9~يB;ya<9me\?/7k, ?/j7X4˾9Н7CW>pTı7vSa7v?;s:vI;*..n`6B|u=zW YvFB1p>kB/yMc(ҳ.ĺ+4BOԲT/ Qь)JE $KRz*yA]NqO#h/KPTLcʃsi`2aBӈ?8.)p]2x?c֬<7A)UOM:mcu<<^h7fJkaXUDa9VD")GҔ#=ISaʑpD S2L9 4HzASίqFS_Xw=:ؙ:v~SaؙSη%bc['^AS?~gיu; "dz,:X7O;? yM;7M;teZaI p,~ U_ѿؾRN}zy^zyG1u| ؔtuSϷS)cw;V-#Soq)_з;W3Թ3uLφsgzFL;ufw g^AS)盺wcwT 8߲lsgwNM.߆>eO_ץ/Kn ahoo9PaDn8tف7eaԥ3+~AYsfVl_eO繱Ygv=__,O,k4k{mu8̆>QQ}>Jg AFV{tKj~1C/)~oΉGy̺LRgw*e_Q"82?c,1,'[Z_5Ȫ(Un<alTϵ)Eia~=[mu]mHyدkS/R3_٬>~ xkW~U ~յ{o~nwʮcB9:2針e}&ƚ1#JDo&hbPI[Zw\mR٘Q?v7ChE>;vN^qzZlg|l<]t7${;#ON/ʮ77֝}~0d-{GhwV73*?7`ˍn1n]TglTtۍniK0d{N3lə|0`S_?KG ϸ}PϑK_#rsZJc3CpI 91Zg݂jZS9_7?^x0EuNkhn=хF_l9toBwvKi+k#zF'W_7Ҥ_صxw_|4>׻f\l0f}Oμ0Qhfڛ^^/fQh6n]h>yZh.)ijm**VOovoKFn4=#y֖J, ,Lӳ_; Uzj5?;"]_ZkB#qc[vf/b7~i|ń S;mw9^p,5Y?[X#Ӂ-Ptd:_7Oc`aq0O5axž(.d*ZS?fvBVp3΂_jk 'UΎ;?l!~|7:+{s_ů%ͿT\͜"8;{Agur{{~2?A~vtVqno L o`K{fZ:K80n2(?v7K40d 3hpCaI4P¤u᧡+ [7JNIoVj O_sF9xŹauUa"=6ڽ$-?w&mXo }݇@1â"R$bf(6Ft{c ppDp 2`O缗q{罌3:e졞9O/ms4@.dgȰJN?_rn9ts4 ;@>@p,yRۧ|joufyN;x9ls/6LFm7?uZ֏)fV1184Rv`F.m۪ssG#?uk'ÆdB:J˟23@ƼNr~vbr_3sIDپlxl9P<_Im:642Xk6\QmWϔt?\ov?ֺyc'k3;#/OG+᧪- g?4%]|/lۓY3s/~O=~R&fm՘hgLB~ Y?;'߁Kn*P'_bl¤smH^Yyx9 4uG'"FQp;}>}'dn^NiҞ?л5ਘ咮%sl7Z4D8|wHPBnvκ߇&ab'~Mr `<'hz5g'F:x DmRQNoz7ETC8Ezǃ[wFp;c9~(փ\y\;Z&m+i ^䬕sdk]j^/+ >LQXm%j4s0yjsZ6jug2wM_eSK`;ɿq7:dvg)]7/٥ؓY4k$Y]v~9}f sf Yw~C~ܚM9wJ saCaV[6gSj ݝӚO}qPr}t؅O(m5/7f4`GɅZv(wN_>xwR]<6Gyylx_]k-HB|-؁xt&@d[Zkqze_[ӁMԗ?lRiHnfl{fqYvQzn#ƳwAw>6p'WxErzyVâ(5>6Z5\]nG+ɭC5VԬUx4kyw0S^ Ro}yR! utg4on+'{njr;NK6wYL'0ruSe۹yR}l0RQ\ FJj o$+u+y25JWrЎ3ÝGIɧLO0g&x/ۧYя6/ADűSֽyQV4,7>F ߌAF=>8{2j؋¢8['v@;%a9؍.qU4fHz7vnMQRd2=<91^ԩn1>hg[ů3SϨɻ֛m唴a|v㰖NU7 ::aa3бwT3i^ cәn==Gu9fG޹Go g9b`kY>`Z:^FMv̫ƞ9_z?A~iTa6©3RSWj~Apz{Im,B_T;xF^| VžeN*e1F¦tv.qj(qRцwh=%97UNr|}$Y&A>¨fP&GmӘr.TW7ܫu{ceA=v(ΤEe = |9wq'^HgrN7-i+ hDjt:D Zȵ#jtG^AHS4-gZ?4Ν!r{{z>=qחWws_-Qs}iwGI#f{?z{ ̸g#&Vom\)%'VͰ+:㥋v褎Q:x:3vjֿl[&29} ζdi.P$A1jڧFz8(}!aceqr-(?v` =F;^[k4S|ƬaLXcMYK|X'7 8Zh;(&lYTq=go=ҋ8?s+]a?(mrBܸZ;w3ԣ\l^@n4 >fޯu?S++ŧEY~"- eƖYrE̘D/ j+|l =)kiV^F/=%S;ta8gޝc6ÅW7*kyо.>2Ua/XTRov vbnAOg{olkV:2T뚝y(d?dUFԫ}RKy ^,QA) k[-StjDzɜK |]ƒKA`nQo6J'jfՏ0eݲfZi[doJgcN,ś͔PlB>[ sdA&r)sd2ƞ؅Xq/1ܛy9=M{46VMdOdYzG tJ/O~9-7yc˽/}ڛ;O/K݇'GwW^3sڬ~^=Z ]|3ݵ7-v,f{U-|x}q[{O9+ݤדᣗkfp<;{pYk;굻ۇ>,yow*'^f륿Z7^,XOF0>_{_٪n|~Xۛ=|\>:\j-=X:W·vaF\^kto^~R9fj[f5sR_T{kKy{zjqvGzּrv$ZmOZ(wo?m^/psqɑkoX7Nn/< O/4pwVvz޶F#|ZwչR+\?wYX}|;ju~}﷯Wܺ.xlc/ ʺ@,gs{W~cޅ]c{q={WJꝕ?nΎج7W8F~t~/1ғt!3 _YZt) +[^>{Jv`{wWg-v߮Y:Kx nQk(h;͕}NoW_WcVvKޛ'+" uR'cp6dV1fV[ĿkՔ:[*[Ogó95sS|7+hۂL{Y:sf(xgݻvXt$2#t=>\Ŗ}:R@jʋ?VDtoCI:;<\j K. <ƌby{6upugxǷs296{̀nݹyw=q'o$O=|odkzmIe'sͅUGAeYpq{qmu%z}T^_6ⵥk]QA󖷸7?za^s?"Yػ3S}7)^$]نKU^ɟ]h[[UWC=]A{菖YK<<,c1yvp9&ocnTM1,Ly1,aУʟɤᖛ E+_A6lοL'aS>^m/xc | 󘁏!aE8UY{e?yA?Q ogAf?u\ t^K+[/+WZwJfd!{kt~1j^yL: oN=Sͮ_|lM/;c2*#|:TRշY\Nwnc gKs(iȗK-F~{8ʍ o'H5 g8Ճ0+Otlm?m.ykwʹ3ANc9{yn⼵~GV=c4A3* v(5T34OF'\s+ZqB+Ji>D'VRs-?}n|U?>Y67Skku1v73Lߕեuj6uƘΊ@J?Gݠ?K:W׎^4K(?MvUĴՇ僗͹;R]eu~{7Z:T2[KBˉ!bu׻_ɵC޲ʹ3 7;]9STl}nw V8|X@ҽQ^ܨީ5/UrlVjf-C#~T* kuFrq,l?j̃mj|xZ5{ÝX+< 啛l]i+Ƀ||C#W+iHEqy+\í+?kI,{w~{'K/|J0s4ȴG3~1=z+9~?8[cpe_tpSY=?98oi׭d66v N;r=y6΍ zzk~uܾ-'KkGR|ٍgVjD˧wso)ј;j'{Y:yqZAy-8sߚܾ䎰laA_;ZŻoV-&~?4N{wvGy<׮֌1u /7N_QyW,?z[peNZ[p<;w+-J׎t {sϷ+[k؏qyxf1n|׷mrZO sfݬ2jWGsOu}bſ*h =m˫3콵NvŠZ'ցY4ow_,Οi?~5^vnۋ]=myيX6r^jEW#˹ws-~ZKn,߼3gYeckrWm.<-]V oVWjc7f}?^1ruɃվ*!gr{?r|x=%y+A߯b[ٛ{{ZTel7Ww^4oxt[Oҭn*[[ׯ̃}ԩkcMDr9j;|gO݌2U sļun 5R7;[f촭Nj"Lj[Gܣt|dգލU]ff1Ǐd߀b8H UcA{ܵ+Wo+OwS>_?"fl3F8>̤ΟG~n˦wZ>n5kvVZCX4X}^{9᜗`fne䫾1\Z\;Z׆DQkOGNwl}gu[rmW(vr4A{kVGZ6y_y|R?lsکgᢻ6jG2JyZ1h^yǠp27Vv,Z}Lyˏ`vpyl-97FþdJŭi5nrF 1,6x,\~P'u`싣{{?YuN`#4[Y7ٟvd]/Pb-H }~?o/8yoUcek͹b ߯﹊Y5=?{*g!q%VFwӍ#]gw)vdqe}&=+PqZ[ _Dc=:}p/Y _$o o1/K')^6ݧWk[ǵfg_畿gz~W<=wdGfR(NU;+y>ks_JV|?q]0& u-۫ 7rO5 g1ssCv__Wі+Qcs-5ّHK J' _vslF>5|GsӭݻI證^WlC2ǯ)QX8*{^̓>?ej1Og/ݠez:,Z!ZKP_/'/W#~ȷ2`bm~`Y/T ]zG/qN#_ 棨# Δ6֚x;JJ棕MPê2סmxg,f@Vz * Cj]h}.aGT*i7/ .B>ZXԑL2܊S6Okf,H<ߕXrc)O_P]MX0Uj0OzjvvaÞv=#3 `,W!* @1*3>Bv7#\!VnmA|qO7E>Q{b<{E/B5:2ٖ>؝qy|h0JrL"Μ+.7-=~V_tk\P䦕 BI^~/-:i(fX^oq暒-NSk|/N@~QZx!Zbmi)U Mu\] ZNiĺ;%.O(#AZrM|͌| v[sEmpFƪ;u7nk_Ip6 #q;MNƭ]>CӉ^-L2臓n)k^,(apɘ˺yTak M gCb"LaThRloFP+oQ%:|e'(hcn%FLg6Y7}kΏև~eQPvƳn0~CP%ɎX?fMaO!!H $GS yV~UO XAÉ!_3U1竮qE*ӑEū@+m'ڶ;v'yz:Ly@]"nFdMdM WA@ֆQ#ȱ vw۷"U3NmfJ~|H>37cZnڠڹn* p6 p>śYl eM~L)8"ż}zIun9P[:8N`00-K+Ċ;S`#1<9Y~-u?ZPowx˳hךnзpCowG?2NH{N,no’esB$nmp1S=eXڳٍS/JGVhasCϺޝӦcɮ<~gCA{ZVEP9ǾQWpVam݄uk5[4!,62ؼgb-=-^] õB,zRuet4K ~dc5 MM|Nm cq->UFT{,xa/f$dV`sV ̉▅?y ъ!ȷv֨R'gN&ӮdfW_Q,}hTj\roX YgAHR7D;I.2ܟ$@U$WkG}9'cw?Z浃,\=c{=`oQIKw]שIRVZ6ƊjTG~tF^U 5uZzHMO7A nSo#7ag_.~_ּ4.l59iܝ-{2_D ]m$S۳9f|H[sBY7v^RX;/OMԋw\J\[Z٥^.apVٲ|Qƺ]C4SIS}Y߬\lHl_39N,Fjp^a4eaST(]{ i$~*~E'(ʵK<6s72mbZ ϸ:BK7=7˜M7+ɲBӡmky} £; <8\/U`R\T;uȐV{4ko:=nre͚{Wa^& ËVOYeґ i!mRR=Zߖz WzARoCƶE^Mq+,mHTp/ ظS^xO ulvq2]ڛ2baDjQQQq|Lox~d/i+ ŝyx)jõ;^Ga$#8mZc?͙(nCѾ^q[?_O,O<_ؼAoWy_~uIhWcBd,O@^ (VeRsr[o 8]USwûx?\ϛ~?u~[% װ#ԩ)}tg>׍R]߹ JGI1V& 39\LT^XlFR4,Q;~zlmɃ'YWC~l'6|Si > ж8[=-eeols>[o|><7SE.mNOs<ыZtl{jPlz^}ߏ][T8g",3;6ܳ Pdr(|GHN6г9 &87UKɆGn5]L9wrt=nN[*\[YJK'x|W&Ƽ 01&mo)s8lb?Z^J˹'u^bl+JD)_yMuU.N)GpHK"&u} ZMv#Aw LhSk:d6!%[W TX4~ ۪>h6S&|>cq,; bcntjͅe=~TY5\8E".li@YtG7NzbU֡jSFt^uQd KW$70>/uܷ'6__XGC!xyފˆ!ɷ`ꪘ~ 8lyX[5mʬ}SB[jw! .xx)AE$W`^(U'PP 5?(ի*wfLQ]r^Ď$Օ2i[i 0W۵G<ˡc{a.&0.P )[Kf1J$?FbH[Of8LZJSӌīy[c*?OK#(yuzZ-{BeǘػQW`⑑[c:G@eo XZ[6hlwʐЕ77>=i+VJ^1U[L0=%p0=iϡ&ٿ_j(o/VߡTF0Hz0ϰDTfAkd)ĮMM՝jI!/̹K5]?eTFvg^;c`mn֝ShX8}} h7mģjGS^aSYgn$K*fҊnj|\Wm=; Ԋf>V`CǼ~ u%+!sU+=msC+gGw yPW{ODQ.v*J;xYݟ`J/⪻G$OxҌoe/K]Y]XY+/be[: .k#d"I8_TcqpkY6^.A9 Ǝ7帟P_{+D>̤ #-P#Z@|&> B촘ejz@ ͫ e:%wӚ3T\иUw>0Sixz$A4+K?^MQmĞf]D0.)XvL)LE=6E[%occ0LB+[^<#u{k|i p,}: FM{D,9Qگ\ |XzItV+6 x?w0!+rnvuw5ð-~JX\ ~hy?<6_]8hH)U_%slqT@Ԝ@-†^w_;pg SdPa)(T!d |= yGN\݊_ߵG+'e ϧY30Je[C_i߱>ƒ̍ďЪ54YVB4<̅V<:-'p5g6ڻ vIOFϴ_N(1Og!)3$Rg3(4"+&S1ܶ&6(V>ɚ*[|e=G1<oI-@;/nڽTW-lޜ8Cl3$N[Z="*8C*՘[>[(il\:*jH^#+Qi㕱hq= -ת&ff|*vCpbcrgnEP0KwR'BTaLmvm,§sby`2WCV]ufʯp*7EVHhDs~(wE,ULţSB*tu_}P( Ihǧ-H̠*M@,~C"j'"AI;,ϖ 2)&@V[Pi; llK1\$2]l\vP; e|2$_J@]^~[ZKy]KջGj-_ ]&}xP'Nj ; gJ (\Y"‰AT9u^aw#?ү)d٠ HQ+씃SQ%1!t710ۧT_ ыO0ȍo3!VE-y3C) Mueb\Q)m,5q^3G1%1D{`IbsNʼnHǛ eҿnJg"+DI2ؽ@*c2 h_RR{5d.Dq0rK#*\l Nàޮm$y<ڦZ _y#B~gw\oZr$~dJtɄOP7Nup1ﭾ@GY7a/ݍJfCHԧ8sBĉ .h`wYFȭ_ƙI({!C)մ`&XW6?N3m2;u[rw-}VZ`%I)j;t5g8Hjlލ;$3WPYRHkR  w=wk >|m1ܳ{ru x*!3B9b,m㶷U:p (]1R kyFOGw/=ZPP0ڄV4$c`霮SIրgGM݈Y36ޟ޺=;ZڸReȞPiy A a<@F^i Y:F֍E&-YڶlXٺ}QKY,1}Y@/J%^}zks~yz-ySRw'+R`A7_C v̤6X0\&MFChR$ [8JOVS+X:$SU1C ܚA$VdcF!2Q5ѣOŕE}wdbCch 7M⪪ZzUsv̿z\ܙ:] oyAjJ[wOqiy4= @xfefαe-bϼ`nH<#yCɗS\a4C*1/r^nw8\k?" ;T#|G6-lQ:ft"~72ۯsr h~тc|},Ic.,߸G[4;/x<|y-6^P%Z}(ս-í.ݔֿmvy2i~Cd߁\1;˲J|FBE힥2뽹ēPP矓FiztHЗ$}csSJZ& 0LK{0% 9Ju:lJKv-Tjf&5BՕT=L\>fs.m$JTO Czvz/res2n!eWtָE{cTTԳb"@U`"qô97%O 71&J{U.ڣv~^H0L<ijbƤܻjBg뻂vC.!0/,v/WWJz@`{ ɽTWȓ.w#<;G'{-ߝEywQ+Q;5ial[="q9pc870sIlNJ6>ȋʏDNM\2IK=唻N,Jr0|7rW;3GQ%F=I +tN7`ϷVPPL]*E4hRFșy~m)cVMC^e>*:gCisx.[AM4W<*b"V u7 P>9JoC^ּ>X5 Y[.r&^]U@ ĚW&pu wmR ;-ztP P_᾵̵g_FIz_[~j!G`Џޑ- E%zF+ΜYlk „/0@oxCؚݘ0-XaljM w8sީdAu QaW)[cqޭF琋u!MֽKxJQZB 1wGG*KQ5!(}Zj&@lB[%BS;VDRc<|Iiw:3]4Q5 tQJΥԲ ؛ GT6aZbj[e5շ( #gQt6RXB|񔓫MȋzZ̅K&Q50aujl+fcZ]J#a<{hojꍺq`7۷n)/Q-_0mVs ' \~D(Gw6rʩ]:tL׃r׉Xh*T;sAjYM:{z> J+nK@z`G0afMu6= TDŽ_Au30[cUekV^aOѠ,2$X_6h2_nF] 1;o2<`oWO=Z)i[ؿ f \јG>' (?%$±vtxf: +;F8P߶U)sKGְ||"-Xo]n (KL, Z]d͆xMUbXaęYS{aA?U2ӯ8{3ײN PhliC21ZǦi_\ŹBdWdw_ܮL\Oq>4uL"H& 0C5#!N2Vx1җ;G}ë bYOTVUAOH5B['B̈a/ W JWΩ9F g`WzRHdk-UF35Oޘ8_SGT?~1jcQ] >T:a9|C S|r~1­C7{y$قn., ag`륶yA#؞ǖX=MBܽOmr3/;w/yg [r䅉o Qo<y`ȭ|$_XC  h"2ΐ7ɢ 20 g<ʣ4C:Hft6lLRpzϚ|z-A>̂1a]NMsSwwfe6731UN IvU{,zEsjeF$J1>̟mp0%P ޓF/>WkBai!6=#փXu4f-hK3\m'oˇX6Cݑk6-XXppw]1p$[fU)«Kd9JR+'Pi3[_,[e7|kIIm.qB>tBΑDHsxx} >"£ܹmnE+JTo qB?W+d(V)dゲ WWnaju|뮏էҦiualS?G0\$:dHʇ5eІK=Eɗ;ɪryT+=.-YM⒙Nj'8kO9P xۑr;(C'WdfZ^/Ǖ.,Ij Ϝ 7S!q;-ѦP11=L7p!1Һ^s?KS6֥Iv¤?תo + ȐRp$DXb=)k*&1$@k J#<cTM `pPt>t2Az0BwL_K͹څ>*-sBqLͼs@ )}iFJZ֨TK7)mc﫵sr0ک}+Dww--Z>Ѐ;u:7a:+ÏW5]3rPTir/!I;4ү߯,[w&s$ <폂l T_~v5R}207ݤ~1ݟ8>gkF qtTy&S GDw5hmOuu=9k뛄\T"쪽ÛgM/a}pS<0Zv'9ENL}H L~ekW)ޘ!{k7<0M֎iմ匓\$ȤlxZ'T2P #'Βs-E."5k,6#-./R‚I3Ysp]1y25Cy}Gt-C:ps!fH+g7>{a!j_΍-8dÌ,Vdb;T~tA'|ѡʘm}vG-4ό\ h+xM [X#Jaa1 z! E '5ƞlªF1Rt\߰ÅN%9.v7z>|.OEdK31PrMy^2IXy+ɵ]VF:+EDݓPqg r__69GBZb=6z+ ^:yj^bkK/ͣ<, 'r `#rK=J<^Y~1_J4=uԃ~S8,.E~3L7r8.ӺJ ήU՝8w5lwRr,r!bRgRb i0|Fm0G{[}8uP/hCy&=3n*}v ѡchy ɫWxf|]ƿ}t[.%Vy-/}]PblٌN1,SpJ= 9դ&#^sIum;+LCdoSM+k\t1n4k7Sf4"$`Dp@C?Abko'wZU'qV)HYEL$E: EN0+,T2}QUt~焐z7.nꥢw< _n^bN֫_.UivxY = k[v$&J5 nJ1 gI|6ͤ%(M&uaYޚX<膁%q%[ k=|؋_$drjj(F*5\xʚjo˙v{k(|z,:r`ׅ\~舽8L7^GWZ=n,[fx;dOmP/7"\ ױvVLtߴF9[GVi!Ow?Lx'3XTjwض겋EH Ӗg7H093M]ȳStnCr!:$b wE*ڣOOx%,-#4J]`_2yîAX}y#mZV 1ָ#6njY89ȑىqH|Z cu&σʀ|syT:~cCRCufV-56gnAVg(O:2L{fcؼk#\-}7 \-üJRfػA-K3kUM m[@D \N=K95.LT47^2MgpLxoU}3~Zmf}ڂGm:9užܲ&9ۚgp}̮jmXHq2oV~$%"L7LE R`1UbTglVyJ\-q{[xh2gqǰ n,Zãl@\Q"[.-sօ\s'_)ZIfSIM&|\s9 {RY]qYz).* cK=؂|&r >Y"Ϻ9'>mR;J?׿`XCHV@/{L[Vuƺ(`p8(Z,hN۸xux+^m",}an-{B־>82\3ebz~B#"Cv'ƿZpJ[6iC@Wń-1m8y$V5&ɪ$l W>~;_JJE#< [KY0VFWگ`6E0 8,/bAuZ޻Yvaʼał6Y>G. ߎǽexIRew=J?{7Uvo>M0Grd&oEzɇyS=g7eAuȯ-45(6 ze&GMsz^h(oİyd=tJ_ )a5,Kx5|^#,Wʱ@@ɻxBW{w.> L>V"s3zaiAU zn[cm=BZ{䰷r,vٺѨo!mnXvwtJobd+豯(% dçhxԚK޴ƅX!|lFM#mo$sqfSAT\Qg8P_Saaund NQ+WJq'$aw6 uiDO #/^&WŎVX|"Ux74')I&zP1Lbb_o^3bF1)z}k:!9kmߵ' jX/%S¤x8+h-Ω}?$>= 'NxګC#Sc̹Y؝=j̐Ut?Q ]>|oCr#ڢzxAƤwt&w9 ظ,I[}*NccVFEOb7jdٜ"ߛ4r\7JOj|__z+')򵕃 ;/(/b}f^nf*A* chJ%?カ#h#]K-,MZ ྐǬE'_k#f^.b"u+*N[hFBϢcc5U, '(ڡm D_<7+k&Fӊ.UxkN?Nū]/uK%y)c%k(4-O= wEɶwC18D]I(9M:bpl4~]Pc_sAPTDv!"\F=BۻL:;O8b7I /a"#f I*(v*R+Tm-2 ?6(cMhtT`rQ<~ϨRxJ_lֲf3#nOw  pBj_;{U;Ufܶ^QD.:jk`;.ʉeTYZf:-<UDCu[IKnljngS0.>By6Isa%+O'Zb1MPg$b*kU\EQ^L}jYi"Ŏ:{)f,0TK%$%e덳(y%DGgJ#PHzT íVzM2ѯ҅?I%1%t#H{N!db2Rˢ>U=^!v&ƍR5ImJ5z?m>oJ5ρ,㾓7=XɜsBsQQoxl "?H_ {_QܨLAf}/{PӐ^-=ݫ/zPi"ެ}[Dr0O" &;h>mxv\[ww3Xb˦+0Zw[ uM^NQz(=X*hb(j{jQl<8IM1&]MHj(L*Uk9:e. Sm7RŌhB)9g յ١!)o0>GCi)S?q'\ꚪɕ{ycpj3pµҨRld;UaR!]7깗 P"Drvᩢlݳ7 .gʹ' <^jW*+`p)tȑJŴG*C$;Tݕ3_+u7N3ajZ7ON7J$+(_KR^*L[t9b4V*ӆgqrz3dxT H]p7q;x\d8h$K®:gS7>06zp"3&eeML?}ؾHc6ߥFoR. Y+y>7P雥`B8S@ahKmQ=, 3gJ3=Ynwv-t}ޚ?loE3utYL Sy'ݿwۿ+G󴆗 M1κ%7_S.3H[NZ.Ʈf;~z%˷=\k u<BmFz#er+jˀ^Wǂ5nɚ^8yv(#7hglo։8^c.W^‚ղj=Z5fN"! ,;mXnx ',yl?JvK+NϽF?v~s_& ͋7m/ '֩߰T6;9?t\x?z;/&^A$bz_cl[c~> ]?_[#whEc\ sALj;=fA7#{EKhw'FHmg~M7{l :> k3IhUN#4$Cc.=[@UEV*5P/fԯ;ߒV(v?xLqZkf4շ$ӰiICt.<܄èr7;vole57u ܬ_Ϡx/2V/tgxZr#`? MBN=tV^x2хJ˷yn*į$㾻~396u<:֭}x6({BdT|X;yz08^5_T(}B,=w-\Ns1%hLЪrdr$sɏ'.7c')6#uHI,#lgM ee.eP&ҙ|]Nþahk3~z6uy^/?fo\}_z5/vIMi4;rJ8u 7r3zHW*U_b%Uܦr3K?׫\`5[r˸7f<Ўۍ!4Fy67d:: Bu׵~3.5qwntU#84y)1*O*Kf ^@H\Bfa߫t&dq-W}Q iJAeR%Q{~#Js-*\<`d\8\J*Լ @`FpjOEo Z@Y=WQn c{ Ĺ;[яwt TN[ 4IrWLޟ26((wrT7 e`]X* 3~PQMٍ|#vȴ t%@AT%tuNrEb44?K5|M [x;<|V!ȰN=)@2^@z G2uu8UVGqҦ"j[~U\W}?9,;[am+.v-CU,$Z6{b-N>CkeMh${' Ȼ5܊;"QÏݹA5Z[H8;~%tF<tkIX*8dQJXW6~yņOan݊A5`@óIBm!6x;f+7Gִ;5sKd@uVei98{s0~S)_hzmNƀ.< ɀ4&lkBr1本b672fQm+JAI-.䮛KGwIy{\ӱ;h߈)۪t_e}vf߬Q%=m^Ä[oLeڷP ^ G^9fy7t\TAE)=B U5ٞTq~ӯrcbeQ|'Xi+pl͐J2Dr:}r?Rjr:.W?O R[GyϯmlA뻤Vf^~F\ ]5Dy0+@k sV\T6为oJfLn嵪+h$_#/zmwP>уK灬$qu bKst󾂙]\S :&\3L \ͫKHǏ(@+CQk +~ž`"jۤ,ng"__=_夭L 4(?ԕ۱1_ Ɵ+c/cD9"lLNXQѰTq}.맏퐞Qo] 5jYE~龙 (2}QcCu~ÄSvt7Zg[$_j-2Vư|S+Ԅ|UP75('_ LR]TGH@\ FZ<7q!=l_o2r7;*_+#UM],O"H\ |[nU̻ FnW06JSe~nUẗ́\r~t`Ĕ[@ڒxT%܂6_x:oT@ހoh0C9OaO}3:i1I~4`CcCitݒwg{ 8rVW1)Upz4r"kaE|<.ax\(bv)46`[l[OPx[5 wtgt2J{Hg8`SlM B n)ߵ}:@k0`O>JΗoXO߻z ܹ~p\d/FUI t,r,nCm]"pMyT=Vi8P>ʖ⎻2Nm-c<@%ÇV 7@\ u t%Y691%x/ܣQ0}Ņk6*YU˺-RLY]207XE>?$\r(ٱw*=b~;)D%UTjVn+ hj< sC`h˱%ۡ;𕙇oE3, GYmtQ)d1ՀN$PՑ+a멑YZ&yTqZ2*UUI1?<6){uS5U2}XPder-=]l)_,L]-CF4߬x{ kf@C=I _wZGU߭XW'z"R?\7/.Gn{~!fmRϹ=g^G:~^ 3t0tz\~K.4Oe.G1$kYAF+y,a^}٪vWX l|hioXbWMSXS=24#.7ڑ{M3]㒾47LyOֱ993*u .D)T#נ7kfojx xe|6AYwq:GE(QGz:ҝu^(l8wL 2712=\s?ģgpEAwyUbz"96$4e1vtܒ}-4[ے/Y:( DK]&w2G5ˋ[˒t4'_cVPa**ߣyTTtg6 uPk!p~Du uqr?i;DFG`:7֝ +O_B{R|cx ^ը!I)K=i SqAdnr = FWc<o\Tn];HwpH9sAߏ;{~u/hL o<"*-mjJ4g} t*ĤZAӖbszւ}7q&6M?#9S'aV#YLp]#TYm[mn?meo8_q{ ^#+m!2NxXz-ae>xxkot'V|m{`|e4u 3别lw4rD'F^lw(4ֶ }mI> 1 Eǔ{^j~: {NV>]0Uu[h4LGGtX4_i xzδzlWYoBLD#OmlDUl's`fyFxGvv,+Ň'F8}&mp-{s ?FHE=׍>1}G:q{JTV=˫8VZͨ޶G 矸=^x˧_@Ѳ]'$ipu5_&b-ȯ4ZETi8֒[{dʾ[̤_s;QBq0 =T,m,iwn>w.!QA2qWxj ɀUS=b^ߢxzs+yڝcR,JRDcSktZ @.@׽"OFrJDJnD=.KZEIG}ca#caV::pEbwz?@=ϥ֍ՎUXYGOiHb`w"M!bG9\jK43GsK^Ü7Y_=Q 0Cģؒ~HsD_5۷,V[Tdrz| Fn؍~9ul 35S okWWc9KO ͞=/QxA;%P mO^k@[f.33 ɡ,eS'0B7 ʛ5Ax]&}<J:~@/)UqvE,W4\ ZbO3OhըTiMg }܄bAn_fOL}ݥ}4olhRWn :Ř0а]sh x԰UD+"(F8Rڒ\=]NWݽcGzKEuiQL vlI:׈C0{p+xڤDP[\\Iػ\h.i<P E+'wr)wA+jgǢڊ[`NtfY!2Unb[4nsHURԠOݢS8v9 !gnyK.f`:]oRʴV(g;,pH[Q['m:L\"G\=p( knS_ ::Ge7}U{Fzbi\̂,d|sBp䮺/Khi VUX"6R켴pO`iYɫ߮My??E-gƎS]te_%g}AA``rVˆti =\,+: T,M2_)l*kUtH`ۇ.-JuSO AE,g_-v=EJS(Z"+H aX>5{+Ez15VNER$l zZGS|O%,b.U/NOO§M6`~X D}imTwVAݝ^Jk9k'wyW.,v?7AǽJPx {Lh @U=GL 蠟ι%_kS]ܚnMb@7ZqIL%ʲX63(_KZfۙo}nU8z=fK)}öD*^RU)>Q&EUѶa'ǐfyCdHʼ# uiq&iEsS?qChEuV3i{z`Zn-nOdyɏ7Đ31ˑt~[ӌqvSDO%U늁nf"w&xe˵Oŝgʟ]Cב J)uCg3Uv{.9P/J kWLX*oC(HP.WJ$=$SVSDJ'yQP2m\g}lc@5̐][~Sv*Fg1x~ ޖrϫ=*0i}%ӟ/($da"fr:3~ʋG͌;N%\S_4CuQb^P EͣgYVо֟a1p [KЦK6ʌ]4MXsOUS}MVi8[ʝ҉qT[ G-:M}aڑ֎U;-4o/l |s70̨!gO(pj?<ܡCD5)kb OT=|STUtor= 2=Q{WsnMXRnTM|e"%([7 g<3 r].kQQмdE5MsnY7_aq:P&ʹ ғ,no].8q;UUotux?IJd2 5!RtAfX$zsn:\x".7xhm/ϼ:tyla IVh{6ʃ~ZEԉ0Vo:͘ud" 1GWxlK !8wÝNXNJc8pP.- fW\z ) ߔLZvމ:*iw4עj8\r6.b{Hzꇋvf+]U04vge` V΄sF!+exXipbc̭ʷɣ_4dUa;F.y,)(URGNC7VİlaFr6+&>>kXew0̿iB"x[A\\R|{ ^3QL'1t^F/2,} _kl. ]=۩Q\WׯOۯ%Z$4gZlSWPՃ=[vV=j߁m2.JgysOyڧ! o I:c_wΆ덭;䶕{,=cFo~'‰JLebk)3ZhԝXRTViLE`2]CDԖz<H(_</]` ˩֊zIl=]= ƤQ2iV<X=9&kKQҰ"u[g^u$Z IJW:-)S!ǹa8rm)6GwP)b'r ƲїD-8@r_L J|Q\GH`5uػp+ TlbN]_c򕤵'1B; CAAwaߵb[ ̐:eK mȜ[z,<q]ci9qrr:?L_C 42e[\-ݓDg!{#+mr'1;=OZC`Hb?z~q {Izq"{EMqQ:ՠ}o~g GU飴m.H9E3z,֦mG? I:$no g-iuw)!a؝^ ͊g8;{ NI"c*ͭs"E.T,:5a8? ˜ez}Nyզ麴iQjReY^Ui^4ǖ]Nw ̡7Ǜe cyZ}oiɜJaZejGTNֹG@enF-jE'\u"ܳe>Uk+kj%d㓺r؛É/=t1ZQ=Fu[⺾ =uJ2|Q,&g$_KF-;vh|[IAH?*#rOC֧h-'JojWzsW!}_$5(Yͬ SE9βl1v8Duф2)T(? v}6{aBit̑qؒ3uZ%LYboFn_OU;or-b:H @_;kˌ5==<0E5 3ڣ\|@sGW˳ڌe %(I/S0!O V^TYObC; \#+on{Ҋ١fn߶{uc<[vSlpѻYrIYucY-u79N]Ii  b^,LƁC:}P󚕺DpE$9XőqgT 2mt1M h͏T/w>M.,\AjR^nhkoZ c R̓t>/$z/u~]xTEͺW7g˙SoIk$K[; 'Jy>Bםȧ8xΥADfA?rj߹5̔5dSs wKMq9O"ĿɈ:kX*8R[tVFd^ sNLjzVSLLѡv,BА:|nV$Tڟ䀟wiobkͷDjqv\Ծ?5mu/X(K6JuV9~j#tscZAEn^J.~B9"Pp]\!6raªdn,sӭ;mR 6'"@kY*PװpwAVMoeD,P{+e9X+ *E[ְv"1Y(u;܋B>.璵,RAsvxڥb4)*{kOn$lY4J)[VYIG8^BXZQ_tzg(g"ȬJq|a3q~-$u"&f].n}QٽsFsF5wwU6C3zW8 iƽ2Vs=U.Nf Or)Gce$PӴ07{C<-J(S(4]@}I:K^g5ʲ56%j|t;mԱ!<07"Å1&'q42`A~| [oȍb'tƵQaㇾQȁCǝsOy湧 Ye .uZbb˝"^zژZ"eܞ)'c⊸7;&zi5Kæp]JKDV|TtXWq]O) 4\эu~etROSo;kYBI^[P.bMIRcz_#MMEACTH1-o"Z5cW[7wwߓ@=[ΝV c/N^)w]c3;ć  L]n\E`9?%tсĬu=pPnW/ E|cW `Eh_qh$W)xggEojK'M\t)؅)uYYaAnv<2MH3r6M], ?7&ƴsn<./`LZR[{'zz* notrOi㴕f-甆&]GVԗ$15?U hybtv0v@$4D˜0j&gwө͆TZI=M6*Ңt~z4L+FWN:^ֱu6_H#'RmOAbE,~Hn&Ynh<ᶂ` Eȯ3{m~Mk~!⠊Mcng1:~Zgsdj1w9+i%qW/\@mK{az#J:0`m 4Ώ˾}eV.Uu[6nmVםŏ, xqLX=ku=IaX ~]30 feVUk>~,G:h?WIz[5}#\Bk*@A'u,l^,Guow. 4,;0`aھ407OqJ>r>6>,[I U!=8u7ۆ{ghWCe;t;]c챜E^ضW˳eqw8WW wK"~w֟lt'y~pwoӄA=m6Rrtn_gNJ>kIr%r!׃[BoBP{7ޙNXg <%|/:0%,+-œށ/ZaKus"zbMY1#؞eֽم')m-ʨRܷbuE[-!FxF L ?d)g [mз!ֲ,'FFXC[;×;Xkbfxg)Vg-Em$Ca!HHFY) ~f89>!QZ턣:T^h #c7Yvoa1ϟ&Y:ڽ סeb6Utן僭" G 8Cl218i{.WB.ւ`AGQAӞYFpM!\{?^T %~:[f&q ^Qtup E s#pF˄ƃMZB;O7=֪`wqMs-K`e xj$;hB }f|JO_6e }<^mk3W25Ƴ{z0o? z5ťH[3 >0-Vk#4_j|$o\Ʊh=s=1ubҞ@Vm*N~-]y0{ukWihRC ;$$ γ֥.d0-zg~ANxZի뗡i, KdC˸»:7 `EEeQ <qQP&c}}b-*q'EiuB&ܔ\|cFmu^s EgwA߶*&=Xd(Ԉ:ȩ\Q-='Z\v&/I} KxYK 󣙼MOVt)Sey- /KU-3inx26Pl ]Gl߆be rC,o*ǚO3_y?vy,HeDGuqdJltzʧ=f848i/jQiK7ZtZ+_ kY#&ܤavHwsu8ZO̥)s>zAk/Xا剗'kvv5q-`qZPVJtDfS;JZ( O}A4p(7N\c8vފfRAK5M/Uɠ=(c1X [YPVIzTo 9]eE\kɦWo:Un.bNz h{^(L1GF|e+M P]v^>!t&T<S6-icYih 6Ux]lCJV -)^&>Y 9,p'yϏLJ*vŞvH;ҎwwR_I6hs<[5o^\0MpA(JD$OaD=:nx m\2 u&Xih~oZ;_Du +̳Ob֒>ӿIĸ\%9< aQgFU F*ύ;~v yDzث1 >|+mY9Fj|VmLYWAؽ W?4py8*?Tso?Ze?yLh%.޵@U܇}('($}3TO*a}ڷ9UsCR`3%y1՗D3w@bu0Un#)g9X|wK:t"Mi.r6$pt;Pi6}Όx)94﵋Wb|[9ҭ,om {NП8_~ mu/J[+u[NV(܆ 3Gn)'.,kT*~Cs^%_KF'%nZ0L:YέW+~Av\k^P@^7qV,Ӵo(TڮUѽӣI3Q\tM#SQP=MS<).6XM|;$u5)D`g+ZWw1`ZSNo' w5J VGFu;xPكׄ;a!VQ[+_/Vߨ~L iL5KU3q+K-HP)MOa!=Ow 5PFk?&x;\N-Tσso7_ohPF5֊\;p!YE̒gQF҇">~.]yоR}ԟoc[퍿 y8p-rj_,7Dz>89Z72m{Ʉ4<V3c*MbPWUqEt3T鿿QrGntXp blywQD"r`t^eF5_ưt#\: (bv<t(N4r̆xJ*t"91O`'/V[ܠg NR||5,;-»QvۋS<\91;'QԬ{k-kj T%'NONV΂bO!*`̮k}]YB *x[\xŗb7w%1 WI~єx&#;,2Zw?)BT`դ8/&XQ/:eʖqS}&pZ ˎj:oK5]} <=jMCho`d? %>ɏ%@GʐۗLbOjbǓ$[:w.Dt\)g3ak~=zK|Q9wrz}XakU!2i2.Ě([ڈӸn+d^`Pyfڥ*? lG՚hjEbWk;cA}{&G˻[˩fbݻYarl7O %ng6E͘rnAlaJ z;͢ʊW"o"FO{E.4Eʆ=x β}"t=~o {6F}C* ED=#4"AWz9Hw^>6XHÒ?C?M=ҥ!x_,~bgK}˭JS͟#L+ϜR z95.djT)gU~/sp)ќ;ȊJbٛ1, B)Ofp+!I9z#A!/)tV/%]L!13LuXR„7{,9zt+: eA@ג-v 4 Ո%o2.d3?mn(r/8Gh Yz3u**hz\6nn\b ƥ޿l\`!k~Ѱy)xnVb3j:r%YBłk@kxl)gs9`K'KGgU| ҩkљۀg8NNpzW?fBp7-y`_ݱf)dru/ >_頇;l:kM:kn"?0$o  P~k#(h)O|ɟ1w`,:xϪU]Vy,TꚞԯXTÆĿS$SFQu-C~y9|[4HE3ƙ0j\FѨ q'-Uɏf5R5 Z9(5`QFwӭ²5޻ѡ.ݎ $x'Zje$?޵ cAěC$aN@AΗ|l~,"aLY砒L ӲacѽMl\X\m{Y%uhZQupiN95YXZ˕Y4 G$ÍOրV2;V%# 0 -fty)foh:|agXwrKۯ gF7WZ6ITZF<ڄ)>Ig{Q2str;(@ 2SWI|N 4܏t?LX\ګu'ս;/X0,9B6cٯRz@{67GcwE;׺ t3 (:|ί 8^U3<߶M/l8"݀V߼q6{n]o_Jq9M^|rETdUՓf95iEs^UYJ~*7,',)>"ǩT㧁3U7x iPK A}i} {F\y\mS[w~{I;Ƽ*Z`rδ <ʈ}E,+%FOKy \5cxdHD?cԾ_UUrujD9q2LkӠ1K[cԩ竸V~;NFݟO~Q3ouŢ`T[>}]_<:smRl^s-FG1w+ѥBfhL9Ub#okJx0¼IvW18Lx+<¢-el>ѣE>* 3Mq 5C~` #rօBln%qݜJLQcf jZh ^IG{F+ IavGd]_~09r<(7md4"|.J)Vޟ}H|]4P_ݤ+J klNt0-2O ((:`%4C,Ná71Y9MTIq s\T 5xyAztq^>8-uE ٲd6 ʎV:AsYsn ? o: }8jT<; _Zˍ9;gAI4 ֏=8:HRqC`yϥTxmZ}YZʕ`(?goP4XnfS<}Vy=g҉gF&Lwڮ(>9zk>Fӥ?48lrNυ`sn}\4U ,u%(^~R5ڋ: U:wʍԩ[8]Et͹%UVӉ{bo+1[L{ȉ#Œ6f$ƼJ7+oz#&@7O枤viM̕$T*E`e6KX6>1[=k࠸$bd_CbG6g푬rBǭrxO p&rnTk*߼Y[+EXBk]l w[^/-jy#EsCmI p)g|`e) &tLX5/sM[mk@{iRdBϢxy=v*ٞRsn-]`,VzJ*c5 3f5kJgYbY,^A= NJzJ/Zˬʷ\se!'|S 쒌yub'=fcZc?>f(S䅽mrHZ΃]&fnVӃJ-i_p T>j[c`%r:ë'r"AM&7]Zއ&~nk&ĕiF::hw?+EY% s/k`^oˢ:YC@ 5CN@hU KW36.eMatayt2pkRǘ\z= 4RV<0j'D.W_XY5jţZ}vٚ_Kܻ&Z;&aM.ShOHk5o1ƞ/D҈55>儰L8,f ;h:._o1Ueџ&mr6Tj4gYpTW8,]s y06%Kr$S"yMZ0,. H '()?8{\v46@FD堸!0y6c^=~*Dic\!W&kv/ӊ7GSpDLydrZ?zͮhޖݐoϾ he)릐OE3|t)?= vr{p!O\!.^x J*}?fɯ1嚵~&M?%Y8+bm-#n"m2\} (Jkf?@Ap `[(D݌Սr* !iX"ȍ=/njkVLcV˻bߞFV[Gcb*Ἧ-di$`QL塛f)n7֋~SsoT#H)HjTVNf>Y`(\j\a{~גbbƅW^67͹&n;pY7cG"7 p7Ϻ=~E,%B7/a'tJmp.$F 27Bk{81UKM`[yFp /Mħ#*׍羿ToEmK_&ʟFr==ϋU~jY?BXݚ,&DBSJ3'՛_Z<꿙Qne=9"^Aݖ؇QF;5 Y8[ .Sg[h]M\悱3B?as;IjTJ}5qT/Angtܻ+{6IF@8Rv`PBN=ԠGxAW/CeљJu(˖ G`NҭW T3؃ =ƋrmN˜0,|5wD;ZgYYeZ?BQU(~H4 `vW8ٙ3P5CoȝL24ӰaM3],7[$7SZi Tϙ۰a^p}"l#҈V. =؛oi=xXz xY.*fPg4eEЦ*N %"kyUbʘ3b1kݿ[6R33vϛGjUKwފ&}KGN6d[ƻ}bOQ=}/lU:W')^M(J&x-,H. Ϲjq:5K1Y/-U}Y ,!+ߛj`^`=L-o͝K hOni$={_W\bV H.ѭ`I/QZ^(nMgҽWwrhP*?;&s ЫKWDew""+З TZ%U9wO:"G?u:6\ 9dC[4߳ ¥M=>;:vvq mY!8h12M( 2+#l=afŝo$xrU%P}Swm\1#W4ـsUp7߁>8P&?yB_yi8[Ǎ#' NKjeF-6q{"4=V=1*c_7T9Jyw-#P'yqi1gk>۲}c"KnykKԇ#ډKLe=aݝ'?k14-[(7Z;qjOkeAR}N-SVt 4eMKs.~{2v: )_1ƫ[{sbJe7dfכ__5v# 厍>OgɇSdA~,I)ոgqx^%.b"nQOIu&" Ң^+JVK=Jgm4>Mt!.Mf(uNw:9[p|qy4xoɞ &=L_+1|9gz}9&`,Y*t>+bi&,ŤDF ͖R٦%Ozg+{|~<4pg]ZŻژm<&88L;cs˫f5K-U˟xs3d]ǘ!ͫC_я2Piisy!T.9(Cm< ^C9KOҝu5DIoddiR Mny.e_Tf7ra ;aMCBK:t8i>W etolb7346YX7+ߠx6s+XǺ+\ީ endstream endobj 279 0 obj <>stream IvSKC-os8;406I^yfQk.?My"yJ1yٮH I|1r`uw2ol{EsϤaEIe!>zu6kAZV*SvNrM&0rnw2czN9rtZSs'~|i'X;Ȫ[M^ԭCFeM$TI| j_jX/Ӻg.oFzw߈w ZiUeR^NtzNyҹxi77|o+Gw*Y*A-܊Z[]t^=Hi*Jd!?Ι#幯h޽W 3 m0EiuBf/uj:6:U,]pe2SȊw}=A7UQĦ<[R#t؁tyP2^"&ܯ?u֎,gz{ӋRS"y2҇SuNe+C sn?ULʦъj Sr dQbV_ɠWsg=NÐ6Qʰ&P0Nw8~},|fϼ(>1X74Y%͸Gx{9ҠXu+)DzTBZ+^VRf/x_G?LcrsLj5%c0|DK\/zQb=M]]会z5ǘoc=hqtY mGu_•-ya3f%_m$!,؜ym5xkNn R8T\z`_GQlӈFaIǍ<1{UfӶ,6eho (Iެ/xEACUd[~ տo|{ݦr".us/x59{dڢ58b4賗埻-/UC* !7-)b5+Gc0-I4[$o?X0nGM{6 ͑3}e8M[fv8r)H (9;hv 6 d3̲yLIftKqp%;nT?r@x˪Ka-rݚwm:Gڂ^_iHkMi]Ϸw4۪mwmboyGjJ|+7w8~X9z3f-0BOc Hqh;V1 H sYplfl$ܶ9ZEoov|ޣ.ް,*n܄\NoժWss8Wc琗F;ezX5=P;MҲ:/6ѯm%Mfot)mMq3[.Q'z)GU%@"RUþ; i.s֚h{Ym'ba$h&wI}1tO(v,T?Mf-. *)} )ظKzysIOdkа|u߿e"Q?t[::B8BU|'ߐW]|YR&_W7B ew)![/jCߓ\o:%f5 &ԏSX ߸uJb|/y ;`ND t r4jAPkjiA'q9vxsqذZ5YiUFo༦jHK[K$;A@> r/CiO̩fXԽBi )o|} (w2X}AHg#7d7wՏ}`Yb~2Bޡ*uOJ]G P0rּDNIHl)r9Tr>œ۳Yf>m^uE e{c=xFnUm=ttA=y+cۋg"B)J,tuL`F"u 8'y q#`Oo^H$mPtE4ܟ1ۀo<:,+ce.n1kϏ֒-RzIFxFoa '52:*t9'1krtCivW#$k:k﹙77,~v/^VnOUuw!yЌhFo)?jj~핂Q! }H'>T_Քհ DI_xL"JJn6Ŕ^*"ɩpe 26L^ LAs!j71Z]1\cRjT}\p$Bvp_Zbˇ_Z7 4ܧV=^uZ}M%y)h\E^ PV YP _V/Cl]r(A7]/]"x$KܷdC+2~g ^h+ ΩQS4-߾ڠ +BImG. >Y=kg Ubr'We{#*pEh%i),qUw.i'5ù"!^:ϫxgה۬B3T装QtL͐rJ~iןo:ron_S2ڋn#EW -^/󂵈[R@xGFL⌒f9ΟlP)z8*Tmꡐ݅v6~syOM44b 0]GO-f՚~[LjZ"uYN- *6f[I cg_sI /w \m%div5QIn;wC* /Jt\*"݇~:&zojK#x]!Q[#{~]ii6ls&Õ*X\{N+j*svPxmCn@~2J>"~ EWA@[y\gcʴr·C'[\'hƔDw;ꞘB "slZbh,TtĚ7I ߬Mۃ( IB:ta+&'@ _b2!Y99&Gu57$/x60ZN| GuZ. B9c!*,5xJҧU$Ať<{9J. +=TKId^}:8zY=O2(<]3Oju^_bfZܺes:u>8^Eteuin>hNAIioͱjMeİJ ģ1ͳ20Vmwx(HB̻5 z !sɹQĥ$,)y"Y:\8-+oT.Rx'cq7Zt|W''͵<~OߑQcB&*(RE.։)B^j Z)6rT։?凫:е2 ֗rIKJK7^+uuN:SU\Ɲ]Y .. A TMnkP"қt8Zyշ6uho*|@NZ];:ֻB &;_;3DY.:Lv#' g R5}ڻQWg&D鼒)/h7SbuGV4mT;L:Dթ9bMAd3 U|X,@;ŰŚdUto,DE.שT*KK$U(ـZJQ[kL=+ wg~E/3u1X4&%G{gG$ˏ 1$!$nġ?ΏLR9-w s-Zuh' >F^3`N= կlp<g#}#k,?z^G'[vu3C'fUˆlRO=Ё#l ^slzžA@Zє mu5u|*p?T3'fhF%/ώҁjEB4Q}*NO)ìjl!p@~W-9\3uH}ff[$U 4~c7;|>cQ<}~ $fW v߯<ܚ ^8[jU 1iL7oKU9t^ftFco[ܝsBr'y|R^cPY"7ʇV⡔ݫ%Ez쥭YrSV}fn(#mX/$D%Z 9&)ZI97]fsilsVo&1e'j˾}nEs[RUjg2VٹKӮZʭ]IӜm/́~z$=1js3N4*^K*U.wCE;|k{udވv6'(uS鬽 ZаD"_bzk5˧#}k =vsZ]ύ',l٨ņ!Ypsԟ|V]߰FW?OV[iB:AA´>F~{6/0t.U +4ϭ_6=d?H^9'FMdܑ Y=)#dm]m/PY- \  pCYhp݃ڵ{Sܮ&/.}_5D*LsRso:N0*}>w5#Bu#Ԁh [w.~8):Xb[cx kiZCсRKVѯ7tGU"GS4# }C{o^;r5w'yJsq+tČg=-AE??dFGv|a1ur+3jp^W,\.ZQk4S[`$ת{u$[5pvJт6Fmaj5`< gm vizyg@`U׾gbEpVw+Fk6tb)j8G%bA̭\Hyk}qhHh{賢IĩY ;vxqhf]}[|c߂ǪiYJZZ:M!*Wq *~cٓw= 6sEv_%~vjM+ CGVÔY^S0Maُ[~Sk"M< WR_P64o 8A>lk7>*K-=Zc̵M~M![:Mh# UkNPC}=׶H*җ^w 75L_wŷ z:3;k9 kzx 4skFiV0a3FH-O_r[}۝d3g4?5أ&| ӏơTYNz- i_͟zy:k[3o'<;ZWAejZi- q{> E-O7Wl5AnG޻v FX\ãW w/;iWLTX*c.Mv'úpn DGgl:p?/gwpEeu73aVN< >SLi]0/w |}_=EG[mCMH^҃KS7}^ʫ4a,}^&n0L@۬’]т֛ a$TC oЅ'r_~Ym^G 3\}?{7YWcAgw\UX-wҰe[YUP+AN׻ (\OfTd"v`dAcT9EPqǜdL2qzXYrp,S&qe$J]a(N2t&8rFd]O}}l2ҫG ӻ*~A~lM_pm8j5dވ1Rkf21}eDSde]g/s˒qEItd]%s!+Z]Q) .e OcRl;QX &.42ֽa,-C rM+3[:R*& r՞z.g"8fwQtl,iOWnYyOE@ -V{VףZl;,7!7h5nԯB y?%ܯ̈́LRae~hƬV^' e^ZGVK>Oy|\ \/YDg5( /98k2^q䇬-1m1.K o7G#thw{UW70aŞfg\B@ r9(lC{ޔt*`U%zKmcaIBVwD&˸S=YE 6l'e)jޥ[(a5Dρz_aTeL>cտr-83xBzhXRnS4VCr*~c@T]=a[b 7mPr QQcpM\a%n^++h+$z 㿨i r'^A<]ltwUP#_%N=pZI4VkNVꩺn8ʓFZͮHۥQ}vm-3Z]jRu2|U!ϦM-D)7F2xDKSHH9mC?+_+kX&%]Ɂud'oOe mQTo zlJ+m5UB' Ɗ6)JN_ӆZq8,Kᠢ]55rSPPW-fv>j,c*(f!{=[،ٍdtk%ed7ei#N4dBZ/ẽxվ&-Ŵ(muf᝻&1ljLII"/Ok_CS+9w ~':8??1\=~BMf\Qg-ȸ-جu2fg˜l~x0НW\ _sc;P|[ ri(cЙO=8G;\+)wjLߓ^b?f&L)  &uBͻ~m61bۏ^cvKz}g_(N0:A%@̇^ѤH\̗y-s=+P:v 9/5cwڎ?3A޸>Po, _}# OgrZɛm Q 7;mj6iU? z+?jKu ~ .I  qLq]/ݳ WyTt̚ӛu_[| (dy}\m;%-猦r͗[#V5*܂/}޼zi*>/(9wnAjþuXH>? 0.~ίMaY}e _~Ǟn =n>'JybH_W&K_7_WϕKsO϶v/}mpmu5¯ǐX[z{srJIOx>|˼cGY7,Ln 75Ljw#^eD 3gDT S5Fm.YnSbxD[0&F#֛T+mvEޣ竄e+t]7ګHB2yD}>#-_ _h׊+WQ~Gs.X-;pF!ĥ>QOԌvc<˝-8=ExXoad}sǻaV6|.39zWK& 4:M /R0V;GDCa>5+ Ҷm^%(E퐙O4@623"i fYGZ6Uu-Md9<4o'ۋ L. ls^@*7%߉4r_bYJwAMuݴp9i@Jw7'NC&]^)!5 (+un"z5>Wsҹխ6'r{GfV$k퍞HuIOw+Mi@}کoM)Io0I_VgYd!w)0[zTǮ{?žd[͐,{>7 x iPU e+Kz':s.JdU*ԋ2~loa[\_#@&DžE_U 6#>x VO&q70 '&m\KƘPFђhI D^{Bog!G+a )k5bنJT:U^ݵ>@jwulWXK6od~'M-уZg>=/M0 [;nqGf3NUK9iS5?f p [څ_f"x`gz[ fMAZ UWPM,'ZemW݅S +~6o? hvm%P?E/UWo0u:S՞g3֖lXfN+$izʹܕk]7G-pWW)dK:/ux8\I2DX{{Ee*q?5opT;:5y{ɟo~Zi'=*1u-W:%[>w!w2?G*ueڇ<ڡNNvor9x\!Z&5I^*/y? /YoWp{A}:u_ qycf?zR5r2e^5ؤV>*tۀD3 D3P]>c,osFݰxjf]u\78n2Mf$܀]9Pe `cFtk[v^ 4ù(өoҙ.l.-5ᄌc=s^rv%7zE| exR-]\fϚhH`;n^\^`dJϖfRhί} xX5nqOڱ4(; W-"lE5[>]hUfJ$m𹧋ϿPjs)xs޿j-8Uj~_?g-i.U1gJēiJ#nXnmsc$M<~ڭrq"1]G?)8 1Jj`'~PkC!Od ,~|tmlꫝ:Y3S$kZțq"P9_V}jmv9fNz,5d'm;#}{&'gĆnMfqpP`!l2yO~Ll0I?.8T fAO8^1!5̼O:l٢5ߖ.>i ]`&Tw~ڌ@A'۳._N1P8F@xĿ&]dr!hڅ{B?k1q~wn x,MUMmgߜ֬6/m(*-,p%@ڤ./lMƨx}v,<=yo(v%(8݃L ˤo!֒N9ꗾ04{VilyaGs8~jp}3Z'` +Be$mFG`(,&?Zͮ+}VCUd1YH;;Uk1u4fnZDUZmͿΖ`\Gzkg8V%Ъܒv(bcZ rvӴ:q%j :NUo҄>BI^-}VC{ 8Փ2v=6 AJ؇isս53?6gKigWX1C>T*va\VNio8Q[}ֱiwF*y OC>4sku`|5Gi-$XVܬkԀľίAPjԗA8V_ 5}+D+m=塙 硧:&X@rY ܃?"$W;09ͷX͘؞I=KK3ҋajlWev¬FrջsH%Ik4zu#ʸ8gznv&!pc!} \mؗ7+O˛=啔Kշ _pKmP=4Bu}9" m9_R{<};Ic9ADV#ufZLLw YNľx*9raF{yy&k:1ǖOy`l0GpB}xBn QNDuC_QˤnǠvz1WOI \_ƴ[?RjK9KJjه: f"F`VI_QZL1!(aUYizi?sJ1 z\Ͷva?w5{\a3ۡ5.Cl^㯍XIKX j'@|wSg|qh e')mƞ£hx9+Sܵ='5ɻpSWgP%l;Ypn;\vbWf555eU.= 䮶+ΆC[]zr] |~"})eS."ڂ%aj Hkw׉Ei#΅\X9d %/=jC^,JK½?=6puo-O 3Lnn~`~^S{Un~/tn$V1/ܐSm}dlx~*>yζWKV=Igf\ ?te"aDŽWoKR4֞l~,3"-w {MH4CRq|DzNA4nv }8A۾ Uڎ R|_lؽs-Q>_CPbX9^MyZd ŷiWb].sڜ."|sfS}f7~ǯ=c:$ot^u&ha_VILVg'd/ڣUziyysh eI_dnIOښZ|>.b4;dW=3>$樌>_8)NP}{ bϮg5cM8e&0XMr #_g_tMbgVbqO\SmO_SP 5tQmo[K( /U^ ޅcT|yes1!eљWݦsŒb{Jl[}s6wBNB؇精kFeƓ3.uWOG~i7k.6QG5ćG{Pb!ء(~ц#ߏԯl֯E¥ ɹU֩YhnON )"kU:+,((OBN%^IԲ6Q&K^֖v6rӺYQrT݋H(Ag+A˘2ai.P;_)Z?ktk[ 'ƒzoЋU{c~ox7\7u-Ϝʤ(u:=]-9:[A>'SSIoMƋۻopJpso}F!el7pYƨSX}\$izIG.@y4ܱ̗A&;dž*5'/sd~^,SCfz!xvQmvؚK:gj+WEfY$ aPqt*Ws_ K_-j{/b2jǞD9 u=UěV?t7v&Cjl] O\?7aa3m<2?Ӊ[O\\P[w!2Mܦn+ߊYvZƍP=\ρ䎲凉VcX.*K'6?A* [yqitgMϛԩ]i4p'S|˱e}=XeТquGug\^_ܩێ[#[6z l`gfvs>?WXrrnx!(a] ]nFXNnC^Nc'[&4oҌ><6md!YV$Tۊ_yGWT{Gn_$Ǣ ?BЕJ {?O 6N֢dڢq)jw|dkzן—=hX|ޠ496s[[16(d=Uz]{%tѵ#IW}W?i8}%(-HڽHq[ =?4K蝴칷PwP%ZU`AaՈRZURߨ(٠+{dWեmy=eM'9nor.?os+$r7mh2ʔl%GUᇡ*2:M3d#z|9"e ' ,P臒xv-S?xȬ` vJVAAR?׋㜖z2yƨ}m; <+j#]1>r[H'mriۚ B Mq\zi"KiCmFYvf`I0E2xsP4`ҋR$+O!7:gDgu@kZU%6%7G {%yTT ř: 'b?<څ*k"3Ozaӹ=A>ö6g}e[Uπm|[uvU 7.WEE%AZ`Ym "nBlty}$"sG՚Z~c# 8\m,/K2jDEgݡ2] M?8Ƥ[Ĭ c`Jp:& Ž97jt46uE rB v4iV{w~ܾFLnog_uӪ5.珝l+}KLogvob>k/i QkfunEEo8YlcYC/q! =c(ks1&B !LR/yP-ThaN/M2zt |_ȘgnӜypO?%Rj} */דQ-i19h¶\y\)th *#hJVqe_rKsLl*bY/]Bf[_3鿋+bY>7ǿ0ekEzi@~;LM߀;5 \",T_Zhncl=Ϳ4`O^F.ku>/; {%}>1e~vM17]z A!ahZ6k,NPw΢-NŁw97[lSF/uW'X{dx'; N?)`,W4׎leJZC6%;'uVҘ]M}e[OoM3ФA ,C͔.uteZ)]|x1t_oJ@ʮck\%)+37io?aߣMVl?>-[) '^%A A;+8 5t 3!(b†ySs1-SjCڲFINY ki鼾ݍl<=MPoTlʡ/n\~2Wֺ֪cq[Zbe<4۷@d:U)mFN5uqJos+);|}Qњ>L 9%`+v|o 5THTsbpӏ" mthQ{Q85E"83_i0D *VAw^;Q?}];[:Gf@iƠ}}oMJ2Ӧ_X 6sLW%IUx>^_ܜkTLm{6̯iX_TfKy+BNf6ɫYsQ0N.1TjҿvgL[7q#|w~m&7фזن1}Z9=(fM-OmnWrmXMQm7z@:@Tܤ'X|bc6U ~ȩx6j6_2%wCirWgʝ[\X-;Ea(qz a7[^MeЦ_:e'u 6_cnQ{Ahx]w1< 3; ZpգQK T+GO_> O4x8$43γ֯d%d;B,mjyXVԛ:XwA_.iܽW7ؖҍObn϶e7\ӊi$dRﶽ=0|p(/V=/.=Hά| F[`W~;5{Nq;*u/OcDYkoY{emm0?DR䘯]Gi))WY3 \K$"r}Pcp=7JKNrC%{-{T0ô*ĕrZ7)0޵YJb+bK~Xi8OSNcT]I 〨x^@H TS@l.?DOI?TP[LIJȏaA?؉֟J1ꝅƏ.>ApxFu+ՃG>`=i"Jd:#X+˿xFg>`6ăD^םu][X**GM)}Ә$95W3˖{g e;xe`TZ tdqʾ-w=q|'6t`tmM|俏%9\5vll|f{+EK< SW7\>z`k ű8Wbk!¢%m7oq|FG)uJ2 ~q>+6PǶF^2 e -i'pS1A\'zE:2"qV^ gz̡zⶦ-=QznWyꙡ?)$|fF^Qӓu-fYԤ5D{@5@ \S -n< \ǰ=mmt6"kJE3%lcQn0V+JDFEļ1ݱ`W%~ƿ)a߇B*p楢-s}a"rtb*+5W$RS6n^b BQ,KV&PC;V&!r!_Z3o;{(OyVv8C*[OwvRd^ݼ&xZ3~eɖz XVOBΡ^cj*Kt( 1w]Ux"ׯinLؘm^k:ڪ{׫B;jpWXPm +:hQ@԰.D-fv#qb-{քN"JS4 A׷1GJX|WE0Q+Eh7r腬hU+6Elok|޳S" ,6kõ J$F`ߏF&j*Oy^m7+"ZNM)VɀprjRcoKOZnW=,M- *(H0;bgf(0:]5 uwtu*R~sGL!OUmʹgHy}q02Ũ\|W_Pq}E4W;`P)osJl?_;Ա:{8X?K놞'霎$[{ܕנ 8v?Cӛb]FG9A߃կ6 5=hb4@5+]GvE-b7Jc~q?S"{V8XlA?n)ÀO[eXehlOL@uAІiJg?ڸV:lO"zGwPo#uztzn ~ս\ȜaԫA ~v2_8կG^{#Ȉ;G%Yg2|Ll_޴{ίf8&/ذ;ܶ}' 5 Nlhki # wQFsUjxBsO ġ澭_/zr#G؍^K 1F+ذFE3=BV5m2sv'INYN|}8ǟl} ͛[{HrfHFkGlةWUy'$kdV¿bX ɮ.xy1{- ͟Y@H+Q4iaљ9:0]'ҟ6]^;38{*='@TZyhT։'kS=[5wsklY6}* GCNhtkGQ>nRLhJTJoU h0(i{ϫcjD 3(* j&[ l3b`ƴA7oƾ ܵpupƍM@ c:ۜ^Ƚ:6s]~/J?Y}OβvɋUVr5,ApX/ R G֙)?v^K%f~ZW| Ҹ"Odz5[뵔b*qɧe4JonYxl)+{YO|Ԍ Fu mVN)%6ְq6ibЀ~KXTejɕ4uג_@Lv}+K'9B?GmFyEKHmSR9$}~2t`mH>Ϡa5ſ d rb\XS=YpzS״M7o]]:ss֋x~9m>9,_Z5bbX# US4=9 ()|};HeQ6)y ̒EBfm#ŬrΙ}9h[☸PA\iK m,+_p: Go`HlLǽ6`-H$O6uÄ!` }\Tןקa$T{{ (U)Y?(Ne@s+ zTd x+b3 jJ6BW ͧ9'Z5>9LnyYsMq/H;){sYv2e6/#lkMnKN1V4'5t8㩛!]d6'O3evvQκlo]3(X\(sAx( Ԍvx ꣥a1lG*~ak<iSx{8 Sxyg=&Ho-n~_;SLgZ VèڇM;ȁVV`b&qqW۝L-]a5X/,ck_ڭB/G ؛Yf4sb-j@Y Zkh0 ̀yf83^_K~ bqoߚ;T @OJFdǷ[n]KprVfaԚ9]7W{qC,KΚggrJ0K$dgq^6-3 _3\8ڟug'm~nDW(*793 [δvmEqFKӿHE0}+2[8PkG#,]a(:2a& oAVlY:[dd;A|zpF6V.g oi:'cb(lX0 q'ݦЯoG;.fX֕>Y[J'W5IyAw܌Rcy߉]>+zp@"g;J p2>b7Egܭ0#-Dʖ=<=%?Bز9S؞ W,a筎I;~hv>}y7B/оlhϨ@qo\7A /jѧ`,pHcbEgߓ^Q04(O|5Y-ɨ|8M5@PsVe7\4Na^lA=] Gb<+j豫Zwyy( ÇelgсTho^ͿCt53 Gh9 ۠tGeYi5ON7eWl݀WQ]w+xҩa6` OI=_A鍻j~mj}/mAP߀& `&}Nȼ,HeL෰nyn9{\;2v2X9!:Jt~'y#~'%$yZfVv=V\ Ti8HEQƁRE:Cv`~Nm5BqvsqY1d&Ŷ73p>j@PE6ګBOc<[]7jh K,@:x&=-^kcvU\$տk+CUn{ֽ)jK?t>,LW,'RsIpRS]^›`\upV.#]'i%I9Ca=`Ѕ҈!;ڐs _< YN?s'&s"P: oFu;#A>V_89[CnwA+ӑ3͞[-kD72-7}{K iwH-m 8OuF{㎐:smnd5s^eد.)hE' dA(gni$h&}qEOCr+=@y|/nI&޶{iW?tpd@ɩu~cmuUGK|K,xY gE^Ƃzp_#lTFܤ/ **.UTaMک'/1в46s,_c";8 J[mg}"7^eķ{|Uj_p8Xo|2\taU=R|b&/$pdr9[m2 m>=p~/]ǨM-3Bɕ§v,7ꏤCkyX7itrv]$M˵Nc$9dzGj0: %>=;q׺*Xp8 Hj_Ox<:޳)l~hx[^d:zɋ5ЁS$uO퇁GJq)qԂI5վ&IeEi?/aEʂ+69 @WA\}yfktf\3Itxݴv_{Y#o۩[W9W9"*~AX[x_߁J 3Sq鞖=MhL˻M8{ |=KdzRr;W7e@^]^br2i4cAuD ,|ԝ/Hgd,ؿd|ph9c2fvaw'^J*ӱh?Eֹ33K6 `c֗`Z/l gWu3b3[{\.-i 5gZ~my9_xh#]Q`Е6rds6vhki+w%o`j\~ꆿ܎#3^R2~i-cZXG=ڹۗ Q+܁ܫ#ԣۛYx6sɺg*3{8FHX) A9zqsWctK)] ROqIHvBَCXJty׆4~Ýĩ%ݟQto$a}![{K+c\+zǭޡ5)LLPcls ưc^^|Xl`SfW"7=o'~33kuԅZPW>Wi ieO K4u3Ol{6wHu\ǝ=m6 ݃>K2q/z$fbݭ8mtj\nB4zD"t9@.#Cta瀘t&U=έ cz'q]M0oWޗ7O Odp7 $6E:B!^.t(łX&^^suwqlyc:ϚVkA{a?Y8f\iMmz!m0crj\.|-fU0 eCtm6ץ_5$QTuW _煆.ڟjio.N>!>\ia`8&+LZZ(j6=s :;O*#M$ɡ4/[Q"nU=rn X;Exeδ~&0acR#q~&0t$m42m̄K;7fEhFtTsA#o|WƱVu끋J٫jn}ny*k*A V3|({} 14H (|z}9Æ xgK"zLdà tM3t>$,kOLl9:['5˙0܍w\.Y` -fެí/VKL'1_oYG ԑ5YwRI?bꩵY֊,|l#IJ ,%$,,.1; :\$h!˫D o/tT\cmE(ߍGDYomrPN1 Nt@O *֦kTFF`O{oǼ$Sbp2U;z@⼢+-Sl鮍*zF.-[7;aPol֏2K=l6|\E9ݨT`؂?mn,{M> bF>~ G5>"=fC+0bn3TPF}M p[E=ԟޟoP;#-,o%Ԫ.ٲD˹P{C}XSׁ᫃ d 'M,Jb b"XY0@U-jE`Q%2FOֺ@m&|<Z ~bc_#,jM@UAN(J8U[PկoM|a4yIJX 6}Fżm+^.47!=&Vgֽ}0qm}R)nTTsRpk~P^{ޏ ;ovr2ڪVjZGsBkI`ݰ DR[" UU΃X4R?zj|lq&e4> FA,g/&d ՃDLU p[HHry# R;`0 -Bݙ{3@lsJh3ҍb[ӕ}7 yc?>^bDOɗ4(.1(l\}}%.[9)~w3Csex hBMWxAL­i#Q׃V5#ݼ¡l8 CB-8Y9ul ^D=G>+y5;tDum!H ѵZ?M:r>_]:یA9TxwQli*Rg1}gM`j libZ#(G׶-|OLl֞R|?Dyq)U|'mtMjdmKrU*=mҹ3^d n(EnUKk&)&|_`gq鈰o8 ?Ȼʈ{n֖cK9(X?u[ZKns"t]ό;abLJ.UMWKE¦UG<~ ~@<~eMm~:G}aNquӛ*(܋h52!bBun>F6}"e@cH@Z:yk<4v9"­-B^o}gcUTLe9gx5;^̆)vYE!Eqۢ H螆`XEqʱg; gE0gM-PfNܞdQN +3 :Tq\`YX_lJ=nAv'_ q#UH\j&q\:1dm,!a5}fC/`n,^ZT[[A$۵q F55pD1x=8!~̍'cYZ3?yjMYRdN2Ft/-LqV > iV:8䝁܊" `iXE#?鿫͚[&~:wF#Պo|Џva_e.iIHGutӱ6׋G`iwR^O Y%io%]<ʍ&;N BٚJ `%oz Z=L)mRuH;;?wI߽mebAx?㴢^ؕuGr/ޏT?QVchց}% (@ˀm| .;Äm+}1L ˒;{39"3"WxN?mxKɸm:K7[ie9KeC9 /TtUgEk| ʴ3Ӭ1w"B⋛4S#%?NǬotX~ wȝ{as-8 jvBs\u^w;!)q͊*p,hf37+ۡ+7{ ?@dJvMt ޛ1 S#CW4$70clSa{ʪ{wɺxswY@ RCZZbuXDA}%1^彩|tK䘞 }d}5^Q&\[룾o3H-Z.OT _nuۋ<ְ2}4Rw ]'%{֨۽z:؍2UQ؆Yt7]J7]^oZ/S♁S u6"x?>EOgy~uG8wsmæ9 i]5@.WQσkp,Dڐ}riM{M ^oed,:5t7+ ?Iz\m4dtLu/œc=_w?qr7ԝ,;@ aF'g\ i/ҁ8W6-0uxP%G')>?Yo4]Ay K>0|u+N9WZ@A'oELr5ZJ2ym-HOqAz3հY^.?,o2e1e6zl%0 M^sKxMcCk}j׆/!zSC3PD}XO=6ZÉwPw [(ݻU>6݊U p'<oL!MChڪ玩dj]6r9blSgqΥג Iy} xA$V@ZconPʀ/<:;;3) 1;zlpdZgeMN53^'%~WAN̫Fp6{/yٛ~dKEZFnm*[W;ۛVNP$ͷăe$Nߋ1j[ز}K̇aƞt\6j R~3:Íz[h9O`W˕kRUmm##ùXΎ^a69TzHa{8RGgYS^rnKMX.mQ#L} v'KB]ȨOȇ~޳">Ǔcպ/`O(sw.-[h]Y2St+$-eK#BW~g>A+ T^pһ?Km믝?n2 \ŋHi^,W~%}u^/ȏYE>wcxz[Cۣs'}E&:l+QڶM| 9 n:ej)g zo3?֠m/7j{ ߅dص8rxj-o!m]r,Ӕ؏d%Cc. 2e,')t{ë.U$Ϻ(lNWFxz?qdl΂e~U'fvfZwEIFn~B u?z=,@K]-4EW*vӆL ȷc>+iD;Ղ8p%(GX"W#2^YSkOp!Ñep7?2Pn=w@\^5^Ԩ!y^PW.&ԣ Cw@;G蠧Ftc0mgwQq?Pl@*ᖼ66MCƛv%ّ{arWM +]Ojwo.A\Ҋb9[Yӝ,ϗm1iH9׳_>ɇ'^։my@hq\iFXv0^3)?&*-}u5?J?A+&t"3lIZ_hx ~>0ڀBD5< w#_(O7<|ӦXk6ԃ(LbZ4F Ҿs? Z_8ԿC)m¶μ7K/"6v[C1`n]ɵ I|w3kV셊R:Mr/Naaw^.d GNZۧ+es8,ԭֿ3Wx+5,Y$CK>B LP\R,̮اSS1XnO~LߚfmM#볽9tt#-{F]4"saj<(-4 P` )Ӂ>W $nL]= ִ4qbIjϥ:oF@ /pο00p^Q>24HPgSeߟZJ"&-$j=ї ..Z+f=*S^1쌉ڍf,bRFUrsE+1L{/5cC&2ќamǎcؽ9h˧I+|eRzi6%S5]+#XܗaV' }]j<mI1tYDY5 ;dWٺݐTJ~R!͋ &KWp9T*!r?OupM[;u_zPR8U1Ms/sZ8Y.l|>U*Ŭf[崡JYr˃0ZqM>k0rolUF]MtLK êә])04n8ܝfcqGh VBϹdO1Xזb?#"̆iq{:lQ7IW\t+/EfLj7M3ע7o~Q}m`~,rzw a)Կ}j/" 38 y,r&%c}ꎅZ61YGCܓ!yȇw5v惘u#˻x֘EA=Z]Eo%H_ēDWB7ϓyۚ}ĩ~1 ېǨd/g\Vk>v)}cߣHǦ`۔Y +]4>M%ik[{;<)5܀"Kii8"gu|aeOcbLi0Oe8l Gar{O8ո#O;[$W`8pQ?u{4Z(+RD/ u\tjVAW&̨h횟ڇ['K~Yܴ_m닢PQY}O7&K:Г* _c5)ESS񌴕:8ṩLgsͰ^gWp+:"w_0`,|/}ka6귚Rh_g:ya]Wq.8_^G'-',gxs?DWc|oyˑNaѦˎq8atX L $rz$7IJCt=Z'<@<0Ï4Vъ$ PIKJC$zw\v(e*9&Z1FWYۺowbW5Tr{h_ڪV &yfdu#+z ZrɃc:@ YDg灟9ŌA zW&4*LiwHCk񥔘{VݡE%ʥۍwsiPbyk - ZЯ-:2́鯍uݝKkY}v>d#8Tm]j:pFp>응;Ե53bl` fENEv`w$#fZUPvSrؐ4zݳ< |>Bc "* Kor+Ȍzc"4FU{v^f gKym&㞍 ; ]t)Ѕ|잆 m@Ev|l9#t6l7pbm`YӉۻ O/#gh: s.\{!<[-GApWp/ݹ-@RwQKQmy͕$Wy>oŘIׯO{<_Ͽ =\ 2/rz^ݸSXO3Pl*k}TsbM9|ahb4y}xxi{Q=ڎkG_p v+|CLs?tMQliG<ϳ<# (?ݻvfU埇r+"pl-9RY( \Yj!8$״n])! !:cܵ $ԢܾHNM#2OC'_BW =B!:x]0W^ {%x< i˛!l' aOH_"':#~^ Vjj`Iz N(L[>PNvFS2sd^eHЄaӪ vSTb2M{YTCW9 N~C8inЪ?9t< /B 9l0֭xu٥A9Wg50t^b{,F+_nQy{SzYZ^J9OobVBzHzEtAiUӈn~z`v DbhVeH{l98^N F[;`ggF~ኲʽ@~jVqN釟t8fjp\TvVOZtʬ$ vvq'^C8Hܺ$L zuX_|'AAoeǬt<% TE;!Ϣ&ûN.4Xt,QiCxMp*r AZG bw.x@FjH웥(AHl.:Dݬ[HA.[F<.Ut EZh>KGU[_" $(ee%U_%o HIYL@ily6ʵ"l\C/;+b76u=+oZNߓKmIld%f7ͻf)߹iZ3e7_ZR!{XsVy8RkE촷MfΉQ㪱 NR;ݑΝ{4P] KF=?/kj8 }8[ :!TJc BD jd]OJUIYI2JcuOؙ>[qtOĂΨ/z q}ʤk!yF|ћ_eҨ/M5| 5lo~ƤQ3_kCQ3_OM57__p޹]NDem0~B u1==9T^2#nIb6yKvhU=MrRƟyZVV6jQM5Ժl;@4[>9IaֺsR^r鵼u~).=zRT4͌4M޻x߭S/3wL*t[> o԰EQzZeۈn>/&;ɟ<85{jX8.{7ᄑ;NѶ:rѤmO6g/B=6Gݖ.|x?ƥa'˩inTYu-۞й=G6;uACWKX]"3G'zmP·ܙOhכ5*JHH$oIG7nguzp)t` RjnKt4MKoн=nŬKA4LvA4![at˚l)df+Yo76FL6_˓ً-mxк6TH+|^hL16)o O`wu}j8/9y\FpUZtϭJ:*ѭ6A,IV9J\zQtn1̒aq0ߗwo!a 6Z6Ån{]0IZn#vZfM>٧97{V~ׅTm5uM 3~JfL´]hI]73P6;YO1xE~k&ZB kd3 ܄>a*YEew&\74 {TPQ7(]Zx |pLb>7F/5μ (Ec)a,nlR ;{Frtx\XLjgijRah-?Xol'Uy 2K2v,_؎*@p zS7\isig[LV[/Jq2$>dzw*/kw|[|"ajŜԪ:AQ|TRIlk}pLWePR!ruPajP{2VW=.|mPjDޝ< \_(qYH.\> (֝r5=d]՜Yuv814TL^u);+Ҧ|9!|\^|3I,ciɔEXo,>5Xo~IZ9!!Qc/<|z/OLߐZ7tӾoΨ/_Q[INRpMꇌ{45_ >kVO I:*6f"ܔ9˻S\eMʓ˄pJQ'86ƝN+uZ#pJv`)(eF'^ ra2~o &^B ʝ5_9I~O"\RչMFk~DEH̿ wꌿK+S~OqHJ"cU&Be.UШ:!18N_0;PۆTqz[)e;mJ=LmL2g$j?+a$@Mފ \WN ;=4W97X܇LNCT'BlLZ)FIf"Nzq2U9{Npʬaiè ǧ֛i% I쏞}k8Iȸ1ɞHWoDf+J@+jg# 3S D:~gdՎ&(<=]%8EVKڣ*]o?-a.&sI}Uw]bRnW?@)|^~ tiȏWBs.Hܛ>y#ҶrAoɹ7\07Pb)"d~Ƹtf3$g|*dׄbTyB}! Z+q?q]Ky8XICѼDnf{6l(qbR$[#_)4![b2JdR!ڢ ս8#ʡ=z;)i ଊ #bQICxw.\DSG~֪Z]#*mXw}jޙ寡YP}엶L?槝5̬GU ]q]dD>A$o7${/ fd]}I~lQ+FA.E0hBG#l:zPh!#` ^_ \_$aͺnr[r]>.0Adž1i3f6q AT\skl5͕ ֩/8yǬY1ϖ+PrPYLE]&xdƙNk?-\Yn2KSɀasA3|zeXTB,b.r3Z=fLW0y)7lLJ_z=W؞{evV6xS}7{lH9ysÊw8spՂ{4ʹ1fp6}Z2ii^DNpSw źj6%@"Nt &~jfNi9=N2= 5S6z,K''KC\wƽMh>~V MX%Kν>>l։>ӣ!Srdأ={|.#g[Y3Ϻ)V8kĹNNzݑNMw~b[^@͛ *t$s&'ƃ[4Jurr[": TveJtV*? e.$["&<;tʹ::GtuO;-EՓ*  UA/𹧵wej&ԝ8pu>q̡АŒ8? T[d,9`%>?#aUCs>tXS8:=>Yv;x2J^̖&9R؆{▶Il_>htVI$ixb%?$j6S"udҥ2<֓p-y貓Jz.Zl=Tx&Qev%rc.@C@ bC«F"n>/qo\ '$B%uQZ=Ų#=GZ?Ru3a P8vrJq&fHTWw.!{L1k!C_.^\~ȊJ)̘cKך)yKMq~`L ,`4ma$80H$6ڷCZGrz}ul/*Iĉx>YUdqyNQ6ǒnΝSF}pBHD^2UUI%؃Ac/rM$aZTLO$L<qrzfg.Rh̺0Up9AhO[u'!>5fxK5y;s_';b3Pk LM*K]x2{XZ&eaamP=U)~P^7Ne7UGjK>B]rwu~o kY? x% FaHEl-xu#Շ n=iˁ?aAX[hvOԩK^ā;)azL-g`9B#$+Ad"nyZ_ |n  e3 Hׯ?#^,*%Wb<ٚ"Wɑqbˌ:jB}r,Og؈IJX'- nn.yyՙ=[iq0C5Th@=uYLnZ'.RŚ[NNբԮsd@g`k OM.L[Fq X?#$/?͟sXE}8gp VOUIo>V~lRuti4\iF-kkGEuJZٸ,aw78Ϣ~{(8|Ϣ~_i_7g{{s!svJx|ޤQ3_o&JvQi4x}X-=5H̺"p^J=ʡT ŎvZCG ?v3~CuRWI?(d8$ܿ)-ѫ?Ŋ{L{vCD 9+fkedMfa;j륻{L$E#߶|_"|}ŒJX%{׀)_KiW힨rn&jn8/`dY>T Ã^_#T U:kwڳ3w!'P󶳵)yTXتU8PM %Yp&tӅfH!,qX^)9Z"}ęsUp9aDYr_?($.88m\s:qg;:s%zL&k`w1si}\쯲%|GM$S䯨/ h?;lK:y6;M ǯh7J떩MVQ8)#sP‘g=W+ܐ5쳷?/qJ5^F(P_h/hFH6quȒj{nY6~|`-NS݀gmSc C8OsCC#OdI բCK{W avL2ɟYN; `O~.u;i;٩"{gT/C7Tz9kt!]Mf?VG wf\ӭa %o0w&ՙW3$Yh]8ҔPҰ+ T 'kU]nW@(v.P~CU[BXCӣYU&;/L/μ4cw,nR;@mCz-Bև8EkGmKA+XŒ':VMJ?P t:TIo7I=Aap[FfHpEWNxSel)q1wVQyh/B=-'f{^3'mqr:$v{.+ +y8'St$~$hL(<:9TTnd47JD׶zR=u \m2>fC+aEo1"(>[ ^JkmB9ximƾK{E?(o9 |Pt7M)&|0GcaKL vp!N\ Pʹ,P8ds Tqmk4|}3P6"7t]Oa] @_g.oT7+lb^L9_o` Br Eš 0k$VnS~P"ߜhE=(*T 2vFW@>" 8Of%9'DO98^"dgJ@?xՉO6%0FϏ:%A̬nB9<#L%-T~|{ St`8'9J ] |@VI vmUr%3ZyW]ǃ_Kɘ"8 luB~T-1К3\EjU[u~P؜>SS:7i[6a7$fJs#d#Mg"x| ,]^ gU%Usho/ywMWBQ yj],n0tXjGW'&;{墹G[3=aYjMpnK/y/,u ܎zekԀ]jZef2.Kx Hj 1Ds:͎u껫^-7}Ck  xLiZFv8JX7- 䕏MO5ɺn1L[iH]o5m s❅N rlNśz0;s8 -ߗ[L5M9ENGfio)/,Pci#0Nu2 a$9eӧ6ҫìp|)sizG roLSވ]G*Ѥun T%Phn] >ےSKRV@_œ #}5iL?*zwA;r )" 1EeKhys%ঃUW~ڰ(Jv.MG#5vl_ك㻺]_Ox=zJ+hat$uVhrH̿m7 +S+3,qX%&Nߏ<}y^gļᚢ4:Wu( mMd3em(i3#7rt:P8rnZՋ$Nt˟Ag|gQ?_~gP?Ӿ拽|z~?AG?P?Dd3ܘ'V8'ST*EÊB՚sbs*\{$R~^ޜ%_aFVp|_ip:;qL8[5-ffٖ z*Ky:X:w 9[F&55*.5_z)eVgҗu%?%t+6z-J沑@tk0t8uJt⮶ٻvfLoJ7"@au\:mlx[Ϣ-V EC@`tP i/5LT7 v(Wy%!125bm׉[y$v#65WDCj?CsaE:_k(bQt>/aEo(@w$ןFS:w2UfUoY=ju~j/8enʪr0vd(F5m{JbQѴ %~"TtKFz[ kYv~*UOc D{k."Χ &O&a>m~5w{.fN::oW,T95is_RB773j7E Fk(o- { Ċw;@bh=w,bݽ=NËyυ"3JU}*cdvCqP6{NFde2j .寨/n!5*zrqnj%;tYiڂ 'C w`zF>BMLjg|mTJkK烮@y<;ҟKah][>{ lmp&h? ܀F8*+"F[XhY }uG32DDŀ$ +cf?מW"a EtϏIw6ޙcWuۯJc-. vȦ5:h)-8=Te?*qϕ6^L(r< |guqKm8AgiB*&+Pއ@Ŧ?,3Ks 0.I4q [IE'syIt 6z˥wI\:m~8W/T+$ EBwp9cB6^)s*e+dn5pXȼ7 rx-ߔ~G]J(@^7a2pnؿyp9v:ycY۩qf{i5G$k Ir};d/$A'FA͡|)]2棡CEv ~MBvK$lc֓lp܃[Mj >xhpqՋ}\^2N[ݹFwg?2hu~S?ģN2˸fK:Jjiq@kRf߯Qm;.kpVi |"H\/!a~ kK=:ZrPsOee,;IESG9N;E~>hg]_ADXJ/ԑjA^]uoy%ڣe-@2aAIFe(FWbrX7t_U Otkʩk9Q8ŝq q]9v^/+b*\ԜRyYWO:w$86[b{h]07f4%7DoA`TkUqíw'd 8Xz|/|~3KtİK}B9 =(w"j]tc"ٮhQp}nW7BP|Xhc,qn0i^ 숷Y]i1o7%NB~k_X/R oZ{9lD>~Dݱ> Z2w0tIj3/$@,ЋsSǣyH3Y-3>֦d{6qM ^>/ n5e^(j5jm`60Z.pDŧԌO ;\qe̍z,78؃x1 y1Q,^ǵ*b/ 4r7'$Uԛu7#єճ~eߝݟS,NbHhGIlLD+eVLA\)D 긏r ڄ3LL+;Gx;UF/v  ,M4Cpo0kF`*,q |^򂟅AY 9]]F@Xpd{rn71Q_y˄Ǒ~jE_;.E&m#c_lG-ZV,a79XܶF7y\tXISp3E?w2K j9fݛQn%(h:D;tsX7l2r<14Cm 1Z«JW  OTzyŝ?%a2# "16ӮLJKE@=Nxb h:;CE177UߛR))#M[-qJϕKXEzs @덦~&D2R@:FT} dPfj!}X,gm`ΜV~hLö~f|Tx;*?J!y?Vy3^0Be]nKӹXz%l@YLqVbؚugq6:n{o8[k KeŎNVҩsGϣ5sr򸷇5t5OmtOsMAщHӖR)"ǧSradE7Z2ًTM\ s?}˿_QrdMa?OU}[qm5?9t/M~zנä}_P-6B5$#7C1 M6e=qWUʂӹ>%òZތP_$<ɰzE^oԬJһFRAn=O%:;xe>+}?:f&@Tֻ ⚹j^FJx WXQxvB|RޜxFa<n2S4NFYLg|@|kut"^TKl{4BOW-g#Vl<]mGρ]U5Y[qN4qd$?kQէ~!֓:qjcM'2 TNBֹ2m2jPѵO5tnױYp&{ ?%aPR\׹̇ө9~ 1}Л\scp@3DYfN?N+[♫|USNPGbf&ș n5>|Te_QP.eYu:z~^J _NA=$/UBqsj7"]nq^8eL(MhrtjpCq <*$tH4':o"^ڬ4]d#]j6S$4aMNyݶgf,mަ99l,C#4(WF/V\W{|YJ ) +&V):t\ xq>mWBÞj>}I bL5IRT (,3|kK[vABg@z/J^A#nhDw!bޱ `;Tzc)pd % -_A7ŽK32(V'05 O溑ϋRfE$.9\ +eͰ(.d)}Jn۠\V נfhzvE9Beem~3yQJgH*SUjg  ESV zk5ͮxcw,4VpAJ)mp.܁ Qʗ3&cҪ/(RFľ?.ё47v̧o3Dʽ6uK E {oK\UNGśDq<@篌^z+MΘF* |h#q-͂zMyXv21cf{VS{fLh Na5=G%j潌 ZJ4VT#[{qeV*>Dڜ.E-*+Aգ1g3ޜ0Ӗ1馺wq߭_Gp~\܇?D#re?Q3xV\ob95ZNze I/0ʹˍvOۛv-ڿYgE EPQDe4w^ݵrPduGD6$hiI&r!i]t%$a 7vGqК,I- @+8#kBܹC3)=9CȝJB"4I]uk6V5apK/qmGVp:(uǣ{?fn`sa hHp?ygPdeTJ0;~8G0{iUblnp{ \%s&8q5'-iSg.j܀܋W]BO+ a"ULXFmlK@/M6[ӏ.`v/w'϶{nvyDw_ou3)$Kxso,ҭf}q.&)[|'0|O<=9cIo{a-(5DN!!uI۬hŝd-?T e/^f|e joG)dW&WFtl^ Sf9' q4y/!+PmP/B6uBNpƝo[qSxIOXv"5X/I>\ dpIlZËx8i)S|(UO%Zm:ukn㳙-" k{Rp[ouXE(>\&9Q^VL8:NWu:cZASڤkW+W|P|xM{mD=Ė"83) ʲO;-V/C ^㎰HNjnxUa17c?: qH% JE; aJtpX:NJ5[էuRq"(dl^vYdw <)[ qx}8ljq@iNP-o~HҦf{,^|8jWO=͂|m+|-=JiM?u?Nt?5?EkTз}^U֢zn(xq5Lܟ*[UkbO0n:vvw01!(OxÎ`!C7(x2|Qd{XU[/&{R>iḾEnw :Hj`K ϊ%laMkbM/7gPv{^0ԛ&%Z iaA)pMQ%VU>wsksW:ɸ gj7,^-@%WftSBB)ZF^ovl痒ٸh-)}iG',?e}zC=ڌzBObP~(P0Erڻ^8$eWL qy5`t`6hH\b)ZvnM v^!tHSx x;*vK gʽQ%ܚWxVoTwn[?<Í@hI6k0&4zs징/9τ UQ%XwSmX¾BM_3d4v^z99uZr\6ДЯtBNj.+%θ F}Zz+GKwZ@p)Or'^ڻH9׮gSY.'?@\i(~}zAUw)\-ZB_m}W46!x'>gQCgo7gWŽj׳/t<#:?@$u@ik߿JqKn&##U_HjvҏmOzˑ_,gn߯r7fk^Oy)iژ}O%?Un)=@!B.P?`nW1A$sm#V|U gܾ7sJX8r`ղnNpټOS~<Q1ZeE߭PaE6 KpeUTDy}oޫ?Q=PSa}ZT}UmyYȸH;ؽ6s0*c=H^{ y-tVLM `Exx@aKˮņwWyzv"jM}gd|69B4Za5C^gl'`P hsUg`9"?G9Z'e P$U8FrxV6^[xheA﮿ݨ=+L2|B~ϖO^;%i͚ /d[vdٟ'x]r?YagV>= հ{2ͽ,ϞϓK'z$N>L|ά 7N+u]OY=ՙ cU#ngRG Qh[mO#Px@mN6W`rr}'{ո#F^;骍l梨],:R rlVZW2 qz)@Տ _zK/Ȉ,KAɮ]M^3x~*D~l6qR%V%,t}~ lVl/?/ODi~B 4Ζm?}?l#ʙ m$k:H]ﺼ L=g#;{긜\1HYvҹs"qIm'aZ ( )V7ʶp>ҩdda*kYЏSK/>,jZ,jI|-8-a1>n{^쥻3; :1`q_KP(Ph겝(><(V<:)[iUg(!>4fiHv͵[E+oݕ\1g͓ȴnMo X#9^|)wzH6cYYZ-SH{5`0hU"mvaqYuzٟF:3O۫:=T/"5`؋c@a n&zѩ^4^ASFs0}@5t{BB7ʴ=Q2RKTlj~uɨΣA|.3LYk9{>LbV+;K@<Ѧ+77_ܦe4{z@n@ 5ƑmU;'5բG2?,Dt}_?u<7Jh(*q(EݥOpR6{0xQkﲺƥj]-{ڂ=?5ݿW,_pxX5zO{3n:ox1so4a(npIQzVFnٹӻ<Kese}.`]>xxf 5`I}e5; 6 ɤ3EH h7>?iK g:Ҽnmpbł2`fԨT Xc sS,7h].+,m6PE4eOޱF:X2-'h:RɃK $֨KĹ--ԛ7mrj,^a/'~U#כ`/XV~YH}׶e > { 7IRzkGމ2ɗ u?Ns"b^D[7lxKɟ8;-8`l3Kg1-i~U2j\5XkLSQ/W(j7QP[>,=8tՏZj,9 fWa]?rƝ[yE㒋fCΈB Cٹk]6S.:FPSrأ$XӾKg&U93ʹd e yЖP헚YnXo3|3u^G_xoT\o>UXUS{]Uz%dob&Kpw@"w б`t? hy%Ds$j,uH= YM[<=.*3VczJ#6 Pq(GY=zUqE8G@ 69Te aޡ;/|E>?ҡa}8=Vb "e~Z@F:(i"W(_Hn =j{ d; ~UƦNO#E~"h[m><ֽyu)EpNq,L=\RO=H9Uria.B Sq4#\I?zy]prnnjժt[יkdՇve3q(De_߿]ћlA4=;drN MBSPH1Tỏ̂w>磪vlG3Euֱ'=| \_y9qy٨}!Tqh>wѡ8UC.%}-:FSW9dAO^k ?%[^ Ud!/)eFsڴ|K~w9mp(p( iV5?C&̷:7Ha<\X_=xEXjd` zNjMPQ ~ 掕,Vên,%fb:o'r{PPτ*m6U6~}:G_YfYzNggs8Yb;ތ x 2yOU73+{.oo|yM{xXwѯKΓlSYf]D[J[RW}Ҿ+^w@t y.m*Y;=֙)趉yMȻ-Ik[7hC>5Y eZK弼JCfEol/rE?e3=PX*5|G(]'߶BfڽfΩѲNI̷j`e-5WT[lf ٯzN>}Pyca=WE>XcIY1~0Cb/ x tԬR|Sium_U~pNĸ1Ƶ?TZ'r_V]%<~gPs)ZW'kUGr\hfԾ֛B8wp~g]Nv_1~12`8ɨXzsDDn&afU4*{Phk6wVq]60%:㩨-1y:ݶB8Ff{qQeFUGM:l}syP\7uPZE0Ѯܼ[زmìJPIGafq5鼈Y{'*{󸸔o]pA}!ZZx;~S|XlFx|oFϝvم}~/bo2F\5zI_j[6͹^[=Ԑ B2scu{I2I),dXQȬ'{\"-w/kގQI}LC+ũaR>2ȭ>9֕]'n'5M507|${ۛJq* GR<ݗƓ|XhK]l#JXk*E86 +ʳIP\>*WaM}xt F|(8ht-2,SXpr+\Lir76T@ U|"\)?ۛ=ȾȞ Xdfd~ d `w'ϳ^D7fz`nmC_Uo>Ih.2PuPe&BzC3%;W d_vY= UpoPT妰J#W׆E {$l}f50@3/mE*6=x)sPX"[5[Гi@~h:ZH|Z) :yT@β 7~Q(b/>]i;ތ8؄6ARLo,sC,C9jc r kC:6,dkd^]Xj%>stream g)L<-R<6h9xf*ӯĘّ,T݉G8 d 3h؋ov7BnLQa큁*<9.U_lۧ/+6bE_lRr=َruAג/@l1T:-bFi :SEXIs "HN_؍|Mn.TU^'žS]FN3dfFO67F'Γu^mVϸҔ/֊mR_kr8m ~33eU u.fliS.Cl# 2e[pY֥VKYo9Qի Y v~mgBR=^7>U=(RS?|(\WliKkSRoNnnNz @nȕYJ]iɻ5tϫJ|  ߁û^6ƿ⾦x֮4Щ*-dSgj!q(/˩]%6*FQ-/6ZgrL9yw/2sDTf?ҳ#WQ=v P\೔+ 6sA/Tkvp;Zቈ.pmnG",_\mByܪ[3˝W-YK_WL+lSXxMkkiw3/=1zš6K"(,FPu#YyA=vǖ{סQaRk䷧I ʃfI 7CnlKgw`de&Wz.hNa.0Dwn;.`7%@لkp}`{?\~*g臾 OLRi#&UIHAԴAzrVM23:HoLȜ3ycq[xy UR/+L^>8L\8%[j8^D|jYಧv=&׋*fG~Ҝ$xlIIh["Ǻؘ G;J텿HٹiGk3t.6H2?dfIL,캐cd?^`>ˮ?8RbPCom|& QeGyH Q+4FuwmY?}_`~?䭱`51,rʁȩ&gޛL:6IltҰX6%d𤋮#uYw7Sk6S\|  J<&]Ν~KsIoMy4钴Kލ׽蒺yA $}첩eʔ; ?:wQhgH7r|=u 0xQa-^ rSY\q$Gi,]]+*+T]نbZ _k9_ GJg?RSu8e{vg&n,#=U߅OP*R~]Ov$Uub{0ke=T&S!Wc݆nKK꫏mD[N*rXN0Ѯt=WҨTE]i/e<֎Π4(2R#s&:J,ofJP9%ޕUjQw_:}:xeHLA/%)*7ןJ08b`O9-Nټ`Lcl~w3Zkqu17Pd._k.7;mYo p7`L]%;'wBw {<{b:P/|pQ2A*L[{_FVT[8GfZc[P]?ų.2}Yxtbzl.YL->Z|V(i'@_K:~s鰚Fԙnq_^'}(&^mF-N4aT`ӮȽ&;CvJ70N_(ְc:$CzELg#QSOpo{ZaxM1M6lliW"_jιQǷ Vz0Hq,>$noeRlxigC"2lE),Q*JSh t~<^cYӊa8BkIV+[̑tNmVXc3W6Ys:welHtD/{#lZx훥'q"XrdXWdu5jU}\9VS7|C)18ij^WjTj8wS$!Cs Qk4`hDݼ/w,RogAb1RMvU]dj^+`엏ͻ<ϹEKVJ]iJPVR]Y47o2X=lݙ M'}C'^2+ |U`~DrQ#R5ej^"<`KK;-S8we0Zs~ qE=l-70w5`>vAF-LGsKږJ簩шWԩ|\e$D@f} d]wsi|ɧQ@kUL-es̈́?^Nf +Oa ISuzr dQٍG,@6U\̽2L?OZvʴ2\db򄲍},W_wOy@ w&A9Ee lgr*$ȕPbF}2!C\>AfOC_e&, 櫗'6va垎W ZJ57_r%T}6K)x${ Sn<.ݠ'x2 ž f٦ x} =7 sз_' d(r U\͢@յ]W@IB2#Ț Tm*5P* d yӄW[ G hCOvjBʞ( > cVPvX6b^*1{ Znmzhd[!= 1N2Wa ϰ6G_aiD5R=@^QW@άs[*KPƾCPPElPTI 5Xs5ΟIeKxHwDtKaOqy-n>u@{Kw&jxڹAiٽF`"QwA1@!vH0j$b@ Wmiz7t! E QuK_~ʾ# 1>Deyi,ґcIii&J{'ɏľn (m+A?`’ZqqGޕ~^C{5ms귙|}'grFQs n[9+ o'y:5Rj5>|]!eMYO:iIk(@ĸ>g8P,)(WPM'/IA𢡊w-}{GXGvz k U=}쟻x$PQi?tO&!i;KO|I>4׺4,'`Aq4zEF #-,W&jo=`@gB4@ k}R$ݭb\^uIǞ}=BsȅI3+.Wé;-8|YM끤Wh2,ƯVs5kU\)?'4*(M^MU,va{ywuipyCGU%|fkM)z5[W|v-x\xlܼ?fhjF j+tb @/@&REwwmr{9>~r(y뛜Cj2B ψ&HlLSϥ[N{=^A4۾- 'R;m@gRtqJx߭!َeU-htPcgaf=$Mלe a L@/fx31UbQK^ A_EFҗih&/%REv==fv 浓NC#dpɽZD_|_ha!)0ndڈTky|*Jb O :&Jb~K[=*k&qcaFѣ -=냪>3!o^iL Rӳisc.M7N0O"F$R\o N=D>J:ӡWDv3f'Vϫy1}93ʩ^*]Xukg1Laܫ 8>ǙO+7Չl2EBx?IF(1 {Pܫ(.ۦ6ZWj=>mׂY]x3xGfx(lg yڕ#.%+OFq|D2<͒]wޠVQ(W2۳w%;8EtG:ΜϮaXn%μcAX3W1 %gFMUhB?#O?Bѿ~@E"AiǠ2 ^IV 70ye&.u\s^G^0܅c}ărUm W4^s~˽P _1>|c03$#X"JJT g9%>NXQ9ҭRuj [m"MJ+<&tf@zٴ{ketMJb3R1fAu=u1"3 Q (~/=:?*=""+̣W1v1+WkE';L_Ob !o#xPeliW^`[gP.HI72i#3vE0`O$M+(̋3>\}=U7rAو_|ߥx*1BbdFl<(~b4pJ,kdkaZJ {>_\' q` J="(',}*fa`Î|{ԽgWgf}9݀;ȟz6XjQk;CZ9t f&&)Mtzր2IzH-/ob}+"fFec9H%+ +t#;Ƶf=ӛ҅h<ʱX53U.̹oqfgOWƳ"bI.z9{Oúd }3uٱFSc2|]>> jILWsg|\dbI#P|F#ǻ~o'SFL»fr]_:;zv$~cáڟHN7^kdp}"zQw Wi].E[h AF-5X0ь"Ea?B:dnQvG-7_kxmO68$ CgGz. Lę{vCvͬtE-eMwؑy- {O6-ҩ MN:Ou R!S֖?F][K/~'tMW^?qB]7>]&}:`yMO(\a4KMFDŮ*QZX-}lb+yPL7, fX(EcWa! nBqp7$ve"s BǫvVShXxk0eQwnp5쵁RP\VF^&#tJzW|yß0W^Atzvԉ^_nc%gtx9a tߌA#oFJ5,U#iowf)|7QrbM[T`߇` ~`(C$@?sh 40Zm8f9/M{ =!țU;:  NB-i azJp}db~FH`)ڻהpa˿y-JC4ܛH]`GTD*ip7|3 I3<şJOбMDk@z.^fHRG>{]/fR0G?:4)M'UDБ- އR<8=n({J"A E#E@$ʟhwSe:]z55lVr@.P--?댐T|[O `]Ŀ*;-eqst+N0xk4Kn|o ;o孖\o _D x(ڭ 5P;ՀW&5biIr" t&&ek\@8  \L@E^c8HsnnOP{-U԰u2l- I{>_k5a? "&L|jn}[tMöƜWbn [}2]]Zhr6/?o/eބ`}g{}i|ѯ+;RpɛmDivHQ9[&R*)H0]nOpUda ]i)|jn&_}!4d7G^kwU+jW\һϘӏmU*|l[ rS8l%,ݕff$Dl;vDʅM+ĵ+K(.*H Þ>6*ǍyIfDe&jF7=y~+?:fgZz.?''3 %xaORYofm oi ]jX/K_.N4ojiܔHNHtj(j<9,S >ಃas?_㰿ByZU(G65=7}\ɯY_Z j})rxoy|: έ 5:G/~i;.E#x PƓ#L< lkxkxTjOv&:m@# CjvdBsEb lح&^o ,}}={s3{j2?cS"56'PT8V_QbjOɅqU:T愻^z77?/?Wyeqφog@鹦њy_HְsROY꣖>@Od]PJL!h4׭󀖩cٚ;7,WEe/so dvodk,k0V *@Xf;mplN$- q"u+$UӞ-]Nueb_[Hg"i+q2 fZfO?s!]=w"'jY&+I۳&a1u.Jz JӟU;ϻ`#m?ozԣ=£u. ֛YsfwW ^o^nbu?)nV=6Q+K>LMOMu,񥖒4Q]aC ;'{8 2;^g pno4P}༽ԍ43uOJY=h|zF O/S`yimiTffQ}%2` ;:ew3`ٓ`/^{OP2,h}\ {9d^Bx2+7?KfWն"G Uc7n[9Gsir4Kb?di$Q՛B:| m3=W-xGUJո!Bwn5=tExϟx+@=;[ɇ>r/+ծ_6ΑqUM'7mms\]cʮIw.ؘ:)<ϒT 4m4AgJ2a3&/ZJww4;+IvGTI \;jv+l-+49mό6>zXCl'˨Qj(2ړ~Őy~A0*is;u},4Ş~sͫߎxJʣUǃ>i8ߌ5RhR56;ܽ:WD:N|Qj׿K7'ea VfU+U|;"h Wp.SJ錚V5qmyxv60n>`*}'Xa+5,RU܎+uhUdԠ_wOY?a&I,0?~g-IzjCMK!~LuNbQL%*δda׮JrYJ-&>j#yT/Zc.ķ Bb*z@Tp=){ȓtN2}3Pj.R:W+?u|[~TYe~)i\NGEK KYP}lbL˰QܝP8~Ȩ} 0j'ɒc ȋ;UͫĐg7/ BAP\U@# =9J0\ + wؙ6 dTۿ@^-\se 9? y&zMw}Mb0Q zPs$o ,H:(|@<_d ۽QS3 &IMCrN  `U0]$bDmn-(|n&(wD[P7\%8kQ9xVW"+'LH4Ϻ~vD=Й97Z>Y 'o/<$(҉@$ih5Ļ#[!ª^=<ԚC4d0:AڼCImy?t)1I;D| vOj&Re*gYNJ:ct4nfc+EZmU󺞉kx/ |o<%/~(\|oH*^K>lidP]y.G7RQxqyW _i3|.T8+3|i9-xyȱLJm+}5fL\{˳~ۯoH+9 )C'Dz@i]/|ޘ ?u2*Sy}{;D'G&R'?-=FD#ˇŽ8#;SIo3I"<6E d,bTz6l7=ío.u o/yq(5v8 3{7Dݎ/:j/UVI  H ҳ-;L/<,Ƨ뜌 'bЗ}W_sӳz_izf܃)88<`GuQ[kwt~-UdCeh ̊opJt2'0LZg@׊}F4s &-'\}y<ሀP`_^rN~rc۪mmk[)?QjɔG5T7[_Q<2).Bzj!ǒ+_M8Ukf}kg}.Ƨu0` َ>\zц jWk+DMRLבkjI`ȈNg}AۉKX|/Jǐ=MRx}l7.rCv0ݚZnsΫh:ܖE 2O4f]iJ4}‹A2H_]톯 opDs1ןը"/_=~*ȣtUq03^ P˼)0.rKT 3DO~V[Pa8_Ћíw=8JnCg%2Bb.\Swr +D}uce[r${ Ǚwc-t(C(h\GrtM`FozfH ۫QdwnO~jζy\n/;E`A}6v۪iP3rڗ$6i"LBQ[ѹujG28't/y I I_8;etNK1ޥq8#ZXNOj^Rq|pmc&wc e2uapw4迹'Pgq̀qz<8Yw_`UJGϺ}뭞[tPf?{L{nF3qTڊwŀU-.7"Sت<_|sKU:nhemvm[m5PO^ sl g9ZcCkOոL.$QuKrBRۡFZ{.qd$ݘx8ig"\ܻy"dUۗ#o;ie,siZRh*:/-U~w'G/7  U͢F``vsU_up`wkeB$UPy¥ ;R%[`6nsн}ѿJEn_{6Ȍ ]h ; Bg~qi X]~zx~eWQ,p<#|F18'C&/F&Cj@rۇ*\Н3U6Ke5f8[})vuYOxxcyah59nl~T`֭#jɔK7,c|b8CЦMcz|%gpkIô449M!!݈?S V7͛4OXtVW%\Vbz,^V>[n"kMI{Rt,3dH}B ǎK/&;+9vHGo6*Nf;፯YjajXHsR~2Mxf57EK唻c߇63P˜v7MU3LoЦ.ȹ Z=HǩӻC櫥}/m_- 4r9oT![VBr3wCK:boQ/U1 :C9Db6Ԇyt~#ˣ,R P X9uBM@$CAt9c >e|T+ V=˿EV rH;HADpȧ߼94:ߗHQ:8%>+P-h.w4< B;M F 0'w0ycR8KbYއb 3pt?@)f=i,ȷ1ȋR\73R kRD{ar_ rOj7Io,m+0:Mʟ҄˗LWW5?g0A!G"$Duak`,wN ~A>p* h'g;giV@tP_N`s^S17f'Z1"BuO?>"X>' ='gljs`;4XxA]8* ?'6oD{vSWu_k WmD g>=23~!S]< T=N$B\/ҰB,* C:l?>&[Tdv`KAak'! -Sh=@L=Zٌo;ގgy+e*itd v?{I$R^R^~آgxhͷt./qzW>,7~7ﱶGVxY y{è\^g-:~ ˭fLYlO~88þ ~MKI#Fz>1j +U>?ֳ ;'T7)Gn{ѽ(686>܉B7i/ 7{G>wUۇ}|/5-9z6B"Qzk'Weɴn#Zn{ܾ#w |x.K7w\:G.k|;`#/n0ǎ-G97m9;s,mng}WVy Q3)o+;47?61oۥŶs& ]כNHa`j/(y"~CROi}2:ez)RI_}|Irݲxprs_W][cpnV Br)rݯ2PhYMu0x4goso0wDr0'F1mjq{N7p(jakyB}Mn&?X hy^:E\kw}g5=3&21Wݜgl9B ɸ!,4m }gO779ln{8>boHg{0IWѨT?ԢKNlUv5C.抺mum2mݕ&yV,IP:2VHo8v k>s L \m Da4_񍦷}#k6XZ^*y{ޑCyVPsyOcZ1~FPCV*kL&BiIS+W^9^Mz $` OHjS sHI6<1-Uwsh̝$-Dqt9E.By3r:î  23xXzT뵕Ii8R-\OPLR-(VҩDĎudgmZnU2_ LU .5.8g6іu? vg,ˋϩI=k^vgiY   :l1--KwZTFl47SW|N^5l6*ZK ÿfHeϊqŮ?^7tFYf-@tڐsv-WlvQzprʼnU6(e.*6jRM.Q .iimj`@5=uC׽w+ǵ ,;Q*6H&6}9gu^Waf30 yۦ= ۆ,&6(6Jw+(CLP-[sNԚ8ۑx&$cjfӛ:u9nBn<U֑*-Qt|hyOl8nӝzgekZܖ5\5ܒX/)5"1n⽠w̯{j=c+TTop͂Q^t|$EDo/ ܟ(n]w:uPY26$W gmq&H{gk2vY [% k =!CyJx ]ѿ3ܹ:9/ϹtOY~d.J $A #H` 4M2ya2Kse`<~ f2 T1ANz1V(r˥Wu ח]_x>d| h[?K2Ditٺ xN~'ATfG1[< Gmqt (SK}h1 [xA̻`[5A.{)_$iԓ(apm l 9)&nFq{osm}{]OFJKĨ\)ƳIv_PH:Gr;d\Oy!YͿ?$ zHzIS2X-0\a-F@{{ɋ"zUV XH@ǛJwU#>s}'nCsW.(Ti97A} ܫPSi{c |> ysI$2ztoO.!C(\nܦRe=_kZŤ]$[>ŴjȱLxH>沮upzѼJ~3ePVۈ--dM,tgn4Q05w|vnI^/|uq}5~)pZuέ5l(M y6@C:o/\:S4xN1h3YhZ|wVzCd޺Ҿ0|<K7)&7:f-EyglV扄0r`";Z?Y$6n{kdpw9n/aZ/at]I)ry&(_Ec}ruQ&eS\LNՕ9)IcR2zLf`2sɱ4~:}ǑKʸ,Ry=A&>Jº_]?X~ig9\g#YN:wd]>؎>^x)Pt8=/KR9ݼՆi)eCh.?-ɘYM 4> |媫b]1%=sp9{(w59vQc)uFkD u='pS0 A ~KS.;~?{B-2j+GM4o@-B׹oEMҝuZܩFqaW #܀ky#eHvGjc`V$9膟wtqE8Ep䝎,3_F4iHjؒ@WNK=f>YscGϬx19hƒOp{2@!ϝdǃe& x ? n{k#$4!cXOLWԤnĖHVL)2+.xoE誽MAቡ1}/!781vh!k{ћuz0–x5[a3|}%1erF!5/~i=x VJy\g9Vgu4:%5'!zNZ3G[P?{`T o$`ǻXVD{WG1j3PbrZ9U JvƭҎ\vޛSoPqPFSFƦ)}d⺼lf6П nJN%OYjTpy\֨u6UH9 c{ f^e{rmO7eg;>FeeO֨l[+[5m<1륻g8_|'D¯^9qtqvǘU$UPnm8pKV߃>w6:;YĜIMә5voTiNfX85HN$7W' |2_$S>ˡΩ@n'5h38zَ{q7m7z|_z?#psZnklS:/Kdݚެ6CMs׈i A|gtb4l925*-7`uxo>3h:Q3r\N}WEu#M_\v,MovL#]W2bkZXv(0_S扴JB&)k4,A:#! VǼûZ7{gvLfn qϾz4zd*qۺ^jCz=?܎lp*uSBlތG#5$Snotk 'VZ?{u$xRι+U4իTOyqfJ:i%)KsCJ\Y ={]A?@/Xn=-NH ˄2j mڼB.wX-nZOS|/O;e|>+T^~5A)CŴ@rQwfFt'J~g)ϧ"d?8FYINqZ%3ijTK«T?SO_ը)))U2ggx=ET+; v23 S5F Q@kDkEdPbjyv%k ?'bkEi2X[YL.=v}>Ikl3aosv36w_h ;}eV\X%҄[jZ T~ D3/r}6K%u&+c@u ez hp +9%|mK3phޯ$=q,*S)wJ5(*C RֿIl`> KA&?'(1ϝ%-0"8' Zh %& ?z gko@'[5t:&*rEys`+YWyn q6 σ ˖@FTi 2 d&46/w$ixI~'x2۱Nu0<%h~l}0$: x%ts"k3픧ړPXvPoJҤJҔ UkLdH2% 28Wbr&ʽNf*c8kg7ߣ 'YFA煠()Q~MOv ?o`@Z2@v 0,55K  9a3,>wu %I/|#l暏 nn@7πrqƒK.̉i|[jbOnEpwiXqLU@P> :(+U5`u7B~ܽd<EU9q+KiQߵ2N.Rߛ&Om\=|Wu?}Y;Mk ,@ .P`ɦ,P[o3W7}ی3|ﶢ $ghdOUqE0wk {Ss^9em~Fe~Tk.~6;8դ~1IMZ QnSNC=ze?uR=E~BiV}n15Eն0g--uyzdWVtW+HK׷uwgXLvӮ2o֥|<}˽vѧ͕j_jcJdתb/j_L)I0եQ^}i9ae\hP)i,PPσl\KZn=Uד#{2kt}mwx?خVf^m=x=SgT^w-csyx@ Eժ(ϯXz7oځ dZO[[mW)H%.{4(N;PX@j=ޓ^r#ÉFboCYZ#p͘aq֙}RlDc?"t*k)_dVga#`a[ɛ`Y *Eu 9sK }Np;?puۿG+ d;ت0nV+gqdVS*Ƴ|/aɴ۱:Ik^ ~zHlnt/jx^DDQw춹џ;4?-!~ؿ]= @E/~fSfPI)_Pz//7;E}o.#dC6VWqHC+.GRlKz>tmz`+ȌbL"GmD'z.5{!k+DbPzo,_&o6͵3,Gڰ֪{'5@79w}#nP-W!#4PԵ2=o{-ȎB*#(/>lSq;KC/QYb{/njsGL;a}َ܆r.@,͕Y]$CO֬ʊY՘e!o@/ZN:|qeG`=!̼8Yリ;#hM.C1gKBL8/ 9~5(RyQ7VN;nK5,Uv^*n*3 UJVJSra2OusؼPƲ8ۏrDSІwltڍn'?M+3`َԲDtߕs[-9+eIW)l-IIϾVf6c,t6_9(ʃu5N}K`J&8[ mbyڶ&nYT jqJ*1/\"_|ݰHo\,UA0ᗴ=B]ΏlSJa30هrv_H{s[vZQF=Γ+c0 oR*4Y4mT+e}̯B^+寑g]Ζ~76?g70Tͫ1Hf3ofVBpê`uUvs~Ƙ8W[5=)^ݤfejŧ]U70ғa467gyZ2A8M J&My@ W ӕ?)nB} 'C^AcΡ1X1nmCSqϳw_b|)bSΦE~oѠVP`b(o( "7ዾ`>%dgd}tB/͝`no Ԩ$zdhaY;K7>A uЫ/b+9:: d5?F ¹tVBV|!N밌r\ |~Pl f%F v!_cjg:;SC(ѫgEtwΙ>nܯ'Sj»yKv_7  -eNZ䡏#t1#kʾ9yt=gwxY*[O*go[9d0Hp27F?P@ ȾocOV.4v+-'k_LTLB]mٴ³ P"^";{d :k]W/ '}*;$ ¾V-_']Cl@پ^ h#(⧈|I7W[&7~z5@$'uSM0AۻhKU[At@$YϯZM57UZmg`NAnFkΕBbl8*o*do>AI"T^5t9/MbKJ?Ÿ4ɧҢ:bjm:!|losEF>7ͥ76 0\u+>)26WLS*Z($%y|e{@GG*H2_s,ln_ӫ][f Ü5g0iTZ=6UF?F c,퐵"\ʫV<ʟSCڹ>* gϜ4ANH?d\: г.k<_Zճ{XT^tF[Mn裗&V,QFVHm*W1](]dcd_gYyXgsK:-8/:YIkV2n~t:w%_G;PtKk #c,[JZ1hUevD2҈6L)~&%nB9q˝fTeJoVLZ{<_/ut9Ίu̯V9UjhtOG.]Z,ۣzV,ז 9 99Gs#sh1ӿ~؉_G.~qs|2둱Xm0_O=nZUa"2k ~m.O-]D:\\?" *,Ns}NIϬA!uud0cB{L$MҟL޼v0+߭y䡏:쿫Ť#U%|UdB Q/s::U6q}>4i {Iإ;t=];h}eWZޖGvP Gi#efwfRO)Td6N)(w1lo-? fuj Qz_Gz'G}ڽ{'cNK˰ƴ'{E ~ Uesi N>7'n #N 'rqz9Tf9GH;6|JvHQ9 -C͇ 8*oK*׃t y7璶J3|`c8䡗c4 4fӉ0g,;^&ҧֲd܀/ъ C8m`j1_97$WB1~xlxr>9yb+ߚzqLKњ{g!:vy`\sѪMkP5gdďڅ;v@uvhʰ;iCNQJّs[MoNmOdvtQ_^^Zi(p&/Lg2ZnѫÈ:OU;gPtgC7mWڮW2ϫM_L@/›oS{s_˂)J׋o*6Z 09Y%Uc8ߦh,?r%\JQdP!2}՝f8%|ot:{3%e!;C,CkÔj}e~;8,qCAUhHr-is>ߗlw+^ƚyB [IDn jBz޼EviKe;:߳ۦ^翘|eqD/Nivy ȤJAjbj!r&Ჹ'Nsߴ=Zv4z7=-nqy{p۵+BG- auk,aѨEd |W]4FF(6MdP`QLyihC/}>n+9^:h`ۆ5kkͽZ9iyjf#u1qQ >7>9ؓ^&$>S7{M_)bfvy[-wh,eG`}3S&ڕ);j5g9$="N$&5jGbծjW,NZ}kߝ81eUc,ޕP,WW9} k%?{)?Үz]UM"^7¶ISƛiyĸMUܲB P7yRכkCB\Jm eսE> 8*/_ _ DtVb+A%o%;>N>!e}Z/?Zf@.MBR$, W=BZKZY1Suk^|L*Ռ"juuo8!:grBw|b{)1;XE=Νdޑ?׋F7AR!iS/Wq Z—CIi:ߣu܈f4]\yǦ0^zt-rwD(D+8_J~[/- qaJdb@e| B\.V$8X&7skeg͡=* 6*;fpGBnVVsؤWn#$a02%q sԸv^ WJziI價'FC vڙfr)5h"$Pl($\&+vtKℾ R5* vxOi-ٚv??3E?/vNlQRizˍ?"HC:ZK| "a|  uw{; 4t{5'q?˟ d㯴^܋%r޼sL>XK zºpş;Ʋ&$9)vvI -=E !DgcQeU?ɐu7P҂kܔ; 5 /fbRX?g#x*0؈G(ON+>t/uȗeocH_tr$`nw"n^f~8LB7hh'MKә<~pK}@KN;j͞nWw|^Shԭs,>~zsRԍ'1&wqx|85K־weHJk%ɉ>Ȍ  ~ND5ןƪxs{kSLI ;7L]+R+KJOq˙P: ?pw.+AgA4  ?]/$DGDyj^{_/L:v=OK5Nh*F͎#~(Ǿr(Xs? wZwwSv D.Xܗ49^֌lҋq=fs|$Q^0 L4#_Q/A%}1ƞ]&_8^Ff& k=eoF675Z3mzqprә x;:?˚Su[n8r6{#*dVRF?C,H9r<~~/=q|Zp.2\>xG{kCMzmfjĹeئ n-ێRs\:V,O HzWW)e^ky3^aXuۛɝ7[rO>fphxv|g݇?offNضVUF}-1+*QD*`3tv<嘫f WQhi0:>5>k>|fډ 6zĺ˶m0U/2l 㑺NKm&d#/_XMMJ7ziV =(bD bQz섧5Bf Ne]o!VӃ6>U0H1lkkg7]o*r:5e2ﬔ.+8qM~"*ݺ|8mۡڈVdkq y7+wKGhّb/Skֳu͛Y2W(=ْ;1Q[`LJ"Y;gB1?$)Q`?d'\>>3wde};@/ ZΦ.Ѳ:RpfꢳoZ\pEru+Icxmd)ҲiqZc<ɑ;sj?EWNz w2&;!{/ωp~{iĊRo| 8tޖ}W.K {[ȳ,V1fA:my詉K.$bcC>ʻW_|5Kqtz?=Yy tÚRHZxr 1;l־?';J:'׃XS Z4ƨ{UNd^>n}άlx0Nf|_6pݩV=kureEqYzL۩qa)ZJDE|߷9|IωE,o  ñj8G[zXab`}<6JXp%<6\DŰcE=\pӳշ34󻇠xbNl()vߞnb3Nz[ZR0-tds 8w-6_ FUSh?h}VLaA8U`燤FۏKoR|MB"?Q{1P AIytM?`:h\MƔTAF$OEd $5GeP C8_Һt8Euh'tM[t~g'j:Tn^Cg9#>n TgŽoXct?}-3(ibQTgd|&x#FJH͢~w& ~ui7Mqvfc,(Rq/D`ԝFUhʘs|oKNէzdi5 rAZRf\aR{ytXͮp;:K 1,\8,nk8-Eo|>^f-[^Ŧ *v[hR'otֻ\e^V`wt+fNv1\YQʰ&R-9"`#_&tXtD^9zeM yMdk: MmBRTxvy^AMsڠ³wq$}J˒[ v'4ro%;} y%օ=<$2$:`3ab}s)kuk.y7_,['e]uI2ʜ'JT0(1]|`8 V (Nc8/x0Y? >۟>/Dw>_ח}>V꾳=MиxJeo̸Dmpn7 ~)?v}voma?7(V T֯C :`yT u.vjP;VWtjYhJ,skCa_R{ydM3Ħ:峆|dvh}\Рau]׸n:tĆ:v,9+͕8-5ێFuTTsRe\h& MjQV +|WrѨRn?X![V˥)ئvK)F[bB}]jX?lèMV(j T 4vAFٞJa5H)w5 ,JEذp8&B;>{[DN&VqQ mfd'+3(q8r hvܰZ~NO&oe=K훹-6{0!;lrdB3"#h6cX'#_3 g_-7'}fVjW| Q ǵO㼾vn9;? `X?uGqDX^b3b `SXm2ɇh|0y y7SN$I%HR ')2j yd'f$%I c亖$)A2T&q ;%8h*HuoQ5#G}G3#e:,TP^%KޖtK.[f.hYqidqk!I/[f=ƥK絜/ϕNJ(X蹹|Br] rƯ?~DdsVYT8gGJ>F)yvA2l'u.O"?\l(Q¾>j2ӹ2rojѭc˝5f1=>$H4JF[@Zf:}m|b5J>bL)ȿJP"?9we^m2'ˇ|^< JbaP<'/a~8[Jξ[ r#0 uBM?>4| zBIɇSрy2;Y({2/hSj;S=| lv!Az o6m :nv)"f11\?dm׿kϽAfC]97Y KqNQy\ }.Q~7iyitKt! 崒7PVf_ź2g2\Ⱥ['l[^ޜ|Ǟ;mdfQ4iy$]&&(>nٖMbsiDޠ|X7k,W<wK%rk.-=vpmt?B%mq(?s7e2Iw=;|N3d[vR\EԛmQ-t7 ьcZS^Se[y[듚Yl4,D#,h~fr8^iw.5zpL$7rvܹtqV*SG"RW~g6.#8S5ǰv0}[eg>֜w#幊A+D,S[rv?d ^U)k&]N5|:Y\xx+(jj\.O6HOjN̩SUNH~P:/DPۨ=1/?҂]rb4_ACYcWW_$wG>:?=GﯱM37Ebk FUA2^^'P9݊M 71u(ؔK8޸EF")L1ߪϾ^LϝlMfqX%Qyc||{.`\5(tй^6L7aylq:I۬efc۝[_LVQ5뼈z6,: 7anG3U6M搽qZoKϖcv*`cJ"F룰jw?|u(sia5 g\G [w2b{؍Q5_ӫzvDߺ^wo)^aY}j5T'%UzKX!T\BX5̖UokTa/zca~z x@˃Jח_ZJ?bFLݭs'q9h6Q xA֚Dn"Vs.dZ*>3k!sekê/8/sVUzv!Ի.P &2|E҆q%iӀH3\VrP\`BXJ;Oz60ՑS^=P~_Ċ11:';uotyC~~xRpPL"h<Ć3^찲_? .Q+} ]VV[:hTU'KH aGqo"J{X2eN=Q ;/ĸ!ZHq@.; o&uE4./ .UYηe{hQ6nRC[<]5IJq!8dWŽCfσA;pN-%UhY)!/+B?f ]L 2Gzj۝F IحY4p;|;hxv,bZ j>|Zw^^(CFρ:ؑlHk5SkV Ǽ^(8 nAX[j9}.oŊ1SAmjt98 ]*\{J +qhM;79׹?,FK0Fj:H$W !t!yo?l}҄?MT3/b{+'tNin &ڷ*zfw{r`aM:5x]2q\m?Ȳ>K]w̸%nj7} CNokػ:%8rn-EVq>S\^D\#lԆ+f'Oޜğz5kkEVW{y[z'rCKL$5}Uow,OrƎЁ"kVPW_[L%YֺWN(W;*+Dʟfˋq.SI`Fv4։Ҩ Qn"a3}ؗ#^+= 2PO= $]]lZ%+mg >KUlDAvbEA*3@Tؽaa yGDwMuczkٗ{p/VT:WYu8K<9WՋ'z|+'T۩!osW;#$YyYp~_:\~>ןQz_*D~~Լ }/ zu?vEe^[\*%~.mEJ!uPV!M!hHL>"䬓e)<<$ﹿ^c3U$\8lߛ/Jl4b mYO ȑΓh|p1ɻ!#:Xpu५i1j-ZBo-n}Z}ͯb#m#L' ~mWfe\f|z.s!No:T.Qjh[ %T0nAaЈW{} g3REof:juӡ)6?d14'fIM.>F1:xmvv;]ͪ.㠹[>8/.{Mma7丹޼&ܷb\Q"Q;CY)z+F}2;m>Q>=a[)7t;];%(>Ft.ddЁCW%gn/eVɟNwQ\p8;%Qsε1LAlz(iG$\Xvwkyھn"~gh8Ϫ4VTDLum?$ZEpopYK;.Ŝp&Mp_F5!\LZ3wvPH6w) e;)o FT;I^|4~WneXցY3;鵘ԧS)9l4'SSN\|zDpۃTpA[5mtVIĤjxUJC9h!DzTH(C)//>p%ZQvwtAG ^r^K ̠JnqXkN?m|FcV߬N;@[$zI^Mf/*i>{5Ԟ,=ߖ+ճQiS|75JtOpܡ{8Ey r]gM1n|sh_㖅cnj vdcIt5T&w$_AEخk~񐰨9y.n E]k\\a\ྰ\ldN8#FSg3X$;N:h~w1X5󻫗76v/zdsg=u?lR\(~JQ$ !iN(5}n,1\Hj~}Yv*eW]>qqEݏy>1-^\ܴ{y^u#p9oNय6)_7hQJ Ic} |}BMBoymru-g8D1L1K~|7[>Gp8%. 曳򘳦YlZ/mluQBDItfG #Qy^2MnQ1WB9XGP9.2$ФTJ}A%WJ%}Jn04 ùv yAܙauwjCö8cYUwSaatú2AO آBb݀#MضѶGcdnԅuJ[EhOz@Rv0' θWBq/ZTJF#fG٩,LB:*J>ȷ~"# e*Mm= 4锅^&34gth))) ኞl `PпXo˛5q" MfKٸLQ5sj9N`4Y~/ uN ;?QX44j\kE|IDr]||xNx9T2!{9ǿ vpG偍ra/-ͽw?N?k>j3t9K8Q$U!ϖ87~%Myzg[Ԁ z44]}2/4yK"{huD݊E,yqۈ֬z]Evs8#4jrU#`6wb}Yd^",Ƣ' Xt{BTETt2UQ0͍o-=r}͆+O}w;f$1޺$= {[|20诣ʢ暛^ KP xfv'۝`Owq:Iuc96DgS:s]m&.;N;uwè>!7s`$5BJp4#Aýycz۳/^>ϹuKǴ-nO!F納>Ik68mE>^y5\ N&=&^oո;3J<[k.kPNJ~俻v;k6:eHZ PwehXkYnƤrOCk8>ul֐nX*o4Vz7VBT3jn E D>G _7L~޽i!E;kp>$myFEzF+ON lL%?ipnͶ.+z]k5S!j-ӚTϰt[jh{;[my \εܪƎj~gJv"=L. ~Q|ye3 m{V:NojHeOQ޺U=;]lg_࿏2w{⧤dOc07jvƇqK6a -?V,lGϹm8A䧭R'kXrfҴ˵@˝1c^:z5fI&aQ''*TPVUZM}zg~斷%9_ũ[6-րxM~s3 :El-]}]OxUYK9xLAi' YTi`0zHխ&M,*7Z}wHFgtt31z~ORKEc'UqQ7e=2۱ȱ$橄.-!8.N@ݳP~5ZKĽJD;]'> gzmmnq|f;M=o 9NBF[qlY"=_!av.u/xίP&8'pՠڞvlwB_?)o/{q;Wu].%6Ѻox rlD&Q6UP&=.m(;_sx@IWHF[*MwuMr5E:I٦VGc:Vd.b 0Fxw\^fJ;u3%`}e7'|WR)^ecy}uZ۴)z'$,+Fmsrԭ!O_Kz4ңy{7zN^KnMQ;^vq2N*]Z4u.5'>quGgdȌ&2ƍ<+P (ܪvHrI2\ɼ`GJZo|qi%x+|x/]1-ᒓ44|rhb0әFN#<_Sn'4=VE:H'3sۍy(V(WpHc`Õ8-B~!Î ڱQCQRQ}RJ@'ڻCG?|P՚N{p\0UU~(ۧ-dS]sO46ݩ&BD~}n0QS!>H;D ;b˗j2hG|VG`\"ܬ@. ;ߵ^ׄs3J,bɍϿ⎌"'/4Jސ =O<j}l"8z'-3V|a0x7 k8 wi߀GQ7@U4\'x.Iy})s^$z- TkðiEHew*v{6Pz@JPI4@%qƀ|~?O}VBXB +jo.͖֮kzY/bx={&_9j/\qW K[Lt)2jumAG{+ ,ڿEAD P9gQAgZsQbS]tuq9۱:kzlJG9<͠iF3QBn<> c9ʱ;_ɝPK1{bNU?1UMhM-MYPrǓim% qOrН s笵RHe1{4Δ( [ j=\W\0 ?.wuWinwԳZ-|vf'zL+9SWÍЌaթ1R|s3!kM2xjB DqYRų݊So,PB [VF2.LcQ5[az&n;XƗ|bןǽ>nferf_ }nh춊Fɺd|cJm +@EeErC{_)POG`!;POª:°;jn?{35R(T]hqLRR0[[!-|*=z>qU]V}%b-C{!~CT jPBTRRym+uΙ^!8ZdGRh mf;gيC=\бSǼyCb"ڮЅ;?avl̲EB605]1C53Upf}v^{v9:L>NQne GIpުh sӇn1).-_^ q;ͧz;@pX[r ^ \nB XR7+?bʵ+VCa와QxY<({6[+%~\zO ĜQ@sK N;|H\H+4ց4yyހtt@Β-s3㥬O+F sLk{Ƴ/6tan9zP8OG^67[ S ;9_@: E P (=KЁ2@9Ο)>O N+X $Qrr bMMX_RR?i}N:OmiI<}zw8vIK_K3 K|w?!yΩ'Sc/Rj"gRM,NR_Nj+"s_tGCVٲuúr'rTkKs;7^r׏1Q9KSrihPNj~OJ n$/(TͻZ?}?3NMOm)g9f7\'_ O#<({ftt$DcsTvFvww4aHMȆX9f]]ӛV?|/[ir:\`]75u~5:B37YNbZ>#|9@Mno\6n/Z[l8`64=h5vq.#\piId>z"i-l6 =u5bXI͞[[#0 1k:{S >k&WX/@--^9Y.hW0K?6Cwu;Blw{1U@i{US{].y'JK/\mFp=P9֧vV] =[k/m#^벿͆nfit>`&6az\DytF፳V@;KǙ)E'f?+?zOXRK+S~-':<䨭0t* qbM4ï׼h`+@S3*l7Xxp1W$jqjwy5|R8@.X.旎yc\Zw!yLt}4z_`EArTcnVwy~Y\Â|}ӶVǼvjh\XDsܗnl\5Ұ]j^Tp!5+z csxO8nHA}}(T89(Ѿ5-1}_]|0¦~7f֫oM݅2`hߖ6yj5CR,<@v]mN rx" =5'nsn}VB9 pBR@46@RwIm7Ӧ->mG:Yոw &ʢƀ.٭~ٹ_/YpVTN۴kjd5d<נrn{jKiq+6GRx|4{5Wg묵RQ`zM}*EFٙU+\wr)o/Vmv<" xfk!yp>Nߚx|2F܎^H4'A^`IkE5~VF^^//ע]u1+lҝ-sֆ,wSo^d\\V#, ~#GLItwj͡^86 AcY[6KfoZ^<)q3C[ęɃiW3om٪܏5ʱ4g.zGR*6GO'L)I뀏_kLk5Y ozO﹃>N¶󭫓o]ڐ 702μᣯ7y^ՅֶYyaEY;mmZy]Rlt-SL׾X-2YqgR=m pִ%Sw3I˙&{՘綷biAwX y]B(ajàhӼ_߅r?yEU[ʪCVs |O~j65sKV~Rtt[tEwl\vM[Bϯ2nӑVi^V59sU V *U6@-*%ݤ2L94ko;$< -Vx;mၩ;M"/~sؑ*^ـVՃbX^Y, zbwTy ze̯X]͙Z8ʦq(%,$OBDHE£k×s ͗aɫ:Ed8=j9vXcxڟ|޶m1%R֍&IsY.эV;ٮD͕Lys|Cɝ0A^ 2_:75 ;k;n&ݸ\|ärG&ͨ=j˿A:GUY5]εơaح#sQV. ?vf.d98Y,9,4[=. %#v.ܡޜ S<9BG:eKS֔)m[z__:^4yC #*GvN+0>2>>x^< _dCu".tDb'(=vfXk:d7_vyO=Q|P4YjRnN=:bv~,+X~r`X3ayvO|e<:pg3{:@ݺa#Gx ?p-nj]H SqST":%xbwQ\GnIR{B {qV١L~/c?Bm~]C7ZYlMu'g F(4䡤S$t4tFQKNi–ߤ /ꂪg`" Y{e 0j|:aUՑG"H/iDV:Iѽ^{CPҪv,= [(d(i_##nl˯QGã!i yw/n 85`fe) 0;3|{X9{YX0`%0OayڝoF`qx W\y!K{4"̾QZtpp|+/]+]>bP=֕J W!HavLǀCQwNy' \ټNC eUt8aȆqȞRk&9%FI^[FUk#^\pxUAxn)so/  .񁀙c {#mR6@ n;̾f5ee~GB= GвmD ~΁9"KS-@{;MapLH@4j7 vou H$>4nd>@:9N@C~~oW{7IM 퀣n^s\73l6~JеVU[eg@ - TzSv[r@!'(r).S4S$Jk?ɧbJ ??c&QfK9o'{x8ZwD5o4a/iwMD =signNwW"L~H^55eLOѪxRS$f w8nkfBݰ[*{PD]GR=Wvf)?;'q>;|~=ܫi;=mڬ{e*kb1&e}~ 06nͽW^/Nm(NbǤjq1 ߣSƑƵt8] 6ZMFUQ~ZXFԇ_BND46Ϗ~nP{KS#cW<vFΛ ӻF'YӋ.X ܊LR# H+v\Ӆ\NOLq'k{-H^;M^⨐ڛ{eR:*~VyEcCꚞ[CKEL&rQ͡V?̦v6u2D2E'ăx1Y(&{Ks8ug#/$o ko{1?MxndVms_J쉚0ςMys4d}9'%hLʏFWZ{8{sLƅXݽb쾎b~,8GjMG'C^ΒCT쭪nrʹ ŀg5VJc }􈕞Ұ3TO(c zӢb=,N) VS͒幗wQѷn-C9;:[ Rے-Vwn5+e Gh73^d$yd=g;y?@)|X?hߠ?Do>Fh5*rV7{.Q2_uFZy -z6nLѳaq^IlPx->Lk774 …D@FѺ=_)a nӺM5LnM=j]9Z|fϭH:Y՝x U;̯ 7\}J̾[P]=>inJ?7(P9J~'DEypMƬ$e֗\/`u=Mb,S OP Hdx;gjֹ.*MqSkW[>=q};x\57YVGР*^tx:[6YO'Jf 7,8 e_2yZ+oh|Vx8o]l#Bo^}W49lG/Yи{xiF af*ыv+Ҡiv;P@}Ǵ7NqV:_KvcSU[p̉yܱze֏c`;.gNۻv5(Oh0-`^C:Jq7_*e|դC2,'H!f#QƼȶ#Enf0ü{_ cmIwuumA҆m2W6χHEPYN ŭ=#WooરԈzDKWYl IWvOw?B` Bh}A8-SA6?L]BriѨV|Q]*K4Q>ԛ ƕa+SVUV(XwT6A8ZjgDlTVNU\؁(l%+ ńPmx_fJ1NcO;65YѭMl'JUFT?TԢJr`u İlOܬȋե!Z*0j64,9g~8ҬY6XO鼳qJ5~F*8vɎYު}T8Q̅o}Keҧ y9eQp8?*;qcvҩC EY֬ S pSZk"/Z1vy9RsP1jgo_t;q~yńZy$rR(!Q/ ;_n7h.M.WY}mZ‰'S0]HoP*UEu< 3y"y&yFC/(J.0+'XQg˛}k՝B5em[R3}T ǟ/3=/V^cʸf<۳ѡVc@dN=-fְU"dOPׯ<xۈʭԭt.AX^]~z//2s~ɾa\A 1$Iɚ"Ť,6Uu~B#" 1! On3wo\Bi4h񥊖qaa51SژɈ{ 3AcCt.22)kO9rV0'aW'RQ2+vkcEj:񨥙ɫ?rr5R/x-eѷ }R)bU_Ec f(\o;+U˗oh3to+~e6ƓwC?f&Ut vYi2 g]Y/-ZO.D{ࡍ:4e{̷)UTܳSΉow8A p1~l}23l?{yk^jڱcFT Y281SLE YnYc x^1y5Ÿ|8rRo)w!=ߕ:}f܄TWNlz߉ʺ :V|X9ϑ h 4& 3CQ _xk Ro A^C;sLJew~n^w^ V#:_*4TnS׌cjRYv~xN޸u'mk3&@2vbIy?ռ1RHa#(FRA yED$gH lS4\̍1aT{Lnrndؓ[W˨4-m4@s(63^|PnNEm!-sqcGz?H51 esA.2;7"%EL"',s ܙ`PA8uҝu$x9r2bLYO#z%6ei >Dz?#hUl?!h7Xϭww~T|>JIuf<8@lrt܈޺ҰM cDL׾B+U)Q==3?F }FwםG[eg,Ν`1 ҉NXo5-'ٖ]Smc}D4M٤X K%WWfZmgBi-ʑs_Q!]uw.uM<ڧm3ɭu]HKBxT[l]7M rr ==ŧxg}ߟ& }wگw8dѳ[ @N})̉l׺ /%@ *X7ߵ,ƄN 8>/t=ԃ@Xv+ /FCiI`}G~r?%.=X!|>6PIsev.}7ٮ؀ Qqj `#5F̻1bFvx0F1Fհ3s"oY~`:Pj}=I9 &tz9;f~swe>IpPx4wCU}Eϕ4MΘ ';"{i? n9{ǏEa ŖըPl3k|͠M:>yG5ͼxqt{Օӻ:Fjθ=نmY\cڮ"6wRMqZ&̍sơz'k|dZv(4Wx>ٻ}RMߕNuՌ:rtrdo[ܨ|>XŁ~I u vVy9H~\e+CQ6Q`݆b3u.f=ԮWnDj܉pdVIu ehԶ\[NLi.MiFy6ha9Pt7Pk%"4%Qs?4aZyT* ھ0Q/%_^˯(-iXZ@$KE6dF2oa53t :MKe׹~\[EEdwZfZ̛gC#ʔuISvgO,\QY,Y7Ŧ1R7 ʌw j;TkvcwV [\4Ǚ2lB^RNE@KVGSj(+fٔN"U*8ӓ,ޓU/>=/:7[ڋV$\{f ALU7gOx.Vd c0ˆu*R`Օ5Pd^-gs]5϶XYLfj[yct.r >t0iDNxoͭZQb]1T,}ݵOԨjzҔm/Y9?[֋Jò@q#-TPTe4Lp]&؁\Wn#iWOZr XڂkIk:,[/ƂPc_|89G~7޶ui’3g tAu&]ݴ lgռÔ(ȸiR}Lm1 ";:|5Bןт>}yZֹwg↢48ysZ*sw w [ޯLoYpEm|8vǍnYj]D)͌^^j<\<".ukU w OKDu"orÑl`'L5Zu2(a" cv#ż0b me'=eFV̪ Jg1xs&5p:'̐pϓEAt.x%jjG:9mw8kX:3 aVbJ֧lDcʍ@{ˏhdw5و;V  -yqN,LEz/ZUGчL67 y*ptY,5f1ٵ'Li/4t$ jHuvPp!c#GoJD\Ug#tgAi֭s?lʷVl3*{nt\lXĩBsBgIHt .UBR{'hPuf#O EtK2q'! I{fb fї6hB]a'^ڽ] j&⹢Z@ԜkOA..2GF}S"-:dIT0γP#7tYzנKiP)R؄9w1Va*a1}lecs,+jMFʷW?ĄѕY#uf;ˏ x?\1hJKZV.A&S3zHo*:Q7y%/R~ 2+`tS ~h|ٯΊ7,Q/!\ʋTKBv>Gٜe[ꍚ츿oשrٲkQ[ ˻@SW'}'3KBUF|?"uk +9Z?)HvZB,\wUhAMm5O}C`'__T @N/8 C(7j}8w\19iF(iK'xҧXq,Ra#d0RQ<{\je{e*Dt-R wTLa)u@>r' I䉈Sπ\u䨖=9F61EϚ%%K4uJE̡z!Gt0 I=?u_z-)._y O&e@+j h:S9f]mvsJ hA -~e^-ͅ`٨ctc1x%,aKBC&΢z+ ^sp LO;pedtd0m6̭L*"YZNqn6[&`kRRYV&0P-%tgK!{^޴RՀGʙfmC+[ '8Cv)o)r dc.H5m7[7WJ;+;ڀ{y| O[x蜍ܖ=FJΏj?nSTq-4 XYm^Cv#|3(C4foE>O g@p2' ,Y tDſQU( V>]dĖwYak#C!QX{9ޙƻTk_ٱuWXU95F-TT}%@Z үձ$ -O ܹ3_mYbbժw>C5wH7ݰ\+CKY.dH^LJs/U`w|Q9ϙu; 7gMOOCɧ?b?8xD:ϐfև+ 9=s3? S?dP1TSCȊDzs>`Ǜ7&IݐcIS,G;6s767n꺡%e7^BOIZRç[fuNqlN'f{c1X'?V|z;~$WuE{OR94fKE_iz;WF~ԗܿCT%ߗEO!/mɱ_vgBxRTؼX4y3::;11NEoPnG>W^k*A2{f hEe-ZmyNIůw)\f[>2rÇB;[fHIvG7̎,`rwaTr١L]4#oަt 5`W$ JDÿ۸5dOn~kaǵͬ\4sm8+ktk0a$&O %wƺ\{L'omE(8zE$$w{Yi-W-? ܅͗w/tknkή*NZ.᲏~{We8;jo.è;fZLХLqb޹MG8@v)[#'Mj ӹwּ[U97WR^/,wq9pwlvrLO2G)mb094_w;xog^݅'32n-mުnMsM8f14v P7={rc:=ar<ՆVPSXd5AYn빜먛UQ \m?9!TאׂaVmbPm4vA_;d˛s6[Gjo'w֔mLhk+fLTկc[ϽӭTĉBÖ |7LnZg)WZ~Ocn2sLT޳+b͖eԺܲuoNr@}|Ere.1f^\Yˣa,"hUĔҞ8WOs!gۨ75W_GdSm*p&\\.K\&ݬUԲ3wܷ|7] qE19VqGmrN,ϭ"QQ(/i/zSaF kQl)tEQ< @.6י.l2肍Ϊ$7 d WN؀j nim {%ĈdnS,Zz=Tz9Mӭ:?+w}~vhqv[ C"[S*~-0m wcK7X@~rmf ,{9I"Sd?d;O1xymm-9ko؝4ZI%| 5{O}VW&TωZ/nz>pyȶvy>&87G}70rﺵ]ī>;-ܲ" s ӳ&f̳j1b}%dܸ7k<ܖdqzusr+ rgݙCKcf}N zOpa]d1{<hT;9`Py?O8K?V?~nslN4hq{g;sn4;[&,1,>N30? endstream endobj 281 0 obj <>stream 곐4.d9 E,}o#Ej  f/@ow?{.@G&v ]=+Geym.C3fKiLbF>4NuG}ώ~n`Vf~F-ӷbn\2&1*Lr:I:CJs?My>K"?GO/1ڡYKRSӟ2H쉃2\mV[z&vy0󪓴ڷ~Җ?^d0W5{j ڮ cFӄYev0 &lsWo%s Œd/cAa+ $>Ŷo]Ԗw{*?Lbg㶼^[f?nAټi=Dpt,+zs2UR"]'xw[b IV"s uz\fz1++u~u#,o+oRjf@n"ިvܛqoiw8kTv=vRP,Z^e3VήZ;x8@5s]*bvڤ~]ݣ Ϛh-czZ֨/Ǡ޺aXU9 yG5kZ״ ׶3Aoϻ˪Uur7ݠN廭~HTOd{OQgNXbs9FoWKļr4sZMåhOȚᯝ2:W]j-UY\#F-Q:uU\U2wzLzI z'0eU _| `rP^i풣w5Ptv%+-6_M/M5c:R~=*!V+w3* 9ZB~ (M֕^9<4&e[ )UPn+*QR 6O[dPZLꛈ{MZ.TksjnÕ~bk} mXRty$T2:)5 el|}JᮭJR\BlN&+׃dBeҡ )60FC`Ӫ7#SZ~F{ǣ۩a{} {<;]VVe=נ<."unjq*TZs^B(wXIJK%s:Ŏ!E BϏTV^3'fahJk7QVSThlaƓqEf^x ]O+gNJY0P]ȍz;A"1N@ׂp?0 XA%tM~l2ܧ9Dl)q 4}*zzxغ8Kz¥ԌX{ZѢCeJΒd=J!?;J# so&|Xt2rܧ#JNp}eg}5aB,l1Fb`*d Ǫ[Y/ Vtg#1g6]V7]9rݹ$|1(-mx1Uj>Ǒp+v6 lvp@-8fyz=澧h6SWNWNÜڗjwFct,n(}g ּ^T%J8%?~Pxwƀޒ5wY3c?OAYz[6ߍej߸TK=z iȶ͐mbI_`}ÁIH"8mVK?tTuԨWjw)Odg|&'ܫ6?JOxN/[>a!Bʆ&kjuc@@!$KK$9{W.${-7>,h6M!GxiB|!83#:/Mm5NQf4ʤR G~OE1eb`SwDP88&uxEr,&,1֓?aD1,+VއwŽw \.t:mta(^tء, QHZ Rk mtszt~=iѯ$[.Rs8ŕf}Ue":+(X?e  Y b=svFEƦfη䀝;!ODV:Λ8T+Vs*Zړ)Y%[<2aPu,JlXFPSBPrKuĮ`b0.|1~+ZjNXo\)ŮX`-Q.݃.h6)%R;$CVMO|8hQk_vd&fy ֺ YXFJ::U/" !i)ąm&鿡v_=N᫐(%7K>_Ҭ\T)}ȽaO BL~`fzܙSXeq}I򖏈{|6ߒ-fA&[u).$~#ZMC+rLa?s({"\|+k j"2g`ݻbg,$s ӛL+݂|x < 2jSȾ@O|O)>PoD1åi/f.œTT+R+#"P7?zyZDmB”v=̏zC+^ z8ׇxϧ) @¹- HLqҖQ 5I1 ݑ H])~V/ϢHRo:MUt۬BPS8yL {4-54fOskk (l%@PC >ݍMq!9}"sхO>N8wcb륙]VJW 卽c()!uL}\#g.Iڢ4Nb@=G +kE|X:ЀK6``*W65>~O"oxIA;D!rcV  |pmz7^v~6Dž\!u"`!͕ ?:"I$~pnp39=֏k(_uP JtUи`q4oлg BÙ:sÑ䘿Z/vUW@6= INR@G CI=>H#@dX\&*)#l۬oc4a:aF WZHa~6OJ+?π˿RóQsj As7g2nNvMlﳙ~,C<^SIlĤlJѱ7#dvnZ_mXSׄ7Ge%mc8pWVخBꣅ2$Bdq3}OY#>ruգcmEqb0sD8" z<۰ιY(Z/_.6C%:k~k,"o̖RҚ N8:ZUlØ?@t~gPrè+ 7cL @3YYmjvn7f.y`:;yBÆ3_!-Lv[7?<ow^Tݴ^q~m_?tY=sYB`PZ5ciol`[Ve5 \Obؘp1!M=KB)Ѕ WNIhMB8j8աVU.{OȺW!W ]S~Ōv*wu)ot/xw+#Q ғEd;NŲtGs?:iW $LFSÍV:ۣ(7?9T\e)!~JXܶrLCWS 唐֒I37OZ Xvm>%ӡ7(qӻʕzh;V$ff=fQ.qkN=ZU xX 8^^i˯nWY ~ǫ@N< sRz3Nibpʽ+މbNl^UHhgsxW\|.ffoѣ|-LRV{!0M&qX;UvV8zimTrBʕkV-Ad7dzLf܂Z*0ʥRS%PHHch P Oa/ͼ2,@%=ފ2q;dp~OԜNlfjmlfm)>a `HIR8= &Ebϑ)e 8~~.UB+v}~!YQ0L$k>/Qw&\_NBmHbVY5+i5=fC/48.);K"B)e2*<^;* |@Җ@եy;̩."!? flйZEWo׎ ̡Fc@h9 uD)Ĥ"81)vE@@  |vDPhFU²R_* C5)v3̨3c8vfB0zq.>Nm|@irV/dk!By@>/@BPRJJ4 (K*'?@8{muGףF۸\oi~n,:@V'+g!O >,gkmEγXnakZPoV no'iv"t Ɗl̯n./͜TN9}qRelܿVwR8`0h҄ett8bu1> k#Dsx !}s~O.>^fܸӗO)TmbP"uf_MoPۿn읆&[~kA5E.(cu['ξ65wÐEHLu?aKumܳ"(b$h88z4utanoז>+WY|*^dwoR˻7l7>Xn9=qa`Vz};=ݸvSM cỉ;ڷ3l%)!?CUYy":SJ޻fV2Mtod>`rc9VKA=zSuWK_Os C6 ИvcttFނxbW\ы2Uu53[jWڠڄwiL \?֛>%v'{r&Bٚ _ϭڑo>1XK)dq'g 'wJȫ- ㆋ/ux~=(@ܡI$oƌ\kf?gaQBiEN`U:k\WUs])c3=-%WU\8Œa>o1-Ov7a*QWQyKq;I:ӝj`bEV*Tg6_5\/\z9ƴ\46!iB7~.*~иTX!kB`q5 v0MX>mUE iٽնM6oQX.Z=VS'g&bƖz {[Y1S3Xih=ҞHj,KS8SGcfvVnZ[=GѽVɫ'Ea4. $:g?qׂXS-G jklcOŎNsİqS ZwYhw/2yK)PRG7CU}R7K b*H^n,;ѩIJ&fK :J1yI %_HzCCH~6==åC#nm}rn*nٚ0i;7V3BiOJNKŽZCd=v%њ@dJp7_+_~c+ $[gɮn:_ꖡ~jΦfIU Z)CC{kK-s9`lɘL93^k9L40 KR,uɡɘRllߙQY֏_v֥Z/K[Sžg]yX5B^_3*>w{3`Q|菤Zar:h̳q8P0u5i1(FХk.jQ24VzW^B2Ĕd_<>J5:q4')؉W7׎ Rp$BGߢˡ?u0(Sݺv\W!^)XE) }d4{jY[2;xՙB͡q|>CgzYɶ?Uy֞!4S֗zj>&2F'4 fu)~FF%b&9X4ܽ 5g5t@| |m]bpg=,6_5odÝ-쐠Z4ʚ o06B;zm*fY3d钜-4)D'JNlY(w?US dyq}sJr-FV8>[؎5Z 2;zf;̷댙R>st\x;]qbdїNO@Z덮@,͕d2HnP(|Eܠ\˱R%{ۂO0?_zcw |ңH3bH^ O+Խ_9Gu#{>[&C]Uցo=ВXoPidޞ795?Ͱwg;gyh3gr7z,y Q p2eF.J*C.B@aCMAY{+wFZF}/kF++HOQvYN.Yc_c`^ɡRF%W' *x#?d$ c p4%XK{|a?CUV 'mO?zOL5T|7c댫ArgMa'%15#J> /yr*d>H{3#ؕu17qxS:ĚvJ&1gڎS$\Sᱴ.˽:܅Ԏ!lك}r @n@,A<6Jc"q^ڔؐJO}* 9ff<̰֖h_jo+JXdW nc #N{``j t+ .m@եIP AԐ՜L1_@;PUP~m[V3[t=+UjI+X5g^ %$"cVQsP.43ڋ"X".۽9+1AF57w@䅊b ,vqg18/~oj%!j OHj+#sq` v 0N00CqyMA3*K? tH"9ZL\X=- 0[cg$X@:`jE>C;BbyO, `zc;`cq]y8D~Dž %!p 칖řv .Ci4K>jfs$˺E`FHWƌ^BTQB|pP8ex7ۀ[|?ɭE(&xy7ࣦ W|\%x%O:~OJ0DbS4I]B: fSɒ&|jR_u?[Dž 7 D=@le"lcKbI6?' Z[R30yNE%hWfD>1!Q~[NY`i3KWi?Q]}4#q =|2 f WW+;]ȵRy}d|L$ fN. H.u?Ng&x{R7Wv(UGNs|9}G/շ zjŖYBnYfu>5:(6:4w@mPGkcbNIᝆYco!9k~[XΙc6]3OMϢt[pXU|x`<0o |BIFS W>Mixz )~ "ˋ4|mx8n"T˃!sԃXvw2㲲nCwѳ^EK4/j~`N%jΟ=WrrsK:L ݧ[(p ?kki7VD{Hv].^5~b>V:x6И4ľdW&Ux{ "D咍 ~ԅG\;2ʧq]7Re$T+Yr>Ef0ˢ֔A~ YeH=aWQFW'د<bz/t'{gxG6:o(ϰΥ{4 !x8||,0Qľîx ^c`ߏ܃~}Jwǎ= ]W[.V'͠v2F 2[ֺtaYP~,Kdd}tnF H>1Ӑ>ܟfzw[aOKΞQY{]3ng5"NM҂-.qoox @>SNj*bJ-k>V5JE%?H3%V<93Ϋv}"HJz{]ki9jp`4>vU-~65l~Te[ ^3tBxJJFҫإj6t*~xâ kW|3W*1K\ B;9E;NEWSbSq'&%o,(l(՟|JxS4XxM.^qrqr'H9`AA2퀼jg(듿?xx!x2zy2Xz2d.+b|bR|E}5 OcMJ̷qΪ O BmgnKv*~$=٨וgZȟQɣ1Gu,IeMR4*jg8[YkYB^g'.7E\V5|mO4+[O`ei_s`i1#`$.orBc /q({,w- ~'c7?/szBSќzjdKyc q,Ni{2l&R #k  o1 2Q^`دwW.w.Tŧ690zmp&\`vkӂ7I"U-> 9/+}Q;?IU%`I>Z  /Ns&`|EyH wܵ9YW 19Wpr%:(6YlI8H,ϛea kں,fbrD㩁H $RYPa}PTX+<~xU2Nurgyمh6Yyg |{zu.buPًڳ}:/aSY˵7jŪ'M [ZF*L0,RNjօEEkךm1SbOpN3o4DQ5jrK~R.&Rݤ&͂8|_n{>[mOr)nNٴCˁ eo \qsL{DnO~] c`yzxkۭth(Ԕ@wT\|QhkKsM7GSoL֌#R+ɒ#W9Kef;NM$ۃ61y6yMMR65ya9t77dB_iBYlO&%M49k+~P7Vu>Uqo) <,LA2>ovakȓhSUjJp1`H٩ChsD$`FO NoK'DF-@hqf=jlVeu3XI('+-j9l:&I$AhHp)Ro2 !KNд@l5AH$[':S0H @| ^2 ȐmSL_?Z7/?кȀSW1 qn*ά&4XYC,43<˅eȋIu ȫ6sɣ m EvVC]M*_ 5s: 錛TnO DEѩ X d ?3PfTUȀu@(P3~\Nmꐙ&uT N3j}:J^jM5-.I ViPj1j)Y̕j/|:E-` t"DL<Yo*CtTuSz<}~0uIB'_t~fn>|goЫ6#gߨHF_"BX-1YĮ“@+I}%$LU 0LLs̔j5˟{5)`.\J]-ƀu` XV,[5IΘ+]6' kE^8cϖ/x t]Ʌ}L*0G2>`;Vdnn4w~oO&&XgOa(y>`{\.95*/y8߽YV7dDRN%6 7sCa=[Sjv?/ћU_+c h=)>“Z5 蓊e4! oXkos?{V:ǭ+0<}gr2ho!s@XBٹÿIR7#rb ݬsBbg @>LvaK;YeWַH9r3}s֦]kiͬUC/[ 86h,p.ޮ̆/N _7bTC=dSy/_/ril}f{0OvO%C4KńQ3 qŬ@'z~}x\d>.йKh'[cz+u>^㒅˅*By2;kmjFs=˔L[?ru~a֏w>6@!l)JK}dfji sG]FPa3iD$pSky V-gPAR͑muua^|k'NHZ@ygݵl2?Ԩz oyR2,\ƭQFCZ{n77ݺ&:: J bkopk{bIȵ Xأ<4,5,8m\ܗ!2ljmXrPl>lZ^[J=F~ k&絤gPǡ=8 M>V;V܈V v]/E3bwW/W|M;.nSQ]FIFUZlZk|hM `eTu @'a'E ZY,ˤgͼֆq+sB:NkZxQjc@+ɬTz=Tge$I:#RTb*ɴ}4egSnbW;@iͩoC̡Iq2zdn!JӮdYP_qU".Bn׋;/W Nq6oU+tH&=(CQ:kЃ%~w]# A~A{gA%3\9*hqd-K:>C /.,b6ha}<+[X~c(GMs <@pLU}1FuG++ ܄Ĺsn yӟAzp%I:;xy7^;rN^>8A@Ž PJ^45M=/HpZT`uH7fWNeܖѳ.,Ip*1F~W0xww;v7'r]={C&r!<xN۟%:)f,&x$.Y ||Kd1w\K~vHۚv D,T-;?a5JIgؗ\d/w7j^ȣe  8";8@Djv @{# |%]ǜNNzQT՗?H!Gűܗq|NB3< B4ԒRS&@*Hwh%x̘5@> =fγ > BpI?4ngֺRlʸPJUBI`^ʼnMGpQZBMko9_8"o0͞llB( " S6i/5@ (+H@ ($h_@PJ%0' Wvk ̒ZQ1|c y4r$6(\\P^ZXbPPIfrFgCt7?}ϑz|.p@2Ny5w<}J]澍b4=o­PöV<ՂU|˒j\OiZC*i@Y&Xn<"P}"˒nޣk띴9Yq攩ޯ5iuy Z-fe]yw*˻& J ToVҠ[FķkuP&f!rRE,6񶫔k[Y~ WUrǕ ˻[iuP_D(=`_~0ujф1L ]aޣF?,EuC" &Sumic X4Za YvuE#M^k\ʗ7}X\P-̽gwݙ;'Z#s}UEnnnE~mKFOZb-@NPR55~i @h,ha5ֽoV`0ZYߍuu R68ŚL .jTjC}gntS\SS?& :Tv3jK{ f:Umi&MsƦTa M°.@$5|Cd,EP5*(M%t˝br:#y@#[K{]$O?CrE1W xv dVesN \gu@39G΂֔^z->֯(A)`Ƀ}DNm,@\(3$o {1yq51wBKj2e#q`U۷|/Z#k|&աRKsrx*֡r_$/MFS{%e[BV8$Z!w]f4޵rc ϩRR̽2`cf>*17mbOoIr}KJV1Yɵ1SV >Ӛ>r&_K(5YHhۋ r| w|=|y1 $ 0*.v棎3sVV ӮUL!zUMh3ȝ L # 6(f7dYӺٚ_o s'o |U嚜r bfxMJc̭ȭ"e7#}z41yTs/ ))=@W-@7(qMbnG/u˓KuZ^;kT-ܐ[[cupH,o稊A_`1tXLp1tf$SU B,̶sRwΙec^x Uimٿre׃ U?m :.Ð-%AU%b?;hv ; o}y,A8y0N)&ia`q8G m02SD-Q}YWFKu鿞{^rfyIMk[z 924^v MOCQ܀~P "] T?O䍬O ]*yrw4aʾU:Q7?X}3#&ewDm<π; trD{ba1LwqKpYDB@4&s@ _@, pE\}vjgCL'pI$B "aYrcrET c@3w Ȏ[d]2x.> OкmM]ldx "Q$bVY$ J0oszf93-2O-vmj_x-_jyeJAi.@/TDCt%ZԣYu+N8DM>|&R;jt-izb.MŭN<~8¹'q:m@loF~Df7+r?/tv`*$L5ΤÊ` 96:e3^TyM{)%^Y"i<+$Z>lf}no:5=W-6!`]6 p>AGSuu X7:D%>iH7iZA koС;8TXy7R} w\<*$ $Sj Ke8/An67vM ;ݺ.Z_NX.=G+r3ED<"8RXי쾑ޛS}V 2ۄo{,g>hL;V+wqv/Hw7T_UM'kmL1%7+OK* r}D:'tNpHCսw|d:D۷}-%U/#hܫ\$t-LLO:vYJ2}:o&v$ GӘFKq"5hSWŮk,܂ZџBđIӶqa.M4cK8O"l:&< Q|[^יqLdԫvgRW)EPd6Q%Yѕm{9Z0,FZ#=ldL|p B2:(^ rY=Dg|U)<L?߬j-a$ wc"yX~SFKIdE%"{lW_)͝Iux1Mec˯Ar*݋TM8kICop 1?Kȿkl|矴 ;iר V`{ZA@gM/\]C^~4p ڟk m#ދh-(j&d ?+5kA$>Nz],sn ڽ&IƎZ:>ATd RˣF `/^9iFjjN[gwi#F96LjVe],d6u.(k'~B^KQPΦjuLT;'|]c9#kr-fʈPȣu\+'Vf$AG!-&`Fv$fVeA+S`v*] I0&iR쮑er-K ߕR&^$=W,\Wl"y~r͋m]Ri,.BÓ|kZht1б?|5'%6y:Y¯ VjjSo2h%_`ޓQ~o/X=LI5ϝ[kBK|V=lm3e IZe ʤ1Ԛ/*Ȯ1]TjIApҒNJ,x/\y pgɧY.mZW(vฑӦtjn#oV(%x$8R 6O-j:LQ`MS2լ /XWx!޹{szjOͥ>nj \3E㧃CHTU`Zysa fkgtm~T$,Ǹ~XXyyq,C a/&MojgJΔV`@SD7L i8A9+. Ύ;<R =yL5 k G5dG I (qΛY ׯS= bO$)s̞vH`e]"N1׳Zf\=sڱ\ GnCl&{Xg#]rgֺInbu/IMl]ZDŽ\^Sp@,_ M;yպifZe&wxuTTIyKRقF>ȥBP G6m,j-W.7WxYKӶ4zLnM5KL@&D*7rJanv~ˌ#g7DGSRNR7MHUzVP=:s>^G!|Dk& XD3DqnnRN9mΞ/~oGܰARy`/Za3)fa*{Iwzs-eU2w*VyLn V3GAXTQ'NmF(SFOx1|Hw3!n /2ˊS,m?7AEk4})sF%,y~N2RvCR@ P1Suyz8#"VhcA.~|o^J雞wg6g&%#&)[N㳟>Ƴz9J_pm"ae#ɜ_3K$AW2>̢>fB!xO'BvLb7fL$|^ ɯW]hǹ脢WcQ,BYFE k N*X1S&b]gTW/i5N;C&.3O֡L;3I6 7R}T|ګ8>]a|95K1{7aػRˤc¨QqOij hB؏m6])j=B#?btt,~BֵSh51#ÍIr [A3-@oDsF;*fmL#g0+rV{5QZN^`@3s>TRxҙWHES9@0>{jl6vAQ #1.p/WK{摏{v10 $ c$RU``4up,cƔ-XϞ*d>EiO.Rλ">6#Ύ >x;Kg$h>wr*0] wЃO<epIvο ύ(S+e^|2v %J8`yؒKu&xlj Z vj\I¥w8c,Kӥ63 g3^Q5RXk\8UL.do| jg> y\afpsrh&?6K5hݨds|&@$z9W H榀;@?\įo ķ#ϗ/NJTŜoݏ/y4o< anRMi . }vDb1>UXM2 QQ2[,@ H̆ &8o2pyWX%"yEz9: >{;GR9 '`_}aqob4q2Ȩ~<Y2<)@GF*Z.P tT&Ql@!;H O',y$sqۿk{C*C qS$F2oXY#z?R v*`@Ap`S>^$x.Gm1hZ4ݝfTHc Mך"Nl0pRv7<$b:#Ǧɬ|' )foX)]$,J7;Qe2`* Xn9N8Wb:nlUG\F #&Qyc,̤EJ`c7vTaA;{uir_p K |< ] 55jΉyTq~Wl.M8?9˽mQ9DTb"|簺DT07Yk}* ZSAf 6^ S ;AAM~d|#.4>#b#:Hu$">|W!Z8.{\~%t傼pdA>3n4AnAnb/F/G/wR>9|~{_ݽd%~gnE$.cuLcL圠}{8ݵL꣈+DKlZW>fg"@i*iU;{o-P|ӋW_ehݻ`oC8zH|5r+7{o|b_ɔOS2} S,?|aꔑq_6LC̷z%B1e=1wCWxl" uȇPY/\n8Vz-#fM[cHd|i5O& A ϛ >SFt]ڎqWMN U}Ԭ?+Fqdi $y%d#>F5`o"<_^H@fɆZD^d2|FZu==}CxacncӰVt:yYAدЖ,AwhŇ#|Jۣ4SnCO̹͆?ϿDO^]COWi-HCNٞG+:d@L]>zEOk[~*v1dY3-Ȯ7nâK&aQ7AF܌7 N4"ڀ;_jC, $i#{/*z>6Thw}o`XLܫVhejDjC猊5J?B8GQVíh%PEޮQn5ZnpH&Pܣ&Z& !h27^Nj츚CӊZ"Q foF?qGmr!K )^t'Zf" |r CG*yǬ&yӑ7\ "+; I|B0)L(5yV¯Vնb'w) 2eOnk6D[Df؆Zȥqz6G٭3.g=M2mʓcZH>x@|h|$ZYkr at5_K()3VlzK[MqEln[)Y\F["_fV $D%TӦ~b^Ʀ%`9hdr),E|¬C\Eσ 2j2̜2.Iv2IҡKꅯ/&Fj6Kɞ1U ^ ()s&)ݡ@ӆ!Ϸt1;Q~9I|!lMe#X1bԊbP;zK%S,T թ3S:7xV$6aT:FcLNL^4 b&&9K03vW;3O`D|M@KŻh*o{.psB#yXL^BXT8I[s6sHSYtiSTLWнF,'h:{_ۧY[ x_8iۃ÷O&"h{ؑLF1wJ :~%8٥j_^"^fCs8pYr>FLR"@"V9wD#mߤd)t8]$| !{o!X>XɆwOҷ; t4slxAenvH~|WJ9[2&{]q?*=UUUF$.fdD& z^m' JFKĤxRR6G/bsFjf@kG:RHmZrcSxSLӅa;Afwsa˪lIaQ\ɹ[Ps1b~!Ei;Q Ufx p0;{t""sۯAոR=ߚI@hUn)>/o"_^)'.1~N i_j9RtWdQ2FnӇFq1IdEBE4^J,ȟ.;Nڸȼ@z{YސrȴH-?R9NRS8 TN..37O3%GƇjgsY!|RI^di#p{H1[K*[ ׇ P[C@> !(D% (ށ_ @.QQboNfqNlf2Rq{ C9?$?7D"}#A#- *-J:Pk+6ptc.ඦ~hKODU3f:3*pyC BvpAN3F3˽1l "+EP<hǗ0<9 `IMRKcJC`T -+,Q?qՃw0FY92aC9vfN.~ }OdUa&Es{) ;0vV am 0],wlϝ!W`r® c 崆Z`vVylP/e#x!Icjj~A6ҽ ~1v.x*G %z QrdHG<&ON/WU݈\!\, |F! =x_xkeDtF8bK/[B{%9m8XLg줦FBZM Ғu+ rV joY'Oħ BJ~aN M9{} ksaZ/*dLn쏒yϯbzb!\VF\`1nh]Y@W]@+@8 U1R#3|X̿$s,XZRZ^V1d8 N o?S}@ H%Ge'"5ICx: !H4@e99 ䷪, bNi  tU\my>,`:#Vt- Huot‚Qʼn5o%"I3|*`r &f6y %s=M} {i4K#D Y&9x^8/0T_":Gj>yķ:]PGyUR} 9 Rz{\T<* 3/JпI:_ HL_)>HzKR$m|/P#{,q$ըpFNĿj Lf[roT57RX#qK}˖9 -Q1d&2h +b BW4~Q1F2 _G_rZa/Wn"7l&2䧜 Ή] 򲍃|&_Y\+?~"HMTp$^rɅq>gPOrLE5,3>.TޔJ\e.{X^4p:eN3?rbMwC: 3S܋lI]fmMvY$t'Dž\6Gl۵s[XビߤYJ7ʅ}q_{~o9ڿ & I»W6gՁY~E&~4~&pM l& 6+>yd<:Kzf|VyȺž3޺Ao>ډٵ̿}[b(l:v/܋hߑL% ؇K9lğaw߳[i䬩:3#g}2+W#bey^Y c7"f}}:l̊M"$B. fGiA> 4ɂ)Sbwye]vp NZA#ڜ)`=rtoK->MNivTœ`^e%%H*T脫![QgaF! 0-cI͑wI54@ݶM-sf? a<3m s0_~'Ձ ?$lb<m?+_'1HVY>BwcOOkVJf _ӳ9m5< i7h#?v4\iI1 Tkgy? _{f$\E_J`~lqkn! Ft$6Nh'wqLw::oRIQΝI3u74dv ޗ4xF)5`_~£]#^s-l8fx޼VJgD~aqSB00U9dͼ-[zr5qe_(Ճp(r]#fljlYțx;.voL>+`z> g{>s_~h+(;TAylW=[>AF\>͚iL3bJ{9YI~rcY[A펵lGw-~VbY+ߺ.Z fY yof}t^h0хA7F蕋~c"S7)Uq 6}RyZ-nf{(fa34yM7Y[øTwa.YGZ~snٽw%k|?NLC¬mϬ!=&A9'vUuRIr3{=n*jz8*Η%Q1t:ϐyj_q#VBbseXgr65fToe Lڽg<Zқw^UnQOsR홟2,Mkj`2ř^zz!$ ~٬57AjvM -8Sl'>Nq{YxGN,I&? 96wrvvB7Kw\M";Wbz3έ$v֍Zpގezz:S nrb1463&YkKհVr()/|3E]n%30qt4?v5*{qY|C$s<:*IE4/eYo?WV2OshfxKe" K3W,]V6zd諸LʢТ䡳~H#GW--Y/|εSn[lAbr6RQ9/| `ƣGٕGEGE mJfDÚ F|xQ/;/#|,'c a. y' җEyl(}fy;*n*b)U0=xlr=qȪP;cJz=JRM7A )ԍP`c`D>fc85GedYcB".ϯߖ)~ސݿv~ÌW H5փTsH5qLl6a}0`HܶոQ|/bvo{eߞV{y6JATCpÝv:=t[ 5M/A5fL&j--- oFu>U R=NNX$1{?2}iwj5LP35=zDT]z7m;0/*]_U6Sa؆T ]1CT3e@r,T)7@qbӌ;~:q׭2Qg N> k>§|Zn{q駏P-yzx}%MoczT7`쾋i]Zs78^Wk{  STV_zV`TgQ3ͣ{yCm}ٔ|z +5:xQD-cDkȯǣqPk%A;v8T.1cRYfTXv8ydQx%ѵ7ˉH ɩ| KIԥU 67U,@x4uOv׼O-%3L/1@g4g`6&;K<HqejRk#_FER'ʯr#o 3h׼cQ/e \FuMEA+Y5&M9)R:-V g`X'҃!aeFa5 bνt:.Wʥn7X2kKD,=p5{>gt7ܣ|]^2+g:8QǣJk|QWRwFx<]'P妈d1oy;$x_u쉡44̞Opź3+gNaz q,N  g?KǂTglG<G{ǂkBΤG O0ޏxF)O il՗0ޏ^I04 Ru]l`uH5u 6-띿(/#g%{!ə+˳S.h0 l"C(BW jTTR 41u"7!]L]45Yafֿ̐ uq1b *us>cʾ-Pu`H~Z@-Ǐm19?oZg5M;uspe}Ө 4QoKoxxd!s=93IuQ=ϱCqusV+: *D߆Q]U抭6s&${c0&y7ڣkd!NHOO;i+iL4 SwTL{z=,{)Gg\Qc\AnѽLz%=.B1L:i.+ :r̯cϕ}S}1M>]ܴ@Vhⷣ8n}--(vXk8NiZwfFs\7%[IƐ?PxdԒ/E,ЩXʰJzM=4*9RnaЫ?Gy׻,=u>pi}zRD1Gȿv1x$Q(ob-3E__^L7lR/-cx0-^/{lj;T·04J吜NsKDI9SCuR IE|5YFH|/@Q![]Qo봚"ce3,x%:L+ZWF-*ܩ9IcFIUCgGpF$aT7qgZ/yW;%&lٛк;KzcSrb3ʎ̰($Vy2l0&ʠU7x=}zxv:m;f`}Z?5V5>1VPS~]2Ͱ<w }v$d5wew:KLwvb[TJ@p `3cNH ;o&1T̨>Îs޺=m2-Vlnʝ[cyV ' M>v[73J[ӋHWo[N2/w+n.\#v.5kv}`,.޻D:HG,gf}fgNxϏ+3W+7þ+7[%G|7&G3ث&}P;\K4cP) _|Nx ǵj]濭g4okiq7r~2-kהPy[&Jsu({*SK7J"nƶسK\yp"@O; S D\.ijsWF rf]eZMN!{[HGsO웂-]CxCx\3??sҥJQ׊A?jyGȱ'g*Kl,6NyrɎ~|]lg=nC ,mB Q5uMpU{΢je I"[:Hwޥn<f y;'?m++i͙0d1!"֠B\aHeR yANrbNV}3!y۽\"x^?/:ϪCp*Щ'+Ӑ:%R %k`HwT!2O]Jweݖ |HV!+ē{ B? 28,gFYѢ}Ovh*2c 3DK^~NKikfCv*fxQ(sL}7dxo{N,}1&Zs }HeKT@Jz*O`hgjO?2)f[%hKsm"j>wVD/ԩ\&HּSn72ntLZP "L rzmȹAQw[HrH7gDjܤYuNMVۂ&?F éݻw쫈 '~R3 Vb;|Lg~ns%=U%R.v84zVK{ݝI| QW4+!szVKt7RShahWb8ss`]<{[tOip .PƔɝZ|AџN)'eb~L‰J;rM+J(&FG[ C,,=GOA#hW<۟Y ?$wx:Koy.lTg4{|\Vqd]e$-3(.p&+c*ɝ'3ztaS>Z}'sw~E" T ^VaJ*@TrȔ_2;YJ}]Dɝ;=E6&(xt"hYlMVgQf9黕'hJNܯ]z3Dh"{%2673Avi*oIYMqߪwVFOg}sr[%vKuF_5"fPo>BF ϯ[ݜsY֘|V[}zޢ SW_ RXb>`y?IA;3o(:9ы@a:4_O&g)`r-yWbMfyܢouf5gO2`fMtjN֥7)-R[.[^k㭟CQ[y.KqqAJV}9lJzI{=zj6cQ=̌{ca]iH[ʐZwaؽ nR{M͠2(! 1th72$TKj2Wz9JMcm9-y?MrC\ut}#cԓƗ$q;.߽CK/pFEL_`?g4"ok5.hጞim@Reu{Y+y:S*߬Sec{Ri|$'HJF!u5CڋBʳiQB1S1+~-+Hyr,٭WrH\h%:=Jq %.PӁ a;|qnU/_S) !ՀL)mlj}˓h]"VTJ^1fN KaH;و־3SD+Q/\Y-L-)zd2S)~3rx59F"k9/L"K>,A%FGCIQCyuIZe\Hu&|lKN '_˞6<H1a~cNeyQ>YL’a1%@I"V'o!@>&-hȲ+vRZeo)Hk$'^x@]P]щPp . Cg C:akc1/jYUsgzUd5rpuQ hD5;*v=&FqOKXlL"Ԩ>`HwkVu3* so0ʼ'~2Y28GzQJ'>4۽LLD@@* i$~k@e~+~r3f";]D7XqLƅ tz_iNy9G=ahcv v%*h>=R6 @MbzP9ݽBG:{;_.##Zwۭ|;ս1[?yNḼ34iӷ1!w1lCԥ Q<@*w v(&Wx5bos j\_0LJ'cy[T#.֩Ď[68NpMfNUS] brTVܧ 'ZiՋ.d|6\_0?$+S$>2&oyVD;eb[@oT>ny!bs%nQ D=B[&V;?q'B^F;Cco4_S\TIcIEvFٜvQ]fYr K@Bԧ}~x~ %{HHk&*PF\NMߑLiCNĺ)c=o Gn4N$9@!y c[kaU75[.YמE0lbjzϱm՚1I^0Kf&]+A|y>NIV#;vv9):e)di\X([ikdߝ!V>5?j.W5Y7BgDo^4>vZ ʋu6qJA5qN!5mۜ]F)iV\S Q`aT0f*U$m)gQ*ە͓*{]bKlDC?L oQ _~3-8eZ ieS4%.bAT]V=yMYE;u.q99vV+kHW\_<އ\ęd l[@ s@ݏ$Tș:2rN\CJ@2eNȩ^_mrUYEQTE?͖ɾdYqV8,L>(3(;uQ˝J@_EIMnY+vЊﲢVY[qSSH4I'0e7,>;usk !惛}~vŬ_P緩jMPz+9/:^59>UaB!RRn+Ζ}f ֲ뛳r%u5{@D/ /IJ_Q!eW0}־CJ됳[`)KG>ʸl+w1fKBKؕZ2s%vf!VM_ ϊA5*UGm"@ >[@3})*ʇRRvNҡEMKT0KcsW&kE$\%Ɖ8?M`C 4 Qlg0tPQ?DRgLp~S (Pu Z,6: ~{iI@oA [+~@N9B㉚9 RT,8)UZ|$\sO6?Ϸܶl3Fôt`.o^O%Im˹} T5VIbɡ^Uw*sgtjbF]BDd'rwR_S&ӯ!CEI~paH$ŧ ^=쭟=LU|>Wpڏ&F 1rg*JHoCnkc͙69:H}LMh59~U7*r~M]W)GeߴٮTg҈iU9&Wͦ^b->3r;x@淜ARR@+y}. @Jb * [+ QYgK}4:]I-滶iԐRkZo9̗Xy)W `]j RI&vI$rLY_ir(,񷫔$=wP uk("ed2+WUSʰ|k׳Ms\HITzw؏DN^һ 0UaH݂~&DP6̴yD^VZ9/J0&2g]/j9=G-a y,0m]j.K`u=oL9 +Q1"seK͸Br;ފE)4w/䢢=DP轣~I2vz`H'St#zIw݂W97r@K`\&8hյÙ}QحvÐNy;uջ.OKt7,dGm6p-@{k9#j W٨^fQDgjUIF!FnܐH~fqhU }%FmX`xA0?> Ztq u gfm:w쐼Lo{bG !$ݙY$7*0LHl/zS`}~IBSBNZT46 X$<;5#Rk{D(su N,3ؑ[~s"/+;%K+?Iw9 D'f83Lr('=Tc))cwGtĎf [ }#$͉\&OxeWC~I:ߦ"V{\h!lz̥Y 8OQ\Xj,8CS?h||nW}6Mf-qZy&ʶ[ٗnm5rUmTԷ$~-VWs1,exBG HMAuf^_9\L{bE {b2?dnDŽ@eY AF{ؚ֗ Ŝ3̴RXi&1%i '.7۱\W>|.HhrR9vL5JqZJX6U_۩+)yT)\uޱ8C~¡a"sqeyw_]棘a6Rƛ΍A0͎[dB5_l׹&G6/ wLq  E/+5T sZڏbl\hlQI)w<3!L@ձ1]s)U[ R>y؜qCm*GҌ"R{6[P~`%9T@ dK%Q}V= Ýeεy4X iCjy8T9h I2Fv;=:I͸7f^z\v0t`naFgҿ$9D b醟[u^.6Ssz"vvޣ d+f糫nR:ضhimFveV=Y/;(5#Pj(%s(2}{;&ؖiz9s0[ۍb3e9}UNƸGSxFr$#q4].e|[jOM[Zׯ8Eg4_ר_'4ڿ4J@ΑrREyJ|koD>r=T; ۟wmHW05a V(Pӕ9>:͔4-sP%ӵ|ampT_Uc']Ym/'A{dJqw"b4KԶǁbEҺe)WK9#կlU"O;]G ^?J)e7@qO`}U`EDlNsnFuTlz x8hcEYQ:-Ë .ciy )^ŽӚnWPvSPbY]7l~c8KaJzJF܉P'apMAX8ؒ9SZOc/#FLFy6NdZN -K\B2@ҵ| 9B8B_NPDߛҪ%Q,l !kt=1`wIpQ_8(eoo(Ncc3eR{2!$؏X>]]Nyq%|U'!^dmrc5p#l. ;9e.V+)=֌O-9˗?BD9i*OJ,JEdfauyxUө8$7^Qz1TFRf͔I6LER9>cWNTcklL#U@鴉Lnʹ2y Sr=? O{يKRKrz=pQbi3H-{s; Jd&=r48z7UruRc CVPP^fgUu,EAVVi׵C_$FYS4t4g_OZ(ӯCx_b$QjO?JZLJ̈́\AyobT\Uv'][\WąUmv)d|+٘Z^>sqEx:NX8*E^i-_=v͇.èLJ܂[;yXHjCDzУ*8ȉP^oM`Q%@sTv;z6իЧI7N麝9nh*i95qFqSwC]+hW9'<}~ (g$H8Y.C H|p˂ 8S,I;,'u#ZY*oT9u9}7 MK[Ja9Nuف}DKKX#oCT-GYSP2gcxo);5cyH5'|מUuMgi6pr^/MDS1?~U@yv˂BTeDkrѦGAo ׳G[N8*cA R" "q|v^]قwXz_Э$6[a V/g ((ՙ8Q|T3t='Zr@ʩ 0gʦ;Nr?v*?imui.s(2!tI:f5rѦdfN✩د b l7ע9iG(N Hy"yR%&D ivJ.v/lNt7׸Yf bLs:sv3:2i*&V`S gc,.'E%"NRT/OQ2DGXEоnk_է lՅ̢[ AWǍ,T1-i'p|il (tIf+vzMr0'Q?mRI|Ɨf2qiU祺yqll녜<1DG 婚 / sn1zMs@~Fkybg鲤Os9Ԝ3wl2`\+^̿, 6N lLNRDih7y)o I3 bJj-C vqr|\DUM698ӈ`Ѳ|Ʒunt@gN_h!k*_ף`9tv$HlUYun"Aciq2P▃ǜ_+'hso2]Yyg|U.,q\R{*76u4و?7י:3]ǵ0 ;xR-+qhݛ~WFO;pSuN ̟J$kN^7u0m]b83D= ;$Bqެ E cK9::zm[FۛWy`fZ7VhgҤBqO `hm15xȶC7^ \-3xlpe8kUUwigSӖmvm.-wOMjĜ77!r9OM ꃂ|M]; ( : ` a2 t-~T?Wcy ,r]Zpk9+ӓ?8e29UK@rlLT6cU/pFY Qߢ~32)"ynkB<_"(ݻ`݄x u_R]OksnVA$ pmylWIŮZ6~yl3.涶?IdtRf rMVݬa\o#ݶה<$D *^˗S(mm&@6])˿˧5ڗ/PyXyЧUپ&D%;&٧Ѭ>PLw0E3RxZU}ou.W#3%b{j%t*֮#wܢeYN{"_`π"Fd7UHͽ &;Ж)EDEA|PMPȖsuor w}u_Y}@ skLPRynoGK\w$lʣiӗGo.tǮ/(v XיN𙊐MP*I(dܛ s튯x5#*vZW)L_@436q.dcĤL#QmZnmZ&Y$TvuhHσ@2 A)YkG^/JG{I[|z>#hrbiZUF_b2kK]]ӧh he]5ǃgʴAĕ)/R [l}(E JӉq:iWaE$]|KI 8sߦxҞy,MvUx/]7/\P< P.Ci3}^HeXUqG->@F.b ̐tO;( [E) A7kr.r)ۣU`_Iz6vpMo/K'\W7](޳\.̐}p7k̡iL^"K5:Z=(gdL6@yy\ 6|ԭ@imo+VKszR7,VԥU[cWÅu5@ 9q;&ģe(o}H>u{sЮfS QUF(})9(4Mr|66WK{toHS͑+%t^@q秹,ÊQkTC5h&D/=ӷcZfVC>h7'$;?[;F'*Em"9;&ywOJytg(%:4l'(| H$* ;Y{/ ]6p=ImB2-r#Ͷ.)'w錄{y+egn>큵]$)7,1/RN|9R)>n͂OjƷEa<߸‡4$yE{CȜ_3e=*-cV)yrhl)g^2YI>!Ei{WY|:V_3I1(;(5ySCqʾ,Vi21aSڙDD3AܦR5x(TFvav荒1OyhX@ 0#;zQ?&%˦F㽔Tn|TS8ah& *2&E_H3bE 'sTE!ejcBg:KuB}A@ (ˇ>p۩9Kb>/zDΤ)HTBt4Ll6V 51߇rl;ھh)jls}VV\lY&;PI19 ]Zp\Qm3;r ?7Θ:.͗W~|13\mǕMdB[0u?XU~0yLӹ^yotSee+['<ͅ+7ܣt.sGi1&nrޛ:O+ZcGm4E}9M[|w~(lgcu4:+^ J&fϵ[feY 0[k?{Yc%gO>AsDoa-o-YڛǍ{ݜ7w69 1>:*Qoնg/%DjyLX+WƏȎİ4)o]:a~6%WsQ@iHo)uьSS.m=2!ct38o Mj74smfF7A c tq}7%.sjq-ps EYK_nιEȝ\v l%t86<׹\va^m0DQv7<:/{Kmb:[$M-C̖ixv]nNל{ Ng&bnsfJqX%Z:!j:+Vs^G[`v{Y(/9ܭܱgAn>_@ds (ȱ 2]Yp2G**oi_ iz:+S̮Lo]aN[~rΥ1-&^*/-8u_Gm 'r @|J|'t9rN}Ǜf싑6=ggpsEpjeWYwk׽\zb-Bij `+OUt^2 t@@ޙ4sʱ{n G¶ٯ_' ^08 Y0,=WAL~E(_ϾcMW-\< 5}2[z2Ӛ_@^(LA~2a>nL48@j|x ȕ֚H>;j@Flo8On\tKx~ U=ՕD>ǿo me| Q/GpDZRS/]Q:i)C~`{|N #>Kݼ&-#dt-J]s2n2/ ?i[ȗ(UN(x4 ,N#p5`LU/2bz1XoGPlNDAET;;lܻj?UGu_N5eȕp[o..9?T >obK,EByߤ˛aX(s1Yi7#Њ^uj^et9y~1vp;3Pӣ/@*{)-%E{tqxN ֖'@vZ, E+ >: <@g7>QiG"d֠r[rգ35Z$70sPn{p\Uk-xv*ͭ?>ޜ~mN^rAX>|w?n:*Sp0O˩\1/ldnU,8Kq}rvU9l 5,(wbxgd>XknSjؙZ=3'ӈ߭O#{m'e}ǿ[𢡄M7舣YOSKY""q96=C%w09%p`}OF~f ,GC<]~u?uk?3(y%0f~c陝畲AԝiMj0WݞRǙ\g u@~w?՗rOd"nM^F.zc,®EKg9_3=qh)٥x4>*7vj{JZȂ8*Č+TsΌq^TFͩq;uI2O? -5_R1X.Ƀ\;^yI~NaM8|w1o~_kI.BHŁXY]Csw::4 ?,JB\j%_X-|z,FSRQ<^vO,U.Iܻz~pr{&wPT YZWFEz͛^CԇZP3ՌvĪUgןԼRLvsH'۸{ˍ?U]%O^]G{q2nUͷjTFrۈzf^ڷT7=NL4+) .<U';8cmU+y]i^K[mvc9ZED:w=*mbdpm>RUQG8.Ax䣶ip5WR.:g ?}^"_*wŦ|h\ϯ CsF+Gal{sw8y4eo4`˚gl[*j%gU7 ϭ.R}?P[vӳ vgO]x'݃nnu=S^^UJ֢NiF1jiX6Fk{}fj롗UJKwоBkBm3E:d/U}]I.I r{V,/`if*.B>{* Ɗ]u۳Y8āxt۲'n]ݟ(VQҮuQT[ȱYo3U-gT\ )5'"ͬo-aV3#rBkEx*"iܴ[^ˮ5ܫlkvꦔ8"z2"#up2ߩ1G}] ^fh8z Ȼc,c@:/8djepTxKJ׏rvQ.4 'jb{-w_|,)$mY?]e,WRcS=x]$Q+mZ0Dj)3>?l?P] _SYC|n'R{|hE獶J<; jō_Jo;JrIE;Lbr|='ٗ&!̡gtL=O-w.Ksr?~t';`c݀e m:`tOFK) xbauwPySrj+vFz *+g_~7ǡe3O?Zi;*rI^'jnT ׂRaC"ٵ$-nV]\ma;-?j5QyqtRk٫ Cj q}_xu@zQea0|w|RHv8ɍzkMJ޶V@OYϻ2@nI)e2)J^ J~k|**%[Иm$~>U.>Wz_8^ ?4;[nƙU3j|Ά^WGOnno[ Ɓ?+j\bluژYt;~QUj[<ͿSVJu) X}/o`ϕ e?ǦSf0z7Yz%W[?7T$?~LWRr}sLeH!q9[JOظ v3(K:6B\%BKCF/[FOR@*qVP VLV]?/utբ:43pW؆^s$T(g~/Po]zwls=l8X#K1}CޗTQ5_a?˫]2"_]"K XJ2/}PZ,k*PT?/ql-~w'wz|JtZW{{aNa;npm2PgAסTcK1 I@6 -OԻ-{MeǮ DHEgEye˿6 _v3^ =&ᖉ.KmTqTo3a+fg[y))vG*::«-{e_ug m~ mpfz6g>\MRIԙ:clS<U@#ZE';tk>cSQ'_Vwk_S<¡ھ;`k.GQ}[s6gt3Q]A/̊@Թ˜F "T4zׇwG6Q(!-qJ1[Ah9)]rojpw#m,xG6+#̭X3a,aB@vE@ЂtDzJAy unF=RZ!tWWl[֗ Ll.6]Wa֜oMmYw^`UnoJFOr4 rv-f//zSxM㻷B7./ ~Q0)e< t0}jͺ{9dv?oBz ^}Pgk\rKqӼE-q毉|5-4U2{xXnkyk[f9а(N5ƤΤXr29 @8 lX9bĘt:`@Bnk=~:JqV%@!5{oB;YSj9%oܴgfٚQȲ:b{ӻd X=v& wNl?ՁߏZxf $NsAs=y:xf݉R&[L}+aJ?(Q=\K ^is]y.-ƀ>pi0H%ݸzG5yj,s=r&p.|헂cǭja~Q8z21/Ɨyduao{!9.¶I,-gv$~oLMzsl[y*Wa3?tqaA1utd.0Y𼹰Ŋ e)Ůxo?H 6b\x[@ ڨgiyr߽ .#-Au=׳5̦m&25F9;[w{7z2ʕ|ƕ_H:h/u>U6[Ku=XD~.EАOA֙މիJ5lxi .eOӎ/cͱ5{sVV.})V̵a3Y.Po4}fsEbsd}lfZ2ɒߵ EwX/Og8s5$ZOFafu,͵]:2HoAɓ^nNȳFk9) @;@2_LJbL4l]Cg9+d) KRonE򎶂eER:gN51q9{ײiZ'pD-}閴ЬtdK$mߢ.-`~ޯVX5&@X-, /#RgIk{Dz<;.VΡ:*mAz^v@;KO/ӛsZ}Df ZZ+ey|xrp,)߮^9_^蝪nx搯$K59OFw;ΛN!^zǬ͸oERf_L ~$#lZ ";J}KBSJα)(]9S\8;[lz-5gx,.|GgڥEVsd+v }5>ī=K>ʕ,* x*mZy}$Ts*S-7 No_<5[e֡v^LJRfO2yI j}D ^IJL<փj= c7'ޛ^ ,r?#Iw-da쏆y?^ [htO4-0.3ⴕR、U80^+=St Ӄ負DWNC i*)֫Aj._}gyZkv3srrNB^CUK79 G+SUIB_"8U z^ o&tQe˜rżoE69͖r.\1;lvߢ^~t{ NZڹ@~1^!?`9gM!:XS, @gn`hYOW>|@TYUJ@^ 'qd2cH|"pL \6y@v]ԛG̨8pq|qѯ dP|2bO[A`ݮ qҠ~M&n.^Ńz{wyzo,@< 1Dwޞnz }f'8  = wPFv >6&=z/CnBŶLE'I t*@; 0q+,_:9eU|2_0ՅVH b S{L" + *@o>ԫWöQބP9HDA2{2M޸KG#_=kekNzW'x`ЦPb5|oPgI,Js3uWp3]w74aiFKX|Fǩyխ6(6r٫xNl[ J33QO 2E(Vllj\/9 6ԍZWR{0le[xN߫J 7ml+Pg%:O2,N^.ٽfh".qwxv [t0k=ų oB~&v^\뺽}Mf}2kXm5fU[+&l/X@uv!r)8Pm@5 ̆lTNT[pC̟߆qNjqSEK&L6] @%;w,smc5VZ0Ld$YC w(uty +ua?r&k8j-e 98@$@6 )'>61/뜏^}V LhT7XLB=@MMXNɜ+n&$EPjwRcP_ϴ,o\q*'Z}ͷ })?6Eæn0\fm&w^:/)o\kSiԠ]nO:BH,g-PLJ_0O2*a5FoޗKłɍTlF-ۗ0K>N}e"堟JW/^v9{ vI{b;Va >JP`br^ )l=/eIf~0XKˢ5eHcЏ3Wpӽrv0!:-+-+V kyAu_ޗ3xz%_oYkn~a20햆3w^wߙKL?GItm[G~N ':+ʹC 诀:> Y {;]1ƣ%.FyJ>f nK,a^LLjV .c\iFSdFWzSF [v$[o't𿭧?x .l$:)! zOj͔=oLM/_FBt롱=@{pl z 7$A!{\w.TF2o`[jIj{F3L+.QQX+6I>u^I}Y80j651Xw}<@_^/]O>lmʞEs8B*ddPW9n9\Gnmz 3͉2ZxoZ:ĹFzF분ih@׳टDۨ3F#??8*3ti~q.7:3AoN~WQ"įB`|"eZme;i%vEA]F^J:ڀFɎcӻNV}t\#c6naQi6(s1вĵ4^ںzIX'R-E!U#[t Jbt׬q5+ݛ)Ǜ @?NH2?6~ks,[vi0D6oK Yg q?\MmubOf߹ |n[,?]@HRTu)l(»*)ٯ ?>}Zf[~gV+4m,Nc|M ѸAľ7}̎ Z*N6Zh'9]꼥ţWѪW}1$ ԫXVכyIȈ%VW*LO1w,O?mj ԙ0v!iW8# 6Z<6~XJu^ cD4SݥߍXdYqeyr+iފWN++ƒaohmjuTN51ũi(A-{x:\P$mvOiuVvڵ d::^ oUzl?*4Nܒ)M AQ}7dW4JŐE7D{Uc%?GMZ̩=_ZM_Z⏇*"Z*rJsJ*S[lT;E(Ћ4iEHVRg sYBc'> ,BJaW˿b[6kEgyHN'l ¡M0?kL ZiO8gL*SM T;\=[ 8^',>R*JTf'㔥b 7?,IcOiN7ubtWX_hu|p+0nS aCYTt#7MVQ}\>+eշ>ufBFY{ezJYZ@D&K ,7']˹rEI4/s2Ԧl_>3많UP^ε;8X}BF&r^{"'K1aO2,~I '!$Qa,aF-߁pF;ݗ{O{ fkweu7 ĕp>stream #w(sMPABAY *PHbF<Vibw?.G\'@ޕA6C 3Q7V?ʽRgl~Q@l]%6OT~LHt.Ԑ%.@3@ 2+@&7X#s|Cq1=CQ >db@XOϒ,xҼ8O.IL0<۷.!:qFPX a- W+?8<]^YLz5  `dW%ROiX =sE'밁dxުɭB;^zsh`;ÿ= CFL c{\hM\43Ȇɾۙ1]y^HC}FIf˿Ӽsmn~?nJjUO!+9ϋ30[BMJc6+m4sM[dP`axU9Y|q_k;?/!: 2ZnAq{>'?m/cb8>K}.U8&3۟ q}T1 =*{?{Wv׺B{TlϪ)zU}4rBf9~l]m3f_GaRvGZGGha/+`iPgpa'-@&P+ŖҟӿJWr{;o[RI)袱n0/f6NIqs<[]c;Sfc6$bBƢbzp_J+]FNRħ|rzxHضTNk2Z}g9L L6Eh-oPudFd"6i$ ӷG "JoA2o`d5aDJ$h}Ks~Q7<]ʆ8h1lSۥZ˜fMd7X<74bip&͖z2۫t7S(g} &l_ĖIfſVEsxC#H. K;] xޖJQx{Y_&2k*tj}G a*yja>3/~M#|LVyCȿK~FōKz"~ 2KR2%?t٪V]06ʩ{[22aV6۝54L;t|֞w&6ƫwٍ:7T_H,vu@A䅇B=},}N)owov[Qs*2񅎧9ʰvj`ޥ_Q_eujԳhk@ՠl8/sAH9m)_C{̤>Om+ss3~IBH]`\Ewj[5iiuÐZq1 ;\n[4 q]27bKl6Py! W/7>VLq8ut0gʣn2ĠC16nsZ6zW-תd+*5%y9HvOȃuti&I"Q5*FWGnٟ\l=qSXVgsI"_OfQyd`8V&LW_m-riESۅ^Fwt4blyQ|M.ewE:HLH|! +~mᄎcH~Zڰ{oKTɷZL{xՋ|ǤUQɵ3XtKo_EwuONy!K͚|bxj#>+̕QJZOt|l= kW+nU'jH7u;z}Ua'a8ZYBY?2﨧DZ\4WAg骞e}K d$x'TYSF`9% ٢EEܨ ~6?Y[=GW i`\@ (,%˓ÐRr yshW΅Yry3⬓*=-;*]V>_q|GxRi*S}纭lw FvaLo]>o5x~~]d "2;+սinX^y$D'b|EI"(E;($8pd#d~trUhh71qȐl޸g;ZܪĹmi~j2)h7怈j0P|2W`wP-W2zIL3_2/4:'@Jc,NO /fɯ9/kv@Azwݴڟ=E|ZkhQkVRgJ& 9NUp[fbە&r+d!T$۳RIlZ՜A.\?W"<{i̍ 3!%,䭋CNnHGrΏu㭆}LWNRzQ0r  JzQ eNw^OVxK9O\N51O2@(߻ޘ*s+&}mO9=5*7IjvTMt6Hf2G8%vxa/LO T,2{S ϊ |= 0ERhʐɟi3^ T}qۏJv;>w(|,C`Ӫ'FBjb̌n:iQF! 4qlkEjf*M 4~ւ,)wd(% l?F;l0Y z7_1+UZ 3:MAA&W{LvDtLA@zh;=<C4QlKdƢ|LLD"+_R߼t"d'%ȮbȊź?@f@FmGP~{3 ÔA}[@:<\R w} b$DbRLW(O/@XaA22,fLd1DcHB'l,.@.G{)ڿ{AibbAD%wg{ pLT;w6T|#6}"1]l'5HXixKқĥl^n^zk9Cj%ٮ@LZי cA`- peh7v1m9O+C̲u$KFA ] 6|xԼ_ ;I|ԃ" *&HU}W)$Ħ;7I./Ep`qz [6ss);}H5oYͼOwR^C1Hͽ9H]H_HWцi=@.mԞI2 lGi<+eu6b|k݋_'3=Ϲ~.} >QanOZ1Vf=  m6 H> *gzr,mBnm/UD| EyEGG˸7#g-C'CweOߕzO/B;5a r$4 UzbxoycJVnT`i݆;+t r=n(dc_wJ; j,75 {2AAK@M9q@dDBb@}|މ>)7!RzwTxnw]NebUԶwrΤ*n,Oe8F`^ޜMҞ'65A\8[OvwuBOQ:[ҩM>3r:?i_݅T85a'lP亦7HjO9{Vc?F~:`( 'yJ wVtts͙fmk~G7fY#Qx+a;J6"~:Fc@kM_؇u3ct9a.G`B {%Dީwqc3cT[obew\3m鯅9W||QZ2ٻG#-)qǺ&6qGG/T* [{s9$\Q,w.?}7HP9ne) bl7?8 oݸZa{~Dg2g0ck)Nz<4z}| _)<Ȕ1c5}~:O8qXxom҄[e؍b_ͻfEiv4-<e>{h开Qȏ[3ʳ(00bJ߆ J,0/Ұ[BbEa/71hq\zA7DzttÛ.Tuu݅>0Ē}hמu4^CzN+)T:wW*7e`/@Zo-{OAnp>hqE=ng6!siYxI UϷ^SllCo}ӄLxLʑVRġ"N˫},ϒ=B ;_:/fO`[ʋfpTZQNVYsF7G2b X|>cy76{ =J֪ߥEs,V+yeRU~w*) _6+1; $!=ۃmYY:33PKgkv(=gLk맧hz?+EJ>4dͻ.C#TۙRg}eฯxʙP:@rG b3bdW vvez.Y4mԴtNxܪ"ʹ9+1U,d4_MK=K |Pr~; ='ݿȪÂ,:i:}_ws=nCwfPUV*|T& qSZ+v1_o9ZAjU[pD3ҕzpCա+?7&}CXXG?HR9}f /m~sm},qN,Tq䔃n*82ȅkL\X+)ͻ\iM3is2l\"PRP:8f({vFRo,&tp֝](GW]^6JvHR9Ywڳϥ"[?__\N2]nև:]L"{9*zJUuUY>IM)P (p[aӫ~YhZR̬tnmNѥf;nZܤI7!~4N,Pu$\~CԤٕsFV=m"FH?᥿[O@Qyzhn'-';ȶ?lDa:mo*n\Q{L/<#w ʗ [~ˤqrY꘵|ȝ_bA"IE63lqr[zMtcMwwn;c^V~5UslLJ˸QZY/M^5x)Aqz ^*NkfL@}1rC4ڎ۩4maY [܉Qe|o-.KvpwP:-e4w^qŮuDdUȽqpRase5_zEͮ[ ڍ&ټcܝ&kBw=N ?tjY$jݍWÏ\>p]OגRЊg>(%}_XclԇohܟZ*W]$#~ki z^bA uu}MdT~=m^.M%ue8@et~ؗ͜=go i6')߳>4ϣ/\~>Ҕp:<)mvF;P]tҧUr^v)~jQ7(gɗYvte>g!y*(ϧ"-rL} ɺ>HLHN_s%:vijۮY}=ݺ8(^+|)}+gףx |rTfRȰ5,K͸;da%孱N7DBh~s>QoVx*0cR0!0ϙPPb{#)WqhbU6bt^L;L1l|ʯ3q!I⤔'[#)OD>Œ! P!BDLʧ _  5~6Q`49X>i`t;Z;9ersd+j?2`a F>jGlr|lg#&F/LJ?PA{(uA0u`i l0ԯ5g(EBXso{@>7+C4@`Hmax++Ķ~KfOK07GP}:"@W3pYlg &A`XyԹ:B<3JU)PC,wP~`Dl~~xL{mCܜK\M,輕X_)}j `7XB+7`Df߄ס8p 9SK#%EUv]Ak9@јD$Q6  f;>28f,}G` M&ѯlfw!>l,1KcR;tO'^Ji@:U_+0?c^~  [ AbA.l-)r72cRBoa.`Z~bՊ٪[>܊Ou{F5e&Lh3~_pjH^VY?1HRcEw R>^(Hi2HmMImu~iYɯ+Qxn*-l/u76fv0s=DU=y>ͲvǾ/vlW1\i):_ןNSt*2-rs$u>;7lv,@!h} `քŝkCd]j?9?d߬b@_ONH3Nujl;L}Lv^>] [Mo𰱐|^h٫-f{X1M#g/泟Q 赠T+hZ=G톛Nq~Lx,{?Hӻ{)l#o.:[jekׅ޶r1RK%nvjlg }ruwfub9 ibä;smw'42_(::CMq0Bzc]$yZV;dYfIS_Mo5?s[XA5،m@ IXn_ k^>stE"Wdپ6a*bIԢƦ)Yq:M%^.=q $g择5*Xk:θ6},.Hu(UiRyڻ|sgA~<ލCSdcy\y"`1-5۟E10a<=6⋷&tjc`'GڌLy` 09)IX}uVap%`cݬC`ľY+e[&M`]HUSǃ݌4=잺e;i58ʺZ'X)T`ؖNuy]D3;J'u)7+4sfN]`1;0oЩ;5xP͎)E2*dg~iu(KG:y,F:lf+9/I:?sRT; ;>%3U(8ݞ37|᭖W>C4M/etFqI*Ds\_=wa&IVp0Ȓ0E6bA-Ee٢bw(UWvΟ7wx(_]K=+\l?-PanB'>ʨgVy22DѧĪ.}~5No>?{|A@,:KBlW9RNP[]'m(5yS Z0wOwfrLK 9 (9BPT]:NW,,?n*ߦy*^|8>3x_=i7wj-nؖ-)[ʝ!u8_n&mu(-nQ5Tk5uӤ;C%|\Iߺ9Iy*BWZTc %sl1c*~`&b2oˈ%dm{M695;{*i7p\n^|fhhUfd3-&9dehd9ўmg.# /<2Z!RO "G ew@d%Yw.a> #ڲLvϛ&#d/5wb8t&B{ W='ײ$Lcj]repo9xB:mZTA_>H?SfN)6:mwZx3VWo1IFm/ q\nju3 [wq.#3~dI}hR<.鵨7`ު$Ᾱ`r+z!^>Hv,_dnaI!Ž~96&m,4WnkPnC !%U됬o4Ecd9b.?fɽ /Z7o#]_YIӌJCcJiInZkӷt3)Ѣ)5VZ|; kf^œNjr#[h=Y>c87SId~Ŧ,w=Vs3FmVߟbvخkqʒTj/ZҤ~rv'um|կ-9'Sw]Y`7N4s}ߋ|V'a^}I'܆3azGhY{6iOzA4;ڨ~}A+"1PV`UYެjj%n6'wVݻ^0=Y-o0)9tL /F=c 4:`DQvķEQl5a~R7vv\{U\4*=ɕ I6<G ^e,>+0GWK>ǹYٲ~a\!"bO46Z^Fu.괷- gd%RE1R+\Lz*wi9.Qb#`4`3 _Urq fN~}\;N\ `FdS~u-|Е~3d[i)O&22j73%g H*BYZdQDf֜ ¼=ܧ9GbFReͳ5Sv[R]KbPKTTkMPʿIX|dyt9*ײtmgؚϑ|뮤EKy[Ă[ },Rq <+tp J\ DLگ cs۬OΓ$<=Ql'l_ŝfL`W6x,`xLal`2=M / }}zmd!FܯFj/Yni)1޽;a\d ?Kz]Ks5@R|ss>.'>;J~.0`.o8^}_yg~0qyR/M=zz^ǿ~"]_ ʅs`3Yc7ZDuQ>bKOJ>;dEmN>/:<[jMbvxќղ!-"vF{$ .V.xǴ_bߛzSWl\3ٝ|fgn5'w~VK<"R R Ui'f[ KgMZ_TzY}*o֥Tks}'M.Zoݞ>0<R6 ZK 8~~wmݻ-vV.x!C>f!92X$;e@f](m%:}lT}#\^GHFk}_+Uf1Dw2~?7A&+,_X.m{Иd}#rH9 H|b}\sʬiMH}{qu ,G v(q"߆e6OAl\ҩšƋ&<,yZ[7-g3KB* _e@<(Ŝ!/G3l~E cAK#nC=-, >9C`npN}`# }sIv[Vcv:ݚy:y~ Y Y.&lxbf|b, 󅁌)rο]=?hvֵaWƗy9& _IJ3/tH5ߣ&KkZKmY-zv nj-,_<.E¼B=זjfU}u*G(Rʒ}}ܕяf\iŜ/ak\ ʸ2hjC.Q +h=خ&So杽SW?Kv~4;(͔lu]=v/G{ǮMވF0cuJV#3'5>+dh4jϦPBnBKa퓼9y ܟud? r+ y~;*sQ=!}tN|(F.fͷW{[ī6;kg8>>&U ˍUS.ߕCO4y8JJ 9eE*'u>U~`[B.(Sznl0O޻.Et*eaK~p4i6ӟhn% {o49ƵAdO6b&NXɃs%2Hgy3+7!'|sr]4OYRYd8'3MuVXh|?nU'˄$X`a8`d~'{sIHG@`؜ ?_i{}`yzO;SּծsM}~4)75nbͨѮrD߿%&T=G+\[ġf {gb]:zR[ if їb}IkLfmp{^64t;dҸLGtPsv'y/sa0o9*됹sVOvkf4a{󌐉9` Kq~h %ȷrsPpulnvpZ̏?Έ{5)13 9%U!LJ~J.}0S6=&3ӗt PNN΅Kw)t'OxgIj]7υƩ=oz7I/hy{ͤ ;=:+N[u7^LsL~uoy"{9494h]3T}3Wz-Ag}kMSXвXx[v\~7Zk3juK[.}Dˡ ,+^9_fHɚ8 )nif+!|k^\}m'\eV$+`u*M+ueV*u~a׸5+!7vԌSz;8F3['z*m2_-Ymz%ec7e֟c-ũ65~{ ;=2h剫ؘEAyK"a_Vy=KcX<]VX9?0^^ G:_?\oND+׌_XTtfb7,݈WԶ>/ӞG6]~2q5bLQZUPޯrpz;R,1tA;hLjGvȬ{C;oҙKdc ҇}!-y뾚/uK~ɳؠo\mب [FNtB)|Ù 5uQUbE3ȰU-"sHg>N!0a8Jj:X)%cTB)qşn>$ᒇx ]q?R㏜B7A_aN^r VS6$c|&! w㯌_l?ԅ>"׀X21 bǟ8"ƟOK+J$@XrER͇P2.Q|z G?!N Ίa؍&ћ#Հ8oU!8%-|n_PlƘXԗO,~@Anb]25@YBM=@c: =.@\Ze9>׳idaF|WڎJ ѥ?jק 0#P&4?n7Qz`HGiu(|=K\I+A|혓mL{wo@^V}.ڣ>b&–kw˗:2.3 a^tKo)cz 0Nhy=ݗGO=K4njJPWϏp~欖wJ'pU*0"_mU-`4%-̆R\YC0%ޢ_; t:no{;hh=H/XeV'_jq˯>:}iW7^eVLCv/o]bIq@@00xyTg%':{\|oVfqY}rӣꧦ]xֳtt5\DΔt|Rv,8;e6^v-~aFTP]+R.1KX7gu6CV_ݍ|Cۓ I70hvސ9XKdwZ"`o ܴ:eardWhJ-v]* a@l7R^+1Ksr{!-3KOZ2])-VgJiim ٴN#UT ^^HeڹKGmתbLۜ/|ATl.,I.;Mc[^/Ǵ=]}ܢ~kjM*WkN]ZBPivE5-= Fld9㚮¶51O$7PIGTzq/ L8档M ]{xJۙiO`HVV*vXu+zֺezf#3ԌX÷F7SSl?ꎦvTH&b3]dq ,El1@of~b|{M3qfdz0"uHŸ 3qs<.fx5]ysY_jZ^bMuWs1W@L|j}̛Z\߮njtJswK뺥wSeғ[e8q`@:y؟'iEmUE$[Io#evJO馔i%h(h^MPVЂ_@|(#*)CBMv59dY `f [ R^47{8Ump ,]LeQćDϘDZ]"f-A5-IJ.yd?˧cn_K{A=zW\!.ϖM@oU;Sc<6j坕xNJJd)櫗ﲶPj?B Ua0ZY0R_g(;~鍺9Ң,2L.. NMy\{Eќ !mYkUWN7)4pͨWp̱Q3|yF[C +!ᧂKxtx,;I6YbQyZeRӮFtu;[KI*vbg{=G=h4G[3'Gۓ"x{%.~}~~'ftZ`M\Gſw~I,J 3QɄpD{BnTTRQk򜲝$ DMYSA}<-yrPγxS̠R̀P,Z%LrR&i[ͪVd>>6#]=mte#H,/̎]Wʳ'yd (0)o^6~vC ,b d/w2D(ܼ#-~*/t/%h~wiúon뻶(iMx2lz\IPһ$ u[z,;hj@Ndyfz*]:n%懎Q^8w ;V.vFЭ6ٮƤ3hIUd owUTPPeWTT?P{qC1"Ttޭ>w 8tuL2Ziޘ:k̑/;p˃o3E${H͔Δ\:*[ծXEǽauVAmLP .{FR@} pAs϶7O͚m6#ghOw%TƀW qdKsh`tLIsVe&"pԼ9ØiMf *Z%smr:T:uTԍ|S^HêUG'6y:Q~'؛"gɾ9+oφzș ƇJ<+ kt6nYhmI:|8ڝlzyyᅦ6-!Mm,;Nsع}ՕZ]V꭫.&ۇWg))m92Boz,lTϽq|w=G/smY.Rhub4Ç}nR]$i8;;a#g{H}W@ul`KWJmU7%e]#ӑ? =s_^y $B7´-͏͸M+'t;;}B.voٻd߂U=OϏCyWO#W$Aפ0X BatP$_EhQw>?#ś1 ӬUKWgUR`Ňpe\>C\|1ZDdHh|BH\b/B/n~ K{¤,"1^3&?!zQL씫ү(>)^my*WaˢY="=)[|uק>\wХ\@DC"Cbcuhg˚"a1“Z3M4x{աڍƮW%,[D>Li6nQw(H1*gՂ.۶I5w|t庵O \q _0VPx=$C"W1)ۀJ+bHg$VXUWGH}[PHmfBF>ۅ ׭X,7̤>E[Ϊd(@$0򐭃(`NY?-]c8\CI&5Kgg5h ,}Nah lf}ߪ䞇g\2@GzJ) Ɖq?SF/{J4^-eҧw)?Uȋ y0060qA0{eF_5;h%ˆZOˆΩ).x ~SVŔs`tYa4|S?&^ĸ)\jf?aڅi|בzF F>շ0z*r+jX:H0z%ar)nEC'+-5#@"}x(L;Ly[R1[_ܜ0f;9J9aA"}0)QKR'k+;0Pp vF/?Caxgn[iO&qC[(L,LЛ&)LJzT@ԲtmXA`]!eHya.^P^{] . %u}ݹqBgN2,颿%J>#fp=JIE[>8PyS $[-ỻM$>|}>7Z{?Qק׮L)mPܙ_!7i2{d_ eXr-'+"H8}?{~]C$:mա3 bB~\gW^1 НMVo#8uxbrM=EvR;C|EĢKř?jOUI=o7.rڍcѺJ.^D"g<a\ p+]p;mTsZ"* 4@yѼ^*_5|!A͏@[,dx2/l~.,-hbFWA+=m=B~h;m#6X"꬝X_pi |A2`5(I獪F/ʏV ܵr^q_w\#{O%gn#YQ[5t2U){O&֚UMo]-N f#[r% QE촑ŕE =+JM?Cȥct7jenM̝7Ϲ vٕz`+9)cסg_k0ze Ы'IY/~2mO֋$\mL[?+v7{T]\y Jmey;?d܉Z»@şl̟W1*Nm|:rkcA(oY@_Һ#UTbPG ЃLZ o?RbwmM?e< y9NB/?/xb>ZNڼg >{m7=3j[JccTUhJVVŖF{ { ĻgSm#bIEu2pLj }LXMa?y|'R3ɞ/|_/:8u#;tmrS⭡y״yV>X&˒C^V)ψ@Z  eJ]מ,d-óS&ɝk\se|WyTm.MAW{3C}.[;ɟ6hsu>EUXFx¬ғzꝧ $[̆+o|S>qbE ؛tC棇zP>"$3_S`[Wm_X}eC\4'_+T@+ 6WO;k HZ8*M%;b>mt6Aa 1t4̼ۡGzS_k;. kuLVV>]J:\>7omIFY =`c&?zI#h/=3{ 33[Nh2@dra*I0٤2O^ 6 # @7vk0LDQR9vXWr*9RIާV M\OuB`;c0gaVe3s#B߰]C4fi7r>^_^8.p\0j+ +}P.WPƛtw({&dŞəw5nD}l tD`3Д723|Hy&s@ʫeֳ!eQ vDo^j5m~ /񍛵 ۬^\my^ᖀ vE??.4mP٬yC.w{ :< Ѷoևv'kjI&jH6 #Ԣ{kP|O~f!tm.Iu7i:\&GͳХzև&tcFꡁXB|ڙwjՁwŪwC2hae:B;FRKFN kka\8֜q͹Vx/ֹ6lhNIϪBQ0_ЧdF/:^]WzEx\ll\#9w[:2:euDZj%{UOB5WVU~2ղOeezFʨ4+}PzSՑNh 16h`2dm#wb" 7^Uh/2`lX=qX=}ܑ7ILfE4-*.$SHWaSB?je!;^"n/Bxn`$\`E`]͢deG9?l"SFJC^f][)arR*- $N1 !eqK9)q#4[rDÐ-3)< >Sc!i8SP1 |ܟh¨5ȅSK`al&ݫa"ݔ5圆*~0Zm Q)4d,Ņ 0dy|/+ X^LCu4(=5F73 7h@$&L8;IǸF|'L)R7#(ÔhFkgq=Ͽ'] 8G@}}LYһˤ==^|!x+1c͇1B)6%>qJ#EUt`}2=B^~6|~ C|߈{b8]iF ò&K/L0LrC)춴"ߕr V~͌jQ䉊tz}ٟ{$7x9Int_"*,J|GSj|pg=_n}.x'gZoا1tY\cn!..9Tx=~?߇h I^'2h|һr.wm>j{|훇h|/mFPF6rq/yrUqCvnWУvCz]ݝy'!؝nWyj% 1[:n\rWӾߏಁYtӹpR.]w}i~B䨛!?|ܭq;0n~"'ձ~yC RyKzTk?HNQ@4kҟ.ɜYU柖ot4{ S*n?fXc[sm{{6upE, w䈊nmu z >4okPiUxx͸~J.w*޷.l)V>+hN`ݻSN_2v{ 3I P|kc:=PzU/:&<1+3UTޗٽf\Z8A"s(o?ۜ^ӛ| pr)#s7#F';1kE - ?0}r|0ڹ76[7zl;gG7Km+"Kb zbno_iΚq%}Ĭxzj߶ykcn83bJ4.|{1ꧽWm_Z-5RՕ{EXbчPW6}嚳we7qWH7]>I7G[?HO!D>NlvW vcG>[41VEs4홺Fz V^Hug[Jˣ;řTH\l7lO(OcO1 ѧ3 ϔ[& t/㘑u9%oηz@r8˃ksSzǮ79y#/;L^H /~CU-UJE) .}aթQuzdl{ܒQ 0[6@efrl Ӹ&_؎z|~K7CN8Kg^#M.MUlw4gy9LMD{iZܳ?y>v],DFD)YC5Y~u["qA'ձpHnxn w~Wz~:AڅjEhܥy\/^L tU#X4M1Z)# s@u՛,5Jˏc7c.{aaxfEfrjչ -uѰ;y_?SBX(Ptat,Št>u kumvI :0~Hw)-j]AћA4S[a2CJ CsmV7္-2A`'djfHy6L|̤YH>:JrD>[{w[j_U 'g6k8*lcu =(79GӵI.:2HvFF FN3 v.׻P% T7ķl \ ˊa`\Sin{{Fne-.䋜h 5Q戸 },*(=ug[Ld6WyrFٷKz~OKpg򭆱 @73Y`Y=NڮYvڟ!juVTSTNh<9y]9`qa|^"c \yR mJ:F( O 4 ~jB5?UGMUkcV9ء2MG+dUzj4S*Z^-}:m9+_>Ͳ.y2/ySM{5sRW4{Ƚ+uK(ϫEܺSsҒ+ۥnqz-_ X\JcI!v8x`XYcS0zU ?5@hK~@77n=WoI >"G%spEmȠd߮o΄fO .Pt,бI nC坅@=Kzw)A^4A զf~eRA&jƶVԹך@Bwo v`C-/ɶpTJ:Pe&|Pߦ_..\w̽)Orni`+'_>O@ Z6e 56$klQ)΅Acaf_R>i 1©$N.+|>@&@!@zOcv9_eCa֮zf$$N[|d9}$z.eܠ*D]pH9dzdMUA~*9]r XU]w=˖d7.%Cő6[qF%NBR9)0 g;@@k\")B5e'׋:l?U4^{B cM0*Fi- chta>\?a-s@pPHb=}谲Mz){-}R0$eVK)F郹tv_2敆9 yvFlm,(X˧4:Ja&)E6P'EfM? 4zG#mFQ7_۷oN>&_&%d&OW-3QYA.(0~oN*) ڣq-)? 88g+Y]Tea>CTu_~~K۔&uƞ+ظjnN 89Fk']al'XBcmC&_fܿ|vAj{<{JPPFÞe_~ZY?w~{{ya~LApW00H0]0xUo`>HҞ' 5 N*%RYY{OE2nN~jviߑ XFs/q5nfEc'9e' ӃÄz_o^]|zGp&h{'XlwK(w+kn7(+.5zO8=~`4AͭNg#V-%H1Cȸ[XD<734xFVZ*Z˥~=[R:~∏9`Rw 7O gtzєwU{6e+a_כ,Vءl.lw@'mb˴Ny$a|05"jUrۣ}gʻsNPv\cmNW[#ַNXZ&p1ZD mi`*d<6{mdi.rVh~ .vwO\Qڬ&nPo`΄7}4\u0Sw LvR\/u0VXXZ2<41ՋsiH;Smm>0, ߋMƨuwGH$5Z\Mp\݇OWy^jAKɦ/X)\lOQ7O|np}?^#,$nn 3`.i5{/ Z)eU}͖m4Zu3ZDEZPDWn03ߓ*h;2CɘAZۗVso+-8mᢾ wx(xK Y=kj_,ѵl/?wϼvYnd4MC'b'JUԅr||xK,.~=d3f/?Sy my^(&| ?ttͭ䡲B?|zb#egB?txHSU|2ۼj 0>Ӽ'|p=p'0EcqQR %<6% .ӓ9v+mk1V]jt(RߟAaI-tGCy/̈́mil5c>y>WjQǮȱ7_̹l zbl=fW[|!gHDES#vT:#Sv;Qs|H׵+ʫ,)l֜A.Kʠ*ɲS)=<|>/>d~ɍKS νJ^Ȍ|"o ^ogӔ! 1l7z/}n_J[ӧKm͗RW |Kqg_+ 1Xo}B}uK⋫AØ90R)[acy4&VW$&cRI-eL~eLyjqFkk mrӚP C"xjj-~A\1TЯxgʤpq%\z j#%|DhchIm[!=V2ׇzrCɴ?*?}xd=m'8m[i)l3`[Rb͢|M#~ׯUNu]2$Oy`~=ƎA|SejXiCґPWg<^tb=y{sdum5[eJ$= ]'=@R+FfցymWLO;ǧ 2 RwS_,4/%_#YhcXt9ط pz٥(Qk̽;| :<0tEJIwNvřMz H߇B0t',D\ L[PD|Z;sO/.Ku_qR0cc˽T{ض鈎GYP}bZc{՚ɣ5ԱV&[-ϵZ3, f3 amIoT7y&t X(/-K#-fKDm=((u[[hB4zxt.ZF)[OkZм:5UYi|Aj#gțfj+%_b8 ȊdVd[+fj'vQ, vۭvo=p:G6W/g{YC z=޷Wm}<d UGrݡfj!F8ܿIk 1OHKf4:n|, ] /\.wpv̦TvQ7|ҥLR^-7X儿i+գnx.7l\uUj'i$_e K7L1Y^gQ/U5Y9n͞:F:/VNĺC{HurkCmêNUANV3 :1q :X93A塍>1%}C^@n #0 GzO5PH8pL?RWZ 3+Rͷ6hvemBT`88.'8wktf)g(X7+42>J6VlD&}h,Xk2SLKB͐[mb.뎒4Y= 7 V&Y)?ߜĮ> ^JȷI/4TBbz\4Jqi)dSv]rSA̭W߾gߜ>^m&7Gšׅy㳵'aԟ<2F(י,)A/R7aLۘ 3>S睲8ρͭ=P'z1n܍zio[N0iiJ'Rlay]HHOfzFhFy9\7搗TWg@?i/pfz墣u2 \tcqVھUz6^ H~ "Ga4xF>]Aww^2y>^kE|7GK6WXv'["5hΜdn=N{u|}a#[3xZ=ק# ;@|lM~қ-@&1L(|) ?E(?[udma\{GА18V#VzPrhp{|nӾHA+Kή"_ۺ9onl05N,0fhϹ?g=s}+=ݤ3F 6vN.L-*Ū`j~ҏȃ[[mj젛ӛY[^1M]M`iStkI`'4?b:EU[_ HQ1sD{w ŏrzoID{\pU>ɦ#J'J9F!R;ڌ} [1#6/r6Xo%s$IWt q݁zJ 0w2/ЬH>1Y2qJvS(.{Oú`?X {Ue#XH ]7:3(h^l B؋:iOoMxPyl'ۣF N,4GTx3 S%Z"ZBBۅ6 6  L`Z0-(gaf%<!EDvCf?';wYL[}aH}*54#ugr6WWAk5cO`;ap [xfffjdڈhVFz,|]ͅF}(kk$6kCNP=T=VHH5Af72ՖΈ/Dͺm~95#KBf#|TM:$m^L]Icx._t4>6f,,~o3 `Yn.MY1w32YiSK[ R8i#O'EsL$YD5O[WC7ug EkZ=_y3>M|ݵXgG%؞zv𗸻_愴t9?]5үr_UYbzƏJOZ7Dgsf[s,\<^S6+a v!9#c9yT>I}z u8?=w|+wN}7nN㶧WtV.>\9ʯ)1>;)V>W?Jk_l]'?yۊ:[&'"C+u@`fvFEC=B۞,.'<(_ǯU*SvcUP׉sA9)usC|V+2/ޗ]rM1ί|t% 6؍VϘv齃UK4fqKVq<:,lo!ڕ!(j(:;B7C6Ok#BЉQ4?~:o%_.'LjuvIb>a>ה_/. ,6 Q"j۳ON{emtSxaw6asnSy\|c,#nIBZz`u60b#[3|)!LKUS/UOS&H]X1lrb}̽ms&qRNPPPq)3\Bzm;^X1-Da~^fͼ62MͻL+:\[0D QǘTf&BSkX6hL;OW+໕ i$ĕz+ӕ\&tI(e(3XuԵȹL)2- Np~=t̲z4u8c6-!n`Bim:roZFwܢFmwhEH!L!'AIIoW Sq[tK/n/ޜݽ Vo*t:'Ks+u5= rᮠN;TZ&95hT)kR0WV( 5,V:Q;n )E. á-B fLUmU,vjR3]G7򻑪i${Xvr(X+(ܤb !ψv޺#;_~r_%#æoXY3d]m `{b <>NN'z ӵ*OȊW1͏c*έH.HB]YWF~rŠ$^Uz=&c|혳G>ӷ%}VZ}*HT>G3~p9u< 5->{Yg,|?=p"$a2ڋQ~} + (į|E3qXx'kDz5Y5y1)P# vJ΃(y>~$y o:?Ѯ$] v O4#OyrU1CGrav=S6ݭ suWi%) U'GJR%J hr1hRFiO fy fDa~.{ܤ5[SC"^>"fCn.{-nZԅJTf2b֦I%p@AfN!;)]s ~M|2^hfC =! G` oۇoILSnEQM|kN,ȁAi[(2wOA&A|y+.ˋyKΙZ+06瞘NZU̦Ҙ b1`}//*J]?wF)KW( Q/ @Դ@@L6:Dc"u@o( zZ8aڊ12kWC; J [be?3z Hh$nҿ?rk^S;go*=*1_*3<@~\tb>o'yh KhU -5X`r) b7R@9hbgP] 4uhk9B`o``(q 0r300]0LM!xxCG-2hAu U'drw˴b]լ# z/OZKY-s~"-1Gp2{p$lpSlJ{(;<ne{>(?:O- ׿,/7L7=+]D-Jqx<9Fr0t~H'#B$B BI&ɧ|KUWfp5AN~{}D&tvuy q9i"AQk}5oL|A&Bx|z^yma;ԫS.WAm5> _Ӿ28_wxڹgs]^*kzo_ѧ 1J7_bwEqv͗r k7CRχR{o&oyYK/Is^>W/WnT8%U?{/qq&j=e_G׻Nc^B~W=Z#h3a' L.\kBqA47a߷~V \Yr1o;hWR'bM~^EJǝ\3X3 LƓ&}>{J8+B`aJχ+)4Ї ^/ZqV\9N;~OWLcp:f)cR>;??PX՜0SKG*8X!`gH8mq<z\/XIiQ䊟o=#{/k&],'ѢP%vleqwEng &U'M7OPcN|@>#~?5z϶޲ GmЉQ]gmS)XvY7a>&.pc`㊣>m5έ`TauQ҂Qux ^4lK!=qk8$Pb&V1*9NCV&ʟ;G;w4F-NHOmn_z2e#9?%l_`tϱD !?@-]콴剅qSi.Uq]ZhWoIDouU6(4f1F׿"">B&ʶq,ؽ:W&1n^01>Vi^Pib.St.41oh?oC;Ux[piy?#;S G棣XFޒ[ʾ g{F-`CB ͭ(~)J<Lh |ϼ6&s\UdN_b0Ii1w'Qĝ7x-q"!lpЫLd~Xd 7>cg7}] ;ᐲ4$ݰwa h0X3kNԋ`aȴeR"ʝgkD]7pebfs j]j0jUf{L0ws/log1V&}u%6yjlg;zP]Z&?֐^DaGDۅR;֧!AME-#ǓBz×/,Y _Afa,P},]E gTo* qOIW#8yb(bDqDs4\\lӹg֡Z;t?P~UABqWmLfHG#t]Ѡ¡BȮkF"@ڳ驹a6i+~}1UC57>&Zk]?- GUDuEo=كDch>}-#{mcchBvݪ RH ^X*>TP젒vOP;|~A d9]җXEؓoo"/v |abC8> 0B05(nMHP.jkW ~AJ,qt*_jHm*Hg# 2*lYW'6ϪSQ Ņ@JˤL!#4aWkUu@T9MzMg\g߅eiJK8/ SO n0"^zE;PlLk$n].}{Z)Mw +fxY_mδNʁ#y6z f %\0TLWX,nzzs"Q)V]P#,:7k6vN6̙`  8$}δCn;oA_|NK<>'RA KtRpKu:Vq 'v\(brr&<: @G@㆖=ГvLl#zA^^S̞=7):=z?6o1.>,QB\AƋ4Yh׮ `׸8)ߌ|2V8n\_R KJk[^ Yx\ Y?pNi>UDy~ Y&$#P^c f2E˳t^A։c gf01\ i\$AS\^߱Xk3@XkΥjm;+iļقQ n=[F! ѡI!kde`Ju"]M5zz8:VO!^YklOĄt{tY8-3@dpĠ͈5K)Ta{U/ODٹI{`Ʒ_h_Sk\n\ZpP#B5\Dy~^jXɲ~֊{טq_7\Ze8?ksgrkٙbO-fFG"P;2?kȧ,WZKRD$ܺv_vz*I#BɟCƗ ȿ۟6 3Wܗ| t95oH,7hf}wUx7*߶؟i7$̯ii{kc\wލۑ#w^z>~˵U.Dj+:ӛkGzs fH?Gu{|v5wR,ÿ5-\b1LPa>>.Y_}:TL>{~ jw G˱sb%:s5 {S2ig$p2aiF:#dF#=,=nOʏhzCwR\8:t;Zʱx}?.4 f}^z7blhCeP5]̧MܻZktc=Zj-c"eN4Fx+pYZkm5V 6O4 Mdc}#g^W6}Xjڇ&pzԔQq`kYm{1y!mowqu!q%s 9]vknCfv~s-MwޘuS7(aoqNqzU^f삞"lrlhca/ZmW#)QW{b{v}Eݬ.z@͛ݫtn e</{L 6fQEE]Z&ЙzkEЫ`.CRC7I.6(%!{ǿƲ&;' ga=]1,D}ӻYΝ V*= XsqEjũq'V)O;;Vl7he샡o[!¬ﳚMwkAn:yq:t'\ 7][uSIl"˝enbZ7A=TjL4jVzï480|% Êѷ3?'lP~a݊'7[rmbN>W ntZ;}rzovچa"][{*= '1BÈ&$E 9 C8ڡf6D(JГio%f$WKRxV/>;m8h3[N7~u~lC6_Eqa iAz _snZ9Cc`{C&tU{@fՠa<)9rVaym7#եjxںm-I[괨ʭ' RB:{QPvYpe$8gZ\>pX̘W0Y 20"btEtáaa׌C4Ϧ 5>.y+HM?$p9e1" X1 skjZOA֤E_yv:QJ˦ &͂6΋v*n|" >$墌wF9iT6 |eB 2kqUQrF{ c-}aD8H_z&!CӠh="GDD|Q$R[ƺԦuUӷzY#(;l;1(qes—WP"Q 뢱ec|eƸLI=Ww6[ug*!3L?7)}㚛,QE>ε I~}oN4kJ\zyF ͮ%y|oR Vk :4Bj" P[2(?4> Z񶩷qêuͣen)5;cq]\-Jw+$=cOZشLyï?,~[]ef#J‘JS!j 9"//m'XCPHc< ?YY&wR?9+HtDۧ5CIo@iUY}iȱ$lR*}L(`II z +B}# K _トmYjRgcT0τ'b4PpCɗ4u[Ee)q֠?L|!•}j'6a9 ı6y l&i>ӰDy}#i0&Wt(zcH]繦,h+(AI3 bBQŜsg?g(ݏV-V}' Dr]’g?(b&[88+o$Ce(ӨZ[ KO+-2 ]auH]G$1O5LB#&Q=$P8=tʮ\h6},X=,ﱜz椏m(P,ոT9Ia]OwYAqdVOO[+zwD Sm]hB$QtNxyVexEYWxC;KGD#Bc~o Q vz?^7:ެ5D=B ,=q#&}[s'P ߉ݹ`ZvO/?҈4C1==nOHր wp׀ZON ʹI(hrܠL}y L͕__:o>({\Cya] !<@ho@hKa>aS@"'%p&c{3[C+*=)_D5] ^9? ;p=j@Lu ~MvGn@_8cQ=H@ >dqbYrxU F,OBG&eDc n`%Ithq ˿jV5 ΏVޤ&@[7,(Pٜ T\ޟ\K]5;*QYW4N Folk!mԛ>*j;ꉚ ϳ7rAR8ztg7 /0r'72Ъ$V:m]K}ҭtyϘ^&i!/#&8I:F ?Sɇ~ouIm-軇 q7O$r?!1$ABI(T||ka.ׅT^# u3dpڹu%+8K_/=٩>YRDD&&.UX vmxȧ71 W ݩvw0/V.4|=9/'a*|OV Gyi!Q?f{o݄?-wH =!lTJX듳X#?4yUB[+[a x v|POãYgۦ<[YGz!\NKa+We=iHv3hS;?@9!yٿ'H%.{p |ЖBvv;Ye՞ʃ_sRXF兼 0fV؝0Nh,r:? e~]?ZޤAzȜy9_vWC|#t~Mi,M~x$1˷mPڎg@>=v}r&&J sl!Uuot$mWb>w^me08i):N='(A|{ؕӇozX+O<Vӕ>welV]+:[2KW6(s-X.xWp 9.EkokQ/K0 e xOa30~v@]6_[[| \m%X]Mhuk ?_fԔkl&]YB塳~8c8§X!+k(+| BQmor/= S.r0>*QHPӭБRJ(0߼+g!t:]J[!U\UTJJ.<ɇ3"ɞlʞi"?Co- E~lMATX+wASTX1WP_~, 1 axL#h5[<:˖^{ɌlMZ|Rvz4/ܼH2ړM /ދooHbO鐟nVUq}{i7%OfOzi~wޢtr0uG>VR]%ݎm fqeeH{^GEUZwAoð0`'' L9uT8/[_eV9ekŠs'7'S {^7:@J>4sUa7K>tUnԹǒ[>v9!9dT3U rOw57I 7C&7}! >M>g{M;E۵)TRVti;SRYiB|*DO9JmQꚡ).j '-RH'R>6id v9ślXģ rb7Ski;v,}fTzp<8ͱr& ,C߯EuJfѬj](TQ%d2oHCG5=KQ[_V'~O"9GhjNYk,:LYP8=7?F$*BT`((%(EkT:u*WR%?y5+MEڸ0'G\YY_7:cT׏|^bw_*rvzAw&zԅJmuQohrŶ o)\& G[!> WvTl,uOU6Q%k7Jp^FFϼD+3Ř,un>BܨʝfNK|oRs|1ɮVFfM/X퐩bW 2`am?KY @ҽe.TPA*2eԏF4,Ok{oO*;fFݏh؇W>d sl'^lۇή(z@[_gTXo.(4iQz;Rti=jҠ(=<_เadOU(b\Q9nk:VIN#3גW7"6Z&yL/;\'.nIxZf}ii 1L&icZcd NIp{ҠLͥ\M6l @c,Jjz#@9,] P?$hRfWK_l[PuSW +w𷲱6I+څCZo2|d- }Cm@oݷMR}dO"ss K 6ЍN7A햩mѰn"hżN#}t֌=Z`pHx`1>`߳.`?xh1ކ1ą\)(XLOdIrY$)f! MMHD,8_K.*(CxWWwv>P???#?`%͑Xx.AioAk'3ƶbm31E&̥z+q!+A?y6|ZGU7;  DȮQZ@H `|IjZ@[S wHE?~[ܺsk鍖`ҏxvOmQBz zݕߒlzS+ }2Tz'⍺ |"y>]\G64PcvT[gVo)$:jfח؄,}L3^zN [0!<n)wֱ]"G QR R]T t-;O4A_Ǹ՛s' ?^cY-DS Gs]'T߭o|cd4MlAv[z[?CЉNbz˾ͤBxU$nmU6ϩt,Z_tD]DӾaw>opI/c{壼EóE$DMY麉MJ5積Gwk=ݰfwsيgvXy::zuuzyBA9#{Rt jcymq :T_  l© [ = 'Gs&_)osu>bo`3O=`w5vSˈЧ"t~v #: ňzԏԕއ[71?nȃ[]\:t=}R$xJyنEi鄯kcb, Ca;^|=pxٺǯ4ԩo?4Sc)}yeu^{^4mF%nB՘TNJ_x.yCap6rfai,|zo>hD]YrкuaK~j/ԵR|B̧Ƥ6vU;T2Zj8?պTWsHulJ?\f -8uHN^= {+wg@Q#΃LK37_d5닌ݭg+v_FjnqJvCU:JT V.*yS;' 0N91v<6k٣7zf E5}=S-ܼ7&A^jd "_Zm[{Bz5GxWD~arOqN3+gmKٛo/k]QڴE+:03x'b0x]v0 Asҿ^6uꞪT:jȖ5q:L4>3^igj2]9yآ{Up(@]}5oeβdILd~L\rH|:9\mp.u% gSXKlJȉ jRln[8X֦Y8݂7DΖyX=w~6[0^vU5j3GfpTi'J\FGk}z3,w˯qtF4%i Um1[.6Mi ptVtCR2|J}nV͞ʍƠxl?MeFY]C<$!7o1;dH ҽ%]6:^m 56 g(nZU6vp+@y*f8cMXFUƒhfZ-2Z]V7ߛRfBɯEYdeT8iSk^#mN,i:YTNk"6]̙}b۬cV^i.hoh3)ӡ槭lavEs.2Ó$+ %v?FGGUǺcbr7GX5 >L7aZv؊v %_;gJ_ #c8(+1VzO /ju!TbrJ) ^/˞2J'&6om/#7`j>W])AVF-;A!e(q&*/XVF[ًphegclC%jVpmxSSZ(:-䝑g|pnWag,{JHA MP\:La ] ~-iutc^cqQ`(TZ3付cd+T3Yws~~ioV+4\k;mg*ٝSKYa.lc' U0' _(f :WGE.Sr[=/ss{Dr}$'ҭcAn M#>AS>FA;lsQ.0e7e*+o|~n%l!LOx3@HSW܌\]}rf+,lPk ?n-d.0uu|e~)$ޞ A>NI_Lh{˫;܎Z0eEψآ3QPa%uie޹BrlMg|BQ7/QWI>NH~b )pkoe $S1F5F2e}UǘZ# )l_\n+\6Ke?w.sؐ6%͇ O9"HD4 8v9ɜh:ׯ{6K luO)|Ҙn4lcK56jf 1%>siU }ҀON˜Voun/wꎭZ>ip0ǧyla'[xh6{氵(XU4]4=F#ʿ3(H;H֐K۔QnYR~p_Wu,Ȗ1hU*>Ŝ©ɕ!u $-<#-z2{f)k"0cm@C-E@QM?f?Wӻw$ɰq_7`!-i59sjGXg_;2>)~7x?X0bjLK ic4 P]yvC ?qa D%g!O; cĄrր'2Xr +1vwX"'>Mhbs )qVƒ if%c̪A8hڇ]ZB,hQFbY@|mGr92 6쬒VY ՞*g =i)C)Εc],s9ñ=;^ڐ~Q[}@-;Qip9u@}K,p"RDjc4RVZz@+ Nx#*k(]uLBm/nܹ )s٬2dz6Gz8x#sC@O}@?(9*%(ՉA?崣!gw9Ak8@(n[|_޽T_ -,q}pCܚ FxMj n6Y+XK`[3H]wxW5Y\y5Sy 0VhÿqG}7Lŝy&"*!vaUp[@1V&:bwV,_waH!Q2ee`gΓhx_?%ɾ&ʽ7əH$gXktr 䮴J BXv(v Mo3C1x>4,Ҥ0Wro˻9K?naLVIrҕɷņ;ǂzkV?q7HČdf#K%x@{GjKV:,kr/[j\jϔ}>ʙ͓BO94>VMD $ٙ ĄC&H$υ6zܭ5ֺaM |'?oxx:A[vvѷCi#sy._ܥ :̟N|O$$s PW+JGrNB$MrJkzD+vԗZij3֟v|FfPRe᱋xhlkÿ3{\&H76 uF!\+7M|UlVs9͟siDh+`0`:`ڇGN?=d܁dl>"nvvյH4(t}ϝɴ-(fM`֪ᵰOxDg yϲM$3x%#Du>z$*G Yiˢ[#eP5PANPV,? y8*\_ iv5w 3QgmE/$G\mILEPVMRzt :̿Tmr#- W]f˓HYN!Gδ 1 UHK~uJlIǚTˤ8;`Wj{.֥+kw - wDeޕbTZվc}D:#".EM>節fISC/GnԲMPjyJ %v)XilPyYgnd>K`^wMVt*.U&=)TiKtQ~c].ٞЗFP;?,oƍύ /TMIgi9 .rd*[!U°bybi>zd蠫6xS4U9}WSj/Ԥtv[/I ǰm0QVߌPЏ<~;vy%g/" [ lY2, L,F0xluzm>ϕ/ [tߤ޳C]u?5۬F!@*Hn7 QB Rl蓼y; Dyvn[G̪!4H>'{>lr:rb~P{nr5[]ؑK9# ЉOT0TWQh_Ϊ슷 Q :jgќy~M'g,̡N°Y|0Ȑ A.gܿ-M w^GM*F7G/eJ6fL6ɒ#-ޅ{0>pv.u%_j"`[2>(0nrt9""vh 9̚^2{3vM%Gi-Z.]O֚p@K&K 1XxK*3D~ rNdTLIkOLuǣQiku>?9R/e*3ffϼEI}ܳG44ɺQ*KuK>ҴF2f70 "%-^x!i<ߝ4Px!Q'?;n^[WVHg uGGe@Ta5VCbf%-hd_.Ļ 3SDjyqDl<%f3]F'"2Ŋʯگո8豗-\(p.S`j暘P&5cUn}Wv[e#S5[o?YNf1ÉV`djs5CmG0ć~șv ڌk0-#]52O8ʞͥΥ<.H3U F6n#;*VM:L9frz.`zr\kYP?lRgYp\pɴtdcCuJ9lfNeLzzhtOut.%+B[:&-DvW;El .!E$2Ռ!^Èbi} qţW*hn+5nT&]NŜbjŐz͎xi ˔ B׿*ͬ/lX#éRçr =>R" 7b=9Uqk\סu7A$G`9,A0NH>D N=x'7~dlL`0n>!(p~q9b:yc- _n§CU#mSlS B* fq(]6br`3F2+/#)@ n 0Dl$(Hɶڑ38`?ldrӈ͵IQYm_tU8ݔs\VcZ!Pe1@U1P݉qPD @q,TY*#ʿgYVͷ0jO,cVG:P ;uijM+ߋzAiͬǀMe!O_VЧ BXs{?ݚAvp.>F)ͬvG }+dXa L}k=,Ɵ1\!7>:܀| N?0968!aߎfd?3x^jf*6Wk["#HrwcsyihtyMZC'w'`wHpE쳀ft:.h?F BDsX\ۚ|yYV=geJSs#N)jR:d2{ ωnfq&{P  p- i  'S"JN!@ Yck|YםnL ,к".]I[M-Vv؍S3H|IC4k'~h2@R' Ȼ{6P2 (M 5Ta'[e$[32#đO-5-G9'‰7~t?L_WE;67$ 4`Mfw@kt$'IԱ=vKqOo$!ɾ\IVo]-~; J9JJaNnnm΄^+.t$χu&H4D)ꟸOI5Ok6o6iPp[V:XuY+ѱKalTRN>\1O´e~ew=;$RһUh  CZ9Tʋwu.tD18(Ϲaj7av;.M5:muKa=~C>ɝAw_D|ҩ*Üپz;po=;Hp+vjˋ[:}Z=3NdP"a8UYv[I]!йkPW~1ޜTgQѰÐnCa{{Qc8ּ*]bwdϞ݇mniⅦTl~&V3_f><6Ŷ^įnsdG\7w6 9wE;۲wZNGXB-(4pkBI԰3Kn-ˬѺ(9gDQs翑kGXuѫ,mf[G}9FĞxVBA˻fȖ#h.us2 $SȲ?rZfÝʔ8.:n]]x?խZe4J_( oBe]~4ˑtɇ⓭.=-.)lqӢA?}6aQWpYXi~Z`y_i#QXνd)tar*z{^Exўwݓ`tq}TGlWDϋ8؃ AD/rk뾭Z.o _t8uR@ATgF[%E>r׃Wmwtg>nI,sV*vleA}dM2ǽלck"/9Ec嶎ӰDĿ2-4۸шkɀ4km?r2Ʉ^ vn9vhGz@f= kRy 9o]@eTv Wzk -PGfo&{_cVh}:zZ1M|4b56O3U7n@BG~Keשʯe Ieh4r|q9gJ&0eDϼS8(_ݣ+kMCh{12aa`0tmvug=hR"xT5J}c(Ԛ%wG%8f~Q`LgLA":"OcmZjvݒXV[AJ;l]`i.|II76r+UvcҾ*꣘ Hvő<6qj|;(~c}o.@'E{),M[}N9]moTT4^AAzEyi]_ߙ<89wrƮ<%og$#ᕒ[BZ)[+hq ۢʞz'W}BmNݹ3=ܹ^4N9Qa. ?&][yZ]17/Y(gzvM⮔#cq.bA}EI߄UO%+zkrpnVf*Z˴YOct3fanVL[lr7eW.C۹bmyx~WV2W񷩒G;KAz-"B?[8ء-=aF. \`Q@څ$q[\)"_D>s{W>7j+]9uxp)wMSYg26圶e.5ȓD8?[eV)s4!XFS7h*ZꀶDs@gDVЎ&qM@{9~ZIЄA꫊: \ع(*N/;eŵc}2YE\(V+.ʴ ᕴ AKp:`rNʡ#yNk YLw/ӇlπL84!`0NI ǻ/Z|֧ܕ RkS>y:ƞLV]dqUz/.)fF3i􊀕Wsؒl3j<a': XJ-xN-c ﹗|cw*<{A093!,eHlZU&)gE'㴙jyU= ' ɉ| 0x+H2_+T~ )f`jvJ>-o(Ṟkg=Bpl?GY?BC[9<2K@W OHl $.ِč.@CHd0I 1K3ajeE+:ZIgYX푑84J+JkS曆MN4l?m_|leƒ"P *IJI%7ߔ Ǘ6;@0P8S4%l(392p!Q7*W8tZAxfzy|S1ED h $H2xNy 1Эt;!OP^H\s[d5g'piYiqJO7S%ޔ ̞%C"7" $n,H>*l?*K6], oS DzHOF {PxB!\~X?Fwu}{gN⯃{CimP5Bi@ZK7ٷN^}L)x/[rx\+h.2q@1 Ow͝dtS0TzM:Y3V򪐆"9ua) B8?ްw"zkҙ\+' endstream endobj 283 0 obj <>stream Q;rϝΎ- 70^ב|nK}-ͦ&'LcO> mBLJSo)d>ng% Ig+v {o 7ڦ5s%Rl+ݿ!7Fd}N0ҴZT&!X4;2s|3ՠ#LWsz:o|ӝfoόVnLtŌmRjӛՊ8n=Q3־ƽ?Y<䰗d/G-N@™0dUoݩ娝؝|ڕUZx\5ҴJ_m-B ce+ZnPBe]i*!7wnInxlmF<-J>s*;ހ*(f *bat+|wLIM%Lh-7Z[8J7`(޴6*:oO#b]YTB-諾K;O^yJ+ƅ.hd.gw U\G'n475Zn9nTRI>uaFhig̤^}rĻ."b:A߃H¨` >aȣ/ 'U,nv\,Eoh^d8$n ~=2N8]aN(N9?JăS)#)O6nWeUgҭ ĸTB-oOJrA8~\(#}o'/lݺmؼ9;*҄V_ĴmV+XZi[56ɘ/LL@v&fV[Ä*rԇ-jY;Tʓ{JZtXsԸv(kzs{ L6}>v[c<QsVp&viƲmxt=U&蓭&_5j\j?*jc rN#쳺вOaafkuDz5>}פ{NY_+}1o#h M{H+Ȝ*m~s,%T?{^n.M~Wч+58=ǻR5dzڴ&;Wr<,X[5̹)ye}[zEwnƯ&"ߝz^Y/|۵)6JزpH'񌉨,s\uMG'-5rX&澀闑W_ٷt.0Ab1uu/6JochGjb(Jo*w)>UG9>3=F Igi8Et @,tVy< ~|hBTڑw17h誻jk߫os\i{WYʜ&~=NݪgUK1nN~7l; `ЖF{i$Uw!NNA0H~ ϔ>=+<7l+fXiج4QgLl8'xiIηfmukkpSkm)jdి7ɃYJp/ZA!,P1k̫'y\5;- rd4Z_hMӦzxOf]+~fkA6# 58r:;J7nHrҸY{ΉIB%PT77ė^gj.CN6Y|YBޙ^F?^$'hNe9s*9adґʎ5FV  ֗H%S553_L,/IHƊxM TDkS^[j< sm2+_ski]U̶tpfaR"s(XD!g=U2!@k1:"_R>ScAnUf 3R7%aUY|ErH8{>X~5=r Fw+.b&E?TiiQ/5QvIAnDf7 2Q4 |8 Xr`\kc\*ћN;ɶtG{VVYtMZVUPz}5+CL %A!^Xd_ M[HB8gSi2lμK3iGav9EUVAnkUC P$kinջ|)#d%4]7BiKɰ2M摠\?Ief$%ôoc<4 ZX5j60J\7 i=+6llJ5Ky[m:ϗ.5[ĤIj 24fXUu>>+&rSuĒ5zhz"}K hi->$'j,?(#75paWP{C1IQ\ uRd._!#O]xFJo!iJ߈?@W@r"Pp=f0r{('9SZoťz}w}v’^/-J<߾++9Ž)sN4]_(#zGeYzhw_@h*Кp h1tZV@Ъ`coӅQ6+A5<y231?>&_i7]mP]g6 0fPQYu9LNr/֗ZfSWoߐd 7J%s?KrY8/^֬ 󻅆)7 ʈWnu.;NHt yS2Ԍ+9GZj IF\[`.G/v9{|XEpk l>z\eu)@Օm/?6^sV:L s.:uamH<>Kc[/Jcd;c|q%~Iu?e;xfRͲE?PNf 6:9 34?9B6Я =sVׇ";ti,b%14 %|=+,)̫,7;]6G_3:>/eaN^}rxYxU:vaҊ3>VgX*\E,uBwS.!<55t6%ah@_\ƑqaTҶhx GE_R /ڜ?\zPL oJ=Ut.*!=FKn/Ѭh:xF¨0}\bO=+GH>qa\J%/2{fn]l9ynp"_Iyo5˻iVYMM3h > EYo em25=Gnoo'GE&x>?sYhkclmլX^,膑tt_⪳w ;{MTҳM}xW*f]vBtfr^;P=C}_ǶM+ s(JFЩr7綾wXt:\gS͖Zn<20ikQ̼|~_.yr͌ctvdKѶɥjrG΂p%bWa,v`F;W-c@=VJ]RM ?oV5/rWoG &U%<Ǖq!'aԼ]{51o!v2g*c eXZe ]G/3]_g$Ymִ߽ӚP2lV7(Lʦh`)I("Z}V?Z&EY+͡__ԇ=Ls&%c'zzy!uE8:ˈG4qUn JӨOٕ{u,}J*Itه,S;ϯcVΎ0Ul`m䂧"nF1\Q}.wnU}]l!ccԋrf[. 6hJ[n2s2d \EF enHY{|Q`Wӕ/5۾ZoM&sA/+!R7Ls=2b9cigLY<݋RIRT㐥W=?ӈ BlscKr / asA;^N;&EΛ$XUlhnpMa >"zYaxi>!hۭR=>*![x54tgaX3)۳g'fPfHCsJi #b]'O,_/#. }:P.`"V ! 4>^^o{R ۀ2s/¾ŀ~4sUڸW{P$^ x뾰2豛GѺY& ?~ICL܄ce;u [ЙQ/d-?yi_@{nI1*^hƋZU,*ϗH|#/:( oC`?PD 0p4Bӆ  &eƊ`m,>4suUi*i"‰F٦<EkΟLèo'I_>Xt ~TK/Dõ XAf7ˉ䳉̊78xp$2nlBpgN"u*g~8e46")T;Z7U M''7 `Ae}#Z@Cd-I"פN"#ff. Hy嗀`Jw@_<_۾FɆS*^\l z=5UNQ$i|AE΂$țbPy4gPA*&eCj\:D/@ 맀#@w/@w& BkW*fxۏ4uAKRdtDy.TCa‘ǑaM1.S< ;'3XuJb8Pv(my'eԀlrt׹B_"z_5 1#ńQ92f/ƀ3gCx&I6cppp \^$(Q߭s7uYc4mɵF V,phjRA?_'q Kr"OV Db@.y IGm{@wZg7=KZވW\\Y?~褦wk;Ne6?YwYKD@w= /z"9cZIOhYǮ@6H"fVΟW HrF!{i.jfo_o7VMshr54@%P]4@]Eq}X;~2Vt/pX{ H7I_֛Z{ RkoQOnuI7zY&d+09N5! _?ٷlڛ$2$8X3Μ)>tV{Q[t˗;ZV|f=WF_ORrn^Z ~d߾b-4ӻ5qtݩr4^sYO;2r=ڔMĺ*iwfq9jϹ@ge4O,]IݮF}cu^u ^:>ٜMBw7:vx.=&G||]Im )CMZ7.s} ̝m&"$L8z1=r=l0/vU| CY!Rf+⾓ȝcNsK7K?Yek`W2 lHfq?::qjlo|U}:ztEGrmz5[=h>7URnMoWpr^Tɡ-*H#"u'oxlxaXbe| sM}5B}x)4F6ic\gC3k#9J@zlpSjдW}jYemѯ\XՋr$5s0*fUHR&bUVQTZF~ڨ{FƳMUQRTέy? E+wwTb^6<6 ?E(Kϒv>NօF&ă~.QIilr.jO\^njs"z| 4zyձ3׶%2`9-1saTX\ņykB+u(yx{cmu_\FbQaϬAZvT6>Yw\~, ۮ3BgzDЃ =9DV?YOF79lD[40 +UQ3,uIpv?w3PQy5^= :ֱ^h1hj D_+vn{hG"qjsFo*qR ST)0](quL%ik痥;9$x-'JvVЖGf1s2"&zcFLHX}ݹzh|tm7Z9@=N*a/zR,]%.v'}ȝ\e#R$[1 ~쉣n"]''w >gDii0Dz?ք{p&?kZm?~5H@!ryq/,Y~.wG6Zei<`梻iv8~io~sbmd6&*r'ʢ6|}Fײ0*p͠ڎ(&Ƭ~>zT2ʭy)ꮴ6*3cdXb$DUq'sj6;cPilTq^e60FyOg?dN>v0+7/* .$ÎxI0'%-g'@dBc0*S t, J`DZueI" 9zؼz"D].=u62a~@!=W0V/dN?tQUcsE߷}c/؋~_[+Y>Iad9Q t[)m]N2 l]|z(eRKZPJ(g>J (I]4216=7gU.; rqLa>6l3q88fu"YM +`H~&$I($C0ueL#p)8]&9c0 Yrg0ӳ ESgE;Op'펿+q3`0$,6{qh6[F2-5(smel&6`U,[%jF݈|NjWgB9?;8>&Dˉ*x~xSV/tu/5 K|%yэЏ%7Vpr#W+-R#K>fRhDNq.!{DK"A U ִPӂ#ါr<~Vo*QW O9 6=y[137f>@szƚyxkE! ܒGQ9"83-a9 ?8tȀߊ _I+Qn7X8,؟8րXHX¯9 d³yhw NYѭob($ܘFjBGYk;._Yv_wXy>y}ye @vܶ SƛJ@ (Ha .G{\_]VBIPQ!eX ~t ՄZGņGϯ1_6R$uj}=ڿrh FL:sFq?MD$3^d;8<[F(Y{ys]",\zUJ1Nq%)Ju1ӐEL~8ֺo2P/Jr@ӵ!W/ ]f;X~ XX,z^X}n@m|3~29T3rF0Űpy]rզ/ؓSPMǫߦMP'~g - }A.Hv_WCgl_8Оw'3\Ϧg'ljVzz q{ok^6 3m91VoZӯ_W>aGM*C*\zFM|Zz.;Qu qcuC/@*hEsTAtg0bjRUj#4Z]V+ΩvͶWl7G 'nێ?iLYc7 j9֋m9&\Vj`9h;nB{lѭo@RxdCl;twAY7o̳ $2EM׶6_e YeC|dc]Kq-[꧆b05 2,L̪{[~^3y꒷sN+>>=[T/~E6!B^|ԶSq"D܉ު%pPF+܈b0E7>ⱐf!VV&WTa@V#iiTz5AԱ!JʯIgV̏-_b1|ݶrlfVt-\MV@2sKQZ̦NzLfcb.Dy!'/=d:v|l9"{Z1>&W?hO"țhVi&قOZZIph-DWrIM)ӲY㧄̰zkSk*F'v:*IF뙺UO<]) h30u ߚNU!ATr(H3$%Ql&:eM]zj)h_QUg4&x󙨂3y4W+Nzu=&XG|\Pt;=aCݵO 堼z¢'H#'L#ƒqfV|b_zD`yÛyBb:]q]H(o/EGU6G6M-'TI\MmYt[aKBAPV[U)fKO1 (b8_]N;Wj=ae3T˸݈Js1N?G,zϻnJ>Ak/Ϥy_WԜ6UUO($pWs/Wr7w^s"ܓNEǙE%W4\G'9ƆH& 9tTψ,\?jS292{҈sV|]YK)F9kF%\]:dAYI9kbKDΆ_U0?F8odȽν"ZzoO dg]KGnFH mב>|mVa27TfT*%XR;kA),WUSjtIHBx\b UN7e Yi6KZ{&?(L`$fI6arF|>%.td:j~=)R}/ײZ: NRHjg/(Fp/@<&DC3#epc{[K.rSNtY6tfi~fꉥJYEIzۈt9xW:ߪQᒎbI|ĵ]q1^~HjYP饍վ`XTg3SK&9A4>`%7Iac]6sY1ly+YJ BUN!;ALQ>a5/ܪs y  Ȩ6N[|tR7+X۵5ʹ*}T9QpC>HAkMƑGbcށKޜ:DJwP.g} ,qU e }l("DJŀRQ@o@OW@pkyMs_G>s=f"* EY=H'o>:wޟߓ]&.( WXzdOTKLg$`Ŏ@5xu0aNfY*|f ӾVSo>c'\5⎕ 㤡Ԝ('JGe:Fur/*GrִE=[Ѝ'`(Gl1zB7q쇚A]{(`1`7XN E#۵";cYm4bL|>duY`aV7g8[PUDnY;|"vߖ5 .$ T.U%um+hju[o?QPY ^z [!>@ 5 .} *DF &&:v=f) z"ܮϹ%5-uqE3 ˡQ9a%X$?IX&_DZ,ca$pX>' 9/ {m$Ͼi`GL×@AF2UkաUd+ˡEJїۿpQϜp&_DX_4ܭ^ =XQ/ n h*r2>@Μ xVJaBt>MXc̳ohb&Tw=8MKjj&"ko0hH>Axu/-w BvfZWf9|yl5jbF:$J{֋%E2{_DZ$6Qx' D D=[߫y`90 l.N!ס՝%צ}ШUAaob~"ި:/S{ޟ =PΛO`SZ: l {cr/~'z??Keߢ)ٖꅒ~ssB^O4TMSotC&U3ccgiHC n~E1_iH'Q@3-= Hd`CgF3@oO餿Rh5CJE!` >Qח\~^z9AJ&t e"[TW2X>{I ^t:#o-/DVnM&Jj.1U#jv)EVVHWdOeJ}͒\V;J&CG|tnY|v"PYt?B_􄲺C_ڤuZ:"5'Xu%5gJrRLV҈be^^$obzq=SIQlMkZt G$nF|v9te6#4壞&[-C[S#:ߝj.^sVVpT|")aR%F*Wu:R0o{(;?L(NnR&\t S jLTYq(VJ\GoLʧ:Jf.y\7MN}u!^%tcB0oR{*.hsd'bmMgC6ch6.^fVRV~SCɫ|V/g=b)KTR;ɑ:0CbDM΄[=s?j ?Po+7Jq. 9U6I8WL>28KcVkQL2vY^bNJ櫞n-'ݝm3"2,ZVr$\tJ ë=3x1ogM\8^q߳fz%c,0šFogkj˃!%yLI',Rc"RmJ(wT] +j'ٮ7q}8[qr:=岙]|̒]4 3lMasCMդJIqlj2>qvD;0^ҭ1|scDSģc4d\],A* Fo Ǵ5{W# 5W`-ƌf}YK 6,yL zf8>|N*JL8 -AjqJ$.T8`'ړYI %p WPVHpM#[3B"O1crvx5X Ar؈K}6mƶ tymQP7uD6Ӕl>5 qS7ø[yaLLr Rͤ pG)UF/"^4Ŏxv3}!sTyYxWN qٹ3+N%VqXKmJ;| 9M[-:cn_~b +Gɨ/m@* Df b9Tm`嗶G92v9??>Jx%NWgNr =tڔ8*߫6鴷=oCq]앬֙a Yi91(}m͎M5@t3@t gm y D\J'n9kg58 d.v\hP9;G)-<]SBZ`KMχ(Y)_D`ԗ iևR!d@2KlcH.$&=@E)Hh}=:5Eѽڋb֍~D 6"`%4ٻul}`l1`LchUFF 8Ae~ Ȟ_"~ߚ^W͒ڋ҃u~{fӃ ;#= lol%` {Wcp5_|ZIxK-ի.T'??SO%uĽ|33&^O~ s)txk9 /S E h.wsŧ~}}A |[C-Yl2sr`.2w)jP?c u:FF}EM@^<'ZH\ iwߕq}m-mp4?LS158N5=!@+ /|3sA&c̝ CVn[1qr;@飮59FjPkFyҨmkUNXZױL%ʝ)d]?bm)IU,SE5t HE6[~K6LhW܁Y4WqKn/|S!VnIrz9rݶRVwF6+i'uB>މQ(>d L I!S{nh'x]* (@)2줝xN T'ݜ$Em{Dy %4(*V=oRȥjFZ6pZdܵ8}E?3ZUڇ5մzq~D&UO$8vT|Of<-8~\윶uWjwX%.D_F<-ܧhَm\wFTߞa*v DKE@"eaF~-c)+K'h=Qog ,:U!ft$ZͮrĒ.-mzBmt&=ұ]B_NO@O: 'yG5^;f&3YE"b %N6^[=@AhA Q-MYŘSJ1*+w<;+c!LE62)*y^DYu!uxs mFhR`g)@9bqR8nr%z"I$oR%mhiE .-S3"iMT1DL3* le1#Xܸ2[RFR|6\r{%o>nIܬ+)r8򜊧{!:el-z:u;`*4o]2$ js+{Q(v2 Rd/ɼC%8@dl%:2EGu5 I#Z)UeM:lC̶btlnnˤ1b{ȍ6;+RMfYYMXLFVJ\輧>W'N6iMkO\Z ɚWQ,a;zRovH?):XRDl{Zk0MJh\k>~&?i9%&-uUR*J0]Wdتi¡DQ@)nxWDndz"mQkp"Sa]P X,im,S{_٭I97` 7!Cyj?<(y\ ѱ w̝.`Tm<p}@RJee"PG$SePn'@栾l!L.S5H(e>Ŝ56 Kk7H]H׈N_3Qu dls]r!uƀoP }(B>RK'ȵr,8T??ycNdc$JeM7Љh3eVސT.^t6&n:n*m]K*g"\=wU k[@m G(;j.cEˀFNPZP=H#"#tno Kz\R#[j%¤Jx3^ir8m@%Zah;`y4CQ)kzoO@}Е1 GFe-)ՎOH} 68` 3ZlrR/0ZpoN?Fu?՟4`#XrOwc96ƀ)(5ow=RYe^lG3"/O_gtXֿY`_{N n0 ƀ+΀Kx=,`O-g>| Y&}e|QZ)%b# V94{ [@(|@pb5  -"w/# Q{E֢òÜO9C T =F?-v_W-""[@P^; M Uvd; Ĵ8 D/|88u_BLX_ E0*yGY,˯'EI?s\uTGVo}]Y@{Ϻ@N@˜C $(S/S*!_?tӾ]B0*Ai ?ſc3+p64n zoAhvQkUI'X0&:~Ik-< ?2QLf!p/ ZFg`/J@ m(~yG Ě@ޣ镁q`"Y "aM3Gn![]I'<.QjVdA uیsn>n,ܦ-'g$s3X 򧈢+iX*xTڐrrSb*եCwfݝzt7WvbJ/qI,}[n=9|9ƣTi*M}M{~"qZ"?8?_m\h_i\͟=JjA7^TEmE;^ݍ WA/6%=K:&/w5TgxwWKuRDp5?!={ W}Tgx{/UKÛvo[7}T_ٖ?Rx_W- vRx#{8cG55{Nx[ d*?Jng$ /9;kö}S-egh7PSVx]X TX3?y,L/u(^Z&|k~V_mJGf瑼(DtںU]?%/e'^1:?i/܊~zy|"N^LnTߞ~v4Ιΰ^j7}.US^/ecR_ز~*]^g{LMSf\ gCbt 3qyUpnAu~%%˳| {K?{dk|^El3e؍t>>8 ]qNNgHjc3Rf/~TI=}͘nl!3fXnv]&*Z 4q:Qve5WKUaӗ?/<\B_~x<_}#;]9:׮fwGG8p5wgre[%g`K ,4NY?N/*9[,ƫVgȈ l|[ĝvf4g_\)xH(p5jhR7KuRsfF_e36fwAGOV봬FA).q*є-K')__r7?X ^VJכOm%cmub}r/pbCҍ(in2Fz=wK޵{[ sN8\Ni#2L4jz˚tG#7Jռbs9 h \͟x䥀,u]zuߒU22yh%?{kG|,v |Ǿkl9ȉѳt9F}|yJ!ȏ1%ܚKwgl|֣L6Tv37 v}~mdjk;׊ү_K7ѵvk~ny|} ѹnNQV1\v8QrOheou֥y-t}>scOH[<Һ/O(M5fS(ڠR*^ u٠>ɭ4VY|$Y6Oˋ}nV%W^INkMk;2uwWQPOژEʩ:<Ŗ[ Ĝ &@A F]߅{fy{zi.V86 ̍$Eő|bU聉~ؙA1k,AR֪@~R%۹{X~yGLۻfvOeg/׋=`Iz{f+ :a3?-g~AgL?p5?m(k׺tF~v5Ia3.Kot4ϫTt#z~ Ia3w5-<lσݞr.r>1qaR?miۙiEs-ٝvFvinG2\lEc)Q \P^:9gY:գh.Vك.ű>6섛ŋE`3?&uCjSfPbXw{>1Ibͬy3sW5ɐ|/MiqWaʗBJ~b}]qT(c}izڠ?ԔH(ST1=J2Tw%?MmE ;oE Οʋ ֞uc1Tlܭ(x=en.a~ ,KvٔlYعG(JHm^T>i-  %E$="UO0~+KlDT*jT/}HMQv0&u

    >r ]7UJ7zo-ּT27Cg3rLl1='hSd-~e~?XA±4A xa0bwmnyӮٹWnITYDq)<-% Ej1odbN%jPejE~CiL]5le )eЍ> Jl$&hM/h! Fxx*5~!/2Y=2(pTw9qij,#Ǻp<0E)Ë>BRN}dѻmDmlD Qv'橪\ke z8:$jMhZH'A\?CqJOr.Ie!}J}o'v`ǹWwXrAg8SC(ݎJ}Σ{("s؁;#~>+*ӧ=OU]eJ*VIS[c-613'bϋpˡcn(mjo)`~\sUBYnla;I^*Fwqn"KeCoֆxb3XA@bL8G4@g lL1 oXP@)!?v5+ODsl^?zQozz"k 0"Aʝʆ}3 mp[p9Ŭ/ξy)*#6Og<]^}adk:CwpnuY_깰)?t7ijCnrgގ* edoZR?J~?!G*h;Ф.C֣孲pĐގ5Lr?f^[%ce"[MmIsיHLWUi(wi} HaO~2^cC6i\zqc+WLK2(ҼܔTSbl]0~pWt>g)IPÌ`xS}?KkװirxV- >YZ(%?75s>o3ǎ|$wN 5~?@QZƤ8|}.V3gD&  dFF3J0S!p47L7{)[|aSKT{~ߗnox,jxTic5f1Xl.9̙̇8 +%1$ @x3o:'r`gFLG PM{R.nL_:T1F5ddikZf>Z9bp30twyj7|-@N:z.@N<;=B<66F|υ9=J*|}Z~}o69q]}bpT7De `X Xq@}u% ¥cʵ^Iu@ֲOW3JUt7j_mjTuw^ަ\c?:%젣SGxJkq91Tҧ~&C[dh{}⩽("**54Ξ.#JFKcp0P*KOUc͐ Q}Oq1;oCúZ|+%6J#$j2Hm~jM (C~Z̞T4PyN~ }<Sdz]hY?moFS93gQZo+ގWk(,H̜jG|_H-+8C%=8UP$V֡QMKU Q~Sݤ {oϭrde.|);'i=?/QxS { у~| 't'MZ&?c7Jn gV+4iʵ"8B.R!1[b)a. ut6E|JMQþrvX{oś%eT'Y탽Y:%hg]YƁ$XŔFp%)fƎ7ݏrL(dpb'_#ؕعw\˧.aS-73z3*Kb@$-:؜-AO+n}|Q :&H0YFQ_< Τ h%ջ鷙_[^QD%)J|BkeY:",]̍k ";4/G{"B$2>]" 'z{ ՅRʙ3HCv;\å!cV<^k-.Uc|:a&3y)Nt>Ӹ1=\9P.hC|VBR}4s"{ j\7fQg3 +t~ zёc!F/ȝ$M2X08?q b@>q:9l :|'IՌJN!5=^zcRşԦ={ hɢNࢧ _@t5q*&|PŹa^2Zɜ/Je}[;.@vd'= ֢!im9x∀k&1patp3n9T92ׇnkX;nb<7o2'^a -s1G~ԛ>B8,}0M~?*{A08N|7U=kd{.pbVZbsZԡio9Q mMfKԆFЏ?0tp_ ހૈdr{a7wʕ3i;âk jϗX80>~^(8zlX 's+J`VAJ_+ZWC}7emmWmqg1)M@F싅o`୍Բ@Ѿ;ة{½aOƯ^/oAvwc~KIn%P"]ZgzrI˯FdǃU[o:RFfC7a/޸!G'}6@ۜq霂?)d0PW+0daR ]8U;0KF)z%?*u'o深bs]DeɵK;ybV]$OJNOKNOKs5Qt_?mR/'Zu(uUCUJ=m* [{  b:#]VUT Ш8䛨w섖ofC:!@WNr~|;b?y.VK!sa+-; +D}@X R RkZsvu u@GYv9i!+w-KFUpI9zHW&2N͘=C}f2 8ܿAF -a*GPHFJO Ͻ8Wg^}[Q^jkg23ʝ*I;5v7m=GFmV/N@fIhR M:;Pfu\c,XV[G}ݧ[n]cK[˚n̩`qB\YfW񻈲VTTc `kJuRgVv6vWN)8TU;>L-4e_2$4uP'@iFUZĢj.effs>T91fS=kg0o.x;CY^}n\=Q5&pO (cS*vlI[_'"V)h'\o\BNg=7:,r_/ω08;: ǼdLR -ص/k muHCP椪T˜Z>!A 8KeB_`RgHŊEvQ1_@vv gRXQ@oA#-ri}[ǣ'׬:A5x<-?l%.7mӝ?W&s(W\|?F7v %d5[&]Iզ)c-sEi]-ueZ}Ymefg,A@1ѴMf8)dƗ?{Ф9X̄Fu#`~H⩇nL[y1mH%B+Ƣ7gIffw2߿qju)F6+R+J|-hWk;ht\d:x'6˙u)iقWD7:rNSYl42K]zUDcYnSM:E'9٧-ԛ!JtE3g}Pg8/qi8}q2f)3OvEoJ))RX==7qZ\Z:26I Թ4gkhgЕݏyDv)ʙ'#\FQD9]]j{g=ٝ72Uan6Ls|RH5DڢRC+{S?!J ? J1k>&gs^[u:dizkN92{3Uȯݲy engTŧo}\; S(1ن sr3ۊ9L3␣,DǙ /zlPVj7`38XT::@@WXMa+\zk-N!;l=oJب,au2̘=lWQp˶\{ j黿NרHYuO} dFjY9d|@ b zI+XU]f s؄@.9>/_a%8*O:8@E{zO- ~W2W銩E3١DxjU1ei9yz&6al|$9ԃ 1s![Tʊ7bՅCBvԓˋa)}QJ{ߙ.%nL=n|P1:\אq'>[(+ 𫜐_e"Tν,NabʽR^^^7aN$1&.9gjX]l1USi!< g9o-繉=ѻm @* 'Mm_.#{ vҳ~vCj}Mr 'ыů۽-o"t/N/~VtFFY_/\/wΨ B?oFtJm`q2ZxndS@(xcq;Y%|1T'wϽ/DU\IK7no7a9v:Q~A菟|> PzpqiRv41#}=F*BJ{nK;uĮ˛9rX;Y[ԯś~, $ѻiǶ%~?%BEHCu?g0?P%qy 1ՃzO9P`};0^{osᓑ\*R̙_5nMZ`L/6L6߀^Oϡ5㟩ص kEP,d|yƎ=b!os^R%QF=>;EneǕ:2z=nJf8{Tڄo 'P\Ya` YP`k?"הuwI]rvHa>)Ww^kbxz 9JPh`A1<lfEyWt-dKaIAֶ697t+8I2t[ڣQ(, Z~P' 4dCk7(5ogW+5=t'Gt$#xPZEʓ;leY1Q2:7̖XN[d@zQeebTW8 JLT(aϿ݊/yβ,'D]61gf\Rij*(-Ir-[gClgy[[!9[Ozyr!l}K<]ֳydtKO *zodZOezbV7VT(z\ ԩ]NPú$}yu˛t%U)wnB73ؚn(>)XZ?yO"?Z3>[f?!?Ф#'YWXR*=qܩ]/]űyg-5&-M{Mfkgܓנ~=\ǣ#<2kTfiD!|t~q4pƼZY}vcE!2-4z'FUȵ}kUYWq.Fs-]>G!!bt, ,\s2]ΞO~It}8 PCg`> 8Jzj[rY)̬*k-=$KiTVXd-&<0sZBLy븟`3kKhJ5G>t|X'3_/0n 9t[KQ3eQ^a(%fo;^%G*y8+fJ>5LT} VYk߀R*gZ8olx{M)iUDVw5s9I\YA/o5!}mm*&ul|ekqDIFܘ"hQ?IwZA!wuiywON ?nԽvGشvMퟂ9Ǟ\=527]dqmq͎3 U#T_ÉT v3[x).~zNO, :B󻓼NOU{QFϛOq//?5ZHO@uT"n֮R ^'=KSFKܹ0FrfftX6?K{iWgs㰭vRGHΟ Ai.٣Wge@|4;(RDŽ6],Gd:pz6ݦmm=Q8PkH"Ym] /Os G m.=(xρIׄnDU_6@sёPbggҶ]I+ TO9S>_R]t zx-ayjD\aHD?P#y_wyЊM^D xo]Ya=jgǣ٘ciE \:0N~oi7<^W~>Vs{Gl=QyY."n~dv aOs]OFiCޖ@2t~ jkuFBO&Jm.nH0oݾR`)9e׻)nv)[+TOz}4E}P}Wj zz6ā]]v;ˆh i򇰗2ߞL^)u9=Ao"5xs0y5lu]d%0^tȂwcoցCN-VqZuќX{ G?[Jv$$DUi\Ϙڟ}֕;]_a?[/%:di7T ye5Xhut6*/kTI}5mY ;_I a3?P C_YmP0jU)atBz%xP/-?kOf|&hƟlG=f,ÄW*p%91V&i;lnOW0/D?) :a3?- :a3?- :#ZtFIUN @x佰 3@O]wZߺR~{y|y"` ji{5e7˺e2wz?!?yzMj>ŶB5߃!@XHr ,l Y 4Or'ĢdtԅLlދ+^_+\|84ojT*@?^tVK lC hA ˬ \=<5\@%~`sa ثjf636:ND盹}°FlU젓oUޒZ=l0^p˅wCĒH'[$w=V/]}nfvSq gϸNA\3q˖;H6r_!ߤcpxL s( @=@B̶0QMt꒎f,]jù[=GsS4n ?g Wg20ΩhIBzW {3lNg=ѰF(H͝⮥(WٕTl/ߌS3a<2WԇՎ6auP I>A{DaD/XY<@,Veuo»ƍ‰oaf:Ic:\wlo[#qy CmuXrc&s'"*3 Ъ<0JPu_(.Ot`{ԧt=& ^<5:n}m9%[n=饯&Å*Bq78P`}|)9HՂ?*@s錞<@ou Rh>Vȅsdi6vh- u*NM7[RSx&KlWkbe4,E}ߤJAkݲT4rbzpf藀mk DAf_ގ6|/RI\{ >zbBbRq{f8#)sC}Aq32Or' Ǘ&;ok#'4/fZ:DmUDb8+Jb'7~TQ٬<,$ 3|> Vv}1{5Ůrr;VyvVӉܺ[eЕMy79#6a̘- v6/*yhRV%S&U t{t7r40zѮn\xԊ((;Ljl_,\yeǀX<D{MzXgkF묩ɇ@-H3w藇sT5xWB9Pf%'}"RjQ침;uLgrs4u>9S q~p-P и^ő%Prnͻqnl4wuՕJ=\w_\1e֏;5564Y d $p|QvJEP@kNq l{Ǻ$i R1'V G-j;Oq҃-oY;٫yNqOֶnyR \rrq:/_gIUo`=kc՝Ei${>wb ivZu'^e}^g~eӅW5^O1l u;T=)'e<*@aن 광&8 J { ;O6\I;E"OY˙BJ)bd/ZՍ='|.eQz^ eo"ގϐ$DŰ{OKhύJ±Dt ܓ䣟[-̞5=ճ3j{dyl,/Ng [jf2v&H AB@D߳ C`pOMpO_:`L.ҦJphxv7(ն\V^zT6D}7惡NXSb*f~?z|pu?OώF{Ngk<ϛ_s?>dYCͣE-Uj S;Ln/bKIxj~NkpPz}*Zw'usn?!ʧR}P<Pu`KkJL4zP+UU4]͍qb6$=80Q9zM?`8/6b;a[zYoM଻݌*"CPtzMs21.91Sթ7GO-[XqvH3WDl,yJ.t²>bRx8" s:U/FWtn rcQ֭x[ Mq\Y y^z值fŘJ?3J4ix%AK϶{u XAmukvhuN +DIȿ8 3uez^Ǜi%ˮ9H <纓>L҆U$ϋG{v-aD6EQPE%03yzg5aGn<}iҐ7A_>nf"`ۊ~\q:ڶk=Ea|{S~j@R8aL\5:UYTXNdb~` ;Of*^}5(H : bxP%;@,BkV< ^4ҥj-䙷zs=WՂUv{شWƗAYRKu(ս|o'P%xJʀ*wPb(3OxoZAG!S>N9Z"qU#|Jx>rʓ~T?=ͭnaom/PjIN 0 3 ؃ ^Ց+a-A8>׃G{$Ϥ586ݠ~2<^_x, HJoA^(ek`i y6DWzX} ݭڃ{_˨!ή]\]x\qOu쨵ez3Aߓu(U$jRj$ S*ȷ{M?&Qex9(>^_zyvutYqё{sm:=?=nS™t7#:Zo( dNKr}P_e>zI>knRcK}n' G.* 3aUnHw<7V1Z06襮jhQ".OA#*;DΓhd'\|X> {m~x G;tjwR1O7Ve;/?6lU^ʲsuLF+"yxTk]OA?8Bѓr!>/8‚8]jwu9SL/ږeҡ:Jd+-OGy<,nb>4oSqRZ{f#ju?tG$5pi{F{vs:ΐ:eu,`㐕h'R0*,͟"6;6I3 ;laI%WmB92@1wŷF(x:Lhwy^phI[7nI}԰\`J%3?3:Yz=;ErgZ Z,e@0 ơ>6=w,zQIst=H]7*wvvݯ|=.w'6ou'2:pbM%;!j^W]Ѭ]K|[SBS+ M:|=UDu$[G5ǃy&Py'vN;pq.}0Tt'Z hbybQh3q1NƕL ۖ9Zߖܯ7 IekziثoψZ/|R%O^=On%89A )ۗו0?Fid_#z_杝L Tuծ6,`\&"FqĸVJwCD75DL q*2N$տ>Ox O킊wVW^ܰAճ}-}0%~ٔ\,kwwЙ/WaN"u:Vr\pҸ${huN&3(ũ&^E V^DJ=#\cd-l4+}eDR =kWh-|:5j. OJ1 '垭bw+;!)t#Jܒjp_~3%2v-"6w$O;&f񷹫qJG}۟-M'jǮ/θI tpEL|(LZc;hq G2 "ɥ)^_Bß͚?}o8YpD|h+~9'v?] 70o5$٭mz;9ެ;,rMlLӀR?YIU$ed3ve a'LLVq;%߆rFꫬ?Xg\P R歶_Y4ŦT冯zzynhݖKlyԗ1:يm;MJ V[ҵ=r4]lT%!du$ [ m=GvLtƃUWzӷ]isOm\\9~8 '9=ɇ?ĺUW[гr,‚cɬr?;h)J4d&5Ojoz>E8pډơ]欽*:N4wM?Ew[\`Qn&,bM(;s2g=o’ J5YQ!N ˖A~'YXpy`r/ {#UO}X/u^o[^a7Vyefag7 gMEh ?g)^? -;H nf6L1;/Q^߲gܶ#=NryxӕY ,vædzM7!\ +x#& ФK]0g ~5 g*u7Kr>ފ;G9+;ܠ%~d<Y˳VS/ |B*TRM5Z̅O I% 4&yI Cźmʞfsj1bp^b;vCz+&3y<荒Ox&?GZ+o'[uoEZׯwf_(bn#*wI%y (,aLo`{:֍&-} g;{f~fY+O݀`9neG|O2lD)VڵK1A w|@%MFv^ w(ȡu2oFF=)qzj# JfZw]g?ĭs ]l?a) 7`37>NuNtg`mARM596[iHdJsV 3ɲBW{iˊ,>8t!n^+J.H%1WjNOٟoRAΟaQb1X]zQ|Fll<[M~f hNWb=J_]s@9z->'+F|P+A7+!:XkR˲r8J<[s3qj]$1lDoYOX'*fTGB+ɸ϶A3:'+2}|q.̱.tw] l.2o w1P7(u/T Ucl[ \]N`gv4L.I1鮒l] "okd~m.&A|@<*6ȹ%uhlζDWw\ 0fV 26JZbIz uee4N^Wt͖Z * >I Y328Ex6Ύjkm;u` ⓑ,I@˾u,!t=NV%h||Zq;rMaeAarܥͷ /gD+ܢ?ק]1qe!Ȉ]={?fr~Ci{s g\ommV+:dN@[ڭj<pNvamue/%tŦ¶Iji;Mi3yn.gHz/"ߧwKU?j;W 9‚0Zn|2/p@L9~۰{fzTv?UԎnfGuQQ޼Dm]JˣMiġ_Yy<; ZXl#γ_F:hkoM<6*= bO.Km{tp5;7e=ŨR?cF"~XL;;kZۅ7)Nd]QfKmJek!3U?/ o~}k6] F1f_w㹜S:&qўwr3.nQo|;C\Y]44 7jQ?_t**[y:x1ݤ2,SCvs:c jwSk϶HOC涹3׭+@?Qѡ|.1rDK y&ؚȀmVC/;61bS_)Iϛ0Z(hJHbquWJf{ᕦ%*]<18ŝ 7ms d7wJ~۠wv3+\͞Q&|+4( IFtyN%J*e6Ћԗ Mo0?,?-9vi~XI]'6mn M)_F[jvj귧ge/JI.)ʍ۶p~:_zWrqLR*MՈAJD6 rR?{4"@%@=|L|] co @H #n[HcxߧwFiF?৙LD-@rk j軇A^Z"LݝT- NA@'7Poww =XYwm}0bS=ZNh,<6"{|l m!{iJ , +@y$hQ(B7 AQz95$pʦLCaR84<n:@.%](~F^.7 f p\SR< r *?fCǿxhxޠ[ izx< o=c0nijS;;h1=~tܛ-O[UN poYWn@[nF7@w,@-!/$ߘFr?L{&qEӪxˇ,"=0oPФ0:NEDvDQ ]8ϋI9ߠ'[Y4h!:vZ$_KBW֏Qek}#erfJ {Ԉi0\ȷ0 lчS!0%KS9ɋFp{_û2ᤝJrAm^zl{'Rj5*;2/|m]tyqIT${~dKFX8/m5'st>9Qbu <[[a.]4}&M3k ŹRɾޏvMl V}]^Ywkto>z{Ah1noU|8HBtQ ̹⧉SUCDuGU'y};ʴd@n, Pbc (scZ3J}Fw1`u=yc_#`Ͻ~c";헷ocNlGd,n>v]fF:)S< +ɇx NTr(^W~LGGWnw=jGa]}4ȩ^'] X|jQ^=T_&m7fg@Wɗ 5ނ=5H]qAkUwzw?(j%^%#N3u7l`[va%-i-L]*cNlcFv1읣4G{0eN{ ]sC8)[XOg`>\J5 ln=`|ohU427j* xidwaJf}RTݽvr9Oފn3W6NZ]^K֢Z̈E&:UԙHUKN?̲.7=?ّڥ'9(l@{RO>W.@?#]f1/W/.IhqaF]kfp9[m~q3EP?fW-.۾:)T>K!y=\vO@2nm=W%hC ͋euCqJz WwkakY>xJ;=e{ܾl'/;ꂄ8ӻ l0rfo 8:#F+7D৖*;𴐚"քBC˟Od7gu0K\8r :13=t3gRml ]ͭwzP{wu3w֭jɩfad~!ƋN̶"-9-#O_3at*Gor2HE?+Fq G|&=e2썩㎸Kd鞹]+uY[5Pd c&=>Bvirx@Ҡ(n ]̮Zq^!sӨ nQt>C(,)чrpbۂm=luwC cs>%7tKw VĥAMm҄ vt+q+M{ L8O|pR~eΙF  L;#*,c7ˤ<*;PV~wr=sGb*;"cݶ\٥x|QhZd  @0e Pu Mz'pa}ft_(e|E߫qE9j6K^q=9鮓ecɹOhf,rkbY>u4Y=_;_{ku&M~k1cڂ#q[ @;K5ڿ2# i}?{ICJrpQd-Ϋ1n3\_ѿ w'b;kWq%`8Pp7ƹfqFqpkq+t1uG4{Kk/AYA=rls*p `WOMfga,XBi[fi|IH'f5R?!m\jaX{.-T5W(UC][iC.{qD|C8og>c><.]uokRm !͙!T!^CBGqS{vRkڏ h]ƴ%YCl Ό*+f6mV?].DqU;4r_s[A{r}K2읬Vf rU\O>Fxp4E{;E kn32,ZWЅi^`x-ʴ'-bՙιxzjl!]~0+eNޚ~5d[^lR/`P3!2+FR+?։KVeXlwIjpPνmg?AîLN>³-=F ;n$p _ Tz rzM$F-o[5bPmZ}\ _ (e JblF͇uR$_J6@|m#ǷA}\˃saP&+ נB@oZ+vRᲴ]>!w9۞s^ێ^.C g9+T̈́z:"71ڣ31Zi̝6w^U5*.\^ ͍]zyf?kol$ ;|Dqt}(EbՂG(%.B}dĮ2nZx+[FkF_e?zdA-ߐ8sJuh W(>/g78q/~&%-^2M/ ɬS+:RRDyW&Q]eѿуKd!ƻI`>}}?~ 3v`{%fHY/ŝޥ ^HV ̫wj=r& yO!Ijf˷c'+͟' LP;BJbw==*#iA? œ3c{ ow$~F7x#WT^GA<=h>]OTg7@VM_"2@x 0˱$9I~IC@VM{RSJ^ܫ_{fLm`ׯ }1/%J_ =YMR|~!A {ѩvlͿ{"y=3+tqpHQ@J w?F'yO9Tm.M6Ϗg}&>6&{o/valobokt'/>87 P͵%뎽+y2C1  G lvvx'g2 \8$}t;u\{~fk{E|dJQ -͚r P3%5q񸉏Bsil9Dqmpl=q./XOo'V {yeۛ뭻IYmu(A`eaD̤K mNW=3RS0<\Е#~۩.~m$Z@ MbYshڋdguoġ>b3;}@=.+ԍ|Xj/V R(5QMrɃfdsj2'?]|Һ՚4-5ק`\_yv)+ߐ#ZqJcq[`uq.߇a:v6dʹ ^2t>KodSL:qѾ(5[>_\uL/2)ZaG-F/;I7of;R3[/~G?4{΢>d ]]'1[csj{2.;u9%9IOH89/# cf΅ʂKf3‡Ѡ>KlQ0IJo\SYV#yں4|nbM }()vNM݆F,rc'K$[p3tт lvJZ4[b? JOWwحA3\k6I e.CMib1%~v"][<leoj6pģebn\u"OC-"4GQl&q-J(uׇ 3]RUi(8 ((Rzqvt]/5WVuc2h3iB5,Bc롾"sGڄWK|/Աb̽2,9鲹J!1HWqWAr_v ؛\x4m*@/)MTbN2+f\+֧j+ė- MkKt/ ΄VCjjʐ!*S*OlZb-TѬhkd;X/( ̰HSNK$gkZOG΍ɞ7,T0-xumZA."~FK+ #05 i% T@n\?:@c%(u OwMB@{5j-$E Ĩ 4# bbBOe(QI&7F{?3};WΖ``cCm "/S8'pxB,2|e>o3KhX'eHRNbzg{O\3sYn{(cPq+' ^ V%xx-+>nyiE#6 M8C4f5 KXQ 0A\1߳WHe,7x|Y ^zjpa-Xn̽` =@L0%8Y8+^wݖGQwK pӚʿ܎% fMϻ0o'gE[Y,O_= +'1e`3iqj6<[1o=wx+?{[|j 9qt#^rU d|}Q6jGJwzAn< :8{ CLÞ!ģ` fUZ0[si R_ѸLEfE '0.Pg,ˀB+{̰=3k턊[_h]=wB.b;AӖ?ޓ杚w,5AfΚǎ-|G'FvsZ} U(/@ Zvj]w2ߧUqu~B挮g;nֵk2܎re;[/Ul RG-!5ksD~>ߐ,<+=ذ{(U~iwPxRK[5̚_/^ќ \JaHs)$Y)N,?W3MvQןwr0rz`X Թ,Bl&u9A#Iy^*;pMٗ~Vz u9FJ6qE)F(chKi}ɦ3id)17(7Uon @SG*E/z;/j(<9V a $.֌l!Vr[M*E+S+}${KwːE .iWADL? a4`POEjB3CzfqUb |`*Gc-uǟ"c擻Qmx^Z<筗bb"˯%wznK0_~C ^:[+?gÀ[1uN*+d'vT-kR}H]6GUue5q\ig^}uZ2C/cMqN;M{ho!='9 K1L͡:G#@G&+`[Za+~HΥoC1fyە$`fYIhkJ|;=(r̊ i[xpq/DBOܬG=py\K<n҇ų[<ͺ'^)xO6dzWY֪:2^D1ʅ`׹l2|4mj*WVCj<gBp>2*PÎG>z~0r.={' cHm;͵3Z} TD5JElcxavZ~E&̤֙&8=Mn˾Xl{gmym%-ZfN f[爛0t:$+{=!p yQM?'S5/uօ|i,Ip%vy+i7tCl I.x͛3U J7=UQhj6hӷE3{t7EE5_$7gxU3c2(TZn\'/u!nH@i~lePp1&wN_N)C䢓g6 :AR  e4b7SXt"wѾ^=^߲dvy\f| P^0˕ٚyճ:O1v!)5ZgJ*1-.s(WJTJ%˨ÊC\\kpΕc'ժ\GI|rChjBnVMA펜{ظuI>Nt봞eJiTaR_P{^Zw[{UU/}}0o%WCbD?7Êgى oQѯzzvLfogݼSFzkO=)jZ$(Lʓ*,U/1ԥ'=EoEnPT]ѭ!J }%QxU^;g'ȥVv:0hBg,Fijw6zfn)"^DL烃W 4mWa?ltOPz|{~1d?Bu3!=ŕƙƲjceVm2O_̽P|uf5}麦Yz13|TUNzis&s]ͯދ7cvnqpח3WԃF km$xT(A5*T\;OH\"vSb\47HuLoᮑyb<ͮy4%%׉j^L>M+Kx ^@4G( ?o x/K426_[\0#ָ4g}TOv &-`ډ](\tF @YJI521<.o3ɾO;W$No>u~z"ˆn ?ۯil3]l"=G4@oIn6 C詤CtL\PӽZxT!@.ƷҨ\@c?vG\bvOYU6iv֞!,>n So}#AȧIǓ΅f X ~7R/xcC2@9qj-|W zz:z;8u^q*}6yo| ; Ozvq W"ݠNk2kHdJY"6=~s )T`.Ƿ{UWf!Zjp+qnnt"Oz{;{n 3K«'9#^ǤjɇtrUVր\6 o' GN6b/򥨖awPwCwCs7sIl7\ Afة^{r/w͔y[ O u&y!pcv/ USD!& U.)]/T~Wey<["@YAoPׇw7e9z7v/nX{z$W"dU*/PF)syϢcXN7:dPT <.^ }l̥wd9Ww]𴩅9{f!g.YR_{Ed,#²إ؏Omѧ/}Y ?8qbȍdp}e?;=9wQ5/Q݊om[~jqK6aՁ6v)bfcJGǃnbbFɖQԲes;CX7|P& 1%*o.?}qQMEv 6of~v> -}xom&vMR(4D{YJ+EJ={C՗1XGSPjhL8xQ-{~uA6۶67:(yfBOoXmESWJf uyvS LWVi;Cm=X. $W ʅ/an IUj >_wr\zP;OkɞX-Ou 6Y'`>ly biZ!3ׯ/~J\rё2]~j9om4:=g7*X]ng~%:]|L /KakN$6^)}ǃ- -}dq*& ό'qv+l&ꭸWk8}?ͱ0G%Yi?n1IW_YOTGf\ķ{w>cg3&=4SledgyC[Tob"=g:l5l:,H1*ssm{5rkY?v۰kzJE_i O OZZk6Om֩<OKe=jo#DCs r FZN>Ww R=C`@(>;3?֝-i̩h7*jhbo&E'.Gɦnsl)`Ug|,םhJlǫ'CX= (쟇L,{׆}sZ8B~F> & 3*'*Twv0rfŠfvCUyQFO~=fqmn|EuF91͸B˧9鶍 ZM > n+_{a.=x {X`펃±Vuàhxͭ&r?>£%ckZ*>+ؑʖs$[Q\u{ړqUeth(:lnҝ6uv"GH\FNp t/-PrR0:o0)@iP ݧI]\^P;L&EغE Oe<(,VN-':o;kmdpom+ktzjgݯYo<|1;3ǫR BWvO'FeL =uZ[XHsܡU^Ay\T3edʘݔ1~h6ȶ~"GlN?>>ho(fwT?jo ѾQ;&FZqڪ(̫s^6E2.a+e^b묺aY)9qM;/p?؆]T]LvM_'O|#z#iTc+VpX6y.%%]ysz](m~9ZrrF%ʶ N>_41Sjk>fƐG1 2}?+%Nd if8,=e۷xԼ,a=vZ*_)=Rbs{w2ⷐWQkJ딶w2fsF`z$(gg]cw( ڥ&a.@1[k "nP0 @:r }b끴A%E |io|f\8?::T:öŎh Nqn@V*@!@!F} q Z)v/ , w .?'![|A>stream sn3 z | nTfmR+' /e[ƌr,CK{Ps=F@7gިLJdN}5vP2mz ɻոmWNV a| PPwxv|˥NriJ§u9S뗱Z%ٮ*ū-zhgu +L*٥-VdI,Ɗ279xs[oQdf ؼ_قk˂}w3B}o:Y*ZzYoFGI5uV8'ౢ<,ߖU~>|{>23 DO5*|87BQ_@ Jǘ"lԸ/fKu= NTc@>tmU[kZ_Ye bZnɖ,JTΫ/s@ lCñWG*-rs937l܉Q:mk8dǪJO~Z͜2;g+V`7ݾmt}:q9+`znR1}f/i4`tﻑ*@.3d@ɕ!v2إ:MIibt(D(W/H&@Ff>enZ83~M*^j&Z)=RSwYؼynx};u{^|"+mwTjN,2`VA1r^;gV'Vb91Ky29˽ J rM7NԶvU]znw-hwȰ7Ob5K X^=l5r(N=faDMkv4KVw0jSh˴ژ9ܵ ճwhF6JA#!b/93\i˓dp#|֭ۚޝ*4jN68]r#W\'"iȥjb#?\TM.kH6hUʅzdTrc⑅ߔdێCz& P9lԯKK̦&SMdM#8w,jNU7;GWll ?a-wyG.f4ޯbη&.']-ƥŵWT>j5j뺕*X+Zh-H=o,;m|S}߭_-rHlekt"fMnO%kn/d:[n9zޜW+Ьu[\`hתvʬ_z\ޛ.Qzɺ%'TOZ,Ζ8 Y7 0Ǟߊ=+Sc UWKeugS<&o靿dR}t]2 [>pYR,bnI< |5Q5Jc:?\:/d#J%5mjyWcFwoq[nf֫F䒢J/Rl|`l*s r3*잧4ޒ3 i*RtRpc3q̨}»>sltG>6:ioBMnIᔘ-^¥Pn*Oܮ}QgV"4հzXǹan]H}n6qc9Z[H; n4-)E6 a +;z @~mS1Rn=o T/lI;s !("IϯAC A? @z)Miz_lfVԣ\htQ<@'MٽĂ  |tG@  8g1'LSݼrݝ-zYF#,7Гu g#zg n:Xz<*q P .a aNXv<a--/|33.\{Fėٳ=W'm$mݵݼmw*ͳՕ+iR+BN=@| [_<@amu+sFTZsDA!Bϣ1bOw@#R9wS"rH\zE 07KBC'n0dSeosV:~?tOs0j8ʽ_>o{ֱo\>bI3'֥C;;vǽ|Jg撳g$PJ%1 J> <bcN^%y=끊c'wGX=8Zד\l<)l%N}gnB쪧->>ogѪ2i]glyZdu*<"`:[U@ =͍y@R6v1h;+ZW*7 'FNH9%cs=b/WOѝ]G.m3m?D-x-RʫP*Wm5uH'1%5]?ru*^ڹ[Xα]^Ye:PVȅR6Rɏngv~qʀ. m||uPwsscR{ҙC;{mI5oldnzg 9B5=TM^m|cQX֎gt.?&UŬ{@f}BaL~lF< r +.r5M'^;O*KYdcbk;Z^lp r٩m:rgGj!5(K_KdՖ6Yh<b}/jrD*e@F7Z}Dt3-X>lFxzm[K9O[}&S$թ&rD2ZLBu! \[@K{5G b^ b /PhtnLyA\ CL]r:Sjb]N߲[Zm۶ۗM}kc4N/ON=) 7; O:Mη}Afw,Zh!RH*(z#k?xNk楶*qVVLbݤ*ചUDGAFeWׅpiq{dq$@Ciqs:v}jtSuQ+Z.iw6RI@vLHرx[n&svyզ\D;*}|2OjDgEI``}`rø>˼; Yr+)re~w(XxSmm:[Պ>Pdg7VtxS97r~YA8YȋsvFU:/8i)sy ݅((Wn:oJmpotͿ8}Ng;4DƄ#|TTAGFtAj-qo?sKUm[𳀒THN* "*b(j}>iЪaNg=1ɩ+j,Ӿ{ԂmPT#9Z4Y'ۻ%Uxx1HF8c_~nBymId8ay,-gx6 iZnqEb7C_xXzۤ$hPs~.9T}.*aL1y0f1-aҠ"L) /ɝxU eQG5{U oUȤW!M ]QFujUaY']՟|nM̛3֕FqI70jߵWkU;vl;h /Z: "]_V}topʃ5;tqW4\[ƵKvmYϸ 玫u}mhKO*MvUDegɪbկ*rBzJC~n.mT2'");BXo fDঘSl~qlu0EY pX=|xZCSu:h P%έUv+`(k.FJi+ZZY6)f*W"rXuԖ9mªX˥\AMy'T{ϳ~%ssֵqg6LVNRN._-slZܼhu˂=(1٢Rz@`ՃU0^ !ଖߦ L q)5:HN_p50Rk6R3vVi"bc,!"tlׅ@4lgj~n5 %4kD@z(:4!u(m'`dKM#j#y#¥lUjÚ@5@g0yaaN)q>Ll9e†WR:Z1I9g%a"N)OBIӿ_"0q}(p繒9iV#'[? `&EER^F@]w$}# i-L^2LވFy&\?b)@,E@## B^iga+ݞ/ҵE)VA\.F`">7t:q/b`ŋE"4~f\`Sx%Ri*;@qʪ"5勜aáAP~E'? 3X܈NC])z!6uA+L|8+#pH^d*F`5i1kEMJk,5w 1ПbG1S盤{FI炄<4("LesadGez'tJxV2ac6q_ݚ_4Ab>* {ݐ|$,=h4dxg64=_/RʧQ*i/o?iGI#T}_DAoyJ\nj^jE$oZ9(#\gyp ?k;:B\zy94~%W+WQP]y8;I{-Qe޾E;j͇^9/"!"tv4F;B1nl{dAqWɳXnp)Fh+8WnE{'K$X> zY6TTtj!01:b{A8"#N;U2pKm$B o-?px [bk˙rمtYV6м eL!p6cl9i'u&t õG&;n[YE2zx=QEa˭6!(r<]-]{5=,m=I|[n>}ljCZ^A;_{m6mݽn6U')'Ff`ciC7Mң%GjJׯ:oiQ5j#d{~[\iC < Ɗb_DHX|$0 g\;,3&dLqYFF6A{r3>:n zE+8r#MYk A~-J|Qܮ3]_B]Zo ,ɖ&~wCZQ8FO+%fjO~<>!vɺTǁ^gR_t/gw%d[Qi{ R ۻl:[)7^ȳqr3Ɖ:)^mguMlkfVvmY͞z2јݵ`9OAb*"՞5HXy2˒/"L!g w6,R2ѣ٢zJ{)}TŘul_J;|9Z/yB Nts݈X|ntcTkD9ku:ӲJFc]44E:oxcH\ a,y7K' Wl^,5J4Eyo{}p@fUvܢI4:u}Coo -kphۂI;`UE3_ߗv(č l?ߍٍ1Ü=zy(/ķ䠄%^yrΰYUa[r(c{7jnidR2DXlκ`ҭA?4f얭ӛWKYu-|d+U*+mWY>P( ״ ^X͡R|8.Ngq%DW]6/S9Ӟz w.Цo4yOŠmz%]_l:DUvqb1{i|qw+mXX&F<~,ˁ6堛B."{+"JD>@U!hb>V72;љˊ#gլ ]> >*bIGIp[/x >[p@0KJE`;Ԡ6*̪dU6=H r1or-B1v(4{c?55 -MIU]~]v5@F%E :+4a=o`MO`v2`ZrkL[d0yٔpzuhc{.z1o^#1T)aHiƹZ[NG o} Xtu<2VV"H(!0>zg';dn[(Ux;Jgezo)q_D@FEGUɸIQaSq ctO10rs45G"07LCiq[^…Iǜ{HYsh993] C!A2SNCk.t5e n,NoD #9J Ifj#@ҿqb+=LN2L&SWAO]L7R.TF/EM?1rZi2{N~fֿ H9JZ̑}\[HViSS:\-yjF]G *}՘WR^ p.%M3 n#EXD~ H#~5g9I&G󭿤& kF9M$ʍĬtzՈr v%0+}=+R DSr^zNk2ho}L޵zy'9ɃR;M(t|x=y&/"0QNQ(ޗ~R$ڎYrRr}gQj"snfO>⃿LJoA動V Aqֿ8ڙl+0/[E4/MgJLFNwMTO0߆/}E);oEÎ{|Rcv6'-[_Zen#y>&Y|^}-1*#St6?t]iFy$ROZ "}/*=HQ@X}nv,sLBv^AK@)o%T#ןwlJkcVs|tF e5Pu:)fڎڄwNf$3ysI@J?Etmsw@/78p]?m?-QQ'rBH:{sMaulRMC2JYƢȆѷ5ޣ7vNFgˏ3_DPw+~F]&g"lOq_ k]beIujnN"vO81K7 j`J5*kd4_̴FYk h i+ERUwz 3޵7Na-ܬ~!Y7'1ҧ.wkէnvl^fܷYom8q ؠ3nՈ5u\5RM᠓ߌ/~6_G˂<{]|$z\^}Kcm= (0#)0Yi mq葋[~vtF׽j f@E]2w-~~vN'n*gc }d-7/o\3J+o}.uV-[:y328ԇ0W2CcTwY>÷MP뛩/LviD=>@jWKpQ-Zi#>_D0%uζ͕85N'X`7;7~LFu<A99RY3jsHo9ݾHfݓ*n;({ʵ[}LYuˣ=?~*W0R\o_^3s<mQ# ͳ]`'Sεz3`EAުӝ6۶@uB\QȔ#Wɶqa")i!=ER=6W:?T/֟3g{v6sB|rڃqYu{0e'噹q cO{wr%BU0fe ]tR)UѓkHdql|!<|nΞOuɹW^7?NtmkX'g͊M{yj9cxѦ+S̽ wmFg5XƹX*Kw@)mHFBl;Ş&!$#=="҅Kon__6Ѫ?q#խ/B٥o;@ .p}xh2/1vi v.vZ]#&h UڍAKnx n E ɣk[}sv, C83`\Ɨ3kh_hs21 6 {JW'7l ih'7:-2WpmT[&Κy C ~PLT=QN&4İnǧjY[vs8I] ">lӯ!|DӲaZ@ ؖ:RZҬt)Du94{:>Y: zk"Mb /qZu+|)D4l&C!K.A}y'7Fb|Ԇvd.5G@xl<6aDF}֣5]IϸqۯrTs5_o֦>8/L-^c{Pb^{"EϺ: sᯏF5u" ֪EdnWCV%'{R5?x+cC얻mw [t˅f=]_kt%kCUmڿi]*D陯HdT;pjCa}!+ȲWYu\8۶^0_%Y /fFBB^w&EDW|Ӭ-J՗KOϝD[j:Ѹ܊5/> .jn s`h(+ ͻ &>u7>HpT搛=] xs *w=Tn/ |޿Hg3ُͨzХM̵٘rU̷1 V+N0QTk?8*KpJ h~}ם Ml$[/0u0訉tLt:iSAsr[d8)Vn$`Js5]`MESRA#{ "tuMH=*o+:7DǤ0'D)kJLK/L*'mz) C6XV!n˭7+[]A =C-D $)ZEB_B~f44~fJHֳÔG14a i9eVE{h0n30`_xٝNun1F,N\RgqDϧ )Zv ˥J0jW)koG?gt4Η =]xa t^sNS@IyS(2e*0i #Wp}ZB@l~n`1L%??]~Ämaj0v\[ƒ8M9mX.Rt4 c-PDʘNHazurBF)څK wW"ݣCc^|4mA(iɪ.II] Szr0LPRȍjZsL\ m`4]#xQ|6׹ǧDh>ȣÞ"mf󖾪F8[Z}:櫟IrlF@Xy8~ZKs nUbVy} ]:ݥbbkڌ;c~$dCȭ{vmupov4,2ݢ\mr D9Ƣ6#"첡{)7>hE4H|ܣg?ߗ ~> OBvU":\r<}N uܣ⾃y-?xih<=6}wgMv%n5m|al fke+ϊ֯er>:Ⱦ7?ӿD> 0<Tг&j[s6nNlv=w>SՂM|p:Ob*t]b'β-Y}g=ۻ˸%tmN<7G0DOk6N9ն1<um{#ݭ?rK'Vϡ6q¡jnjC/?k̝N)vB w{fINU{ EQiJ`%/{mNlՙ8;M}6ȭ%b٭$Mv=|N1ߍ鮶]w)vfa{un[znVpZW VsR/(b&J64d2%I|O#W|<~%|)~%&||llEp z_#XYI!1,8Z"&=On&o{u5un[]D| @BD2W\П<ԞƝ9}b8Yfd6|N_4\_s#kiJ ZBl8+# s` KK|+l.nR|mWᝈtSC|f99cGȨM˔Δ9 k{~X]/NgpZ^hek.hAЛ}y转MSbSVփ>']8"2AƮnx*oY9^ȫ'j=rFV\:F΄Ξ[IK#*5NjZ /AK==^n[lZ{hz&6R6v,VKGm[i (/0ވ,pϋᔼ'2n^!!I1ܒ_|wk< :ktXupne/LM>oЀ3͊YaՕF-Hw(Co"8Ӓʫo̕#zvZ·h4ɐd6Q{9~omgv)Uo)qsm~餳q :/XT7J[EP/iN BercfpOXlWԴ :sȨ*h]&Ϧ% yUmĥ G_4 A@g岯~lcUiDi;C֡zegIU [ 3>{\vҎ؅ң-pڋUybJ =5w?ZH^}|2+TS~˿[o@>,k1K{}0ȳ OtQ'thDu23ΜI.oןksZ C^j>g[U jjJ*WUz^b*.6UYTmdcBǑ>֧a_'d1YتbU̿f!)7Zk4fuj_j~VF"̍A]e,&XFEV{{oV?[}{9M_CF u‰NRnY҉$Vz"J6Nr2yqRd%%}jgt֋ O4..pa VE2ߐ<|՜~rz/k :2' <`.9),_>-)bpC`) [-Sg *]**>RD` UkrfŪZ(RB|n.)q ; h^0INYBS0UF2z5?(v7֖g-Xn涥\(ly{dʥs*}iRr Ɂk)}Pʍ̆MA*锲6P1Ѕ_cc13K]+C8lCwc΋S@_ώ`yQQIq)r?q6~V9 yh?Ϊ0itNataRaI67S4T9 OCo0]a_aD˹0v0TնpQ_ 朑$"AQ?\szKU=56F||`H>DJ>Jq%9'F=~U'Õgebq:%eqt2j9t=z?<}?;UĮuz~ ހ"o`z>q;~e<|7Z=;08>_K><,n2өqO&va8>w F9/`1¼rhO~]=nw?ss AJsD~KwL_G|[-!=ZbE9&QR^,8^T?qȃ*fxv>w7e1rGH3B_U;uGˍL ᝻66O;_0uܟ%gu2{0x3u񁄚JP;;v}ٚ{uwltו-X6gc>?n{Ϋ 뗐ڞZ=4K^_hɅ_;.`=ŧ +FׄEp*#Ʊ!Ur/W=w̛{f]Z W;Oώۘ_oz^ݲ[Nxp9}+si*Q (۷(!W/浰t}ryOܯ^eYPWfF!x/+\,Ys&Zx{rk>;,7c#ݧ ?60. `V_ yO2;I x.ZtDąBnfYX~=;8t5!yImTϚlf5 иnRlV[~"q.4K@mɃƆU9@ö5 ိ0l]X2\lcǝfz=Ñb.},D|^փhU 1oQ= RԞ]SǙP+i FtkfvΩAVk!x=@;qgG~jœ5nk[3p%@M(w;h)A+`UTFSy>_}#c=%}HrHraH΃3?=\jyyMNYb޴m;X{m%X֍U$,tVzZ\M_w[&\G1ד1AUKy4~; {3B(q}/%ܽ $fCji%׫A5VE?thmN$ZkNipݻl;)n4Dv ᔡyqcSƪfpLO?e[~>[AtDESЈ۳AB5`kV{m)9#e}Jc Š=IJhUӱE:jkr{=cޕ"c>T s'RV/NoFi~CvD--2W- Y#ݕ M|szbhflhrO3ɽx+18ka2;^|8 e5n3f7붣т|K- NF; =7ia>+ wű@q]k4 U\6-A&X*^ S$[ǷS__w1HiTHd8ܢ$Z {5Cr]zkgݲbWBlFnkNc Z ˼$r+vcݖV nqXY%1'&IuAŷťĥ·T&*'}LT^s[gYv4:1;SVdmYff) og[O]| B3Nr}4egdΧOOɝՅYRQ6\rv,-[{ ֖+R 4”C#:զ"1\pX.-:a" ^2u\`ULZ]b²V51Hj4f]C"|QksxdPmms.#I@̐!#,RͶZx/kH-tw[S,Wm{Z'{o>E/DיJDZEl.’5*!!ukx?WsF wZDToy "В)r𒠷Q/vu*PQ9v ݖ+'UOXWunxzm5H\: *Q T+̢?o?cTl,ٹDC91ڏ}87\}ݷA]ۨ7HyUwT KAr,N\bobT^m ]Rd(wZQl~Fp:S-ЌF> |g>@д`܋Q}k Q&P_!J:mU 5]lS7PfXYn),Xw &+OFILtsu{}p8[ ,}Co8g>_dθ'&BG\ WSI%eyڒIQ#!6w_1F5q. VU\OCJ:*%5@- S P /xD/4qڼP*$.+͹WdˡXvZȒHPy \?-b4W5X&I,6w: 4o6;w/RV(G?$y]G#h@V{)"BW˽+|Vsr;!{в'Ɓqq̧x+Nyׅ H/C^tH>h|LN>s$y$!N$!m>N $tXI9T2=8l)ObHY)b$vU;_H5M>2-z9O>L\Hmn R[Zbbuɡ<SVzJbK%L ,)X%6L뵪O/pe~>K>Kp#R?HBy|"ם&3MNF]\/if=}T%PZ]w*gCW//r?~KzaUS=g8x4tWvNM+^pzc{|2.XH>;|VY^){,%oN>b ڙ/jr=:uC6~w{9'>q_Mֺ<˳l{AD;/wfDT؀SႅQRsRNK=]; vŇa򹳧3Ҟ\z&>F〹G@w{+y/+Կ磰px^9 'VQáQwFa> t/6/} yPp;~7tү`I>/(? -*z:v9Mӝpv6U$1O]_k%[qhkMAGAmY.Z\xx24S:;Ww.gS8뻶D+һTez<>Ja~z0(*\,*hogƺ v ΁ξ58jd$ݾH++oΓ%u4&?o1"2޹9}6U 7K[kg*>QI롷EC::kIPvZwuM/Q\Yᕩc͔A Ƹa0a5I`X*V. Uy)/0`mjK,6Pȱֺ*ޥkZa8GtV^΍d& aُbQ<`wvDM69`41Ù/q\_2x’rRDyeiD!oOXj,~Pa3ǰ;}{ȵ7bj(8M6k1W7_uDmQ(xjQtŎ,ϛ (}dtLG| 5|S &w qvܼuUg먓δiUt"T$WnP6MbU]]IDxEʭH_X4~ kb3 @x\\}nx7g5OPy:7M)_l~=k9LIT6;{g) k,Fē> I$Ģg9 %u{/ΜK_'IqO 3?TNhYRѱ[X֘G@Lxs76٭uLaQÒ,=D&y[{+NO 뵳K|CF2lϬm3fq`17%2 ʽc') .ZӁ]χYd~M\V/+ωnǗf*4B_BƵNYVA6M~h6߬7&gG1{G$]:qF+JV!epJ,I(N#}|G`b^c ŖOYzUU@g_鼠8:_ek +P+ʤ |q)Cw[f7zr}7(\PrF$5M|$E N0I8[X{u.fKUr03a:Cw~<h=eBAR4 D((-~WKy3|4ZԒ)( fYs5+ZCD|..<1&`#qtz;wPL NB`T47rnh'Yl'yPx+H-+w40k/3G WCsH͠L4WпZ#Bjm?~mI#-k7^z_F+ r33߸.U]bl:=nNS몧>?ԯxB87r7urS.lNm uXjO}$7g@8#8 jE<.U#-{i(zO}dwo6͜!8'\|Uu0; L}Wl;o}SD|56¾k8lkgkH;koM=aКb";V 6,l~aPކeat`wocР1d?O~:;^tt^rFmcm|@هZ߷=l}/f6mV7y8)D}ׁ)Ft!qU蹠}i+n{Fz>d۝6JVKeGZggeL۹o\S|ƖĆT )76 j-,9Ϧ%$LΏj9[HvL O!Omhjh* a3Rjeݐ j46[?b5v|15ͨ|xUs^CVB:P%XpEU$.1ZIж@v!) rWXP@G}-$Mf]rUH/*.WWXAͣre@4D0sܝ.&^z7DG87kءܛ**7-!#}vͨɹ mN8M*}JPqrx;hZbQ΢ nqi7T;C=wԇ)s>ׯ?Mv6?*#þxurARfyM&O6Wa]Ҳ'$·['vcY$X)b74IIl(>a# 'iN m4]Fy@Xv{=J} @r+%dPSړZ}G*}i6`mCke_[ũ)*bn)E+>.}0X.U YY_PE,K.Hp.NSZYg )4S(n>\"6ku*Y*˟bbm7]وaw'cE//^i>1m 1-Xqlx]X~!VN;k71ɗ<,5{>)(esنRPh~C5 ^GiV) ={dIBrLrN IBu)-;E.z !IU`;.i!Ȣ !I>Fɻ3McSZZeMi,>5S.Whmo,o|kAKkתxjyɜ?8N\_!%ѰG|v|LTM>WO ϥuJ;M~Z`ח/rGOΟL4^awua,ޕOz;ccWp; .g^f ||nߜcI)-˥OH M.p%qةCݠ?z6+IKrHhtv釳 $W<7 ]J~?O#Annu;vE.ݿ9>-V$NV7XXy>[]bؼ dp/r>ĉBPG+|c \.mf~: ViֻV:|y1YOMeg8h=ɞ۠/s Ny*=ZAC 1e\Mgރ-lվKwݧm މ?ހu^Nѯ9~0DH;]ߙ)qC4e1ٸsrw_y֏gvHjw'Y>ěf"(f "B\FeF"OlΡZ嚓wÜ G$hui`~,u9uV:\]Ĭ&*KbrlbowXp2<\\lUf-Ӛ 4r3ҷDZ50ϴ^=#{4N]TG|*َn{<?j3iorA Xu!:xmVu&+S1)O:Sek2ݞAj0:n<ʱ2Gwt%PqۋhyvO2΄$;Y!Vh~E*tR.Xmo5MĭN qS388YlLEr[=.4WJ:m;111gVѵ4P'?h/\H[HDwxU'_y錯_-n;F*qw|]Cϟ,ȝxFww']+WE ɯKI[_fg,վ)pW *c:2j-Vvg_{ʶ\\jvUALӎϬ@8\tcWGwGdRkUQ'zO2q {y{6%'RrR>0v|#Q9)|{l/W>2GWix0Sxz^OL%u;TσV؛Ƭ:@|Gg?mHz^5_GGedBu&9b&YwNĦᅻ;0kP"> ɰ#yqHI d`$yUR|7^!>؅b"W?fk; qi.!2ލ|)_bRx |17˹ ש}ZFzma-G3Qɺ8jwFdzM#J@nhKA n YB@zC/ܝfo#@2Oمl?ݩG l-/?7N6YrjwȘ/F?1C#RètJs ?D-z@^#tB:X4 /FF=Q],=i-Or_QkC9n/#V4^s*\Y]D}=hOPKYiIkD`Li|f+bgm`{}0g\t$J!BmoDgЪQͨ-7!*O0OC.3P:)YusznQĮ!&X"*$-ŷ cFGOuev~ /Nj )e 0s{A[^vI:E'Aom 06s{ua> KʼnLJl%r|{dTkXjcND'zGZXpy=CG*fyGbrg߱޸`{la.% `a 3 ϛ0>SoƹB˨;^߇v/pT^QQ)̥ÛV2#&, hWQ*c/fv)֝#]FC8Ci9;5VU8RxcP~_I^xtn<$ؙ6@½ۋ~^n=|,rn2h6# Ҩh!^{gDB(1vvml$qεcȱuƏM7?]s/wrAӶ S%qZ8&htIlw,-b[p s&#yRS,VH՝Qb3$ø|@?9h#hEl/GEMY8~˝{[E`4W695+'Шi}47V[zT-n׺P66ZIgIW/*]^8XDWbFߕ{jb, O?q~G?f[`9jy扭bF^ 6ټek=jO_W`G$ yQvXE^kV5-+9bjəe$y֩W+ CAMwB'X/#pW=0-_FC-!dŖN.@(to2RJoJF8 b}|u:\]&hEoZV~ђ&b{⡤XƄʣHzk74n0+g 7ף@ʼPZ7v\^QgrG14Dl;% %)!=cJ^ÕOށ)pPQ0SV;j yj@:U(2O6Đd6@Rr҃ǩKu7Xi%Rc%N 2`^HRY@ @` lJy2)R1*>W o].϶ 2W=xF<~Mѵ!SFq3A C qf@.VE*N:  3u^$a F=eKzn@Is=|⏵Zo&?}ϸTs[J~A1t?9_ |iO,6>Vom` OcS)zb 2I* p$![~$T%SHCVpvĮl o]|7 {[([!I $ IZYu҇&Yg?bk)yZ[KI<*W~4>ֱ,Mݘcd\^c/Ƚq?M>Ü5RWU?%*IFity[ \赪W^'sqعOb Xqը)Vk]y3An=o} nd2s2/S:g|[8}pX~].AIpZbK+/8lǮO o w|yzxj spUH~Ǝi9"pDYvLN/R-W}{ӕwZ#z?$]O>ҚH+ &#&BEKl2}oFw9_ΉpӇ9}Gω߭yXZku[J~ZKN.Y 2!}&4jiGKynЦUa8h /asbvcLڤՌʏWۃSۄav"/G_Y; v=x72iX{ n>~`jN*% nt)FwY:!7GV7duw(x`2cKNO fMk]v['Yq]+jRm*iHmoXrJht&Hr\j2,8 { K%E-(4V;h& uBN-:Z)}'Bk+,펯$Oxh7O,?Q)#ؓD\>]r,oJ_N>/l8q%YEfDqYiS~7ݡ@-W*p+,V;c/gFt% GES)6CzaϱlƬJss沞̠z=ZV0ʢz~5_>>ǹL6-~d|b ϋq'}Wݚ۶pj`]PYEŷWl輌Jj/ۢxM"]YgAY~^T^tCܧw~p>vk}$XT(Ϲ#60by;Twj_YӒ. U =SBf@Y7?y;IgO=x"9'N9^ӵ2?N-5Ni9p<ٙeŧ_w1萳2v%y@ͱE~#jP WʟMJMX1$zgI4f\8+ْͩqGt&{I N&V$xCkV^aԇμꥹlPNeaRww4lcɾy:۽ÿ ӠT[CCuRt<>{IIo*:WL3zy(dqJ˂iQhcjѱD$4_hz^HvS B(hkbjk>t[QhBǸK&] Дtt~:M @Opx:GNզrGPVDhM//1șU `6`zcf}OixӀT$ 0V)Ǹ z(Ѫ^)כ*]~|uE֟;s _tQOM4*#7 Ddcs ~Ak>c p8Jג*1:fp%q-3\_Z1kK ڏ]oog2lU~fy!> r@KX)N; d, h'0Z2LSw"*I$a:w8:OW^ʝ9^G0Bv ~D-` : !7Y ɢ$i5_i?t!) }  CX'}Q[WWS|myYO!DzЉ^[jW=I1 >9FYM ȗo~q|ĭ_\I(ʉL0e4{Nsӣ|b][5p7u9x ?3lw '!yOokKd*c7B9qٯwnL$y&YoM^0a_֕q{שN+<n؊5Pk~]y>DW?MnAOD]^!=[it s ۃ/+lm-l=k җ]Xȁ ;&4uޛ`Nm`-7N\!VkjW^p7E9xrhm%z!"ܳBO3ضWQj2r6J ~nv44wh~zr<}†`|߽cQPS ϋZxo-lDGoVՙY^}~y0yO^ᶹC,*:FMenN5fDߙv/Cg /fGtY_Otҗ2=X˧AA 9\bC^Q+{xO~[$d{~#/Q?c[)Scɵo%/UWGnif}ޓAaV?ز-j8~{ql}gnEu'7;Dó|Ű;ӢmC`Dj 4rrGM0LngavK-G˹ykLe 5mȊ[Of{6_Lc-c6GF_(k\P*]+`~NPD7a}`~JoE@St}xYxtyNIlz*ضy'\5TW?k.Z%#FnC[S~oţ*UڤeT˩WrYNϺS}uZ=& N@Z⼷ݦ'1WMmp׾y6VZRN6F7ެkܯ[jjKjN*?*m gAtC:׆4A-M#f7?*bI ,mTQC/r~P~^ϋ3@=]5i\tS\N,m*J\9ASm"rTr{\=OibDdʤRޓU%gR:lj)T dБ׋6W~fܷ SYGۼt{a5zӼUz}6h΄\YIkAj=y2׆Z}g̃Z25EfJ|ney%++s]H{lsA^Ӓ0XPv?h^jg|2R u n+F[ֲQҜ|K%G#]y(E?%7\ؼhIH~ZW2+L~x.lOa;:=g0+9~9/qxsM ۀZeڪ2R(U5Kec)[)9h<7JntA_Pj[/t_ΛfŽJrk ;>(sM amzHM>ђm\IJ,ˍ;֒#^UO=_ -<.µ "! :nC/m~#;m80:a;gM*X82_3sB]SRaxYILɷG希,Nj'eV\ \,oSZ; tպkoyT^gUz)|fBMRT~}iIѾ j/+xJrs"HWelT!l3U{̕@a~`g^ȦOSN9 1 mh K%xu< }0_e9iom_сnGzn<|KЀ1tRa3jC/Qړb<:7¸Rq)X}*ְٌ%:2ed̖_ ]pj>,†o@q1=V/])[$I*EL0Gt e`}8Icn*򱶵YY8Y풱ϗ+vҜ `P<9HўJtTk5)\l2iFKjĕ>f!κ8. n%l:<9G=)VI$I0:- f1C}' a 2r`NzG~cX$g`@k(2'h ~U|:tbb>Ŕƀ^ithT,)W*"wF:Bm yЅ9$ٺ"tXdB3DʂjzsHv?SObQ K#TeQ9TGŎ'6C[ft;xo~i6֛X{w]]tZd @e0ߪl>]qٿ%2_[SL|/ x=Mc_\o.(oc_C.9ɛB9-'O?&nGB!&Z]ȻrikMpػq_D oHdi$~Kz~ TLgѩ;"ݸBۖ[qhba3B|:^:MoFBqɾ!XZO>E=kd;ȃs\pW/Bxo*@Iկ,պ\u̾Kk? }DfuaxR)93JNۇݛаN;zPܦĭC1Ki&>;넼趥Uz+5//>~^NV>{R|y~+=}qRv(oYqZ?b@,/!zzܷiZkWu+Wʫ^^O⽨ɞgFm}MvjY{nM8N>a-LK޵o>;~k9[qxƙ pr )mCޘoj>3D6h~P/߱p8W>Y֜^kv<g{co8oƺ޳e͋i8w|@^DddVݢ筷ۙ^]}7Csa3yKo(8oO7lODnjQyu\2scp拉N.][U.R"j͊fMl̫\V `9NHPʮߴ{[nxS Ш_>ŧ1mqz^vYre,s/ 6 :B Fd3nRNe*u_Lk%]E XִմːQ;;;I/ KͮO({,NHM^V5 aX/6mMZ^6 70巿iɓB~&Q|7&5kqatgPmYm[7Çl<+!5^}|X˨ШxT Tۅʞ2O;(,[|R&/L_[NgY4Fw_ٱU)B=>}n{Z^/w6\<.YQDC:'9PU m,{cdoWmk~e27PGhc*}TRi+';55փ£{Oz+`1]PAGe3-+3*5lѯn;\W_ߪSG5rq*?Tz%-Uߜ yz+ɫb \+?DwU1ڥtޜMneKT%[isܕ~4~,4$jt)9#@yI 74 z(ȫ{k*v.N)yKB?SahԸ OI3-ݥ VN7^O5j72S>l684#i{٢ JR|((H/k_T Pj—IT0=I$%ЫCS{0ٮȼ0gԜ{Ugi.W֖ [4s㪘@":fSX2YFK=*U\VuW-hHMhKM}3{ۈ$9콅Yo0լ=n=  K>#nxJ , V~*&4jݧA =a}͚No ˗ZQ˫}H~N.+BjN^tH;!.TzI|l%3G?IOit1컯Wى wOl%h8d3`K0…D5hQ_l/=bzR(kNR&=RxKؖKg~{2tV2ʼkB&/x{!7>'K=;of~PL*:1#1G S&m /ږK/=xl~\n>a.m83I*E4\wNy^E5]TV5v;ɹ,dqL0`~W mQţ#lklӇyPW\ޫŌ{v^߭= T0]P}c\jvaIv诉\ V[X[tWs>|ǞRyӅ ^1yPWIgEe/vZXNxa~ aHYe'Cg@]Hـ@{ v$tm@LNZnRJVB?^׭K<=oyþ9"1ٳ~+e{8YGnn`?   _r5)2crɌcv楇6,΁=`;:ڝ\ 6P\.UfZjnf$]&%[ICRQcgFxT=`9rf&f2*:3*igR'ߤb΄; P6J+M &(hF:P?N]^]'7 M53I I0/ﴙmE*1h%R {Ayx^%B'pQQ61B@(@=1v(I(.5b"XqBV4zA~-mZ#v-+2t e,qz."; ']]$/@_gJ r3b w{d>뗇\΀1@v=@ּe`HeK;3uU9wQ׍YWFmvOya+t:!c4#S4T-@I (<1%]--f1'5h:t=Ǹb?bN$bx" %e?LR3 *43 BY7y_ Lӭ+ k6+\K^aR-4֊XAe`9wXX\1o֏4sl1#%~f-yVQp/_wnng9q*v p{['M x5q?ߜTf!!WUSBZ4'̷xԞ4+ү{jFf 9bA@܄_:Ca [ԓR1; a Vo?Rfʋ!lE^_t(3Uy;{9~ynҙ"^(H&lWjj'm.ªYD ȇI? E8A!7qKOBP6~Eذ';+b9ߤ4QN?S]6똵[v!>Fkə|pbY;bt0'Yh׆_sT ͵!68nÅT##8v _Ưe^?DQ_LqG!_|Kq ~*Zܱ|VP;|VllO{}o{kpwWqS0]Ba(Gv{ɯ|[νMXW\{h†2x4)@^ѿQR2Ip V/줵8M fjmBO{=}_E- ZNlnY7ϯ3enB~vܔnfL9a[g<)ϓ?*~pXɌxnr-wM|9%1{5G?\frjog{KSgύPWnx8X18$5 x󅡯"`UAn}l\TVt̾FB(SV7WNi-vtӽSm8B=hp-Li2/464m0ۛ\4\Bԟà>*n].LˀX|Gky,o7'dZ  tԸf-$OfM2#d^@}[+_f\_KX;IAb]kmt :Fy|Փ5Vs;VGNƓOG`WSs0U6gϷZ1ݴ^kt jz4+ݫ{%dP󓚆(;Bk/]bv+rf=2Ѻ]3q7z=nd0Y.m椸K@s/^rui*1nwxY XIivխʎӝJ3ɮzC_:h~uj|U%]>R Jolg_%k-hQߩ=M2s6?}[u4#̐^F3z@+ŔwW.w˲~~m:St[1vj> W. )M ttIe.5E]% ϫ!d,ޝX65mE_z?+O=8~*}x<ŠOAZd/"nESS-tű@ l:i H 78auNfR'* R6b@G_ݼ%N}Az0{ڧcJjϋb/5}o(H |Jf fWC!Bsx7G)rCăua!*dTafZuw-70< {8^֠hҦncrk}}|Gz+ sr/, ܓ[;9aܐ}#vRZl Y]}f6NL&zӋնY s;?V&)=Rִ"c%Ƈ#q?^lVkڡ}FigVu/C,,XJ! g; zݸwDRd8o%{1/}wa =K>3&OBS؎ ԄV சY/d*@]p.}Zay=M!&kiB)Kr9>]|/?3Fx a!YGnGpo,xَyo>"|!;Nۅ]~;gY5#\:=m_c5$Ɣxl(k?aZ3_=B2H3T4䷗4o~(g;jgƳ^SAZJM%6nbO*[#UQ. e<Ԫr通m@M gPPT볌Uu[;"hBo8W.Ƥ;wܕʃCA64eQ PUгob-cam0!O:q`Z"`ګ`:#`,/oі%v3%y$">ߐSX"\:Q<175>tDu8\?lInWw ƀ;D >F, ~r~s@v֗ۈ$v _%Bf4B9¼#>$ j.-]ׁ8̏c@H zBr9˥ȝC;,V9WX0,TЩVo~ڛz+I=d,hu<( @>"WJ6ɧh_Ȁ`Ɩ ωߐ&5ŐבH^s<<{ݸK}%կ7 =&+tb4P+]﹨nk_EY Q,fs:tu8zUGڋ294ɽl~+5#[ܧuqps^y睛s;׽t_O%3}3qׂYCÿ›dTCWgRTAk/ux7}\7ܟO(p=~[d5KoT::DMfv ݜ;ȑ>9KAX@N%RNS<'9Px 9)wS5fí}kݎ39z̈vk{]/C_/A_-8B{"1ݻ52E̴IɩajEd'uxdsZaZkI_P8sR!SП9Mt8[},\(DPpbм9_#zߜwt?㷺 wxp}X._u&Zqn37%?kGpfJ'#)~*;QṔ`Y1SI_ǽ&S+b׍eWaϯxxwssR=@9+3|&kuEwn5a`eK4{$꾋޾N½&)b>_i}г=jwBsy6 o+rex;oGu1^+3gV.[ƯNЎVkȚ60: n?R4l4j&n6Sv3Yt^Zv㶑?[YU46mDi^7CN=鐘i<\y2a9^w e~0~lu4孴'jEm+rY]˽V0†{wg.߻.NOFcq5{S-E5Si\1d9I_>k:Ǐ Q \ X^芽ͳ3rwk̀f!GL}V ._-No[v^o^Q{aFՌ{#+ V*[k*[Np]; .L*3Vݳ7H?Sst*FOamx_OӼ ҆꣱k$c83-έ]xמ!_T3&o ņQYDCЋxl?Smʬwa ![zPz k ׶kºcqݢ]KS{Z>g\|*/>_Qތ+K6[2LNZnÙ5ʝW72*-փ=eqnZB}Wr.w3r974'*cJo*CU+oi%p8sQV]`Nu!V&eI+.'_xtI`CF}n]`zUnz=Q |antpљbPu[ ܛ_l `-_jev-*;ZR0-H:2Y.]HLɉ^xYSO!qllIdef=npeRitV+IYsH L1/72.Fj(1׭!^z5(.EʼXaQR:!%Aoiftp2B Qh)ǣ-rՖZjsQmJ)^5-_=x d*0>uʅl x<4,.rrc"\E|@K1j-Oއ-c5R~|x~ƃEL=LJRJDaAVWEy ae'FA⍬;x0hpQpj9mdQ򤳨21l>=EƢJM1yx[Z/s.IJzrq(ZL'X^HgP#ᵟ^y sH9%<'SbcƢΨlq>[HV4k}hzt t j))Ɵ>صǞe<-&׀tf/ͨMwKxxk,8k7%'+jvaۋEG0lKԱBMGW9ąU>4 )V'eL^3hSRa279ak쵲4-%^4+" >_]nѓ1>b|t#*Vߊ@f0c!=,݌.ZPّ}&?K =N$㟣Tq׵M+[l2L xtq.=_Ot$DT⟐~v3:|M}Z0{ƳZ^x-G 5zdË؟_= >n8Ԓ1M3/.VBEɵes+¾3깲p)PZh2PWTv1M Loyi9[w\uUG歍إE+ʡڒJB@cV3svV=nf36O@psxb/|玷\4׊{ 7^%7 \\  xuprk %k qoa4n%) Wrig-tNp{<9o Mpo|a~('W)S3)8fKAfLtp-UBv{OuuaSNڎ+]QJǨIʼn>ۼ83dU{y>W9m _bgLJ:) dNtf*{Qu ^v ,͡ p\ ^W9ŧǛ9qBo+/wL'qhґJYWeɗu"06A?uGL y-&GUt~wAF g@lB$| q_$QPϮ60ܽSw/> tb.((,s)26q6Y.n02&.]bIp.* @f   O~AGuVvԙ9읱 _]#pMQۧ[|t5b|3 _eyhM,rkB2 d@[v--t_-"9h܀d:[qYiFuO>~FIDu艣 =PzC rl~%`8G "L+tXT1(6h}< `AfNFtyZ' >6`N6j6Zٽ)C]zHU^r_Y򙇜lxͤc"%} p\nZw}5ÏcYEwΊ[T6z1zwuxFp~BP\b/R8|S [)bjZ)7?@1@1CHHHkkPdyPd2(r)(bL\RިҟEd^2-IU T0 Ƚ7ߜTYkQ}m HHHB5Sc?rnv2<䀜@.X; #Q(>vGqy(G alvR@sjeM}3:M|$V@a7 Y/9۸#},9/5H]»m:Or_USSYi4atyo'T{sK:/#6Arf/͍-%8mfW+ZDk8wi,L丏oyI>{aݸܻ]y{C,tL#݂51bZ- 4ˆ2l"ߋV}4k#uzb?/ʔ⸈PR9ɕ55 j:چ>UCXZZ~5D:mCmuTR{v:׿j# `*I=vcs:|hA[ c/>GsV^L^=mR=SܓBlZ5- z;ʇv-qoj;T=ϞD]<Eun5+Vj5x{W Ocn֎j7.Je[k ,ϛ'cGP.],=]qih軒]q%4]ZZ fYT3I崲M7 MTrϥXnpUP`Q& 7`Yf `otUJ.YS<֦6mMMuλ%6+*HDdK2~y2~E~ٿXp7~~k!0= +x[NK̩S0R+KZi߰w 05h{xy̨SPZTPfZQ3Ws[joQ(Ax;;.+r}xb0{HL15dw77~Z.M5rM^#̌-MneTAv7L,~-5vF݂xVf>bbXɚCx,/j ???:x?tT7yi?sҠ;vS* ΃}ZlXV7\ߖ-ӓ3ZB{*J}^[E `O~KƗk|\M 5مvٱҢCr=n~*>ЛFZ'Mݿ1XesjrJ3K\? y5E 'OϮi1ܾg:w8əGr]\-{?wq8I5{0K;8#5~~2XWG^iuGTsV+SZo@b^_Q=AIrQș>r}.zEr >n0@mBj Eº:9,Wܲq[=g _T]2T5}6d<|Hꝉ.AJER'F+8LD4iobv3'tUv,k G_ܒ擢U|mfIt>\HFDuKqxt&E:G+Mx62Ă>1?9-YT+668/ 60k/m.d6~fЮEU( {EH0l-Wuh:Uك,FĔxX#!>8j6Ǫ=t]oЊa j"@ VE/0Sf6-dխmqQOVyX3}gjy=PRj ||T= c}w WVU-PI& ek:, O] 36&Z] B [T}Cwq{J[*n{U5v̞`nHUS|-gCUuBż f.'mp,7Ad`DZ(ʷ;y-jA 3?G,XE;ưPrЏ׸;c{o>rʇC`Q~ Rء{cm*nΟ]=^=pg6 WXb}R#rqPr2i4c<͆ qe2@Rm%Sa٭GXi1?L/z叴]f?'KQZ\9p1jXAީ&rd$;uV{?* 2>9ʎ+LW2<ЍV(tV4ʿ@ jJuֳ=OzSϱ%\o^iY2r} kQXJcrؿ,EkU9; dfl'cR=SXkY`#:HlmͫC-34_ eyI9WkVxЦgDo|Qe݌n1K=rniԗ!|^Ɩ!ˬ'v,?='?3%R&V#w I<"p[ 4A l Լr߭ykX:`R%,P[\(;;Jm^LGI BRt)J6M0Z%xY T%, I H &&+_@ E@|-@ě# 1n&0?b`:8 t`gNX6Nxe_3hc4x!+hS@W0&O*ArXqLxCZjD jHǸeR?qFeq<({2ur7VF=֡h&G2=^W8L0dnMa0| /T3)-Q mucx`D4Fn'1/`A5&I3D?_S#R€; vKY-2!Ds2{,m^k '0>4Gpb)Cs86 {qepw oÖ6GD| z8kXWxJeNs9"&vGE?vT/C@s- X *mI+3w ;iZ Խ3YowH'7vn Z<wMV7KR7i&˕=+d ӘԀV9f/5RvO}UNym8)w{r݋&y&Ԇsvu3vyO['Lz|='g2NޥqiyK6rl1sS(6L~mܛK\)ON*7?~!bd;|lOvsr]aDiPm=n?kݐI]gv.M'ckoVfTb»wrGRkF2737{{Rwn!9:-6Hz~iAj0~'O*T7%SC-M'3bB|T223<f^'qg>%{UFҕmW_=էNFwMݭjyo\ ㇣Vu-N>K?} i_ˋ[_şQn}8 ;:Ndz;Jq8#@ י|I*jkR7Ys@hYzJц6܊B7?\Hcu>,xFT.OYGz9 RM1fR_VKߡ_ps7wS/IJU{Rvm#g^Zm?Z U"D։/,u4X][:~YlU"iR|v[;3e!_cW_=2:R'ε"&UԬbuiԫ T]vDou>ykr}W5D,ʹhd֮D`T0So@*š X3 a ܘB\4v͘Uò'[{Vs/c on.s$-S>@L[o\V[AQa㼵Nj#mj%r2p%`rп= FRw@t;:&>ڞݮLkJrvյVz7$ރEUn{1xj{ׯGK\Вb*@+u%-í{6Je)ʵא, ؑ)c6D`F?yun4 ]5 vX{a׃9N1qYjmY-0bgRw aCjņDeWcmejYc\k_lYAo/b28nge!k[Ĉe_Gͽ_-v¯64Vj'*o ;mŘƩb½4gt_mP?Vwssk)B4}=oyWi-\A``)H6(uM);7˔fӑ;h~\}Tp_Rg!t^ʇOU5x>%;\_h6t|G kh"^44^t66xWuLVcQӟnr Wvϡ!vB(S` \d;߬ø:Aqle𛝳ZYHŠیކޝ&39&q7IB8%]wcuӚ:m8mmW{L9CKc86%oAq#+`U,|7.}S~CkP^j{aOF_^r*) %3E2nE/7ܥ.3)эX6U$ZG >""n|yy||Z,l|0BШ& ߘ> Bi]ģEۖ*5V[`h75nn\2)QP$J`0y7[ QŒ |*!+A ջSG`-l˨@>{{6h<.r׭H1 J]0Oh/nlFnlrm=;$i7h4v>Ǭ>Jg gVKAiݽZA`~ˆT%MPM >[vkOZO#Bv\7)%9ěewUŰ;O~Ș#vzE `:A׼ұS/aWq9egZ,Y;xkGIXWhĝ|t %m$LZU!L> @9*`+ ɿK44@28uHK@>ǜZ3{/ *~_@2̡%Ɯ4Bk|+!JnlJk#:VMZ #a?9I" hRX~P< @6@o+@\z޳ճrq]}m9 Nn l]lےcl _[co KÒգ6pe\ nqp8/L2TxQn1Z\f;I.< Jf`ƾq9@#QC"x%/zGガdѢY`D%u!+Q.V N֬54Q#@< r{)WO ݈ރXїh*Sf($܆ m)sx5)m vBQ=Y_M[DLPH+  $ bD%Nl41떿rYOEJ,~Ѽl}E"jr>p/YKG~ctttE`yN=? y@b{Lpz^_zASOh(ژ{.?Tm,N=>јF{+d9*Lv£f xs΀ͫqjM~'Rn?+o2@T $؆]VO `1` i XK=3q6ZKେ\o@S@맙Gnހ_'Go5Q|g`~EtDs_'o m(y3q Yyq>nfSHPRG;/6{`!7Ӆ_4gopʬ7 Ջ֟br""ގ@Ժ9 J \/NSK޺{ ˻uOyBxD~ͭ{_ҏ߂xTz1;O5 !#\27K:/6 ?p|6g|RߘO.B!A~Ü^}b8^~;|zymEv jп(~u]UhaWiN랟W._|0^vqu1~)F});h[jYl:f۟ZfU@^W.Xb[GؘUslc?_B4s࿌I34ԓ8'Xra;s^IjݜYЯ=yzlcx {iuzN:6/Botfo#.B~ÌF2UÂ\$Y#dJd 4Tr6,_[F8BFo}3Mɩ_q]}m=b.EՇ'L"pTՂjSojfߝu^|B{zq=ّ!e-;T-Ou#Tx֖!-E+\+#9uɎb}B!$k^,Ȱ"̬we:Ð?^Kw'%L5VT}Z5hx= A?\MJ"bfsۨ @yp"B`>0Иnܑ޽WT=驙['r ͋U-x U\56M-jΩN&O#_T>Z`n^U5;]=X3a>V*L%9w}62g˂kZv l`}Mש+ ăvv jh wI9Dܮf Dӕ L@t~rӨ+;ᷜ6Q__oO:x/t=kl\iVo2eom5ת?퉽U"Tg,k!qhn|Z3G*7a~\+E/BXIl}+T9N"b9nxi{;D>=;>:C.-wpS Q6 uKJW˗&~mM~2Na-6: . $#;F{ms|E[]>"%ZW*Q%Olynb^Jz=nq&JYcfE.1V]0S2jƉ (g@vs;dhA_Hۮ&[ nA$7aTUkp|hAa`Ú}> 26Tڅ ?o}TS=TRLG? k:d醍s]\J sogb^^e"xzY!?4f 7fu kӛ,\LAN̆t /٨(Zzdgd9$@c84QzURh1wܸmBLk?Wsf^'.^dN2:K3( `59ٵ(g#FP,G?˯'wctFc#WzWP8b6]/֛tS:op/u|m%?Hǃ*mA2S%N=D $}bMQ=>-,qX`. ei^-^du*־s%9(bœsz2{gϣ2R@`x!A̰kD %9z|{ +&R1aqaUdAIԢŊ!IK-\n!ɴH;N[zhU*վ(*dT}3eOE%=1ZFq.RbK.Ao۵eUNC *UX%Y7,.#oKl.@V)*sy*5UrZd:$rD+Ĭ4b1—|6o¸(D$Y= &A8S|)^Q||.Ҧ$+f$d!rw ݚ(MA}ɰa1"{mg<2׫,у{!Q0sT!M_ *~O!*I\GH3 ~iۺJQ}ԃr,rd̥r6کfQ8uKUR7 Hw1߂¥ی>F}}Ơo g`RoWaLoM2&!+gIxC}[Xz%"DR]z3wnsWoţdz׌{s:9[98F]9Uwm"dU#_O$?X,= ,]34pmzS3uo}C׷;Cwn[|u3*ĭƲIϗHh"$A ?rmJqv'xg:Qdn 1|1=iI#nՇ!NΕ=hrb.+}oҏS;EeKzDla_ endstream endobj 63 0 obj [/ICCBased 144 0 R] endobj 9 0 obj [8 0 R] endobj 285 0 obj <> endobj xref 0 286 0000000000 65535 f 0000000016 00000 n 0000000144 00000 n 0000082930 00000 n 0000000000 00000 f 0000747849 00000 n 0000748065 00000 n 0000747712 00000 n 0000747522 00000 n 0001595641 00000 n 0000082982 00000 n 0000083967 00000 n 0000087629 00000 n 0000755914 00000 n 0000122580 00000 n 0000529717 00000 n 0000755308 00000 n 0000755431 00000 n 0000755555 00000 n 0000755678 00000 n 0000755801 00000 n 0000089568 00000 n 0000090196 00000 n 0000090824 00000 n 0000091139 00000 n 0000091764 00000 n 0000092394 00000 n 0000092720 00000 n 0000093046 00000 n 0000093372 00000 n 0000093707 00000 n 0000094033 00000 n 0000094368 00000 n 0000094692 00000 n 0000095315 00000 n 0000095648 00000 n 0000095938 00000 n 0000096228 00000 n 0000096518 00000 n 0000096852 00000 n 0000097143 00000 n 0000097434 00000 n 0000097725 00000 n 0000098016 00000 n 0000098307 00000 n 0000098934 00000 n 0000099225 00000 n 0000099566 00000 n 0000099906 00000 n 0000100245 00000 n 0000100585 00000 n 0000100923 00000 n 0000101263 00000 n 0000101603 00000 n 0000101943 00000 n 0000102265 00000 n 0000102592 00000 n 0000102909 00000 n 0000103245 00000 n 0000103584 00000 n 0000103895 00000 n 0000104206 00000 n 0000087691 00000 n 0001595605 00000 n 0000089006 00000 n 0000089054 00000 n 0000747459 00000 n 0000747396 00000 n 0000706316 00000 n 0000106098 00000 n 0000727560 00000 n 0000706379 00000 n 0000706253 00000 n 0000706190 00000 n 0000705540 00000 n 0000705603 00000 n 0000669170 00000 n 0000669107 00000 n 0000687817 00000 n 0000668457 00000 n 0000668520 00000 n 0000604332 00000 n 0000637388 00000 n 0000653283 00000 n 0000637451 00000 n 0000636738 00000 n 0000636801 00000 n 0000636079 00000 n 0000636142 00000 n 0000635430 00000 n 0000635493 00000 n 0000635367 00000 n 0000604269 00000 n 0000620179 00000 n 0000604206 00000 n 0000604143 00000 n 0000604080 00000 n 0000585074 00000 n 0000594698 00000 n 0000585137 00000 n 0000585010 00000 n 0000584946 00000 n 0000584882 00000 n 0000584818 00000 n 0000584754 00000 n 0000584690 00000 n 0000584626 00000 n 0000574099 00000 n 0000530479 00000 n 0000579965 00000 n 0000574163 00000 n 0000567756 00000 n 0000571014 00000 n 0000567820 00000 n 0000567095 00000 n 0000567159 00000 n 0000535673 00000 n 0000566433 00000 n 0000566497 00000 n 0000529958 00000 n 0000565773 00000 n 0000565837 00000 n 0000565111 00000 n 0000565175 00000 n 0000564449 00000 n 0000564513 00000 n 0000563787 00000 n 0000563851 00000 n 0000563723 00000 n 0000541938 00000 n 0000553002 00000 n 0000542002 00000 n 0000541874 00000 n 0000535609 00000 n 0000538770 00000 n 0000529894 00000 n 0000532797 00000 n 0000529830 00000 n 0000529653 00000 n 0000104540 00000 n 0000512509 00000 n 0000104604 00000 n 0000106142 00000 n 0000122616 00000 n 0000122674 00000 n 0000512625 00000 n 0000512691 00000 n 0000512726 00000 n 0000513028 00000 n 0000529540 00000 n 0000513102 00000 n 0000530524 00000 n 0000532739 00000 n 0000532913 00000 n 0000532979 00000 n 0000533014 00000 n 0000533320 00000 n 0000533394 00000 n 0000536260 00000 n 0000538886 00000 n 0000538952 00000 n 0000538987 00000 n 0000539290 00000 n 0000539364 00000 n 0000542867 00000 n 0000553118 00000 n 0000553184 00000 n 0000553219 00000 n 0000553514 00000 n 0000553588 00000 n 0000563967 00000 n 0000564033 00000 n 0000564068 00000 n 0000564375 00000 n 0000564629 00000 n 0000564695 00000 n 0000564730 00000 n 0000565037 00000 n 0000565291 00000 n 0000565357 00000 n 0000565392 00000 n 0000565699 00000 n 0000565953 00000 n 0000566019 00000 n 0000566054 00000 n 0000566359 00000 n 0000566613 00000 n 0000566679 00000 n 0000566714 00000 n 0000567021 00000 n 0000567275 00000 n 0000567341 00000 n 0000567376 00000 n 0000567682 00000 n 0000568527 00000 n 0000571130 00000 n 0000571196 00000 n 0000571231 00000 n 0000571538 00000 n 0000571612 00000 n 0000575903 00000 n 0000580081 00000 n 0000580147 00000 n 0000580182 00000 n 0000580490 00000 n 0000580564 00000 n 0000585911 00000 n 0000594813 00000 n 0000594879 00000 n 0000594914 00000 n 0000595219 00000 n 0000595293 00000 n 0000605586 00000 n 0000620294 00000 n 0000620360 00000 n 0000620395 00000 n 0000620700 00000 n 0000620774 00000 n 0000635608 00000 n 0000635674 00000 n 0000635709 00000 n 0000636005 00000 n 0000636257 00000 n 0000636323 00000 n 0000636358 00000 n 0000636664 00000 n 0000636916 00000 n 0000636982 00000 n 0000637017 00000 n 0000637314 00000 n 0000638705 00000 n 0000653398 00000 n 0000653464 00000 n 0000653499 00000 n 0000653805 00000 n 0000653879 00000 n 0000668635 00000 n 0000668701 00000 n 0000668736 00000 n 0000669033 00000 n 0000670681 00000 n 0000687932 00000 n 0000687998 00000 n 0000688033 00000 n 0000688330 00000 n 0000688404 00000 n 0000705718 00000 n 0000705784 00000 n 0000705819 00000 n 0000706116 00000 n 0000708300 00000 n 0000727675 00000 n 0000727741 00000 n 0000727776 00000 n 0000728062 00000 n 0000728136 00000 n 0000747594 00000 n 0000747626 00000 n 0000752539 00000 n 0000752566 00000 n 0000751037 00000 n 0000748376 00000 n 0000748696 00000 n 0000751333 00000 n 0000752944 00000 n 0000753192 00000 n 0000753262 00000 n 0000753536 00000 n 0000753617 00000 n 0000755989 00000 n 0000756449 00000 n 0000757465 00000 n 0000773443 00000 n 0000839033 00000 n 0000904623 00000 n 0000970213 00000 n 0001035803 00000 n 0001070885 00000 n 0001136475 00000 n 0001202065 00000 n 0001267655 00000 n 0001333245 00000 n 0001398835 00000 n 0001464425 00000 n 0001530015 00000 n 0001595664 00000 n trailer <<4CD26A66CD787C4DA5FC6A2070DD9322>]>> startxref 1595857 %%EOF buildbot-0.8.8/docs/manual/_images/status.svg000066400000000000000000013273001222546025000212140ustar00rootroot00000000000000 image/svg+xml Status Delivery Georgi Valkov 2010-01-28T18:21:55+02:00 2010-01-28T18:21:55+02:00 2010-01-28T18:21:54+02:00 Adobe Illustrator CS4 256 248 JPEG /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgA+AEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FXYqxfXtRvoPPnl WyhnZLS8j1I3UIPwyGKKMx8h/kljTFWUYq7FXYq7FXYq7FXYq7FXYqx7zV5yttDeGyt7d9S1u6Uv a6ZCwVigNDLNIfhiiB25t1OygnbFWKzW/m7Vf3ms67NagkEWGjn6pCnsZyGuZPnzUf5OC005/LkJ UhNS1eNj0ddW1FiPoedl+8Y2tL4b/wA8aHSS1vP8RWKD49Pv+Ed3QD/dV3GqKze0qGv8wxtaZp5d 8x6Z5g04XtgzUVjFcW8q8JoJk+3FNGd0dfD6RUb4UJpirsVdirsVQ0ZX9JzrzYsIYSYz9kAvLQjf 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 XYq7FXYq7FX/2Q== xmp.iid:0F9190091A0CDF1198A8D064EBA738F3 xmp.did:0F9190091A0CDF1198A8D064EBA738F3 uuid:5D20892493BFDB11914A8590D31508C8 proof:pdf uuid:7385d860-ddab-4cd9-b389-94efa1ea2700 xmp.did:0AFC8385150CDF1198A8D064EBA738F3 uuid:5D20892493BFDB11914A8590D31508C8 proof:pdf converted from application/pdf to <unknown> saved xmp.iid:D27F11740720681191099C3B601C4548 2008-04-17T14:19:15+05:30 Adobe Illustrator CS4 / converted from application/pdf to <unknown> converted from application/pdf to <unknown> saved xmp.iid:F97F1174072068118D4ED246B3ADB1C6 2008-05-15T16:23:06-07:00 Adobe Illustrator CS4 / saved xmp.iid:FA7F1174072068118D4ED246B3ADB1C6 2008-05-15T17:10:45-07:00 Adobe Illustrator CS4 / saved xmp.iid:EF7F117407206811A46CA4519D24356B 2008-05-15T22:53:33-07:00 Adobe Illustrator CS4 / saved xmp.iid:F07F117407206811A46CA4519D24356B 2008-05-15T23:07:07-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F117407206811BDDDFD38D0CF24DD 2008-05-16T10:35:43-07:00 Adobe Illustrator CS4 / converted from application/pdf to <unknown> saved xmp.iid:F97F117407206811BDDDFD38D0CF24DD 2008-05-16T10:40:59-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to <unknown> saved xmp.iid:FA7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:26:55-07:00 Adobe Illustrator CS4 / saved xmp.iid:FB7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:29:01-07:00 Adobe Illustrator CS4 / saved xmp.iid:FC7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:29:20-07:00 Adobe Illustrator CS4 / saved xmp.iid:FD7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:30:54-07:00 Adobe Illustrator CS4 / saved xmp.iid:FE7F117407206811BDDDFD38D0CF24DD 2008-05-16T11:31:22-07:00 Adobe Illustrator CS4 / saved xmp.iid:B233668C16206811BDDDFD38D0CF24DD 2008-05-16T12:23:46-07:00 Adobe Illustrator CS4 / saved xmp.iid:B333668C16206811BDDDFD38D0CF24DD 2008-05-16T13:27:54-07:00 Adobe Illustrator CS4 / saved xmp.iid:B433668C16206811BDDDFD38D0CF24DD 2008-05-16T13:46:13-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F11740720681197C1BF14D1759E83 2008-05-16T15:47:57-07:00 Adobe Illustrator CS4 / saved xmp.iid:F87F11740720681197C1BF14D1759E83 2008-05-16T15:51:06-07:00 Adobe Illustrator CS4 / saved xmp.iid:F97F11740720681197C1BF14D1759E83 2008-05-16T15:52:22-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FA7F117407206811B628E3BF27C8C41B 2008-05-22T13:28:01-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:FF7F117407206811B628E3BF27C8C41B 2008-05-22T16:23:53-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:07C3BD25102DDD1181B594070CEB88D9 2008-05-28T16:45:26-07:00 Adobe Illustrator CS4 / converted from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator saved xmp.iid:F87F1174072068119098B097FDA39BEF 2008-06-02T13:25:25-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F117407206811BB1DBF8F242B6F84 2008-06-09T14:58:36-07:00 Adobe Illustrator CS4 / saved xmp.iid:F97F117407206811ACAFB8DA80854E76 2008-06-11T14:31:27-07:00 Adobe Illustrator CS4 / saved xmp.iid:0180117407206811834383CD3A8D2303 2008-06-11T22:37:35-07:00 Adobe Illustrator CS4 / saved xmp.iid:F77F117407206811818C85DF6A1A75C3 2008-06-27T14:40:42-07:00 Adobe Illustrator CS4 / saved xmp.iid:921461FF4F63DE11954883E494157F9B 2009-06-27T22:56:11+03:00 Adobe Illustrator CS4 / saved xmp.iid:0AFC8385150CDF1198A8D064EBA738F3 2010-01-28T16:32:48+02:00 Adobe Illustrator CS4 / saved xmp.iid:0F9190091A0CDF1198A8D064EBA738F3 2010-01-28T18:21:55+02:00 Adobe Illustrator CS4 / Print False True 1 792.000000 612.000000 Points MyriadPro-Bold Myriad Pro Bold Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-Bold.otf MyriadPro-Cond Myriad Pro Condensed Open Type Version 2.037;PS 2.000;hotconv 1.0.51;makeotf.lib2.0.18671 False MyriadPro-Cond.otf Cyan Magenta Yellow Black Default Swatch Group 0 White CMYK PROCESS 0.000000 0.000000 0.000000 0.000000 Black CMYK PROCESS 0.000000 0.000000 0.000000 100.000000 CMYK Red CMYK PROCESS 0.000000 100.000000 100.000000 0.000000 CMYK Yellow CMYK PROCESS 0.000000 0.000000 100.000000 0.000000 CMYK Green CMYK PROCESS 100.000000 0.000000 100.000000 0.000000 CMYK Cyan CMYK PROCESS 100.000000 0.000000 0.000000 0.000000 CMYK Blue CMYK PROCESS 100.000000 100.000000 0.000000 0.000000 CMYK Magenta CMYK PROCESS 0.000000 100.000000 0.000000 0.000000 C=15 M=100 Y=90 K=10 CMYK PROCESS 14.999998 100.000000 90.000004 10.000002 C=0 M=90 Y=85 K=0 CMYK PROCESS 0.000000 90.000004 84.999996 0.000000 C=0 M=80 Y=95 K=0 CMYK PROCESS 0.000000 80.000001 94.999999 0.000000 C=0 M=50 Y=100 K=0 CMYK PROCESS 0.000000 50.000000 100.000000 0.000000 C=0 M=35 Y=85 K=0 CMYK PROCESS 0.000000 35.000002 84.999996 0.000000 C=5 M=0 Y=90 K=0 CMYK PROCESS 5.000001 0.000000 90.000004 0.000000 C=20 M=0 Y=100 K=0 CMYK PROCESS 19.999999 0.000000 100.000000 0.000000 C=50 M=0 Y=100 K=0 CMYK PROCESS 50.000000 0.000000 100.000000 0.000000 C=75 M=0 Y=100 K=0 CMYK PROCESS 75.000000 0.000000 100.000000 0.000000 C=85 M=10 Y=100 K=10 CMYK PROCESS 84.999996 10.000002 100.000000 10.000002 C=90 M=30 Y=95 K=30 CMYK PROCESS 90.000004 30.000001 94.999999 30.000001 C=75 M=0 Y=75 K=0 CMYK PROCESS 75.000000 0.000000 75.000000 0.000000 C=80 M=10 Y=45 K=0 CMYK PROCESS 80.000001 10.000002 44.999999 0.000000 C=70 M=15 Y=0 K=0 CMYK PROCESS 69.999999 14.999998 0.000000 0.000000 C=85 M=50 Y=0 K=0 CMYK PROCESS 84.999996 50.000000 0.000000 0.000000 C=100 M=95 Y=5 K=0 CMYK PROCESS 100.000000 94.999999 5.000001 0.000000 C=100 M=100 Y=25 K=25 CMYK PROCESS 100.000000 100.000000 25.000000 25.000000 C=75 M=100 Y=0 K=0 CMYK PROCESS 75.000000 100.000000 0.000000 0.000000 C=50 M=100 Y=0 K=0 CMYK PROCESS 50.000000 100.000000 0.000000 0.000000 C=35 M=100 Y=35 K=10 CMYK PROCESS 35.000002 100.000000 35.000002 10.000002 C=10 M=100 Y=50 K=0 CMYK PROCESS 10.000002 100.000000 50.000000 0.000000 C=0 M=95 Y=20 K=0 CMYK PROCESS 0.000000 94.999999 19.999999 0.000000 C=25 M=25 Y=40 K=0 CMYK PROCESS 25.000000 25.000000 39.999998 0.000000 C=40 M=45 Y=50 K=5 CMYK PROCESS 39.999998 44.999999 50.000000 5.000001 C=50 M=50 Y=60 K=25 CMYK PROCESS 50.000000 50.000000 60.000002 25.000000 C=55 M=60 Y=65 K=40 CMYK PROCESS 55.000001 60.000002 64.999998 39.999998 C=25 M=40 Y=65 K=0 CMYK PROCESS 25.000000 39.999998 64.999998 0.000000 C=30 M=50 Y=75 K=10 CMYK PROCESS 30.000001 50.000000 75.000000 10.000002 C=35 M=60 Y=80 K=25 CMYK PROCESS 35.000002 60.000002 80.000001 25.000000 C=40 M=65 Y=90 K=35 CMYK PROCESS 39.999998 64.999998 90.000004 35.000002 C=40 M=70 Y=100 K=50 CMYK PROCESS 39.999998 69.999999 100.000000 50.000000 C=50 M=70 Y=80 K=70 CMYK PROCESS 50.000000 69.999999 80.000001 69.999999 Grays 1 C=0 M=0 Y=0 K=100 CMYK PROCESS 0.000000 0.000000 0.000000 100.000000 C=0 M=0 Y=0 K=90 CMYK PROCESS 0.000000 0.000000 0.000000 89.999402 C=0 M=0 Y=0 K=80 CMYK PROCESS 0.000000 0.000000 0.000000 79.998797 C=0 M=0 Y=0 K=70 CMYK PROCESS 0.000000 0.000000 0.000000 69.999701 C=0 M=0 Y=0 K=60 CMYK PROCESS 0.000000 0.000000 0.000000 59.999102 C=0 M=0 Y=0 K=50 CMYK PROCESS 0.000000 0.000000 0.000000 50.000000 C=0 M=0 Y=0 K=40 CMYK PROCESS 0.000000 0.000000 0.000000 39.999402 C=0 M=0 Y=0 K=30 CMYK PROCESS 0.000000 0.000000 0.000000 29.998803 C=0 M=0 Y=0 K=20 CMYK PROCESS 0.000000 0.000000 0.000000 19.999701 C=0 M=0 Y=0 K=10 CMYK PROCESS 0.000000 0.000000 0.000000 9.999102 C=0 M=0 Y=0 K=5 CMYK PROCESS 0.000000 0.000000 0.000000 4.998803 Brights 1 C=0 M=100 Y=100 K=0 CMYK PROCESS 0.000000 100.000000 100.000000 0.000000 C=0 M=75 Y=100 K=0 CMYK PROCESS 0.000000 75.000000 100.000000 0.000000 C=0 M=10 Y=95 K=0 CMYK PROCESS 0.000000 10.000002 94.999999 0.000000 C=85 M=10 Y=100 K=0 CMYK PROCESS 84.999996 10.000002 100.000000 0.000000 C=100 M=90 Y=0 K=0 CMYK PROCESS 100.000000 90.000004 0.000000 0.000000 C=60 M=90 Y=0 K=0 CMYK PROCESS 60.000002 90.000004 0.003099 0.003099 Adobe PDF library 9.00 buildbot-0.8.8/docs/manual/_images/status.txt000066400000000000000000000016231222546025000212300ustar00rootroot00000000000000 Status Objects Status Plugins User Clients +------+ +---------+ +-----------+ |Status|<--------------+-->|Waterfall|<-------|Web Browser| +------+ | +---------+ +-----------+ | +-----+ | v v | +-------+ +-------+ | +---+ +----------+ |Builder| |Builder| +---->|IRC|<----------->IRC Server| |Status | |Status | | +---+ +----------+ +-------+ +-------+ | | +----+ | v v | +------------+ +----+ +------+ +------+ +-->|MailNotifier|---->|SMTP| |Build | |Build | +------------+ +----+ |Status| |Status| +------+ +------+ | +-----+ v v +------+ +------+ |Step | |Step | |Status| |Status| +------+ +------+ | +---+ v v +----+ +----+ |Log | |Log | |File| |File| +----+ +----+ buildbot-0.8.8/docs/manual/cfg-builders.rst000066400000000000000000000202651222546025000206430ustar00rootroot00000000000000.. -*- rst -*- .. bb:cfg:: builders .. _Builder-Configuration: Builder Configuration --------------------- The :bb:cfg:`builders` configuration key is a list of objects giving configuration for the Builders. For more information on the function of Builders in Buildbot, see :ref:`the Concepts chapter `. The class definition for the builder configuration is in :file:`buildbot.config`. In the configuration file, its use looks like:: from buildbot.config import BuilderConfig c['builders'] = [ BuilderConfig(name='quick', slavenames=['bot1', 'bot2'], factory=f_quick), BuilderConfig(name='thorough', slavename='bot1', factory=f_thorough), ] ``BuilderConfig`` takes the following keyword arguments: ``name`` This specifies the Builder's name, which is used in status reports. ``slavename`` ``slavenames`` These arguments specify the buildslave or buildslaves that will be used by this Builder. All slaves names must appear in the :bb:cfg:`slaves` configuration parameter. Each buildslave can accommodate multiple builders. The ``slavenames`` parameter can be a list of names, while ``slavename`` can specify only one slave. ``factory`` This is a :class:`buildbot.process.factory.BuildFactory` instance which controls how the build is performed by defining the steps in the build. Full details appear in their own section, :ref:`Build-Factories`. Other optional keys may be set on each ``BuilderConfig``: ``builddir`` Specifies the name of a subdirectory of the master's basedir in which everything related to this builder will be stored. This holds build status information. If not set, this parameter defaults to the builder name, with some characters escaped. Each builder must have a unique build directory. ``slavebuilddir`` Specifies the name of a subdirectory (under the slave's configured base directory) in which everything related to this builder will be placed on the buildslave. This is where checkouts, compiles, and tests are run. If not set, defaults to ``builddir``. If a slave is connected to multiple builders that share the same ``slavebuilddir``, make sure the slave is set to run one build at a time or ensure this is fine to run multiple builds from the same directory simultaneously. ``category`` If provided, this is a string that identifies a category for the builder to be a part of. Status clients can limit themselves to a subset of the available categories. A common use for this is to add new builders to your setup (for a new module, or for a new buildslave) that do not work correctly yet and allow you to integrate them with the active builders. You can put these new builders in a test category, make your main status clients ignore them, and have only private status clients pick them up. As soon as they work, you can move them over to the active category. ``nextSlave`` If provided, this is a function that controls which slave will be assigned future jobs. The function is passed two arguments, the :class:`Builder` object which is assigning a new job, and a list of :class:`BuildSlave` objects. The function should return one of the :class:`BuildSlave` objects, or ``None`` if none of the available slaves should be used. The function can optionally return a Deferred, which should fire with the same results. ``nextBuild`` If provided, this is a function that controls which build request will be handled next. The function is passed two arguments, the :class:`Builder` object which is assigning a new job, and a list of :class:`BuildRequest` objects of pending builds. The function should return one of the :class:`BuildRequest` objects, or ``None`` if none of the pending builds should be started. This function can optionally return a Deferred which should fire with the same results. ``canStartBuild`` If provided, this is a function that can veto whether a particular buildslave should be used for a given build request. The function is passed three arguments: the :class:`Builder`, a :class:`BuildSlave`, and a :class:`BuildRequest`. The function should return ``True`` if the combination is acceptable, or ``False`` otherwise. This function can optionally return a Deferred which should fire with the same results. ``locks`` This argument specifies a list of locks that apply to this builder; see :ref:`Interlocks`. ``env`` A Builder may be given a dictionary of environment variables in this parameter. The variables are used in :bb:step:`ShellCommand` steps in builds created by this builder. The environment variables will override anything in the buildslave's environment. Variables passed directly to a :class:`ShellCommand` will override variables of the same name passed to the Builder. For example, if you have a pool of identical slaves it is often easier to manage variables like :envvar:`PATH` from Buildbot rather than manually editing it inside of the slaves' environment. :: f = factory.BuildFactory f.addStep(ShellCommand( command=['bash', './configure'])) f.addStep(Compile()) c['builders'] = [ BuilderConfig(name='test', factory=f, slavenames=['slave1', 'slave2', 'slave3', 'slave4'], env={'PATH': '/opt/local/bin:/opt/app/bin:/usr/local/bin:/usr/bin'}), ] .. index:: Builds; merging ``mergeRequests`` Specifies how build requests for this builder should be merged. See :ref:`Merging-Build-Requests`, below. .. index:: Properties; builder ``properties`` A builder may be given a dictionary of :ref:`Build-Properties` specific for this builder in this parameter. Those values can be used later on like other properties. :ref:`Interpolate`. ``description`` A builder may be given an arbitrary description, which will show up in the web status on the builder's page. .. index:: Builds; merging .. _Merging-Build-Requests: Merging Build Requests ~~~~~~~~~~~~~~~~~~~~~~ When more than one build request is available for a builder, Buildbot can "merge" the requests into a single build. This is desirable when build requests arrive more quickly than the available slaves can satisfy them, but has the drawback that separate results for each build are not available. Requests are only candidated for a merge if both requests have exactly the same :ref:`codebases`. This behavior can be controlled globally, using the :bb:cfg:`mergeRequests` parameter, and on a per-:class:`Builder` basis, using the ``mergeRequests`` argument to the :class:`Builder` configuration. If ``mergeRequests`` is given, it completely overrides the global configuration. For either configuration parameter, a value of ``True`` (the default) causes buildbot to merge BuildRequests that have "compatible" source stamps. Source stamps are compatible if: * their codebase, branch, project, and repository attributes match exactly; * neither source stamp has a patch (e.g., from a try scheduler); and * either both source stamps are associated with changes, or neither ar associated with changes but they have matching revisions. This algorithm is implemented by the :class:`SourceStamp` method :func:`canBeMergedWith`. A configuration value of ``False`` indicates that requests should never be merged. The configuration value can also be a callable, specifying a custom merging function. See :ref:`Merge-Request-Functions` for details. .. index:: Builds; priority .. _Prioritizing-Builds: Prioritizing Builds ~~~~~~~~~~~~~~~~~~~ The :class:`BuilderConfig` parameter ``nextBuild`` can be use to prioritize build requests within a builder. Note that this is orthogonal to :ref:`Prioritizing-Builders`, which controls the order in which builders are called on to start their builds. The details of writing such a function are in :ref:`Build-Priority-Functions`. Such a function can be provided to the BuilderConfig as follows:: def pickNextBuild(builder, requests): # ... c['builders'] = [ BuilderConfig(name='test', factory=f, nextBuild=pickNextBuild, slavenames=['slave1', 'slave2', 'slave3', 'slave4']), ] buildbot-0.8.8/docs/manual/cfg-buildfactories.rst000066400000000000000000000337131222546025000220330ustar00rootroot00000000000000.. _Build-Factories: Build Factories =============== Each Builder is equipped with a ``build factory``, which is defines the steps used to perform that particular type of build. This factory is created in the configuration file, and attached to a Builder through the ``factory`` element of its dictionary. The steps used by these builds are defined in the next section, :ref:`Build-Steps`. .. note:: Build factories are used with builders, and are not added directly to the buildmaster configuration dictionary. .. _BuildFactory: .. index:: Build Factory Defining a Build Factory ------------------------ A :class:`BuildFactory` defines the steps that every build will follow. Think of it as a glorified script. For example, a build factory which consists of an SVN checkout followed by a ``make build`` would be configured as follows:: from buildbot.steps import svn, shell from buildbot.process import factory f = factory.BuildFactory() f.addStep(svn.SVN(svnurl="http://..", mode="incremental")) f.addStep(shell.Compile(command=["make", "build"])) This factory would then be attached to one builder (or several, if desired):: c['builders'].append( BuilderConfig(name='quick', slavenames=['bot1', 'bot2'], factory=f)) It is also possible to pass a list of steps into the :class:`BuildFactory` when it is created. Using :meth:`addStep` is usually simpler, but there are cases where is is more convenient to create the list of steps ahead of time, perhaps using some Python tricks to generate the steps. :: from buildbot.steps import source, shell from buildbot.process import factory all_steps = [ source.CVS(cvsroot=CVSROOT, cvsmodule="project", mode="update"), shell.Compile(command=["make", "build"]), ] f = factory.BuildFactory(all_steps) Finally, you can also add a sequence of steps all at once:: f.addSteps(all_steps) Attributes ~~~~~~~~~~ The following attributes can be set on a build factory after it is created, e.g., :: f = factory.BuildFactory() f.useProgress = False :attr:`useProgress` (defaults to ``True``): if ``True``, the buildmaster keeps track of how long each step takes, so it can provide estimates of how long future builds will take. If builds are not expected to take a consistent amount of time (such as incremental builds in which a random set of files are recompiled or tested each time), this should be set to ``False`` to inhibit progress-tracking. :attr:`workdir` (defaults to 'build'): workdir given to every build step created by this factory as default. The workdir can be overridden in a build step definition. If this attribute is set to a string, that string will be used for constructing the workdir (buildslave base + builder builddir + workdir). The attribute can also be a Python callable, for more complex cases, as described in :ref:`Factory-Workdir-Functions`. Predefined Build Factories -------------------------- Buildbot includes a few predefined build factories that perform common build sequences. In practice, these are rarely used, as every site has slightly different requirements, but the source for these factories may provide examples for implementation of those requirements. .. _GNUAutoconf: .. index:: GNUAutoconf Build Factory; GNUAutoconf GNUAutoconf ~~~~~~~~~~~ .. py:class:: buildbot.process.factory.GNUAutoconf `GNU Autoconf `_ is a software portability tool, intended to make it possible to write programs in C (and other languages) which will run on a variety of UNIX-like systems. Most GNU software is built using autoconf. It is frequently used in combination with GNU automake. These tools both encourage a build process which usually looks like this: .. code-block:: bash % CONFIG_ENV=foo ./configure --with-flags % make all % make check # make install (except of course the Buildbot always skips the ``make install`` part). The Buildbot's :class:`buildbot.process.factory.GNUAutoconf` factory is designed to build projects which use GNU autoconf and/or automake. The configuration environment variables, the configure flags, and command lines used for the compile and test are all configurable, in general the default values will be suitable. Example:: f = factory.GNUAutoconf(source=source.SVN(svnurl=URL, mode="copy"), flags=["--disable-nls"]) Required Arguments: ``source`` This argument must be a step specification tuple that provides a BuildStep to generate the source tree. Optional Arguments: ``configure`` The command used to configure the tree. Defaults to :command:`./configure`. Accepts either a string or a list of shell argv elements. ``configureEnv`` The environment used for the initial configuration step. This accepts a dictionary which will be merged into the buildslave's normal environment. This is commonly used to provide things like ``CFLAGS="-O2 -g"`` (to turn off debug symbols during the compile). Defaults to an empty dictionary. ``configureFlags`` A list of flags to be appended to the argument list of the configure command. This is commonly used to enable or disable specific features of the autoconf-controlled package, like ``["--without-x"]`` to disable windowing support. Defaults to an empty list. ``compile`` this is a shell command or list of argv values which is used to actually compile the tree. It defaults to ``make all``. If set to ``None``, the compile step is skipped. ``test`` this is a shell command or list of argv values which is used to run the tree's self-tests. It defaults to @code{make check}. If set to None, the test step is skipped. .. _BasicBuildFactory: .. index:: BasicBuildFactory Build Factory; BasicBuildFactory BasicBuildFactory ~~~~~~~~~~~~~~~~~ .. py:class:: buildbot.process.factory.BasicBuildFactory This is a subclass of :class:`GNUAutoconf` which assumes the source is in CVS, and uses ``mode='clobber'`` to always build from a clean working copy. .. _BasicSVN: .. index:: BasicSVN Build Factory; BasicSVN BasicSVN ~~~~~~~~ .. py:class:: buildbot.process.factory.BasicSVN This class is similar to :class:`BasicBuildFactory`, but uses SVN instead of CVS. .. _QuickBuildFactory: .. index:: QuickBuildFactory Build Factory; QuickBuildFactory QuickBuildFactory ~~~~~~~~~~~~~~~~~ .. py:class:: buildbot.process.factory.QuickBuildFactory The :class:`QuickBuildFactory` class is a subclass of :class:`GNUAutoconf` which assumes the source is in CVS, and uses ``mode='update'`` to get incremental updates. The difference between a `full build` and a `quick build` is that quick builds are generally done incrementally, starting with the tree where the previous build was performed. That simply means that the source-checkout step should be given a ``mode='update'`` flag, to do the source update in-place. In addition to that, this class sets the :attr:`useProgress` flag to ``False``. Incremental builds will (or at least the ought to) compile as few files as necessary, so they will take an unpredictable amount of time to run. Therefore it would be misleading to claim to predict how long the build will take. This class is probably not of use to new projects. .. _Factory-CPAN: .. index:: CPAN Build Factory; CPAN CPAN ~~~~ .. py:class:: buildbot.process.factory.CPAN Most Perl modules available from the `CPAN `_ archive use the ``MakeMaker`` module to provide configuration, build, and test services. The standard build routine for these modules looks like: .. code-block:: bash % perl Makefile.PL % make % make test # make install (except again Buildbot skips the install step) Buildbot provides a :class:`CPAN` factory to compile and test these projects. Arguments: ``source`` (required): A step specification tuple, like that used by :class:`GNUAutoconf`. ``perl`` A string which specifies the :command:`perl` executable to use. Defaults to just :command:`perl`. .. _Distutils: .. index:: Distutils, Build Factory; Distutils Distutils ~~~~~~~~~ .. py:class:: buildbot.process.factory.Distutils Most Python modules use the ``distutils`` package to provide configuration and build services. The standard build process looks like: .. code-block:: bash % python ./setup.py build % python ./setup.py install Unfortunately, although Python provides a standard unit-test framework named ``unittest``, to the best of my knowledge ``distutils`` does not provide a standardized target to run such unit tests. (Please let me know if I'm wrong, and I will update this factory.) The :class:`Distutils` factory provides support for running the build part of this process. It accepts the same ``source=`` parameter as the other build factories. Arguments: ``source`` (required): A step specification tuple, like that used by :class:`GNUAutoconf`. ``python`` A string which specifies the :command:`python` executable to use. Defaults to just :command:`python`. ``test`` Provides a shell command which runs unit tests. This accepts either a string or a list. The default value is ``None``, which disables the test step (since there is no common default command to run unit tests in distutils modules). .. _Trial: .. index:: Trial Build Factory; Trial Trial ~~~~~ .. py:class:: buildbot.process.factory.Trial Twisted provides a unit test tool named :command:`trial` which provides a few improvements over Python's built-in :mod:`unittest` module. Many python projects which use Twisted for their networking or application services also use trial for their unit tests. These modules are usually built and tested with something like the following: .. code-block:: bash % python ./setup.py build % PYTHONPATH=build/lib.linux-i686-2.3 trial -v PROJECTNAME.test % python ./setup.py install Unfortunately, the :file:`build/lib` directory into which the built/copied ``.py`` files are placed is actually architecture-dependent, and I do not yet know of a simple way to calculate its value. For many projects it is sufficient to import their libraries `in place` from the tree's base directory (``PYTHONPATH=.``). In addition, the :samp:`{PROJECTNAME}` value where the test files are located is project-dependent: it is usually just the project's top-level library directory, as common practice suggests the unit test files are put in the :mod:`test` sub-module. This value cannot be guessed, the :class:`Trial` class must be told where to find the test files. The :class:`Trial` class provides support for building and testing projects which use distutils and trial. If the test module name is specified, trial will be invoked. The library path used for testing can also be set. One advantage of trial is that the Buildbot happens to know how to parse trial output, letting it identify which tests passed and which ones failed. The Buildbot can then provide fine-grained reports about how many tests have failed, when individual tests fail when they had been passing previously, etc. Another feature of trial is that you can give it a series of source ``.py`` files, and it will search them for special ``test-case-name`` tags that indicate which test cases provide coverage for that file. Trial can then run just the appropriate tests. This is useful for quick builds, where you want to only run the test cases that cover the changed functionality. Arguments: ``testpath`` Provides a directory to add to :envvar:`PYTHONPATH` when running the unit tests, if tests are being run. Defaults to ``.`` to include the project files in-place. The generated build library is frequently architecture-dependent, but may simply be :file:`build/lib` for pure-python modules. ``python`` which Python executable to use. This list will form the start of the `argv` array that will launch trial. If you use this, you should set ``trial`` to an explicit path (like :file:`/usr/bin/trial` or :file:`./bin/trial`). The parameter defaults to ``None``, which leaves it out entirely (running ``trial args`` instead of ``python ./bin/trial args``). Likely values are ``['python']``, ``['python2.2']``, or ``['python', '-Wall']``. ``trial`` provides the name of the :command:`trial` command. It is occasionally useful to use an alternate executable, such as :command:`trial2.2` which might run the tests under an older version of Python. Defaults to :command:`trial`. ``trialMode`` a list of arguments to pass to trial, specifically to set the reporting mode. This defaults to ``['--reporter=bwverbose']``, which only works for Twisted-2.1.0 and later. ``trialArgs`` a list of arguments to pass to trial, available to turn on any extra flags you like. Defaults to ``[]``. ``tests`` Provides a module name or names which contain the unit tests for this project. Accepts a string, typically :samp:`{PROJECTNAME}.test`, or a list of strings. Defaults to ``None``, indicating that no tests should be run. You must either set this or ``testChanges``. ``testChanges`` if ``True``, ignore the ``tests`` parameter and instead ask the Build for all the files that make up the Changes going into this build. Pass these filenames to trial and ask it to look for test-case-name tags, running just the tests necessary to cover the changes. ``recurse`` If ``True``, tells Trial (with the ``--recurse`` argument) to look in all subdirectories for additional test cases. ``reactor`` which reactor to use, like 'gtk' or 'java'. If not provided, the Twisted's usual platform-dependent default is used. ``randomly`` If ``True``, tells Trial (with the ``--random=0`` argument) to run the test cases in random order, which sometimes catches subtle inter-test dependency bugs. Defaults to ``False``. The step can also take any of the :class:`ShellCommand` arguments, e.g., :attr:`haltOnFailure`. Unless one of ``tests`` or ``testChanges`` are set, the step will generate an exception. buildbot-0.8.8/docs/manual/cfg-buildslaves.rst000066400000000000000000000601301222546025000213420ustar00rootroot00000000000000.. -*- rst -*- .. _Buildslaves: .. bb:cfg:: slaves Buildslaves ----------- The :bb:cfg:`slaves` configuration key specifies a list of known buildslaves. In the common case, each buildslave is defined by an instance of the :class:`BuildSlave` class. It represents a standard, manually started machine that will try to connect to the buildbot master as a slave. Buildbot also supports "on-demand", or latent, buildslaves, which allow buildbot to dynamically start and stop buildslave instances. A :class:`BuildSlave` instance is created with a ``slavename`` and a ``slavepassword``. These are the same two values that need to be provided to the buildslave administrator when they create the buildslave. The slavename must be unique, of course. The password exists to prevent evildoers from interfering with the buildbot by inserting their own (broken) buildslaves into the system and thus displacing the real ones. Buildslaves with an unrecognized slavename or a non-matching password will be rejected when they attempt to connect, and a message describing the problem will be written to the log file (see :ref:`Logfiles`). A configuration for two slaves would look like:: from buildbot.buildslave import BuildSlave c['slaves'] = [ BuildSlave('bot-solaris', 'solarispasswd'), BuildSlave('bot-bsd', 'bsdpasswd'), ] BuildSlave Options ~~~~~~~~~~~~~~~~~~ .. index:: Properties; from buildslave :class:`BuildSlave` objects can also be created with an optional ``properties`` argument, a dictionary specifying properties that will be available to any builds performed on this slave. For example:: c['slaves'] = [ BuildSlave('bot-solaris', 'solarispasswd', properties={ 'os':'solaris' }), ] .. index:: Build Slaves; limiting concurrency The :class:`BuildSlave` constructor can also take an optional ``max_builds`` parameter to limit the number of builds that it will execute simultaneously:: c['slaves'] = [ BuildSlave("bot-linux", "linuxpassword", max_builds=2) ] Master-Slave TCP Keepalive ++++++++++++++++++++++++++ By default, the buildmaster sends a simple, non-blocking message to each slave every hour. These keepalives ensure that traffic is flowing over the underlying TCP connection, allowing the system's network stack to detect any problems before a build is started. The interval can be modified by specifying the interval in seconds using the ``keepalive_interval`` parameter of BuildSlave:: c['slaves'] = [ BuildSlave('bot-linux', 'linuxpasswd', keepalive_interval=3600), ] The interval can be set to ``None`` to disable this functionality altogether. .. _When-Buildslaves-Go-Missing: When Buildslaves Go Missing +++++++++++++++++++++++++++ Sometimes, the buildslaves go away. One very common reason for this is when the buildslave process is started once (manually) and left running, but then later the machine reboots and the process is not automatically restarted. If you'd like to have the administrator of the buildslave (or other people) be notified by email when the buildslave has been missing for too long, just add the ``notify_on_missing=`` argument to the :class:`BuildSlave` definition. This value can be a single email address, or a list of addresses:: c['slaves'] = [ BuildSlave('bot-solaris', 'solarispasswd', notify_on_missing="bob@example.com"), ] By default, this will send email when the buildslave has been disconnected for more than one hour. Only one email per connection-loss event will be sent. To change the timeout, use ``missing_timeout=`` and give it a number of seconds (the default is 3600). You can have the buildmaster send email to multiple recipients: just provide a list of addresses instead of a single one:: c['slaves'] = [ BuildSlave('bot-solaris', 'solarispasswd', notify_on_missing=["bob@example.com", "alice@example.org"], missing_timeout=300, # notify after 5 minutes ), ] The email sent this way will use a :class:`MailNotifier` (see :bb:status:`MailNotifier`) status target, if one is configured. This provides a way for you to control the *from* address of the email, as well as the relayhost (aka *smarthost*) to use as an SMTP server. If no :class:`MailNotifier` is configured on this buildmaster, the buildslave-missing emails will be sent using a default configuration. Note that if you want to have a :class:`MailNotifier` for buildslave-missing emails but not for regular build emails, just create one with ``builders=[]``, as follows:: from buildbot.status import mail m = mail.MailNotifier(fromaddr="buildbot@localhost", builders=[], relayhost="smtp.example.org") c['status'].append(m) from buildbot.buildslave import BuildSlave c['slaves'] = [ BuildSlave('bot-solaris', 'solarispasswd', notify_on_missing="bob@example.com"), ] .. index:: BuildSlaves; latent .. _Latent-Buildslaves: Latent Buildslaves ~~~~~~~~~~~~~~~~~~ The standard buildbot model has slaves started manually. The previous section described how to configure the master for this approach. Another approach is to let the buildbot master start slaves when builds are ready, on-demand. Thanks to services such as Amazon Web Services' Elastic Compute Cloud ("AWS EC2"), this is relatively easy to set up, and can be very useful for some situations. The buildslaves that are started on-demand are called "latent" buildslaves. As of this writing, buildbot ships with an abstract base class for building latent buildslaves, and a concrete implementation for AWS EC2 and for libvirt. Common Options ++++++++++++++ The following options are available for all latent buildslaves. ``build_wait_timeout`` This option allows you to specify how long a latent slave should wait after a build for another build before it shuts down. It defaults to 10 minutes. If this is set to 0 then the slave will be shut down immediately. If it is less than 0 it will never automatically shutdown. .. index:: AWS EC2 BuildSlaves; AWS EC2 Amazon Web Services Elastic Compute Cloud ("AWS EC2") +++++++++++++++++++++++++++++++++++++++++++++++++++++ `EC2 `_ is a web service that allows you to start virtual machines in an Amazon data center. Please see their website for details, including costs. Using the AWS EC2 latent buildslaves involves getting an EC2 account with AWS and setting up payment; customizing one or more EC2 machine images ("AMIs") on your desired operating system(s) and publishing them (privately if needed); and configuring the buildbot master to know how to start your customized images for "substantiating" your latent slaves. Get an AWS EC2 Account ###################### To start off, to use the AWS EC2 latent buildslave, you need to get an AWS developer account and sign up for EC2. Although Amazon often changes this process, these instructions should help you get started: 1. Go to http://aws.amazon.com/ and click to "Sign Up Now" for an AWS account. 2. Once you are logged into your account, you need to sign up for EC2. Instructions for how to do this have changed over time because Amazon changes their website, so the best advice is to hunt for it. After signing up for EC2, it may say it wants you to upload an x.509 cert. You will need this to create images (see below) but it is not technically necessary for the buildbot master configuration. 3. You must enter a valid credit card before you will be able to use EC2. Do that under 'Payment Method'. 4. Make sure you're signed up for EC2 by going to 'Your Account'->'Account Activity' and verifying EC2 is listed. Create an AMI ############# Now you need to create an AMI and configure the master. You may need to run through this cycle a few times to get it working, but these instructions should get you started. Creating an AMI is out of the scope of this document. The `EC2 Getting Started Guide `_ is a good resource for this task. Here are a few additional hints. * When an instance of the image starts, it needs to automatically start a buildbot slave that connects to your master (to create a buildbot slave, :ref:`Creating-a-buildslave`; to make a daemon, :ref:`Launching-the-daemons`). * You may want to make an instance of the buildbot slave, configure it as a standard buildslave in the master (i.e., not as a latent slave), and test and debug it that way before you turn it into an AMI and convert to a latent slave in the master. Configure the Master with an EC2LatentBuildSlave ################################################ Now let's assume you have an AMI that should work with the EC2LatentBuildSlave. It's now time to set up your buildbot master configuration. You will need some information from your AWS account: the `Access Key Id` and the `Secret Access Key`. If you've built the AMI yourself, you probably already are familiar with these values. If you have not, and someone has given you access to an AMI, these hints may help you find the necessary values: * While logged into your AWS account, find the "Access Identifiers" link (either on the left, or via "Your Account" -> "Access Identifiers". * On the page, you'll see alphanumeric values for "Your Access Key Id:" and "Your Secret Access Key:". Make a note of these. Later on, we'll call the first one your ``identifier`` and the second one your ``secret_identifier``\. When creating an EC2LatentBuildSlave in the buildbot master configuration, the first three arguments are required. The name and password are the first two arguments, and work the same as with normal buildslaves. The next argument specifies the type of the EC2 virtual machine (available options as of this writing include ``m1.small``, ``m1.large``, ``m1.xlarge``, ``c1.medium``, and ``c1.xlarge``; see the EC2 documentation for descriptions of these machines). Here is the simplest example of configuring an EC2 latent buildslave. It specifies all necessary remaining values explicitly in the instantiation. :: from buildbot.buildslave.ec2 import EC2LatentBuildSlave c['slaves'] = [EC2LatentBuildSlave('bot1', 'sekrit', 'm1.large', ami='ami-12345', identifier='publickey', secret_identifier='privatekey' )] The ``ami`` argument specifies the AMI that the master should start. The ``identifier`` argument specifies the AWS `Access Key Id`, and the ``secret_identifier`` specifies the AWS `Secret Access Key.` Both the AMI and the account information can be specified in alternate ways. .. note:: Whoever has your ``identifier`` and ``secret_identifier`` values can request AWS work charged to your account, so these values need to be carefully protected. Another way to specify these access keys is to put them in a separate file. You can then make the access privileges stricter for this separate file, and potentially let more people read your main configuration file. By default, you can make an :file:`.ec2` directory in the home folder of the user running the buildbot master. In that directory, create a file called :file:`aws_id`. The first line of that file should be your access key id; the second line should be your secret access key id. Then you can instantiate the build slave as follows. :: from buildbot.buildslave.ec2 import EC2LatentBuildSlave c['slaves'] = [EC2LatentBuildSlave('bot1', 'sekrit', 'm1.large', ami='ami-12345')] If you want to put the key information in another file, use the ``aws_id_file_path`` initialization argument. Previous examples used a particular AMI. If the Buildbot master will be deployed in a process-controlled environment, it may be convenient to specify the AMI more flexibly. Rather than specifying an individual AMI, specify one or two AMI filters. In all cases, the AMI that sorts last by its location (the S3 bucket and manifest name) will be preferred. One available filter is to specify the acceptable AMI owners, by AWS account number (the 12 digit number, usually rendered in AWS with hyphens like "1234-5678-9012", should be entered as in integer). :: from buildbot.buildslave.ec2 import EC2LatentBuildSlave bot1 = EC2LatentBuildSlave('bot1', 'sekrit', 'm1.large', valid_ami_owners=[11111111111, 22222222222], identifier='publickey', secret_identifier='privatekey' ) The other available filter is to provide a regular expression string that will be matched against each AMI's location (the S3 bucket and manifest name). :: from buildbot.buildslave.ec2 import EC2LatentBuildSlave bot1 = EC2LatentBuildSlave( 'bot1', 'sekrit', 'm1.large', valid_ami_location_regex=r'buildbot\-.*/image.manifest.xml', identifier='publickey', secret_identifier='privatekey') The regular expression can specify a group, which will be preferred for the sorting. Only the first group is used; subsequent groups are ignored. :: from buildbot.buildslave.ec2 import EC2LatentBuildSlave bot1 = EC2LatentBuildSlave( 'bot1', 'sekrit', 'm1.large', valid_ami_location_regex=r'buildbot\-.*\-(.*)/image.manifest.xml', identifier='publickey', secret_identifier='privatekey') If the group can be cast to an integer, it will be. This allows 10 to sort after 1, for instance. :: from buildbot.buildslave.ec2 import EC2LatentBuildSlave bot1 = EC2LatentBuildSlave( 'bot1', 'sekrit', 'm1.large', valid_ami_location_regex=r'buildbot\-.*\-(\d+)/image.manifest.xml', identifier='publickey', secret_identifier='privatekey') In addition to using the password as a handshake between the master and the slave, you may want to use a firewall to assert that only machines from a specific IP can connect as slaves. This is possible with AWS EC2 by using the Elastic IP feature. To configure, generate a Elastic IP in AWS, and then specify it in your configuration using the ``elastic_ip`` argument. :: from buildbot.buildslave.ec2 import EC2LatentBuildSlave c['slaves'] = [EC2LatentBuildSlave('bot1', 'sekrit', 'm1.large', 'ami-12345', identifier='publickey', secret_identifier='privatekey', elastic_ip='208.77.188.166' )] The :class:`EC2LatentBuildSlave` supports all other configuration from the standard :class:`BuildSlave`. The ``missing_timeout`` and ``notify_on_missing`` specify how long to wait for an EC2 instance to attach before considering the attempt to have failed, and email addresses to alert, respectively. ``missing_timeout`` defaults to 20 minutes. ``keypair_name`` and ``security_name`` allow you to specify different names for these AWS EC2 values. They both default to ``latent_buildbot_slave``. .. index:: libvirt BuildSlaves; libvirt Libvirt +++++++ `libvirt `_ is a virtualization API for interacting with the virtualization capabilities of recent versions of Linux and other OSes. It is LGPL and comes with a stable C API, and Python bindings. This means we know have an API which when tied to buildbot allows us to have slaves that run under Xen, QEMU, KVM, LXC, OpenVZ, User Mode Linux, VirtualBox and VMWare. The libvirt code in Buildbot was developed against libvirt 0.7.5 on Ubuntu Lucid. It is used with KVM to test Python code on Karmic VM's, but obviously isn't limited to that. Each build is run on a new VM, images are temporary and thrown away after each build. Setting up libvirt ################## We won't show you how to set up libvirt as it is quite different on each platform, but there are a few things you should keep in mind. * If you are running on Ubuntu, your master should run Lucid. Libvirt and apparmor are buggy on Karmic. * If you are using the system libvirt, your buildbot master user will need to be in the libvirtd group. * If you are using KVM, your buildbot master user will need to be in the KVM group. * You need to think carefully about your virtual network *first*. Will NAT be enough? What IP will my VM's need to connect to for connecting to the master? Configuring your base image ########################### You need to create a base image for your builds that has everything needed to build your software. You need to configure the base image with a buildbot slave that is configured to connect to the master on boot. Because this image may need updating a lot, we strongly suggest scripting its creation. If you want to have multiple slaves using the same base image it can be annoying to duplicate the image just to change the buildbot credentials. One option is to use libvirt's DHCP server to allocate an identity to the slave: DHCP sets a hostname, and the slave takes its identity from that. Doing all this is really beyond the scope of the manual, but there is a :file:`vmbuilder` script and a :file:`network.xml` file to create such a DHCP server in :file:`contrib/` (:ref:`Contrib-Scripts`) that should get you started: .. code-block:: bash sudo apt-get install ubuntu-vm-builder sudo contrib/libvirt/vmbuilder Should create an :file:`ubuntu/` folder with a suitable image in it. .. code-block:: none virsh net-define contrib/libvirt/network.xml virsh net-start buildbot-network Should set up a KVM compatible libvirt network for your buildbot VM's to run on. Configuring your Master ####################### If you want to add a simple on demand VM to your setup, you only need the following. We set the username to ``minion1``, the password to ``sekrit``. The base image is called ``base_image`` and a copy of it will be made for the duration of the VM's life. That copy will be thrown away every time a build is complete. :: from buildbot.buildslave.libvirt import LibVirtSlave, Connection c['slaves'] = [LibVirtSlave('minion1', 'sekrit', Connection("qemu:///session"), '/home/buildbot/images/minion1', '/home/buildbot/images/base_image')] You can use virt-manager to define ``minion1`` with the correct hardware. If you don't, buildbot won't be able to find a VM to start. :class:`LibVirtSlave` accepts the following arguments: ``name`` Both a buildbot username and the name of the virtual machine. ``password`` A password for the buildbot to login to the master with. ``connection`` :class:`Connection` instance wrapping connection to libvirt. ``hd_image`` The path to a libvirt disk image, normally in qcow2 format when using KVM. ``base_image`` If given a base image, buildbot will clone it every time it starts a VM. This means you always have a clean environment to do your build in. ``xml`` If a VM isn't predefined in virt-manager, then you can instead provide XML like that used with ``virsh define``. The VM will be created automatically when needed, and destroyed when not needed any longer. OpenStack +++++++++ `OpenStack `_ is a series of interconnected components that facilitates managing compute, storage, and network resources in a data center. It is available under the Apache License and has a REST interface along with a Python client. Get an Account in an OpenStack cloud #################################### Setting up OpenStack is outside the domain of this document. There are four account details necessary for the Buildbot master to interact with your OpenStack cloud: username, password, a tenant name, and the auth URL to use. Create an Image ############### OpenStack supports a large number of image formats. OpenStack maintains a short list of prebuilt images; if the desired image is not listed, The `OpenStack Compute Administration Manual `_ is a good resource for creating new images. You need to configure the image with a buildbot slave to connect to the master on boot. Configure the Master with an OpenStackLatentBuildSlave ###################################################### With the configured image in hand, it is time to configure the buildbot master to create OpenStack instances of it. You will need the aforementioned account details. These are the same details set in either environment variables or passed as options to an OpenStack client. :class:`OpenStackLatentBuildSlave` accepts the following arguments: ``name`` The buildslave name. ``password`` A password for the buildslave to login to the master with. ``flavor`` The flavor ID to use for the instance. ``image`` A string containing the image UUID to use for the instance. A callable may instead be passed. It will be passed the list of available images and must return the image to use. ``os_username`` ``os_password`` ``os_tenant_name`` ``os_auth_url`` The OpenStack authentication needed to create and delete instances. These are the same as the environment variables with uppercase names of the arguments. ``meta`` A dictionary of string key-value pairs to pass to the instance. These will be available under the ``metadata`` key from the metadata service. Here is the simplest example of configuring an OpenStack latent buildslave. :: from buildbot.buildslave.openstack import OpenStackLatentBuildSlave c['slaves'] = [OpenStackLatentBuildSlave('bot2', 'sekrit', flavor=1, image='8ac9d4a4-5e03-48b0-acde-77a0345a9ab1', os_username='user', os_password='password', os_tenant_name='tenant', os_auth_url='http://127.0.0.1:35357/v2.0')] The ``image`` argument also supports being given a callable. The callable will be passed the list of available images and must return the image to use. The invocation happens in a separate thread to prevent blocking the build master when interacting with OpenStack. :: from buildbot.buildslave.openstack import OpenStackLatentBuildSlave def find_image(images): # Sort oldest to newest. cmp_fn = lambda x,y: cmp(x.created, y.created) candidate_images = sorted(images, cmp=cmp_fn) # Return the oldest candiate image. return candidate_images[0] c['slaves'] = [OpenStackLatentBuildSlave('bot2', 'sekrit', flavor=1, image=find_image, os_username='user', os_password='password', os_tenant_name='tenant', os_auth_url='http://127.0.0.1:35357/v2.0')] :class:`OpenStackLatentBuildSlave` supports all other configuration from the standard :class:`BuildSlave`. The ``missing_timeout`` and ``notify_on_missing`` specify how long to wait for an OpenStack instance to attach before considering the attempt to have failed and email addresses to alert, respectively. ``missing_timeout`` defaults to 20 minutes. Dangers with Latent Buildslaves +++++++++++++++++++++++++++++++ Any latent build slave that interacts with a for-fee service, such as the EC2LatentBuildSlave, brings significant risks. As already identified, the configuration will need access to account information that, if obtained by a criminal, can be used to charge services to your account. Also, bugs in the buildbot software may lead to unnecessary charges. In particular, if the master neglects to shut down an instance for some reason, a virtual machine may be running unnecessarily, charging against your account. Manual and/or automatic (e.g. nagios with a plugin using a library like boto) double-checking may be appropriate. A comparatively trivial note is that currently if two instances try to attach to the same latent buildslave, it is likely that the system will become confused. This should not occur, unless, for instance, you configure a normal build slave to connect with the authentication of a latent buildbot. If this situation does occurs, stop all attached instances and restart the master. buildbot-0.8.8/docs/manual/cfg-buildsteps.rst000066400000000000000000003532471222546025000212210ustar00rootroot00000000000000.. _Build-Steps: Build Steps =========== :class:`BuildStep`\s are usually specified in the buildmaster's configuration file, in a list that goes into the :class:`BuildFactory`. The :class:`BuildStep` instances in this list are used as templates to construct new independent copies for each build (so that state can be kept on the :class:`BuildStep` in one build without affecting a later build). Each :class:`BuildFactory` can be created with a list of steps, or the factory can be created empty and then steps added to it using the :meth:`addStep` method:: from buildbot.steps import source, shell from buildbot.process import factory f = factory.BuildFactory() f.addStep(source.SVN(svnurl="http://svn.example.org/Trunk/")) f.addStep(shell.ShellCommand(command=["make", "all"])) f.addStep(shell.ShellCommand(command=["make", "test"])) The basic behavior for a :class:`BuildStep` is to: * run for a while, then stop * possibly invoke some RemoteCommands on the attached build slave * possibly produce a set of log files * finish with a status described by one of four values defined in :mod:`buildbot.status.builder`: ``SUCCESS``, ``WARNINGS``, ``FAILURE``, ``SKIPPED`` * provide a list of short strings to describe the step The rest of this section describes all the standard :class:`BuildStep` objects available for use in a :class:`Build`, and the parameters which can be used to control each. A full list of build steps is available in the :bb:index:`step`. .. index:: Buildstep Parameter .. _Buildstep-Common-Parameters: Common Parameters ----------------- All :class:`BuildStep`\s accept some common parameters. Some of these control how their individual status affects the overall build. Others are used to specify which `Locks` (see :ref:`Interlocks`) should be acquired before allowing the step to run. Arguments common to all :class:`BuildStep` subclasses: ``name`` the name used to describe the step on the status display. It is also used to give a name to any :class:`LogFile`\s created by this step. .. index:: Buildstep Parameter; haltOnFailure ``haltOnFailure`` if ``True``, a ``FAILURE`` of this build step will cause the build to halt immediately. Steps with ``alwaysRun=True`` are still run. Generally speaking, ``haltOnFailure`` implies ``flunkOnFailure`` (the default for most :class:`BuildStep`\s). In some cases, particularly series of tests, it makes sense to ``haltOnFailure`` if something fails early on but not ``flunkOnFailure``. This can be achieved with ``haltOnFailure=True``, ``flunkOnFailure=False``. .. index:: Buildstep Parameter; flunkOnWarnings ``flunkOnWarnings`` when ``True``, a ``WARNINGS`` or ``FAILURE`` of this build step will mark the overall build as ``FAILURE``. The remaining steps will still be executed. .. index:: Buildstep Parameter; flunkOnFailure ``flunkOnFailure`` when ``True``, a ``FAILURE`` of this build step will mark the overall build as a ``FAILURE``. The remaining steps will still be executed. .. index:: Buildstep Parameter; warnOnWarnings ``warnOnWarnings`` when ``True``, a ``WARNINGS`` or ``FAILURE`` of this build step will mark the overall build as having ``WARNINGS``. The remaining steps will still be executed. .. index:: Buildstep Parameter; warnOnFailure ``warnOnFailure`` when ``True``, a ``FAILURE`` of this build step will mark the overall build as having ``WARNINGS``. The remaining steps will still be executed. .. index:: Buildstep Parameter; alwaysRun ``alwaysRun`` if ``True``, this build step will always be run, even if a previous buildstep with ``haltOnFailure=True`` has failed. .. index:: Buildstep Parameter; doStepIf ``doStepIf`` A step can be configured to only run under certain conditions. To do this, set the step's ``doStepIf`` to a boolean value, or to a function that returns a boolean value or Deferred. If the value or function result is false, then the step will return ``SKIPPED`` without doing anything. Otherwise, the step will be executed normally. If you set ``doStepIf`` to a function, that function should accept one parameter, which will be the :class:`Step` object itself. .. index:: Buildstep Parameter; hideStepIf ``hideStepIf`` A step can be optionally hidden from the waterfall and build details web pages. To do this, set the step's ``hideStepIf`` to a boolean value, or to a function that takes two parameters -- the results and the :class:`BuildStep` -- and returns a boolean value. Steps are always shown while they execute, however after the step as finished, this parameter is evaluated (if a function) and if the value is True, the step is hidden. For example, in order to hide the step if the step has been skipped, :: factory.addStep(Foo(..., hideStepIf=lambda results, s: results==SKIPPED)) .. index:: Buildstep Parameter; locks ``locks`` a list of ``Locks`` (instances of :class:`buildbot.locks.SlaveLock` or :class:`buildbot.locks.MasterLock`) that should be acquired before starting this :class:`Step`. The ``Locks`` will be released when the step is complete. Note that this is a list of actual :class:`Lock` instances, not names. Also note that all Locks must have unique names. See :ref:`Interlocks`. .. _Source-Checkout: Source Checkout --------------- .. py:module:: buildbot.steps.source At the moment, Buildbot contains two implementations of most source steps. The new implementation handles most of the logic on the master side, and has a simpler, more unified approach. The older implementation (:ref:`Source-Checkout-Slave-Side`) handles the logic on the slave side, and some of the classes have a bewildering array of options. .. caution:: Master-side source checkout steps are recently developed and not stable yet. If you find any bugs please report them on the `Buildbot Trac `_. The older Slave-side described source steps are :ref:`Source-Checkout-Slave-Side`. The old source steps are imported like this:: from buildbot.steps.source import Git while new source steps are in separate source-packages for each version-control system:: from buildbot.steps.source.git import Git New users should, where possible, use the new implementations. The old implementations will be deprecated in a later release. Old users should take this opportunity to switch to the new implementations while both are supported by Buildbot. Some version control systems have not yet been implemented as master-side steps. If you are interested in continued support for such a version control system, please consider helping the Buildbot developers to create such an implementation. In particular, version-control systems with proprietary licenses will not be supported without access to the version-control system for development. Common Parameters +++++++++++++++++ All source checkout steps accept some common parameters to control how they get the sources and where they should be placed. The remaining per-VC-system parameters are mostly to specify where exactly the sources are coming from. ``mode`` ``method`` These two parameters specify the means by which the source is checked out. ``mode`` specifies the type of checkout and ``method`` tells about the way to implement it. :: factory = BuildFactory() from buildbot.steps.source.mercurial import Mercurial factory.addStep(Mercurial(repourl='path/to/repo', mode='full', method='fresh')) The ``mode`` parameter a string describing the kind of VC operation that is desired, defaulting to ``incremental``. The options are ``incremental`` Update the source to the desired revision, but do not remove any other files generated by previous builds. This allows compilers to take advantage of object files from previous builds. This mode is exactly same as the old ``update`` mode. ``full`` Update the source, but delete remnants of previous builds. Build steps that follow will need to regenerate all object files. Methods are specific to the version-control system in question, as they may take advantage of special behaviors in that version-control system that can make checkouts more efficient or reliable. ``workdir`` like all Steps, this indicates the directory where the build will take place. Source Steps are special in that they perform some operations outside of the workdir (like creating the workdir itself). ``alwaysUseLatest`` if True, bypass the usual behavior of checking out the revision in the source stamp, and always update to the latest revision in the repository instead. ``retry`` If set, this specifies a tuple of ``(delay, repeats)`` which means that when a full VC checkout fails, it should be retried up to ``repeats`` times, waiting ``delay`` seconds between attempts. If you don't provide this, it defaults to ``None``, which means VC operations should not be retried. This is provided to make life easier for buildslaves which are stuck behind poor network connections. ``repository`` The name of this parameter might vary depending on the Source step you are running. The concept explained here is common to all steps and applies to ``repourl`` as well as for ``baseURL`` (when applicable). A common idiom is to pass ``Property('repository', 'url://default/repo/path')`` as repository. This grabs the repository from the source stamp of the build. This can be a security issue, if you allow force builds from the web, or have the :class:`WebStatus` change hooks enabled; as the buildslave will download code from an arbitrary repository. ``codebase`` This specifies which codebase the source step should use to select the right source stamp. The default codebase value is ''. The codebase must correspond to a codebase assigned by the :bb:cfg:`codebaseGenerator`. If there is no codebaseGenerator defined in the master then codebase doesn't need to be set, the default value will then match all changes. ``timeout`` Specifies the timeout for slave-side operations, in seconds. If your repositories are particularly large, then you may need to increase this value from its default of 1200 (20 minutes). ``logEnviron`` If this option is true (the default), then the step's logfile will describe the environment variables on the slave. In situations where the environment is not relevant and is long, it may be easier to set logEnviron=False. ``env`` a dictionary of environment strings which will be added to the child command's environment. The usual property interpolations can be used in environment variable names and values - see :ref:`Properties`. .. bb:step:: Mercurial .. _Step-Mercurial: Mercurial +++++++++ .. py:class:: buildbot.steps.source.mercurial.Mercurial The :bb:step:`Mercurial` build step performs a `Mercurial `_ (aka ``hg``) checkout or update. Branches are available in two modes: ``dirname``, where the name of the branch is a suffix of the name of the repository, or ``inrepo``, which uses Hg's named-branches support. Make sure this setting matches your changehook, if you have that installed. :: from buildbot.steps.source.mercurial import Mercurial factory.addStep(Mercurial(repourl='path/to/repo', mode='full', method='fresh', branchType='inrepo')) The Mercurial step takes the following arguments: ``repourl`` where the Mercurial source repository is available. ``defaultBranch`` this specifies the name of the branch to use when a Build does not provide one of its own. This will be appended to ``repourl`` to create the string that will be passed to the ``hg clone`` command. ``branchType`` either 'dirname' (default) or 'inrepo' depending on whether the branch name should be appended to the ``repourl`` or the branch is a Mercurial named branch and can be found within the ``repourl``. ``clobberOnBranchChange`` boolean, defaults to ``True``. If set and using inrepos branches, clobber the tree at each branch change. Otherwise, just update to the branch. ``mode`` ``method`` Mercurial's incremental mode does not require a method. The full mode has three methods defined: ``clobber`` It removes the build directory entirely then makes full clone from repo. This can be slow as it need to clone whole repository ``fresh`` This remove all other files except those tracked by VCS. First it does :command:`hg purge --all` then pull/update ``clean`` All the files which are tracked by Mercurial and listed ignore files are not deleted. Remaining all other files will be deleted before pull/update. This is equivalent to :command:`hg purge` then pull/update. .. bb:step:: Git .. _Step-Git: Git +++ .. py:class:: buildbot.steps.source.git.Git The ``Git`` build step clones or updates a `Git `_ repository and checks out the specified branch or revision. Note that the buildbot supports Git version 1.2.0 and later: earlier versions (such as the one shipped in Ubuntu 'Dapper') do not support the :command:`git init` command that the buildbot uses. :: from buildbot.steps.source.git import Git factory.addStep(Git(repourl='git://path/to/repo', mode='full', method='clobber', submodules=True)) The Git step takes the following arguments: ``repourl`` (required): the URL of the upstream Git repository. ``branch`` (optional): this specifies the name of the branch to use when a Build does not provide one of its own. If this this parameter is not specified, and the Build does not provide a branch, the default branch of the remote repository will be used. ``submodules`` (optional): when initializing/updating a Git repository, this decides whether or not buildbot should consider Git submodules. Default: ``False``. ``shallow`` (optional): instructs git to attempt shallow clones (``--depth 1``). This option can be used only in full builds with clobber method. ``progress`` (optional): passes the (``--progress``) flag to (:command:`git fetch`). This solves issues of long fetches being killed due to lack of output, but requires Git 1.7.2 or later. ``retryFetch`` (optional): this value defaults to ``False``. In any case if fetch fails buildbot retries to fetch again instead of failing the entire source checkout. ``clobberOnFailure`` (optional): defaults to ``False``. If a fetch or full clone fails we can checkout source removing everything. This way new repository will be cloned. If retry fails it fails the source checkout step. ``mode`` (optional): defaults to ``'incremental'``. Specifies whether to clean the build tree or not. ``incremental`` The source is update, but any built files are left untouched. ``full`` The build tree is clean of any built files. The exact method for doing this is controlled by the ``method`` argument. ``method`` (optional): defaults to ``fresh`` when mode is ``full``. Git's incremental mode does not require a method. The full mode has four methods defined: ``clobber`` It removes the build directory entirely then makes full clone from repo. This can be slow as it need to clone whole repository. To make faster clones enable ``shallow`` option. If shallow options is enabled and build request have unknown revision value, then this step fails. ``fresh`` This remove all other files except those tracked by Git. First it does :command:`git clean -d -f -x` then fetch/checkout to a specified revision(if any). This option is equal to update mode with ``ignore_ignores=True`` in old steps. ``clean`` All the files which are tracked by Git and listed ignore files are not deleted. Remaining all other files will be deleted before fetch/checkout. This is equivalent to :command:`git clean -d -f` then fetch. This is equivalent to ``ignore_ignores=False`` in old steps. ``copy`` This first checkout source into source directory then copy the ``source`` directory to ``build`` directory then performs the build operation in the copied directory. This way we make fresh builds with very less bandwidth to download source. The behavior of source checkout follows exactly same as incremental. It performs all the incremental checkout behavior in ``source`` directory. ``getDescription`` (optional) After checkout, invoke a `git describe` on the revision and save the result in a property; the property's name is either ``commit-description`` or ``commit-description-foo``, depending on whether the ``codebase`` argument was also provided. The argument should either be a ``bool`` or ``dict``, and will change how `git describe` is called: * ``getDescription=False``: disables this feature explicitly * ``getDescription=True`` or empty ``dict()``: Run `git describe` with no args * ``getDescription={...}``: a dict with keys named the same as the Git option. Each key's value can be ``False`` or ``None`` to explicitly skip that argument. For the following keys, a value of ``True`` appends the same-named Git argument: * ``all`` : `--all` * ``always``: `--always` * ``contains``: `--contains` * ``debug``: `--debug` * ``long``: `--long`` * ``exact-match``: `--exact-match` * ``tags``: `--tags` * ``dirty``: `--dirty` For the following keys, an integer or string value (depending on what Git expects) will set the argument's parameter appropriately. Examples show the key-value pair: * ``match=foo``: `--match foo` * ``abbrev=7``: `--abbrev=7` * ``candidates=7``: `--candidates=7` * ``dirty=foo``: `--dirty=foo` ``config`` (optional) A dict of git configuration settings to pass to the remote git commands. .. bb:step:: SVN .. _Step-SVN: SVN +++ .. py:class:: buildbot.steps.source.svn.SVN The :bb:step:`SVN` build step performs a `Subversion `_ checkout or update. There are two basic ways of setting up the checkout step, depending upon whether you are using multiple branches or not. The :bb:step:`SVN` step should be created with the ``repourl`` argument: ``repourl`` (required): this specifies the ``URL`` argument that will be given to the :command:`svn checkout` command. It dictates both where the repository is located and which sub-tree should be extracted. One way to specify the branch is to use ``Interpolate``. For example, if you wanted to check out the trunk repository, you could use ``repourl=Interpolate("http://svn.example.com/repos/%(src::branch)s")`` Alternatively, if you are using a remote Subversion repository which is accessible through HTTP at a URL of ``http://svn.example.com/repos``, and you wanted to check out the ``trunk/calc`` sub-tree, you would directly use ``repourl="http://svn.example.com/repos/trunk/calc"`` as an argument to your :bb:step:`SVN` step. If you are building from multiple branches, then you should create the :bb:step:`SVN` step with the ``repourl`` and provide branch information with ``Interpolate``:: from buildbot.steps.source.svn import SVN factory.addStep(SVN(mode='incremental', repourl=Interpolate('svn://svn.example.org/svn/%(src::branch)s/myproject'))) Alternatively, the ``repourl`` argument can be used to create the :bb:step:`SVN` step without ``Interpolate``:: from buildbot.steps.source.svn import SVN factory.addStep(SVN(mode='full', repourl='svn://svn.example.org/svn/myproject/trunk')) ``username`` (optional): if specified, this will be passed to the ``svn`` binary with a ``--username`` option. ``password`` (optional): if specified, this will be passed to the ``svn`` binary with a ``--password`` option. ``extra_args`` (optional): if specified, an array of strings that will be passed as extra arguments to the ``svn`` binary. ``keep_on_purge`` (optional): specific files or directories to keep between purges, like some build outputs that can be reused between builds. ``depth`` (optional): Specify depth argument to achieve sparse checkout. Only available if slave has Subversion 1.5 or higher. If set to ``empty`` updates will not pull in any files or subdirectories not already present. If set to ``files``, updates will pull in any files not already present, but not directories. If set to ``immediates``, updates will pull in any files or subdirectories not already present, the new subdirectories will have depth: empty. If set to ``infinity``, updates will pull in any files or subdirectories not already present; the new subdirectories will have depth-infinity. Infinity is equivalent to SVN default update behavior, without specifying any depth argument. ``preferLastChangedRev`` (optional): By default, the ``got_revision`` property is set to the repository's global revision ("Revision" in the `svn info` output). Set this parameter to ``True`` to have it set to the "Last Changed Rev" instead. ``mode`` ``method`` SVN's incremental mode does not require a method. The full mode has five methods defined: ``clobber`` It removes the working directory for each build then makes full checkout. ``fresh`` This always always purges local changes before updating. This deletes unversioned files and reverts everything that would appear in a :command:`svn status --no-ignore`. This is equivalent to the old update mode with ``always_purge``. ``clean`` This is same as fresh except that it deletes all unversioned files generated by :command:`svn status`. ``copy`` This first checkout source into source directory then copy the ``source`` directory to ``build`` directory then performs the build operation in the copied directory. This way we make fresh builds with very less bandwidth to download source. The behavior of source checkout follows exactly same as incremental. It performs all the incremental checkout behavior in ``source`` directory. ``export`` Similar to ``method='copy'``, except using ``svn export`` to create build directory so that there are no ``.svn`` directories in the build directory. If you are using branches, you must also make sure your ``ChangeSource`` will report the correct branch names. .. bb:step:: CVS .. _Step-CVS: CVS +++ .. py:class:: buildbot.steps.source.cvs.CVS The :bb:step:`CVS` build step performs a `CVS `_ checkout or update. :: from buildbot.steps.source.cvs import CVS factory.addStep(CVS(mode='incremental', cvsroot=':pserver:me@cvs.sourceforge.net:/cvsroot/myproj', cvsmodule='buildbot')) This step takes the following arguments: ``cvsroot`` (required): specify the CVSROOT value, which points to a CVS repository, probably on a remote machine. For example, if Buildbot was hosted in CVS then the cvsroot value you would use to get a copy of the Buildbot source code might be ``:pserver:anonymous@cvs.sourceforge.net:/cvsroot/buildbot``. ``cvsmodule`` (required): specify the cvs ``module``, which is generally a subdirectory of the CVSROOT. The cvsmodule for the Buildbot source code is ``buildbot``. ``branch`` a string which will be used in a ``-r`` argument. This is most useful for specifying a branch to work on. Defaults to ``HEAD``. ``global_options`` a list of flags to be put before the argument ``checkout`` in the CVS command. ``extra_options`` a list of flags to be put after the ``checkout`` in the CVS command. ``mode`` ``method`` No method is needed for incremental mode. For full mode, ``method`` can take the values shown below. If no value is given, it defaults to ``fresh``. ``clobber`` This specifies to remove the ``workdir`` and make a full checkout. ``fresh`` This method first runs ``cvsdisard`` in the build directory, then updates it. This requires ``cvsdiscard`` which is a part of the cvsutil package. ``clean`` This method is the same as ``method='fresh'``, but it runs ``cvsdiscard --ignore`` instead of ``cvsdiscard``. ``copy`` This maintains a ``source`` directory for source, which it updates copies to the build directory. This allows Buildbot to start with a fresh directory, without downloading the entire repository on every build. .. bb:step:: Bzr .. _Step-Bzr: Bzr +++ .. py:class:: buildbot.steps.source.bzr.Bzr bzr is a descendant of Arch/Baz, and is frequently referred to as simply `Bazaar`. The repository-vs-workspace model is similar to Darcs, but it uses a strictly linear sequence of revisions (one history per branch) like Arch. Branches are put in subdirectories. This makes it look very much like Mercurial. :: from buildbot.steps.source.bzr import Bzr factory.addStep(Bzr(mode='incremental', repourl='lp:~knielsen/maria/tmp-buildbot-test')) The step takes the following arguments: ``repourl`` (required unless ``baseURL`` is provided): the URL at which the Bzr source repository is available. ``baseURL`` (required unless ``repourl`` is provided): the base repository URL, to which a branch name will be appended. It should probably end in a slash. ``defaultBranch`` (allowed if and only if ``baseURL`` is provided): this specifies the name of the branch to use when a Build does not provide one of its own. This will be appended to ``baseURL`` to create the string that will be passed to the ``bzr checkout`` command. ``mode`` ``method`` No method is needed for incremental mode. For full mode, ``method`` can take the values shown below. If no value is given, it defaults to ``fresh``. ``clobber`` This specifies to remove the ``workdir`` and make a full checkout. ``fresh`` This method first runs ``bzr clean-tree`` to remove all the unversioned files then ``update`` the repo. This remove all unversioned files including those in .bzrignore. ``clean`` This is same as fresh except that it doesn't remove the files mentioned in .bzrginore i.e, by running ``bzr clean-tree --ignore``. ``copy`` A local bzr repository is maintained and the repo is copied to ``build`` directory for each build. Before each build the local bzr repo is updated then copied to ``build`` for next steps. .. bb:step:: P4 P4 ++ .. py:class:: buildbot.steps.source.p4.P4 The :bb:step:`P4` build step creates a `Perforce `_ client specification and performs an update. :: from buildbot.steps.source.p4 import P4 factory.addStep(P4(p4port=p4port, p4client=WithProperties('%(P4USER)s-%(slavename)s-%(buildername)s'), p4user=p4user, p4base='//depot', p4viewspec=p4viewspec, mode='incremental', )) You can specify the client spec in two different ways. You can use the ``p4base``, ``p4branch``, and (optionally) ``p4extra_views`` to build up the viewspec, or you can utilize the ``p4viewspec`` to specify the whole viewspec as a set of tuples. Using p4viewspec will allow you to add lines such as:: //depot/branch/mybranch/... ///... -//depot/branch/mybranch/notthisdir/... ///notthisdir/... If you specify ``p4viewspec`` and any of ``p4base``, ``p4branch``, and/or ``p4extra_views`` you will receive a configuration error exception. ``p4base`` A view into the Perforce depot without branch name or trailing "/...". Typically ``//depot/proj``. ``p4branch`` (optional): A single string, which is appended to the p4base as follows ``//...`` to form the first line in the viewspec ``p4extra_views`` (optional): a list of ``(depotpath, clientpath)`` tuples containing extra views to be mapped into the client specification. Both will have ``/...`` appended automatically. The client name and source directory will be prepended to the client path. ``p4viewspec`` This will override any p4branch, p4base, and/or p4extra_views specified. The viewspec will be an array of tuples as follows ``[('//depot/main/','')]`` yields a viewspec with just ``//depot/main/... ///...`` ``p4port`` (optional): the :samp:`{host}:{port}` string describing how to get to the P4 Depot (repository), used as the :option:`-p` argument for all p4 commands. ``p4user`` (optional): the Perforce user, used as the :option:`-u` argument to all p4 commands. ``p4passwd`` (optional): the Perforce password, used as the :option:`-p` argument to all p4 commands. ``p4client`` (optional): The name of the client to use. In ``mode='full'`` and ``mode='incremental'``, it's particularly important that a unique name is used for each checkout directory to avoid incorrect synchronization. For this reason, Python percent substitution will be performed on this value to replace %(slave)s with the slave name and %(builder)s with the builder name. The default is `buildbot_%(slave)s_%(build)s`. ``p4line_end`` (optional): The type of line ending handling P4 should use. This is added directly to the client spec's ``LineEnd`` property. The default is ``local``. .. bb:step:: Repo Repo +++++++++++++++++ .. py:class:: buildbot.steps.source.repo.Repo The :bb:step:`Repo` build step performs a `Repo `_ init and sync. It is a drop-in replacement for `Repo (Slave-Side)`, which should not be used anymore for new and old projects. The Repo step takes the following arguments: ``manifestURL`` (required): the URL at which the Repo's manifests source repository is available. ``manifestBranch`` (optional, defaults to ``master``): the manifest repository branch on which repo will take its manifest. Corresponds to the ``-b`` argument to the :command:`repo init` command. ``manifestFile`` (optional, defaults to ``default.xml``): the manifest filename. Corresponds to the ``-m`` argument to the :command:`repo init` command. ``tarball`` (optional, defaults to ``None``): the repo tarball used for fast bootstrap. If not present the tarball will be created automatically after first sync. It is a copy of the ``.repo`` directory which contains all the Git objects. This feature helps to minimize network usage on very big projects with lots of slaves. ``jobs`` (optional, defaults to ``None``): Number of projects to fetch simultaneously while syncing. Passed to repo sync subcommand with "-j". ``syncAllBranches`` (optional, defaults to ``False``): renderable boolean to control whether ``repo`` syncs all branches. i.e. ``repo sync -c`` ``updateTarballAge`` (optional, defaults to "one week"): renderable to control the policy of updating of the tarball given properties Returns: max age of tarball in seconds, or None, if we want to skip tarball update The default value should be good trade off on size of the tarball, and update frequency compared to cost of tarball creation ``repoDownloads`` (optional, defaults to None): list of ``repo download`` commands to perform at the end of the Repo step each string in the list will be prefixed ``repo download``, and run as is. This means you can include parameter in the string. e.g: - ``["-c project 1234/4"]`` will cherry-pick patchset 4 of patch 1234 in project ``project`` - ``["-f project 1234/4"]`` will enforce fast-forward on patchset 4 of patch 1234 in project ``project`` .. py:class:: buildbot.steps.source.repo.RepoDownloadsFromProperties ``RepoDownloadsFromProperties`` can be used as a renderable of the ``repoDownload`` parameter it will look in passed properties for string with following possible format: - ``repo download project change_number/patchset_number``. - ``project change_number/patchset_number``. - ``project/change_number/patchset_number``. All of these properties will be translated into a :command:`repo download`. This feature allows integrators to build with several pending interdependent changes, which at the moment cannot be described properly in Gerrit, and can only be described by humans. .. py:class:: buildbot.steps.source.repo.RepoDownloadsFromChangeSource ``RepoDownloadsFromChangeSource`` can be used as a renderable of the ``repoDownload`` parameter This rendereable integrates with :bb:chsrc:`GerritChangeSource`, and will automatically use the :command:`repo download` command of repo to download the additionnal changes introduced by a pending changeset. .. note:: you can use the two above Rendereable in conjuction by using the class ``buildbot.process.properties.FlattenList`` for example:: from buildbot.steps.source.repo import Repo, RepoDownloadsFromChangeSource, from buildbot.steps.source.repo import RepoDownloadsFromProperties from buildbot.process.properties import FlattenList factory.addStep(Repo(manifestUrl='git://mygerrit.org/manifest.git', repoDownloads=FlattenList([RepoDownloadsFromChangeSource(), RepoDownloadsFromProperties("repo_downloads") ] ) )) .. index:: double: Gerrit integration; Repo Build Step .. _Source-Checkout-Slave-Side: Source Checkout (Slave-Side) ---------------------------- This section describes the more mature slave-side source steps. Where possible, new users should use the master-side source checkout steps, as the slave-side steps will be removed in a future version. See :ref:`Source-Checkout`. The first step of any build is typically to acquire the source code from which the build will be performed. There are several classes to handle this, one for each of the different source control system that Buildbot knows about. For a description of how Buildbot treats source control in general, see :ref:`Version-Control-Systems`. All source checkout steps accept some common parameters to control how they get the sources and where they should be placed. The remaining per-VC-system parameters are mostly to specify where exactly the sources are coming from. ``mode`` a string describing the kind of VC operation that is desired. Defaults to ``update``. ``update`` specifies that the CVS checkout/update should be performed directly into the workdir. Each build is performed in the same directory, allowing for incremental builds. This minimizes disk space, bandwidth, and CPU time. However, it may encounter problems if the build process does not handle dependencies properly (sometimes you must do a *clean build* to make sure everything gets compiled), or if source files are deleted but generated files can influence test behavior (e.g. Python's .pyc files), or when source directories are deleted but generated files prevent CVS from removing them. Builds ought to be correct regardless of whether they are done *from scratch* or incrementally, but it is useful to test both kinds: this mode exercises the incremental-build style. ``copy`` specifies that the CVS workspace should be maintained in a separate directory (called the :file:`copydir`), using checkout or update as necessary. For each build, a new workdir is created with a copy of the source tree (``rm -rf workdir; cp -r copydir workdir``). This doubles the disk space required, but keeps the bandwidth low (update instead of a full checkout). A full 'clean' build is performed each time. This avoids any generated-file build problems, but is still occasionally vulnerable to CVS problems such as a repository being manually rearranged, causing CVS errors on update which are not an issue with a full checkout. .. TODO: something is screwy about this, revisit. Is it the source directory or the working directory that is deleted each time? ``clobber`` specifies that the working directory should be deleted each time, necessitating a full checkout for each build. This insures a clean build off a complete checkout, avoiding any of the problems described above. This mode exercises the *from-scratch* build style. ``export`` this is like ``clobber``, except that the ``cvs export`` command is used to create the working directory. This command removes all CVS metadata files (the :file:`CVS/` directories) from the tree, which is sometimes useful for creating source tarballs (to avoid including the metadata in the tar file). ``workdir`` As for all steps, this indicates the directory where the build will take place. Source Steps are special in that they perform some operations outside of the workdir (like creating the workdir itself). ``alwaysUseLatest`` if ``True``, bypass the usual `update to the last Change` behavior, and always update to the latest changes instead. ``retry`` If set, this specifies a tuple of ``(delay, repeats)`` which means that when a full VC checkout fails, it should be retried up to `repeats` times, waiting `delay` seconds between attempts. If you don't provide this, it defaults to ``None``, which means VC operations should not be retried. This is provided to make life easier for buildslaves which are stuck behind poor network connections. ``repository`` The name of this parameter might varies depending on the Source step you are running. The concept explained here is common to all steps and applies to ``repourl`` as well as for ``baseURL`` (when applicable). Buildbot, now being aware of the repository name via the change source, might in some cases not need the repository url. There are multiple way to pass it through to this step, those correspond to the type of the parameter given to this step: ``None`` In the case where no parameter is specified, the repository url will be taken exactly from the Change attribute. You are looking for that one if your ChangeSource step has all information about how to reach the Change. string The parameter might be a string, in this case, this string will be taken as the repository url, and nothing more. the value coming from the ChangeSource step will be forgotten. format string If the parameter is a string containing ``%s``, then this the repository attribute from the :class:`Change` will be place in place of the ``%s``. This is useful when the change source knows where the repository resides locally, but don't know the scheme used to access it. For instance ``ssh://server/%s`` makes sense if the the repository attribute is the local path of the repository. dict In this case, the repository URL will be the value indexed by the repository attribute in the dict given as parameter. callable The callable given as parameter will take the repository attribute from the Change and its return value will be used as repository URL. .. note:: this is quite similar to the mechanism used by the WebStatus for the ``changecommentlink``, ``projects`` or ``repositories`` parameter. ``timeout`` Specifies the timeout for slave-side operations, in seconds. If your repositories are particularly large, then you may need to increase this value from its default of 1200 (20 minutes). My habit as a developer is to do a ``cvs update`` and :command:`make` each morning. Problems can occur, either because of bad code being checked in, or by incomplete dependencies causing a partial rebuild to fail where a complete from-scratch build might succeed. A quick Builder which emulates this incremental-build behavior would use the ``mode='update'`` setting. On the other hand, other kinds of dependency problems can cause a clean build to fail where a partial build might succeed. This frequently results from a link step that depends upon an object file that was removed from a later version of the tree: in the partial tree, the object file is still around (even though the Makefiles no longer know how to create it). `official` builds (traceable builds performed from a known set of source revisions) are always done as clean builds, to make sure it is not influenced by any uncontrolled factors (like leftover files from a previous build). A `full` :class:`Builder` which behaves this way would want to use the ``mode='clobber'`` setting. Each VC system has a corresponding source checkout class: their arguments are described on the following pages. .. bb:step:: CVS (Slave-Side) .. _Step-CVS-Slave-Side: CVS (Slave-Side) ++++++++++++++++ The :class:`CVS ` build step performs a `CVS `_ checkout or update. It takes the following arguments: ``cvsroot`` (required): specify the CVSROOT value, which points to a CVS repository, probably on a remote machine. For example, the cvsroot value you would use to get a copy of the Buildbot source code is ``:pserver:anonymous@cvs.sourceforge.net:/cvsroot/buildbot`` ``cvsmodule`` (required): specify the cvs ``module``, which is generally a subdirectory of the CVSROOT. The `cvsmodule` for the Buildbot source code is ``buildbot``. ``branch`` a string which will be used in a :option:`-r` argument. This is most useful for specifying a branch to work on. Defaults to ``HEAD``. ``global_options`` a list of flags to be put before the verb in the CVS command. ``checkout_options`` ``export_options`` ``extra_options`` a list of flags to be put after the verb in the CVS command. ``checkout_options`` is only used for checkout operations, ``export_options`` is only used for export operations, and ``extra_options`` is used for both. ``checkoutDelay`` if set, the number of seconds to put between the timestamp of the last known Change and the value used for the :option:`-D` option. Defaults to half of the parent :class:`Build`\'s ``treeStableTimer``. .. bb:step:: SVN (Slave-Side) .. _Step-SVN-Slave-Side: SVN (Slave-Side) ++++++++++++++++ The :bb:step:`SVN ` build step performs a `Subversion `_ checkout or update. There are two basic ways of setting up the checkout step, depending upon whether you are using multiple branches or not. The most versatile way to create the ``SVN`` step is with the ``svnurl`` argument: ``svnurl`` (required): this specifies the ``URL`` argument that will be given to the ``svn checkout`` command. It dictates both where the repository is located and which sub-tree should be extracted. In this respect, it is like a combination of the CVS ``cvsroot`` and ``cvsmodule`` arguments. For example, if you are using a remote Subversion repository which is accessible through HTTP at a URL of ``http://svn.example.com/repos``, and you wanted to check out the ``trunk/calc`` sub-tree, you would use ``svnurl="http://svn.example.com/repos/trunk/calc"`` as an argument to your ``SVN`` step. The ``svnurl`` argument can be considered as a universal means to create the ``SVN`` step as it ignores the branch information in the SourceStamp. Alternatively, if you are building from multiple branches, then you should preferentially create the ``SVN`` step with the ``baseURL`` and ``defaultBranch`` arguments instead: ``baseURL`` (required): this specifies the base repository URL, to which a branch name will be appended. It should probably end in a slash. ``defaultBranch`` (optional): this specifies the name of the branch to use when a Build does not provide one of its own. This will be appended to ``baseURL`` to create the string that will be passed to the ``svn checkout`` command. It is possible to mix to have a mix of ``SVN`` steps that use either the ``svnurl`` or ``baseURL`` arguments but not both at the same time. ``username`` (optional): if specified, this will be passed to the :command:`svn` binary with a :option:`--username` option. ``password`` (optional): if specified, this will be passed to the ``svn`` binary with a :option:`--password` option. The password itself will be suitably obfuscated in the logs. ``extra_args`` (optional): if specified, an array of strings that will be passed as extra arguments to the :command:`svn` binary. ``keep_on_purge`` (optional): specific files or directories to keep between purges, like some build outputs that can be reused between builds. ``ignore_ignores`` (optional): when purging changes, don't use rules defined in ``svn:ignore`` properties and global-ignores in subversion/config. ``always_purge`` (optional): if set to ``True``, always purge local changes before updating. This deletes unversioned files and reverts everything that would appear in a ``svn status``. ``depth`` (optional): Specify depth argument to achieve sparse checkout. Only available if slave has Subversion 1.5 or higher. If set to "empty" updates will not pull in any files or subdirectories not already present. If set to "files", updates will pull in any files not already present, but not directories. If set to "immediates", updates will pull in any files or subdirectories not already present, the new subdirectories will have depth: empty. If set to "infinity", updates will pull in any files or subdirectories not already present; the new subdirectories will have depth-infinity. Infinity is equivalent to SVN default update behavior, without specifying any depth argument. If you are using branches, you must also make sure your :class:`ChangeSource` will report the correct branch names. .. bb:step:: Darcs (Slave-Side) Darcs (Slave-Side) ++++++++++++++++++ The :bb:step:`Darcs ` build step performs a `Darcs `_ checkout or update. Like :bb:step:`SVN `, this step can either be configured to always check out a specific tree, or set up to pull from a particular branch that gets specified separately for each build. Also like SVN, the repository URL given to Darcs is created by concatenating a ``baseURL`` with the branch name, and if no particular branch is requested, it uses a ``defaultBranch``. The only difference in usage is that each potential Darcs repository URL must point to a fully-fledged repository, whereas SVN URLs usually point to sub-trees of the main Subversion repository. In other words, doing an SVN checkout of ``baseURL`` is legal, but silly, since you'd probably wind up with a copy of every single branch in the whole repository. Doing a Darcs checkout of ``baseURL`` is just plain wrong, since the parent directory of a collection of Darcs repositories is not itself a valid repository. The Darcs step takes the following arguments: ``repourl`` (required unless ``baseURL`` is provided): the URL at which the Darcs source repository is available. ``baseURL`` (required unless ``repourl`` is provided): the base repository URL, to which a branch name will be appended. It should probably end in a slash. ``defaultBranch`` (allowed if and only if ``baseURL`` is provided): this specifies the name of the branch to use when a Build does not provide one of its own. This will be appended to ``baseURL`` to create the string that will be passed to the ``darcs get`` command. .. bb:step:: Mercurial (Slave-Side) Mercurial (Slave-Side) ++++++++++++++++++++++ The :bb:step:`Mercurial ` build step performs a `Mercurial `_ (aka `hg`) checkout or update. Branches are available in two modes: `dirname` like :bb:step:`Darcs `, or `inrepo`, which uses the repository internal branches. Make sure this setting matches your changehook, if you have that installed. The Mercurial step takes the following arguments: ``repourl`` (required unless ``baseURL`` is provided): the URL at which the Mercurial source repository is available. ``baseURL`` (required unless ``repourl`` is provided): the base repository URL, to which a branch name will be appended. It should probably end in a slash. ``defaultBranch`` (allowed if and only if ``baseURL`` is provided): this specifies the name of the branch to use when a :class:`Build` does not provide one of its own. This will be appended to ``baseURL`` to create the string that will be passed to the ``hg clone`` command. ``branchType`` either 'dirname' (default) or 'inrepo' depending on whether the branch name should be appended to the ``baseURL`` or the branch is a Mercurial named branch and can be found within the ``repourl``. ``clobberOnBranchChange`` boolean, defaults to ``True``. If set and using inrepos branches, clobber the tree at each branch change. Otherwise, just update to the branch. .. bb:step:: Bzr (Slave-Side) Bzr (Slave-Side) ++++++++++++++++ bzr is a descendant of Arch/Baz, and is frequently referred to as simply `Bazaar`. The repository-vs-workspace model is similar to Darcs, but it uses a strictly linear sequence of revisions (one history per branch) like Arch. Branches are put in subdirectories. This makes it look very much like Mercurial. It takes the following arguments: ``repourl`` (required unless ``baseURL`` is provided): the URL at which the Bzr source repository is available. ``baseURL`` (required unless ``repourl`` is provided): the base repository URL, to which a branch name will be appended. It should probably end in a slash. ``defaultBranch`` (allowed if and only if ``baseURL`` is provided): this specifies the name of the branch to use when a Build does not provide one of its own. This will be appended to ``baseURL`` to create the string that will be passed to the ``bzr checkout`` command. ``forceSharedRepo`` (boolean, optional, defaults to ``False``): If set to ``True``, the working directory will be made into a bzr shared repository if it is not already. Shared repository greatly reduces the amount of history data that needs to be downloaded if not using update/copy mode, or if using update/copy mode with multiple branches. .. bb:step:: P4 (Slave-Side) P4 (Slave-Side) +++++++++++++++ The :bb:step:`P4 (Slave-Side)` build step creates a `Perforce `_ client specification and performs an update. ``p4base`` A view into the Perforce depot without branch name or trailing "...". Typically ``//depot/proj/``. ``defaultBranch`` A branch name to append on build requests if none is specified. Typically ``trunk``. ``p4port`` (optional): the :samp:`{host}:{port}` string describing how to get to the P4 Depot (repository), used as the :option:`-p` argument for all p4 commands. ``p4user`` (optional): the Perforce user, used as the :option:`-u` argument to all p4 commands. ``p4passwd`` (optional): the Perforce password, used as the :option:`-p` argument to all p4 commands. ``p4extra_views`` (optional): a list of ``(depotpath, clientpath)`` tuples containing extra views to be mapped into the client specification. Both will have "/..." appended automatically. The client name and source directory will be prepended to the client path. ``p4client`` (optional): The name of the client to use. In ``mode='copy'`` and ``mode='update'``, it's particularly important that a unique name is used for each checkout directory to avoid incorrect synchronization. For this reason, Python percent substitution will be performed on this value to replace %(slave)s with the slave name and %(builder)s with the builder name. The default is `buildbot_%(slave)s_%(build)s`. ``p4line_end`` (optional): The type of line ending handling P4 should use. This is added directly to the client spec's ``LineEnd`` property. The default is ``local``. .. bb:step:: Git (Slave-Side) Git (Slave-Side) ++++++++++++++++ The :bb:step:`Git ` build step clones or updates a `Git `_ repository and checks out the specified branch or revision. Note that the buildbot supports Git version 1.2.0 and later: earlier versions (such as the one shipped in Ubuntu 'Dapper') do not support the ``git init`` command that the buildbot uses. The ``Git`` step takes the following arguments: ``repourl`` (required): the URL of the upstream Git repository. ``branch`` (optional): this specifies the name of the branch to use when a Build does not provide one of its own. If this this parameter is not specified, and the :class:`Build` does not provide a branch, the `master` branch will be used. ``ignore_ignores`` (optional): when purging changes, don't use :file:`.gitignore` and :file:`.git/info/exclude`. ``submodules`` (optional): when initializing/updating a Git repository, this decides whether or not buildbot should consider Git submodules. Default: ``False``. ``reference`` (optional): use the specified string as a path to a reference repository on the local machine. Git will try to grab objects from this path first instead of the main repository, if they exist. ``shallow`` (optional): instructs Git to attempt shallow clones (``--depth 1``). If the user/scheduler asks for a specific revision, this parameter is ignored. ``progress`` (optional): passes the (``--progress``) flag to (``git fetch``). This solves issues of long fetches being killed due to lack of output, but requires Git 1.7.2 or later. This Source step integrates with :bb:chsrc:`GerritChangeSource`, and will automatically use Gerrit's "virtual branch" (``refs/changes/*``) to download the additionnal changes introduced by a pending changeset. .. index:: double: Gerrit integration; Git (Slave-Side) Build Step Gerrit integration can be also triggered using forced build with ``gerrit_change`` property with value in format: ``change_number/patchset_number``. .. bb:step:: BK (Slave-Side) BitKeeper (Slave-Side) ++++++++++++++++++++++ The :bb:step:`BK ` build step performs a `BitKeeper `_ checkout or update. The BitKeeper step takes the following arguments: ``repourl`` (required unless ``baseURL`` is provided): the URL at which the BitKeeper source repository is available. ``baseURL`` (required unless ``repourl`` is provided): the base repository URL, to which a branch name will be appended. It should probably end in a slash. .. bb:step:: Repo (Slave-Side) Repo (Slave-Side) +++++++++++++++++ .. py:class:: buildbot.steps.source.Repo The :bb:step:`Repo (Slave-Side)` build step performs a `Repo `_ init and sync. This step is obsolete and should not be used anymore. please use: `Repo` instead The Repo step takes the following arguments: ``manifest_url`` (required): the URL at which the Repo's manifests source repository is available. ``manifest_branch`` (optional, defaults to ``master``): the manifest repository branch on which repo will take its manifest. Corresponds to the ``-b`` argument to the :command:`repo init` command. ``manifest_file`` (optional, defaults to ``default.xml``): the manifest filename. Corresponds to the ``-m`` argument to the :command:`repo init` command. ``tarball`` (optional, defaults to ``None``): the repo tarball used for fast bootstrap. If not present the tarball will be created automatically after first sync. It is a copy of the ``.repo`` directory which contains all the Git objects. This feature helps to minimize network usage on very big projects. ``jobs`` (optional, defaults to ``None``): Number of projects to fetch simultaneously while syncing. Passed to repo sync subcommand with "-j". This Source step integrates with :bb:chsrc:`GerritChangeSource`, and will automatically use the :command:`repo download` command of repo to download the additionnal changes introduced by a pending changeset. .. index:: double: Gerrit integration; Repo (Slave-Side) Build Step Gerrit integration can be also triggered using forced build with following properties: ``repo_d``, ``repo_d[0-9]``, ``repo_download``, ``repo_download[0-9]`` with values in format: ``project/change_number/patchset_number``. All of these properties will be translated into a :command:`repo download`. This feature allows integrators to build with several pending interdependent changes, which at the moment cannot be described properly in Gerrit, and can only be described by humans. .. bb:step:: Monotone (Slave-Side) Monotone (Slave-Side) +++++++++++++++++++++ The :bb:step:`Monotone ` build step performs a `Monotone `_, (aka ``mtn``) checkout or update. The Monotone step takes the following arguments: ``repourl`` the URL at which the Monotone source repository is available. ``branch`` this specifies the name of the branch to use when a Build does not provide one of its own. ``progress`` this is a boolean that has a pull from the repository use ``--ticker=dot`` instead of the default ``--ticker=none``. .. bb:step:: ShellCommand ShellCommand ------------ Most interesting steps involve executing a process of some sort on the buildslave. The :bb:step:`ShellCommand` class handles this activity. Several subclasses of :bb:step:`ShellCommand` are provided as starting points for common build steps. Using ShellCommands +++++++++++++++++++ .. py:class:: buildbot.steps.shell.ShellCommand This is a useful base class for just about everything you might want to do during a build (except for the initial source checkout). It runs a single command in a child shell on the buildslave. All stdout/stderr is recorded into a :class:`LogFile`. The step usually finishes with a status of ``FAILURE`` if the command's exit code is non-zero, otherwise it has a status of ``SUCCESS``. The preferred way to specify the command is with a list of argv strings, since this allows for spaces in filenames and avoids doing any fragile shell-escaping. You can also specify the command with a single string, in which case the string is given to :samp:`/bin/sh -c {COMMAND}` for parsing. On Windows, commands are run via ``cmd.exe /c`` which works well. However, if you're running a batch file, the error level does not get propagated correctly unless you add 'call' before your batch file's name: ``cmd=['call', 'myfile.bat', ...]``. The :bb:step:`ShellCommand` arguments are: ``command`` a list of strings (preferred) or single string (discouraged) which specifies the command to be run. A list of strings is preferred because it can be used directly as an argv array. Using a single string (with embedded spaces) requires the buildslave to pass the string to :command:`/bin/sh` for interpretation, which raises all sorts of difficult questions about how to escape or interpret shell metacharacters. If ``command`` contains nested lists (for example, from a properties substitution), then that list will be flattened before it is executed. On the topic of shell metacharacters, note that in DOS the pipe character (``|``) is conditionally escaped (to ``^|``) when it occurs inside a more complex string in a list of strings. It remains unescaped when it occurs as part of a single string or as a lone pipe in a list of strings. ``workdir`` All ShellCommands are run by default in the ``workdir``, which defaults to the :file:`build` subdirectory of the slave builder's base directory. The absolute path of the workdir will thus be the slave's basedir (set as an option to ``buildslave create-slave``, :ref:`Creating-a-buildslave`) plus the builder's basedir (set in the builder's ``builddir`` key in :file:`master.cfg`) plus the workdir itself (a class-level attribute of the BuildFactory, defaults to :file:`build`). For example:: from buildbot.steps.shell import ShellCommand f.addStep(ShellCommand(command=["make", "test"], workdir="build/tests")) ``env`` a dictionary of environment strings which will be added to the child command's environment. For example, to run tests with a different i18n language setting, you might use :: from buildbot.steps.shell import ShellCommand f.addStep(ShellCommand(command=["make", "test"], env={'LANG': 'fr_FR'})) These variable settings will override any existing ones in the buildslave's environment or the environment specified in the :class:`Builder`. The exception is :envvar:`PYTHONPATH`, which is merged with (actually prepended to) any existing :envvar:`PYTHONPATH` setting. The following example will prepend :file:`/home/buildbot/lib/python` to any existing :envvar:`PYTHONPATH`:: from buildbot.steps.shell import ShellCommand f.addStep(ShellCommand( command=["make", "test"], env={'PYTHONPATH': "/home/buildbot/lib/python"})) To avoid the need of concatenating path together in the master config file, if the value is a list, it will be joined together using the right platform dependant separator. Those variables support expansion so that if you just want to prepend :file:`/home/buildbot/bin` to the :envvar:`PATH` environment variable, you can do it by putting the value ``${PATH}`` at the end of the value like in the example below. Variables that don't exist on the slave will be replaced by ``""``. :: from buildbot.steps.shell import ShellCommand f.addStep(ShellCommand( command=["make", "test"], env={'PATH': ["/home/buildbot/bin", "${PATH}"]})) Note that environment values must be strings (or lists that are turned into strings). In particular, numeric properties such as ``buildnumber`` must be substituted using :ref:`Interpolate`. ``want_stdout`` if ``False``, stdout from the child process is discarded rather than being sent to the buildmaster for inclusion in the step's :class:`LogFile`. ``want_stderr`` like ``want_stdout`` but for :file:`stderr`. Note that commands run through a PTY do not have separate :file:`stdout`/:file:`stderr` streams: both are merged into :file:`stdout`. ``usePTY`` Should this command be run in a ``pty``? The default is to observe the configuration of the client (:ref:`Buildslave-Options`), but specifying ``True`` or ``False`` here will override the default. This option is not available on Windows. In general, you do not want to use a pseudo-terminal. This is is *only* useful for running commands that require a terminal - for example, testing a command-line application that will only accept passwords read from a terminal. Using a pseudo-terminal brings lots of compatibility problems, and prevents Buildbot from distinguishing the standard error (red) and standard output (black) streams. In previous versions, the advantage of using a pseudo-terminal was that ``grandchild`` processes were more likely to be cleaned up if the build was interrupted or times out. This occurred because using a pseudo-terminal incidentally puts the command into its own process group. As of Buildbot-0.8.4, all commands are placed in process groups, and thus grandchild processes will be cleaned up properly. ``logfiles`` Sometimes commands will log interesting data to a local file, rather than emitting everything to stdout or stderr. For example, Twisted's :command:`trial` command (which runs unit tests) only presents summary information to stdout, and puts the rest into a file named :file:`_trial_temp/test.log`. It is often useful to watch these files as the command runs, rather than using :command:`/bin/cat` to dump their contents afterwards. The ``logfiles=`` argument allows you to collect data from these secondary logfiles in near-real-time, as the step is running. It accepts a dictionary which maps from a local Log name (which is how the log data is presented in the build results) to either a remote filename (interpreted relative to the build's working directory), or a dictionary of options. Each named file will be polled on a regular basis (every couple of seconds) as the build runs, and any new text will be sent over to the buildmaster. If you provide a dictionary of options instead of a string, you must specify the ``filename`` key. You can optionally provide a ``follow`` key which is a boolean controlling whether a logfile is followed or concatenated in its entirety. Following is appropriate for logfiles to which the build step will append, where the pre-existing contents are not interesting. The default value for ``follow`` is ``False``, which gives the same behavior as just providing a string filename. :: from buildbot.steps.shell import ShellCommand f.addStep(ShellCommand( command=["make", "test"], logfiles={"triallog": "_trial_temp/test.log"})) The above example will add a log named 'triallog' on the master, based on :file:`_trial_temp/test.log` on the slave. :: from buildbot.steps.shell import ShellCommand f.addStep(ShellCommand( command=["make", "test"], logfiles={"triallog": {"filename": "_trial_temp/test.log", "follow": True,}})) ``lazylogfiles`` If set to ``True``, logfiles will be tracked lazily, meaning that they will only be added when and if something is written to them. This can be used to suppress the display of empty or missing log files. The default is ``False``. ``timeout`` if the command fails to produce any output for this many seconds, it is assumed to be locked up and will be killed. This defaults to 1200 seconds. Pass ``None`` to disable. ``maxTime`` if the command takes longer than this many seconds, it will be killed. This is disabled by default. ``description`` This will be used to describe the command (on the Waterfall display) while the command is still running. It should be a single imperfect-tense verb, like `compiling` or `testing`. The preferred form is a list of short strings, which allows the HTML displays to create narrower columns by emitting a
    tag between each word. You may also provide a single string. ``descriptionDone`` This will be used to describe the command once it has finished. A simple noun like `compile` or `tests` should be used. Like ``description``, this may either be a list of short strings or a single string. If neither ``description`` nor ``descriptionDone`` are set, the actual command arguments will be used to construct the description. This may be a bit too wide to fit comfortably on the Waterfall display. :: from buildbot.steps.shell import ShellCommand f.addStep(ShellCommand(command=["make", "test"], description=["testing"], descriptionDone=["tests"])) ``descriptionSuffix`` This is an optional suffix appended to the end of the description (ie, after ``description`` and ``descriptionDone``). This can be used to distinguish between build steps that would display the same descriptions in the waterfall. This parameter may be set to list of short strings, a single string, or ``None``. For example, a builder might use the ``Compile`` step to build two different codebases. The ``descriptionSuffix`` could be set to `projectFoo` and `projectBar`, respectively for each step, which will result in the full descriptions `compiling projectFoo` and `compiling projectBar` to be shown in the waterfall. ``logEnviron`` If this option is ``True`` (the default), then the step's logfile will describe the environment variables on the slave. In situations where the environment is not relevant and is long, it may be easier to set ``logEnviron=False``. ``interruptSignal`` If the command should be interrupted (either by buildmaster or timeout etc.), what signal should be sent to the process, specified by name. By default this is "KILL" (9). Specify "TERM" (15) to give the process a chance to cleanup. This functionality requires a 0.8.6 slave or newer. ``initialStdin`` If the command expects input on stdin, that can be supplied a a string with this parameter. This value should not be excessively large, as it is handled as a single string throughout Buildbot -- for example, do not pass the contents of a tarball with this parameter. ``decodeRC`` This is a dictionary that decodes exit codes into results value. e.g: ``{0:SUCCESS,1:FAILURE,2:WARNINGS}``, will treat the exit code ``2`` as WARNINGS. The default is to treat just 0 as successful. (``{0:SUCCESS}``) any exit code not present in the dictionary will be treated as ``FAILURE`` .. bb:step:: Configure Configure +++++++++ .. py:class:: buildbot.steps.shell.Configure This is intended to handle the :command:`./configure` step from autoconf-style projects, or the ``perl Makefile.PL`` step from perl :file:`MakeMaker.pm`-style modules. The default command is :command:`./configure` but you can change this by providing a ``command=`` parameter. The arguments are identical to :bb:step:`ShellCommand`. :: from buildbot.steps.shell import Configure f.addStep(Configure()) .. bb:step:: Compile Compile +++++++ .. index:: Properties; warnings-count This is meant to handle compiling or building a project written in C. The default command is ``make all``. When the compile is finished, the log file is scanned for GCC warning messages, a summary log is created with any problems that were seen, and the step is marked as WARNINGS if any were discovered. Through the :class:`WarningCountingShellCommand` superclass, the number of warnings is stored in a Build Property named `warnings-count`, which is accumulated over all :bb:step:`Compile` steps (so if two warnings are found in one step, and three are found in another step, the overall build will have a `warnings-count` property of 5). Each step can be optionally given a maximum number of warnings via the maxWarnCount parameter. If this limit is exceeded, the step will be marked as a failure. The default regular expression used to detect a warning is ``'.*warning[: ].*'`` , which is fairly liberal and may cause false-positives. To use a different regexp, provide a ``warningPattern=`` argument, or use a subclass which sets the ``warningPattern`` attribute:: from buildbot.steps.shell import Compile f.addStep(Compile(command=["make", "test"], warningPattern="^Warning: ")) The ``warningPattern=`` can also be a pre-compiled Python regexp object: this makes it possible to add flags like ``re.I`` (to use case-insensitive matching). Note that the compiled ``warningPattern`` will have its :meth:`match` method called, which is subtly different from a :meth:`search`. Your regular expression must match the from the beginning of the line. This means that to look for the word "warning" in the middle of a line, you will need to prepend ``'.*'`` to your regular expression. The ``suppressionFile=`` argument can be specified as the (relative) path of a file inside the workdir defining warnings to be suppressed from the warning counting and log file. The file will be uploaded to the master from the slave before compiling, and any warning matched by a line in the suppression file will be ignored. This is useful to accept certain warnings (eg. in some special module of the source tree or in cases where the compiler is being particularly stupid), yet still be able to easily detect and fix the introduction of new warnings. The file must contain one line per pattern of warnings to ignore. Empty lines and lines beginning with ``#`` are ignored. Other lines must consist of a regexp matching the file name, followed by a colon (``:``), followed by a regexp matching the text of the warning. Optionally this may be followed by another colon and a line number range. For example: .. code-block:: none # Sample warning suppression file mi_packrec.c : .*result of 32-bit shift implicitly converted to 64 bits.* : 560-600 DictTabInfo.cpp : .*invalid access to non-static.* kernel_types.h : .*only defines private constructors and has no friends.* : 51 If no line number range is specified, the pattern matches the whole file; if only one number is given it matches only on that line. The default warningPattern regexp only matches the warning text, so line numbers and file names are ignored. To enable line number and file name matching, provide a different regexp and provide a function (callable) as the argument of ``warningExtractor=``. The function is called with three arguments: the :class:`BuildStep` object, the line in the log file with the warning, and the ``SRE_Match`` object of the regexp search for ``warningPattern``. It should return a tuple ``(filename, linenumber, warning_test)``. For example:: f.addStep(Compile(command=["make"], warningPattern="^(.\*?):([0-9]+): [Ww]arning: (.\*)$", warningExtractor=Compile.warnExtractFromRegexpGroups, suppressionFile="support-files/compiler_warnings.supp")) (``Compile.warnExtractFromRegexpGroups`` is a pre-defined function that returns the filename, linenumber, and text from groups (1,2,3) of the regexp match). In projects with source files in multiple directories, it is possible to get full path names for file names matched in the suppression file, as long as the build command outputs the names of directories as they are entered into and left again. For this, specify regexps for the arguments ``directoryEnterPattern=`` and ``directoryLeavePattern=``. The ``directoryEnterPattern=`` regexp should return the name of the directory entered into in the first matched group. The defaults, which are suitable for .. GNU Make, are these:: .. directoryEnterPattern = "make.*: Entering directory [\"`'](.*)['`\"]" .. directoryLeavePattern = "make.*: Leaving directory" (TODO: this step needs to be extended to look for GCC error messages as well, and collect them into a separate logfile, along with the source code filenames involved). .. index:: Visual Studio, Visual C++ .. bb:step:: VC6 .. bb:step:: VC7 .. bb:step:: VC8 .. bb:step:: VC9 .. bb:step:: VC10 .. bb:step:: VC11 .. bb:step:: VS2003 .. bb:step:: VS2005 .. bb:step:: VS2008 .. bb:step:: VS2010 .. bb:step:: VS2012 .. bb:step:: VCExpress9 .. bb:step:: MsBuild Visual C++ ++++++++++ These steps are meant to handle compilation using Microsoft compilers. VC++ 6-11 (aka Visual Studio 2003-2012 and VCExpress9) are supported via calling ``devenv``. VS2012 as well as Windows Driver Kit 8 are supported via the new ``MsBuild`` step. These steps will take care of setting up a clean compilation environment, parsing the generated output in real time and delivering as detailed as possible information about the compilation executed. All of the classes are in :mod:`buildbot.steps.vstudio`. The available classes are: * ``VC6`` * ``VC7`` * ``VC8`` * ``VC9`` * ``VC10`` * ``VC11`` * ``VS2003`` * ``VS2005`` * ``VS2008`` * ``VS2010`` * ``VS2012`` * ``VCExpress9`` * ``MsBuild`` The available constructor arguments are ``mode`` The mode default to ``rebuild``, which means that first all the remaining object files will be cleaned by the compiler. The alternate values are ``build``, where only the updated files will be recompiled, and ``clean``, where the current build files are removed and no compilation occurs. ``projectfile`` This is a mandatory argument which specifies the project file to be used during the compilation. ``config`` This argument defaults to ``release`` an gives to the compiler the configuration to use. ``installdir`` This is the place where the compiler is installed. The default value is compiler specific and is the default place where the compiler is installed. ``useenv`` This boolean parameter, defaulting to ``False`` instruct the compiler to use its own settings or the one defined through the environment variables :envvar:`PATH`, :envvar:`INCLUDE`, and :envvar:`LIB`. If any of the ``INCLUDE`` or ``LIB`` parameter is defined, this parameter automatically switches to ``True``. ``PATH`` This is a list of path to be added to the :envvar:`PATH` environment variable. The default value is the one defined in the compiler options. ``INCLUDE`` This is a list of path where the compiler will first look for include files. Then comes the default paths defined in the compiler options. ``LIB`` This is a list of path where the compiler will first look for libraries. Then comes the default path defined in the compiler options. ``arch`` That one is only available with the class VS2005 (VC8). It gives the target architecture of the built artifact. It defaults to ``x86`` and does not apply to ``MsBuild``. Please see ``platform`` below. ``project`` This gives the specific project to build from within a workspace. It defaults to building all projects. This is useful for building cmake generate projects. ``platform`` This is a mandatory argument for MsBuild specifying the target platform such as 'Win32', 'x64' or 'Vista Debug'. The last one is an example of driver targets that appear once Windows Driver Kit 8 is installed. Here is an example on how to drive compilation with Visual Studio 2010:: from buildbot.steps.VisualStudio import VS2010 f.addStep( VS2010(projectfile="project.sln", config="release", arch="x64", mode="build", INCLUDE=[r'C:\3rd-pary\libmagic\include'], LIB=[r'C:\3rd-party\libmagic\lib-x64'])) Here is a similar example using "msbuild":: from buildbot.steps.VisualStudio import MsBuild # Build one project in Release mode for Win32 f.addStep( MsBuild(projectfile="trunk.sln", config="Release", platform="Win32", workdir="trunk", project="tools\\protoc")) # Build the entire solution in Debug mode for x64 f.addStep( MsBuild(projectfile="trunk.sln", config='Debug', platform='x64', workdir="trunk")) .. bb:step:: Test Test ++++ :: from buildbot.steps.shell import Test f.addStep(Test()) This is meant to handle unit tests. The default command is :command:`make test`, and the ``warnOnFailure`` flag is set. The other arguments are identical to :bb:step:`ShellCommand`. .. bb:step:: TreeSize .. index:: Properties; tree-size-KiB TreeSize ++++++++ :: from buildbot.steps.shell import TreeSize f.addStep(TreeSize()) This is a simple command that uses the :command:`du` tool to measure the size of the code tree. It puts the size (as a count of 1024-byte blocks, aka 'KiB' or 'kibibytes') on the step's status text, and sets a build property named ``tree-size-KiB`` with the same value. All arguments are identical to :bb:step:`ShellCommand`. .. bb:step:: PerlModuleTest PerlModuleTest ++++++++++++++ :: from buildbot.steps.shell import PerlModuleTest f.addStep(PerlModuleTest()) This is a simple command that knows how to run tests of perl modules. It parses the output to determine the number of tests passed and failed and total number executed, saving the results for later query. The command is ``prove --lib lib -r t``, although this can be overridden with the ``command`` argument. All other arguments are identical to those for :bb:step:`ShellCommand`. .. bb:step:: MTR MTR (mysql-test-run) ++++++++++++++++++++ The :bb:step:`MTR` class is a subclass of :bb:step:`Test`. It is used to run test suites using the mysql-test-run program, as used in MySQL, Drizzle, MariaDB, and MySQL storage engine plugins. The shell command to run the test suite is specified in the same way as for the :bb:step:`Test` class. The :bb:step:`MTR` class will parse the output of running the test suite, and use the count of tests executed so far to provide more accurate completion time estimates. Any test failures that occur during the test are summarized on the Waterfall Display. Server error logs are added as additional log files, useful to debug test failures. Optionally, data about the test run and any test failures can be inserted into a database for further analysis and report generation. To use this facility, create an instance of :class:`twisted.enterprise.adbapi.ConnectionPool` with connections to the database. The necessary tables can be created automatically by setting ``autoCreateTables`` to ``True``, or manually using the SQL found in the :file:`mtrlogobserver.py` source file. One problem with specifying a database is that each reload of the configuration will get a new instance of ``ConnectionPool`` (even if the connection parameters are the same). To avoid that Buildbot thinks the builder configuration has changed because of this, use the :class:`process.mtrlogobserver.EqConnectionPool` subclass of :class:`ConnectionPool`, which implements an equiality operation that avoids this problem. Example use:: from buildbot.process.mtrlogobserver import MTR, EqConnectionPool myPool = EqConnectionPool("MySQLdb", "host", "buildbot", "password", "db") myFactory.addStep(MTR(workdir="mysql-test", dbpool=myPool, command=["perl", "mysql-test-run.pl", "--force"])) The :bb:step:`MTR` step's arguments are: ``textLimit`` Maximum number of test failures to show on the waterfall page (to not flood the page in case of a large number of test failures. Defaults to 5. ``testNameLimit`` Maximum length of test names to show unabbreviated in the waterfall page, to avoid excessive column width. Defaults to 16. ``parallel`` Value of :option:`--parallel` option used for :file:`mysql-test-run.pl` (number of processes used to run the test suite in parallel). Defaults to 4. This is used to determine the number of server error log files to download from the slave. Specifying a too high value does not hurt (as nonexisting error logs will be ignored), however if using :option:`--parallel` value greater than the default it needs to be specified, or some server error logs will be missing. ``dbpool`` An instance of :class:`twisted.enterprise.adbapi.ConnectionPool`, or ``None``. Defaults to ``None``. If specified, results are inserted into the database using the :class:`ConnectionPool`. ``autoCreateTables`` Boolean, defaults to ``False``. If ``True`` (and ``dbpool`` is specified), the necessary database tables will be created automatically if they do not exist already. Alternatively, the tables can be created manually from the SQL statements found in the :file:`mtrlogobserver.py` source file. ``test_type`` Short string that will be inserted into the database in the row for the test run. Defaults to the empty string, but can be specified to identify different types of test runs. ``test_info`` Descriptive string that will be inserted into the database in the row for the test run. Defaults to the empty string, but can be specified as a user-readable description of this particular test run. ``mtr_subdir`` The subdirectory in which to look for server error log files. Defaults to :file:`mysql-test`, which is usually correct. :ref:`Interpolate` is supported. .. bb:step:: SubunitShellCommand .. _Step-SubunitShellCommand: SubunitShellCommand +++++++++++++++++++ .. py:class:: buildbot.steps.subunit.SubunitShellCommand This buildstep is similar to :bb:step:`ShellCommand`, except that it runs the log content through a subunit filter to extract test and failure counts. :: from buildbot.steps.subunit import SubunitShellCommand f.addStep(SubunitShellCommand(command="make test")) This runs ``make test`` and filters it through subunit. The 'tests' and 'test failed' progress metrics will now accumulate test data from the test run. If ``failureOnNoTests`` is ``True``, this step will fail if no test is run. By default ``failureOnNoTests`` is False. .. _Slave-Filesystem-Steps: Slave Filesystem Steps ---------------------- Here are some buildsteps for manipulating the slave's filesystem. .. bb:step:: FileExists FileExists ++++++++++ This step will assert that a given file exists, failing if it does not. The filename can be specified with a property. :: from buildbot.steps.slave import FileExists f.addStep(FileExists(file='test_data')) This step requires slave version 0.8.4 or later. .. bb:step:: CopyDirectory CopyDirectory +++++++++++++++ This command copies a directory on the slave. :: from buildbot.steps.slave import CopyDirectory f.addStep(CopyDirectory(src="build/data", dest="tmp/data")) This step requires slave version 0.8.5 or later. The CopyDirectory step takes the following arguments: ``timeout`` if the copy command fails to produce any output for this many seconds, it is assumed to be locked up and will be killed. This defaults to 120 seconds. Pass ``None`` to disable. ``maxTime`` if the command takes longer than this many seconds, it will be killed. This is disabled by default. .. bb:step:: RemoveDirectory RemoveDirectory +++++++++++++++ This command recursively deletes a directory on the slave. :: from buildbot.steps.slave import RemoveDirectory f.addStep(RemoveDirectory(dir="build/build")) This step requires slave version 0.8.4 or later. .. bb:step:: MakeDirectory MakeDirectory +++++++++++++++ This command creates a directory on the slave. :: from buildbot.steps.slave import MakeDirectory f.addStep(MakeDirectory(dir="build/build")) This step requires slave version 0.8.5 or later. .. _Python-BuildSteps: Python BuildSteps ----------------- Here are some :class:`BuildStep`\s that are specifically useful for projects implemented in Python. .. bb:step:: BuildEPYDoc .. _Step-BuildEPYDoc: BuildEPYDoc +++++++++++ .. py:class:: buildbot.steps.python.BuildEPYDoc `epydoc `_ is a tool for generating API documentation for Python modules from their docstrings. It reads all the :file:`.py` files from your source tree, processes the docstrings therein, and creates a large tree of :file:`.html` files (or a single :file:`.pdf` file). The :bb:step:`BuildEPYDoc` step will run :command:`epydoc` to produce this API documentation, and will count the errors and warnings from its output. You must supply the command line to be used. The default is ``make epydocs``, which assumes that your project has a :file:`Makefile` with an `epydocs` target. You might wish to use something like :samp:`epydoc -o apiref source/{PKGNAME}` instead. You might also want to add :option:`--pdf` to generate a PDF file instead of a large tree of HTML files. The API docs are generated in-place in the build tree (under the workdir, in the subdirectory controlled by the :option:`-o` argument). To make them useful, you will probably have to copy them to somewhere they can be read. A command like ``rsync -ad apiref/ dev.example.com:~public_html/current-apiref/`` might be useful. You might instead want to bundle them into a tarball and publish it in the same place where the generated install tarball is placed. :: from buildbot.steps.python import BuildEPYDoc f.addStep(BuildEPYDoc(command=["epydoc", "-o", "apiref", "source/mypkg"])) .. bb:step:: PyFlakes .. _Step-PyFlake: PyFlakes ++++++++ .. py:class:: buildbot.steps.python.PyFlakes `PyFlakes `_ is a tool to perform basic static analysis of Python code to look for simple errors, like missing imports and references of undefined names. It is like a fast and simple form of the C :command:`lint` program. Other tools (like `pychecker `_\) provide more detailed results but take longer to run. The :bb:step:`PyFlakes` step will run pyflakes and count the various kinds of errors and warnings it detects. You must supply the command line to be used. The default is ``make pyflakes``, which assumes you have a top-level :file:`Makefile` with a ``pyflakes`` target. You might want to use something like ``pyflakes .`` or ``pyflakes src``. :: from buildbot.steps.python import PyFlakes f.addStep(PyFlakes(command=["pyflakes", "src"])) .. bb:step:: Sphinx .. _Step-Sphinx: Sphinx ++++++ .. py:class:: buildbot.steps.python.Sphinx `Sphinx `_ is the Python Documentation Generator. It uses `RestructuredText `_ as input format. The :bb:step:`Sphinx` step will run :program:`sphinx-build` or any other program specified in its ``sphinx`` argument and count the various warnings and error it detects. :: from buildbot.steps.python import Sphinx f.addStep(Sphinx(sphinx_builddir="_build")) This step takes the following arguments: ``sphinx_builddir`` (required) Name of the directory where the documentation will be generated. ``sphinx_sourcedir`` (optional, defaulting to ``.``), Name the directory where the :file:`conf.py` file will be found ``sphinx_builder`` (optional) Indicates the builder to use. ``sphinx`` (optional, defaulting to :program:`sphinx-build`) Indicates the executable to run. ``tags`` (optional) List of ``tags`` to pass to :program:`sphinx-build` ``defines`` (optional) Dictionary of defines to overwrite values of the :file:`conf.py` file. ``mode`` (optional) String, one of ``full`` or ``incremental`` (the default). If set to ``full``, indicates to Sphinx to rebuild everything without re-using the previous build results. .. bb:step:: PyLint .. _Step-PyLint: PyLint ++++++ Similarly, the :bb:step:`PyLint` step will run :command:`pylint` and analyze the results. You must supply the command line to be used. There is no default. :: from buildbot.steps.python import PyLint f.addStep(PyLint(command=["pylint", "src"])) .. bb:step:: Trial .. _Step-Trial: Trial +++++ .. py:class:: buildbot.steps.python_twisted.Trial This step runs a unit test suite using :command:`trial`, a unittest-like testing framework that is a component of Twisted Python. Trial is used to implement Twisted's own unit tests, and is the unittest-framework of choice for many projects that use Twisted internally. Projects that use trial typically have all their test cases in a 'test' subdirectory of their top-level library directory. For example, for a package ``petmail``, the tests might be in :file:`petmail/test/test_*.py`. More complicated packages (like Twisted itself) may have multiple test directories, like :file:`twisted/test/test_*.py` for the core functionality and :file:`twisted/mail/test/test_*.py` for the email-specific tests. To run trial tests manually, you run the :command:`trial` executable and tell it where the test cases are located. The most common way of doing this is with a module name. For petmail, this might look like :command:`trial petmail.test`, which would locate all the :file:`test_*.py` files under :file:`petmail/test/`, running every test case it could find in them. Unlike the ``unittest.py`` that comes with Python, it is not necessary to run the :file:`test_foo.py` as a script; you always let trial do the importing and running. The step's ``tests``` parameter controls which tests trial will run: it can be a string or a list of strings. To find the test cases, the Python search path must allow something like ``import petmail.test`` to work. For packages that don't use a separate top-level :file:`lib` directory, ``PYTHONPATH=.`` will work, and will use the test cases (and the code they are testing) in-place. ``PYTHONPATH=build/lib`` or ``PYTHONPATH=build/lib.somearch`` are also useful when you do a ``python setup.py build`` step first. The ``testpath`` attribute of this class controls what :envvar:`PYTHONPATH` is set to before running :command:`trial`. Trial has the ability, through the ``--testmodule`` flag, to run only the set of test cases named by special ``test-case-name`` tags in source files. We can get the list of changed source files from our parent Build and provide them to trial, thus running the minimal set of test cases needed to cover the Changes. This is useful for quick builds, especially in trees with a lot of test cases. The ``testChanges`` parameter controls this feature: if set, it will override ``tests``. The trial executable itself is typically just :command:`trial`, and is typically found in the shell search path. It can be overridden with the ``trial`` parameter. This is useful for Twisted's own unittests, which want to use the copy of bin/trial that comes with the sources. To influence the version of Python being used for the tests, or to add flags to the command, set the ``python`` parameter. This can be a string (like ``python2.2``) or a list (like ``['python2.3', '-Wall']``). Trial creates and switches into a directory named :file:`_trial_temp/` before running the tests, and sends the twisted log (which includes all exceptions) to a file named :file:`test.log`. This file will be pulled up to the master where it can be seen as part of the status output. :: from buildbot.steps.python_twisted import Trial f.addStep(Trial(tests='petmail.test')) Trial has the ability to run tests on several workers in parallel (beginning with Twisted 12.3.0). Set ``jobs`` to the number of workers you want to run. Note that running :command:`trial` in this way will create multiple log files (named :file:`test.N.log`, :file:`err.N.log` and :file:`out.N.log` starting with ``N=0``) rather than a single :file:`test.log`. This step takes the following arguments: ``jobs`` (optional) Number of slave-resident workers to use when running the tests. Defaults to 1 worker. Only works with Twisted>=12.3.0. .. bb:step:: RemovePYCs RemovePYCs ++++++++++ .. py:class:: buildbot.steps.python_twisted.RemovePYCs This is a simple built-in step that will remove ``.pyc`` files from the workdir. This is useful in builds that update their source (and thus do not automatically delete ``.pyc`` files) but where some part of the build process is dynamically searching for Python modules. Notably, trial has a bad habit of finding old test modules. :: from buildbot.steps.python_twisted import RemovePYCs f.addStep(RemovePYCs()) .. index:: File Transfer .. bb:step:: FileUpload .. bb:step:: FileDownload Transferring Files ------------------ .. py:class:: buildbot.steps.transfer.FileUpload .. py:class:: buildbot.steps.transfer.FileDownload Most of the work involved in a build will take place on the buildslave. But occasionally it is useful to do some work on the buildmaster side. The most basic way to involve the buildmaster is simply to move a file from the slave to the master, or vice versa. There are a pair of steps named :bb:step:`FileUpload` and :bb:step:`FileDownload` to provide this functionality. :bb:step:`FileUpload` moves a file *up to* the master, while :bb:step:`FileDownload` moves a file *down from* the master. As an example, let's assume that there is a step which produces an HTML file within the source tree that contains some sort of generated project documentation. We want to move this file to the buildmaster, into a :file:`~/public_html` directory, so it can be visible to developers. This file will wind up in the slave-side working directory under the name :file:`docs/reference.html`. We want to put it into the master-side :file:`~/public_html/ref.html`, and add a link to the HTML status to the uploaded file. :: from buildbot.steps.shell import ShellCommand from buildbot.steps.transfer import FileUpload f.addStep(ShellCommand(command=["make", "docs"])) f.addStep(FileUpload(slavesrc="docs/reference.html", masterdest="/home/bb/public_html/ref.html", url="http://somesite/~buildbot/ref.html")) The ``masterdest=`` argument will be passed to :meth:`os.path.expanduser`, so things like ``~`` will be expanded properly. Non-absolute paths will be interpreted relative to the buildmaster's base directory. Likewise, the ``slavesrc=`` argument will be expanded and interpreted relative to the builder's working directory. .. note:: The copied file will have the same permissions on the master as on the slave, look at the ``mode=`` parameter to set it differently. To move a file from the master to the slave, use the :bb:step:`FileDownload` command. For example, let's assume that some step requires a configuration file that, for whatever reason, could not be recorded in the source code repository or generated on the buildslave side:: from buildbot.steps.shell import ShellCommand from buildbot.steps.transfer import FileDownload f.addStep(FileDownload(mastersrc="~/todays_build_config.txt", slavedest="build_config.txt")) f.addStep(ShellCommand(command=["make", "config"])) Like :bb:step:`FileUpload`, the ``mastersrc=`` argument is interpreted relative to the buildmaster's base directory, and the ``slavedest=`` argument is relative to the builder's working directory. If the buildslave is running in :file:`~buildslave`, and the builder's ``builddir`` is something like :file:`tests-i386`, then the workdir is going to be :file:`~buildslave/tests-i386/build`, and a ``slavedest=`` of :file:`foo/bar.html` will get put in :file:`~buildslave/tests-i386/build/foo/bar.html`. Both of these commands will create any missing intervening directories. Other Parameters ++++++++++++++++ The ``maxsize=`` argument lets you set a maximum size for the file to be transferred. This may help to avoid surprises: transferring a 100MB coredump when you were expecting to move a 10kB status file might take an awfully long time. The ``blocksize=`` argument controls how the file is sent over the network: larger blocksizes are slightly more efficient but also consume more memory on each end, and there is a hard-coded limit of about 640kB. The ``mode=`` argument allows you to control the access permissions of the target file, traditionally expressed as an octal integer. The most common value is probably ``0755``, which sets the `x` executable bit on the file (useful for shell scripts and the like). The default value for ``mode=`` is None, which means the permission bits will default to whatever the umask of the writing process is. The default umask tends to be fairly restrictive, but at least on the buildslave you can make it less restrictive with a --umask command-line option at creation time (:ref:`Buildslave-Options`). The ``keepstamp=`` argument is a boolean that, when ``True``, forces the modified and accessed time of the destination file to match the times of the source file. When ``False`` (the default), the modified and accessed times of the destination file are set to the current time on the buildmaster. The ``url=`` argument allows you to specify an url that will be displayed in the HTML status. The title of the url will be the name of the item transferred (directory for :class:`DirectoryUpload` or file for :class:`FileUpload`). This allows the user to add a link to the uploaded item if that one is uploaded to an accessible place. .. bb:step:: DirectoryUpload Transfering Directories +++++++++++++++++++++++ .. py:class:: buildbot.steps.transfer.DirectoryUpload To transfer complete directories from the buildslave to the master, there is a :class:`BuildStep` named :bb:step:`DirectoryUpload`. It works like :bb:step:`FileUpload`, just for directories. However it does not support the ``maxsize``, ``blocksize`` and ``mode`` arguments. As an example, let's assume an generated project documentation, which consists of many files (like the output of :command:`doxygen` or :command:`epydoc`). We want to move the entire documentation to the buildmaster, into a :file:`~/public_html/docs` directory, and add a link to the uploaded documentation on the HTML status page. On the slave-side the directory can be found under :file:`docs`:: from buildbot.steps.shell import ShellCommand from buildbot.steps.transfer import DirectoryUpload f.addStep(ShellCommand(command=["make", "docs"])) f.addStep(DirectoryUpload(slavesrc="docs", masterdest="~/public_html/docs", url="~buildbot/docs")) The :bb:step:`DirectoryUpload` step will create all necessary directories and transfers empty directories, too. The ``maxsize`` and ``blocksize`` parameters are the same as for :bb:step:`FileUpload`, although note that the size of the transferred data is implementation-dependent, and probably much larger than you expect due to the encoding used (currently tar). The optional ``compress`` argument can be given as ``'gz'`` or ``'bz2'`` to compress the datastream. .. note:: The permissions on the copied files will be the same on the master as originally on the slave, see :option:`buildslave create-slave --umask` to change the default one. .. bb:step:: StringDownload .. bb:step:: JSONStringDownload .. bb:step:: JSONPropertiesDownload Transfering Strings ------------------- .. py:class:: buildbot.steps.transfer.StringDownload .. py:class:: buildbot.steps.transfer.JSONStringDownload .. py:class:: buildbot.steps.transfer.JSONPropertiesDownload Sometimes it is useful to transfer a calculated value from the master to the slave. Instead of having to create a temporary file and then use FileDownload, you can use one of the string download steps. :: from buildbot.steps.transfer import StringDownload f.addStep(StringDownload(Interpolate("%(src::branch)s-%(prop:got_revision)s\n"), slavedest="buildid.txt")) :bb:step:`StringDownload` works just like :bb:step:`FileDownload` except it takes a single argument, ``s``, representing the string to download instead of a ``mastersrc`` argument. :: from buildbot.steps.transfer import JSONStringDownload buildinfo = { branch: Property('branch'), got_revision: Property('got_revision') } f.addStep(JSONStringDownload(buildinfo, slavedest="buildinfo.json")) :bb:step:`JSONStringDownload` is similar, except it takes an ``o`` argument, which must be JSON serializable, and transfers that as a JSON-encoded string to the slave. .. index:: Properties; JSONPropertiesDownload :: from buildbot.steps.transfer import JSONPropertiesDownload f.addStep(JSONPropertiesDownload(slavedest="build-properties.json")) :bb:step:`JSONPropertiesDownload` transfers a json-encoded string that represents a dictionary where properties maps to a dictionary of build property ``name`` to property ``value``; and ``sourcestamp`` represents the build's sourcestamp. .. bb:step:: MasterShellCommand Running Commands on the Master ------------------------------ .. py:class:: buildbot.steps.master.MasterShellCommand Occasionally, it is useful to execute some task on the master, for example to create a directory, deploy a build result, or trigger some other centralized processing. This is possible, in a limited fashion, with the :bb:step:`MasterShellCommand` step. This step operates similarly to a regular :bb:step:`ShellCommand`, but executes on the master, instead of the slave. To be clear, the enclosing :class:`Build` object must still have a slave object, just as for any other step -- only, in this step, the slave does not do anything. In this example, the step renames a tarball based on the day of the week. :: from buildbot.steps.transfer import FileUpload from buildbot.steps.master import MasterShellCommand f.addStep(FileUpload(slavesrc="widgetsoft.tar.gz", masterdest="/var/buildoutputs/widgetsoft-new.tar.gz")) f.addStep(MasterShellCommand(command=""" cd /var/buildoutputs; mv widgetsoft-new.tar.gz widgetsoft-`date +%a`.tar.gz""")) .. note:: By default, this step passes a copy of the buildmaster's environment variables to the subprocess. To pass an explicit environment instead, add an ``env={..}`` argument. Environment variables constructed using the ``env`` argument support expansion so that if you just want to prepend :file:`/home/buildbot/bin` to the :envvar:`PATH` environment variable, you can do it by putting the value ``${PATH}`` at the end of the value like in the example below. Variables that don't exist on the master will be replaced by ``""``. :: from buildbot.steps.master import MasterShellCommand f.addStep(MasterShellCommand( command=["make", "www"], env={'PATH': ["/home/buildbot/bin", "${PATH}"]})) Note that environment values must be strings (or lists that are turned into strings). In particular, numeric properties such as ``buildnumber`` must be substituted using :ref:`Interpolate`. ``interruptSignal`` (optional) Signal to use to end the process, if the step is interrupted. .. bb:step:: LogRenderable LogRenderable +++++++++++++ .. py:class:: buildbot.steps.master.LogRenderable This build step takes content which can be renderable and logs it in a pretty-printed format. It can be useful for debugging properties during a build. .. index:: Properties; from steps .. _Setting-Properties: Setting Properties ------------------ These steps set properties on the master based on information from the slave. .. bb:step:: SetProperty .. _Step-SetProperty: SetProperty +++++++++++ .. py:class:: buildbot.steps.master.SetProperty SetProperty takes two arguments of ``property`` and ``value`` where the ``value`` is to be assigned to the ``property`` key. It is usually called with the ``value`` argument being specifed as a :ref:`Interpolate` object which allows the value to be built from other property values:: from buildbot.steps.master import SetProperty from buildbot.process.properties import Interpolate f.addStep(SetProperty(property="SomeProperty", value=Interpolate("sch=%(prop:scheduler)s, slave=%(prop:slavename)s"))) .. bb:step:: SetPropertyFromCommand SetPropertyFromCommand ++++++++++++++++++++++ .. py:class:: buildbot.steps.shell.SetPropertyFromCommand This buildstep is similar to :bb:step:`ShellCommand`, except that it captures the output of the command into a property. It is usually used like this:: from buildbot.steps import shell f.addStep(shell.SetPropertyFromCommand(command="uname -a", property="uname")) This runs ``uname -a`` and captures its stdout, stripped of leading and trailing whitespace, in the property ``uname``. To avoid stripping, add ``strip=False``. The ``property`` argument can be specified as a :ref:`Interpolate` object, allowing the property name to be built from other property values. The more advanced usage allows you to specify a function to extract properties from the command output. Here you can use regular expressions, string interpolation, or whatever you would like. In this form, :func:`extract_fn` should be passed, and not :class:`Property`. The :func:`extract_fn` function is called with three arguments: the exit status of the command, its standard output as a string, and its standard error as a string. It should return a dictionary containing all new properties. :: def glob2list(rc, stdout, stderr): jpgs = [ l.strip() for l in stdout.split('\n') ] return { 'jpgs' : jpgs } f.addStep(SetPropertyFromCommand(command="ls -1 *.jpg", extract_fn=glob2list)) Note that any ordering relationship of the contents of stdout and stderr is lost. For example, given :: f.addStep(SetPropertyFromCommand( command="echo output1; echo error >&2; echo output2", extract_fn=my_extract)) Then ``my_extract`` will see ``stdout="output1\noutput2\n"`` and ``stderr="error\n"``. .. bb:step:: SetPropertiesFromEnv .. py:class:: buildbot.steps.slave.SetPropertiesFromEnv SetPropertiesFromEnv ++++++++++++++++++++ Buildbot slaves (later than version 0.8.3) provide their environment variables to the master on connect. These can be copied into Buildbot properties with the :bb:step:`SetPropertiesFromEnv` step. Pass a variable or list of variables in the ``variables`` parameter, then simply use the values as properties in a later step. Note that on Windows, environment variables are case-insensitive, but Buildbot property names are case sensitive. The property will have exactly the variable name you specify, even if the underlying environment variable is capitalized differently. If, for example, you use ``variables=['Tmp']``, the result will be a property named ``Tmp``, even though the environment variable is displayed as :envvar:`TMP` in the Windows GUI. :: from buildbot.steps.slave import SetPropertiesFromEnv from buildbot.steps.shell import Compile f.addStep(SetPropertiesFromEnv(variables=["SOME_JAVA_LIB_HOME", "JAVAC"])) f.addStep(Compile(commands=[Interpolate("%(prop:JAVAC)s"), "-cp", Interpolate("%(prop:SOME_JAVA_LIB_HOME)s"))) Note that this step requires that the Buildslave be at least version 0.8.3. For previous versions, no environment variables are available (the slave environment will appear to be empty). .. index:: Properties; triggering schedulers .. bb:step:: Trigger .. _Triggering-Schedulers: Triggering Schedulers --------------------- The counterpart to the Triggerable described in section :bb:Sched:`Triggerable` is the :bb:step:`Trigger` build step:: from buildbot.steps.trigger import Trigger f.addStep(Trigger(schedulerNames=['build-prep'], waitForFinish=True, updateSourceStamp=True, set_properties={ 'quick' : False }) The ``schedulerNames=`` argument lists the :bb:sched:`Triggerable` schedulers that should be triggered when this step is executed. Note that it is possible, but not advisable, to create a cycle where a build continually triggers itself, because the schedulers are specified by name. If ``waitForFinish`` is ``True``, then the step will not finish until all of the builds from the triggered schedulers have finished. Hyperlinks are added to the waterfall and the build detail web pages for each triggered build. If this argument is ``False`` (the default) or not given, then the buildstep succeeds immediately after triggering the schedulers. The SourceStamps to use for the triggered build are controlled by the arguments ``updateSourceStamp``, ``alwaysUseLatest``, and ``sourceStamps``. If ``updateSourceStamp`` is ``True`` (the default), then step updates the :class:`SourceStamp`s given to the :bb:sched:`Triggerable` schedulers to include ``got_revision`` (the revision actually used in this build) as ``revision`` (the revision to use in the triggered builds). This is useful to ensure that all of the builds use exactly the same :class:`SourceStamp`s, even if other :class:`Change`\s have occurred while the build was running. If ``updateSourceStamp`` is False (and neither of the other arguments are specified), then the exact same SourceStamps are used. If ``alwaysUseLatest`` is True, then no SourceStamps are given, corresponding to using the latest revisions of the repositories specified in the Source steps. This is useful if the triggered builds use to a different source repository. The argument ``sourceStamps`` accepts a list of dictionaries containing the keys ``branch``, ``revision``, ``repository``, ``project``, and optionally ``patch_level``, ``patch_body``, ``patch_subdir``, ``patch_author`` and ``patch_comment`` and creates the corresponding SourceStamps. If only one sourceStamp has to be specified then the argument ``sourceStamp`` can be used for a dictionary containing the keys mentioned above. The arguments ``updateSourceStamp``, ``alwaysUseLatest``, and ``sourceStamp`` can be specified using properties. The ``set_properties`` parameter allows control of the properties that are passed to the triggered scheduler. The parameter takes a dictionary mapping property names to values. You may use :ref:`Interpolate` here to dynamically construct new property values. For the simple case of copying a property, this might look like :: set_properties={"my_prop1" : Property("my_prop1")} The ``copy_properties`` parameter, given a list of properties to copy into the new build request, has been deprecated in favor of explicit use of ``set_properties``. RPM-Related Steps ----------------- These steps work with RPMs and spec files. .. bb:step:: RpmBuild RpmBuild ++++++++ The :bb:step:`RpmBuild` step builds RPMs based on a spec file:: from buildbot.steps.package.rpm import RpmBuild f.addStep(RpmBuild(specfile="proj.spec", dist='.el5')) The step takes the following parameters ``specfile`` The ``.spec`` file to build from ``topdir`` Definition for ``_topdir``, defaulting to the workdir. ``builddir`` Definition for ``_builddir``, defaulting to the workdir. ``rpmdir`` Definition for ``_rpmdir``, defaulting to the workdir. ``sourcedir`` Definition for ``_sourcedir``, defaulting to the workdir. ``srcrpmdir`` Definition for ``_srcrpmdir``, defaulting to the workdir. ``dist`` Distribution to build, used as the definition for ``_dist``. ``autoRelease`` If true, use the auto-release mechanics. ``vcsRevision`` If true, use the version-control revision mechanics. This uses the ``got_revision`` property to determine the revision and define ``_revision``. Note that this will not work with multi-codebase builds. .. bb:step:: RpmLint RpmLint +++++++ The :bb:step:`RpmLint` step checks for common problems in RPM packages or spec files:: from buildbot.steps.package.rpm import RpmLint f.addStep(RpmLint()) The step takes the following parameters ``fileloc`` The file or directory to check. In case of a directory, it is recursively searched for RPMs and spec files to check. ``config`` Path to a rpmlint config file. This is passed as the user configuration file if present. Mock Steps ++++++++++ Mock (http://fedoraproject.org/wiki/Projects/Mock) creates chroots and builds packages in them. It populates the changeroot with a basic system and the packages listed as build requirement. The type of chroot to build is specified with the ``root`` parameter. To use mock your buildbot user must be added to the ``mock`` group. .. bb:step:: MockBuildSRPM MockBuildSRPM Step ++++++++++++++++++ The :bb:step:`MockBuildSRPM` step builds a SourceRPM based on a spec file and optionally a source directory:: from buildbot.steps.package.rpm import MockBuildSRPM f.addStep(MockBuildSRPM(root='default', spec='mypkg.spec')) The step takes the following parameters ``root`` Use chroot configuration defined in ``/etc/mock/.cfg``. ``resultdir`` The directory where the logfiles and the SourceRPM are written to. ``spec`` Build the SourceRPM from this spec file. ``sources`` Path to the directory containing the sources, defaulting to ``.``. .. bb:step:: MockRebuild MockRebuild Step ++++++++++++++++ The :bb:step:`MockRebuild` step rebuilds a SourceRPM package:: from buildbot.steps.package.rpm import MockRebuild f.addStep(MockRebuild(root='default', spec='mypkg-1.0-1.src.rpm')) The step takes the following parameters ``root`` Uses chroot configuration defined in ``/etc/mock/.cfg``. ``resultdir`` The directory where the logfiles and the SourceRPM are written to. ``srpm`` The path to the SourceRPM to rebuild. Debian Build Steps ------------------ .. bb:step:: DebPbuilder DebPbuilder +++++++++++ The :bb:step:`DebPbuilder` step builds Debian packages within a chroot built by pbuilder. It populates the changeroot with a basic system and the packages listed as build requirement. The type of chroot to build is specified with the ``distribution``, ``distribution`` and ``mirror`` parameter. To use pbuilder your buildbot must have the right to run pbuilder as root through sudo. :: from buildbot.steps.package.deb.pbuilder import DebPbuilder f.addStep(DebPbuilder()) The step takes the following parameters ``architecture`` Architecture to build chroot for. ``distribution`` Name, or nickname, of the distribution. Defaults to 'stable'. ``basetgz`` Path of the basetgz to use for building. ``mirror`` URL of the mirror used to download the packages from. ``extrapackages`` List if packages to install in addition to the base system. ``keyring`` Path to a gpg keyring to verify the downloaded packages. This is necessary if you build for a foreign distribution. ``components`` Repos to activate for chroot building. .. bb:step:: DebCowbuilder DebCowbuilder +++++++++++++ The :bb:step:`DebCowbuilder` step is a subclass of :bb:step:`DebPbuilder`, which use cowbuilder instead of pbuilder. .. bb:step:: DebLintian DebLintian ++++++++++ The :bb:step:`DebLintian` step checks a build .deb for bugs and policy violations. The packages or changes file to test is specified in ``fileloc`` :: from buildbot.steps.package.deb.lintian import DebLintian f.addStep(DebLintian(fileloc=Interpolate("%(prop:deb-changes)s"))) Miscellaneous BuildSteps ------------------------ A number of steps do not fall into any particular category. .. bb:step:: HLint HLint +++++ The :bb:step:`HLint` step runs Twisted Lore, a lint-like checker over a set of ``.xhtml`` files. Any deviations from recommended style is flagged and put in the output log. The step looks at the list of changes in the build to determine which files to check - it does not check all files. It specifically excludes any ``.xhtml`` files in the top-level ``sandbox/`` directory. The step takes a single, optional, parameter: ``python``. This specifies the Python executable to use to run Lore. :: from buildbot.steps.python_twisted import HLint f.addStep(HLint()) MaxQ ++++ .. bb:step:: MaxQ MaxQ (http://maxq.tigris.org/) is a web testing tool that allows you to record HTTP sessions and play them back. The :bb:step:`MaxQ` step runs this framework. :: from buildbot.steps.maxq import MaxQ f.addStep(MaxQ(testdir='tests/')) The single argument, ``testdir``, specifies where the tests should be run. This directory will be passed to the ``run_maxq.py`` command, and the results analyzed. buildbot-0.8.8/docs/manual/cfg-changesources.rst000066400000000000000000001426311222546025000216650ustar00rootroot00000000000000.. _Change-Sources: Change Sources -------------- A Version Control System maintains a source tree, and tells the buildmaster when it changes. The first step of each :class:`Build` is typically to acquire a copy of some version of this tree. This chapter describes how the Buildbot learns about what :class:`Change`\s have occurred. For more information on VC systems and :class:`Change`\s, see :ref:`Version-Control-Systems`. :class:`Change`\s can be provided by a variety of :class:`ChangeSource` types, although any given project will typically have only a single :class:`ChangeSource` active. This section provides a description of all available :class:`ChangeSource` types and explains how to set up each of them. .. _Choosing-a-Change-Source: Choosing a Change Source ~~~~~~~~~~~~~~~~~~~~~~~~ There are a variety of :class:`ChangeSource` classes available, some of which are meant to be used in conjunction with other tools to deliver :class:`Change` events from the VC repository to the buildmaster. As a quick guide, here is a list of VC systems and the :class:`ChangeSource`\s that might be useful with them. Note that some of these modules are in Buildbot's "contrib" directory, meaning that they have been offered by other users in hopes they may be useful, and might require some additional work to make them functional. CVS * :bb:chsrc:`CVSMaildirSource` (watching mail sent by ``contrib/buildbot_cvs_mail.py`` script) * :bb:chsrc:`PBChangeSource` (listening for connections from ``buildbot sendchange`` run in a loginfo script) * :bb:chsrc:`PBChangeSource` (listening for connections from a long-running :file:`contrib/viewcvspoll.py` polling process which examines the ViewCVS database directly) * :bb:chsrc:`Change Hooks` in WebStatus SVN * :bb:chsrc:`PBChangeSource` (listening for connections from :file:`contrib/svn_buildbot.py` run in a postcommit script) * :bb:chsrc:`PBChangeSource` (listening for connections from a long-running :file:`contrib/svn_watcher.py` or :file:`contrib/svnpoller.py` polling process * :bb:chsrc:`SVNCommitEmailMaildirSource` (watching for email sent by :file:`commit-email.pl`) * :bb:chsrc:`SVNPoller` (polling the SVN repository) * :bb:chsrc:`Change Hooks` in WebStatus * :bb:chsrc:`GoogleCodeAtomPoller` (polling the commit feed for a GoogleCode Git repository) Darcs * :bb:chsrc:`PBChangeSource` (listening for connections from :file:`contrib/darcs_buildbot.py` in a commit script) * :bb:chsrc:`Change Hooks` in WebStatus Mercurial * :bb:chsrc:`PBChangeSource` (listening for connections from :file:`contrib/hg_buildbot.py` run in an 'changegroup' hook) * :bb:chsrc:`Change Hooks` in WebStatus * :bb:chsrc:`PBChangeSource` (listening for connections from :file:`buildbot/changes/hgbuildbot.py` run as an in-process 'changegroup' hook) * :bb:chsrc:`HgPoller` (polling a remote Mercurial repository) * :bb:chsrc:`GoogleCodeAtomPoller` (polling the commit feed for a GoogleCode Git repository) Bzr (the newer Bazaar) * :bb:chsrc:`PBChangeSource` (listening for connections from :file:`contrib/bzr_buildbot.py` run in a post-change-branch-tip or commit hook) * :bb:chsrc:`BzrPoller` (polling the Bzr repository) * :bb:chsrc:`Change Hooks` in WebStatus Git * :bb:chsrc:`PBChangeSource` (listening for connections from :file:`contrib/git_buildbot.py` run in the post-receive hook) * :bb:chsrc:`PBChangeSource` (listening for connections from :file:`contrib/github_buildbot.py`, which listens for notifications from GitHub) * :bb:chsrc:`Change Hooks` in WebStatus * GitHub change hook (specifically designed for GitHub notifications, but requiring a publicly-accessible WebStatus) * :bb:chsrc:`GitPoller` (polling a remote Git repository) * :bb:chsrc:`GoogleCodeAtomPoller` (polling the commit feed for a GoogleCode Git repository) Repo/Git * :bb:chsrc:`GerritChangeSource` connects to Gerrit via SSH to get a live stream of changes Monotone * :bb:chsrc:`PBChangeSource` (listening for connections from :file:`monotone-buildbot.lua`, which is available with Monotone) All VC systems can be driven by a :bb:chsrc:`PBChangeSource` and the ``buildbot sendchange`` tool run from some form of commit script. If you write an email parsing function, they can also all be driven by a suitable :ref:`mail-parsing source `. Additionally, handlers for web-based notification (i.e. from GitHub) can be used with WebStatus' change_hook module. The interface is simple, so adding your own handlers (and sharing!) should be a breeze. See :bb:index:`chsrc` for a full list of change sources. .. index:: Change Sources .. bb:cfg:: change_source Configuring Change Sources ~~~~~~~~~~~~~~~~~~~~~~~~~~ The :bb:cfg:`change_source` configuration key holds all active change sources for the configuration. Most configurations have a single :class:`ChangeSource`, watching only a single tree, e.g., :: c['change_source'] = PBChangeSource() For more advanced configurations, the parameter can be a list of change sources:: source1 = ... source2 = ... c['change_source'] = [ source1, source1 ] Repository and Project ++++++++++++++++++++++ :class:`ChangeSource`\s will, in general, automatically provide the proper :attr:`repository` attribute for any changes they produce. For systems which operate on URL-like specifiers, this is a repository URL. Other :class:`ChangeSource`\s adapt the concept as necessary. Many :class:`ChangeSource`\s allow you to specify a project, as well. This attribute is useful when building from several distinct codebases in the same buildmaster: the project string can serve to differentiate the different codebases. :class:`Scheduler`\s can filter on project, so you can configure different builders to run for each project. .. _Mail-parsing-ChangeSources: Mail-parsing ChangeSources ~~~~~~~~~~~~~~~~~~~~~~~~~~ Many projects publish information about changes to their source tree by sending an email message out to a mailing list, frequently named :samp:`{PROJECT}-commits` or :samp:`{PROJECT}-changes`. Each message usually contains a description of the change (who made the change, which files were affected) and sometimes a copy of the diff. Humans can subscribe to this list to stay informed about what's happening to the source tree. The Buildbot can also be subscribed to a `-commits` mailing list, and can trigger builds in response to Changes that it hears about. The buildmaster admin needs to arrange for these email messages to arrive in a place where the buildmaster can find them, and configure the buildmaster to parse the messages correctly. Once that is in place, the email parser will create Change objects and deliver them to the Schedulers (see :ref:`Schedulers`) just like any other ChangeSource. There are two components to setting up an email-based ChangeSource. The first is to route the email messages to the buildmaster, which is done by dropping them into a `maildir`. The second is to actually parse the messages, which is highly dependent upon the tool that was used to create them. Each VC system has a collection of favorite change-emailing tools, and each has a slightly different format, so each has a different parsing function. There is a separate ChangeSource variant for each parsing function. Once you've chosen a maildir location and a parsing function, create the change source and put it in ``change_source`` :: from buildbot.changes.mail import CVSMaildirSource c['change_source'] = CVSMaildirSource("~/maildir-buildbot", prefix="/trunk/") .. _Subscribing-the-Buildmaster: Subscribing the Buildmaster +++++++++++++++++++++++++++ The recommended way to install the buildbot is to create a dedicated account for the buildmaster. If you do this, the account will probably have a distinct email address (perhaps `buildmaster@example.org`). Then just arrange for this account's email to be delivered to a suitable maildir (described in the next section). If the buildbot does not have its own account, `extension addresses` can be used to distinguish between email intended for the buildmaster and email intended for the rest of the account. In most modern MTAs, the e.g. `foo@example.org` account has control over every email address at example.org which begins with "foo", such that email addressed to `account-foo@example.org` can be delivered to a different destination than `account-bar@example.org`. qmail does this by using separate :file:`.qmail` files for the two destinations (:file:`.qmail-foo` and :file:`.qmail-bar`, with :file:`.qmail` controlling the base address and :file:`.qmail-default` controlling all other extensions). Other MTAs have similar mechanisms. Thus you can assign an extension address like `foo-buildmaster@example.org` to the buildmaster, and retain `foo@example.org` for your own use. .. _Using-Maildirs: Using Maildirs ++++++++++++++ A `maildir` is a simple directory structure originally developed for qmail that allows safe atomic update without locking. Create a base directory with three subdirectories: :file:`new`, :file:`tmp`, and :file:`cur`. When messages arrive, they are put into a uniquely-named file (using pids, timestamps, and random numbers) in :file:`tmp`. When the file is complete, it is atomically renamed into :file:`new`. Eventually the buildmaster notices the file in :file:`new`, reads and parses the contents, then moves it into :file:`cur`. A cronjob can be used to delete files in :file:`cur` at leisure. Maildirs are frequently created with the :command:`maildirmake` tool, but a simple :command:`mkdir -p ~/MAILDIR/\{cur,new,tmp\}` is pretty much equivalent. Many modern MTAs can deliver directly to maildirs. The usual :file:`.forward` or :file:`.procmailrc` syntax is to name the base directory with a trailing slash, so something like ``~/MAILDIR/``\. qmail and postfix are maildir-capable MTAs, and procmail is a maildir-capable MDA (Mail Delivery Agent). Here is an example procmail config, located in :file:`~/.procmailrc`:: # .procmailrc # routes incoming mail to appropriate mailboxes PATH=/usr/bin:/usr/local/bin MAILDIR=$HOME/Mail LOGFILE=.procmail_log SHELL=/bin/sh :0 * new If procmail is not setup on a system wide basis, then the following one-line :file:`.forward` file will invoke it. :: !/usr/bin/procmail For MTAs which cannot put files into maildirs directly, the `safecat` tool can be executed from a :file:`.forward` file to accomplish the same thing. The Buildmaster uses the linux DNotify facility to receive immediate notification when the maildir's :file:`new` directory has changed. When this facility is not available, it polls the directory for new messages, every 10 seconds by default. .. _Parsing-Email-Change-Messages: Parsing Email Change Messages +++++++++++++++++++++++++++++ The second component to setting up an email-based :class:`ChangeSource` is to parse the actual notices. This is highly dependent upon the VC system and commit script in use. A couple of common tools used to create these change emails, along with the buildbot tools to parse them, are: CVS Buildbot CVS MailNotifier :bb:chsrc:`CVSMaildirSource` SVN svnmailer http://opensource.perlig.de/en/svnmailer/ :file:`commit-email.pl` :bb:chsrc:`SVNCommitEmailMaildirSource` Bzr Launchpad :bb:chsrc:`BzrLaunchpadEmailMaildirSource` Mercurial NotifyExtension http://www.selenic.com/mercurial/wiki/index.cgi/NotifyExtension Git post-receive-email http://git.kernel.org/?p=git/git.git;a=blob;f=contrib/hooks/post-receive-email;hb=HEAD The following sections describe the parsers available for each of these tools. Most of these parsers accept a ``prefix=`` argument, which is used to limit the set of files that the buildmaster pays attention to. This is most useful for systems like CVS and SVN which put multiple projects in a single repository (or use repository names to indicate branches). Each filename that appears in the email is tested against the prefix: if the filename does not start with the prefix, the file is ignored. If the filename *does* start with the prefix, that prefix is stripped from the filename before any further processing is done. Thus the prefix usually ends with a slash. .. bb:chsrc:: CVSMaildirSource .. _CVSMaildirSource: CVSMaildirSource ++++++++++++++++ .. py:class:: buildbot.changes.mail.CVSMaildirSource This parser works with the :file:`buildbot_cvs_maildir.py` script in the contrib directory. The script sends an email containing all the files submitted in one directory. It is invoked by using the :file:`CVSROOT/loginfo` facility. The Buildbot's :bb:chsrc:`CVSMaildirSource` knows how to parse these messages and turn them into Change objects. It takes the directory name of the maildir root. For example:: from buildbot.changes.mail import CVSMaildirSource c['change_source'] = CVSMaildirSource("/home/buildbot/Mail") Configuration of CVS and buildbot_cvs_mail.py ############################################# CVS must be configured to invoke the buildbot_cvs_mail.py script when files are checked in. This is done via the CVS loginfo configuration file. To update this, first do:: cvs checkout CVSROOT cd to the CVSROOT directory and edit the file loginfo, adding a line like:: SomeModule /cvsroot/CVSROOT/buildbot_cvs_mail.py --cvsroot :ext:example.com:/cvsroot -e buildbot -P SomeModule %@{sVv@} .. note:: For cvs version 1.12.x, the ``--path %p`` option is required. Version 1.11.x and 1.12.x report the directory path differently. The above example you put the buildbot_cvs_mail.py script under /cvsroot/CVSROOT. It can be anywhere. Run the script with --help to see all the options. At the very least, the options ``-e`` (email) and ``-P`` (project) should be specified. The line must end with ``%{sVv}`` This is expanded to the files that were modified. Additional entries can be added to support more modules. See :command:`buildbot_cvs_mail.py --help`` for more information on the available options. .. bb:chsrc:: SVNCommitEmailMaildirSource .. _SVNCommitEmailMaildirSource: SVNCommitEmailMaildirSource ++++++++++++++++++++++++++++ .. py:class:: buildbot.changes.mail.SVNCommitEmailMaildirSource :bb:chsrc:`SVNCommitEmailMaildirSource` parses message sent out by the :file:`commit-email.pl` script, which is included in the Subversion distribution. It does not currently handle branches: all of the Change objects that it creates will be associated with the default (i.e. trunk) branch. :: from buildbot.changes.mail import SVNCommitEmailMaildirSource c['change_source'] = SVNCommitEmailMaildirSource("~/maildir-buildbot") .. bb:chsrc:: BzrLaunchpadEmailMaildirSource .. _BzrLaunchpadEmailMaildirSource: BzrLaunchpadEmailMaildirSource +++++++++++++++++++++++++++++++ .. py:class:: buildbot.changes.mail.BzrLaunchpadEmailMaildirSource :bb:chsrc:`BzrLaunchpadEmailMaildirSource` parses the mails that are sent to addresses that subscribe to branch revision notifications for a bzr branch hosted on Launchpad. The branch name defaults to :samp:`lp:{Launchpad path}`. For example ``lp:~maria-captains/maria/5.1``. If only a single branch is used, the default branch name can be changed by setting ``defaultBranch``. For multiple branches, pass a dictionary as the value of the ``branchMap`` option to map specific repository paths to specific branch names (see example below). The leading ``lp:`` prefix of the path is optional. The ``prefix`` option is not supported (it is silently ignored). Use the ``branchMap`` and ``defaultBranch`` instead to assign changes to branches (and just do not subscribe the buildbot to branches that are not of interest). The revision number is obtained from the email text. The bzr revision id is not available in the mails sent by Launchpad. However, it is possible to set the bzr `append_revisions_only` option for public shared repositories to avoid new pushes of merges changing the meaning of old revision numbers. :: from buildbot.changes.mail import BzrLaunchpadEmailMaildirSource bm = { 'lp:~maria-captains/maria/5.1' : '5.1', 'lp:~maria-captains/maria/6.0' : '6.0' } c['change_source'] = BzrLaunchpadEmailMaildirSource("~/maildir-buildbot", branchMap = bm) .. bb:chsrc:: PBChangeSource .. _PBChangeSource: PBChangeSource ~~~~~~~~~~~~~~ .. py:class:: buildbot.changes.pb.PBChangeSource :bb:chsrc:`PBChangeSource` actually listens on a TCP port for clients to connect and push change notices *into* the Buildmaster. This is used by the built-in ``buildbot sendchange`` notification tool, as well as several version-control hook scripts. This change is also useful for creating new kinds of change sources that work on a `push` model instead of some kind of subscription scheme, for example a script which is run out of an email :file:`.forward` file. This ChangeSource always runs on the same TCP port as the slaves. It shares the same protocol, and in fact shares the same space of "usernames", so you cannot configure a :bb:chsrc:`PBChangeSource` with the same name as a slave. If you have a publicly accessible slave port, and are using :bb:chsrc:`PBChangeSource`, *you must establish a secure username and password for the change source*. If your sendchange credentials are known (e.g., the defaults), then your buildmaster is susceptible to injection of arbitrary changes, which (depending on the build factories) could lead to arbitrary code execution on buildslaves. The :bb:chsrc:`PBChangeSource` is created with the following arguments. ``port`` which port to listen on. If ``None`` (which is the default), it shares the port used for buildslave connections. ``user`` The user account that the client program must use to connect. Defaults to ``change`` ``passwd`` The password for the connection - defaults to ``changepw``. Do not use this default on a publicly exposed port! ``prefix`` The prefix to be found and stripped from filenames delivered over the connection, defaulting to ``None``. Any filenames which do not start with this prefix will be removed. If all the filenames in a given Change are removed, the that whole Change will be dropped. This string should probably end with a directory separator. This is useful for changes coming from version control systems that represent branches as parent directories within the repository (like SVN and Perforce). Use a prefix of ``trunk/`` or ``project/branches/foobranch/`` to only follow one branch and to get correct tree-relative filenames. Without a prefix, the :bb:chsrc:`PBChangeSource` will probably deliver Changes with filenames like :file:`trunk/foo.c` instead of just :file:`foo.c`. Of course this also depends upon the tool sending the Changes in (like :bb:cmdline:`buildbot sendchange `) and what filenames it is delivering: that tool may be filtering and stripping prefixes at the sending end. For example:: from buildbot.changes import pb c['change_source'] = pb.PBChangeSource(port=9999, user='laura', passwd='fpga') The following hooks are useful for sending changes to a :bb:chsrc:`PBChangeSource`\: .. _Mercurial-Hook: Mercurial Hook ++++++++++++++ Since Mercurial is written in Python, the hook script can invoke Buildbot's :meth:`sendchange` function directly, rather than having to spawn an external process. This function delivers the same sort of changes as :command:`buildbot sendchange` and the various hook scripts in :file:`contrib/`, so you'll need to add a :bb:chsrc:`PBChangeSource` to your buildmaster to receive these changes. To set this up, first choose a Mercurial repository that represents your central `official` source tree. This will be the same repository that your buildslaves will eventually pull from. Install Buildbot on the machine that hosts this repository, using the same version of Python as Mercurial is using (so that the Mercurial hook can import code from buildbot). Then add the following to the :file:`.hg/hgrc` file in that repository, replacing the buildmaster hostname/portnumber as appropriate for your buildbot: .. code-block:: ini [hooks] changegroup.buildbot = python:buildbot.changes.hgbuildbot.hook [hgbuildbot] master = buildmaster.example.org:9987 # .. other hgbuildbot parameters .. The ``master`` configuration key allows to have more than one buildmaster specification. The buildmasters have to be separated by a whitspace or comma (see also 'hg help config'): .. code-block:: ini master = buildmaster.example.org:9987 buildmaster2.example.org:9989 .. note:: Mercurial lets you define multiple ``changegroup`` hooks by giving them distinct names, like ``changegroup.foo`` and ``changegroup.bar``, which is why we use ``changegroup.buildbot`` in this example. There is nothing magical about the `buildbot` suffix in the hook name. The ``[hgbuildbot]`` section *is* special, however, as it is the only section that the buildbot hook pays attention to.) Also note that this runs as a ``changegroup`` hook, rather than as an ``incoming`` hook. The ``changegroup`` hook is run with multiple revisions at a time (say, if multiple revisions are being pushed to this repository in a single :command:`hg push` command), whereas the ``incoming`` hook is run with just one revision at a time. The ``hgbuildbot.hook`` function will only work with the ``changegroup`` hook. Changes' attribute ``properties`` has an entry ``is_merge`` which is set to true when the change was caused by a merge. Authentication ############## If the buildmaster :bb:chsrc:`PBChangeSource` is configured to require sendchange credentials then you can set these with the ``auth`` parameter. When this parameter is not set it defaults to ``change:changepw``, which are the defaults for the ``user`` and ``password`` values of a ``PBChangeSource`` which doesn't require authentication. .. code-block:: ini [hgbuildbot] auth = clientname:supersecret # ... You can set this parameter in either the global :file:`/etc/mercurial/hgrc`, your personal :file:`~/.hgrc` file or the repository local :file:`.hg/hgrc` file. But since this value is stored in plain text, you must make sure that it can only be read by those users that need to know the authentication credentials. Branch Type ########### The ``[hgbuildbot]`` section has two other parameters that you might specify, both of which control the name of the branch that is attached to the changes coming from this hook. One common branch naming policy for Mercurial repositories is to use Mercurial's built-in branches (the kind created with :command:`hg branch` and listed with :command:`hg branches`). This feature associates persistent names with particular lines of descent within a single repository. (note that the buildbot ``source.Mercurial`` checkout step does not yet support this kind of branch). To have the commit hook deliver this sort of branch name with the Change object, use ``branchtype = inrepo``, this is the default behavior: .. code-block:: ini [hgbuildbot] branchtype = inrepo # ... Another approach is for each branch to go into a separate repository, and all the branches for a single project share a common parent directory. For example, you might have :file:`/var/repos/{PROJECT}/trunk/` and :file:`/var/repos/{PROJECT}/release`. To use this style, use the ``branchtype = dirname`` setting, which simply uses the last component of the repository's enclosing directory as the branch name: .. code-block:: ini [hgbuildbot] branchtype = dirname # ... Finally, if you want to simply specify the branchname directly, for all changes, use ``branch = BRANCHNAME``. This overrides ``branchtype``: .. code-block:: ini [hgbuildbot] branch = trunk # ... If you use ``branch=`` like this, you'll need to put a separate :file:`.hgrc` in each repository. If you use ``branchtype=``, you may be able to use the same :file:`.hgrc` for all your repositories, stored in :file:`~/.hgrc` or :file:`/etc/mercurial/hgrc`. Compatibility ############# As twisted needs to hook some signals, and some web servers strictly forbid that, the parameter ``fork`` in the ``[hgbuildbot]`` section will instruct Mercurial to fork before sending the change request. Then as the created process will be of short life, it is considered as safe to disable the signal restriction in the Apache setting like that ``WSGIRestrictSignal Off``. Refer to the documentation of your web server for other way to do the same. Resulting Changes ################# The ``category`` parameter sets the category for any changes generated from the hook. Likewise, the ``project`` parameter sets the project. Changes' ``repository`` attributes are formed from the Mercurial repo path by stripping ``strip`` slashes on the left, then prepending the ``baseurl``. For example, assume the following parameters: .. code-block:: ini [hgbuildbot] baseurl = http://hg.myorg.com/repos/ strip = 3 # ... Then a repopath of ``/var/repos/myproject/release`` would have its left 3 slashes stripped, leaving ``myproject/release``, after which the base URL would be prepended, to create ``http://hg.myorg.com/repos/myproject/release``. The ``hgbuildbot`` ``baseurl`` value defaults to the value of the same parameter in the ``web`` section of the configuration. .. note:: older versions of Buildbot created repository strings that did not contain an entire URL. To continue this pattern, set the ``hgbuildbot`` ``baseurl`` parameter to an empty string: .. code-block:: ini [hgbuildbot] baseurl = http://hg.myorg.com/repos/ .. _Bzr-Hook: Bzr Hook ++++++++ Bzr is also written in Python, and the Bzr hook depends on Twisted to send the changes. To install, put :file:`contrib/bzr_buildbot.py` in one of your plugins locations a bzr plugins directory (e.g., :file:`~/.bazaar/plugins`). Then, in one of your bazaar conf files (e.g., :file:`~/.bazaar/locations.conf`), set the location you want to connect with buildbot with these keys: * ``buildbot_on`` one of 'commit', 'push, or 'change'. Turns the plugin on to report changes via commit, changes via push, or any changes to the trunk. 'change' is recommended. * ``buildbot_server`` (required to send to a buildbot master) the URL of the buildbot master to which you will connect (as of this writing, the same server and port to which slaves connect). * ``buildbot_port`` (optional, defaults to 9989) the port of the buildbot master to which you will connect (as of this writing, the same server and port to which slaves connect) * ``buildbot_pqm`` (optional, defaults to not pqm) Normally, the user that commits the revision is the user that is responsible for the change. When run in a pqm (Patch Queue Manager, see https://launchpad.net/pqm) environment, the user that commits is the Patch Queue Manager, and the user that committed the *parent* revision is responsible for the change. To turn on the pqm mode, set this value to any of (case-insensitive) "Yes", "Y", "True", or "T". * ``buildbot_dry_run`` (optional, defaults to not a dry run) Normally, the post-commit hook will attempt to communicate with the configured buildbot server and port. If this parameter is included and any of (case-insensitive) "Yes", "Y", "True", or "T", then the hook will simply print what it would have sent, but not attempt to contact the buildbot master. * ``buildbot_send_branch_name`` (optional, defaults to not sending the branch name) If your buildbot's bzr source build step uses a repourl, do *not* turn this on. If your buildbot's bzr build step uses a baseURL, then you may set this value to any of (case-insensitive) "Yes", "Y", "True", or "T" to have the buildbot master append the branch name to the baseURL. .. note:: The bzr smart server (as of version 2.2.2) doesn't know how to resolve ``bzr://`` urls into absolute paths so any paths in ``locations.conf`` won't match, hence no change notifications will be sent to Buildbot. Setting configuration parameters globally or in-branch might still work. When buildbot no longer has a hardcoded password, it will be a configuration option here as well. Here's a simple example that you might have in your :file:`~/.bazaar/locations.conf`\. .. code-block:: ini [chroot-*:///var/local/myrepo/mybranch] buildbot_on = change buildbot_server = localhost .. bb:chsrc:: P4Source .. _P4Source: P4Source ~~~~~~~~ The :bb:chsrc:`P4Source` periodically polls a `Perforce `_ depot for changes. It accepts the following arguments: ``p4base`` The base depot path to watch, without the trailing '/...'. ``p4port`` The Perforce server to connect to (as :samp:`{host}:{port}`). ``p4user`` The Perforce user. ``p4passwd`` The Perforce password. ``p4bin`` An optional string parameter. Specify the location of the perforce command line binary (p4). You only need to do this if the perforce binary is not in the path of the buildbot user. Defaults to `p4`. ``split_file`` A function that maps a pathname, without the leading ``p4base``, to a (branch, filename) tuple. The default just returns ``(None, branchfile)``, which effectively disables branch support. You should supply a function which understands your repository structure. ``pollInterval`` How often to poll, in seconds. Defaults to 600 (10 minutes). ``histmax`` The maximum number of changes to inspect at a time. If more than this number occur since the last poll, older changes will be silently ignored. ``encoding`` The character encoding of ``p4``\'s output. This defaults to "utf8", but if your commit messages are in another encoding, specify that here. Example +++++++ This configuration uses the :envvar:`P4PORT`, :envvar:`P4USER`, and :envvar:`P4PASSWD` specified in the buildmaster's environment. It watches a project in which the branch name is simply the next path component, and the file is all path components after. :: from buildbot.changes import p4poller s = p4poller.P4Source(p4base='//depot/project/', split_file=lambda branchfile: branchfile.split('/',1), ) c['change_source'] = s .. bb:chsrc:: BonsaiPoller .. _BonsaiPoller: BonsaiPoller ~~~~~~~~~~~~ The :bb:chsrc:`BonsaiPoller` periodically polls a Bonsai server. This is a CGI script accessed through a web server that provides information about a CVS tree, for example the Mozilla bonsai server at http://bonsai.mozilla.org. Bonsai servers are usable by both humans and machines. In this case, the buildbot's change source forms a query which asks about any files in the specified branch which have changed since the last query. :bb:chsrc:`BonsaiPoller` accepts the following arguments: ``bonsaiURL`` The base URL of the Bonsai server, e.g., ``http://bonsai.mozilla.org`` ``module`` The module to look for changes in. Commonly this is ``all``. ``branch`` The branch to look for changes in. This will appear in the ``branch`` field of the resulting change objects. ``tree`` The tree to look for changes in. Commonly this is ``all``. ``cvsroot`` The CVS root of the repository. Usually this is ``/cvsroot``. ``pollInterval`` The time (in seconds) between queries for changes. ``project`` The project name to attach to all change objects produced by this change source. .. bb:chsrc:: SVNPoller .. _SVNPoller: SVNPoller ~~~~~~~~~ .. py:class:: buildbot.changes.svnpoller.SVNPoller The :bb:chsrc:`SVNPoller` is a ChangeSource which periodically polls a `Subversion `_ repository for new revisions, by running the ``svn log`` command in a subshell. It can watch a single branch or multiple branches. :bb:chsrc:`SVNPoller` accepts the following arguments: ``svnurl`` The base URL path to watch, like ``svn://svn.twistedmatrix.com/svn/Twisted/trunk``, or ``http://divmod.org/svn/Divmo/``, or even ``file:///home/svn/Repository/ProjectA/branches/1.5/``. This must include the access scheme, the location of the repository (both the hostname for remote ones, and any additional directory names necessary to get to the repository), and the sub-path within the repository's virtual filesystem for the project and branch of interest. The :bb:chsrc:`SVNPoller` will only pay attention to files inside the subdirectory specified by the complete svnurl. ``split_file`` A function to convert pathnames into ``(branch, relative_pathname)`` tuples. Use this to explain your repository's branch-naming policy to :bb:chsrc:`SVNPoller`. This function must accept a single string (the pathname relative to the repository) and return a two-entry tuple. Directory pathnames always end with a right slash to distinguish them from files, like ``trunk/src/``, or ``src/``. There are a few utility functions in :mod:`buildbot.changes.svnpoller` that can be used as a :meth:`split_file` function; see below for details. For directories, the relative pathname returned by :meth:`split_file` should end with a right slash but an empty string is also accepted for the root, like ``("branches/1.5.x", "")`` being converted from ``"branches/1.5.x/"``. The default value always returns ``(None, path)``, which indicates that all files are on the trunk. Subclasses of :bb:chsrc:`SVNPoller` can override the :meth:`split_file` method instead of using the ``split_file=`` argument. ``project`` Set the name of the project to be used for the :bb:chsrc:`SVNPoller`. This will then be set in any changes generated by the :bb:chsrc:`SVNPoller`, and can be used in a :ref:`Change Filter ` for triggering particular builders. ``svnuser`` An optional string parameter. If set, the :option:`--user` argument will be added to all :command:`svn` commands. Use this if you have to authenticate to the svn server before you can do :command:`svn info` or :command:`svn log` commands. ``svnpasswd`` Like ``svnuser``, this will cause a :option:`--password` argument to be passed to all :command:`svn` commands. ``pollInterval`` How often to poll, in seconds. Defaults to 600 (checking once every 10 minutes). Lower this if you want the buildbot to notice changes faster, raise it if you want to reduce the network and CPU load on your svn server. Please be considerate of public SVN repositories by using a large interval when polling them. ``histmax`` The maximum number of changes to inspect at a time. Every ``pollInterval`` seconds, the :bb:chsrc:`SVNPoller` asks for the last ``histmax`` changes and looks through them for any revisions it does not already know about. If more than ``histmax`` revisions have been committed since the last poll, older changes will be silently ignored. Larger values of ``histmax`` will cause more time and memory to be consumed on each poll attempt. ``histmax`` defaults to 100. ``svnbin`` This controls the :command:`svn` executable to use. If subversion is installed in a weird place on your system (outside of the buildmaster's :envvar:`PATH`), use this to tell :bb:chsrc:`SVNPoller` where to find it. The default value of `svn` will almost always be sufficient. ``revlinktmpl`` This parameter is deprecated in favour of specifying a global revlink option. This parameter allows a link to be provided for each revision (for example, to websvn or viewvc). These links appear anywhere changes are shown, such as on build or change pages. The proper form for this parameter is an URL with the portion that will substitute for a revision number replaced by ''%s''. For example, ``'http://myserver/websvn/revision.php?rev=%s'`` could be used to cause revision links to be created to a websvn repository viewer. ``cachepath`` If specified, this is a pathname of a cache file that :bb:chsrc:`SVNPoller` will use to store its state between restarts of the master. ``extra_args`` If specified, the extra arguments will be added to the svn command args. Several split file functions are available for common SVN repository layouts. For a poller that is only monitoring trunk, the default split file function is available explicitly as ``split_file_alwaystrunk``:: from buildbot.changes.svnpoller import SVNPoller from buildbot.changes.svnpoller import split_file_alwaystrunk c['change_source'] = SVNPoller( svnurl="svn://svn.twistedmatrix.com/svn/Twisted/trunk", split_file=split_file_alwaystrunk) For repositories with the ``/trunk`` and ``/branches/{BRANCH}`` layout, ``split_file_branches`` will do the job:: from buildbot.changes.svnpoller import SVNPoller from buildbot.changes.svnpoller import split_file_branches c['change_source'] = SVNPoller( svnurl="https://amanda.svn.sourceforge.net/svnroot/amanda/amanda", split_file=split_file_branches) When using this splitter the poller will set the ``project`` attribute of any changes to the ``project`` attribute of the poller. For repositories with the ``{PROJECT}/trunk`` and ``{PROJECT}/branches/{BRANCH}`` layout, ``split_file_projects_branches`` will do the job:: from buildbot.changes.svnpoller import SVNPoller from buildbot.changes.svnpoller import split_file_projects_branches c['change_source'] = SVNPoller( svnurl="https://amanda.svn.sourceforge.net/svnroot/amanda/", split_file=split_file_projects_branches) When using this splitter the poller will set the ``project`` attribute of any changes to the project determined by the splitter. The :bb:chsrc:`SVNPoller` is highly adaptable to various Subversion layouts. See :ref:`Customizing-SVNPoller` for details and some common scenarios. .. bb:chsrc:: BzrPoller .. _Bzr-Poller: Bzr Poller ~~~~~~~~~~ If you cannot insert a Bzr hook in the server, you can use the Bzr Poller. To use, put :file:`contrib/bzr_buildbot.py` somewhere that your buildbot configuration can import it. Even putting it in the same directory as the :file:`master.cfg` should work. Install the poller in the buildbot configuration as with any other change source. Minimally, provide a URL that you want to poll (``bzr://``, ``bzr+ssh://``, or ``lp:``), making sure the buildbot user has necessary privileges. :: # bzr_buildbot.py in the same directory as master.cfg from bzr_buildbot import BzrPoller c['change_source'] = BzrPoller( url='bzr://hostname/my_project', poll_interval=300) The ``BzrPoller`` parameters are: ``url`` The URL to poll. ``poll_interval`` The number of seconds to wait between polls. Defaults to 10 minutes. ``branch_name`` Any value to be used as the branch name. Defaults to None, or specify a string, or specify the constants from :file:`bzr_buildbot.py` ``SHORT`` or ``FULL`` to get the short branch name or full branch address. ``blame_merge_author`` normally, the user that commits the revision is the user that is responsible for the change. When run in a pqm (Patch Queue Manager, see https://launchpad.net/pqm) environment, the user that commits is the Patch Queue Manager, and the user that committed the merged, *parent* revision is responsible for the change. set this value to ``True`` if this is pointed against a PQM-managed branch. .. bb:chsrc:: GitPoller .. _GitPoller: GitPoller ~~~~~~~~~ If you cannot take advantage of post-receive hooks as provided by :file:`contrib/git_buildbot.py` for example, then you can use the :bb:chsrc:`GitPoller`. The :bb:chsrc:`GitPoller` periodically fetches from a remote Git repository and processes any changes. It requires its own working directory for operation. The default should be adequate, but it can be overridden via the ``workdir`` property. .. note:: There can only be a single `GitPoller` pointed at any given repository. The :bb:chsrc:`GitPoller` requires Git-1.7 and later. It accepts the following arguments: ``repourl`` the git-url that describes the remote repository, e.g. ``git@example.com:foobaz/myrepo.git`` (see the :command:`git fetch` help for more info on git-url formats) ``branches`` a list of the branches to fetch, will default to ``['master']`` ``branch`` accepts a single branch name to fetch. Exists for backwards compatibility with old configurations. ``pollInterval`` interval in seconds between polls, default is 10 minutes ``gitbin`` path to the Git binary, defaults to just ``'git'`` ``category`` Set the category to be used for the changes produced by the :bb:chsrc:`GitPoller`. This will then be set in any changes generated by the :bb:chsrc:`GitPoller`, and can be used in a Change Filter for triggering particular builders. ``project`` Set the name of the project to be used for the :bb:chsrc:`GitPoller`. This will then be set in any changes generated by the ``GitPoller``, and can be used in a Change Filter for triggering particular builders. ``usetimestamps`` parse each revision's commit timestamp (default is ``True``), or ignore it in favor of the current time (so recently processed commits appear together in the waterfall page) ``encoding`` Set encoding will be used to parse author's name and commit message. Default encoding is ``'utf-8'``. This will not be applied to file names since Git will translate non-ascii file names to unreadable escape sequences. ``workdir`` the directory where the poller should keep its local repository. The default is :samp:`gitpoller_work`. If this is a relative path, it will be interpreted relative to the master's basedir. Multiple Git pollers can share the same directory. A configuration for the Git poller might look like this:: from buildbot.changes.gitpoller import GitPoller c['change_source'] = GitPoller(repourl='git@example.com:foobaz/myrepo.git', branches=['master', 'great_new_feature']) .. bb:chsrc:: HgPoller .. _HgPoller: HgPoller ~~~~~~~~ If you cannot take advantage of post-receive hooks as provided by :file:`buildbot/changes/hgbuildbot.py` for example, then you can use the :bb:chsrc:`HgPoller`. The :bb:chsrc:`HgPoller` periodically pulls a named branch from a remote Mercurial repository and processes any changes. It requires its own working directory for operation, which must be specified via the ``workdir`` property. The :bb:chsrc:`HgPoller` requires a working ``hg`` executable, and at least a read-only access to the repository it polls (possibly through ssh keys or by tweaking the ``hgrc`` of the system user buildbot runs as). The :bb:chsrc:`HgPoller` will not transmit any change if there are several heads on the watched named branch. This is similar (although not identical) to the Mercurial executable behaviour. This exceptional condition is usually the result of a developer mistake, and usually does not last for long. It is reported in logs. If fixed by a later merge, the buildmaster administrator does not have anything to do: that merge will be transmitted, together with the intermediate ones. The :bb:chsrc:`HgPoller` accepts the following arguments: ``repourl`` the url that describes the remote repository, e.g. ``http://hg.example.com/projects/myrepo``. Any url suitable for ``hg pull`` can be specified. ``branch`` the desired branch to pull, will default to ``'default'`` ``workdir`` the directory where the poller should keep its local repository. It is mandatory for now, although later releases may provide a meaningful default. It also serves to identify the poller in the buildmaster internal database. Changing it may result in re-processing all changes so far. Several :bb:chsrc:`HgPoller` instances may share the same ``workdir`` for mutualisation of the common history between two different branches, thus easing on local and remote system resources and bandwidth. If relative, the ``workdir`` will be interpreted from the master directory. ``pollInterval`` interval in seconds between polls, default is 10 minutes ``hgbin`` path to the Mercurial binary, defaults to just ``'hg'`` ``category`` Set the category to be used for the changes produced by the :bb:chsrc:`HgPoller`. This will then be set in any changes generated by the :bb:chsrc:`HgPoller`, and can be used in a Change Filter for triggering particular builders. ``project`` Set the name of the project to be used for the :bb:chsrc:`HgPoller`. This will then be set in any changes generated by the ``HgPoller``, and can be used in a Change Filter for triggering particular builders. ``usetimestamps`` parse each revision's commit timestamp (default is ``True``), or ignore it in favor of the current time (so recently processed commits appear together in the waterfall page) ``encoding`` Set encoding will be used to parse author's name and commit message. Default encoding is ``'utf-8'``. A configuration for the Mercurial poller might look like this:: from buildbot.changes.hgpoller import HgPoller c['change_source'] = HgPoller(repourl='http://hg.example.org/projects/myrepo', branch='great_new_feature', workdir='hg-myrepo') .. bb:chsrc:: GerritChangeSource .. _GerritChangeSource: GerritChangeSource ~~~~~~~~~~~~~~~~~~ .. py:class:: buildbot.changes.gerritchangesource.GerritChangeSource The :bb:chsrc:`GerritChangeSource` class connects to a Gerrit server by its SSH interface and uses its event source mechanism, `gerrit stream-events `_. This class adds a change to the buildbot system for each of the following events: ``patchset-created`` A change is proposed for review. Automatic checks like :file:`checkpatch.pl` can be automatically triggered. Beware of what kind of automatic task you trigger. At this point, no trusted human has reviewed the code, and a patch could be specially crafted by an attacker to compromise your buildslaves. ``ref-updated`` A change has been merged into the repository. Typically, this kind of event can lead to a complete rebuild of the project, and upload binaries to an incremental build results server. This class will populate the property list of the triggered build with the info received from Gerrit server in JSON format. .. index:: Properties; from GerritChangeSource In case of ``patchset-created`` event, these properties will be: ``event.change.branch`` Branch of the Change ``event.change.id`` Change's ID in the Gerrit system (the ChangeId: in commit comments) ``event.change.number`` Change's number in Gerrit system ``event.change.owner.email`` Change's owner email (owner is first uploader) ``event.change.owner.name`` Change's owner name ``event.change.project`` Project of the Change ``event.change.subject`` Change's subject ``event.change.url`` URL of the Change in the Gerrit's web interface ``event.patchSet.number`` Patchset's version number ``event.patchSet.ref`` Patchset's Gerrit "virtual branch" ``event.patchSet.revision`` Patchset's Git commit ID ``event.patchSet.uploader.email`` Patchset uploader's email (owner is first uploader) ``event.patchSet.uploader.name`` Patchset uploader's name (owner is first uploader) ``event.type`` Event type (``patchset-created``) ``event.uploader.email`` Patchset uploader's email ``event.uploader.name`` Patchset uploader's name In case of ``ref-updated`` event, these properties will be: ``event.refUpdate.newRev`` New Git commit ID (after merger) ``event.refUpdate.oldRev`` Previous Git commit ID (before merger) ``event.refUpdate.project`` Project that was updated ``event.refUpdate.refName`` Branch that was updated ``event.submitter.email`` Submitter's email (merger responsible) ``event.submitter.name`` Submitter's name (merger responsible) ``event.type`` Event type (``ref-updated``) ``event.submitter.email`` Submitter's email (merger responsible) ``event.submitter.name`` Submitter's name (merger responsible) A configuration for this source might look like:: from buildbot.changes.gerritchangesource import GerritChangeSource c['change_source'] = GerritChangeSource(gerrit_server, gerrit_user) see :file:`master/docs/examples/repo_gerrit.cfg` in the Buildbot distribution for a full example setup of :bb:chsrc:`GerritChangeSource`. .. bb:chsrc:: Change Hooks .. _Change-Hooks-HTTP-Notifications: Change Hooks (HTTP Notifications) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Buildbot already provides a web frontend, and that frontend can easily be used to receive HTTP push notifications of commits from services like GitHub or GoogleCode. See :ref:`Change-Hooks` for more information. .. bb:chsrc:: GoogleCodeAtomPoller .. _GoogleCodeAtomPoller: GoogleCodeAtomPoller ~~~~~~~~~~~~~~~~~~~~ The :bb:chsrc:`GoogleCodeAtomPoller` periodically polls a Google Code Project's commit feed for changes. Works on SVN, Git, and Mercurial repositories. Branches are not understood (yet). It accepts the following arguments: ``feedurl`` The commit Atom feed URL of the GoogleCode repository (MANDATORY) ``pollinterval`` Polling frequency for the feed (in seconds). Default is 1 hour (OPTIONAL) As an example, to poll the Ostinato project's commit feed every 3 hours, the configuration would look like this:: from googlecode_atom import GoogleCodeAtomPoller c['change_source'] = GoogleCodeAtomPoller( feedurl="http://code.google.com/feeds/p/ostinato/hgchanges/basic", pollinterval=10800) (note that you will need to download ``googlecode_atom.py`` from the Buildbot source and install it somewhere on your PYTHONPATH first) buildbot-0.8.8/docs/manual/cfg-global.rst000066400000000000000000000700221222546025000202660ustar00rootroot00000000000000Global Configuration -------------------- The keys in this section affect the operations of the buildmaster globally. .. bb:cfg:: db .. bb:cfg:: db_url .. bb:cfg:: db_poll_interval .. _Database-Specification: Database Specification ~~~~~~~~~~~~~~~~~~~~~~ Buildbot requires a connection to a database to maintain certain state information, such as tracking pending build requests. In the default configuration Buildbot uses a file-based SQLite database, stored in the :file:`state.sqlite` file of the master's base directory. Override this configuration with the :bb:cfg:`db_url` parameter. Buildbot accepts a database configuration in a dictionary named ``db``. All keys are optional:: c['db'] = { 'db_url' : 'sqlite:///state.sqlite', 'db_poll_interval' : 30, } The ``db_url`` key indicates the database engine to use. The format of this parameter is completely documented at http://www.sqlalchemy.org/docs/dialects/, but is generally of the form:: driver://[username:password@]host:port/database[?args] The optional ``db_poll_interval`` specifies the interval, in seconds, between checks for pending tasks in the database. This parameter is generally only useful in multi-master mode. See :ref:`Multi-master-mode`. These parameters can be specified directly in the configuration dictionary, as ``c['db_url']`` and ``c['db_poll_interval']``, although this method is deprecated. The following sections give additional information for particular database backends: .. index:: SQLite SQLite ++++++ For sqlite databases, since there is no host and port, relative paths are specified with ``sqlite:///`` and absolute paths with ``sqlite:////``. Examples:: c['db_url'] = "sqlite:///state.sqlite" SQLite requires no special configuration. If Buildbot produces "database is locked" exceptions, try adding ``serialize_access=1`` to the DB URL as a workaround:: c['db_url'] = "sqlite:///state.sqlite?serialize_access=1" and please file a bug at http://trac.buildbot.net. .. index:: MySQL MySQL +++++ .. code-block:: python c['db_url'] = "mysql://user:pass@somehost.com/database_name?max_idle=300" The ``max_idle`` argument for MySQL connections is unique to Buildbot, and should be set to something less than the ``wait_timeout`` configured for your server. This controls the SQLAlchemy ``pool_recycle`` parameter, which defaults to no timeout. Setting this parameter ensures that connections are closed and re-opened after the configured amount of idle time. If you see errors such as ``_mysql_exceptions.OperationalError: (2006, 'MySQL server has gone away')``, this means your ``max_idle`` setting is probably too high. ``show global variables like 'wait_timeout';`` will show what the currently configured ``wait_timeout`` is on your MySQL server. Buildbot requires ``use_unique=True`` and ``charset=utf8``, and will add them automatically, so they do not need to be specified in ``db_url``. MySQL defaults to the MyISAM storage engine, but this can be overridden with the ``storage_engine`` URL argument. Note that, because of InnoDB's extremely short key length limitations, it cannot be used to run Buildbot. See http://bugs.mysql.com/bug.php?id=4541 for more information. Buildbot uses temporary tables internally to manage large transactions. MySQL has trouble doing replication with temporary tables, so if you are using a replicated MySQL installation, you may need to handle this situation carefully. The MySQL documentation (http://dev.mysql.com/doc/refman/5.5/en/replication-features-temptables.html) recommends using ``--replicate-wild-ignore-table`` to ignore temporary tables that should not be replicated. All Buildbot temporary tables begin with ``bbtmp_``, so an option such as ``--replicate-wild-ignore-table=bbtmp_.*`` may help. .. index:: Postgres Postgres ++++++++ .. code-block:: python c['db_url'] = "postgresql://username@hostname/dbname" PosgreSQL requires no special configuration. .. bb:cfg:: multiMaster .. _Multi-master-mode: Multi-master mode ~~~~~~~~~~~~~~~~~ Normally buildbot operates using a single master process that uses the configured database to save state. It is possible to configure buildbot to have multiple master processes that share state in the same database. This has been well tested using a MySQL database. There are several benefits of Multi-master mode: * You can have large numbers of build slaves handling the same queue of build requests. A single master can only handle so many slaves (the number is based on a number of factors including type of builds, number of builds, and master and slave IO and CPU capacity--there is no fixed formula). By adding another master which shares the queue of build requests, you can attach more slaves to this additional master, and increase your build throughput. * You can shut one master down to do maintenance, and other masters will continue to do builds. State that is shared in the database includes: * List of changes * Scheduler names and internal state * Build requests, including the builder name Because of this shared state, you are strongly encouraged to: * Ensure that each named scheduler runs on only one master. If the same scheduler runs on multiple masters, it will trigger duplicate builds and may produce other undesirable behaviors. * Ensure builder names are unique for a given build factory implementation. You can have the same builder name configured on many masters, but if the build factories differ, you will get different results depending on which master claims the build. One suggested configuration is to have one buildbot master configured with just the scheduler and change sources; and then other masters configured with just the builders. To enable multi-master mode in this configuration, you will need to set the :bb:cfg:`multiMaster` option so that buildbot doesn't warn about missing schedulers or builders. You will also need to set :bb:cfg:`db_poll_interval` to specify the interval (in seconds) at which masters should poll the database for tasks. :: # Enable multiMaster mode; disables warnings about unknown builders and # schedulers c['multiMaster'] = True # Check for new build requests every 60 seconds c['db'] = { 'db_url' : 'mysql://...', 'db_poll_interval' : 30, } .. bb:cfg:: buildbotURL .. bb:cfg:: titleURL .. bb:cfg:: title Site Definitions ~~~~~~~~~~~~~~~~~~~ Three basic settings describe the buildmaster in status reports:: c['title'] = "Buildbot" c['titleURL'] = "http://buildbot.sourceforge.net/" c['buildbotURL'] = "http://localhost:8010/" :bb:cfg:`title` is a short string that will appear at the top of this buildbot installation's :class:`html.WebStatus` home page (linked to the :bb:cfg:`titleURL`), and is embedded in the title of the waterfall HTML page. :bb:cfg:`titleURL` is a URL string that must end with a slash (``/``). HTML status displays will show ``title`` as a link to :bb:cfg:`titleURL`. This URL is often used to provide a link from buildbot HTML pages to your project's home page. The :bb:cfg:`buildbotURL` string should point to the location where the buildbot's internal web server is visible. This URL must end with a slash (``/``). This typically uses the port number set for the web status (:bb:status:`WebStatus`): the buildbot needs your help to figure out a suitable externally-visible host URL. When status notices are sent to users (either by email or over IRC), :bb:cfg:`buildbotURL` will be used to create a URL to the specific build or problem that they are being notified about. It will also be made available to queriers (over IRC) who want to find out where to get more information about this buildbot. .. bb:cfg:: logCompressionLimit .. bb:cfg:: logCompressionMethod .. bb:cfg:: logMaxSize .. bb:cfg:: logMaxTailSize Log Handling ~~~~~~~~~~~~ :: c['logCompressionLimit'] = 16384 c['logCompressionMethod'] = 'gz' c['logMaxSize'] = 1024*1024 # 1M c['logMaxTailSize'] = 32768 The :bb:cfg:`logCompressionLimit` enables compression of build logs on disk for logs that are bigger than the given size, or disables that completely if set to ``False``. The default value is 4096, which should be a reasonable default on most file systems. This setting has no impact on status plugins, and merely affects the required disk space on the master for build logs. The :bb:cfg:`logCompressionMethod` controls what type of compression is used for build logs. The default is 'bz2', and the other valid option is 'gz'. 'bz2' offers better compression at the expense of more CPU time. The :bb:cfg:`logMaxSize` parameter sets an upper limit (in bytes) to how large logs from an individual build step can be. The default value is None, meaning no upper limit to the log size. Any output exceeding :bb:cfg:`logMaxSize` will be truncated, and a message to this effect will be added to the log's HEADER channel. If :bb:cfg:`logMaxSize` is set, and the output from a step exceeds the maximum, the :bb:cfg:`logMaxTailSize` parameter controls how much of the end of the build log will be kept. The effect of setting this parameter is that the log will contain the first :bb:cfg:`logMaxSize` bytes and the last :bb:cfg:`logMaxTailSize` bytes of output. Don't set this value too high, as the the tail of the log is kept in memory. Data Lifetime ~~~~~~~~~~~~~ .. bb:cfg:: changeHorizon .. bb:cfg:: buildHorizon .. bb:cfg:: eventHorizon .. bb:cfg:: logHorizon Horizons ++++++++ :: c['changeHorizon'] = 200 c['buildHorizon'] = 100 c['eventHorizon'] = 50 c['logHorizon'] = 40 c['buildCacheSize'] = 15 Buildbot stores historical information on disk in the form of "Pickle" files and compressed logfiles. In a large installation, these can quickly consume disk space, yet in many cases developers never consult this historical information. The :bb:cfg:`changeHorizon` key determines how many changes the master will keep a record of. One place these changes are displayed is on the waterfall page. This parameter defaults to 0, which means keep all changes indefinitely. The :bb:cfg:`buildHorizon` specifies the minimum number of builds for each builder which should be kept on disk. The :bb:cfg:`eventHorizon` specifies the minimum number of events to keep--events mostly describe connections and disconnections of slaves, and are seldom helpful to developers. The :bb:cfg:`logHorizon` gives the minimum number of builds for which logs should be maintained; this parameter must be less than or equal to :bb:cfg:`buildHorizon`. Builds older than :bb:cfg:`logHorizon` but not older than :bb:cfg:`buildHorizon` will maintain their overall status and the status of each step, but the logfiles will be deleted. .. bb:cfg:: caches .. bb:cfg:: changeCacheSize .. bb:cfg:: buildCacheSize Caches ++++++ :: c['caches'] = { 'Changes' : 100, # formerly c['changeCacheSize'] 'Builds' : 500, # formerly c['buildCacheSize'] 'chdicts' : 100, 'BuildRequests' : 10, 'SourceStamps' : 20, 'ssdicts' : 20, 'objectids' : 10, 'usdicts' : 100, } The :bb:cfg:`caches` configuration key contains the configuration for Buildbot's in-memory caches. These caches keep frequently-used objects in memory to avoid unnecessary trips to the database or to pickle files. Caches are divided by object type, and each has a configurable maximum size. The default size for each cache is 1, except where noted below. A value of 1 allows Buildbot to make a number of optimizations without consuming much memory. Larger, busier installations will likely want to increase these values. The available caches are: ``Changes`` the number of change objects to cache in memory. This should be larger than the number of changes that typically arrive in the span of a few minutes, otherwise your schedulers will be reloading changes from the database every time they run. For distributed version control systems, like Git or Hg, several thousand changes may arrive at once, so setting this parameter to something like 10000 isn't unreasonable. This parameter is the same as the deprecated global parameter :bb:cfg:`changeCacheSize`. Its default value is 10. ``Builds`` The :bb:cfg:`buildCacheSize` parameter gives the number of builds for each builder which are cached in memory. This number should be larger than the number of builds required for commonly-used status displays (the waterfall or grid views), so that those displays do not miss the cache on a refresh. This parameter is the same as the deprecated global parameter :bb:cfg:`buildCacheSize`. Its default value is 15. ``chdicts`` The number of rows from the ``changes`` table to cache in memory. This value should be similar to the value for ``Changes``. ``BuildRequests`` The number of BuildRequest objects kept in memory. This number should be higher than the typical number of outstanding build requests. If the master ordinarily finds jobs for BuildRequests immediately, you may set a lower value. ``SourceStamps`` the number of SourceStamp objects kept in memory. This number should generally be similar to the number ``BuildRequesets``. ``ssdicts`` The number of rows from the ``sourcestamps`` table to cache in memory. This value should be similar to the value for ``SourceStamps``. ``objectids`` The number of object IDs - a means to correlate an object in the Buildbot configuration with an identity in the database--to cache. In this version, object IDs are not looked up often during runtime, so a relatively low value such as 10 is fine. ``usdicts`` The number of rows from the ``users`` table to cache in memory. Note that for a given user there will be a row for each attribute that user has. c['buildCacheSize'] = 15 .. bb:cfg:: mergeRequests .. index:: Builds; merging Merging Build Requests ~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python c['mergeRequests'] = True This is a global default value for builders' :bb:cfg:`mergeRequests` parameter, and controls the merging of build requests. This parameter can be overridden on a per-builder basis. See :ref:`Merging-Build-Requests` for the allowed values for this parameter. .. index:: Builders; priority .. bb:cfg:: prioritizeBuilders .. _Prioritizing-Builders: Prioritizing Builders ~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python def prioritizeBuilders(buildmaster, builders): # ... c['prioritizeBuilders'] = prioritizeBuilders By default, buildbot will attempt to start builds on builders in order, beginning with the builder with the oldest pending request. Customize this behavior with the :bb:cfg:`prioritizeBuilders` configuration key, which takes a callable. See :ref:`Builder-Priority-Functions` for details on this callable. This parameter controls the order that the build master can start builds, and is useful in situations where there is resource contention between builders, e.g., for a test database. It does not affect the order in which a builder processes the build requests in its queue. For that purpose, see :ref:`Prioritizing-Builds`. .. bb:cfg:: slavePortnum .. _Setting-the-PB-Port-for-Slaves: Setting the PB Port for Slaves ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ :: c['slavePortnum'] = 10000 The buildmaster will listen on a TCP port of your choosing for connections from buildslaves. It can also use this port for connections from remote Change Sources, status clients, and debug tools. This port should be visible to the outside world, and you'll need to tell your buildslave admins about your choice. It does not matter which port you pick, as long it is externally visible; however, you should probably use something larger than 1024, since most operating systems don't allow non-root processes to bind to low-numbered ports. If your buildmaster is behind a firewall or a NAT box of some sort, you may have to configure your firewall to permit inbound connections to this port. :bb:cfg:`slavePortnum` is a *strports* specification string, defined in the ``twisted.application.strports`` module (try ``pydoc twisted.application.strports`` to get documentation on the format). This means that you can have the buildmaster listen on a localhost-only port by doing: .. code-block:: python c['slavePortnum'] = "tcp:10000:interface=127.0.0.1" This might be useful if you only run buildslaves on the same machine, and they are all configured to contact the buildmaster at ``localhost:10000``. .. index:: Properties; global .. bb:cfg:: properties Defining Global Properties ~~~~~~~~~~~~~~~~~~~~~~~~~~ The :bb:cfg:`properties` configuration key defines a dictionary of properties that will be available to all builds started by the buildmaster: .. code-block:: python c['properties'] = { 'Widget-version' : '1.2', 'release-stage' : 'alpha' } .. bb:cfg:: debugPassword .. _Debug-Options: Debug Options ~~~~~~~~~~~~~ If you set :bb:cfg:`debugPassword`, then you can connect to the buildmaster with the diagnostic tool launched by :samp:`buildbot debugclient {MASTER}:{PORT}`. From this tool, you can reload the config file, manually force builds, and inject changes, which may be useful for testing your buildmaster without actually committing changes to your repository (or before you have the Change Sources configured.) The debug tool uses the same port number as the slaves, :bb:cfg:`slavePortnum`, and you may configure its authentication credentials as follows:: c['debugPassword'] = "debugpassword" .. index:: Manhole .. bb:cfg:: manhole Manhole ~~~~~~~ If you set :bb:cfg:`manhole` to an instance of one of the classes in ``buildbot.manhole``, you can telnet or ssh into the buildmaster and get an interactive Python shell, which may be useful for debugging buildbot internals. It is probably only useful for buildbot developers. It exposes full access to the buildmaster's account (including the ability to modify and delete files), so it should not be enabled with a weak or easily guessable password. There are three separate :class:`Manhole` classes. Two of them use SSH, one uses unencrypted telnet. Two of them use a username+password combination to grant access, one of them uses an SSH-style :file:`authorized_keys` file which contains a list of ssh public keys. .. note:: Using any Manhole requires that ``pycrypto`` and ``pyasn1`` be installed. These are not part of the normal Buildbot dependencies. `manhole.AuthorizedKeysManhole` You construct this with the name of a file that contains one SSH public key per line, just like :file:`~/.ssh/authorized_keys`. If you provide a non-absolute filename, it will be interpreted relative to the buildmaster's base directory. `manhole.PasswordManhole` This one accepts SSH connections but asks for a username and password when authenticating. It accepts only one such pair. `manhole.TelnetManhole` This accepts regular unencrypted telnet connections, and asks for a username/password pair before providing access. Because this username/password is transmitted in the clear, and because Manhole access to the buildmaster is equivalent to granting full shell privileges to both the buildmaster and all the buildslaves (and to all accounts which then run code produced by the buildslaves), it is highly recommended that you use one of the SSH manholes instead. :: # some examples: from buildbot import manhole c['manhole'] = manhole.AuthorizedKeysManhole(1234, "authorized_keys") c['manhole'] = manhole.PasswordManhole(1234, "alice", "mysecretpassword") c['manhole'] = manhole.TelnetManhole(1234, "bob", "snoop_my_password_please") The :class:`Manhole` instance can be configured to listen on a specific port. You may wish to have this listening port bind to the loopback interface (sometimes known as `lo0`, `localhost`, or 127.0.0.1) to restrict access to clients which are running on the same host. :: from buildbot.manhole import PasswordManhole c['manhole'] = PasswordManhole("tcp:9999:interface=127.0.0.1","admin","passwd") To have the :class:`Manhole` listen on all interfaces, use ``"tcp:9999"`` or simply 9999. This port specification uses ``twisted.application.strports``, so you can make it listen on SSL or even UNIX-domain sockets if you want. Note that using any :class:`Manhole` requires that the `TwistedConch`_ package be installed. The buildmaster's SSH server will use a different host key than the normal sshd running on a typical unix host. This will cause the ssh client to complain about a `host key mismatch`, because it does not realize there are two separate servers running on the same host. To avoid this, use a clause like the following in your :file:`.ssh/config` file: .. code-block:: none Host remotehost-buildbot HostName remotehost HostKeyAlias remotehost-buildbot Port 9999 # use 'user' if you use PasswordManhole and your name is not 'admin'. # if you use AuthorizedKeysManhole, this probably doesn't matter. User admin Using Manhole +++++++++++++ After you have connected to a manhole instance, you will find yourself at a Python prompt. You have access to two objects: ``master`` (the BuildMaster) and ``status`` (the master's Status object). Most interesting objects on the master can be reached from these two objects. To aid in navigation, the ``show`` method is defined. It displays the non-method attributes of an object. A manhole session might look like:: >>> show(master) data attributes of basedir : '/home/dustin/code/buildbot/t/buildbot/'... botmaster : buildCacheSize : None buildHorizon : None buildbotURL : http://localhost:8010/ changeCacheSize : None change_svc : configFileName : master.cfg db : db_poll_interval : None db_url : sqlite:///state.sqlite ... >>> show(master.botmaster.builders['win32']) data attributes of ... >>> win32 = _ >>> win32.category = 'w32' .. bb:cfg:: metrics Metrics Options ~~~~~~~~~~~~~~~ :: c['metrics'] = dict(log_interval=10, periodic_interval=10) :bb:cfg:`metrics` can be a dictionary that configures various aspects of the metrics subsystem. If :bb:cfg:`metrics` is ``None``, then metrics collection, logging and reporting will be disabled. ``log_interval`` determines how often metrics should be logged to twistd.log. It defaults to 60s. If set to 0 or ``None``, then logging of metrics will be disabled. This value can be changed via a reconfig. ``periodic_interval`` determines how often various non-event based metrics are collected, such as memory usage, uncollectable garbage, reactor delay. This defaults to 10s. If set to 0 or ``None``, then periodic collection of this data is disabled. This value can also be changed via a reconfig. Read more about metrics in the :ref:`Metrics` section in the developer documentation. .. bb:cfg:: user_managers .. _Users-Options: Users Options ~~~~~~~~~~~~~ :: from buildbot.process.users import manual c['user_managers'] = [] c['user_managers'].append(manual.CommandlineUserManager(username="user", passwd="userpw", port=9990)) :bb:cfg:`user_managers` contains a list of ways to manually manage User Objects within Buildbot (see :ref:`User-Objects`). Currently implemented is a commandline tool `buildbot user`, described at length in :bb:cmdline:`user`. In the future, a web client will also be able to manage User Objects and their attributes. As shown above, to enable the `buildbot user` tool, you must initialize a `CommandlineUserManager` instance in your `master.cfg`. `CommandlineUserManager` instances require the following arguments: ``username`` This is the `username` that will be registered on the PB connection and need to be used when calling `buildbot user`. ``passwd`` This is the `passwd` that will be registered on the PB connection and need to be used when calling `buildbot user`. ``port`` The PB connection `port` must be different than `c['slavePortnum']` and be specified when calling `buildbot user` .. bb:cfg:: validation .. _Input-Validation: Input Validation ~~~~~~~~~~~~~~~~ :: import re c['validation'] = { 'branch' : re.compile(r'^[\w.+/~-]*$'), 'revision' : re.compile(r'^[ \w\.\-\/]*$'), 'property_name' : re.compile(r'^[\w\.\-\/\~:]*$'), 'property_value' : re.compile(r'^[\w\.\-\/\~:]*$'), } This option configures the validation applied to user inputs of various types. This validation is important since these values are often included in command-line arguments executed on slaves. Allowing arbitrary input from untrusted users may raise security concerns. The keys describe the type of input validated; the values are compiled regular expressions against which the input will be matched. The defaults for each type of input are those given in the example, above. .. bb:cfg:: revlink Revision Links ~~~~~~~~~~~~~~ The :bb:cfg:`revlink` parameter is used to create links from revision IDs in the web status to a web-view of your source control system. The parameter's value must be a callable. By default, Buildbot is configured to generate revlinks for a number of open source hosting platforms. The callable takes the revision id and repository argument, and should return an URL to the revision. Note that the revision id may not always be in the form you expect, so code defensively. In particular, a revision of "??" may be supplied when no other information is available. Note that :class:`SourceStamp`\s that are not created from version-control changes (e.g., those created by a Nightly or Periodic scheduler) may have an empty repository string, if the repository is not known to the scheduler. Revision Link Helpers +++++++++++++++++++++ Buildbot provides two helpers for generating revision links. :class:`buildbot.revlinks.RevlinkMatcher` takes a list of regular expressions, and replacement text. The regular expressions should all have the same number of capture groups. The replacement text should have sed-style references to that capture groups (i.e. '\1' for the first capture group), and a single '%s' reference, for the revision ID. The repository given is tried against each regular expression in turn. The results are the substituted into the replacement text, along with the revision ID to obtain the revision link. :: from buildbot import revlinks c['revlink'] = revlinks.RevlinkMatch([r'git://notmuchmail.org/git/(.*)'], r'http://git.notmuchmail.org/git/\1/commit/%s') :class:`buildbot.revlinks.RevlinkMultiplexer` takes a list of revision link callables, and tries each in turn, returning the first successful match. .. _TwistedConch: http://twistedmatrix.com/trac/wiki/TwistedConch .. bb:cfg:: codebaseGenerator Codebase Generator ~~~~~~~~~~~~~~~~~~ :: all_repositories = { r'https://hg/hg/mailsuite/mailclient': 'mailexe', r'https://hg/hg/mailsuite/mapilib': 'mapilib', r'https://hg/hg/mailsuite/imaplib': 'imaplib', r'https://github.com/mailinc/mailsuite/mailclient': 'mailexe', r'https://github.com/mailinc/mailsuite/mapilib': 'mapilib', r'https://github.com/mailinc/mailsuite/imaplib': 'imaplib', } def codebaseGenerator(chdict): return all_repositories[chdict['repository']] c['codebaseGenerator'] = codebaseGenerator For any incoming change, the :ref:`codebase` is set to ''. This codebase value is sufficient if all changes come from the same repository (or clones). If changes come from different repositories, extra processing will be needed to determine the codebase for the incoming change. This codebase will then be a logical name for the combination of repository and or branch etc. The `codebaseGenerator` accepts a change dictionary as produced by the :py:class:`buildbot.db.changes.ChangesConnectorComponent `, with a changeid equal to `None`. buildbot-0.8.8/docs/manual/cfg-interlocks.rst000066400000000000000000000161761222546025000212150ustar00rootroot00000000000000.. -*- rst -*- .. _Interlocks: Interlocks ---------- Until now, we assumed that a master can run builds at any slave whenever needed or desired. Some times, you want to enforce additional constraints on builds. For reasons like limited network bandwidth, old slave machines, or a self-willed data base server, you may want to limit the number of builds (or build steps) that can access a resource. .. _Access-Modes: Access Modes ~~~~~~~~~~~~ The mechanism used by Buildbot is known as the read/write lock [#]_. It allows either many readers or a single writer but not a combination of readers and writers. The general lock has been modified and extended for use in Buildbot. Firstly, the general lock allows an infinite number of readers. In Buildbot, we often want to put an upper limit on the number of readers, for example allowing two out of five possible builds at the same time. To do this, the lock counts the number of active readers. Secondly, the terms *read mode* and *write mode* are confusing in Buildbot context. They have been replaced by *counting mode* (since the lock counts them) and *exclusive mode*. As a result of these changes, locks in Buildbot allow a number of builds (up to some fixed number) in counting mode, or they allow one build in exclusive mode. .. note:: Access modes are specified when a lock is used. That is, it is possible to have a single lock that is used by several slaves in counting mode, and several slaves in exclusive mode. In fact, this is the strength of the modes: accessing a lock in exclusive mode will prevent all counting-mode accesses. Count ~~~~~ Often, not all slaves are equal. To allow for this situation, Buildbot allows to have a separate upper limit on the count for each slave. In this way, you can have at most 3 concurrent builds at a fast slave, 2 at a slightly older slave, and 1 at all other slaves. Scope ~~~~~ The final thing you can specify when you introduce a new lock is its scope. Some constraints are global -- they must be enforced over all slaves. Other constraints are local to each slave. A *master lock* is used for the global constraints. You can ensure for example that at most one build (of all builds running at all slaves) accesses the data base server. With a *slave lock* you can add a limit local to each slave. With such a lock, you can for example enforce an upper limit to the number of active builds at a slave, like above. Examples ~~~~~~~~ Time for a few examples. Below a master lock is defined to protect a data base, and a slave lock is created to limit the number of builds at each slave. :: from buildbot import locks db_lock = locks.MasterLock("database") build_lock = locks.SlaveLock("slave_builds", maxCount = 1, maxCountForSlave = { 'fast': 3, 'new': 2 }) After importing locks from buildbot, :data:`db_lock` is defined to be a master lock. The ``database`` string is used for uniquely identifying the lock. At the next line, a slave lock called :data:`build_lock` is created. It is identified by the ``slave_builds`` string. Since the requirements of the lock are a bit more complicated, two optional arguments are also specified. The ``maxCount`` parameter sets the default limit for builds in counting mode to ``1``. For the slave called ``'fast'`` however, we want to have at most three builds, and for the slave called ``'new'`` the upper limit is two builds running at the same time. The next step is accessing the locks in builds. Buildbot allows a lock to be used during an entire build (from beginning to end), or only during a single build step. In the latter case, the lock is claimed for use just before the step starts, and released again when the step ends. To prevent deadlocks, [#]_ it is not possible to claim or release locks at other times. To use locks, you add them with a ``locks`` argument to a build or a step. Each use of a lock is either in counting mode (that is, possibly shared with other builds) or in exclusive mode, and this is indicated with the syntax ``lock.access(mode)``, where :data:`mode` is one of ``"counting"`` or ``"exclusive"``. A build or build step proceeds only when it has acquired all locks. If a build or step needs a lot of locks, it may be starved [#]_ by other builds that need fewer locks. To illustrate use of locks, a few examples. :: from buildbot import locks from buildbot.steps import source, shell from buildbot.process import factory db_lock = locks.MasterLock("database") build_lock = locks.SlaveLock("slave_builds", maxCount = 1, maxCountForSlave = { 'fast': 3, 'new': 2 }) f = factory.BuildFactory() f.addStep(source.SVN(svnurl="http://example.org/svn/Trunk")) f.addStep(shell.ShellCommand(command="make all")) f.addStep(shell.ShellCommand(command="make test", locks=[db_lock.access('exclusive')])) b1 = {'name': 'full1', 'slavename': 'fast', 'builddir': 'f1', 'factory': f, 'locks': [build_lock.access('counting')] } b2 = {'name': 'full2', 'slavename': 'new', 'builddir': 'f2', 'factory': f, 'locks': [build_lock.access('counting')] } b3 = {'name': 'full3', 'slavename': 'old', 'builddir': 'f3', 'factory': f, 'locks': [build_lock.access('counting')] } b4 = {'name': 'full4', 'slavename': 'other', 'builddir': 'f4', 'factory': f, 'locks': [build_lock.access('counting')] } c['builders'] = [b1, b2, b3, b4] Here we have four slaves :data:`b1`, :data:`b2`, :data:`b3`, and :data:`b4`. Each slave performs the same checkout, make, and test build step sequence. We want to enforce that at most one test step is executed between all slaves due to restrictions with the data base server. This is done by adding the ``locks=`` parameter with the third step. It takes a list of locks with their access mode. In this case only the :data:`db_lock` is needed. The exclusive access mode is used to ensure there is at most one slave that executes the test step. In addition to exclusive accessing the data base, we also want slaves to stay responsive even under the load of a large number of builds being triggered. For this purpose, the slave lock called :data:`build_lock` is defined. Since the restraint holds for entire builds, the lock is specified in the builder with ``'locks': [build_lock.access('counting')]``. Note that you will occasionally see ``lock.access(mode)`` written as ``LockAccess(lock, mode)``. The two are equivalent, but the former is preferred. .. [#] See http://en.wikipedia.org/wiki/Read/write_lock_pattern for more information. .. [#] Deadlock is the situation where two or more slaves each hold a lock in exclusive mode, and in addition want to claim the lock held by the other slave exclusively as well. Since locks allow at most one exclusive user, both slaves will wait forever. .. [#] Starving is the situation that only a few locks are available, and they are immediately grabbed by another build. As a result, it may take a long time before all locks needed by the starved build are free at the same time. buildbot-0.8.8/docs/manual/cfg-intro.rst000066400000000000000000000220671222546025000201670ustar00rootroot00000000000000Configuring Buildbot ==================== The buildbot's behavior is defined by the *config file*, which normally lives in the :file:`master.cfg` file in the buildmaster's base directory (but this can be changed with an option to the :command:`buildbot create-master` command). This file completely specifies which :class:`Builder`\s are to be run, which slaves they should use, how :class:`Change`\s should be tracked, and where the status information is to be sent. The buildmaster's :file:`buildbot.tac` file names the base directory; everything else comes from the config file. A sample config file was installed for you when you created the buildmaster, but you will need to edit it before your buildbot will do anything useful. This chapter gives an overview of the format of this file and the various sections in it. You will need to read the later chapters to understand how to fill in each section properly. .. _Config-File-Format: Config File Format ------------------ The config file is, fundamentally, just a piece of Python code which defines a dictionary named ``BuildmasterConfig``, with a number of keys that are treated specially. You don't need to know Python to do basic configuration, though, you can just copy the syntax of the sample file. If you *are* comfortable writing Python code, however, you can use all the power of a full programming language to achieve more complicated configurations. .. index: BuildMaster Config The ``BuildmasterConfig`` name is the only one which matters: all other names defined during the execution of the file are discarded. When parsing the config file, the Buildmaster generally compares the old configuration with the new one and performs the minimum set of actions necessary to bring the buildbot up to date: :class:`Builder`\s which are not changed are left untouched, and :class:`Builder`\s which are modified get to keep their old event history. The beginning of the :file:`master.cfg` file typically starts with something like:: BuildmasterConfig = c = {} Therefore a config key like :bb:cfg:`change_source` will usually appear in :file:`master.cfg` as ``c['change_source']``. See :bb:index:`cfg` for a full list of ``BuildMasterConfig`` keys. Basic Python Syntax ~~~~~~~~~~~~~~~~~~~ The master configuration file is interpreted as Python, allowing the full flexibility of the language. For the configurations described in this section, a detailed knowledge of Python is not required, but the basic syntax is easily described. Python comments start with a hash character ``#``, tuples are defined with ``(parenthesis, pairs)``, and lists (arrays) are defined with ``[square, brackets]``. Tuples and lists are mostly interchangeable. Dictionaries (data structures which map *keys* to *values*) are defined with curly braces: ``{'key1': value1, 'key2': value2}``. Function calls (and object instantiation) can use named parameters, like ``w = html.Waterfall(http_port=8010)``. The config file starts with a series of ``import`` statements, which make various kinds of :class:`Step`\s and :class:`Status` targets available for later use. The main ``BuildmasterConfig`` dictionary is created, then it is populated with a variety of keys, described section-by-section in subsequent chapters. .. _Predefined-Config-File-Symbols: Predefined Config File Symbols ------------------------------ The following symbols are automatically available for use in the configuration file. ``basedir`` the base directory for the buildmaster. This string has not been expanded, so it may start with a tilde. It needs to be expanded before use. The config file is located in :: os.path.expanduser(os.path.join(basedir, 'master.cfg')) ``__file__`` the absolute path of the config file. The config file's directory is located in ``os.path.dirname(__file__)``. .. _Testing-the-Config-File: Testing the Config File ----------------------- To verify that the config file is well-formed and contains no deprecated or invalid elements, use the ``checkconfig`` command, passing it either a master directory or a config file. .. code-block:: bash % buildbot checkconfig master.cfg Config file is good! # or % buildbot checkconfig /tmp/masterdir Config file is good! If the config file has deprecated features (perhaps because you've upgraded the buildmaster and need to update the config file to match), they will be announced by checkconfig. In this case, the config file will work, but you should really remove the deprecated items and use the recommended replacements instead: .. code-block:: none % buildbot checkconfig master.cfg /usr/lib/python2.4/site-packages/buildbot/master.py:559: DeprecationWarning: c['sources'] is deprecated as of 0.7.6 and will be removed by 0.8.0 . Please use c['change_source'] instead. Config file is good! If you have errors in your configuration file, checkconfig will let you know: .. code-block:: none % buildbot checkconfig master.cfg Configuration Errors: c['slaves'] must be a list of BuildSlave instances no slaves are configured builder 'smoketest' uses unknown slaves 'linux-002' If the config file is simply broken, that will be caught too: .. code-block:: none % buildbot checkconfig master.cfg error while parsing config file: Traceback (most recent call last): File "/home/buildbot/master/bin/buildbot", line 4, in runner.run() File "/home/buildbot/master/buildbot/scripts/runner.py", line 1358, in run if not doCheckConfig(so): File "/home/buildbot/master/buildbot/scripts/runner.py", line 1079, in doCheckConfig return cl.load(quiet=quiet) File "/home/buildbot/master/buildbot/scripts/checkconfig.py", line 29, in load self.basedir, self.configFileName) --- --- File "/home/buildbot/master/buildbot/config.py", line 147, in loadConfig exec f in localDict exceptions.SyntaxError: invalid syntax (master.cfg, line 52) Configuration Errors: error while parsing config file: invalid syntax (master.cfg, line 52) (traceback in logfile) Loading the Config File ----------------------- The config file is only read at specific points in time. It is first read when the buildmaster is launched. .. note: If the configuration is invalid, the master will display the errors in the console output, but will not exit. Reloading the Config File (reconfig) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you are on the system hosting the buildmaster, you can send a ``SIGHUP`` signal to it: the :command:`buildbot` tool has a shortcut for this: .. code-block:: none buildbot reconfig BASEDIR This command will show you all of the lines from :file:`twistd.log` that relate to the reconfiguration. If there are any problems during the config-file reload, they will be displayed in these lines. When reloading the config file, the buildmaster will endeavor to change as little as possible about the running system. For example, although old status targets may be shut down and new ones started up, any status targets that were not changed since the last time the config file was read will be left running and untouched. Likewise any :class:`Builder`\s which have not been changed will be left running. If a :class:`Builder` is modified (say, the build process is changed) while a :class:`Build` is currently running, that :class:`Build` will keep running with the old process until it completes. Any previously queued :class:`Build`\s (or :class:`Build`\s which get queued after the reconfig) will use the new process. .. warning:: Buildbot's reconfiguration system is fragile for a few difficult-to-fix reasons: * Any modules imported by the configuration file are not automatically reloaded. Python modules such as http://pypi.python.org/pypi/lazy-reload may help here, but reloading modules is fraught with subtleties and difficult-to-decipher failure cases. * During the reconfiguration, active internal objects are divorced from the service hierarchy, leading to tracebacks in the web interface and other components. These are ordinarily transient, but with HTTP connection caching (either by the browser or an intervening proxy) they can last for a long time. * If the new configuration file is invalid, it is possible for Buildbot's internal state to be corrupted, leading to undefined results. When this occurs, it is best to restart the master. * For more advanced configurations, it is impossible for Buildbot to tell if the configuration for a :class:`Builder` or :class:`Scheduler` has changed, and thus the :class:`Builder` or :class:`Scheduler` will always be reloaded. This occurs most commonly when a callable is passed as a configuration parameter. The bbproto project (at https://github.com/dabrahams/bbproto) may help to construct large (multi-file) configurations which can be effectively reloaded and reconfigured. Reconfig by Debug Client ~~~~~~~~~~~~~~~~~~~~~~~~ The :bb:cmdline:`debug tool ` (:samp:`buildbot debugclient --master {HOST}:{PORT}`) has a :guilabel:`Reload .cfg` button which will also trigger a reload. buildbot-0.8.8/docs/manual/cfg-properties.rst000066400000000000000000000355371222546025000212360ustar00rootroot00000000000000.. index:: Properties .. _Properties: Properties ========== Build properties are a generalized way to provide configuration information to build steps; see :ref:`Build-Properties` for the conceptual overview of properties. Some build properties come from external sources and are set before the build begins; others are set during the build, and available for later steps. The sources for properties are: * :bb:cfg:`global configuration ` -- These properties apply to all builds. * :ref:`schedulers ` -- A scheduler can specify properties that become available to all builds it starts. * :ref:`changes ` -- A change can have properties attached to it, supplying extra information gathered by the change source. This is most commonly used with the :bb:cmdline:`sendchange` command. * :bb:status:`forced builds ` -- The "Force Build" form allows users to specify properties * :bb:cfg:`buildslaves ` -- A buildslave can pass properties on to the builds it performs. * :ref:`builds ` -- A build automatically sets a number of properties on itself. * :bb:cfg:`builders ` -- A builder can set properties on all the builds it runs. * :ref:`steps ` -- The steps of a build can set properties that are available to subsequent steps. In particular, source steps set the `got_revision` property. If the same property is supplied in multiple places, the final appearance takes precedence. For example, a property set in a builder configuration will override one supplied by a scheduler. Properties are stored internally in JSON format, so they are limited to basic types of data: numbers, strings, lists, and dictionaries. .. index:: single: Properties; Common Properties .. _Common-Build-Properties: Common Build Properties ----------------------- The following build properties are set when the build is started, and are available to all steps. .. index:: single: Properties; got_revision ``got_revision`` This property is set when a :class:`Source` step checks out the source tree, and provides the revision that was actually obtained from the VC system. In general this should be the same as ``revision``, except for non-absolute sourcestamps, where ``got_revision`` indicates what revision was current when the checkout was performed. This can be used to rebuild the same source code later. .. note:: For some VC systems (Darcs in particular), the revision is a large string containing newlines, and is not suitable for interpolation into a filename. For multi-codebase builds (where codebase is not the default `''`), this property is a dictionary, keyed by codebase. .. index:: single: Properties; buildername ``buildername`` This is a string that indicates which :class:`Builder` the build was a part of. The combination of buildername and buildnumber uniquely identify a build. .. index:: single: Properties; buildnumber ``buildnumber`` Each build gets a number, scoped to the :class:`Builder` (so the first build performed on any given :class:`Builder` will have a build number of 0). This integer property contains the build's number. .. index:: single: Properties; slavename ``slavename`` This is a string which identifies which buildslave the build is running on. .. index:: single: Properties; scheduler ``scheduler`` If the build was started from a scheduler, then this property will contain the name of that scheduler. ``workdir`` The absolute path of the base working directory on the slave, of the current builder. .. index:: single: Properties; workdir For single codebase builds, where the codebase is `''`, the following :ref:`Source-Stamp-Attributes` are also available as properties: ``branch``, ``revision``, ``repository``, and ``project`` . .. _Source-Stamp-Attributes: Source Stamp Attributes ----------------------- .. index:: single: Properties; branch ``branch`` ``revision`` ``repository`` ``project`` ``codebase`` For details of these attributes see :doc:`/manual/concepts`. ``changes`` This attribute is a list of dictionaries reperesnting the changes that make up this sourcestamp. ``has_patch`` ``patch_level`` ``patch_body`` ``patch_subdir`` ``patch_author`` ``patch_comment`` These attributes are set if the source stamp was created by a :ref:`try scheduler`. Using Properties in Steps ------------------------- For the most part, properties are used to alter the behavior of build steps during a build. This is done by annotating the step definition in ``master.cfg`` with placeholders. When the step is executed, these placeholders will be replaced using the current values of the build properties. .. note:: Properties are defined while a build is in progress; their values are not available when the configuration file is parsed. This can sometimes confuse newcomers to Buildbot! In particular, the following is a common error:: if Property('release_train') == 'alpha': f.addStep(...) This does not work because the value of the property is not available when the ``if`` statement is executed. However, Python will not detect this as an error - you will just never see the step added to the factory. You can use build properties in most step parameters. Please file bugs for any parameters which do not accept properties. .. index:: single: Properties; Property .. _Property: Property ++++++++ The simplest form of annotation is to wrap the property name with :class:`Property`:: from buildbot.steps.shell import ShellCommand from buildbot.process.properties import Property f.addStep(ShellCommand(command=[ 'echo', 'buildername:', Property('buildername') ])) You can specify a default value by passing a ``default`` keyword argument:: f.addStep(ShellCommand(command=[ 'echo', 'warnings:', Property('warnings', default='none') ])) The default value is used when the property doesn't exist, or when the value is something Python regards as ``False``. The ``defaultWhenFalse`` argument can be set to ``False`` to force buildbot to use the default argument only if the parameter is not set:: f.addStep(ShellCommand(command=[ 'echo', 'warnings:', Property('warnings', default='none', defaultWhenFalse=False) ])) The default value can reference other properties, e.g., :: command=Property('command', default=Property('default-command')) .. index:: single: Properties; Interpolate .. _Interpolate: Interpolate +++++++++++ :class:`Property` can only be used to replace an entire argument: in the example above, it replaces an argument to ``echo``. Often, properties need to be interpolated into strings, instead. The tool for that job is :ref:`Interpolate`. The more common pattern is to use Python dictionary-style string interpolation by using the ``%(prop:)s`` syntax. In this form, the property name goes in the parentheses, as above. A common mistake is to omit the trailing "s", leading to a rather obscure error from Python ("ValueError: unsupported format character"). :: from buildbot.steps.shell import ShellCommand from buildbot.process.properties import Interpolate f.addStep(ShellCommand(command=[ 'make', Interpolate('REVISION=%(prop:got_revision)s'), 'dist' ])) This example will result in a ``make`` command with an argument like ``REVISION=12098``. .. _Interpolate-DictStyle: The syntax of dictionary-style interpolation is a selector, followed by a colon, followed by a selector specific key, optionally followed by a colon and a string indicating how to interpret the value produced by the key. The following selectors are supported. ``prop`` The key is the name of a property. ``src`` The key is a codebase and source stamp attribute, separated by a colon. ``kw`` The key refers to a keyword argument passed to ``Interpolate``. The following ways of interpreting the value are available. ``-replacement`` If the key exists, substitute its value; otherwise, substitute ``replacement``. ``replacement`` may be empty (``%(prop:propname:-)s``). This is the default. ``~replacement`` Like ``-replacement``, but only substitutes the value of the key if it is something Python regards as ``True``. Python considers ``None``, 0, empty lists, and the empty string to be false, so such values will be replaced by ``replacement``. ``+replacement`` If the key exists, substitute ``replacement``; otherwise, substitute an empty string. ``?|sub_if_exists|sub_if_missing`` ``#?|sub_if_true|sub_if_false`` Ternary substitution, depending on either the key being present (with ``?``, similar to ``+``) or being ``True`` (with ``#?``, like ``~``). Notice that there is a pipe immediately following the question mark *and* between the two substitution alternatives. The character that follows the question mark is used as the delimiter between the two alternatives. In the above examples, it is a pipe, but any character other than ``(`` can be used. Although these are similar to shell substitutions, no other substitutions are currently supported. Example :: from buildbot.steps.shell import ShellCommand from buildbot.process.properties import Interpolate f.addStep(ShellCommand(command=[ 'make', Interpolate('REVISION=%(prop:got_revision:-%(src::revision:-unknown)s)s') 'dist' ])) In addition, ``Interpolate`` supports using positional string interpolation. Here, ``%s`` is used as a placeholder, and the substitutions (which may themselves be placeholders), are given as subsequent arguments:: .. note: Like Python, you can use either positional interpolation *or* dictionary-style interpolation, not both. Thus you cannot use a string like ``Interpolate("foo-%(src::revision)s-%s", "branch")``. .. index:: single: Properties; Renderer .. _Renderer: Renderer ++++++++ While Interpolate can handle many simple cases, and even some common conditionals, more complex cases are best handled with Python code. The ``renderer`` decorator creates a renderable object that will be replaced with the result of the function, called when the step it's passed to begins. The function receives an :class:`~buildbot.interfaces.IProperties` object, which it can use to examine the values of any and all properties. For example:: @properties.renderer def makeCommand(props): command = [ 'make' ] cpus = props.getProperty('CPUs') if cpus: command += [ '-j', str(cpus+1) ] else: command += [ '-j', '2' ] command += [ 'all' ] return command f.addStep(ShellCommand(command=makeCommand)) You can think of ``renderer`` as saying "call this function when the step starts". .. index:: single: Properties; WithProperties .. _WithProperties: WithProperties ++++++++++++++ .. warning:: This placeholder is deprecated. It is an older version of :ref:`Interpolate`. It exists for compatibility with older configs. The simplest use of this class is with positional string interpolation. Here, ``%s`` is used as a placeholder, and property names are given as subsequent arguments:: from buildbot.steps.shell import ShellCommand from buildbot.process.properties import WithProperties f.addStep(ShellCommand( command=["tar", "czf", WithProperties("build-%s-%s.tar.gz", "branch", "revision"), "source"])) If this :class:`BuildStep` were used in a tree obtained from Git, it would create a tarball with a name like :file:`build-master-a7d3a333db708e786edb34b6af646edd8d4d3ad9.tar.gz`. .. index:: unsupported format character The more common pattern is to use Python dictionary-style string interpolation by using the ``%(propname)s`` syntax. In this form, the property name goes in the parentheses, as above. A common mistake is to omit the trailing "s", leading to a rather obscure error from Python ("ValueError: unsupported format character"). :: from buildbot.steps.shell import ShellCommand from buildbot.process.properties import WithProperties f.addStep(ShellCommand(command=[ 'make', WithProperties('REVISION=%(got_revision)s'), 'dist' ])) This example will result in a ``make`` command with an argument like ``REVISION=12098``. .. _WithProperties-DictStyle: The dictionary-style interpolation supports a number of more advanced syntaxes in the parentheses. ``propname:-replacement`` If ``propname`` exists, substitute its value; otherwise, substitute ``replacement``. ``replacement`` may be empty (``%(propname:-)s``) ``propname:~replacement`` Like ``propname:-replacement``, but only substitutes the value of property ``propname`` if it is something Python regards as ``True``. Python considers ``None``, 0, empty lists, and the empty string to be false, so such values will be replaced by ``replacement``. ``propname:+replacement`` If ``propname`` exists, substitute ``replacement``; otherwise, substitute an empty string. Although these are similar to shell substitutions, no other substitutions are currently supported, and ``replacement`` in the above cannot contain more substitutions. Note: like Python, you can use either positional interpolation *or* dictionary-style interpolation, not both. Thus you cannot use a string like ``WithProperties("foo-%(revision)s-%s", "branch")``. Custom Renderables ++++++++++++++++++ If the options described above are not sufficient, more complex substitutions can be achieved by writing custom renderables. Renderables are objects providing the :class:`~buildbot.interfaces.IRenderable` interface. That interface is simple - objects must provide a `getRenderingFor` method. The method should take one argument - an :class:`~buildbot.interfaces.IProperties` provider - and should return a string or a deferred firing with a string. Pass instances of the class anywhere other renderables are accepted. For example:: class DetermineFoo(object): implements(IRenderable) def getRenderingFor(self, props) if props.hasProperty('bar'): return props['bar'] elif props.hasProperty('baz'): return props['baz'] return 'qux' ShellCommand(command=['echo', DetermineFoo()]) or, more practically, :: class Now(object): implements(IRenderable) def getRenderingFor(self, props) return time.clock() ShellCommand(command=['make', Interpolate('TIME=%(kw:now)', now=Now())]) This is equivalent to:: @renderer def now(props): return time.clock() ShellCommand(command=['make', Interpolate('TIME=%(kw:now)', now=now)]) Note that a custom renderable must be instantiated (and its constructor can take whatever arguments you'd like), whereas a function decorated with :func:`renderer` can be used directly. buildbot-0.8.8/docs/manual/cfg-schedulers.rst000066400000000000000000001270341222546025000211750ustar00rootroot00000000000000.. -*- rst -*- .. _Schedulers: Schedulers ---------- Schedulers are responsible for initiating builds on builders. Some schedulers listen for changes from ChangeSources and generate build sets in response to these changes. Others generate build sets without changes, based on other events in the buildmaster. .. _Configuring-Schedulers: Configuring Schedulers ~~~~~~~~~~~~~~~~~~~~~~ .. bb:cfg:: schedulers The :bb:cfg:`schedulers` configuration parameter gives a list of Scheduler instances, each of which causes builds to be started on a particular set of Builders. The two basic Scheduler classes you are likely to start with are :class:`SingleBranchScheduler` and :class:`Periodic`, but you can write a customized subclass to implement more complicated build scheduling. Scheduler arguments should always be specified by name (as keyword arguments), to allow for future expansion:: sched = SingleBranchScheduler(name="quick", builderNames=['lin', 'win']) There are several common arguments for schedulers, although not all are available with all schedulers. ``name`` Each Scheduler must have a unique name. This is used in status displays, and is also available in the build property ``scheduler``. ``builderNames`` This is the set of builders which this scheduler should trigger, specified as a list of names (strings). .. index:: Properties; from scheduler ``properties`` This is a dictionary specifying properties that will be transmitted to all builds started by this scheduler. The ``owner`` property may be of particular interest, as its contents (as a list) will be added to the list of "interested users" (:ref:`Doing-Things-With-Users`) for each triggered build. For example .. code-block:: python sched = Scheduler(..., properties = { 'owner' : [ 'zorro@company.com', 'silver@company.com' ] }) ``fileIsImportant`` A callable which takes one argument, a Change instance, and returns ``True`` if the change is worth building, and ``False`` if it is not. Unimportant Changes are accumulated until the build is triggered by an important change. The default value of None means that all Changes are important. ``change_filter`` The change filter that will determine which changes are recognized by this scheduler; :ref:`Change-Filters`. Note that this is different from ``fileIsImportant``: if the change filter filters out a Change, then it is completely ignored by the scheduler. If a Change is allowed by the change filter, but is deemed unimportant, then it will not cause builds to start, but will be remembered and shown in status displays. ``codebases`` When the scheduler processes data from more than 1 repository at the same time then a corresponding codebase definition should be passed for each repository. A codebase definition is a dictionary with one or more of the following keys: repository, branch, revision. The codebase definitions have also to be passed as dictionary. .. code-block:: python codebases = {'codebase1': {'repository':'....', 'branch':'default', 'revision': None}, 'codebase2': {'repository':'....'} } .. IMPORTANT:: ``codebases`` behaves also like a change_filter on codebase. The scheduler will only process changes when their codebases are found in ``codebases``. By default ``codebases`` is set to ``{'':{}}`` which means that only changes with codebase '' (default value for codebase) will be accepted by the scheduler. Buildsteps can have a reference to one of the codebases. The step will only get information (revision, branch etc.) that is related to that codebase. When a scheduler is triggered by new changes, these changes (having a codebase) will be incorporated by the new build. The buildsteps referencing to the codebases that have changes get information about those changes. The buildstep that references to a codebase that does not have changes in the build get the information from the codebases definition as configured in the scheduler. ``onlyImportant`` A boolean that, when ``True``, only adds important changes to the buildset as specified in the ``fileIsImportant`` callable. This means that unimportant changes are ignored the same way a ``change_filter`` filters changes. This defaults to ``False`` and only applies when ``fileIsImportant`` is given. The remaining subsections represent a catalog of the available Scheduler types. All these Schedulers are defined in modules under :mod:`buildbot.schedulers`, and the docstrings there are the best source of documentation on the arguments taken by each one. .. _Change-Filters: Change Filters ~~~~~~~~~~~~~~ Several schedulers perform filtering on an incoming set of changes. The filter can most generically be specified as a :class:`ChangeFilter`. Set up a :class:`ChangeFilter` like this:: from buildbot.changes.filter import ChangeFilter my_filter = ChangeFilter( project_re="^baseproduct/.*", branch="devel") and then add it to a scheduler with the ``change_filter`` parameter:: sch = SomeSchedulerClass(..., change_filter=my_filter) There are five attributes of changes on which you can filter: ``project`` the project string, as defined by the ChangeSource. ``repository`` the repository in which this change occurred. ``branch`` the branch on which this change occurred. Note that 'trunk' or 'master' is often denoted by ``None``. ``category`` the category, again as defined by the ChangeSource. ``codebase`` the change's codebase. For each attribute, the filter can look for a single, specific value:: my_filter = ChangeFilter(project = 'myproject') or accept any of a set of values:: my_filter = ChangeFilter(project = ['myproject', 'jimsproject']) or apply a regular expression, using the attribute name with a "``_re``" suffix:: my_filter = ChangeFilter(category_re = '.*deve.*') # or, to use regular expression flags: import re my_filter = ChangeFilter(category_re = re.compile('.*deve.*', re.I)) For anything more complicated, define a Python function to recognize the strings you want:: def my_branch_fn(branch): return branch in branches_to_build and branch not in branches_to_ignore my_filter = ChangeFilter(branch_fn = my_branch_fn) The special argument ``filter_fn`` can be used to specify a function that is given the entire Change object, and returns a boolean. The entire set of allowed arguments, then, is +------------+---------------+---------------+ | project | project_re | project_fn | +------------+---------------+---------------+ | repository | repository_re | repository_fn | +------------+---------------+---------------+ | branch | branch_re | branch_fn | +------------+---------------+---------------+ | category | category_re | category_fn | +------------+---------------+---------------+ | codebase | codebase_re | codebase_fn | +------------+---------------+---------------+ | filter_fn | +--------------------------------------------+ A Change passes the filter only if *all* arguments are satisfied. If no filter object is given to a scheduler, then all changes will be built (subject to any other restrictions the scheduler enforces). .. bb:sched:: SingleBranchScheduler .. bb:sched:: Scheduler .. _Scheduler-SingleBranchScheduler: SingleBranchScheduler ~~~~~~~~~~~~~~~~~~~~~ This is the original and still most popular scheduler class. It follows exactly one branch, and starts a configurable tree-stable-timer after each change on that branch. When the timer expires, it starts a build on some set of Builders. The Scheduler accepts a :meth:`fileIsImportant` function which can be used to ignore some Changes if they do not affect any *important* files. If ``treeStableTimer`` is not set, then this scheduler starts a build for every Change that matches its ``change_filter`` and statsfies :meth:`fileIsImportant`. If ``treeStableTimer`` is set, then a build is triggered for each set of Changes which arrive within the configured time, and match the filters. .. note:: The behavior of this scheduler is undefined, if ``treeStableTimer`` is set, and changes from multiple branches, repositories or codebases are accepted by the filter. .. note:: The ``codebases`` argument will filter out codebases not specified there, but *won't* filter based on the branches specified there. The arguments to this scheduler are: ``name`` ``builderNames`` ``properties`` ``fileIsImportant`` ``change_filter`` ``onlyImportant`` See :ref:`Configuring-Schedulers`. ``treeStableTimer`` The scheduler will wait for this many seconds before starting the build. If new changes are made during this interval, the timer will be restarted, so really the build will be started after a change and then after this many seconds of inactivity. If ``treeStableTimer`` is ``None``, then a separate build is started immediately for each Change. ``fileIsImportant`` A callable which takes one argument, a Change instance, and returns ``True`` if the change is worth building, and ``False`` if it is not. Unimportant Changes are accumulated until the build is triggered by an important change. The default value of None means that all Changes are important. ``categories`` (deprecated; use change_filter) A list of categories of changes that this scheduler will respond to. If this is specified, then any non-matching changes are ignored. ``branch`` (deprecated; use change_filter) The scheduler will pay attention to this branch, ignoring Changes that occur on other branches. Setting ``branch`` equal to the special value of ``None`` means it should only pay attention to the default branch. .. note:: ``None`` is a keyword, not a string, so write ``None`` and not ``"None"``. Example:: from buildbot.schedulers.basic import SingleBranchScheduler from buildbot.changes import filter quick = SingleBranchScheduler(name="quick", change_filter=filter.ChangeFilter(branch='master'), treeStableTimer=60, builderNames=["quick-linux", "quick-netbsd"]) full = SingleBranchScheduler(name="full", change_filter=filter.ChangeFilter(branch='master'), treeStableTimer=5*60, builderNames=["full-linux", "full-netbsd", "full-OSX"]) c['schedulers'] = [quick, full] In this example, the two *quick* builders are triggered 60 seconds after the tree has been changed. The *full* builds do not run quite so quickly (they wait 5 minutes), so hopefully if the quick builds fail due to a missing file or really simple typo, the developer can discover and fix the problem before the full builds are started. Both Schedulers only pay attention to the default branch: any changes on other branches are ignored by these schedulers. Each scheduler triggers a different set of Builders, referenced by name. The old names for this scheduler, ``buildbot.scheduler.Scheduler`` and ``buildbot.schedulers.basic.Scheduler``, are deprecated in favor of the more accurate name ``buildbot.schedulers.basic.SingleBranchScheduler``. .. bb:sched:: AnyBranchScheduler .. _AnyBranchScheduler: AnyBranchScheduler ~~~~~~~~~~~~~~~~~~ This scheduler uses a tree-stable-timer like the default one, but uses a separate timer for each branch. If ``treeStableTimer`` is not set, then this scheduler is indistinguishable from bb:sched:``SingleBranchScheduler``. If ``treeStableTimer`` is set, then a build is triggered for each set of Changes which arrive within the configured time, and match the filters. The arguments to this scheduler are: ``name`` ``builderNames`` ``properties`` ``fileIsImportant`` ``change_filter`` ``onlyImportant`` See :ref:`Configuring-Schedulers`. ``treeStableTimer`` The scheduler will wait for this many seconds before starting the build. If new changes are made *on the same branch* during this interval, the timer will be restarted. ``branches`` (deprecated; use change_filter) Changes on branches not specified on this list will be ignored. ``categories`` (deprecated; use change_filter) A list of categories of changes that this scheduler will respond to. If this is specified, then any non-matching changes are ignored. .. bb:sched:: Dependent .. _Dependent-Scheduler: Dependent Scheduler ~~~~~~~~~~~~~~~~~~~ It is common to wind up with one kind of build which should only be performed if the same source code was successfully handled by some other kind of build first. An example might be a packaging step: you might only want to produce .deb or RPM packages from a tree that was known to compile successfully and pass all unit tests. You could put the packaging step in the same Build as the compile and testing steps, but there might be other reasons to not do this (in particular you might have several Builders worth of compiles/tests, but only wish to do the packaging once). Another example is if you want to skip the *full* builds after a failing *quick* build of the same source code. Or, if one Build creates a product (like a compiled library) that is used by some other Builder, you'd want to make sure the consuming Build is run *after* the producing one. You can use *Dependencies* to express this relationship to the Buildbot. There is a special kind of scheduler named :class:`scheduler.Dependent` that will watch an *upstream* scheduler for builds to complete successfully (on all of its Builders). Each time that happens, the same source code (i.e. the same ``SourceStamp``) will be used to start a new set of builds, on a different set of Builders. This *downstream* scheduler doesn't pay attention to Changes at all. It only pays attention to the upstream scheduler. If the build fails on any of the Builders in the upstream set, the downstream builds will not fire. Note that, for SourceStamps generated by a ChangeSource, the ``revision`` is ``None``, meaning HEAD. If any changes are committed between the time the upstream scheduler begins its build and the time the dependent scheduler begins its build, then those changes will be included in the downstream build. See the :ref:`Triggerable-Scheduler` for a more flexible dependency mechanism that can avoid this problem. The keyword arguments to this scheduler are: ``name`` ``builderNames`` ``properties`` See :ref:`Configuring-Schedulers`. ``upstream`` The upstream scheduler to watch. Note that this is an *instance*, not the name of the scheduler. Example:: from buildbot.schedulers import basic tests = basic.SingleBranchScheduler(name="just-tests", treeStableTimer=5*60, builderNames=["full-linux", "full-netbsd", "full-OSX"]) package = basic.Dependent(name="build-package", upstream=tests, # <- no quotes! builderNames=["make-tarball", "make-deb", "make-rpm"]) c['schedulers'] = [tests, package] .. bb:sched:: Periodic .. _Periodic-Scheduler: Periodic Scheduler ~~~~~~~~~~~~~~~~~~ This simple scheduler just triggers a build every *N* seconds. The arguments to this scheduler are: ``name`` ``builderNames`` ``properties`` ``onlyImportant`` ``periodicBuildTimer`` The time, in seconds, after which to start a build. Example:: from buildbot.schedulers import timed nightly = timed.Periodic(name="daily", builderNames=["full-solaris"], periodicBuildTimer=24*60*60) c['schedulers'] = [nightly] The scheduler in this example just runs the full solaris build once per day. Note that this scheduler only lets you control the time between builds, not the absolute time-of-day of each Build, so this could easily wind up an *evening* or *every afternoon* scheduler depending upon when it was first activated. .. bb:sched:: Nightly .. _Nightly-Scheduler: Nightly Scheduler ~~~~~~~~~~~~~~~~~ This is highly configurable periodic build scheduler, which triggers a build at particular times of day, week, month, or year. The configuration syntax is very similar to the well-known ``crontab`` format, in which you provide values for minute, hour, day, and month (some of which can be wildcards), and a build is triggered whenever the current time matches the given constraints. This can run a build every night, every morning, every weekend, alternate Thursdays, on your boss's birthday, etc. Pass some subset of ``minute``, ``hour``, ``dayOfMonth``, ``month``, and ``dayOfWeek``\; each may be a single number or a list of valid values. The builds will be triggered whenever the current time matches these values. Wildcards are represented by a '*' string. All fields default to a wildcard except 'minute', so with no fields this defaults to a build every hour, on the hour. The full list of parameters is: ``name`` ``builderNames`` ``properties`` ``fileIsImportant`` ``change_filter`` ``onlyImportant`` ``codebases`` See :ref:`Configuring-Schedulers`. Note that ``fileIsImportant`` and ``change_filter`` are only relevant if ``onlyIfChanged`` is ``True``. ``onlyIfChanged`` If this is true, then builds will not be scheduled at the designated time *unless* the specified branch has seen an important change since the previous build. ``branch`` (required) The branch to build when the time comes. Remember that a value of ``None`` here means the default branch, and will not match other branches! ``minute`` The minute of the hour on which to start the build. This defaults to 0, meaning an hourly build. ``hour`` The hour of the day on which to start the build, in 24-hour notation. This defaults to \*, meaning every hour. ``dayOfMonth`` The day of the month to start a build. This defaults to ``*``, meaning every day. ``month`` The month in which to start the build, with January = 1. This defaults to \*, meaning every month. ``dayOfWeek`` The day of the week to start a build, with Monday = 0. This defaults to \*, meaning every day of the week. For example, the following master.cfg clause will cause a build to be started every night at 3:00am:: from buildbot.schedulers import timed c['schedulers'].append( timed.Nightly(name='nightly', branch='master', builderNames=['builder1', 'builder2'], hour=3, minute=0)) This scheduler will perform a build each Monday morning at 6:23am and again at 8:23am, but only if someone has committed code in the interim:: c['schedulers'].append( timed.Nightly(name='BeforeWork', branch=`default`, builderNames=['builder1'], dayOfWeek=0, hour=[6,8], minute=23, onlyIfChanged=True)) The following runs a build every two hours, using Python's :func:`range` function:: c.schedulers.append( timed.Nightly(name='every2hours', branch=None, # default branch builderNames=['builder1'], hour=range(0, 24, 2))) Finally, this example will run only on December 24th:: c['schedulers'].append( timed.Nightly(name='SleighPreflightCheck', branch=None, # default branch builderNames=['flying_circuits', 'radar'], month=12, dayOfMonth=24, hour=12, minute=0)) .. bb:sched:: Try_Jobdir .. bb:sched:: Try_Userpass .. _Try-Schedulers: Try Schedulers ~~~~~~~~~~~~~~ This scheduler allows developers to use the :command:`buildbot try` command to trigger builds of code they have not yet committed. See :bb:cmdline:`try` for complete details. Two implementations are available: :bb:sched:`Try_Jobdir` and :bb:sched:`Try_Userpass`. The former monitors a job directory, specified by the ``jobdir`` parameter, while the latter listens for PB connections on a specific ``port``, and authenticates against ``userport``. The buildmaster must have a scheduler instance in the config file's :bb:cfg:`schedulers` list to receive try requests. This lets the administrator control who may initiate these `trial` builds, which branches are eligible for trial builds, and which Builders should be used for them. The scheduler has various means to accept build requests. All of them enforce more security than the usual buildmaster ports do. Any source code being built can be used to compromise the buildslave accounts, but in general that code must be checked out from the VC repository first, so only people with commit privileges can get control of the buildslaves. The usual force-build control channels can waste buildslave time but do not allow arbitrary commands to be executed by people who don't have those commit privileges. However, the source code patch that is provided with the trial build does not have to go through the VC system first, so it is important to make sure these builds cannot be abused by a non-committer to acquire as much control over the buildslaves as a committer has. Ideally, only developers who have commit access to the VC repository would be able to start trial builds, but unfortunately the buildmaster does not, in general, have access to VC system's user list. As a result, the try scheduler requires a bit more configuration. There are currently two ways to set this up: ``jobdir`` (ssh) This approach creates a command queue directory, called the :file:`jobdir`, in the buildmaster's working directory. The buildmaster admin sets the ownership and permissions of this directory to only grant write access to the desired set of developers, all of whom must have accounts on the machine. The :command:`buildbot try` command creates a special file containing the source stamp information and drops it in the jobdir, just like a standard maildir. When the buildmaster notices the new file, it unpacks the information inside and starts the builds. The config file entries used by 'buildbot try' either specify a local queuedir (for which write and mv are used) or a remote one (using scp and ssh). The advantage of this scheme is that it is quite secure, the disadvantage is that it requires fiddling outside the buildmaster config (to set the permissions on the jobdir correctly). If the buildmaster machine happens to also house the VC repository, then it can be fairly easy to keep the VC userlist in sync with the trial-build userlist. If they are on different machines, this will be much more of a hassle. It may also involve granting developer accounts on a machine that would not otherwise require them. To implement this, the buildslave invokes :samp:`ssh -l {username} {host} buildbot tryserver {ARGS}`, passing the patch contents over stdin. The arguments must include the inlet directory and the revision information. ``user+password`` (PB) In this approach, each developer gets a username/password pair, which are all listed in the buildmaster's configuration file. When the developer runs :command:`buildbot try`, their machine connects to the buildmaster via PB and authenticates themselves using that username and password, then sends a PB command to start the trial build. The advantage of this scheme is that the entire configuration is performed inside the buildmaster's config file. The disadvantages are that it is less secure (while the `cred` authentication system does not expose the password in plaintext over the wire, it does not offer most of the other security properties that SSH does). In addition, the buildmaster admin is responsible for maintaining the username/password list, adding and deleting entries as developers come and go. For example, to set up the `jobdir` style of trial build, using a command queue directory of :file:`{MASTERDIR}/jobdir` (and assuming that all your project developers were members of the ``developers`` unix group), you would first set up that directory: .. code-block:: bash mkdir -p MASTERDIR/jobdir MASTERDIR/jobdir/new MASTERDIR/jobdir/cur MASTERDIR/jobdir/tmp chgrp developers MASTERDIR/jobdir MASTERDIR/jobdir/* chmod g+rwx,o-rwx MASTERDIR/jobdir MASTERDIR/jobdir/* and then use the following scheduler in the buildmaster's config file:: from buildbot.schedulers.trysched import Try_Jobdir s = Try_Jobdir(name="try1", builderNames=["full-linux", "full-netbsd", "full-OSX"], jobdir="jobdir") c['schedulers'] = [s] Note that you must create the jobdir before telling the buildmaster to use this configuration, otherwise you will get an error. Also remember that the buildmaster must be able to read and write to the jobdir as well. Be sure to watch the :file:`twistd.log` file (:ref:`Logfiles`) as you start using the jobdir, to make sure the buildmaster is happy with it. .. note:: Patches in the jobdir are encoded using netstrings, which place an arbitrary upper limit on patch size of 99999 bytes. If your submitted try jobs are rejected with `BadJobfile`, try increasing this limit with a snippet like this in your `master.cfg`:: from twisted.protocols.basic import NetstringReceiver NetstringReceiver.MAX_LENGTH = 1000000 To use the username/password form of authentication, create a :class:`Try_Userpass` instance instead. It takes the same ``builderNames`` argument as the :class:`Try_Jobdir` form, but accepts an additional ``port`` argument (to specify the TCP port to listen on) and a ``userpass`` list of username/password pairs to accept. Remember to use good passwords for this: the security of the buildslave accounts depends upon it:: from buildbot.schedulers.trysched import Try_Userpass s = Try_Userpass(name="try2", builderNames=["full-linux", "full-netbsd", "full-OSX"], port=8031, userpass=[("alice","pw1"), ("bob", "pw2")] ) c['schedulers'] = [s] Like most places in the buildbot, the ``port`` argument takes a `strports` specification. See :mod:`twisted.application.strports` for details. .. bb:sched:: Triggerable .. index:: Triggers .. _Triggerable-Scheduler: Triggerable Scheduler ~~~~~~~~~~~~~~~~~~~~~ The :class:`Triggerable` scheduler waits to be triggered by a Trigger step (see :ref:`Triggering-Schedulers`) in another build. That step can optionally wait for the scheduler's builds to complete. This provides two advantages over Dependent schedulers. First, the same scheduler can be triggered from multiple builds. Second, the ability to wait for a Triggerable's builds to complete provides a form of "subroutine call", where one or more builds can "call" a scheduler to perform some work for them, perhaps on other buildslaves. The Triggerable-Scheduler supports multiple codebases. The scheduler filters out all codebases from Trigger steps that are not configured in the scheduler. The parameters are just the basics: ``name`` ``builderNames`` ``properties`` ``codebases`` See :ref:`Configuring-Schedulers`. This class is only useful in conjunction with the :class:`Trigger` step. Here is a fully-worked example:: from buildbot.schedulers import basic, timed, triggerable from buildbot.process import factory from buildbot.steps import trigger checkin = basic.SingleBranchScheduler(name="checkin", branch=None, treeStableTimer=5*60, builderNames=["checkin"]) nightly = timed.Nightly(name='nightly', branch=None, builderNames=['nightly'], hour=3, minute=0) mktarball = triggerable.Triggerable(name="mktarball", builderNames=["mktarball"]) build = triggerable.Triggerable(name="build-all-platforms", builderNames=["build-all-platforms"]) test = triggerable.Triggerable(name="distributed-test", builderNames=["distributed-test"]) package = triggerable.Triggerable(name="package-all-platforms", builderNames=["package-all-platforms"]) c['schedulers'] = [mktarball, checkin, nightly, build, test, package] # on checkin, make a tarball, build it, and test it checkin_factory = factory.BuildFactory() checkin_factory.addStep(trigger.Trigger(schedulerNames=['mktarball'], waitForFinish=True)) checkin_factory.addStep(trigger.Trigger(schedulerNames=['build-all-platforms'], waitForFinish=True)) checkin_factory.addStep(trigger.Trigger(schedulerNames=['distributed-test'], waitForFinish=True)) # and every night, make a tarball, build it, and package it nightly_factory = factory.BuildFactory() nightly_factory.addStep(trigger.Trigger(schedulerNames=['mktarball'], waitForFinish=True)) nightly_factory.addStep(trigger.Trigger(schedulerNames=['build-all-platforms'], waitForFinish=True)) nightly_factory.addStep(trigger.Trigger(schedulerNames=['package-all-platforms'], waitForFinish=True)) .. bb:sched:: NightlyTriggerable NightlyTriggerable Scheduler ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. py:class:: buildbot.schedulers.timed.NightlyTriggerable The :class:`NightlyTriggerable` scheduler is a mix of the :class:`Nightly` and :class:`Triggerable` schedulers. This scheduler triggers builds at a particular time of day, week, or year, exactly as the :class:`Nightly` scheduler. However, the source stamp set that is used that provided by the last :class:`Trigger` step that targeted this scheduler. The parameters are just the basics: ``name`` ``builderNames`` ``properties`` ``codebases`` See :ref:`Configuring-Schedulers`. ``minute`` ``hour`` ``dayOfMonth`` ``month`` ``dayOfWeek`` See :bb:sched:`Nightly`. This class is only useful in conjunction with the :class:`Trigger` step. Note that ``waitForFinish`` is ignored by :class:`Trigger` steps targeting this scheduler. Here is a fully-worked example:: from buildbot.schedulers import basic, timed from buildbot.process import factory from buildbot.steps import shell, trigger checkin = basic.SingleBranchScheduler(name="checkin", branch=None, treeStableTimer=5*60, builderNames=["checkin"]) nightly = timed.NightlyTriggerable(name='nightly', builderNames=['nightly'], hour=3, minute=0) c['schedulers'] = [checkin, nightly] # on checkin, run tests checkin_factory = factory.BuildFactory() checkin_factory.addStep(shell.Test()) checkin_factory.addStep(trigger.Trigger(schedulerNames=['nightly'])) # and every night, package the latest successful build nightly_factory = factory.BuildFactory() nightly_factory.addStep(shell.ShellCommand(command=['make', 'package'])) .. bb:sched:: ForceScheduler .. index:: Forced Builds ForceScheduler Scheduler ~~~~~~~~~~~~~~~~~~~~~~~~ The :class:`ForceScheduler` scheduler is the way you can configure a force build form in the web UI. In the ``builder/`` web page, you will see one form for each ForceScheduler scheduler that was configured for this builder. This allows you to customize exactly how the build form looks, which builders have a force build form (it might not make sense to force build every builder), and who is allowed to force builds on which builders. The scheduler takes the following parameters: ``name`` ``builderNames`` See :ref:`Configuring-Schedulers`. ``reason`` A :ref:`parameter ` specifying the reason for the build. The default value is a string parameter with value "force build". ``username`` A :ref:`parameter ` specifying the project for the build. The default value is a username parameter, ``codebases`` A list of strings or :ref:`CodebaseParameter ` specifying the codebases that should be presented. The default is a single codebase with no name. ``properties`` A list of :ref:`parameters `, one for each property. These can be arbitrary parameters, where the parameter's name is taken as the property name, or ``AnyPropertyParameter``, which allows the web user to specify the property name. An example may be better than long explanation. What you need in your config file is something like:: from buildbot.schedulers.forcesched import * sch = ForceScheduler(name="force", builderNames=["my-builder"], # will generate a combo box branch=ChoiceStringParameter(name="branch", choices=["main","devel"], default="main"), # will generate a text input reason=StringParameter(name="reason",label="reason:
    ", required=True, size=80), # will generate nothing in the form, but revision, repository, # and project are needed by buildbot scheduling system so we # need to pass a value ("") revision=FixedParameter(name="revision", default=""), repository=FixedParameter(name="repository", default=""), project=FixedParameter(name="project", default=""), # in case you dont require authentication this will display # input for user to type his name username=UserNameParameter(label="your name:
    ", size=80), # A completely customized property list. The name of the # property is the name of the parameter properties=[ BooleanParameter(name="force_build_clean", label="force a make clean", default=False), StringParameter(name="pull_url", label="optionally give a public Git pull url:
    ", default="", size=80) ] ) c['schedulers'].append(sch) Authorization ............. The force scheduler uses the web status's :ref:`authorization ` framework to determine which user has the right to force which build. Here is an example of code on how you can define which user has which right:: user_mapping = { re.compile("project1-builder"): ["project1-maintainer", "john"] , re.compile("project2-builder"): ["project2-maintainer", "jack"], re.compile(".*"): ["root"] } def force_auth(user, status): global user_mapping for r,users in user_mapping.items(): if r.match(status.name): if user in users: return True return False # use authz_cfg in your WebStatus setup authz_cfg=authz.Authz( auth=my_auth, forceBuild = force_auth, ) .. _ForceScheduler-Parameters: ForceSched Parameters ..................... Most of the arguments to ``ForceScheduler`` are "parameters". Several classes of parameters are available, each describing a different kind of input from a force-build form. All parameter types have a few common arguments: ``name`` (required) The name of the parameter. For properties, this will correspond to the name of the property that your parameter will set. The name is also used internally as the identifier for in the HTML form. ``label`` (optional; default is same as name) The label of the parameter. This is what is displayed to the user. HTML is permitted here. ``default`` (optional; default: "") The default value for the parameter, that is used if there is no user input. ``required`` (optional; default: False) If this is true, then an error will be shown to user if there is no input in this field The parameter types are: FixedParameter ############## :: FixedParameter(name="branch", default="trunk"), This parameter type will not be shown on the web form, and always generate a property with its default value. StringParameter ############### :: StringParameter(name="pull_url", label="optionally give a public Git pull url:
    ", default="", size=80) This parameter type will show a single-line text-entry box, and allow the user to enter an arbitrary string. It adds the following arguments: ``regex`` (optional) a string that will be compiled as a regex, and used to validate the input of this parameter ``size`` (optional; default: 10) The width of the input field (in characters) TextParameter ############# :: StringParameter(name="comments", label="comments to be displayed to the user of the built binary", default="This is a development build", cols=60, rows=5) This parameter type is similar to StringParameter, except that it is represented in the HTML form as a textarea, allowing multi-line input. It adds the StringParameter arguments, this type allows: ``cols`` (optional; default: 80) The number of columns the textarea will have ``rows`` (optional; default: 20) The number of rows the textarea will have This class could be subclassed in order to have more customization e.g. * developer could send a list of Git branches to pull from * developer could send a list of gerrit changes to cherry-pick, * developer could send a shell script to amend the build. beware of security issues anyway. IntParameter ############ :: IntParameter(name="debug_level", label="debug level (1-10)", default=2) This parameter type accepts an integer value using a text-entry box. BooleanParameter ################ :: BooleanParameter(name="force_build_clean", label="force a make clean", default=False) This type represents a boolean value. It will be presented as a checkbox. UserNameParameter ################# :: UserNameParameter(label="your name:
    ", size=80) This parameter type accepts a username. If authentication is active, it will use the authenticated user instead of displaying a text-entry box. ``size`` (optional; default: 10) The width of the input field (in characters) ``need_email`` (optional; default True) If true, require a full email address rather than arbitrary text. .. bb:sched:: ChoiceStringParameter ChoiceStringParameter ##################### :: ChoiceStringParameter(name="branch", choices=["main","devel"], default="main") This parameter type lets the user choose between several choices (e.g the list of branches you are supporting, or the test campaign to run). If ``multiple`` is false, then its result is a string - one of the choices. If ``multiple`` is true, then the result is a list of strings from the choices. Note that for some use cases, the choices need to be generated dynamically. This can be done via subclassing and overiding the 'getChoices' member function. An example of this is provided by the source for the :py:class:`InheritBuildParameter` class. Its arguments, in addition to the common options, are: ``choices`` The list of available choices. ``strict`` (optional; default: True) If true, verify that the user's input is from the list. Note that this only affects the validation of the form request; even if this argument is False, there is no HTML form component available to enter an arbitrary value. ``multiple`` If true, then the user may select multiple choices. Example:: ChoiceStringParameter(name="forced_tests", label = "smoke test campaign to run", default = default_tests, multiple = True, strict = True, choices = [ "test_builder1", "test_builder2", "test_builder3" ]) ]) # .. and later base the schedulers to trigger off this property: # triggers the tests depending on the property forced_test builder1.factory.addStep(Trigger(name="Trigger tests", schedulerNames=Property("forced_tests"))) CodebaseParameter ##################### :: CodebaseParameter(codebase="myrepo") This is a parameter group to specify a sourcestamp for a given codebase. ``codebase`` The name of the codebase. ``branch`` (optional; default: StringParameter) A :ref:`parameter ` specifying the branch to build. The default value is a string parameter. ``revision`` (optional; default: StringParameter) A :ref:`parameter ` specifying the revision to build. The default value is a string parameter. ``repository`` (optional; default: StringParameter) A :ref:`parameter ` specifying the repository for the build. The default value is a string parameter. ``project`` (optional; default: StringParameter) A :ref:`parameter ` specifying the project for the build. The default value is a string parameter. .. bb:sched:: InheritBuildParameter InheritBuildParameter ##################### This is a special parameter for inheriting force build properties from another build. The user is presented with a list of compatible builds from which to choose, and all forced-build parameters from the selected build are copied into the new build. The new parameter is: ``compatible_builds`` A function to find compatible builds in the build history. This function is given the master :py:class:`~buildbot.status.master.Status` instance as first argument, and the current builder name as second argument, or None when forcing all builds. Example:: def get_compatible_builds(status, builder): if builder == None: # this is the case for force_build_all return ["cannot generate build list here"] # find all successful builds in builder1 and builder2 builds = [] for builder in ["builder1","builder2"]: builder_status = status.getBuilder(builder) for num in xrange(1,40): # 40 last builds b = builder_status.getBuild(-num) if not b: continue if b.getResults() == FAILURE: continue builds.append(builder+"/"+str(b.getNumber())) return builds # ... properties=[ InheritBuildParameter( name="inherit", label="promote a build for merge", compatible_builds=get_compatible_builds, required = True), ]) .. bb:sched:: BuildslaveChoiceParameter BuildslaveChoiceParameter ######################### This parameter allows a scheduler to require that a build is assigned to the chosen buildslave. The choice is assigned to the `slavename` property for the build. The :py:class:`~buildbot.builder.enforceChosenSlave` functor must be assigned to the ``canStartBuild`` parameter for the ``Builder``. Example:: from buildbot.process.builder import enforceChosenSlave # schedulers: ForceScheduler( # ... properties=[ BuildslaveChoiceParameter(), ] ) # builders: BuilderConfig( # ... canStartBuild=enforceChosenSlave, ) AnyPropertyParameter #################### This parameter type can only be used in ``properties``, and allows the user to specify both the property name and value in the HTML form. This Parameter is here to reimplement old Buildbot behavior, and should be avoided. Stricter parameter name and type should be preferred. buildbot-0.8.8/docs/manual/cfg-statustargets.rst000066400000000000000000001736531222546025000217610ustar00rootroot00000000000000.. bb:cfg:: status .. _Status-Targets: Status Targets -------------- The Buildmaster has a variety of ways to present build status to various users. Each such delivery method is a `Status Target` object in the configuration's :bb:cfg:`status` list. To add status targets, you just append more objects to this list:: c['status'] = [] from buildbot.status import html c['status'].append(html.Waterfall(http_port=8010)) from buildbot.status import mail m = mail.MailNotifier(fromaddr="buildbot@localhost", extraRecipients=["builds@lists.example.com"], sendToInterestedUsers=False) c['status'].append(m) from buildbot.status import words c['status'].append(words.IRC(host="irc.example.com", nick="bb", channels=[{"channel": "#example1"}, {"channel": "#example2", "password": "somesecretpassword"}])) Most status delivery objects take a ``categories=`` argument, which can contain a list of `category` names: in this case, it will only show status for Builders that are in one of the named categories. .. note:: Implementation Note Each of these objects should be a :class:`service.MultiService` which will be attached to the BuildMaster object when the configuration is processed. They should use ``self.parent.getStatus()`` to get access to the top-level :class:`IStatus` object, either inside :meth:`startService` or later. They may call :meth:`status.subscribe()` in :meth:`startService` to receive notifications of builder events, in which case they must define :meth:`builderAdded` and related methods. See the docstrings in :file:`buildbot/interfaces.py` for full details. The remainder of this section describes each built-in status target. A full list of status targets is available in the :bb:index:`status`. .. bb:status:: WebStatus WebStatus ~~~~~~~~~ .. py:class:: buildbot.status.web.baseweb.WebStatus The :class:`buildbot.status.html.WebStatus` status target runs a small web server inside the buildmaster. You can point a browser at this web server and retrieve information about every build the buildbot knows about, as well as find out what the buildbot is currently working on. The first page you will see is the *Welcome Page*, which contains links to all the other useful pages. By default, this page is served from the :file:`status/web/templates/root.html` file in buildbot's library area. One of the most complex resource provided by :class:`WebStatus` is the *Waterfall Display*, which shows a time-based chart of events. This somewhat-busy display provides detailed information about all steps of all recent builds, and provides hyperlinks to look at individual build logs and source changes. By simply reloading this page on a regular basis, you will see a complete description of everything the buildbot is currently working on. A similar, but more developer-oriented display is the `Grid` display. This arranges builds by :class:`SourceStamp` (horizontal axis) and builder (vertical axis), and can provide quick information as to which revisions are passing or failing on which builders. There are also pages with more specialized information. For example, there is a page which shows the last 20 builds performed by the buildbot, one line each. Each line is a link to detailed information about that build. By adding query arguments to the URL used to reach this page, you can narrow the display to builds that involved certain branches, or which ran on certain :class:`Builder`\s. These pages are described in great detail below. Configuration +++++++++++++ The simplest possible configuration for WebStatus is:: from buildbot.status.html import WebStatus c['status'].append(WebStatus(8080)) Buildbot uses a templating system for the web interface. The source of these templates can be found in the :file:`status/web/templates/` directory in buildbot's library area. You can override these templates by creating alternate versions in a :file:`templates/` directory within the buildmaster's base directory. If that isn't enough you can also provide additional Jinja2 template loaders:: import jinja2 myloaders = [ jinja2.FileSystemLoader("/tmp/mypath"), ] c['status'].append(html.WebStatus( …, jinja_loaders = myloaders, )) The first time a buildmaster is created, the :file:`public_html/` directory is populated with some sample files, which you will probably want to customize for your own project. These files are all static: the buildbot does not modify them in any way as it serves them to HTTP clients. Templates in :file:`templates/` take precedence over static files in :file:`public_html/`. The initial :file:`robots.txt` file has Disallow lines for all of the dynamically-generated buildbot pages, to discourage web spiders and search engines from consuming a lot of CPU time as they crawl through the entire history of your buildbot. If you are running the buildbot behind a reverse proxy, you'll probably need to put the :file:`robots.txt` file somewhere else (at the top level of the parent web server), and replace the URL prefixes in it with more suitable values. If you would like to use an alternative root directory, add the ``public_html=`` option to the :class:`WebStatus` creation:: c['status'].append(WebStatus(8080, public_html="/var/www/buildbot")) In addition, if you are familiar with twisted.web *Resource Trees*, you can write code to add additional pages at places inside this web space. Just use :meth:`webstatus.putChild` to place these resources. The following section describes the special URLs and the status views they provide. Buildbot Web Resources ++++++++++++++++++++++ Certain URLs are `magic`, and the pages they serve are created by code in various classes in the :file:`buildbot.status.web` package instead of being read from disk. The most common way to access these pages is for the buildmaster admin to write or modify the :file:`index.html` page to contain links to them. Of course other project web pages can contain links to these buildbot pages as well. Many pages can be modified by adding query arguments to the URL. For example, a page which shows the results of the most recent build normally does this for all builders at once. But by appending ``?builder=i386`` to the end of the URL, the page will show only the results for the `i386` builder. When used in this way, you can add multiple ``builder=`` arguments to see multiple builders. Remembering that URL query arguments are separated *from each other* with ampersands, a URL that ends in ``?builder=i386&builder=ppc`` would show builds for just those two Builders. The ``branch=`` query argument can be used on some pages. This filters the information displayed by that page down to only the builds or changes which involved the given branch. Use ``branch=trunk`` to reference the trunk: if you aren't intentionally using branches, you're probably using trunk. Multiple ``branch=`` arguments can be used to examine multiple branches at once (so appending ``?branch=foo&branch=bar`` to the URL will show builds involving either branch). No ``branch=`` arguments means to show builds and changes for all branches. Some pages may include the Builder name or the build number in the main part of the URL itself. For example, a page that describes Build #7 of the `i386` builder would live at :file:`/builders/i386/builds/7`. The table below lists all of the internal pages and the URLs that can be used to access them. ``/waterfall`` This provides a chronologically-oriented display of the activity of all builders. It is the same display used by the Waterfall display. By adding one or more ``builder=`` query arguments, the Waterfall is restricted to only showing information about the given Builders. By adding one or more ``branch=`` query arguments, the display is restricted to showing information about the given branches. In addition, adding one or more ``category=`` query arguments to the URL will limit the display to Builders that were defined with one of the given categories. A ``show_events=true`` query argument causes the display to include non-:class:`Build` events, like slaves attaching and detaching, as well as reconfiguration events. ``show_events=false`` hides these events. The default is to show them. By adding the ``failures_only=true`` query argument, the Waterfall is restricted to only showing information about the builders that are currently failing. A builder is considered failing if the last finished build was not successful, a step in the current build(s) is failing, or if the builder is offline. The ``last_time=``, ``first_time=``, and ``show_time=`` arguments will control what interval of time is displayed. The default is to show the latest events, but these can be used to look at earlier periods in history. The ``num_events=`` argument also provides a limit on the size of the displayed page. The Waterfall has references to resources many of the other portions of the URL space: :file:`/builders` for access to individual builds, :file:`/changes` for access to information about source code changes, etc. ``/grid`` This provides a chronologically oriented display of builders, by revision. The builders are listed down the left side of the page, and the revisions are listed across the top. By adding one or more ``category=`` arguments the grid will be restricted to revisions in those categories. A :samp:`width={N}` argument will limit the number of revisions shown to *N*, defaulting to 5. A :samp:`branch={BRANCHNAME}` argument will limit the grid to revisions on branch *BRANCHNAME*. ``/tgrid`` The Transposed Grid is similar to the standard grid, but, as the name implies, transposes the grid: the revisions are listed down the left side of the page, and the build hosts are listed across the top. It accepts the same query arguments. The exception being that instead of ``width`` the argument is named ``length``. This page also has a ``rev_order=`` query argument that lets you change in what order revisions are shown. Valid values are ``asc`` (ascending, oldest revision first) and ``desc`` (descending, newest revision first). ``/console`` EXPERIMENTAL: This provides a developer-oriented display of the last changes and how they affected the builders. It allows a developer to quickly see the status of each builder for the first build including his or her change. A green box means that the change succeeded for all the steps for a given builder. A red box means that the changed introduced a new regression on a builder. An orange box means that at least one of the tests failed, but it was also failing in the previous build, so it is not possible to see if there were any regressions from this change. Finally a yellow box means that the test is in progress. By adding one or more ``builder=`` query arguments, the Console view is restricted to only showing information about the given Builders. Adding a ``repository=`` argument will limit display to a given repository. By adding one or more ``branch=`` query arguments, the display is restricted to showing information about the given branches. In addition, adding one or more ``category=`` query arguments to the URL will limit the display to Builders that were defined with one of the given categories. With the ``project=`` query argument, it's possible to restrict the view to changes from the given project. With the ``codebase=`` query argument, it's possible to restrict the view to changes for the given codebase. By adding one or more ``name=`` query arguments to the URL, the console view is restricted to only showing changes made by the given users. NOTE: To use this page, your :file:`buildbot.css` file in :file:`public_html` must be the one found in :bb:src:`master/buildbot/status/web/files/default.css`. This is the default for new installs, but upgrades of very old installs of Buildbot may need to manually fix the CSS file. The console view is still in development. At this moment by default the view sorts revisions lexically, which can lead to odd behavior with non-integer revisions (e.g., Git), or with integer revisions of different length (e.g., 999 and 1000). It also has some issues with displaying multiple branches at the same time. If you do have multiple branches, you should use the ``branch=`` query argument. The ``order_console_by_time`` option may help sorting revisions, although it depends on the date being set correctly in each commit:: w = html.WebStatus(http_port=8080, order_console_by_time=True) ``/rss`` This provides a rss feed summarizing all failed builds. The same query-arguments used by 'waterfall' can be added to filter the feed output. ``/atom`` This provides an atom feed summarizing all failed builds. The same query-arguments used by 'waterfall' can be added to filter the feed output. ``/json`` This view provides quick access to Buildbot status information in a form that is easily digested from other programs, including JavaScript. See ``/json/help`` for detailed interactive documentation of the output formats for this view. :samp:`/buildstatus?builder=${BUILDERNAME}&number=${BUILDNUM}` This displays a waterfall-like chronologically-oriented view of all the steps for a given build number on a given builder. :samp:`/builders/${BUILDERNAME}` This describes the given :class:`Builder` and provides buttons to force a build. A ``numbuilds=`` argument will control how many build lines are displayed (5 by default). :samp:`/builders/${BUILDERNAME}/builds/${BUILDNUM}` This describes a specific Build. :samp:`/builders/${BUILDERNAME}/builds/${BUILDNUM}/steps/${STEPNAME}` This describes a specific BuildStep. :samp:`/builders/${BUILDERNAME}/builds/${BUILDNUM}/steps/${STEPNAME}/logs/${LOGNAME}` This provides an HTML representation of a specific logfile. :samp:`/builders/${BUILDERNAME}/builds/${BUILDNUM}/steps/${STEPNAME}/logs/${LOGNAME}/text` This returns the logfile as plain text, without any HTML coloring markup. It also removes the `headers`, which are the lines that describe what command was run and what the environment variable settings were like. This maybe be useful for saving to disk and feeding to tools like :command:`grep`. ``/changes`` This provides a brief description of the :class:`ChangeSource` in use (see :ref:`Change-Sources`). :samp:`/changes/{NN}` This shows detailed information about the numbered :class:`Change`: who was the author, what files were changed, what revision number was represented, etc. ``/buildslaves`` This summarizes each :class:`BuildSlave`, including which `Builder`\s are configured to use it, whether the buildslave is currently connected or not, and host information retrieved from the buildslave itself. A ``no_builders=1`` URL argument will omit the builders column. This is useful if each buildslave is assigned to a large number of builders. ``/one_line_per_build`` This page shows one line of text for each build, merging information from all :class:`Builder`\s [#]_. Each line specifies the name of the Builder, the number of the :class:`Build`, what revision it used, and a summary of the results. Successful builds are in green, while failing builds are in red. The date and time of the build are added to the right-hand edge of the line. The lines are ordered by build finish timestamp. One or more ``builder=`` or ``branch=`` arguments can be used to restrict the list. In addition, a ``numbuilds=`` argument will control how many lines are displayed (20 by default). ``/builders`` This page shows a small table, with one box for each :class:`Builder`, containing the results of the most recent :class:`Build`. It does not show the individual steps, or the current status. This is a simple summary of buildbot status: if this page is green, then all tests are passing. As with ``/one_line_per_build``, this page will also honor ``builder=`` and ``branch=`` arguments. ``/users`` This page exists for authentication reasons when checking ``showUsersPage``. It'll redirect to ``/authfail`` on ``False``, ``/users/table`` on ``True``, and give a username/password login prompt on ``'auth'``. Passing or failing results redirect to the same pages as ``False`` and ``True``. ``/users/table`` This page shows a table containing users that are stored in the database. It has columns for their respective ``uid`` and ``identifier`` values, with the ``uid`` values being clickable for more detailed information relating to a user. ``/users/table/{NN}`` Shows all the attributes stored in the database relating to the user with uid ``{NN}`` in a table. ``/about`` This page gives a brief summary of the Buildbot itself: software version, versions of some libraries that the Buildbot depends upon, etc. It also contains a link to the buildbot.net home page. There are also a set of web-status resources that are intended for use by other programs, rather than humans. ``/change_hook`` This provides an endpoint for web-based source change notification. It is used by GitHub and contrib/post_build_request.py. See :ref:`Change-Hooks` for more details. WebStatus Configuration Parameters ++++++++++++++++++++++++++++++++++ HTTP Connection ############### The most common way to run a :class:`WebStatus` is on a regular TCP port. To do this, just pass in the TCP port number when you create the :class:`WebStatus` instance; this is called the ``http_port`` argument:: from buildbot.status.html import WebStatus c['status'].append(WebStatus(http_port=8080)) The ``http_port`` argument is actually a `strports specification` for the port that the web server should listen on. This can be a simple port number, or a string like ``http_port="tcp:8080:interface=127.0.0.1"`` (to limit connections to the loopback interface, and therefore to clients running on the same host) [#]_. If instead (or in addition) you provide the ``distrib_port`` argument, a twisted.web distributed server will be started either on a TCP port (if ``distrib_port`` is like ``"tcp:12345"``) or more likely on a UNIX socket (if ``distrib_port`` is like ``"unix:/path/to/socket"``). The ``public_html`` option gives the path to a regular directory of HTML files that will be displayed alongside the various built-in URLs buildbot supplies. This is most often used to supply CSS files (:file:`/buildbot.css`) and a top-level navigational file (:file:`/index.html`), but can also serve any other files required - even build results! .. _Authorization: Authorization ############# The buildbot web status is, by default, read-only. It displays lots of information, but users are not allowed to affect the operation of the buildmaster. However, there are a number of supported activities that can be enabled, and Buildbot can also perform rudimentary username/password authentication. The actions are: ``forceBuild`` force a particular builder to begin building, optionally with a specific revision, branch, etc. ``forceAllBuilds`` force *all* builders to start building ``pingBuilder`` "ping" a builder's buildslaves to check that they are alive ``gracefulShutdown`` gracefully shut down a slave when it is finished with its current build ``pauseSlave`` temporarily stop running new builds on a slave ``stopBuild`` stop a running build ``stopAllBuilds`` stop all running builds ``cancelPendingBuild`` cancel a build that has not yet started ``stopChange`` cancel builds that include a given change number ``cleanShutdown`` shut down the master gracefully, without interrupting builds ``showUsersPage`` access to page displaying users in the database, see :ref:`User-Objects` For each of these actions, you can configure buildbot to never allow the action, always allow the action, allow the action to any authenticated user, or check with a function of your creation to determine whether the action is OK (see below). This is all configured with the :class:`Authz` class:: from buildbot.status.html import WebStatus from buildbot.status.web.authz import Authz authz = Authz( forceBuild=True, stopBuild=True) c['status'].append(WebStatus(http_port=8080, authz=authz)) Each of the actions listed above is an option to :class:`Authz`. You can specify ``False`` (the default) to prohibit that action or ``True`` to enable it. Or you can specify a callable. Each such callable will take a username as its first argument. The remaining arguments vary depending on the type of authorization request. For ``forceBuild``, the second argument is the builder status. Authentication ############## If you do not wish to allow strangers to perform actions, but do want developers to have such access, you will need to add some authentication support. Pass an instance of :class:`status.web.auth.IAuth` as a ``auth`` keyword argument to :class:`Authz`, and specify the action as ``"auth"``. :: from buildbot.status.html import WebStatus from buildbot.status.web.authz import Authz from buildbot.status.web.auth import BasicAuth users = [('bob', 'secret-pass'), ('jill', 'super-pass')] authz = Authz(auth=BasicAuth(users), forceBuild='auth', # only authenticated users pingBuilder=True, # but anyone can do this ) c['status'].append(WebStatus(http_port=8080, authz=authz)) # or from buildbot.status.web.auth import HTPasswdAuth auth = (HTPasswdAuth('/path/to/htpasswd')) # or from buildbot.status.web.auth import UsersAuth auth = UsersAuth() The class :class:`BasicAuth` implements a basic authentication mechanism using a list of user/password tuples provided from the configuration file. The class `HTPasswdAuth` implements an authentication against an :file:`.htpasswd` file. The `HTPasswdAprAuth` a subclass of `HTPasswdAuth` use libaprutil for authenticating. This adds support for apr1/md5 and sha1 password hashes but requires libaprutil at runtime. The :class:`UsersAuth` works with :ref:`User-Objects` to check for valid user credentials. If you need still-more flexibility, pass a function for the authentication action. That function will be called with an authenticated username and some action-specific arguments, and should return true if the action is authorized. :: def canForceBuild(username, builder_status): if builder_status.getName() == 'smoketest': return True # any authenticated user can run smoketest elif username == 'releng': return True # releng can force whatever they want else: return False # otherwise, no way. authz = Authz(auth=BasicAuth(users), forceBuild=canForceBuild) The ``forceBuild`` and ``pingBuilder`` actions both supply a :class:`BuilderStatus` object. The ``stopBuild`` action supplies a :class:`BuildStatus` object. The ``cancelPendingBuild`` action supplies a :class:`BuildRequest`. The remainder do not supply any extra arguments. HTTP-based authentication by frontend server ############################################ In case if WebStatus is served through reverse proxy that supports HTTP-based authentication (like apache, lighttpd), it's possible to to tell WebStatus to trust web server and get username from request headers. This allows displaying correct usernames in build reason, interrupt messages, etc. Just set ``useHttpHeader`` to ``True`` in :class:`Authz` constructor. :: authz = Authz(useHttpHeader=True) # WebStatus secured by web frontend with HTTP auth Please note that WebStatus can decode password for HTTP Basic requests only (for Digest authentication it's just impossible). Custom :class:`status.web.auth.IAuth` subclasses may just ignore password at all since it's already validated by web server. Administrator must make sure that it's impossible to get access to WebStatus using other way than through frontend. Usually this means that WebStatus should listen for incoming connections only on localhost (or on some firewall-protected port). Frontend must require HTTP authentication to access WebStatus pages (using any source for credentials, such as htpasswd, PAM, LDAP). If you allow unauthenticated access through frontend as well, it's possible to specify a ``httpLoginLink`` which will be rendered on the WebStatus for unauthenticated users as a link named Login. :: authz = Authz(useHttpHeader=True, httpLoginLink='https://buildbot/login') A configuration example with Apache HTTPD as reverse proxy could look like the following. :: authz = Authz( useHttpHeader=True, httpLoginLink='https://buildbot/login', auth = HTPasswdAprAuth('/var/www/htpasswd'), forceBuild = 'auth') Corresponding Apache configuration. .. code-block:: apache ProxyPass / http://127.0.0.1:8010/ AuthType Basic AuthName "Buildbot" AuthUserFile /var/www/htpasswd Require valid-user RewriteEngine on RewriteCond %{HTTP_REFERER} ^https?://([^/]+)/(.*)$ RewriteRule ^.*$ https://%1/%2 [R,L] Logging configuration ##################### The `WebStatus` uses a separate log file (:file:`http.log`) to avoid clutter buildbot's default log (:file:`twistd.log`) with request/response messages. This log is also, by default, rotated in the same way as the twistd.log file, but you can also customize the rotation logic with the following parameters if you need a different behaviour. ``rotateLength`` An integer defining the file size at which log files are rotated. ``maxRotatedFiles`` The maximum number of old log files to keep. URL-decorating options ###################### These arguments adds an URL link to various places in the WebStatus, such as revisions, repositories, projects and, optionally, ticket/bug references in change comments. revlink ''''''' The ``revlink`` argument on :class:`WebStatus` is deprecated in favour of the global :bb:cfg:`revlink` option. Only use this if you need to generate different URLs for different web status instances. In addition to a callable like :bb:cfg:`revlink`, this argument accepts a format string or a dict mapping a string (repository name) to format strings. The format string should use ``%s`` to insert the revision id in the url. For example, for Buildbot on GitHub:: revlink='http://github.com/buildbot/buildbot/tree/%s' The revision ID will be URL encoded before inserted in the replacement string changecommentlink ''''''''''''''''' The ``changecommentlink`` argument can be used to create links to ticket-ids from change comments (i.e. #123). The argument can either be a tuple of three strings, a dictionary mapping strings (project names) to tuples or a callable taking a changetext (a :class:`jinja2.Markup` instance) and a project name, returning a the same change text with additional links/html tags added to it. If the tuple is used, it should contain three strings where the first element is a regex that searches for strings (with match groups), the second is a replace-string that, when substituted with ``\1`` etc, yields the URL and the third is the title attribute of the link. (The ``
    `` is added by the system.) So, for Trac tickets (#42, etc): ``changecommentlink(r"#(\d+)", r"http://buildbot.net/trac/ticket/\1", r"Ticket \g<0>")`` . projects '''''''' A dictionary from strings to strings, mapping project names to URLs, or a callable taking a project name and returning an URL. repositories '''''''''''' Same as the projects arg above, a dict or callable mapping project names to URLs. Display-Specific Options ######################## The ``order_console_by_time`` option affects the rendering of the console; see the description of the console above. The ``numbuilds`` option determines the number of builds that most status displays will show. It can usually be overriden in the URL, e.g., ``?numbuilds=13``. The ``num_events`` option gives the default number of events that the waterfall will display. The ``num_events_max`` gives the maximum number of events displayed, even if the web browser requests more. .. _Change-Hooks: Change Hooks ++++++++++++ The ``/change_hook`` url is a magic URL which will accept HTTP requests and translate them into changes for buildbot. Implementations (such as a trivial json-based endpoint and a GitHub implementation) can be found in :bb:src:`master/buildbot/status/web/hooks`. The format of the url is :samp:`/change_hook/{DIALECT}` where DIALECT is a package within the hooks directory. Change_hook is disabled by default and each DIALECT has to be enabled separately, for security reasons An example WebStatus configuration line which enables change_hook and two DIALECTS:: c['status'].append(html.WebStatus(http_port=8010,allowForce=True, change_hook_dialects={ 'base': True, 'somehook': {'option1':True, 'option2':False}})) Within the WebStatus arguments, the ``change_hook`` key enables/disables the module and ``change_hook_dialects`` whitelists DIALECTs where the keys are the module names and the values are optional arguments which will be passed to the hooks. The :file:`post_build_request.py` script in :file:`master/contrib` allows for the submission of an arbitrary change request. Run :command:`post_build_request.py --help` for more information. The ``base`` dialect must be enabled for this to work. GitHub hook ########### The GitHub hook is simple and takes no options. :: c['status'].append(html.WebStatus(.. change_hook_dialects={ 'github' : True })) With this set up, add a Post-Receive URL for the project in the GitHub administrative interface, pointing to ``/change_hook/github`` relative to the root of the web status. For example, if the grid URL is ``http://builds.mycompany.com/bbot/grid``, then point GitHub to ``http://builds.mycompany.com/bbot/change_hook/github``. To specify a project associated to the repository, append ``?project=name`` to the URL. Note that there is a standalone HTTP server available for receiving GitHub notifications, as well: :file:`contrib/github_buildbot.py`. This script may be useful in cases where you cannot expose the WebStatus for public consumption. .. warning:: The incoming HTTP requests for this hook are not authenticated by default. Anyone who can access the web status can "fake" a request from GitHub, potentially causing the buildmaster to run arbitrary code. To protect URL against unauthorized access you should use ``change_hook_auth`` option :: c['status'].append(html.WebStatus(.. change_hook_auth=["file:changehook.passwd"])) And create a file ``changehook.passwd`` :: user:password Then, create a GitHub service hook (see https://help.github.com/articles/post-receive-hooks) with a WebHook URL like ``http://user:password@builds.mycompany.com/bbot/change_hook/github``. See the `documentation `_ for twisted cred for more option to pass to ``change_hook_auth``. Note that not using ``change_hook_auth`` can expose you to security risks. Google Code hook ################ The Google Code hook is quite similar to the GitHub Hook. It has one option for the "Post-Commit Authentication Key" used to check if the request is legitimate:: c['status'].append(html.WebStatus( …, change_hook_dialects={'googlecode': {'secret_key': 'FSP3p-Ghdn4T0oqX'}} )) This will add a "Post-Commit URL" for the project in the Google Code administrative interface, pointing to ``/change_hook/googlecode`` relative to the root of the web status. Alternatively, you can use the :ref:`GoogleCodeAtomPoller` :class:`ChangeSource` that periodically poll the Google Code commit feed for changes. .. note:: Google Code doesn't send the branch on which the changes were made. So, the hook always returns ``'default'`` as the branch, you can override it with the ``'branch'`` option:: change_hook_dialects={'googlecode': {'secret_key': 'FSP3p-Ghdn4T0oqX', 'branch': 'master'}} Poller hook ########### The poller hook allows you to use GET requests to trigger polling. One advantage of this is your buildbot instance can (at start up) poll to get changes that happened while it was down, but then you can still use a commit hook to get fast notification of new changes. Suppose you have a poller configured like this:: c['change_source'] = SVNPoller( svnurl="https://amanda.svn.sourceforge.net/svnroot/amanda/amanda", split_file=split_file_branches) And you configure your WebStatus to enable this hook:: c['status'].append(html.WebStatus( …, change_hook_dialects={'poller': True} )) Then you will be able to trigger a poll of the SVN repository by poking the ``/change_hook/poller`` URL from a commit hook like this:: curl http://yourbuildbot/change_hook/poller?poller=https%3A%2F%2Famanda.svn.sourceforge.net%2Fsvnroot%2Famanda%2Famanda If no ``poller`` argument is provided then the hook will trigger polling of all polling change sources. You can restrict which pollers the webhook has access to using the ``allowed`` option:: c['status'].append(html.WebStatus( …, change_hook_dialects={'poller': {'allowed': ['https://amanda.svn.sourceforge.net/svnroot/amanda/amanda']}} )) .. bb:status:: MailNotifier .. index:: single: email; MailNotifier MailNotifier ~~~~~~~~~~~~ .. py:class:: buildbot.status.mail.MailNotifier The buildbot can also send email when builds finish. The most common use of this is to tell developers when their change has caused the build to fail. It is also quite common to send a message to a mailing list (usually named `builds` or similar) about every build. The :class:`MailNotifier` status target is used to accomplish this. You configure it by specifying who mail should be sent to, under what circumstances mail should be sent, and how to deliver the mail. It can be configured to only send out mail for certain builders, and only send messages when the build fails, or when the builder transitions from success to failure. It can also be configured to include various build logs in each message. If a proper lookup function is configured, the message will be sent to the "interested users" list (:ref:`Doing-Things-With-Users`), which includes all developers who made changes in the build. By default, however, Buildbot does not know how to construct an email addressed based on the information from the version control system. See the ``lookup`` argument, below, for more information. You can add additional, statically-configured, recipients with the ``extraRecipients`` argument. You can also add interested users by setting the ``owners`` build property to a list of users in the scheduler constructor (:ref:`Configuring-Schedulers`). Each :class:`MailNotifier` sends mail to a single set of recipients. To send different kinds of mail to different recipients, use multiple :class:`MailNotifier`\s. The following simple example will send an email upon the completion of each build, to just those developers whose :class:`Change`\s were included in the build. The email contains a description of the :class:`Build`, its results, and URLs where more information can be obtained. :: from buildbot.status.mail import MailNotifier mn = MailNotifier(fromaddr="buildbot@example.org", lookup="example.org") c['status'].append(mn) To get a simple one-message-per-build (say, for a mailing list), use the following form instead. This form does not send mail to individual developers (and thus does not need the ``lookup=`` argument, explained below), instead it only ever sends mail to the `extra recipients` named in the arguments:: mn = MailNotifier(fromaddr="buildbot@example.org", sendToInterestedUsers=False, extraRecipients=['listaddr@example.org']) If your SMTP host requires authentication before it allows you to send emails, this can also be done by specifying ``smtpUser`` and ``smptPassword``:: mn = MailNotifier(fromaddr="myuser@gmail.com", sendToInterestedUsers=False, extraRecipients=["listaddr@example.org"], relayhost="smtp.gmail.com", smtpPort=587, smtpUser="myuser@gmail.com", smtpPassword="mypassword") If you want to require Transport Layer Security (TLS), then you can also set ``useTls``:: mn = MailNotifier(fromaddr="myuser@gmail.com", sendToInterestedUsers=False, extraRecipients=["listaddr@example.org"], useTls=True, relayhost="smtp.gmail.com", smtpPort=587, smtpUser="myuser@gmail.com", smtpPassword="mypassword") .. note:: If you see ``twisted.mail.smtp.TLSRequiredError`` exceptions in the log while using TLS, this can be due *either* to the server not supporting TLS or to a missing `PyOpenSSL`_ package on the buildmaster system. In some cases it is desirable to have different information then what is provided in a standard MailNotifier message. For this purpose MailNotifier provides the argument ``messageFormatter`` (a function) which allows for the creation of messages with unique content. For example, if only short emails are desired (e.g., for delivery to phones) :: from buildbot.status.builder import Results def messageFormatter(mode, name, build, results, master_status): result = Results[results] text = list() text.append("STATUS: %s" % result.title()) return { 'body' : "\n".join(text), 'type' : 'plain' } mn = MailNotifier(fromaddr="buildbot@example.org", sendToInterestedUsers=False, mode=('problem',), extraRecipients=['listaddr@example.org'], messageFormatter=messageFormatter) Another example of a function delivering a customized html email containing the last 80 log lines of logs of the last build step is given below:: from buildbot.status.builder import Results import cgi, datetime def html_message_formatter(mode, name, build, results, master_status): """Provide a customized message to Buildbot's MailNotifier. The last 80 lines of the log are provided as well as the changes relevant to the build. Message content is formatted as html. """ result = Results[results] limit_lines = 80 text = list() text.append(u'

    Build status: %s

    ' % result.upper()) text.append(u'') text.append(u"" % build.getSlavename()) if master_status.getURLForThing(build): text.append(u'' % (master_status.getURLForThing(build), master_status.getURLForThing(build)) ) text.append(u'' % build.getReason()) source = u"" for ss in build.getSourceStamps(): if ss.codebase: source += u'%s: ' % ss.codebase if ss.branch: source += u"[branch %s] " % ss.branch if ss.revision: source += ss.revision else: source += u"HEAD" if ss.patch: source += u" (plus patch)" if ss.patch_info: # add patch comment source += u" (%s)" % ss.patch_info[1] text.append(u"" % source) text.append(u"" % ",".join(build.getResponsibleUsers())) text.append(u'
    Buildslave for this Build:%s
    Complete logs for all build steps:%s
    Build Reason:%s
    Build Source Stamp:%s
    Blamelist:%s
    ') if ss.changes: text.append(u'

    Recent Changes:

    ') for c in ss.changes: cd = c.asDict() when = datetime.datetime.fromtimestamp(cd['when'] ).ctime() text.append(u'') text.append(u'' % cd['repository'] ) text.append(u'' % cd['project'] ) text.append(u'' % when) text.append(u'' % cd['who'] ) text.append(u'' % cd['comments'] ) text.append(u'
    Repository:%s
    Project:%s
    Time:%s
    Changed by:%s
    Comments:%s
    ') files = cd['files'] if files: text.append(u'') for file in files: text.append(u'' % file['name'] ) text.append(u'
    Files
    %s:
    ') text.append(u'
    ') # get log for last step logs = build.getLogs() # logs within a step are in reverse order. Search back until we find stdio for log in reversed(logs): if log.getName() == 'stdio': break name = "%s.%s" % (log.getStep().getName(), log.getName()) status, dummy = log.getStep().getResults() content = log.getText().splitlines() # Note: can be VERY LARGE url = u'%s/steps/%s/logs/%s' % (master_status.getURLForThing(build), log.getStep().getName(), log.getName()) text.append(u'Detailed log of last build step: %s' % (url, url)) text.append(u'
    ') text.append(u'

    Last %d lines of "%s"

    ' % (limit_lines, name)) unilist = list() for line in content[len(content)-limit_lines:]: unilist.append(cgi.escape(unicode(line,'utf-8'))) text.append(u'
    '.join([uniline for uniline in unilist]))
                text.append(u'
    ') text.append(u'

    ') text.append(u'-The Buildbot') return { 'body': u"\n".join(text), 'type': 'html' } mn = MailNotifier(fromaddr="buildbot@example.org", sendToInterestedUsers=False, mode=('failing',), extraRecipients=['listaddr@example.org'], messageFormatter=html_message_formatter) MailNotifier arguments ++++++++++++++++++++++ ``fromaddr`` The email address to be used in the 'From' header. ``sendToInterestedUsers`` (boolean). If ``True`` (the default), send mail to all of the Interested Users. If ``False``, only send mail to the ``extraRecipients`` list. ``extraRecipients`` (list of strings). A list of email addresses to which messages should be sent (in addition to the InterestedUsers list, which includes any developers who made :class:`Change`\s that went into this build). It is a good idea to create a small mailing list and deliver to that, then let subscribers come and go as they please. ``subject`` (string). A string to be used as the subject line of the message. ``%(builder)s`` will be replaced with the name of the builder which provoked the message. ``mode`` (list of strings). A combination of: ``change`` Send mail about builds which change status. ``failing`` Send mail about builds which fail. ``passing`` Send mail about builds which succeed. ``problem`` Send mail about a build which failed when the previous build has passed. ``warnings`` Send mail about builds which generate warnings. ``exception`` Send mail about builds which generate exceptions. ``all`` Always send mail about builds. Defaults to (``failing``, ``passing``, ``warnings``). ``builders`` (list of strings). A list of builder names for which mail should be sent. Defaults to ``None`` (send mail for all builds). Use either builders or categories, but not both. ``categories`` (list of strings). A list of category names to serve status information for. Defaults to ``None`` (all categories). Use either builders or categories, but not both. ``addLogs`` (boolean). If ``True``, include all build logs as attachments to the messages. These can be quite large. This can also be set to a list of log names, to send a subset of the logs. Defaults to ``False``. ``addPatch`` (boolean). If ``True``, include the patch content if a patch was present. Patches are usually used on a :class:`Try` server. Defaults to ``True``. ``buildSetSummary`` (boolean). If ``True``, send a single summary email consisting of the concatenation of all build completion messages rather than a completion message for each build. Defaults to ``False``. ``relayhost`` (string). The host to which the outbound SMTP connection should be made. Defaults to 'localhost' ``smtpPort`` (int). The port that will be used on outbound SMTP connections. Defaults to 25. ``useTls`` (boolean). When this argument is ``True`` (default is ``False``) ``MailNotifier`` sends emails using TLS and authenticates with the ``relayhost``. When using TLS the arguments ``smtpUser`` and ``smtpPassword`` must also be specified. ``smtpUser`` (string). The user name to use when authenticating with the ``relayhost``. ``smtpPassword`` (string). The password that will be used when authenticating with the ``relayhost``. ``lookup`` (implementor of :class:`IEmailLookup`). Object which provides :class:`IEmailLookup`, which is responsible for mapping User names (which come from the VC system) into valid email addresses. If the argument is not provided, the ``MailNotifier`` will attempt to build the ``sendToInterestedUsers`` from the authors of the Changes that led to the Build via :ref:`User-Objects`. If the author of one of the Build's Changes has an email address stored, it will added to the recipients list. With this method, ``owners`` are still added to the recipients. Note that, in the current implementation of user objects, email addresses are not stored; as a result, unless you have specifically added email addresses to the user database, this functionality is unlikely to actually send any emails. Most of the time you can use a simple Domain instance. As a shortcut, you can pass as string: this will be treated as if you had provided ``Domain(str)``. For example, ``lookup='twistedmatrix.com'`` will allow mail to be sent to all developers whose SVN usernames match their twistedmatrix.com account names. See :file:`buildbot/status/mail.py` for more details. Regardless of the setting of ``lookup``, ``MailNotifier`` will also send mail to addresses in the ``extraRecipients`` list. ``messageFormatter`` This is a optional function that can be used to generate a custom mail message. A :func:`messageFormatter` function takes the mail mode (``mode``), builder name (``name``), the build status (``build``), the result code (``results``), and the BuildMaster status (``master_status``). It returns a dictionary. The ``body`` key gives a string that is the complete text of the message. The ``type`` key is the message type ('plain' or 'html'). The 'html' type should be used when generating an HTML message. The ``subject`` key is optional, but gives the subject for the email. ``extraHeaders`` (dictionary) A dictionary containing key/value pairs of extra headers to add to sent e-mails. Both the keys and the values may be a `Interpolate` instance. ``previousBuildGetter`` An optional function to calculate the previous build to the one at hand. A :func:`previousBuildGetter` takes a :class:`BuildStatus` and returns a :class:`BuildStatus`. This function is useful when builders don't process their requests in order of arrival (chronologically) and therefore the order of completion of builds does not reflect the order in which changes (and their respective requests) arrived into the system. In such scenarios, status transitions in the chronological sequence of builds within a builder might not reflect the actual status transition in the topological sequence of changes in the tree. What's more, the latest build (the build at hand) might not always be for the most recent request so it might not make sense to send a "change" or "problem" email about it. Returning None from this function will prevent such emails from going out. As a help to those writing :func:`messageFormatter` functions, the following table describes how to get some useful pieces of information from the various status objects: Name of the builder that generated this event ``name`` Title of the buildmaster :meth:`master_status.getTitle()` MailNotifier mode ``mode`` (a combination of ``change``, ``failing``, ``passing``, ``problem``, ``warnings``, ``exception``, ``all``) Builder result as a string :: from buildbot.status.builder import Results result_str = Results[results] # one of 'success', 'warnings', 'failure', 'skipped', or 'exception' URL to build page ``master_status.getURLForThing(build)`` URL to buildbot main page. ``master_status.getBuildbotURL()`` Build text ``build.getText()`` Mapping of property names to values ``build.getProperties()`` (a :class:`Properties` instance) Slave name ``build.getSlavename()`` Build reason (from a forced build) ``build.getReason()`` List of responsible users ``build.getResponsibleUsers()`` Source information (only valid if ss is not ``None``) A build has a set of sourcestamps:: for ss in build.getSourceStamp(): branch = ss.branch revision = ss.revision patch = ss.patch changes = ss.changes # list A change object has the following useful information: ``who`` (str) who made this change ``revision`` (str) what VC revision is this change ``branch`` (str) on what branch did this change occur ``when`` (str) when did this change occur ``files`` (list of str) what files were affected in this change ``comments`` (str) comments reguarding the change. The ``Change`` methods :meth:`asText` and :meth:`asDict` can be used to format the information above. :meth:`asText` returns a list of strings and :meth:`asDict` returns a dictionary suitable for html/mail rendering. Log information :: logs = list() for log in build.getLogs(): log_name = "%s.%s" % (log.getStep().getName(), log.getName()) log_status, dummy = log.getStep().getResults() log_body = log.getText().splitlines() # Note: can be VERY LARGE log_url = '%s/steps/%s/logs/%s' % (master_status.getURLForThing(build), log.getStep().getName(), log.getName()) logs.append((log_name, log_url, log_body, log_status)) .. bb:status:: IRC .. index:: IRC IRC Bot ~~~~~~~ .. py:class:: buildbot.status.words.IRC The :class:`buildbot.status.words.IRC` status target creates an IRC bot which will attach to certain channels and be available for status queries. It can also be asked to announce builds as they occur, or be told to shut up. :: from buildbot.status import words irc = words.IRC("irc.example.org", "botnickname", useColors=False, channels=[{"channel": "#example1"}, {"channel": "#example2", "password": "somesecretpassword"}], password="mysecretnickservpassword", notify_events={ 'exception': 1, 'successToFailure': 1, 'failureToSuccess': 1, }) c['status'].append(irc) Take a look at the docstring for :class:`words.IRC` for more details on configuring this service. Note that the ``useSSL`` option requires `PyOpenSSL`_. The ``password`` argument, if provided, will be sent to Nickserv to claim the nickname: some IRC servers will not allow clients to send private messages until they have logged in with a password. We can also specify a different ``port`` number. Default value is 6667. To use the service, you address messages at the buildbot, either normally (``botnickname: status``) or with private messages (``/msg botnickname status``). The buildbot will respond in kind. The bot will add color to some of its messages. This is enabled by default, you might turn it off with ``useColors=False`` argument to words.IRC(). If you issue a command that is currently not available, the buildbot will respond with an error message. If the ``noticeOnChannel=True`` option was used, error messages will be sent as channel notices instead of messaging. The default value is ``noticeOnChannel=False``. Some of the commands currently available: ``list builders`` Emit a list of all configured builders :samp:`status {BUILDER}` Announce the status of a specific Builder: what it is doing right now. ``status all`` Announce the status of all Builders :samp:`watch {BUILDER}` If the given :class:`Builder` is currently running, wait until the :class:`Build` is finished and then announce the results. :samp:`last {BUILDER}` Return the results of the last build to run on the given :class:`Builder`. :samp:`join {CHANNEL}` Join the given IRC channel :samp:`leave {CHANNEL}` Leave the given IRC channel :samp:`notify on|off|list {EVENT}` Report events relating to builds. If the command is issued as a private message, then the report will be sent back as a private message to the user who issued the command. Otherwise, the report will be sent to the channel. Available events to be notified are: ``started`` A build has started ``finished`` A build has finished ``success`` A build finished successfully ``failure`` A build failed ``exception`` A build generated and exception ``xToY`` The previous build was x, but this one is Y, where x and Y are each one of success, warnings, failure, exception (except Y is capitalized). For example: ``successToFailure`` will notify if the previous build was successful, but this one failed :samp:`help {COMMAND}` Describe a command. Use :command:`help commands` to get a list of known commands. :samp:`shutdown {ARG}` Control the shutdown process of the buildbot master. Available arguments are: ``check`` Check if the buildbot master is running or shutting down ``start`` Start clean shutdown ``stop`` Stop clean shutdown ``now`` Shutdown immediately without waiting for the builders to finish ``source`` Announce the URL of the Buildbot's home page. ``version`` Announce the version of this Buildbot. Additionally, the config file may specify default notification options as shown in the example earlier. If the ``allowForce=True`` option was used, some additional commands will be available: .. index:: Properties; from forced build :samp:`force build [--branch={BRANCH}] [--revision={REVISION}] [--props=PROP1=VAL1,PROP2=VAL2...] {BUILDER} {REASON}` Tell the given :class:`Builder` to start a build of the latest code. The user requesting the build and *REASON* are recorded in the :class:`Build` status. The buildbot will announce the build's status when it finishes.The user can specify a branch and/or revision with the optional parameters :samp:`--branch={BRANCH}` and :samp:`--revision={REVISION}`. The user can also give a list of properties with :samp:`--props={PROP1=VAL1,PROP2=VAL2..}`. :samp:`stop build {BUILDER} {REASON}` Terminate any running build in the given :class:`Builder`. *REASON* will be added to the build status to explain why it was stopped. You might use this if you committed a bug, corrected it right away, and don't want to wait for the first build (which is destined to fail) to complete before starting the second (hopefully fixed) build. If the `categories` is set to a category of builders (see the categories option in :ref:`Builder-Configuration`) changes related to only that category of builders will be sent to the channel. If the `useRevisions` option is set to `True`, the IRC bot will send status messages that replace the build number with a list of revisions that are contained in that build. So instead of seeing `build #253 of ...`, you would see something like `build containing revisions [a87b2c4]`. Revisions that are stored as hashes are shortened to 7 characters in length, as multiple revisions can be contained in one build and may exceed the IRC message length limit. Two additional arguments can be set to control how fast the IRC bot tries to reconnect when it encounters connection issues. ``lostDelay`` is the number of of seconds the bot will wait to reconnect when the connection is lost, where as ``failedDelay`` is the number of seconds until the bot tries to reconnect when the connection failed. ``lostDelay`` defaults to a random number between 1 and 5, while ``failedDelay`` defaults to a random one between 45 and 60. Setting random defaults like this means multiple IRC bots are less likely to deny each other by flooding the server. .. bb:status:: PBListener PBListener ~~~~~~~~~~ .. @cindex PBListener .. py:class:: buildbot.status.client.PBListener :: import buildbot.status.client pbl = buildbot.status.client.PBListener(port=int, user=str, passwd=str) c['status'].append(pbl) This sets up a PB listener on the given TCP port, to which a PB-based status client can connect and retrieve status information. :command:`buildbot statusgui` (:bb:cmdline:`statusgui`) is an example of such a status client. The ``port`` argument can also be a strports specification string. .. bb:status:: StatusPush StatusPush ~~~~~~~~~~ .. @cindex StatusPush .. py:class:: buildbot.status.status_push.StatusPush :: def Process(self): print str(self.queue.popChunk()) self.queueNextServerPush() import buildbot.status.status_push sp = buildbot.status.status_push.StatusPush(serverPushCb=Process, bufferDelay=0.5, retryDelay=5) c['status'].append(sp) :class:`StatusPush` batches events normally processed and sends it to the :func:`serverPushCb` callback every ``bufferDelay`` seconds. The callback should pop items from the queue and then queue the next callback. If no items were popped from ``self.queue``, ``retryDelay`` seconds will be waited instead. .. bb:status:: HttpStatusPush HttpStatusPush ~~~~~~~~~~~~~~ .. @cindex HttpStatusPush .. @stindex buildbot.status.status_push.HttpStatusPush :: import buildbot.status.status_push sp = buildbot.status.status_push.HttpStatusPush( serverUrl="http://example.com/submit") c['status'].append(sp) :class:`HttpStatusPush` builds on :class:`StatusPush` and sends HTTP requests to ``serverUrl``, with all the items json-encoded. It is useful to create a status front end outside of buildbot for better scalability. .. bb:status:: GerritStatusPush GerritStatusPush ~~~~~~~~~~~~~~~~ .. py:class:: buildbot.status.status_gerrit.GerritStatusPush :: from buildbot.status.status_gerrit import GerritStatusPush from buildbot.status.builder import Results, SUCCESS, RETRY def gerritReviewCB(builderName, build, result, status, arg): if result == RETRY: return None, 0, 0 message = "Buildbot finished compiling your patchset\n" message += "on configuration: %s\n" % builderName message += "The result is: %s\n" % Results[result].upper() if arg: message += "\nFor more details visit:\n" message += status.getURLForThing(build) + "\n" # message, verified, reviewed return message, (result == SUCCESS or -1), 0 def gerritStartCB(builderName, build, arg): message = "Buildbot started compiling your patchset\n" message += "on configuration: %s\n" % builderName if arg: message += "\nFor more details visit:\n" message += status.getURLForThing(build) + "\n" return message c['buildbotURL'] = 'http://buildbot.example.com/' c['status'].append(GerritStatusPush('127.0.0.1', 'buildbot', reviewCB=gerritReviewCB, reviewArg=c['buildbotURL'], startCB=gerritStartCB, startArg=c['buildbotURL'])) GerritStatusPush sends review of the :class:`Change` back to the Gerrit server, optionally also sending a message when a build is started. ``reviewCB`` should return a tuple of message, verified, reviewed. If message is ``None``, no review will be sent. ``startCB`` should return a message. .. [#] Apparently this is the same way http://buildd.debian.org displays build status .. [#] It may even be possible to provide SSL access by using a specification like ``"ssl:12345:privateKey=mykey.pen:certKey=cert.pem"``, but this is completely untested .. _PyOpenSSL: http://pyopenssl.sourceforge.net/ buildbot-0.8.8/docs/manual/cmdline.rst000066400000000000000000001105061222546025000177060ustar00rootroot00000000000000.. _Command-line-Tool: Command-line Tool ================= This section describes command-line tools available after buildbot installation. Since version 0.8 the one-for-all :command:`buildbot` command-line tool was divided into two parts namely :command:`buildbot` and :command:`buildslave`. The last one was separated from main command-line tool to minimize dependencies required for running a buildslave while leaving all other functions to :command:`buildbot` tool. Every command-line tool has a list of global options and a set of commands which have their own options. One can run these tools in the following way: .. code-block:: none buildbot [global options] command [command options] buildslave [global options] command [command options] The ``buildbot`` command is used on the master, while ``buildslave`` is used on the slave. Global options are the same for both tools which perform the following actions: --help Print general help about available commands and global options and exit. All subsequent arguments are ignored. --verbose Set verbose output. --version Print current buildbot version and exit. All subsequent arguments are ignored. You can get help on any command by specifying ``--help`` as a command option: .. code-block:: none buildbot @var{command} --help You can also use manual pages for :command:`buildbot` and :command:`buildslave` for quick reference on command-line options. The remainder of this section describes each buildbot command. See :bb:index:`cmdline` for a full list. buildbot -------- The :command:`buildbot` command-line tool can be used to start or stop a buildmaster or buildbot, and to interact with a running buildmaster. Some of its subcommands are intended for buildmaster admins, while some are for developers who are editing the code that the buildbot is monitoring. Administrator Tools ~~~~~~~~~~~~~~~~~~~ The following :command:`buildbot` sub-commands are intended for buildmaster administrators: .. bb:cmdline:: create-master create-master +++++++++++++ .. code-block:: none buildbot create-master -r {BASEDIR} This creates a new directory and populates it with files that allow it to be used as a buildmaster's base directory. You will usually want to use the :option:`-r` option to create a relocatable :file:`buildbot.tac`. This allows you to move the master directory without editing this file. .. bb:cmdline:: start (buildbot) start +++++ .. code-block:: none buildbot start [--nodaemon] {BASEDIR} This starts a buildmaster which was already created in the given base directory. The daemon is launched in the background, with events logged to a file named :file:`twistd.log`. The :option:`--nodaemon` option instructs Buildbot to skip daemonizing. The process will start in the foreground. It will only return to the command-line when it is stopped. .. bb:cmdline:: restart (buildbot) restart +++++++ .. code-block:: none buildbot restart [--nodaemon] {BASEDIR} Restart the buildmaster. This is equivalent to ``stop`` followed by ``start`` The :option:`--nodaemon` option has the same meaning as for ``start``. .. bb:cmdline:: stop (buildbot) stop ++++ .. code-block:: none buildbot stop {BASEDIR} This terminates the daemon (either buildmaster or buildslave) running in the given directory. The :option:`--clean` option shuts down the buildmaster cleanly. .. bb:cmdline:: sighup sighup ++++++ .. code-block:: none buildbot sighup {BASEDIR} This sends a SIGHUP to the buildmaster running in the given directory, which causes it to re-read its :file:`master.cfg` file. Developer Tools ~~~~~~~~~~~~~~~ These tools are provided for use by the developers who are working on the code that the buildbot is monitoring. .. bb:cmdline:: statuslog statuslog +++++++++ .. code-block:: none buildbot statuslog --master {MASTERHOST}:{PORT} This command starts a simple text-based status client, one which just prints out a new line each time an event occurs on the buildmaster. The :option:`--master` option provides the location of the :class:`buildbot.status.client.PBListener` status port, used to deliver build information to realtime status clients. The option is always in the form of a string, with hostname and port number separated by a colon (:samp:`{HOSTNAME}:{PORTNUM}`). Note that this port is *not* the same as the slaveport (although a future version may allow the same port number to be used for both purposes). If you get an error message to the effect of ``Failure: twisted.cred.error.UnauthorizedLogin:``, this may indicate that you are connecting to the slaveport rather than a :class:`PBListener` port. The :option:`--master` option can also be provided by the ``masterstatus`` name in :file:`.buildbot/options` (see :ref:`buildbot-config-directory`). .. bb:cmdline:: statusgui statusgui +++++++++ If you have set up a :bb:status:`PBListener`, you will be able to monitor your Buildbot using a simple Gtk+ application invoked with the ``buildbot statusgui`` command: .. code-block:: none buildbot statusgui --master {MASTERHOST}:{PORT} This command starts a simple Gtk+-based status client, which contains a few boxes for each Builder that change color as events occur. It uses the same ``--master`` argument and ``masterstatus`` option as the ``buildbot statuslog`` command (:bb:cmdline:`statuslog`). .. bb:cmdline:: try try +++ This lets a developer to ask the question ``What would happen if I committed this patch right now?``. It runs the unit test suite (across multiple build platforms) on the developer's current code, allowing them to make sure they will not break the tree when they finally commit their changes. The ``buildbot try`` command is meant to be run from within a developer's local tree, and starts by figuring out the base revision of that tree (what revision was current the last time the tree was updated), and a patch that can be applied to that revision of the tree to make it match the developer's copy. This ``(revision, patch)`` pair is then sent to the buildmaster, which runs a build with that :class:`SourceStamp`. If you want, the tool will emit status messages as the builds run, and will not terminate until the first failure has been detected (or the last success). There is an alternate form which accepts a pre-made patch file (typically the output of a command like :command:`svn diff`). This ``--diff`` form does not require a local tree to run from. See :ref:`try--diff` concerning the ``--diff`` command option. For this command to work, several pieces must be in place: the :bb:sched:`Try_Jobdir` or ::bb:sched:`Try_Userpass`, as well as some client-side configuration. Locating the master ################### The :command:`try` command needs to be told how to connect to the try scheduler, and must know which of the authentication approaches described above is in use by the buildmaster. You specify the approach by using ``--connect=ssh`` or ``--connect=pb`` (or ``try_connect = 'ssh'`` or ``try_connect = 'pb'`` in :file:`.buildbot/options`). For the PB approach, the command must be given a :option:`--master` argument (in the form :samp:`{HOST}:{PORT}`) that points to TCP port that you picked in the :class:`Try_Userpass` scheduler. It also takes a :option:`--username` and :option:`--passwd` pair of arguments that match one of the entries in the buildmaster's ``userpass`` list. These arguments can also be provided as ``try_master``, ``try_username``, and ``try_password`` entries in the :file:`.buildbot/options` file. For the SSH approach, the command must be given :option:`--host` and :option:`--username`, to get to the buildmaster host. It must also be given :option:`--jobdir`, which points to the inlet directory configured above. The jobdir can be relative to the user's home directory, but most of the time you will use an explicit path like :file:`~buildbot/project/trydir`. These arguments can be provided in :file:`.buildbot/options` as ``try_host``, ``try_username``, ``try_password``, and ``try_jobdir``. The SSH approach also provides a :option:`--buildbotbin` argument to allow specification of the buildbot binary to run on the buildmaster. This is useful in the case where buildbot is installed in a :ref:`virtualenv ` on the buildmaster host, or in other circumstances where the buildbot command is not on the path of the user given by :option:`--username`. The :option:`--buildbotbin` argument can be provided in :file:`.buildbot/options` as ``try_buildbotbin`` Finally, the SSH approach needs to connect to a :class:`PBListener` status port, so it can retrieve and report the results of the build (the PB approach uses the existing connection to retrieve status information, so this step is not necessary). This requires a :option:`--masterstatus` argument, or a ``try_masterstatus`` entry in :file:`.buildbot/options`, in the form of a :samp:`{HOSTNAME}:{PORT}` string. The following command line arguments are deprecated, but retained for backward compatibility: --tryhost is replaced by :option:`--host` --trydir is replaced by :option:`--jobdir` --master is replaced by :option:`--masterstatus` Likewise, the following :file:`.buildbot/options` file entries are deprecated, but retained for backward compatibility: * ``try_dir`` is replaced by ``try_jobdir`` * ``masterstatus`` is replaced by ``try_masterstatus`` Choosing the Builders ##################### A trial build is performed on multiple Builders at the same time, and the developer gets to choose which Builders are used (limited to a set selected by the buildmaster admin with the :class:`TryScheduler`'s ``builderNames=`` argument). The set you choose will depend upon what your goals are: if you are concerned about cross-platform compatibility, you should use multiple Builders, one from each platform of interest. You might use just one builder if that platform has libraries or other facilities that allow better test coverage than what you can accomplish on your own machine, or faster test runs. The set of Builders to use can be specified with multiple :option:`--builder` arguments on the command line. It can also be specified with a single ``try_builders`` option in :file:`.buildbot/options` that uses a list of strings to specify all the Builder names: try_builders = ["full-OSX", "full-win32", "full-linux"] If you are using the PB approach, you can get the names of the builders that are configured for the try scheduler using the ``get-builder-names`` argument: buildbot try --get-builder-names --connect=pb --master=... --username=... --passwd=... Specifying the VC system ######################## The :command:`try` command also needs to know how to take the developer's current tree and extract the (revision, patch) source-stamp pair. Each VC system uses a different process, so you start by telling the :command:`try` command which VC system you are using, with an argument like :option:`--vc=cvs` or :option:`--vc=git`. This can also be provided as ``try_vc`` in :file:`.buildbot/options`. .. The order of this list comes from the end of scripts/tryclient.py The following names are recognized: ``bzr`` ``cvs`` ``darcs`` ``hg`` ``git`` ``mtn`` ``p4`` ``svn`` Finding the top of the tree ########################### Some VC systems (notably CVS and SVN) track each directory more-or-less independently, which means the :command:`try` command needs to move up to the top of the project tree before it will be able to construct a proper full-tree patch. To accomplish this, the :command:`try` command will crawl up through the parent directories until it finds a marker file. The default name for this marker file is :file:`.buildbot-top`, so when you are using CVS or SVN you should ``touch .buildbot-top`` from the top of your tree before running :command:`buildbot try`. Alternatively, you can use a filename like :file:`ChangeLog` or :file:`README`, since many projects put one of these files in their top-most directory (and nowhere else). To set this filename, use ``--topfile=ChangeLog``, or set it in the options file with ``try_topfile = 'ChangeLog'``. You can also manually set the top of the tree with ``--topdir=~/trees/mytree``, or ``try_topdir = '~/trees/mytree'``. If you use ``try_topdir``, in a :file:`.buildbot/options` file, you will need a separate options file for each tree you use, so it may be more convenient to use the ``try_topfile`` approach instead. Other VC systems which work on full projects instead of individual directories (Darcs, Mercurial, Git, Monotone) do not require :command:`try` to know the top directory, so the :option:`--try-topfile` and :option:`--try-topdir` arguments will be ignored. If the :command:`try` command cannot find the top directory, it will abort with an error message. The following command line arguments are deprecated, but retained for backward compatibility: * ``--try-topdir`` is replaced by :option:`--topdir` * ``--try-topfile`` is replaced by :option:`--topfile` Determining the branch name ########################### Some VC systems record the branch information in a way that ``try`` can locate it. For the others, if you are using something other than the default branch, you will have to tell the buildbot which branch your tree is using. You can do this with either the :option:`--branch` argument, or a ``try_branch`` entry in the :file:`.buildbot/options` file. Determining the revision and patch ################################## Each VC system has a separate approach for determining the tree's base revision and computing a patch. CVS :command:`try` pretends that the tree is up to date. It converts the current time into a :option:`-D` time specification, uses it as the base revision, and computes the diff between the upstream tree as of that point in time versus the current contents. This works, more or less, but requires that the local clock be in reasonably good sync with the repository. SVN :command:`try` does a :command:`svn status -u` to find the latest repository revision number (emitted on the last line in the :samp:`Status against revision: {NN}` message). It then performs an :samp:`svn diff -r{NN}` to find out how your tree differs from the repository version, and sends the resulting patch to the buildmaster. If your tree is not up to date, this will result in the ``try`` tree being created with the latest revision, then *backwards* patches applied to bring it ``back`` to the version you actually checked out (plus your actual code changes), but this will still result in the correct tree being used for the build. bzr :command:`try` does a ``bzr revision-info`` to find the base revision, then a ``bzr diff -r$base..`` to obtain the patch. Mercurial ``hg parents --template '{node}\n'`` emits the full revision id (as opposed to the common 12-char truncated) which is a SHA1 hash of the current revision's contents. This is used as the base revision. ``hg diff`` then provides the patch relative to that revision. For :command:`try` to work, your working directory must only have patches that are available from the same remotely-available repository that the build process' ``source.Mercurial`` will use. Perforce :command:`try` does a ``p4 changes -m1 ...`` to determine the latest changelist and implicitly assumes that the local tree is synced to this revision. This is followed by a ``p4 diff -du`` to obtain the patch. A p4 patch differs slightly from a normal diff. It contains full depot paths and must be converted to paths relative to the branch top. To convert the following restriction is imposed. The p4base (see :bb:chsrc:`P4Source`) is assumed to be ``//depot`` Darcs :command:`try` does a ``darcs changes --context`` to find the list of all patches back to and including the last tag that was made. This text file (plus the location of a repository that contains all these patches) is sufficient to re-create the tree. Therefore the contents of this ``context`` file *are* the revision stamp for a Darcs-controlled source tree. It then does a ``darcs diff -u`` to compute the patch relative to that revision. Git ``git branch -v`` lists all the branches available in the local repository along with the revision ID it points to and a short summary of the last commit. The line containing the currently checked out branch begins with ``* `` (star and space) while all the others start with ``  `` (two spaces). :command:`try` scans for this line and extracts the branch name and revision from it. Then it generates a diff against the base revision. .. The spaces in the previous 2 literals are non-breakable spaces   .. todo:: I'm not sure if this actually works the way it's intended since the extracted base revision might not actually exist in the upstream repository. Perhaps we need to add a --remote option to specify the remote tracking branch to generate a diff against. Monotone :command:`mtn automate get_base_revision_id` emits the full revision id which is a SHA1 hash of the current revision's contents. This is used as the base revision. :command:`mtn diff` then provides the patch relative to that revision. For :command:`try` to work, your working directory must only have patches that are available from the same remotely-available repository that the build process' :class:`source.Monotone` will use. patch information ################# You can provide the :option:`--who=dev` to designate who is running the try build. This will add the ``dev`` to the Reason field on the try build's status web page. You can also set ``try_who = dev`` in the :file:`.buildbot/options` file. Note that :option:`--who=dev` will not work on version 0.8.3 or earlier masters. Similarly, :option:`--comment=COMMENT` will specify the comment for the patch, which is also displayed in the patch information. The corresponding config-file option is ``try_comment``. Waiting for results ################### If you provide the :option:`--wait` option (or ``try_wait = True`` in :file:`.buildbot/options`), the ``buildbot try`` command will wait until your changes have either been proven good or bad before exiting. Unless you use the :option:`--quiet` option (or ``try_quiet=True``), it will emit a progress message every 60 seconds until the builds have completed. Sending properties ################## You can set properties to send with your change using either the :option:`--property=key=value` option, which sets a single property, or the :option:`--properties=key1=value1,key2=value2...` option, which sets multiple comma-separated properties. Either of these can be sepcified multiple times. Note that the :option:`--properties` option uses commas to split on properties, so if your property value itself contains a comma, you'll need to use the :option:`--property` option to set it. .. _try--diff: try --diff ++++++++++ Sometimes you might have a patch from someone else that you want to submit to the buildbot. For example, a user may have created a patch to fix some specific bug and sent it to you by email. You've inspected the patch and suspect that it might do the job (and have at least confirmed that it doesn't do anything evil). Now you want to test it out. One approach would be to check out a new local tree, apply the patch, run your local tests, then use ``buildbot try`` to run the tests on other platforms. An alternate approach is to use the ``buildbot try --diff`` form to have the buildbot test the patch without using a local tree. This form takes a :option:`--diff` argument which points to a file that contains the patch you want to apply. By default this patch will be applied to the TRUNK revision, but if you give the optional :option:`--baserev` argument, a tree of the given revision will be used as a starting point instead of TRUNK. You can also use ``buildbot try --diff=-`` to read the patch from :file:`stdin`. Each patch has a ``patchlevel`` associated with it. This indicates the number of slashes (and preceding pathnames) that should be stripped before applying the diff. This exactly corresponds to the :option:`-p` or :option:`--strip` argument to the :command:`patch` utility. By default ``buildbot try --diff`` uses a patchlevel of 0, but you can override this with the :option:`-p` argument. When you use :option:`--diff`, you do not need to use any of the other options that relate to a local tree, specifically :option:`--vc`, :option:`--try-topfile`, or :option:`--try-topdir`. These options will be ignored. Of course you must still specify how to get to the buildmaster (with :option:`--connect`, :option:`--tryhost`, etc). Other Tools ~~~~~~~~~~~ These tools are generally used by buildmaster administrators. .. bb:cmdline:: sendchange sendchange ++++++++++ This command is used to tell the buildmaster about source changes. It is intended to be used from within a commit script, installed on the VC server. It requires that you have a :class:`PBChangeSource` (:bb:chsrc:`PBChangeSource`) running in the buildmaster (by being set in ``c['change_source']``). .. code-block:: none buildbot sendchange --master {MASTERHOST}:{PORT} --auth {USER}:{PASS} --who {USER} {FILENAMES..} The :option:`auth` option specifies the credentials to use to connect to the master, in the form ``user:pass``. If the password is omitted, then sendchange will prompt for it. If both are omitted, the old default (username "change" and password "changepw") will be used. Note that this password is well-known, and should not be used on an internet-accessible port. The :option:`master` and :option:`username` arguments can also be given in the options file (see :ref:`buildbot-config-directory`). There are other (optional) arguments which can influence the ``Change`` that gets submitted: --branch (or option ``branch``) This provides the (string) branch specifier. If omitted, it defaults to ``None``, indicating the ``default branch``. All files included in this Change must be on the same branch. --category (or option ``category``) This provides the (string) category specifier. If omitted, it defaults to ``None``, indicating ``no category``. The category property can be used by :class:`Scheduler`\s to filter what changes they listen to. --project (or option ``project``) This provides the (string) project to which this change applies, and defaults to ''. The project can be used by schedulers to decide which builders should respond to a particular change. --repository (or option ``repository``) This provides the repository from which this change came, and defaults to ``''``. --revision This provides a revision specifier, appropriate to the VC system in use. --revision_file This provides a filename which will be opened and the contents used as the revision specifier. This is specifically for Darcs, which uses the output of ``darcs changes --context`` as a revision specifier. This context file can be a couple of kilobytes long, spanning a couple lines per patch, and would be a hassle to pass as a command-line argument. --property This parameter is used to set a property on the :class:`Change` generated by ``sendchange``. Properties are specified as a :samp:`{name}:{value}` pair, separated by a colon. You may specify many properties by passing this parameter multiple times. --comments This provides the change comments as a single argument. You may want to use :option:`--logfile` instead. --logfile This instructs the tool to read the change comments from the given file. If you use ``-`` as the filename, the tool will read the change comments from stdin. --encoding Specifies the character encoding for all other parameters, defaulting to ``'utf8'``. --vc Specifies which VC system the Change is coming from, one of: ``cvs``, ``svn``, ``darcs``, ``hg``, ``bzr``, ``git``, ``mtn``, or ``p4``. Defaults to ``None``. .. bb:cmdline:: debugclient debugclient +++++++++++ .. code-block:: none buildbot debugclient --master {MASTERHOST}:{PORT} --passwd {DEBUGPW} This launches a small Gtk+/Glade-based debug tool, connecting to the buildmaster's ``debug port``. This debug port shares the same port number as the slaveport (see :ref:`Setting-the-PB-Port-for-Slaves`), but the ``debugPort`` is only enabled if you set a debug password in the buildmaster's config file (see :ref:`Debug-Options`). The :option:`--passwd` option must match the ``c['debugPassword']`` value. :option:`--master` can also be provided in :file:`.debug/options` by the ``master`` key. :option:`--passwd` can be provided by the ``debugPassword`` key. See :ref:`buildbot-config-directory`. The :guilabel:`Connect` button must be pressed before any of the other buttons will be active. This establishes the connection to the buildmaster. The other sections of the tool are as follows: :guilabel:`Reload .cfg` Forces the buildmaster to reload its :file:`master.cfg` file. This is equivalent to sending a SIGHUP to the buildmaster, but can be done remotely through the debug port. Note that it is a good idea to be watching the buildmaster's :file:`twistd.log` as you reload the config file, as any errors which are detected in the config file will be announced there. :guilabel:`Rebuild .py` (not yet implemented). The idea here is to use Twisted's ``rebuild`` facilities to replace the buildmaster's running code with a new version. Even if this worked, it would only be used by buildbot developers. :guilabel:`poke IRC` This locates a :class:`words.IRC` status target and causes it to emit a message on all the channels to which it is currently connected. This was used to debug a problem in which the buildmaster lost the connection to the IRC server and did not attempt to reconnect. :guilabel:`Commit` This allows you to inject a :class:`Change`, just as if a real one had been delivered by whatever VC hook you are using. You can set the name of the committed file and the name of the user who is doing the commit. Optionally, you can also set a revision for the change. If the revision you provide looks like a number, it will be sent as an integer, otherwise it will be sent as a string. :guilabel:`Force Build` This lets you force a :class:`Builder` (selected by name) to start a build of the current source tree. :guilabel:`Currently` (obsolete). This was used to manually set the status of the given :class:`Builder`, but the status-assignment code was changed in an incompatible way and these buttons are no longer meaningful. .. bb:cmdline:: user user ++++ Note that in order to use this command, you need to configure a `CommandlineUserManager` instance in your `master.cfg` file, which is explained in :ref:`Users-Options`. This command allows you to manage users in buildbot's database. No extra requirements are needed to use this command, aside from the Buildmaster running. For details on how Buildbot manages users, see :ref:`Concepts-Users`. --master The :command:`user` command can be run virtually anywhere provided a location of the running buildmaster. The :option:`master` argument is of the form ``{MASTERHOST}:{PORT}``. --username PB connection authentication that should match the arguments to `CommandlineUserManager`. --passwd PB connection authentication that should match the arguments to `CommandlineUserManager`. --op There are four supported values for the :option:`op` argument: :option:`add`, :option:`update`, :option:`remove`, and :option:`get`. Each are described in full in the following sections. --bb_username Used with the :option:`update` option, this sets the user's username for web authentication in the database. It requires :option:`bb_password` to be set along with it. --bb_password Also used with the :option:`update` option, this sets the password portion of a user's web authentication credentials into the database. The password is first encrypted prior to storage for security reasons. --ids When working with users, you need to be able to refer to them by unique identifiers to find particular users in the database. The :option:`ids` option lets you specify a comma separated list of these identifiers for use with the :command:`user` command. The :option:`ids` option is used only when using :option:`remove` or :option:`show`. --info Users are known in buildbot as a collection of attributes tied together by some unique identifier (see :ref:`Concepts-Users`). These attributes are specified in the form ``{TYPE}={VALUE}`` when using the :option:`info` option. These ``{TYPE}={VALUE}`` pairs are specified in a comma separated list, so for example: .. code-block:: none --info=svn=jschmo,git='Joe Schmo ' The :option:`info` option can be specified multiple times in the :command:`user` command, as each specified option will be interpreted as a new user. Note that :option:`info` is only used with :option:`add` or with :option:`update`, and whenever you use :option:`update` you need to specify the identifier of the user you want to update. This is done by prepending the :option:`info` arguments with ``{ID:}``. If we were to update ``'jschmo'`` from the previous example, it would look like this: .. code-block:: none --info=jschmo:git='Joseph Schmo ' Note that :option:`--master`, :option:`--username`, :option:`--passwd`, and :option:`--op` are always required to issue the :command:`user` command. The :option:`--master`, :option:`--username`, and :option:`--passwd` options can be specified in the option file with keywords :option:`user_master`, :option:`user_username`, and :option:`user_passwd`, respectively. If :option:`user_master` is not specified, then :option:`master` from the options file will be used instead. Below are examples of how each command should look. Whenever a :command:`user` command is successful, results will be shown to whoever issued the command. For :option:`add`: .. code-block:: none buildbot user --master={MASTERHOST} --op=add \ --username={USER} --passwd={USERPW} \ --info={TYPE}={VALUE},... For :option:`update`: .. code-block:: none buildbot user --master={MASTERHOST} --op=update \ --username={USER} --passwd={USERPW} \ --info={ID}:{TYPE}={VALUE},... For :option:`remove`: .. code-block:: none buildbot user --master={MASTERHOST} --op=remove \ --username={USER} --passwd={USERPW} \ --ids={ID1},{ID2},... For :option:`get`: .. code-block:: none buildbot user --master={MASTERHOST} --op=get \ --username={USER} --passwd={USERPW} \ --ids={ID1},{ID2},... A note on :option:`update`: when updating the :option:`bb_username` and :option:`bb_password`, the :option:`info` doesn't need to have additional ``{TYPE}={VALUE}`` pairs to update and can just take the ``{ID}`` portion. .. _buildbot-config-directory: .buildbot config directory ~~~~~~~~~~~~~~~~~~~~~~~~~~ Many of the :command:`buildbot` tools must be told how to contact the buildmaster that they interact with. This specification can be provided as a command-line argument, but most of the time it will be easier to set them in an ``options`` file. The :command:`buildbot` command will look for a special directory named :file:`.buildbot`, starting from the current directory (where the command was run) and crawling upwards, eventually looking in the user's home directory. It will look for a file named :file:`options` in this directory, and will evaluate it as a Python script, looking for certain names to be set. You can just put simple ``name = 'value'`` pairs in this file to set the options. For a description of the names used in this file, please see the documentation for the individual :command:`buildbot` sub-commands. The following is a brief sample of what this file's contents could be. .. code-block:: none # for status-reading tools masterstatus = 'buildbot.example.org:12345' # for 'sendchange' or the debug port master = 'buildbot.example.org:18990' debugPassword = 'eiv7Po' Note carefully that the names in the :file:`options` file usually do not match the command-line option name. ``masterstatus`` Equivalent to :option:`--master` for :bb:cmdline:`statuslog` and :bb:cmdline:`statusgui`, this gives the location of the :class:`client.PBListener` status port. ``master`` Equivalent to :option:`--master` for :bb:cmdline:`debugclient` and :bb:cmdline:`sendchange`. This option is used for two purposes. It is the location of the ``debugPort`` for ``debugclient`` and the location of the :class:`pb.PBChangeSource` for ```sendchange``. Generally these are the same port. ``debugPassword`` Equivalent to :option:`--passwd` for :bb:cmdline:`debugclient`. .. important:: This value must match the value of :bb:cfg:`debugPassword`, used to protect the debug port, for the :bb:cmdline:`debugclient` command. ``username`` Equivalent to :option:`--username` for the :bb:cmdline:`sendchange` command. ``branch`` Equivalent to :option:`--branch` for the :bb:cmdline:`sendchange` command. ``category`` Equivalent to :option:`--category` for the :bb:cmdline:`sendchange` command. ``try_connect`` Equivalent to :option:`--connect`, this specifies how the :bb:cmdline:`try` command should deliver its request to the buildmaster. The currently accepted values are ``ssh`` and ``pb``. ``try_builders`` Equivalent to :option:`--builders`, specifies which builders should be used for the :bb:cmdline:`try` build. ``try_vc`` Equivalent to :option:`--vc` for :bb:cmdline:`try`, this specifies the version control system being used. ``try_branch`` Equivalent to :option:`--branch`, this indicates that the current tree is on a non-trunk branch. ``try_topdir`` ``try_topfile`` Use ``try_topdir``, equivalent to :option:`--try-topdir`, to explicitly indicate the top of your working tree, or ``try_topfile``, equivalent to :option:`--try-topfile` to name a file that will only be found in that top-most directory. ``try_host`` ``try_username`` ``try_dir`` When ``try_connect`` is ``ssh``, the command will use ``try_host`` for :option:`--tryhost`, ``try_username`` for :option:`--username`, and ``try_dir`` for :option:`--trydir`. Apologies for the confusing presence and absence of 'try'. ``try_username`` ``try_password`` ``try_master`` Similarly, when ``try_connect`` is ``pb``, the command will pay attention to ``try_username`` for :option:`--username`, ``try_password`` for :option:`--passwd`, and ``try_master`` for :option:`--master`. ``try_wait`` ``masterstatus`` ``try_wait`` and ``masterstatus`` (equivalent to :option:`--wait` and ``master``, respectively) are used to ask the :bb:cmdline:`try` command to wait for the requested build to complete. buildslave ---------- :command:`buildslave` command-line tool is used for buildslave management only and does not provide any additional functionality. One can create, start, stop and restart the buildslave. .. bb:cmdline:: create-slave create-slave ~~~~~~~~~~~~ This creates a new directory and populates it with files that let it be used as a buildslave's base directory. You must provide several arguments, which are used to create the initial :file:`buildbot.tac` file. The :option:`-r` option is advisable here, just like for ``create-master``. .. code-block:: none buildslave create-slave -r {BASEDIR} {MASTERHOST}:{PORT} {SLAVENAME} {PASSWORD} The create-slave options are described in :ref:`Buildslave-Options`. .. bb:cmdline:: start (buildslave) start ~~~~~ This starts a buildslave which was already created in the given base directory. The daemon is launched in the background, with events logged to a file named :file:`twistd.log`. .. code-block:: none buildslave start [--nodaemon] BASEDIR The :option:`--nodaemon` option instructs Buildbot to skip daemonizing. The process will start in the foreground. It will only return to the command-line when it is stopped. .. bb:cmdline:: restart (buildslave) restart ~~~~~~~ .. code-block:: none buildslave restart [--nodaemon] BASEDIR This restarts a buildslave which is already running. It is equivalent to a ``stop`` followed by a ``start``. The :option:`--nodaemon` option has the same meaning as for ``start``. .. bb:cmdline:: stop (buildslave) stop ~~~~ This terminates the daemon buildslave running in the given directory. .. code-block:: none buildbot stop BASEDIR buildbot-0.8.8/docs/manual/concepts.rst000066400000000000000000001110571222546025000201130ustar00rootroot00000000000000Concepts ======== This chapter defines some of the basic concepts that the Buildbot uses. You'll need to understand how the Buildbot sees the world to configure it properly. .. index: repository .. index: codebase .. index: project .. index: revision .. index: branch .. index: source stamp .. _Source-Stamps: Source Stamps ------------- Source code comes from *repositories*, provided by version control systems. Repositories are generally identified by URLs, e.g., ``git://github.com/buildbot/buildbot.git``. In these days of distributed version control systems, the same *codebase* may appear in multiple repositories. For example, ``https://github.com/mozilla/mozilla-central`` and ``http://hg.mozilla.org/mozilla-release`` both contain the Firefox codebase, although not exactly the same code. Many *projects* are built from multiple codebases. For example, a company may build several applications based on the same core library. The "app" codebase and the "core" codebase are in separate repositories, but are compiled together and constitute a single project. Changes to either codebase should cause a rebuild of the application. Most version control systems define some sort of *revision* that can be used (sometimes in combination with a *branch*) to uniquely specify a particular version of the source code. To build a project, Buildbot needs to know exactly which version of each codebase it should build. It uses a *source stamp* to do so for each codebase; the collection of sourcestamps required for a project is called a *source stamp set*. .. index: change .. _Version-Control-Systems: Version Control Systems ----------------------- Buildbot supports a significant number of version control systems, so it treats them abstractly. For purposes of deciding when to perform builds, Buildbot's change sources monitor repositories, and represent any updates to those repositories as *changes*. These change sources fall broadly into two categories: pollers which periodically check the repository for updates; and hooks, where the repository is configured to notify Buildbot whenever an update occurs. This concept does not map perfectly to every version control system. For example, for CVS Buildbot must guess that version updates made to multiple files within a short time represent a single change; for DVCS's like Git, Buildbot records a change when a commit is pushed to the monitored repository, not when it is initially committed. We assume that the :class:`Change`\s arrive at the master in the same order in which they are committed to the repository. When it comes time to actually perform a build, a scheduler prepares a source stamp set, as described above, based on its configuration. When the build begins, one or more source steps use the information in the source stamp set to actually check out the source code, using the normal VCS commands. Tree Stability ~~~~~~~~~~~~~~ Changes tend to arrive at a buildmaster in bursts. In many cases, these bursts of changes are meant to be taken together. For example, a developer may have pushed multiple commits to a DVCS that comprise the same new feature or bugfix. To avoid trying to build every change, Buildbot supports the notion of *tree stability*, by waiting for a burst of changes to finish before starting to schedule builds. This is implemented as a timer, with builds not scheduled until no changes have occurred for the duration of the timer. .. _How-Different-VC-Systems-Specify-Sources: How Different VC Systems Specify Sources ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ For CVS, the static specifications are *repository* and *module*. In addition to those, each build uses a timestamp (or omits the timestamp to mean *the latest*) and *branch tag* (which defaults to ``HEAD``). These parameters collectively specify a set of sources from which a build may be performed. `Subversion `_, combines the repository, module, and branch into a single *Subversion URL* parameter. Within that scope, source checkouts can be specified by a numeric *revision number* (a repository-wide monotonically-increasing marker, such that each transaction that changes the repository is indexed by a different revision number), or a revision timestamp. When branches are used, the repository and module form a static ``baseURL``, while each build has a *revision number* and a *branch* (which defaults to a statically-specified ``defaultBranch``). The ``baseURL`` and ``branch`` are simply concatenated together to derive the ``svnurl`` to use for the checkout. `Perforce `_ is similar. The server is specified through a ``P4PORT`` parameter. Module and branch are specified in a single depot path, and revisions are depot-wide. When branches are used, the ``p4base`` and ``defaultBranch`` are concatenated together to produce the depot path. `Bzr `_ (which is a descendant of Arch/Bazaar, and is frequently referred to as "Bazaar") has the same sort of repository-vs-workspace model as Arch, but the repository data can either be stored inside the working directory or kept elsewhere (either on the same machine or on an entirely different machine). For the purposes of Buildbot (which never commits changes), the repository is specified with a URL and a revision number. The most common way to obtain read-only access to a bzr tree is via HTTP, simply by making the repository visible through a web server like Apache. Bzr can also use FTP and SFTP servers, if the buildslave process has sufficient privileges to access them. Higher performance can be obtained by running a special Bazaar-specific server. None of these matter to the buildbot: the repository URL just has to match the kind of server being used. The ``repoURL`` argument provides the location of the repository. Branches are expressed as subdirectories of the main central repository, which means that if branches are being used, the BZR step is given a ``baseURL`` and ``defaultBranch`` instead of getting the ``repoURL`` argument. `Darcs `_ doesn't really have the notion of a single master repository. Nor does it really have branches. In Darcs, each working directory is also a repository, and there are operations to push and pull patches from one of these ``repositories`` to another. For the Buildbot's purposes, all you need to do is specify the URL of a repository that you want to build from. The build slave will then pull the latest patches from that repository and build them. Multiple branches are implemented by using multiple repositories (possibly living on the same server). Builders which use Darcs therefore have a static ``repourl`` which specifies the location of the repository. If branches are being used, the source Step is instead configured with a ``baseURL`` and a ``defaultBranch``, and the two strings are simply concatenated together to obtain the repository's URL. Each build then has a specific branch which replaces ``defaultBranch``, or just uses the default one. Instead of a revision number, each build can have a ``context``, which is a string that records all the patches that are present in a given tree (this is the output of ``darcs changes --context``, and is considerably less concise than, e.g. Subversion's revision number, but the patch-reordering flexibility of Darcs makes it impossible to provide a shorter useful specification). `Mercurial `_ is like Darcs, in that each branch is stored in a separate repository. The ``repourl``, ``baseURL``, and ``defaultBranch`` arguments are all handled the same way as with Darcs. The *revision*, however, is the hash identifier returned by ``hg identify``. `Git `_ also follows a decentralized model, and each repository can have several branches and tags. The source Step is configured with a static ``repourl`` which specifies the location of the repository. In addition, an optional ``branch`` parameter can be specified to check out code from a specific branch instead of the default *master* branch. The *revision* is specified as a SHA1 hash as returned by e.g. ``git rev-parse``. No attempt is made to ensure that the specified revision is actually a subset of the specified branch. `Monotone `_ is another that follows a decentralized model where each repository can have several branches and tags. The source Step is configured with static ``repourl`` and ``branch`` parameters, which specifies the location of the repository and the branch to use. The *revision* is specified as a SHA1 hash as returned by e.g. ``mtn automate select w:``. No attempt is made to ensure that the specified revision is actually a subset of the specified branch. .. index: change .. _Attributes-of-Changes: Changes ------- .. _Attr-Who: Who ~~~ Each :class:`Change` has a :attr:`who` attribute, which specifies which developer is responsible for the change. This is a string which comes from a namespace controlled by the VC repository. Frequently this means it is a username on the host which runs the repository, but not all VC systems require this. Each :class:`StatusNotifier` will map the :attr:`who` attribute into something appropriate for their particular means of communication: an email address, an IRC handle, etc. This ``who`` attribute is also parsed and stored into Buildbot's database (see :ref:`User-Objects`). Currently, only ``who`` attributes in Changes from ``git`` repositories are translated into user objects, but in the future all incoming Changes will have their ``who`` parsed and stored. .. _Attr-Files: Files ~~~~~ It also has a list of :attr:`files`, which are just the tree-relative filenames of any files that were added, deleted, or modified for this :class:`Change`. These filenames are used by the :func:`fileIsImportant` function (in the :class:`Scheduler`) to decide whether it is worth triggering a new build or not, e.g. the function could use the following function to only run a build if a C file were checked in:: def has_C_files(change): for name in change.files: if name.endswith(".c"): return True return False Certain :class:`BuildStep`\s can also use the list of changed files to run a more targeted series of tests, e.g. the ``python_twisted.Trial`` step can run just the unit tests that provide coverage for the modified .py files instead of running the full test suite. .. _Attr-Comments: Comments ~~~~~~~~ The Change also has a :attr:`comments` attribute, which is a string containing any checkin comments. .. _Attr-Project: Project ~~~~~~~ The :attr:`project` attribute of a change or source stamp describes the project to which it corresponds, as a short human-readable string. This is useful in cases where multiple independent projects are built on the same buildmaster. In such cases, it can be used to control which builds are scheduled for a given commit, and to limit status displays to only one project. .. _Attr-Repository: Repository ~~~~~~~~~~ This attribute specifies the repository in which this change occurred. In the case of DVCS's, this information may be required to check out the committed source code. However, using the repository from a change has security risks: if Buildbot is configured to blindly trust this information, then it may easily be tricked into building arbitrary source code, potentially compromising the buildslaves and the integrity of subsequent builds. .. _Attr-Codebase: Codebase ~~~~~~~~ This attribute specifies the codebase to which this change was made. As described :ref:`above `, multiple repositories may contain the same codebase. A change's codebase is usually determined by the :bb:cfg:`codebaseGenerator` configuration. By default the codebase is ''; this value is used automatically for single-codebase configurations. .. _Attr-Revision: Revision ~~~~~~~~ Each Change can have a :attr:`revision` attribute, which describes how to get a tree with a specific state: a tree which includes this Change (and all that came before it) but none that come after it. If this information is unavailable, the :attr:`revision` attribute will be ``None``. These revisions are provided by the :class:`ChangeSource`. Revisions are always strings. `CVS` :attr:`revision` is the seconds since the epoch as an integer. `SVN` :attr:`revision` is the revision number `Darcs` :attr:`revision` is a large string, the output of :command:`darcs changes --context` `Mercurial` :attr:`revision` is a short string (a hash ID), the output of :command:`hg identify` `P4` :attr:`revision` is the transaction number `Git` :attr:`revision` is a short string (a SHA1 hash), the output of e.g. :command:`git rev-parse` Branches ~~~~~~~~ The Change might also have a :attr:`branch` attribute. This indicates that all of the Change's files are in the same named branch. The Schedulers get to decide whether the branch should be built or not. For VC systems like CVS, Git and Monotone the :attr:`branch` name is unrelated to the filename. (that is, the branch name and the filename inhabit unrelated namespaces). For SVN, branches are expressed as subdirectories of the repository, so the file's ``svnurl`` is a combination of some base URL, the branch name, and the filename within the branch. (In a sense, the branch name and the filename inhabit the same namespace). Darcs branches are subdirectories of a base URL just like SVN. Mercurial branches are the same as Darcs. `CVS` branch='warner-newfeature', files=['src/foo.c'] `SVN` branch='branches/warner-newfeature', files=['src/foo.c'] `Darcs` branch='warner-newfeature', files=['src/foo.c'] `Mercurial` branch='warner-newfeature', files=['src/foo.c'] `Git` branch='warner-newfeature', files=['src/foo.c'] `Monotone` branch='warner-newfeature', files=['src/foo.c'] Change Properties ~~~~~~~~~~~~~~~~~ A Change may have one or more properties attached to it, usually specified through the Force Build form or :bb:cmdline:`sendchange`. Properties are discussed in detail in the :ref:`Build-Properties` section. .. _Scheduling-Builds: Scheduling Builds ----------------- Each Buildmaster has a set of :class:`Scheduler` objects, each of which gets a copy of every incoming :class:`Change`. The Schedulers are responsible for deciding when :class:`Build`\s should be run. Some Buildbot installations might have a single :class:`Scheduler`, while others may have several, each for a different purpose. For example, a *quick* scheduler might exist to give immediate feedback to developers, hoping to catch obvious problems in the code that can be detected quickly. These typically do not run the full test suite, nor do they run on a wide variety of platforms. They also usually do a VC update rather than performing a brand-new checkout each time. A separate *full* scheduler might run more comprehensive tests, to catch more subtle problems. configured to run after the quick scheduler, to give developers time to commit fixes to bugs caught by the quick scheduler before running the comprehensive tests. This scheduler would also feed multiple :class:`Builder`\s. Many schedulers can be configured to wait a while after seeing a source-code change - this is the *tree stable timer*. The timer allows multiple commits to be "batched" together. This is particularly useful in distributed version control systems, where a developer may push a long sequence of changes all at once. To save resources, it's often desirable only to test the most recent change. Schedulers can also filter out the changes they are interested in, based on a number of criteria. For example, a scheduler that only builds documentation might skip any changes that do not affect the documentation. Schedulers can also filter on the branch to which a commit was made. There is some support for configuring dependencies between builds - for example, you may want to build packages only for revisions which pass all of the unit tests. This support is under active development in Buildbot, and is referred to as "build coordination". Periodic builds (those which are run every N seconds rather than after new Changes arrive) are triggered by a special :class:`Periodic` Scheduler subclass. Each Scheduler creates and submits :class:`BuildSet` objects to the :class:`BuildMaster`, which is then responsible for making sure the individual :class:`BuildRequests` are delivered to the target :class:`Builder`\s. :class:`Scheduler` instances are activated by placing them in the ``c['schedulers']`` list in the buildmaster config file. Each :class:`Scheduler` has a unique name. .. _BuildSet: BuildSets --------- A :class:`BuildSet` is the name given to a set of :class:`Build`\s that all compile/test the same version of the tree on multiple :class:`Builder`\s. In general, all these component :class:`Build`\s will perform the same sequence of :class:`Step`\s, using the same source code, but on different platforms or against a different set of libraries. The :class:`BuildSet` is tracked as a single unit, which fails if any of the component :class:`Build`\s have failed, and therefore can succeed only if *all* of the component :class:`Build`\s have succeeded. There are two kinds of status notification messages that can be emitted for a :class:`BuildSet`: the ``firstFailure`` type (which fires as soon as we know the :class:`BuildSet` will fail), and the ``Finished`` type (which fires once the :class:`BuildSet` has completely finished, regardless of whether the overall set passed or failed). A :class:`BuildSet` is created with set of one or more *source stamp* tuples of ``(branch, revision, changes, patch)``, some of which may be ``None``, and a list of :class:`Builder`\s on which it is to be run. They are then given to the BuildMaster, which is responsible for creating a separate :class:`BuildRequest` for each :class:`Builder`. There are a couple of different likely values for the ``SourceStamp``: :samp:`(revision=None, changes={CHANGES}, patch=None)` This is a :class:`SourceStamp` used when a series of :class:`Change`\s have triggered a build. The VC step will attempt to check out a tree that contains *CHANGES* (and any changes that occurred before *CHANGES*, but not any that occurred after them.) :samp:`(revision=None, changes=None, patch=None)` This builds the most recent code on the default branch. This is the sort of :class:`SourceStamp` that would be used on a :class:`Build` that was triggered by a user request, or a :class:`Periodic` scheduler. It is also possible to configure the VC Source Step to always check out the latest sources rather than paying attention to the :class:`Change`\s in the :class:`SourceStamp`, which will result in same behavior as this. :samp:`(branch={BRANCH}, revision=None, changes=None, patch=None)` This builds the most recent code on the given *BRANCH*. Again, this is generally triggered by a user request or :class:`Periodic` build. :samp:`(revision={REV}, changes=None, patch=({LEVEL}, {DIFF}, {SUBDIR_ROOT}))` This checks out the tree at the given revision *REV*, then applies a patch (using ``patch -pLEVEL ``. ``svn`` ``who`` attributes are of the form ``Username``. ``hg`` ``who`` attributes are free-form strings, but usually adhere to similar conventions as ``git`` attributes (``Full Name ``). ``cvs`` ``who`` attributes are of the form ``Username``. ``darcs`` ``who`` attributes contain an ``Email`` and may also include a ``Full Name`` like ``git`` attributes. ``bzr`` ``who`` attributes are free-form strings like ``hg``, and can include a ``Username``, ``Email``, and/or ``Full Name``. Tools +++++ For managing users manually, use the ``buildbot user`` command, which allows you to add, remove, update, and show various attributes of users in the Buildbot database (see :ref:`Command-line-Tool`). To show all of the users in the database in a more pretty manner, use the users page in the :bb:Status:`WebStatus`. Uses ++++ Correlating the various bits and pieces that Buildbot views as users also means that one attribute of a user can be translated into another. This provides a more complete view of users throughout Buildbot. One such use is being able to find email addresses based on a set of Builds to notify users through the ``MailNotifier``. This process is explained more clearly in :ref:``Email-Addresses``. Another way to utilize `User Objects` is through `UsersAuth` for web authentication (see :bb:status:`WebStatus`). To use `UsersAuth`, you need to set a `bb_username` and `bb_password` via the ``buildbot user`` command line tool to check against. The password will be encrypted before storing in the database along with other user attributes. .. _Doing-Things-With-Users: Doing Things With Users ~~~~~~~~~~~~~~~~~~~~~~~ Each change has a single user who is responsible for it. Most builds have a set of changes: the build generally represents the first time these changes have been built and tested by the Buildbot. The build has a *blamelist* that is the union of the users responsible for all the build's changes. If the build was created by a :ref:`Try-Schedulers` this list will include the submitter of the try job, if known. The build provides a list of users who are interested in the build -- the *interested users*. Usually this is equal to the blamelist, but may also be expanded, e.g., to include the current build sherrif or a module's maintainer. If desired, the buildbot can notify the interested users until the problem is resolved. .. _Email-Addresses: Email Addresses ~~~~~~~~~~~~~~~ The :bb:status:`MailNotifier` is a status target which can send email about the results of each build. It accepts a static list of email addresses to which each message should be delivered, but it can also be configured to send mail to the :class:`Build`\'s Interested Users. To do this, it needs a way to convert User names into email addresses. For many VC systems, the User Name is actually an account name on the system which hosts the repository. As such, turning the name into an email address is a simple matter of appending ``@repositoryhost.com``. Some projects use other kinds of mappings (for example the preferred email address may be at ``project.org`` despite the repository host being named ``cvs.project.org``), and some VC systems have full separation between the concept of a user and that of an account on the repository host (like Perforce). Some systems (like Git) put a full contact email address in every change. To convert these names to addresses, the :class:`MailNotifier` uses an :class:`EmailLookup` object. This provides a :meth:`getAddress` method which accepts a name and (eventually) returns an address. The default :class:`MailNotifier` module provides an :class:`EmailLookup` which simply appends a static string, configurable when the notifier is created. To create more complex behaviors (perhaps using an LDAP lookup, or using ``finger`` on a central host to determine a preferred address for the developer), provide a different object as the ``lookup`` argument. If an EmailLookup object isn't given to the MailNotifier, the MailNotifier will try to find emails through :ref:`User-Objects`. This will work the same as if an EmailLookup object was used if every user in the Build's Interested Users list has an email in the database for them. If a user whose change led to a Build doesn't have an email attribute, that user will not receive an email. If ``extraRecipients`` is given, those users are still sent mail when the EmailLookup object is not specified. In the future, when the Problem mechanism has been set up, the Buildbot will need to send mail to arbitrary Users. It will do this by locating a :class:`MailNotifier`\-like object among all the buildmaster's status targets, and asking it to send messages to various Users. This means the User-to-address mapping only has to be set up once, in your :class:`MailNotifier`, and every email message the buildbot emits will take advantage of it. .. _IRC-Nicknames: IRC Nicknames ~~~~~~~~~~~~~ Like :class:`MailNotifier`, the :class:`buildbot.status.words.IRC` class provides a status target which can announce the results of each build. It also provides an interactive interface by responding to online queries posted in the channel or sent as private messages. In the future, the buildbot can be configured map User names to IRC nicknames, to watch for the recent presence of these nicknames, and to deliver build status messages to the interested parties. Like :class:`MailNotifier` does for email addresses, the :class:`IRC` object will have an :class:`IRCLookup` which is responsible for nicknames. The mapping can be set up statically, or it can be updated by online users themselves (by claiming a username with some kind of ``buildbot: i am user warner`` commands). Once the mapping is established, the rest of the buildbot can ask the :class:`IRC` object to send messages to various users. It can report on the likelihood that the user saw the given message (based upon how long the user has been inactive on the channel), which might prompt the Problem Hassler logic to send them an email message instead. These operations and authentication of commands issued by particular nicknames will be implemented in :ref:`User-Objects`. .. _Live-Status-Clients: Live Status Clients ~~~~~~~~~~~~~~~~~~~ The Buildbot also offers a desktop status client interface which can display real-time build status in a GUI panel on the developer's desktop. .. index:: Properties .. _Build-Properties: Build Properties ---------------- Each build has a set of *Build Properties*, which can be used by its build steps to modify their actions. These properties, in the form of key-value pairs, provide a general framework for dynamically altering the behavior of a build based on its circumstances. Properties form a simple kind of variable in a build. Some properties are set when the build starts, and properties can be changed as a build progresses -- properties set or changed in one step may be accessed in subsequent steps. Property values can be numbers, strings, lists, or dictionaries - basically, anything that can be represented in JSON. Properties are very flexible, and can be used to implement all manner of functionality. Here are some examples: Most Source steps record the revision that they checked out in the ``got_revision`` property. A later step could use this property to specify the name of a fully-built tarball, dropped in an easily-accessible directory for later testing. .. note:: In builds with more than one codebase, the ``got_revision`` property is a dictionary, keyed by codebase. Some projects want to perform nightly builds as well as building in response to committed changes. Such a project would run two schedulers, both pointing to the same set of builders, but could provide an ``is_nightly`` property so that steps can distinguish the nightly builds, perhaps to run more resource-intensive tests. Some projects have different build processes on different systems. Rather than create a build factory for each slave, the steps can use buildslave properties to identify the unique aspects of each slave and adapt the build process dynamically. .. _Multiple-Codebase-Builds: Multiple-Codebase Builds ------------------------ What if an end-product is composed of code from several codebases? Changes may arrive from different repositories within the tree-stable-timer period. Buildbot will not only use the source-trees that contain changes but also needs the remaining source-trees to build the complete product. For this reason a :ref:`Scheduler` can be configured to base a build on a set of several source-trees that can (partly) be overridden by the information from incoming :class:`Change`\s. As described :ref:`above `, the source for each codebase is identified by a source stamp, containing its repository, branch and revision. A full build set will specify a source stamp set describing the source to use for each codebase. Configuring all of this takes a coordinated approach. A complete multiple repository configuration consists of: - a *codebase generator* Every relevant change arriving from a VC must contain a codebase. This is done by a :bb:cfg:`codebaseGenerator` that is defined in the configuration. Most generators examine the repository of a change to determine its codebase, using project-specific rules. - some *schedulers* Each :bb:cfg:`scheduler` has to be configured with a set of all required ``codebases`` to build a product. These codebases indicate the set of required source-trees. In order for the scheduler to be able to produce a complete set for each build, the configuration can give a default repository, branch, and revision for each codebase. When a scheduler must generate a source stamp for a codebase that has received no changes, it applies these default values. - multiple *source steps* - one for each codebase A :ref:`Builder`'s build factory must include a :ref:`source step` for each codebase. Each of the source steps has a ``codebase`` attribute which is used to select an appropriate source stamp from the source stamp set for a build. This information comes from the arrived changes or from the scheduler's configured default values. .. note:: Each :ref:`source step` has to have its own ``workdir`` set in order for the checkout to be done for each codebase in its own directory. .. note:: Ensure you specify the codebase within your source step's Interpolate() calls (ex. ``http://.../svn/%(src:codebase:branch)s)``. See :ref:`Interpolate` for details. .. warning:: Defining a :bb:cfg:`codebaseGenerator` that returns non-empty (not ``''``) codebases will change the behavior of all the schedulers. buildbot-0.8.8/docs/manual/configuration.rst000066400000000000000000000014271222546025000211430ustar00rootroot00000000000000.. _Configuration: Configuration ============= The following sections describe the configuration of the various Buildbot components. The information available here is sufficient to create basic build and test configurations, and does not assume great familiarity with Python. In more advanced Buildbot configurations, Buildbot acts as a framework for a continuous-integration application. The next section, :doc:`customization`, describes this approach, with frequent references into the :ref:`development documentation `. .. toctree:: :maxdepth: 2 cfg-intro cfg-global cfg-changesources cfg-schedulers cfg-buildslaves cfg-builders cfg-buildfactories cfg-properties cfg-buildsteps cfg-interlocks cfg-statustargets buildbot-0.8.8/docs/manual/customization.rst000066400000000000000000001372541222546025000212140ustar00rootroot00000000000000.. _Customization: Customization ============= For advanced users, Buildbot acts as a framework supporting a customized build application. For the most part, such configurations consist of subclasses set up for use in a regular Buildbot configuration file. This chapter describes some of the more common idioms in advanced Buildbot configurations. At the moment, this chapter is an unordered set of suggestions; if you'd like to clean it up, fork the project on GitHub and get started! Programmatic Configuration Generation ------------------------------------- Bearing in mind that ``master.cfg`` is a Python file, large configurations can be shortened considerably by judicious use of Python loops. For example, the following will generate a builder for each of a range of supported versions of Python:: pythons = [ 'python2.4', 'python2.5', 'python2.6', 'python2.7', 'python3.2', python3.3' ] pytest_slaves = [ "slave%s" % n for n in range(10) ] for python in pythons: f = BuildFactory() f.addStep(SVN(..)) f.addStep(ShellCommand(command=[ python, 'test.py' ])) c['builders'].append(BuilderConfig( name="test-%s" % python, factory=f, slavenames=pytest_slaves)) .. _Merge-Request-Functions: Merge Request Functions ----------------------- .. index:: Builds; merging The logic Buildbot uses to decide which build request can be merged can be customized by providing a Python function (a callable) instead of ``True`` or ``False`` described in :ref:`Merging-Build-Requests`. The callable will be invoked with three positional arguments: a :class:`Builder` object and two :class:`BuildRequest` objects. It should return true if the requests can be merged, and False otherwise. For example:: def mergeRequests(builder, req1, req2): "any requests with the same branch can be merged" return req1.source.branch == req2.source.branch c['mergeRequests'] = mergeRequests In many cases, the details of the :class:`SourceStamp`\s and :class:`BuildRequest`\s are important. In this example, only :class:`BuildRequest`\s with the same "reason" are merged; thus developers forcing builds for different reasons will see distinct builds. Note the use of the :func:`canBeMergedWith` method to access the source stamp compatibility algorithm. :: def mergeRequests(builder, req1, req2): if req1.source.canBeMergedWith(req2.source) and req1.reason == req2.reason: return True return False c['mergeRequests'] = mergeRequests If it's necessary to perform some extended operation to determine whether two requests can be merged, then the ``mergeRequests`` callable may return its result via Deferred. Note, however, that the number of invocations of the callable is proportional to the square of the request queue length, so a long-running callable may cause undesirable delays when the queue length grows. For example:: def mergeRequests(builder, req1, req2): d = defer.gatherResults([ getMergeInfo(req1.source.revision), getMergeInfo(req2.source.revision), ]) def process(info1, info2): return info1 == info2 d.addCallback(process) return d c['mergeRequests'] = mergeRequests .. _Builder-Priority-Functions: Builder Priority Functions -------------------------- .. index:: Builders; priority The :bb:cfg:`prioritizeBuilders` configuration key specifies a function which is called with two arguments: a :class:`BuildMaster` and a list of :class:`Builder` objects. It should return a list of the same :class:`Builder` objects, in the desired order. It may also remove items from the list if builds should not be started on those builders. If necessary, this function can return its results via a Deferred (it is called with ``maybeDeferred``). A simple ``prioritizeBuilders`` implementation might look like this:: def prioritizeBuilders(buildmaster, builders): """Prioritize builders. 'finalRelease' builds have the highest priority, so they should be built before running tests, or creating builds.""" builderPriorities = { "finalRelease": 0, "test": 1, "build": 2, } builders.sort(key=lambda b: builderPriorities.get(b.name, 0)) return builders c['prioritizeBuilders'] = prioritizeBuilders .. index:: Builds; priority .. _Build-Priority-Functions: Build Priority Functions ------------------------ When a builder has multiple pending build requests, it uses a ``nextBuild`` function to decide which build it should start first. This function is given two parameters: the :class:`Builder`, and a list of :class:`BuildRequest` objects representing pending build requests. A simple function to prioritize release builds over other builds might look like this:: def nextBuild(bldr, requests): for r in requests: if r.source.branch == 'release': return r return requests[0] If some non-immediate result must be calculated, the ``nextBuild`` function can also return a Deferred:: def nextBuild(bldr, requests): d = get_request_priorities(requests) def pick(priorities): if requests: return sorted(zip(priorities, requests))[0][1] d.addCallback(pick) return d .. _Customizing-SVNPoller: Customizing SVNPoller --------------------- Each source file that is tracked by a Subversion repository has a fully-qualified SVN URL in the following form: ``({REPOURL})({PROJECT-plus-BRANCH})({FILEPATH})``. When you create the :bb:chsrc:`SVNPoller`, you give it a ``svnurl`` value that includes all of the ``{REPOURL}`` and possibly some portion of the ``{PROJECT-plus-BRANCH}`` string. The :bb:chsrc:`SVNPoller` is responsible for producing Changes that contain a branch name and a ``{FILEPATH}`` (which is relative to the top of a checked-out tree). The details of how these strings are split up depend upon how your repository names its branches. PROJECT/BRANCHNAME/FILEPATH repositories ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ One common layout is to have all the various projects that share a repository get a single top-level directory each, with ``branches``, ``tags``, and ``trunk`` subdirectories: .. code-block:: none amanda/trunk /branches/3_2 /3_3 /tags/3_2_1 /3_2_2 /3_3_0 To set up a :bb:chsrc:`SVNPoller` that watches the Amanda trunk (and nothing else), we would use the following, using the default ``split_file``:: from buildbot.changes.svnpoller import SVNPoller c['change_source'] = SVNPoller( svnurl="https://svn.amanda.sourceforge.net/svnroot/amanda/amanda/trunk") In this case, every Change that our :bb:chsrc:`SVNPoller` produces will have its branch attribute set to ``None``, to indicate that the Change is on the trunk. No other sub-projects or branches will be tracked. If we want our ChangeSource to follow multiple branches, we have to do two things. First we have to change our ``svnurl=`` argument to watch more than just ``amanda/trunk``. We will set it to ``amanda`` so that we'll see both the trunk and all the branches. Second, we have to tell :bb:chsrc:`SVNPoller` how to split the ``({PROJECT-plus-BRANCH})({FILEPATH})`` strings it gets from the repository out into ``({BRANCH})`` and ``({FILEPATH})```. We do the latter by providing a ``split_file`` function. This function is responsible for splitting something like ``branches/3_3/common-src/amanda.h`` into ``branch='branches/3_3'`` and ``filepath='common-src/amanda.h'``. The function is always given a string that names a file relative to the subdirectory pointed to by the :bb:chsrc:`SVNPoller`\'s ``svnurl=`` argument. It is expected to return a dictionary with at least the ``path`` key. The splitter may optionally set ``branch``, ``project`` and ``repository``. For backwards compatibility it may return a tuple of ``(branchname, path)``. It may also return ``None`` to indicate that the file is of no interest. .. note:: the function should return ``branches/3_3`` rather than just ``3_3`` because the SVN checkout step, will append the branch name to the ``baseURL``, which requires that we keep the ``branches`` component in there. Other VC schemes use a different approach towards branches and may not require this artifact. If your repository uses this same ``{PROJECT}/{BRANCH}/{FILEPATH}`` naming scheme, the following function will work:: def split_file_branches(path): pieces = path.split('/') if len(pieces) > 1 and pieces[0] == 'trunk': return (None, '/'.join(pieces[1:])) elif len(pieces) > 2 and pieces[0] == 'branches': return ('/'.join(pieces[0:2]), '/'.join(pieces[2:])) else: return None In fact, this is the definition of the provided ``split_file_branches`` function. So to have our Twisted-watching :bb:chsrc:`SVNPoller` follow multiple branches, we would use this:: from buildbot.changes.svnpoller import SVNPoller, split_file_branches c['change_source'] = SVNPoller("svn://svn.twistedmatrix.com/svn/Twisted", split_file=split_file_branches) Changes for all sorts of branches (with names like ``"branches/1.5.x"``, and ``None`` to indicate the trunk) will be delivered to the Schedulers. Each Scheduler is then free to use or ignore each branch as it sees fit. If you have multiple projects in the same repository your split function can attach a project name to the Change to help the Scheduler filter out unwanted changes:: from buildbot.changes.svnpoller import split_file_branches def split_file_projects_branches(path): if not "/" in path: return None project, path = path.split("/", 1) f = split_file_branches(path) if f: info = dict(project=project, path=f[1]) if f[0]: info['branch'] = f[0] return info return f Again, this is provided by default. To use it you would do this:: from buildbot.changes.svnpoller import SVNPoller, split_file_projects_branches c['change_source'] = SVNPoller( svnurl="https://svn.amanda.sourceforge.net/svnroot/amanda/", split_file=split_file_projects_branches) Note here that we are monitoring at the root of the repository, and that within that repository is a ``amanda`` subdirectory which in turn has ``trunk`` and ``branches``. It is that ``amanda`` subdirectory whose name becomes the ``project`` field of the Change. BRANCHNAME/PROJECT/FILEPATH repositories ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Another common way to organize a Subversion repository is to put the branch name at the top, and the projects underneath. This is especially frequent when there are a number of related sub-projects that all get released in a group. For example, `Divmod.org `_ hosts a project named `Nevow` as well as one named `Quotient`. In a checked-out Nevow tree there is a directory named `formless` that contains a Python source file named :file:`webform.py`. This repository is accessible via webdav (and thus uses an `http:` scheme) through the divmod.org hostname. There are many branches in this repository, and they use a ``({BRANCHNAME})/({PROJECT})`` naming policy. The fully-qualified SVN URL for the trunk version of :file:`webform.py` is ``http://divmod.org/svn/Divmod/trunk/Nevow/formless/webform.py``. The 1.5.x branch version of this file would have a URL of ``http://divmod.org/svn/Divmod/branches/1.5.x/Nevow/formless/webform.py``. The whole Nevow trunk would be checked out with ``http://divmod.org/svn/Divmod/trunk/Nevow``, while the Quotient trunk would be checked out using ``http://divmod.org/svn/Divmod/trunk/Quotient``. Now suppose we want to have an :bb:chsrc:`SVNPoller` that only cares about the Nevow trunk. This case looks just like the ``{PROJECT}/{BRANCH}`` layout described earlier:: from buildbot.changes.svnpoller import SVNPoller c['change_source'] = SVNPoller("http://divmod.org/svn/Divmod/trunk/Nevow") But what happens when we want to track multiple Nevow branches? We have to point our ``svnurl=`` high enough to see all those branches, but we also don't want to include Quotient changes (since we're only building Nevow). To accomplish this, we must rely upon the ``split_file`` function to help us tell the difference between files that belong to Nevow and those that belong to Quotient, as well as figuring out which branch each one is on. :: from buildbot.changes.svnpoller import SVNPoller c['change_source'] = SVNPoller("http://divmod.org/svn/Divmod", split_file=my_file_splitter) The ``my_file_splitter`` function will be called with repository-relative pathnames like: :file:`trunk/Nevow/formless/webform.py` This is a Nevow file, on the trunk. We want the Change that includes this to see a filename of :file:`formless/webform.py`, and a branch of ``None`` :file:`branches/1.5.x/Nevow/formless/webform.py` This is a Nevow file, on a branch. We want to get ``branch='branches/1.5.x'`` and ``filename='formless/webform.py'``. :file:`trunk/Quotient/setup.py` This is a Quotient file, so we want to ignore it by having :meth:`my_file_splitter` return ``None``. :file:`branches/1.5.x/Quotient/setup.py` This is also a Quotient file, which should be ignored. The following definition for :meth:`my_file_splitter` will do the job:: def my_file_splitter(path): pieces = path.split('/') if pieces[0] == 'trunk': branch = None pieces.pop(0) # remove 'trunk' elif pieces[0] == 'branches': pieces.pop(0) # remove 'branches' # grab branch name branch = 'branches/' + pieces.pop(0) else: return None # something weird projectname = pieces.pop(0) if projectname != 'Nevow': return None # wrong project return dict(branch=branch, path='/'.join(pieces)) If you later decide you want to get changes for Quotient as well you could replace the last 3 lines with simply:: return dict(project=projectname, branch=branch, path='/'.join(pieces)) .. _Writing-Change-Sources: Writing Change Sources ---------------------- For some version-control systems, making Buildbot aware of new changes can be a challenge. If the pre-supplied classes in :ref:`Change-Sources` are not sufficient, then you will need to write your own. There are three approaches, one of which is not even a change source. The first option is to write a change source that exposes some service to which the version control system can "push" changes. This can be more complicated, since it requires implementing a new service, but delivers changes to Buildbot immediately on commit. The second option is often preferable to the first: implement a notification service in an external process (perhaps one that is started directly by the version control system, or by an email server) and delivers changes to Buildbot via :ref:`PBChangeSource`. This section does not describe this particular approach, since it requires no customization within the buildmaster process. The third option is to write a change source which polls for changes - repeatedly connecting to an external service to check for new changes. This works well in many cases, but can produce a high load on the version control system if polling is too frequent, and can take too long to notice changes if the polling is not frequent enough. Writing a Notification-based Change Source ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. py:class:: buildbot.changes.base.ChangeSource A custom change source must implement :class:`buildbot.interfaces.IChangeSource`. The easiest way to do this is to subclass :class:`buildbot.changes.base.ChangeSource`, implementing the :meth:`describe` method to describe the instance. :class:`ChangeSource` is a Twisted service, so you will need to implement the :meth:`startService` and :meth:`stopService` methods to control the means by which your change source receives notifications. When the class does receive a change, it should call ``self.master.addChange(..)`` to submit it to the buildmaster. This method shares the same parameters as ``master.db.changes.addChange``, so consult the API documentation for that function for details on the available arguments. You will probably also want to set ``compare_attrs`` to the list of object attributes which Buildbot will use to compare one change source to another when reconfiguring. During reconfiguration, if the new change source is different from the old, then the old will be stopped and the new started. Writing a Change Poller ~~~~~~~~~~~~~~~~~~~~~~~ .. py:class:: buildbot.changes.base.PollingChangeSource Polling is a very common means of seeking changes, so Buildbot supplies a utility parent class to make it easier. A poller should subclass :class:`buildbot.changes.base.PollingChangeSource`, which is a subclass of :class:`ChangeSource`. This subclass implements the :meth:`Service` methods, and causes the :meth:`poll` method to be called every ``self.pollInterval`` seconds. This method should return a Deferred to signal its completion. Aside from the service methods, the other concerns in the previous section apply here, too. Writing a New Latent Buildslave Implementation ---------------------------------------------- Writing a new latent buildslave should only require subclassing :class:`buildbot.buildslave.AbstractLatentBuildSlave` and implementing :meth:`start_instance` and :meth:`stop_instance`. :: def start_instance(self): # responsible for starting instance that will try to connect with this # master. Should return deferred. Problems should use an errback. The # callback value can be None, or can be an iterable of short strings to # include in the "substantiate success" status message, such as # identifying the instance that started. raise NotImplementedError def stop_instance(self, fast=False): # responsible for shutting down instance. Return a deferred. If `fast`, # we're trying to shut the master down, so callback as soon as is safe. # Callback value is ignored. raise NotImplementedError See :class:`buildbot.ec2buildslave.EC2LatentBuildSlave` for an example, or see the test example :class:`buildbot.test_slaves.FakeLatentBuildSlave`. Custom Build Classes -------------------- The standard :class:`BuildFactory` object creates :class:`Build` objects by default. These Builds will each execute a collection of :class:`BuildStep`\s in a fixed sequence. Each step can affect the results of the build, but in general there is little intelligence to tie the different steps together. By setting the factory's ``buildClass`` attribute to a different class, you can instantiate a different build class. This might be useful, for example, to create a build class that dynamically determines which steps to run. The skeleton of such a project would look like:: class DynamicBuild(Build): # .. override some methods f = factory.BuildFactory() f.buildClass = DynamicBuild f.addStep(...) .. _Factory-Workdir-Functions: Factory Workdir Functions ------------------------- It is sometimes helpful to have a build's workdir determined at runtime based on the parameters of the build. To accomplish this, set the ``workdir`` attribute of the build factory to a callable. That callable will be invoked with the :class:`SourceStamp` for the build, and should return the appropriate workdir. Note that the value must be returned immediately - Deferreds are not supported. This can be useful, for example, in scenarios with multiple repositories submitting changes to BuildBot. In this case you likely will want to have a dedicated workdir per repository, since otherwise a sourcing step with mode = "update" will fail as a workdir with a working copy of repository A can't be "updated" for changes from a repository B. Here is an example how you can achieve workdir-per-repo:: def workdir(source_stamp): return hashlib.md5 (source_stamp.repository).hexdigest()[:8] build_factory = factory.BuildFactory() build_factory.workdir = workdir build_factory.addStep(Git(mode="update")) # ... builders.append ({'name': 'mybuilder', 'slavename': 'myslave', 'builddir': 'mybuilder', 'factory': build_factory}) The end result is a set of workdirs like .. code-block:: none Repo1 => /mybuilder/a78890ba Repo2 => /mybuilder/0823ba88 You could make the :func:`workdir()` function compute other paths, based on parts of the repo URL in the sourcestamp, or lookup in a lookup table based on repo URL. As long as there is a permanent 1:1 mapping between repos and workdir, this will work. Writing New BuildSteps ---------------------- While it is a good idea to keep your build process self-contained in the source code tree, sometimes it is convenient to put more intelligence into your Buildbot configuration. One way to do this is to write a custom :class:`BuildStep`. Once written, this Step can be used in the :file:`master.cfg` file. The best reason for writing a custom :class:`BuildStep` is to better parse the results of the command being run. For example, a :class:`BuildStep` that knows about JUnit could look at the logfiles to determine which tests had been run, how many passed and how many failed, and then report more detailed information than a simple ``rc==0`` -based `good/bad` decision. Buildbot has acquired a large fleet of build steps, and sports a number of knobs and hooks to make steps easier to write. This section may seem a bit overwhelming, but most custom steps will only need to apply one or two of the techniques outlined here. For complete documentation of the build step interfaces, see :doc:`../developer/cls-buildsteps`. .. _Writing-BuildStep-Constructors: Writing BuildStep Constructors ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Build steps act as their own factories, so their constructors are a bit more complex than necessary. In the configuration file, a :class:`~buildbot.process.buildstep.BuildStep` object is instantiated, but because steps store state locally while executing, this object cannot be used during builds. Consider the use of a :class:`BuildStep` in :file:`master.cfg`:: f.addStep(MyStep(someopt="stuff", anotheropt=1)) This creates a single instance of class ``MyStep``. However, Buildbot needs a new object each time the step is executed. An instance of :class:`~buildbot.process.buildstep.BuildStep` remembers how it was constructed, and can create copies of itself. When writing a new step class, then, keep in mind are that you cannot do anything "interesting" in the constructor -- limit yourself to checking and storing arguments. It is customary to call the parent class's constructor with all otherwise-unspecified keyword arguments. Keep a ``**kwargs`` argument on the end of your options, and pass that up to the parent class's constructor. The whole thing looks like this:: class Frobnify(LoggingBuildStep): def __init__(self, frob_what="frobee", frob_how_many=None, frob_how=None, **kwargs): # check if frob_how_many is None: raise TypeError("Frobnify argument how_many is required") # override a parent option kwargs['parentOpt'] = 'xyz' # call parent LoggingBuildStep.__init__(self, **kwargs) # set Frobnify attributes self.frob_what = frob_what self.frob_how_many = how_many self.frob_how = frob_how class FastFrobnify(Frobnify): def __init__(self, speed=5, **kwargs) Frobnify.__init__(self, **kwargs) self.speed = speed Running Commands ~~~~~~~~~~~~~~~~ To spawn a command in the buildslave, create a :class:`~buildbot.process.buildstep.RemoteCommand` instance in your step's ``start`` method and run it with :meth:`~buildbot.process.buildstep.BuildStep.runCommand`:: cmd = RemoteCommand(args) d = self.runCommand(cmd) To add a LogFile, use :meth:`~buildbot.process.buildstep.BuildStep.addLog`. Make sure the log gets closed when it finishes. When giving a Logfile to a :class:`~buildbot.process.buildstep.RemoteShellCommand`, just ask it to close the log when the command completes:: log = self.addLog('output') cmd.useLog(log, closeWhenFinished=True) Updating Status ~~~~~~~~~~~~~~~ TBD .. todo:: What *is* the best way to do this? From the docstring: As the step runs, it should send status information to the BuildStepStatus:: self.step_status.setText(['compile', 'failed']) self.step_status.setText2(['4', 'warnings']) Capturing Logfiles ~~~~~~~~~~~~~~~~~~ Each BuildStep has a collection of `logfiles`. Each one has a short name, like `stdio` or `warnings`. Each :class:`LogFile` contains an arbitrary amount of text, usually the contents of some output file generated during a build or test step, or a record of everything that was printed to :file:`stdout`/:file:`stderr` during the execution of some command. These :class:`LogFile`\s are stored to disk, so they can be retrieved later. Each can contain multiple `channels`, generally limited to three basic ones: stdout, stderr, and `headers`. For example, when a ShellCommand runs, it writes a few lines to the `headers` channel to indicate the exact argv strings being run, which directory the command is being executed in, and the contents of the current environment variables. Then, as the command runs, it adds a lot of :file:`stdout` and :file:`stderr` messages. When the command finishes, a final `header` line is added with the exit code of the process. Status display plugins can format these different channels in different ways. For example, the web page shows LogFiles as text/html, with header lines in blue text, stdout in black, and stderr in red. A different URL is available which provides a text/plain format, in which stdout and stderr are collapsed together, and header lines are stripped completely. This latter option makes it easy to save the results to a file and run :command:`grep` or whatever against the output. Each :class:`BuildStep` contains a mapping (implemented in a Python dictionary) from :class:`LogFile` name to the actual :class:`LogFile` objects. Status plugins can get a list of LogFiles to display, for example, a list of HREF links that, when clicked, provide the full contents of the :class:`LogFile`. Using LogFiles in custom BuildSteps ################################### The most common way for a custom :class:`BuildStep` to use a :class:`LogFile` is to summarize the results of a :bb:step:`ShellCommand` (after the command has finished running). For example, a compile step with thousands of lines of output might want to create a summary of just the warning messages. If you were doing this from a shell, you would use something like: .. code-block:: bash grep "warning:" output.log >warnings.log In a custom BuildStep, you could instead create a ``warnings`` :class:`LogFile` that contained the same text. To do this, you would add code to your :meth:`createSummary` method that pulls lines from the main output log and creates a new :class:`LogFile` with the results:: def createSummary(self, log): warnings = [] sio = StringIO.StringIO(log.getText()) for line in sio.readlines(): if "warning:" in line: warnings.append() self.addCompleteLog('warnings', "".join(warnings)) This example uses the :meth:`addCompleteLog` method, which creates a new :class:`LogFile`, puts some text in it, and then `closes` it, meaning that no further contents will be added. This :class:`LogFile` will appear in the HTML display under an HREF with the name `warnings`, since that is the name of the :class:`LogFile`. You can also use :meth:`addHTMLLog` to create a complete (closed) :class:`LogFile` that contains HTML instead of plain text. The normal :class:`LogFile` will be HTML-escaped if presented through a web page, but the HTML :class:`LogFile` will not. At the moment this is only used to present a pretty HTML representation of an otherwise ugly exception traceback when something goes badly wrong during the :class:`BuildStep`. In contrast, you might want to create a new :class:`LogFile` at the beginning of the step, and add text to it as the command runs. You can create the :class:`LogFile` and attach it to the build by calling :meth:`addLog`, which returns the :class:`LogFile` object. You then add text to this :class:`LogFile` by calling methods like :meth:`addStdout` and :meth:`addHeader`. When you are done, you must call the :meth:`finish` method so the :class:`LogFile` can be closed. It may be useful to create and populate a :class:`LogFile` like this from a :class:`LogObserver` method - see :ref:`Adding-LogObservers`. The ``logfiles=`` argument to :bb:step:`ShellCommand` (see :bb:step:`ShellCommand`) creates new :class:`LogFile`\s and fills them in realtime by asking the buildslave to watch a actual file on disk. The buildslave will look for additions in the target file and report them back to the :class:`BuildStep`. These additions will be added to the :class:`LogFile` by calling :meth:`addStdout`. These secondary LogFiles can be used as the source of a LogObserver just like the normal :file:`stdio` :class:`LogFile`. Reading Logfiles ~~~~~~~~~~~~~~~~ Once a :class:`~buildbot.status.logfile.LogFile` has been added to a :class:`~buildbot.process.buildstep.BuildStep` with :meth:`~buildbot.process.buildstep.BuildStep.addLog()`, :meth:`~buildbot.process.buildstep.BuildStep.addCompleteLog()`, :meth:`~buildbot.process.buildstep.BuildStep.addHTMLLog()`, or ``logfiles={}``, your :class:`~buildbot.process.buildstep.BuildStep.BuildStep` can retrieve it by using :meth:`~buildbot.process.buildstep.BuildStep.getLog()`:: class MyBuildStep(ShellCommand): logfiles = @{ "nodelog": "_test/node.log" @} def evaluateCommand(self, cmd): nodelog = self.getLog("nodelog") if "STARTED" in nodelog.getText(): return SUCCESS else: return FAILURE .. _Adding-LogObservers: Adding LogObservers ~~~~~~~~~~~~~~~~~~~ Most shell commands emit messages to stdout or stderr as they operate, especially if you ask them nicely with a :option:`--verbose` flag of some sort. They may also write text to a log file while they run. Your :class:`BuildStep` can watch this output as it arrives, to keep track of how much progress the command has made. You can get a better measure of progress by counting the number of source files compiled or test cases run than by merely tracking the number of bytes that have been written to stdout. This improves the accuracy and the smoothness of the ETA display. To accomplish this, you will need to attach a :class:`LogObserver` to one of the log channels, most commonly to the :file:`stdio` channel but perhaps to another one which tracks a log file. This observer is given all text as it is emitted from the command, and has the opportunity to parse that output incrementally. Once the observer has decided that some event has occurred (like a source file being compiled), it can use the :meth:`setProgress` method to tell the :class:`BuildStep` about the progress that this event represents. There are a number of pre-built :class:`LogObserver` classes that you can choose from (defined in :mod:`buildbot.process.buildstep`, and of course you can subclass them to add further customization. The :class:`LogLineObserver` class handles the grunt work of buffering and scanning for end-of-line delimiters, allowing your parser to operate on complete :file:`stdout`/:file:`stderr` lines. (Lines longer than a set maximum length are dropped; the maximum defaults to 16384 bytes, but you can change it by calling :meth:`setMaxLineLength()` on your :class:`LogLineObserver` instance. Use ``sys.maxint`` for effective infinity.) For example, let's take a look at the :class:`TrialTestCaseCounter`, which is used by the :bb:step:`Trial` step to count test cases as they are run. As Trial executes, it emits lines like the following: .. code-block:: none buildbot.test.test_config.ConfigTest.testDebugPassword ... [OK] buildbot.test.test_config.ConfigTest.testEmpty ... [OK] buildbot.test.test_config.ConfigTest.testIRC ... [FAIL] buildbot.test.test_config.ConfigTest.testLocks ... [OK] When the tests are finished, trial emits a long line of `======` and then some lines which summarize the tests that failed. We want to avoid parsing these trailing lines, because their format is less well-defined than the `[OK]` lines. The parser class looks like this:: from buildbot.process.buildstep import LogLineObserver class TrialTestCaseCounter(LogLineObserver): _line_re = re.compile(r'^([\w\.]+) \.\.\. \[([^\]]+)\]$') numTests = 0 finished = False def outLineReceived(self, line): if self.finished: return if line.startswith("=" * 40): self.finished = True return m = self._line_re.search(line.strip()) if m: testname, result = m.groups() self.numTests += 1 self.step.setProgress('tests', self.numTests) This parser only pays attention to stdout, since that's where trial writes the progress lines. It has a mode flag named ``finished`` to ignore everything after the ``====`` marker, and a scary-looking regular expression to match each line while hopefully ignoring other messages that might get displayed as the test runs. Each time it identifies a test has been completed, it increments its counter and delivers the new progress value to the step with @code{self.step.setProgress}. This class is specifically measuring progress along the `tests` metric, in units of test cases (as opposed to other kinds of progress like the `output` metric, which measures in units of bytes). The Progress-tracking code uses each progress metric separately to come up with an overall completion percentage and an ETA value. To connect this parser into the :bb:step:`Trial` build step, ``Trial.__init__`` ends with the following clause:: # this counter will feed Progress along the 'test cases' metric counter = TrialTestCaseCounter() self.addLogObserver('stdio', counter) self.progressMetrics += ('tests',) This creates a :class:`TrialTestCaseCounter` and tells the step that the counter wants to watch the :file:`stdio` log. The observer is automatically given a reference to the step in its :attr:`step` attribute. Using Properties ~~~~~~~~~~~~~~~~ In custom :class:`BuildSteps`, you can get and set the build properties with the :meth:`getProperty`/:meth:`setProperty` methods. Each takes a string for the name of the property, and returns or accepts an arbitrary object. For example:: class MakeTarball(ShellCommand): def start(self): if self.getProperty("os") == "win": self.setCommand([ ... ]) # windows-only command else: self.setCommand([ ... ]) # equivalent for other systems ShellCommand.start(self) Remember that properties set in a step may not be available until the next step begins. In particular, any :class:`Property` or :class:`Interpolate` instances for the current step are interpolated before the ``start`` method begins. .. index:: links, BuildStep URLs, addURL BuildStep URLs ~~~~~~~~~~~~~~ Each BuildStep has a collection of `links`. Like its collection of LogFiles, each link has a name and a target URL. The web status page creates HREFs for each link in the same box as it does for LogFiles, except that the target of the link is the external URL instead of an internal link to a page that shows the contents of the LogFile. These external links can be used to point at build information hosted on other servers. For example, the test process might produce an intricate description of which tests passed and failed, or some sort of code coverage data in HTML form, or a PNG or GIF image with a graph of memory usage over time. The external link can provide an easy way for users to navigate from the buildbot's status page to these external web sites or file servers. Note that the step itself is responsible for insuring that there will be a document available at the given URL (perhaps by using :command:`scp` to copy the HTML output to a :file:`~/public_html/` directory on a remote web server). Calling :meth:`addURL` does not magically populate a web server. To set one of these links, the :class:`BuildStep` should call the :meth:`addURL` method with the name of the link and the target URL. Multiple URLs can be set. In this example, we assume that the ``make test`` command causes a collection of HTML files to be created and put somewhere on the coverage.example.org web server, in a filename that incorporates the build number. :: class TestWithCodeCoverage(BuildStep): command = ["make", "test", Interpolate("buildnum=%(prop:buildnumber)s")] def createSummary(self, log): buildnumber = self.getProperty("buildnumber") url = "http://coverage.example.org/builds/%s.html" % buildnumber self.addURL("coverage", url) You might also want to extract the URL from some special message output by the build process itself:: class TestWithCodeCoverage(BuildStep): command = ["make", "test", Interpolate("buildnum=%(prop:buildnumber)s")] def createSummary(self, log): output = StringIO(log.getText()) for line in output.readlines(): if line.startswith("coverage-url:"): url = line[len("coverage-url:"):].strip() self.addURL("coverage", url) return Note that a build process which emits both :file:`stdout` and :file:`stderr` might cause this line to be split or interleaved between other lines. It might be necessary to restrict the :meth:`getText()` call to only stdout with something like this:: output = StringIO("".join([c[1] for c in log.getChunks() if c[0] == LOG_CHANNEL_STDOUT])) Of course if the build is run under a PTY, then stdout and stderr will be merged before the buildbot ever sees them, so such interleaving will be unavoidable. .. todo:: Step Progress BuildStepFailed Running Multiple Commands A Somewhat Whimsical Example ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Let's say that we've got some snazzy new unit-test framework called Framboozle. It's the hottest thing since sliced bread. It slices, it dices, it runs unit tests like there's no tomorrow. Plus if your unit tests fail, you can use its name for a Web 2.1 startup company, make millions of dollars, and hire engineers to fix the bugs for you, while you spend your afternoons lazily hang-gliding along a scenic pacific beach, blissfully unconcerned about the state of your tests. [#framboozle_reg]_ To run a Framboozle-enabled test suite, you just run the 'framboozler' command from the top of your source code tree. The 'framboozler' command emits a bunch of stuff to stdout, but the most interesting bit is that it emits the line "FNURRRGH!" every time it finishes running a test case You'd like to have a test-case counting LogObserver that watches for these lines and counts them, because counting them will help the buildbot more accurately calculate how long the build will take, and this will let you know exactly how long you can sneak out of the office for your hang-gliding lessons without anyone noticing that you're gone. This will involve writing a new :class:`BuildStep` (probably named "Framboozle") which inherits from :bb:step:`ShellCommand`. The :class:`BuildStep` class definition itself will look something like this:: from buildbot.steps.shell import ShellCommand from buildbot.process.buildstep import LogLineObserver class FNURRRGHCounter(LogLineObserver): numTests = 0 def outLineReceived(self, line): if "FNURRRGH!" in line: self.numTests += 1 self.step.setProgress('tests', self.numTests) class Framboozle(ShellCommand): command = ["framboozler"] def __init__(self, **kwargs): ShellCommand.__init__(self, **kwargs) # always upcall! counter = FNURRRGHCounter()) self.addLogObserver('stdio', counter) self.progressMetrics += ('tests',) So that's the code that we want to wind up using. How do we actually deploy it? You have a couple of different options. Option 1: The simplest technique is to simply put this text (everything from START to FINISH) in your :FILE:`master.cfg` file, somewhere before the :class:`BuildFactory` definition where you actually use it in a clause like:: f = BuildFactory() f.addStep(SVN(svnurl="stuff")) f.addStep(Framboozle()) Remember that :file:`master.cfg` is secretly just a Python program with one job: populating the :file:`BuildmasterConfig` dictionary. And Python programs are allowed to define as many classes as they like. So you can define classes and use them in the same file, just as long as the class is defined before some other code tries to use it. This is easy, and it keeps the point of definition very close to the point of use, and whoever replaces you after that unfortunate hang-gliding accident will appreciate being able to easily figure out what the heck this stupid "Framboozle" step is doing anyways. The downside is that every time you reload the config file, the Framboozle class will get redefined, which means that the buildmaster will think that you've reconfigured all the Builders that use it, even though nothing changed. Bleh. Option 2: Instead, we can put this code in a separate file, and import it into the master.cfg file just like we would the normal buildsteps like :bb:step:`ShellCommand` and :bb:step:`SVN`. Create a directory named ~/lib/python, put everything from START to FINISH in :file:`~/lib/python/framboozle.py`, and run your buildmaster using: .. code-block:: bash PYTHONPATH=~/lib/python buildbot start MASTERDIR or use the :file:`Makefile.buildbot` to control the way ``buildbot start`` works. Or add something like this to something like your :file:`~/.bashrc` or :file:`~/.bash_profile` or :file:`~/.cshrc`: .. code-block:: bash export PYTHONPATH=~/lib/python Once we've done this, our :file:`master.cfg` can look like:: from framboozle import Framboozle f = BuildFactory() f.addStep(SVN(svnurl="stuff")) f.addStep(Framboozle()) or:: import framboozle f = BuildFactory() f.addStep(SVN(svnurl="stuff")) f.addStep(framboozle.Framboozle()) (check out the Python docs for details about how "import" and "from A import B" work). What we've done here is to tell Python that every time it handles an "import" statement for some named module, it should look in our :file:`~/lib/python/` for that module before it looks anywhere else. After our directories, it will try in a bunch of standard directories too (including the one where buildbot is installed). By setting the :envvar:`PYTHONPATH` environment variable, you can add directories to the front of this search list. Python knows that once it "import"s a file, it doesn't need to re-import it again. This means that reconfiguring the buildmaster (with ``buildbot reconfig``, for example) won't make it think the Framboozle class has changed every time, so the Builders that use it will not be spuriously restarted. On the other hand, you either have to start your buildmaster in a slightly weird way, or you have to modify your environment to set the :envvar:`PYTHONPATH` variable. Option 3: Install this code into a standard Python library directory Find out what your Python's standard include path is by asking it: .. code-block:: none 80:warner@luther% python Python 2.4.4c0 (#2, Oct 2 2006, 00:57:46) [GCC 4.1.2 20060928 (prerelease) (Debian 4.1.1-15)] on linux2 Type "help", "copyright", "credits" or "license" for more information. >>> import sys >>> import pprint >>> pprint.pprint(sys.path) ['', '/usr/lib/python24.zip', '/usr/lib/python2.4', '/usr/lib/python2.4/plat-linux2', '/usr/lib/python2.4/lib-tk', '/usr/lib/python2.4/lib-dynload', '/usr/local/lib/python2.4/site-packages', '/usr/lib/python2.4/site-packages', '/usr/lib/python2.4/site-packages/Numeric', '/var/lib/python-support/python2.4', '/usr/lib/site-python'] In this case, putting the code into /usr/local/lib/python2.4/site-packages/framboozle.py would work just fine. We can use the same :file:`master.cfg` ``import framboozle`` statement as in Option 2. By putting it in a standard include directory (instead of the decidedly non-standard :file:`~/lib/python`), we don't even have to set :envvar:`PYTHONPATH` to anything special. The downside is that you probably have to be root to write to one of those standard include directories. Option 4: Submit the code for inclusion in the Buildbot distribution Make a fork of buildbot on http://github.com/djmitche/buildbot or post a patch in a bug at http://buildbot.net. In either case, post a note about your patch to the mailing list, so others can provide feedback and, eventually, commit it. from buildbot.steps import framboozle f = BuildFactory() f.addStep(SVN(svnurl="stuff")) f.addStep(framboozle.Framboozle()) And then you don't even have to install framboozle.py anywhere on your system, since it will ship with Buildbot. You don't have to be root, you don't have to set :envvar:`PYTHONPATH`. But you do have to make a good case for Framboozle being worth going into the main distribution, you'll probably have to provide docs and some unit test cases, you'll need to figure out what kind of beer the author likes (IPA's and Stouts for Dustin), and then you'll have to wait until the next release. But in some environments, all this is easier than getting root on your buildmaster box, so the tradeoffs may actually be worth it. Putting the code in master.cfg (1) makes it available to that buildmaster instance. Putting it in a file in a personal library directory (2) makes it available for any buildmasters you might be running. Putting it in a file in a system-wide shared library directory (3) makes it available for any buildmasters that anyone on that system might be running. Getting it into the buildbot's upstream repository (4) makes it available for any buildmasters that anyone in the world might be running. It's all a matter of how widely you want to deploy that new class. Writing New Status Plugins -------------------------- Each status plugin is an object which provides the :class:`twisted.application.service.IService` interface, which creates a tree of Services with the buildmaster at the top [not strictly true]. The status plugins are all children of an object which implements :class:`buildbot.interfaces.IStatus`, the main status object. From this object, the plugin can retrieve anything it wants about current and past builds. It can also subscribe to hear about new and upcoming builds. Status plugins which only react to human queries (like the Waterfall display) never need to subscribe to anything: they are idle until someone asks a question, then wake up and extract the information they need to answer it, then they go back to sleep. Plugins which need to act spontaneously when builds complete (like the :class:`MailNotifier` plugin) need to subscribe to hear about new builds. If the status plugin needs to run network services (like the HTTP server used by the Waterfall plugin), they can be attached as Service children of the plugin itself, using the :class:`IServiceCollection` interface. .. [#framboozle_reg] framboozle.com is still available. Remember, I get 10% :). buildbot-0.8.8/docs/manual/index.rst000066400000000000000000000003651222546025000174030ustar00rootroot00000000000000This is the BuildBot manual for Buildbot version |version|. Buildbot Manual --------------- .. toctree:: :maxdepth: 2 introduction installation concepts configuration customization cmdline resources optimization buildbot-0.8.8/docs/manual/installation.rst000066400000000000000000001137541222546025000210040ustar00rootroot00000000000000Installation ============ .. _Buildbot-Components: Buildbot Components ------------------- Buildbot is shipped in two components: the *buildmaster* (called ``buildbot`` for legacy reasons) and the *buildslave*. The buildslave component has far fewer requirements, and is more broadly compatible than the buildmaster. You will need to carefully pick the environment in which to run your buildmaster, but the buildslave should be able to run just about anywhere. It is possible to install the buildmaster and buildslave on the same system, although for anything but the smallest installation this arrangement will not be very efficient. .. _Requirements: Requirements ------------ .. _Common-Requirements: Common Requirements ~~~~~~~~~~~~~~~~~~~ At a bare minimum, you'll need the following for both the buildmaster and a buildslave: Python: http://www.python.org Buildbot requires Python-2.5 or later on the master, although Python-2.7 is recommended. The slave run on Python-2.4. Twisted: http://twistedmatrix.com Buildbot requires Twisted-9.0.0 or later on the master, and Twisted-8.1.0 on the slave. As always, the most recent version is recommended. In some cases, Twisted is delivered as a collection of subpackages. You'll need at least "Twisted" (the core package), and you'll also want `TwistedMail`_, `TwistedWeb`_, and `TwistedWords`_ (for sending email, serving a web status page, and delivering build status via IRC, respectively). You might also want `TwistedConch`_ (for the encrypted Manhole debug port). Note that Twisted requires ZopeInterface to be installed as well. Of course, your project's build process will impose additional requirements on the buildslaves. These hosts must have all the tools necessary to compile and test your project's source code. Windows Support ''''''''''''''' Buildbot - both master and slave - runs well natively on Windows. The slave runs well on Cygwin, but because of problems with SQLite on Cygwin, the master does not. Buildbot's windows testing is limited to the most recent Twisted and Python versions. For best results, use the most recent available versions of these libraries on Windows. Pywin32: http://sourceforge.net/projects/pywin32/ Twisted requires PyWin32 in order to spawn processes on Windows. .. _Buildmaster-Requirements: Buildmaster Requirements ~~~~~~~~~~~~~~~~~~~~~~~~ sqlite3: http://www.sqlite.org Buildbot requires SQLite to store its state. Version 3.7.0 or higher is recommended, although Buildbot will run against earlier versions -- at the risk of "Database is locked" errors. The minimum version is 3.4.0, below which parallel database queries and schema introspection fail. pysqlite: http://pypi.python.org/pypi/pysqlite The SQLite Python package is required for Python-2.5 and earlier (it is already included in Python-2.5 and later, but the version in Python-2.5 has nasty bugs) simplejson: http://pypi.python.org/pypi/simplejson The simplejson package is required for Python-2.5 and earlier (it is already included as json in Python-2.6 and later) Jinja2: http://jinja.pocoo.org/ Buildbot requires Jinja version 2.1 or higher. Jinja2 is a general purpose templating language and is used by Buildbot to generate the HTML output. SQLAlchemy: http://www.sqlalchemy.org/ Buildbot requires SQLAlchemy 0.6.0 or higher. SQLAlchemy allows Buildbot to build database schemas and queries for a wide variety of database systems. SQLAlchemy-Migrate: http://code.google.com/p/sqlalchemy-migrate/ Buildbot requires one of the following SQLAlchemy-Migrate versions: 0.6.1, 0.7.0, and 0.7.1. Sadly, Migrate's inter-version compatibility is not good, so other versions - newer or older - are unlikely to work correctly. Buildbot uses SQLAlchemy-Migrate to manage schema upgrades from version to version. Python-Dateutil: http://labix.org/python-dateutil The Nightly scheduler requires Python-Dateutil version 1.5 (the last version to support Python-2.x). This is a small, pure-python library. Buildbot will function properly without it if the Nightlys scheduler is not used. .. _Installing-the-code: Installing the code ------------------- The Distribution Package ~~~~~~~~~~~~~~~~~~~~~~~~ Buildbot comes in two parts: ``buildbot`` (the master) and ``buildbot-slave`` (the slave). The two can be installed individually or together. Installation From PyPI ~~~~~~~~~~~~~~~~~~~~~~ The easiest way to install Buildbot is using 'pip'. For the master: .. code-block:: bash pip install buildbot and for the slave: .. code-block:: bash pip install buildbot-slave Installation From Tarballs ~~~~~~~~~~~~~~~~~~~~~~~~~~ Buildbot and Buildslave are installed using the standard Python `distutils `_ process. For either component, after unpacking the tarball, the process is: .. code-block:: bash python setup.py build python setup.py install where the install step may need to be done as root. This will put the bulk of the code in somewhere like :file:`/usr/lib/pythonx.y/site-packages/buildbot`. It will also install the :command:`buildbot` command-line tool in :file:`/usr/bin/buildbot`. If the environment variable ``$NO_INSTALL_REQS`` is set to ``1``, then :file:`setup.py` will not try to install Buildbot's requirements. This is usually only useful when building a Buildbot package. To test this, shift to a different directory (like :file:`/tmp`), and run: .. code-block:: bash buildbot --version # or buildslave --version If it shows you the versions of Buildbot and Twisted, the install went ok. If it says "no such command" or it gets an ``ImportError`` when it tries to load the libraries, then something went wrong. ``pydoc buildbot`` is another useful diagnostic tool. Windows users will find these files in other places. You will need to make sure that Python can find the libraries, and will probably find it convenient to have :command:`buildbot` on your :envvar:`PATH`. .. _Installation-in-a-Virtualenv: Installation in a Virtualenv ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you cannot or do not wish to install the buildbot into a site-wide location like :file:`/usr` or :file:`/usr/local`, you can also install it into the account's home directory or any other location using a tool like `virtualenv `_. .. _Running-Buildbots-Tests-optional: Running Buildbot's Tests (optional) ----------------------------------- If you wish, you can run the buildbot unit test suite. First, ensure you have the `mock `_ Python module installed from PyPi. This module is not required for ordinary Buildbot operation - only to run the tests. Note that this is not the same as the Fedora ``mock`` package! You can check with .. code-block:: bash python -mmock Then, run the tests: .. code-block:: bash PYTHONPATH=. trial buildbot.test # or PYTHONPATH=. trial buildslave.test Nothing should fail, although a few might be skipped. If any of the tests fail for reasons other than a missing ``mock``, you should stop and investigate the cause before continuing the installation process, as it will probably be easier to track down the bug early. In most cases, the problem is incorrectly installed Python modules or a badly configured ``PYTHONPATH``. This may be a good time to contact the Buildbot developers for help. .. _Creating-a-buildmaster: Creating a buildmaster ---------------------- As you learned earlier (:ref:`System-Architecture`), the buildmaster runs on a central host (usually one that is publicly visible, so everybody can check on the status of the project), and controls all aspects of the buildbot system You will probably wish to create a separate user account for the buildmaster, perhaps named ``buildmaster``. Do not run the buildmaster as ``root``! You need to choose a directory for the buildmaster, called the ``basedir``. This directory will be owned by the buildmaster. It will contain configuration, the database, and status information - including logfiles. On a large buildmaster this directory will see a lot of activity, so it should be on a disk with adequate space and speed. Once you've picked a directory, use the ``buildbot create-master`` command to create the directory and populate it with startup files: .. code-block:: bash buildbot create-master -r basedir You will need to create a :ref:`configuration file ` before starting the buildmaster. Most of the rest of this manual is dedicated to explaining how to do this. A sample configuration file is placed in the working directory, named :file:`master.cfg.sample`, which can be copied to :file:`master.cfg` and edited to suit your purposes. (Internal details: This command creates a file named :file:`buildbot.tac` that contains all the state necessary to create the buildmaster. Twisted has a tool called ``twistd`` which can use this .tac file to create and launch a buildmaster instance. twistd takes care of logging and daemonization (running the program in the background). :file:`/usr/bin/buildbot` is a front end which runs `twistd` for you.) Using A Database Server ~~~~~~~~~~~~~~~~~~~~~~~ If you want to use a database server (e.g., MySQL or Postgres) as the database backend for your Buildbot, add the ``--db`` option to the ``create-master`` invocation to specify the :ref:`connection string ` for the database, and make sure that the same URL appears in the ``db_url`` of the :bb:cfg:`db` parameter in your configuration file. Additional Requirements ''''''''''''''''''''''' Depending on the selected database, further Python packages will be required. Consult the SQLAlchemy dialect list for a full description. The most common choice for MySQL is MySQL-python: http://mysql-python.sourceforge.net/ To communicate with MySQL, SQLAlchemy requires MySQL-python. Any reasonably recent version of MySQL-python should suffice. The most common choice for Postgres is Psycopg: http://initd.org/psycopg/ SQLAlchemy uses Psycopg to communicate with Postgres. Any reasonably recent version should suffice. Buildmaster Options ~~~~~~~~~~~~~~~~~~~ This section lists options to the ``create-master`` command. You can also type ``buildbot create-master --help`` for an up-to-the-moment summary. ``--force`` With this option, @command{create-master} will re-use an existing master directory. ``--no-logrotate`` This disables internal buildslave log management mechanism. With this option buildslave does not override the default logfile name and its behaviour giving a possibility to control those with command-line options of twistd daemon. ``--relocatable`` This creates a "relocatable" buildbot.tac, which uses relative paths instead of absolute paths, so that the buildmaster directory can be moved about. ``--config`` The name of the configuration file to use. This configuration file need not reside in the buildmaster directory. ``--log-size`` This is the size in bytes when to rotate the Twisted log files. The default is 10MiB. ``--log-count`` This is the number of log rotations to keep around. You can either specify a number or @code{None} to keep all @file{twistd.log} files around. The default is 10. ``--db`` The database that the Buildmaster should use. Note that the same value must be added to the configuration file. .. _Upgrading-an-Existing-Buildmaster: Upgrading an Existing Buildmaster --------------------------------- If you have just installed a new version of the Buildbot code, and you have buildmasters that were created using an older version, you'll need to upgrade these buildmasters before you can use them. The upgrade process adds and modifies files in the buildmaster's base directory to make it compatible with the new code. .. code-block:: bash buildbot upgrade-master basedir This command will also scan your :file:`master.cfg` file for incompatibilities (by loading it and printing any errors or deprecation warnings that occur). Each buildbot release tries to be compatible with configurations that worked cleanly (i.e. without deprecation warnings) on the previous release: any functions or classes that are to be removed will first be deprecated in a release, to give you a chance to start using the replacement. The ``upgrade-master`` command is idempotent. It is safe to run it multiple times. After each upgrade of the buildbot code, you should use ``upgrade-master`` on all your buildmasters. In general, Buildbot slaves and masters can be upgraded independently, although some new features will not be available, depending on the master and slave versions. Beyond this general information, read all of the sections below that apply to versions through which you are upgrading. .. _Buildmaster-Version-specific-Notes: Version-specific Notes ~~~~~~~~~~~~~~~~~~~~~~ Upgrading a Buildmaster to Buildbot-0.7.6 ''''''''''''''''''''''''''''''''''''''''' The 0.7.6 release introduced the :file:`public_html/` directory, which contains :file:`index.html` and other files served by the ``WebStatus`` and ``Waterfall`` status displays. The ``upgrade-master`` command will create these files if they do not already exist. It will not modify existing copies, but it will write a new copy in e.g. :file:`index.html.new` if the new version differs from the version that already exists. Upgrading a Buildmaster to Buildbot-0.8.0 ''''''''''''''''''''''''''''''''''''''''' Buildbot-0.8.0 introduces a database backend, which is SQLite by default. The ``upgrade-master`` command will automatically create and populate this database with the changes the buildmaster has seen. Note that, as of this release, build history is *not* contained in the database, and is thus not migrated. The upgrade process renames the Changes pickle (``$basedir/changes.pck``) to ``changes.pck.old`` once the upgrade is complete. To reverse the upgrade, simply downgrade Buildbot and move this file back to its original name. You may also wish to delete the state database (``state.sqlite``). Upgrading into a non-SQLite database '''''''''''''''''''''''''''''''''''' If you are not using sqlite, you will need to add an entry into your :file:`master.cfg` to reflect the database version you are using. The upgrade process does *not* edit your :file:`master.cfg` for you. So something like: .. code-block:: python # for using mysql: c['db_url'] = 'mysql://bbuser:@localhost/buildbot' Once the parameter has been added, invoke ``upgrade-master``. This will extract the DB url from your configuration file. .. code-block:: bash buildbot upgrade-master See :ref:`Database-Specification` for more options to specify a database. Change Encoding Issues ###################### The upgrade process assumes that strings in your Changes pickle are encoded in UTF-8 (or plain ASCII). If this is not the case, and if there are non-UTF-8 characters in the pickle, the upgrade will fail with a suitable error message. If this occurs, you have two options. If the change history is not important to your purpose, you can simply delete :file:`changes.pck`. If you would like to keep the change history, then you will need to figure out which encoding is in use, and use :file:`contrib/fix_changes_pickle_encoding.py` (:ref:`Contrib-Scripts`) to rewrite the changes pickle into Unicode before upgrading the master. A typical invocation (with Mac-Roman encoding) might look like: .. code-block:: bash $ python $buildbot/contrib/fix_changes_pickle_encoding.py changes.pck macroman decoding bytestrings in changes.pck using macroman converted 11392 strings backing up changes.pck to changes.pck.old If your Changes pickle uses multiple encodings, you're on your own, but the script in contrib may provide a good starting point for the fix. .. _Upgrading-a-Buildmaster-to-Later-Version: Upgrading a Buildmaster to Later Versions ''''''''''''''''''''''''''''''''''''''''' Up to Buildbot version |version|, no further steps beyond those described above are required. .. _Creating-a-buildslave: Creating a buildslave --------------------- Typically, you will be adding a buildslave to an existing buildmaster, to provide additional architecture coverage. The buildbot administrator will give you several pieces of information necessary to connect to the buildmaster. You should also be somewhat familiar with the project being tested, so you can troubleshoot build problems locally. The buildbot exists to make sure that the project's stated ``how to build it`` process actually works. To this end, the buildslave should run in an environment just like that of your regular developers. Typically the project build process is documented somewhere (:file:`README`, :file:`INSTALL`, etc), in a document that should mention all library dependencies and contain a basic set of build instructions. This document will be useful as you configure the host and account in which the buildslave runs. Here's a good checklist for setting up a buildslave: 1. Set up the account It is recommended (although not mandatory) to set up a separate user account for the buildslave. This account is frequently named ``buildbot`` or ``buildslave``. This serves to isolate your personal working environment from that of the slave's, and helps to minimize the security threat posed by letting possibly-unknown contributors run arbitrary code on your system. The account should have a minimum of fancy init scripts. 2. Install the buildbot code Follow the instructions given earlier (:ref:`Installing-the-code`). If you use a separate buildslave account, and you didn't install the buildbot code to a shared location, then you will need to install it with ``--home=~`` for each account that needs it. 3. Set up the host Make sure the host can actually reach the buildmaster. Usually the buildmaster is running a status webserver on the same machine, so simply point your web browser at it and see if you can get there. Install whatever additional packages or libraries the project's INSTALL document advises. (or not: if your buildslave is supposed to make sure that building without optional libraries still works, then don't install those libraries). Again, these libraries don't necessarily have to be installed to a site-wide shared location, but they must be available to your build process. Accomplishing this is usually very specific to the build process, so installing them to :file:`/usr` or :file:`/usr/local` is usually the best approach. 4. Test the build process Follow the instructions in the :file:`INSTALL` document, in the buildslave's account. Perform a full CVS (or whatever) checkout, configure, make, run tests, etc. Confirm that the build works without manual fussing. If it doesn't work when you do it by hand, it will be unlikely to work when the buildbot attempts to do it in an automated fashion. 5. Choose a base directory This should be somewhere in the buildslave's account, typically named after the project which is being tested. The buildslave will not touch any file outside of this directory. Something like :file:`~/Buildbot` or :file:`~/Buildslaves/fooproject` is appropriate. 6. Get the buildmaster host/port, botname, and password When the buildbot admin configures the buildmaster to accept and use your buildslave, they will provide you with the following pieces of information: * your buildslave's name * the password assigned to your buildslave * the hostname and port number of the buildmaster, i.e. buildbot.example.org:8007 7. Create the buildslave Now run the 'buildslave' command as follows: :samp:`buildslave create-slave {BASEDIR} {MASTERHOST}:{PORT} {SLAVENAME} {PASSWORD}` This will create the base directory and a collection of files inside, including the :file:`buildbot.tac` file that contains all the information you passed to the :command:`buildbot` command. 8. Fill in the hostinfo files When it first connects, the buildslave will send a few files up to the buildmaster which describe the host that it is running on. These files are presented on the web status display so that developers have more information to reproduce any test failures that are witnessed by the buildbot. There are sample files in the :file:`info` subdirectory of the buildbot's base directory. You should edit these to correctly describe you and your host. :file:`{BASEDIR}/info/admin` should contain your name and email address. This is the ``buildslave admin address``, and will be visible from the build status page (so you may wish to munge it a bit if address-harvesting spambots are a concern). :file:`{BASEDIR}/info/host` should be filled with a brief description of the host: OS, version, memory size, CPU speed, versions of relevant libraries installed, and finally the version of the buildbot code which is running the buildslave. The optional :file:`{BASEDIR}/info/access_uri` can specify a URI which will connect a user to the machine. Many systems accept ``ssh://hostname`` URIs for this purpose. If you run many buildslaves, you may want to create a single :file:`~buildslave/info` file and share it among all the buildslaves with symlinks. .. _Buildslave-Options: Buildslave Options ~~~~~~~~~~~~~~~~~~ There are a handful of options you might want to use when creating the buildslave with the :samp:`buildslave create-slave DIR ` command. You can type ``buildslave create-slave --help`` for a summary. To use these, just include them on the ``buildslave create-slave`` command line, like this .. code-block:: bash buildslave create-slave --umask=022 ~/buildslave buildmaster.example.org:42012 {myslavename} {mypasswd} .. program:: buildslave create-slave .. option:: --no-logrotate This disables internal buildslave log management mechanism. With this option buildslave does not override the default logfile name and its behaviour giving a possibility to control those with command-line options of twistd daemon. .. option:: --usepty This is a boolean flag that tells the buildslave whether to launch child processes in a PTY or with regular pipes (the default) when the master does not specify. This option is deprecated, as this particular parameter is better specified on the master. .. option:: --umask This is a string (generally an octal representation of an integer) which will cause the buildslave process' ``umask`` value to be set shortly after initialization. The ``twistd`` daemonization utility forces the umask to 077 at startup (which means that all files created by the buildslave or its child processes will be unreadable by any user other than the buildslave account). If you want build products to be readable by other accounts, you can add ``--umask=022`` to tell the buildslave to fix the umask after twistd clobbers it. If you want build products to be *writable* by other accounts too, use ``--umask=000``, but this is likely to be a security problem. .. option:: --keepalive This is a number that indicates how frequently ``keepalive`` messages should be sent from the buildslave to the buildmaster, expressed in seconds. The default (600) causes a message to be sent to the buildmaster at least once every 10 minutes. To set this to a lower value, use e.g. ``--keepalive=120``. If the buildslave is behind a NAT box or stateful firewall, these messages may help to keep the connection alive: some NAT boxes tend to forget about a connection if it has not been used in a while. When this happens, the buildmaster will think that the buildslave has disappeared, and builds will time out. Meanwhile the buildslave will not realize than anything is wrong. .. option:: --maxdelay This is a number that indicates the maximum amount of time the buildslave will wait between connection attempts, expressed in seconds. The default (300) causes the buildslave to wait at most 5 minutes before trying to connect to the buildmaster again. .. option:: --log-size This is the size in bytes when to rotate the Twisted log files. .. option:: --log-count This is the number of log rotations to keep around. You can either specify a number or ``None`` to keep all :file:`twistd.log` files around. The default is 10. .. option:: --allow-shutdown Can also be passed directly to the BuildSlave constructor in buildbot.tac. If set, it allows the buildslave to initiate a graceful shutdown, meaning that it will ask the master to shut down the slave when the current build, if any, is complete. Setting allow_shutdown to ``file`` will cause the buildslave to watch :file:`shutdown.stamp` in basedir for updates to its mtime. When the mtime changes, the slave will request a graceful shutdown from the master. The file does not need to exist prior to starting the slave. Setting allow_shutdown to ``signal`` will set up a SIGHUP handler to start a graceful shutdown. When the signal is received, the slave will request a graceful shutdown from the master. The default value is ``None``, in which case this feature will be disabled. Both master and slave must be at least version 0.8.3 for this feature to work. .. _Other-Buildslave-Configuration: Other Buildslave Configuration ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``unicode_encoding`` This represents the encoding that buildbot should use when converting unicode commandline arguments into byte strings in order to pass to the operating system when spawning new processes. The default value is what Python's :func:`sys.getfilesystemencoding()` returns, which on Windows is 'mbcs', on Mac OSX is 'utf-8', and on Unix depends on your locale settings. If you need a different encoding, this can be changed in your build slave's :file:`buildbot.tac` file by adding a ``unicode_encoding`` argument to the BuildSlave constructor. .. code-block:: python s = BuildSlave(buildmaster_host, port, slavename, passwd, basedir, keepalive, usepty, umask=umask, maxdelay=maxdelay, unicode_encoding='utf-8', allow_shutdown='signal') .. _Upgrading-an-Existing-Buildslave: Upgrading an Existing Buildslave -------------------------------- If you have just installed a new version of Buildbot-slave, you may need to take some steps to upgrade it. If you are upgrading to version 0.8.2 or later, you can run .. code-block:: bash buildslave upgrade-slave /path/to/buildslave/dir .. _Buildslave-Version-specific-Notes: Version-specific Notes ~~~~~~~~~~~~~~~~~~~~~~ Upgrading a Buildslave to Buildbot-slave-0.8.1 '''''''''''''''''''''''''''''''''''''''''''''' Before Buildbot version 0.8.1, the Buildbot master and slave were part of the same distribution. As of version 0.8.1, the buildslave is a separate distribution. As of this release, you will need to install ``buildbot-slave`` to run a slave. Any automatic startup scripts that had run ``buildbot start`` for previous versions should be changed to run ``buildslave start`` instead. If you are running a version later than 0.8.1, then you can skip the remainder of this section: the ```upgrade-slave`` command will take care of this. If you are upgrading directly to 0.8.1, read on. The existing :file:`buildbot.tac` for any buildslaves running older versions will need to be edited or replaced. If the loss of cached buildslave state (e.g., for Source steps in copy mode) is not problematic, the easiest solution is to simply delete the slave directory and re-run ``buildslave create-slave``. If deleting the slave directory is problematic, the change to :file:`buildbot.tac` is simple. On line 3, replace :: from buildbot.slave.bot import BuildSlave with :: from buildslave.bot import BuildSlave After this change, the buildslave should start as usual. .. _Launching-the-daemons: Launching the daemons --------------------- Both the buildmaster and the buildslave run as daemon programs. To launch them, pass the working directory to the :command:`buildbot` and :command:`buildslave` commands, as appropriate: .. code-block:: bash # start a master buildbot start [ BASEDIR ] # start a slave buildslave start [ SLAVE_BASEDIR ] The *BASEDIR* is option and can be omitted if the current directory contains the buildbot configuration (the :file:`buildbot.tac` file). .. code-block:: bash buildbot start This command will start the daemon and then return, so normally it will not produce any output. To verify that the programs are indeed running, look for a pair of files named :file:`twistd.log` and :file:`twistd.pid` that should be created in the working directory. :file:`twistd.pid` contains the process ID of the newly-spawned daemon. When the buildslave connects to the buildmaster, new directories will start appearing in its base directory. The buildmaster tells the slave to create a directory for each Builder which will be using that slave. All build operations are performed within these directories: CVS checkouts, compiles, and tests. Once you get everything running, you will want to arrange for the buildbot daemons to be started at boot time. One way is to use :command:`cron`, by putting them in a ``@reboot`` crontab entry [#f1]_ .. code-block:: none @reboot buildbot start [ BASEDIR ] When you run :command:`crontab` to set this up, remember to do it as the buildmaster or buildslave account! If you add this to your crontab when running as your regular account (or worse yet, root), then the daemon will run as the wrong user, quite possibly as one with more authority than you intended to provide. It is important to remember that the environment provided to cron jobs and init scripts can be quite different that your normal runtime. There may be fewer environment variables specified, and the :envvar:`PATH` may be shorter than usual. It is a good idea to test out this method of launching the buildslave by using a cron job with a time in the near future, with the same command, and then check :file:`twistd.log` to make sure the slave actually started correctly. Common problems here are for :file:`/usr/local` or :file:`~/bin` to not be on your :envvar:`PATH`, or for :envvar:`PYTHONPATH` to not be set correctly. Sometimes :envvar:`HOME` is messed up too. Some distributions may include conveniences to make starting buildbot at boot time easy. For instance, with the default buildbot package in Debian-based distributions, you may only need to modify :file:`/etc/default/buildbot` (see also :file:`/etc/init.d/buildbot`, which reads the configuration in :file:`/etc/default/buildbot`). Buildbot also comes with its own init scripts that provide support for controlling multi-slave and multi-master setups (mostly because they are based on the init script from the Debian package). With a little modification these scripts can be used both on Debian and RHEL-based distributions and may thus prove helpful to package maintainers who are working on buildbot (or those that haven't yet split buildbot into master and slave packages). .. code-block:: bash # install as /etc/default/buildslave # or /etc/sysconfig/buildslave master/contrib/init-scripts/buildslave.default # install as /etc/default/buildmaster # or /etc/sysconfig/buildmaster master/contrib/init-scripts/buildmaster.default # install as /etc/init.d/buildslave slave/contrib/init-scripts/buildslave.init.sh # install as /etc/init.d/buildmaster slave/contrib/init-scripts/buildmaster.init.sh # ... and tell sysvinit about them chkconfig buildmaster reset # ... or update-rc.d buildmaster defaults .. _Logfiles: Logfiles -------- While a buildbot daemon runs, it emits text to a logfile, named :file:`twistd.log`. A command like ``tail -f twistd.log`` is useful to watch the command output as it runs. The buildmaster will announce any errors with its configuration file in the logfile, so it is a good idea to look at the log at startup time to check for any problems. Most buildmaster activities will cause lines to be added to the log. .. _Shutdown: Shutdown -------- To stop a buildmaster or buildslave manually, use: .. code-block:: bash buildbot stop [ BASEDIR ] # or buildslave stop [ SLAVE_BASEDIR ] This simply looks for the :file:`twistd.pid` file and kills whatever process is identified within. At system shutdown, all processes are sent a ``SIGKILL``. The buildmaster and buildslave will respond to this by shutting down normally. The buildmaster will respond to a ``SIGHUP`` by re-reading its config file. Of course, this only works on Unix-like systems with signal support, and won't work on Windows. The following shortcut is available: .. code-block:: bash buildbot reconfig [ BASEDIR ] When you update the Buildbot code to a new release, you will need to restart the buildmaster and/or buildslave before it can take advantage of the new code. You can do a :samp:`buildbot stop {BASEDIR}` and :samp:`buildbot start {BASEDIR}` in quick succession, or you can use the ``restart`` shortcut, which does both steps for you: .. code-block:: bash buildbot restart [ BASEDIR ] Buildslaves can similarly be restarted with: .. code-block:: bash buildslave restart [ BASEDIR ] There are certain configuration changes that are not handled cleanly by ``buildbot reconfig``. If this occurs, ``buildbot restart`` is a more robust tool to fully switch over to the new configuration. ``buildbot restart`` may also be used to start a stopped Buildbot instance. This behaviour is useful when writing scripts that stop, start and restart Buildbot. A buildslave may also be gracefully shutdown from the :bb:status:`WebStatus` status plugin. This is useful to shutdown a buildslave without interrupting any current builds. The buildmaster will wait until the buildslave is finished all its current builds, and will then tell the buildslave to shutdown. .. _Maintenance: Maintenance ----------- The buildmaster can be configured to send out email notifications when a slave has been offline for a while. Be sure to configure the buildmaster with a contact email address for each slave so these notifications are sent to someone who can bring it back online. If you find you can no longer provide a buildslave to the project, please let the project admins know, so they can put out a call for a replacement. The Buildbot records status and logs output continually, each time a build is performed. The status tends to be small, but the build logs can become quite large. Each build and log are recorded in a separate file, arranged hierarchically under the buildmaster's base directory. To prevent these files from growing without bound, you should periodically delete old build logs. A simple cron job to delete anything older than, say, two weeks should do the job. The only trick is to leave the :file:`buildbot.tac` and other support files alone, for which :command:`find`'s ``-mindepth`` argument helps skip everything in the top directory. You can use something like the following: .. code-block:: none @weekly cd BASEDIR && find . -mindepth 2 i-path './public_html/*' \ -prune -o -type f -mtime +14 -exec rm {} \; @weekly cd BASEDIR && find twistd.log* -mtime +14 -exec rm {} \; Alternatively, you can configure a maximum number of old logs to be kept using the ``--log-count`` command line option when running ``buildslave create-slave`` or ``buildbot create-master``. .. _Troubleshooting: Troubleshooting --------------- Here are a few hints on diagnosing common problems. .. _Starting-the-buildslave: Starting the buildslave ~~~~~~~~~~~~~~~~~~~~~~~ Cron jobs are typically run with a minimal shell (:file:`/bin/sh`, not :file:`/bin/bash`), and tilde expansion is not always performed in such commands. You may want to use explicit paths, because the :envvar:`PATH` is usually quite short and doesn't include anything set by your shell's startup scripts (:file:`.profile`, :file:`.bashrc`, etc). If you've installed buildbot (or other Python libraries) to an unusual location, you may need to add a :envvar:`PYTHONPATH` specification (note that Python will do tilde-expansion on :envvar:`PYTHONPATH` elements by itself). Sometimes it is safer to fully-specify everything: .. code-block:: none @reboot PYTHONPATH=~/lib/python /usr/local/bin/buildbot \ start /usr/home/buildbot/basedir Take the time to get the ``@reboot`` job set up. Otherwise, things will work fine for a while, but the first power outage or system reboot you have will stop the buildslave with nothing but the cries of sorrowful developers to remind you that it has gone away. .. _Connecting-to-the-buildmaster: Connecting to the buildmaster ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If the buildslave cannot connect to the buildmaster, the reason should be described in the :file:`twistd.log` logfile. Some common problems are an incorrect master hostname or port number, or a mistyped bot name or password. If the buildslave loses the connection to the master, it is supposed to attempt to reconnect with an exponentially-increasing backoff. Each attempt (and the time of the next attempt) will be logged. If you get impatient, just manually stop and re-start the buildslave. When the buildmaster is restarted, all slaves will be disconnected, and will attempt to reconnect as usual. The reconnect time will depend upon how long the buildmaster is offline (i.e. how far up the exponential backoff curve the slaves have travelled). Again, :samp:`buildslave restart {BASEDIR}` will speed up the process. .. [#f1] This ``@reboot`` syntax is understood by Vixie cron, which is the flavor usually provided with Linux systems. Other unices may have a cron that doesn't understand ``@reboot``: .. _Contrib-Scripts: Contrib Scripts ~~~~~~~~~~~~~~~ While some features of Buildbot are included in the distribution, others are only available in :file:`contrib/` in the source directory. The latest versions of such scripts are available at http://github.com/buildbot/buildbot/tree/master/master/contrib. .. _TwistedConch: http://twistedmatrix.com/trac/wiki/TwistedConch .. _TwistedWords: http://twistedmatrix.com/trac/wiki/TwistedWords .. _TwistedMail: http://twistedmatrix.com/trac/wiki/TwistedMail .. _TwistedWeb: http://twistedmatrix.com/trac/wiki/TwistedWeb buildbot-0.8.8/docs/manual/introduction.rst000066400000000000000000000332041222546025000210130ustar00rootroot00000000000000.. _Introduction: Introduction ============ BuildBot is a system to automate the compile/test cycle required by most software projects to validate code changes. By automatically rebuilding and testing the tree each time something has changed, build problems are pinpointed quickly, before other developers are inconvenienced by the failure. The guilty developer can be identified and harassed without human intervention. By running the builds on a variety of platforms, developers who do not have the facilities to test their changes everywhere before checkin will at least know shortly afterwards whether they have broken the build or not. Warning counts, lint checks, image size, compile time, and other build parameters can be tracked over time, are more visible, and are therefore easier to improve. The overall goal is to reduce tree breakage and provide a platform to run tests or code-quality checks that are too annoying or pedantic for any human to waste their time with. Developers get immediate (and potentially public) feedback about their changes, encouraging them to be more careful about testing before checkin. Features: * run builds on a variety of slave platforms * arbitrary build process: handles projects using C, Python, whatever * minimal host requirements: Python and Twisted * slaves can be behind a firewall if they can still do checkout * status delivery through web page, email, IRC, other protocols * track builds in progress, provide estimated completion time * flexible configuration by subclassing generic build process classes * debug tools to force a new build, submit fake :class:`Change`\s, query slave status * released under the `GPL `_ .. _History-and-Philosophy: History and Philosophy ---------------------- The Buildbot was inspired by a similar project built for a development team writing a cross-platform embedded system. The various components of the project were supposed to compile and run on several flavors of unix (linux, solaris, BSD), but individual developers had their own preferences and tended to stick to a single platform. From time to time, incompatibilities would sneak in (some unix platforms want to use :file:`string.h`, some prefer :file:`strings.h`), and then the tree would compile for some developers but not others. The buildbot was written to automate the human process of walking into the office, updating a tree, compiling (and discovering the breakage), finding the developer at fault, and complaining to them about the problem they had introduced. With multiple platforms it was difficult for developers to do the right thing (compile their potential change on all platforms); the buildbot offered a way to help. Another problem was when programmers would change the behavior of a library without warning its users, or change internal aspects that other code was (unfortunately) depending upon. Adding unit tests to the codebase helps here: if an application's unit tests pass despite changes in the libraries it uses, you can have more confidence that the library changes haven't broken anything. Many developers complained that the unit tests were inconvenient or took too long to run: having the buildbot run them reduces the developer's workload to a minimum. In general, having more visibility into the project is always good, and automation makes it easier for developers to do the right thing. When everyone can see the status of the project, developers are encouraged to keep the tree in good working order. Unit tests that aren't run on a regular basis tend to suffer from bitrot just like code does: exercising them on a regular basis helps to keep them functioning and useful. The current version of the Buildbot is additionally targeted at distributed free-software projects, where resources and platforms are only available when provided by interested volunteers. The buildslaves are designed to require an absolute minimum of configuration, reducing the effort a potential volunteer needs to expend to be able to contribute a new test environment to the project. The goal is for anyone who wishes that a given project would run on their favorite platform should be able to offer that project a buildslave, running on that platform, where they can verify that their portability code works, and keeps working. .. _System-Architecture: System Architecture ------------------- The Buildbot consists of a single *buildmaster* and one or more *buildslaves*, connected in a star topology. The buildmaster makes all decisions about what, when, and how to build. It sends commands to be run on the build slaves, which simply execute the commands and return the results. (certain steps involve more local decision making, where the overhead of sending a lot of commands back and forth would be inappropriate, but in general the buildmaster is responsible for everything). The buildmaster is usually fed :class:`Change`\s by some sort of version control system (:ref:`change-sources`), which may cause builds to be run. As the builds are performed, various status messages are produced, which are then sent to any registered :ref:`status-targets`. .. image:: _images/overview.* :alt: Overview Diagram The buildmaster is configured and maintained by the *buildmaster admin*, who is generally the project team member responsible for build process issues. Each buildslave is maintained by a *buildslave admin*, who do not need to be quite as involved. Generally slaves are run by anyone who has an interest in seeing the project work well on their favorite platform. .. _BuildSlave-Connections: BuildSlave Connections ~~~~~~~~~~~~~~~~~~~~~~ The buildslaves are typically run on a variety of separate machines, at least one per platform of interest. These machines connect to the buildmaster over a TCP connection to a publically-visible port. As a result, the buildslaves can live behind a NAT box or similar firewalls, as long as they can get to buildmaster. The TCP connections are initiated by the buildslave and accepted by the buildmaster, but commands and results travel both ways within this connection. The buildmaster is always in charge, so all commands travel exclusively from the buildmaster to the buildslave. To perform builds, the buildslaves must typically obtain source code from a CVS/SVN/etc repository. Therefore they must also be able to reach the repository. The buildmaster provides instructions for performing builds, but does not provide the source code itself. .. image:: _images/slaves.* :alt: BuildSlave Connections .. _Buildmaster-Architecture: Buildmaster Architecture ~~~~~~~~~~~~~~~~~~~~~~~~ The buildmaster consists of several pieces: .. image:: _images/master.* :alt: Buildmaster Architecture Change Sources Which create a Change object each time something is modified in the VC repository. Most :class:`ChangeSource`\s listen for messages from a hook script of some sort. Some sources actively poll the repository on a regular basis. All :class:`Change`\s are fed to the :class:`Scheduler`\s. Schedulers Which decide when builds should be performed. They collect :class:`Change`\s into :class:`BuildRequest`\s, which are then queued for delivery to :class:`Builders` until a buildslave is available. Builders Which control exactly *how* each build is performed (with a series of :class:`BuildStep`\s, configured in a :class:`BuildFactory`). Each :class:`Build` is run on a single buildslave. Status plugins Which deliver information about the build results through protocols like HTTP, mail, and IRC. Each :class:`Builder` is configured with a list of :class:`BuildSlave`\s that it will use for its builds. These buildslaves are expected to behave identically: the only reason to use multiple :class:`BuildSlave`\s for a single :class:`Builder` is to provide a measure of load-balancing. Within a single :class:`BuildSlave`, each :class:`Builder` creates its own :class:`SlaveBuilder` instance. These :class:`SlaveBuilder`\s operate independently from each other. Each gets its own base directory to work in. It is quite common to have many :class:`Builder`\s sharing the same buildslave. For example, there might be two buildslaves: one for i386, and a second for PowerPC. There may then be a pair of :class:`Builder`\s that do a full compile/test run, one for each architecture, and a lone :class:`Builder` that creates snapshot source tarballs if the full builders complete successfully. The full builders would each run on a single buildslave, whereas the tarball creation step might run on either buildslave (since the platform doesn't matter when creating source tarballs). In this case, the mapping would look like: .. code-block:: none Builder(full-i386) -> BuildSlaves(slave-i386) Builder(full-ppc) -> BuildSlaves(slave-ppc) Builder(source-tarball) -> BuildSlaves(slave-i386, slave-ppc) and each :class:`BuildSlave` would have two :class:`SlaveBuilders` inside it, one for a full builder, and a second for the source-tarball builder. Once a :class:`SlaveBuilder` is available, the :class:`Builder` pulls one or more :class:`BuildRequest`\s off its incoming queue. (It may pull more than one if it determines that it can merge the requests together; for example, there may be multiple requests to build the current *HEAD* revision). These requests are merged into a single :class:`Build` instance, which includes the :class:`SourceStamp` that describes what exact version of the source code should be used for the build. The :class:`Build` is then randomly assigned to a free :class:`SlaveBuilder` and the build begins. The behaviour when :class:`BuildRequest`\s are merged can be customized, :ref:`Merging-Build-Requests`. .. _Status-Delivery-Architecture: Status Delivery Architecture ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The buildmaster maintains a central :class:`Status` object, to which various status plugins are connected. Through this :class:`Status` object, a full hierarchy of build status objects can be obtained. .. image:: _images/status.* :alt: Status Delivery The configuration file controls which status plugins are active. Each status plugin gets a reference to the top-level :class:`Status` object. From there they can request information on each :class:`Builder`, :class:`Build`, :class:`Step`, and :class:`LogFile`. This query-on-demand interface is used by the ``html.Waterfall`` plugin to create the main status page each time a web browser hits the main URL. The status plugins can also subscribe to hear about new :class:`Build`\s as they occur: this is used by the :class:`MailNotifier` to create new email messages for each recently-completed :class:`Build`. The :class:`Status` object records the status of old builds on disk in the buildmaster's base directory. This allows it to return information about historical builds. There are also status objects that correspond to :class:`Scheduler`\s and :class:`BuildSlave`\s. These allow status plugins to report information about upcoming builds, and the online/offline status of each buildslave. .. _Control-Flow: Control Flow ------------ A day in the life of the buildbot: * A developer commits some source code changes to the repository. A hook script or commit trigger of some sort sends information about this change to the buildmaster through one of its configured Change Sources. This notification might arrive via email, or over a network connection (either initiated by the buildmaster as it *subscribes* to changes, or by the commit trigger as it pushes :class:`Change`\s towards the buildmaster). The :class:`Change` contains information about who made the change, what files were modified, which revision contains the change, and any checkin comments. * The buildmaster distributes this change to all of its configured :class:`Scheduler`\s. Any ``important`` changes cause the ``tree-stable-timer`` to be started, and the :class:`Change` is added to a list of those that will go into a new :class:`Build`. When the timer expires, a :class:`Build` is started on each of a set of configured Builders, all compiling/testing the same source code. Unless configured otherwise, all :class:`Build`\s run in parallel on the various buildslaves. * The :class:`Build` consists of a series of :class:`Step`\s. Each :class:`Step` causes some number of commands to be invoked on the remote buildslave associated with that :class:`Builder`. The first step is almost always to perform a checkout of the appropriate revision from the same VC system that produced the :class:`Change`. The rest generally perform a compile and run unit tests. As each :class:`Step` runs, the buildslave reports back command output and return status to the buildmaster. * As the :class:`Build` runs, status messages like "Build Started", "Step Started", "Build Finished", etc, are published to a collection of Status Targets. One of these targets is usually the HTML ``Waterfall`` display, which shows a chronological list of events, and summarizes the results of the most recent build at the top of each column. Developers can periodically check this page to see how their changes have fared. If they see red, they know that they've made a mistake and need to fix it. If they see green, they know that they've done their duty and don't need to worry about their change breaking anything. * If a :class:`MailNotifier` status target is active, the completion of a build will cause email to be sent to any developers whose :class:`Change`\s were incorporated into this :class:`Build`. The :class:`MailNotifier` can be configured to only send mail upon failing builds, or for builds which have just transitioned from passing to failing. Other status targets can provide similar real-time notification via different communication channels, like IRC. buildbot-0.8.8/docs/manual/optimization.rst000066400000000000000000000020601222546025000210140ustar00rootroot00000000000000.. _Optimization: Optimization ============ If you're feeling your Buildbot is running a bit slow, here are some tricks that may help you, but use them at your own risk. Properties load speedup ----------------------- For example, if most of your build properties are strings, you can gain an approx. 30% speedup if you put this snippet of code inside your master.cfg file:: def speedup_json_loads(): import json, re original_decode = json._default_decoder.decode my_regexp = re.compile(r'^\[\"([^"]*)\",\s+\"([^"]*)\"\]$') def decode_with_re(str, *args, **kw): m = my_regexp.match(str) try: return list(m.groups()) except: return original_decode(str, *args, **kw) json._default_decoder.decode = decode_with_re speedup_json_loads() It patches json decoder so that it would first try to extract a value from JSON that is a list of two strings (which is the case for a property being a string), and would fallback to general JSON decoder on any errorbuildbot-0.8.8/docs/manual/resources.rst000066400000000000000000000007061222546025000203050ustar00rootroot00000000000000.. _Resources: Resources ========= The Buildbot home page is http://buildbot.net/. For configuration questions and general discussion, please use the ``buildbot-devel`` mailing list. The subscription instructions and archives are available at http://lists.sourceforge.net/lists/listinfo/buildbot-devel The ``#buildbot`` channel on Freenode's IRC servers hosts development discussion, and often folks are available to answer questions there, as well. buildbot-0.8.8/docs/relnotes/000077500000000000000000000000001222546025000161145ustar00rootroot00000000000000buildbot-0.8.8/docs/relnotes/0.3.1.txt000066400000000000000000000005751222546025000173230ustar00rootroot00000000000000Buildbot 0.3.1 was released 29 Apr 2003 ** First release. ** Features implemented: change notification from FreshCVS server or parsed maildir contents timed builds basic builds, configure/compile/test some Twisted-specific build steps: docs, unit tests, debuild status reporting via web page ** Features still experimental/unpolished status reporting via PB client buildbot-0.8.8/docs/relnotes/0.3.2.txt000066400000000000000000000025501222546025000173170ustar00rootroot00000000000000Buildbot 0.3.2 was released 7 May 2003 ** packaging changes *** fix major packaging bug: none of the buildbot/* subdirectories were included in the 0.3.1 release. Sorry, I'm still figuring out distutils here.. ** internal changes *** use pb.Cacheable to update Events in remote status client. much cleaner. *** start to clean up BuildProcess->status.builder interface ** bug fixes *** waterfall display was missing a , causing it to be misrendered in most browsers (except the one I was testing it with, of course) *** URL without trailing slash (when served in a twisted-web distributed server, with a url like "http://twistedmatrix.com/~warner.twistd") should do redirect to URL-with-trailing-slash, otherwise internal hrefs are broken. *** remote status clients: forget RemoteReferences at shutdown, removes warnings about "persisting Ephemerals" ** Twisted buildprocess updates: *** match build process as of twisted-1.0.5 **** use python2.2 everywhere now that twisted rejects python2.1 **** look for test-result constants in multiple places *** move experimental 'trial --jelly' code to separate module *** add FreeBSD builder *** catch rc!=0 in HLint step *** remove RunUnitTestsRandomly, use randomly=1 parameter instead *** parameterize ['twisted.test'] default test case to make subclassing easier *** ignore internal distutils warnings in python2.3 builder buildbot-0.8.8/docs/relnotes/0.3.3.txt000066400000000000000000000062311222546025000173200ustar00rootroot00000000000000Buildbot 0.3.3 was released 21 May 2003 ** packaging changes *** include doc/examples in the release. Oops again. ** network changes *** add keepalives to deal with NAT boxes Some NAT boxes drop port mappings if the TCP connection looks idle for too long (maybe 30 minutes?). Add application-level keepalives (dummy commands sent from slave to master every 10 minutes) to appease the NAT box and keep our connection alive. Enable this with --keepalive in the slave mktap command line. Check the README for more details. ** UI changes *** allow slaves to trigger any build that they host Added an internal function to ask the buildmaster to start one of their builds. Must be triggered with a debugger or manhole on the slave side for now, will add a better UI later. *** allow web page viewers to trigger any build Added a button to the per-build page (linked by the build names on the third row of the waterfall page) to allow viewers to manually trigger builds. There is a field for them to indicate who they are and why they are triggering the build. It is possible to abuse this, but for now the benefits outweigh the damage that could be done (worst case, someone can make your machine run builds continuously). ** generic buildprocess changes *** don't queue multiple builds for offline slaves If a slave is not online when a build is ready to run, that build is queued so the slave will run it when it next connects. However, the buildmaster used to queue every such build, so the poor slave machine would be subject to tens or hundreds of builds in a row when they finally did come online. The buildmaster has been changed to merge these multiple builds into a single one. *** bump ShellCommand default timeout to 20 minutes Used for testing out the win32 twisted builder. I will probably revert this in the next relese. *** split args in ShellCommand ourselves instead of using /bin/sh This should remove the need for /bin/sh on the slave side, improving the chances that the buildslave can run on win32. *** add configureEnv argument to Configure step, pass env dict to slave Allows build processes to do things like 'CFLAGS=-O0 ./configure' without using /bin/sh to set the environment variable ** Twisted buildprocess changes *** warn instead of flunk the build when cReactor or qtreactor tests fail These two always fail. For now, downgrade those failures to a warning (orange box instead of red). *** don't use 'clobber' on remote builds Builds that run on remote machines (freebsd, OS-X) now use 'cvs update' instead of clobbering their trees and doing a fresh checkout. The multiple simultaneous CVS checkouts were causing a strain on Glyph's upstream bandwidth. *** use trial --testmodule instead of our own test-case-name grepper The Twisted coding/testing convention has developers put 'test-case-name' tags (emacs local variables, actually) in source files to indicate which test cases should be run to exercise that code. Twisted's unit-test framework just acquired an argument to look for these tags itself. Use that instead of the extra FindUnitTestsForFiles build step we were doing before. Removes a good bit of code from buildbot and into Twisted where it really belongs. buildbot-0.8.8/docs/relnotes/0.3.4.txt000066400000000000000000000025621222546025000173240ustar00rootroot00000000000000Buildbot 0.3.4 was released 28 Jul 2003 ** IRC client The buildmaster can now join a set of IRC channels and respond to simple queries about builder status. ** slave information The build slaves can now report information from a set of info/* files in the slave base directory to the buildmaster. This will be used by the slave administrator to announce details about the system hosting the slave, contact information, etc. For now, info/admin should contain the name/email of the person who is responsible for the buildslave, and info/host should describe the system hosting the build slave (OS version, CPU speed, memory, etc). The contents of these files are made available through the waterfall display. ** change notification email parsers A parser for Syncmail (syncmail.sourceforge.net) was added. SourceForge provides examples of setting up syncmail to deliver CVS commit messages to mailing lists, so hopefully this will make it easier for sourceforge-hosted projects to set up a buildbot. email processors were moved into buildbot.changes.mail . FCMaildirSource was moved, and the compatibility location (buildbot.changes.freshcvsmail) will go away in the next release. ** w32 buildslave ought to work Some non-portable code was changed to make it more likely that the buildslave will run under windows. The Twisted buildbot now has a (more-or-less) working w32 buildslave. buildbot-0.8.8/docs/relnotes/0.3.5.txt000066400000000000000000000050061222546025000173210ustar00rootroot00000000000000Buildbot-0.3.5 was released 19 Sep 2003 ** newcred Buildbot has moved to "newcred", a new authorization framework provided by Twisted, which is a good bit cleaner and easier to work with than the "oldcred" scheme in older versions. This causes both buildmaster and buildslaves to depend upon Twisted 1.0.7 or later. The interface to 'makeApp' has changed somewhat (the multiple kinds of remote connections all use the same TCP port now). Old buildslaves will get "_PortalWrapper instance has no attribute 'remote_username'" errors when they try to connect. They must be upgraded. The FreshCVSSource uses PB to connect to the CVSToys server. This has been upgraded to use newcred too. If you get errors (TODO: what do they look like?) in the log when the buildmaster tries to connect, you need to upgrade your FreshCVS service or use the 'useOldcred' argument when creating your FreshCVSSource. This is a temporary hack to allow the buildmaster to talk to oldcred CVSToys servers. Using it will trigger deprecation warnings. It will go away eventually. In conjunction with this change, makeApp() now accepts a password which can be applied to the debug service. ** new features *** "copydir" for CVS checkouts The CVS build step can now accept a "copydir" parameter, which should be a directory name like "source" or "orig". If provided, the CVS checkout is done once into this directory, then copied into the actual working directory for compilation etc. Later updates are done in place in the copydir, then the workdir is replaced with a copy. This reduces CVS bandwidth (update instead of full checkout) at the expense of twice the disk space (two copies of the tree). *** Subversion (SVN) support Radix (Christopher Armstrong) contributed early support for building Subversion-based trees. The new 'SVN' buildstep behaves roughly like the 'CVS' buildstep, and the contrib/svn_buildbot.py script can be used as a checkin trigger to feed changes to a running buildmaster. ** notable bugfixes *** .tap file generation We no longer set the .tap filename, because the buildmaster/buildslave service might be added to an existing .tap file and we shouldn't presume to own the whole thing. You may want to manually rename the "buildbot.tap" file to something more meaningful (like "buildslave-bot1.tap"). *** IRC reconnect If the IRC server goes away (it was restarted, or the network connection was lost), the buildmaster will now schedule a reconnect attempt. *** w32 buildslave fixes An "rm -rf" was turned into shutil.rmtree on non-posix systems. buildbot-0.8.8/docs/relnotes/0.4.0.txt000066400000000000000000000111551222546025000173170ustar00rootroot00000000000000Buildbot 0.4.0 was released 05 Dec 2003 ** newapp I've moved the codebase to Twisted's new 'application' framework, which drastically cleans up service startup/shutdown just like newcred did for authorization. This is mostly an internal change, but the interface to IChangeSources was modified, so in the off chance that someone has written a custom change source, it may have to be updated to the new scheme. The most user-visible consequence of this change is that now both buildmasters and buildslaves are generated with the standard Twisted 'mktap' utility. Basic documentation is in the README file. Both buildmaster and buildslave .tap files need to be re-generated to run under the new code. I have not figured out the styles.Versioned upgrade path well enough to avoid this yet. Sorry. This also means that both buildslaves and the buildmaster require Twisted-1.1.0 or later. ** reloadable master.cfg Most aspects of a buildmaster is now controlled by a configuration file which can be re-read at runtime without losing build history. This feature makes the buildmaster *much* easier to maintain. In the previous release, you would create the buildmaster by writing a program to define the Builders and ChangeSources and such, then run it to create the .tap file. In the new release, you use 'mktap' to create the .tap file, and the only parameter you give it is the base directory to use. Each time the buildmaster starts, it will look for a file named 'master.cfg' in that directory and parse it as a python script. That script must define a dictionary named 'BuildmasterConfig' with various keys to define the builders, the known slaves, what port to use for the web server, what IRC channels to connect to, etc. This config file can be re-read at runtime, and the buildmaster will compute the differences and add/remove services as necessary. The re-reading is currently triggered through the debug port (contrib/debugclient.py is the debug port client), but future releases will add the ability to trigger the reconfiguration by IRC command, web page button, and probably a local UNIX socket (with a helper script to trigger a rebuild locally). docs/examples/twisted_master.cfg contains a sample configuration file, which also lists all the keys that can be set. There may be some bugs lurking, such as re-configuring the buildmaster while a build is running. It needs more testing. ** MaxQ support Radix contributed some support scripts to run MaxQ test scripts. MaxQ (http://maxq.tigris.org/) is a web testing tool that allows you to record HTTP sessions and play them back. ** Builders can now wait on multiple Interlocks The "Interlock" code has been enhanced to allow multiple builders to wait on each one. This was done to support the new config-file syntax for specifying Interlocks (in which each interlock is a tuple of A and [B], where A is the builder the Interlock depends upon, and [B] is a list of builders that depend upon the Interlock). "Interlock" is misnamed. In the next release it will be changed to "Dependency", because that's what it really expresses. A new class (probably called Interlock) will be created to express the notion that two builders should not run at the same time, useful when multiple builders are run on the same machine and thrashing results when several CPU- or disk- intensive compiles are done simultaneously. ** FreshCVSSource can now handle newcred-enabled FreshCVS daemons There are now two FreshCVSSource classes: FreshCVSSourceNewcred talks to newcred daemons, and FreshCVSSourceOldcred talks to oldcred ones. Mind you, FreshCVS doesn't yet do newcred, but when it does, we'll be ready. 'FreshCVSSource' maps to the oldcred form for now. That will probably change when the current release of CVSToys supports newcred by default. ** usePTY=1 on posix buildslaves When a buildslave is running under POSIX (i.e. pretty much everything except windows), child processes are created with a pty instead of separate stdin/stdout/stderr pipes. This makes it more likely that a hanging build (when killed off by the timeout code) will have all its sub-childred cleaned up. Non-pty children would tend to leave subprocesses running because the buildslave was only able to kill off the top-level process (typically 'make'). Windows doesn't have any concept of ptys, so non-posix systems do not try to enable them. ** mail parsers should actually work now The email parsing functions (FCMaildirSource and SyncmailMaildirSource) were broken because of my confused understanding of how python class methods work. These sources should be functional now. ** more irc bot sillyness The IRC bot can now perform half of the famous AYBABTO scene. buildbot-0.8.8/docs/relnotes/0.4.1.txt000066400000000000000000000015461222546025000173230ustar00rootroot00000000000000Buildbot-0.4.1 was released 09 Dec 2003 ** MaildirSources fixed Several bugs in MaildirSource made them unusable. These have been fixed (for real this time). The Twisted buildbot is using an FCMaildirSource while they fix some FreshCVS daemon problems, which provided the encouragement for getting these bugs fixed. In addition, the use of DNotify (only available under linux) was somehow broken, possibly by changes in some recent version of Python. It appears to be working again now (against both python-2.3.3c1 and python-2.2.1). ** master.cfg can use 'basedir' variable As documented in the sample configuration file (but not actually implemented until now), a variable named 'basedir' is inserted into the namespace used by master.cfg . This can be used with something like: os.path.join(basedir, "maildir") to obtain a master-basedir-relative location. buildbot-0.8.8/docs/relnotes/0.4.2.txt000066400000000000000000000023711222546025000173210ustar00rootroot00000000000000Buildbot-0.4.2 was released 08 Jan 2004 ** test suite updated The test suite has been completely moved over to Twisted's "Trial" framework, and all tests now pass. To run the test suite (consisting of 64 tests, probably covering about 30% of BuildBot's logic), do this: PYTHONPATH=. trial -v buildbot.test ** Mail parsers updated Several bugs in the mail-parsing code were fixed, allowing a buildmaster to be triggered by mail sent out by a CVS repository. (The Twisted Buildbot is now using this to trigger builds, as their CVS server machine is having some difficulties with FreshCVS). The FreshCVS mail format for directory additions appears to have changed recently: the new parser should handle both old and new-style messages. A parser for Bonsai commit messages (buildbot.changes.mail.parseBonsaiMail) was contributed by Stephen Davis. Thanks Stephen! ** CVS "global options" now available The CVS build step can now accept a list of "global options" to give to the cvs command. These go before the "update"/"checkout" word, and are described fully by "cvs --help-options". Two useful ones might be "-r", which causes checked-out files to be read-only, and "-R", which assumes the repository is read-only (perhaps by not attempting to write to lock files). buildbot-0.8.8/docs/relnotes/0.4.3.txt000066400000000000000000000116521222546025000173240ustar00rootroot00000000000000Buildbot-0.4.3 was released 30 Apr 2004 ** PBChangeSource made explicit In 0.4.2 and before, an internal interface was available which allowed special clients to inject changes into the Buildmaster. This interface is used by the contrib/svn_buildbot.py script. The interface has been extracted into a proper PBChangeSource object, which should be created in the master.cfg file just like the other kinds of ChangeSources. See docs/sources.xhtml for details. If you were implicitly using this change source (for example, if you use Subversion and the svn_buildbot.py script), you *must* add this source to your master.cfg file, or changes will not be delivered and no builds will be triggered. The PBChangeSource accepts the same "prefix" argument as all other ChangeSources. For a SVN repository that follows the recommended practice of using "trunk/" for the trunk revisions, you probably want to construct the source like this: source = PBChangeSource(prefix="trunk") to make sure that the Builders are given sensible (trunk-relative) filenames for each changed source file. ** Twisted changes *** step_twisted.RunUnitTests can change "bin/trial" The twisted RunUnitTests step was enhanced to let you run something other than "bin/trial", making it easier to use a buildbot on projects which use Twisted but aren't actually Twisted itself. *** Twisted now uses Subversion Now that Twisted has moved from CVS to SVN, the Twisted build processes have been modified to perform source checkouts from the Subversion repository. ** minor feature additions *** display Changes with HTML Changes are displayed with a bit more pizazz, and a links= argument was added to allow things like ViewCVS links to be added to the display (although it is not yet clear how this argument should be used: the interface remains subject to change untill it has been documented). *** display ShellCommand logs with HTML Headers are in blue, stderr is in red (unless usePTY=1 in which case stderr and stdout are indistinguishable). A link is provided which returns the same contents as plain text (by appending "?text=1" to the URL). *** buildslaves send real tracebacks upon error The .unsafeTracebacks option has been turned on for the buildslaves, allowing them to send a full stack trace when an exception occurs, which is logged in the buildmaster's twistd.log file. This makes it much easier to determine what went wrong on the slave side. *** BasicBuildFactory refactored The BasicBuildFactory class was refactored to make it easier to create derivative classes, in particular the BasicSVN variant. *** "ping buildslave" web button added There is now a button on the "builder information" page that lets a web user initiate a ping of the corresponding build slave (right next to the button that lets them force a build). This was added to help track down a problem with the slave keepalives. ** bugs fixed: You can now have multiple BuildSteps with the same name (the names are used as hash keys in the data structure that helps determine ETA values for each step, the new code creates unique key names if necessary to avoid collisions). This means that, for example, you do not have to create a BuildStep subclass just to have two Compile steps in the same process. If CVSToys is not installed, the tests that depend upon it are skipped. Some tests in 0.4.2 failed because of a missing set of test files, they are now included in the tarball properly. Slave keepalives should work better now in the face of silent connection loss (such as when an intervening NAT box times out the association), the connection should be reestablished in minutes instead of hours. Shell commands on the slave are invoked with an argument list instead of the ugly and error-prone split-on-spaces approach. If the ShellCommand is given a string (instead of a list), it will fall back to splitting on spaces. Shell commands should work on win32 now (using COMSPEC instead of /bin/sh). Buildslaves under w32 should theoretically work now, and one was running for the Twisted buildbot for a while until the machine had to be returned. The "header" lines in ShellCommand logs (which include the first line, that displays the command being run, and the last, which shows its exit status) are now generated by the buildslave side instead of the local (buildmaster) side. This can provide better error handling and is generally cleaner. However, if you have an old buildslave (running 0.4.2 or earlier) and a new buildmaster, then neither end will generate these header lines. CVSCommand was improved, in certain situations 0.4.2 would perform unnecessary checkouts (when an update would have sufficed). Thanks to Johan Dahlin for the patches. The status output was fixed as well, so that failures in CVS and SVN commands (such as not being able to find the 'svn' executable) make the step status box red. Subversion support was refactored to make it behave more like CVS. This is a work in progress and will be improved in the next release. buildbot-0.8.8/docs/relnotes/0.5.0.txt000066400000000000000000000066741222546025000173320ustar00rootroot00000000000000Buildbot 0.5.0 was released 22 Jul 2004 ** new features *** web.distrib servers via TCP The 'webPathname' config option, which specifies a UNIX socket on which to publish the waterfall HTML page (for use by 'mktap web -u' or equivalent), now accepts a numeric port number. This publishes the same thing via TCP, allowing the parent web server to live on a separate machine. This config option could be named better, but it will go away altogether in a few releases, when status delivery is unified. It will be replaced with a WebStatusTarget object, and the config file will simply contain a list of various kinds of status targets. *** 'master.cfg' filename is configurable The buildmaster can use a config file named something other than "master.cfg". Use the --config=foo.cfg option to mktap to control this. *** FreshCVSSource now uses newcred (CVSToys >= 1.0.10) The FreshCVSSource class now defaults to speaking to freshcvs daemons from modern CVSToys releases. If you need to use the buildbot with a daemon from CVSToys-1.0.9 or earlier, use FreshCVSSourceOldcred instead. Note that the new form only requires host/port/username/passwd: the "serviceName" parameter is no longer meaningful. *** Builders are now configured with a dictionary, not a tuple The preferred way to set up a Builder in master.cfg is to provide a dictionary with various keys, rather than a (non-extensible) 4-tuple. See docs/config.xhtml for details. The old tuple-way is still supported for now, it will probably be deprecated in the next release and removed altogether in the following one. *** .periodicBuildTime is now exposed to the config file To set a builder to run at periodic intervals, simply add a 'periodicBuildTime' key to its master.cfg dictionary. Again, see docs/config.xhtml for details. *** svn_buildbot.py adds --include, --exclude The commit trigger script now gives you more control over which files are sent to the buildmaster and which are not. *** usePTY is controllable at slave mktap time The buildslaves usually run their child processes in a pty, which creates a process group for all the children, which makes it much easier to kill them all at once (i.e. if a test hangs). However this causes problems on some systems. Rather than hacking slavecommand.py to disable the use of these ptys, you can now create the slave's .tap file with --usepty=0 at mktap time. ** Twisted changes A summary of warnings (e.g. DeprecationWarnings) is provided as part of the test-case summarizer. The summarizer also counts Skips, expectedFailures, and unexpectedSuccesses, displaying the counts on the test step's event box. The RunUnitTests step now uses "trial -R twisted" instead of "trial twisted.test", which is a bit cleaner. All .pyc files are deleted before starting trial, to avoid getting tripped up by deleted .py files. ** documentation docs/config.xhtml now describes the syntax and allowed contents of the 'master.cfg' configuration file. ** bugfixes Interlocks had a race condition that could cause the lock to get stuck forever. FreshCVSSource has a prefix= argument that was moderately broken (it used to only work if the prefix was a single directory component). It now works with subdirectories. The buildmaster used to complain when it saw the "info" directory in a slave's workspace. This directory is used to publish information about the slave host and its administrator, and is not a leftover build directory as the complaint suggested. This complain has been silenced. buildbot-0.8.8/docs/relnotes/0.6.0.txt000066400000000000000000000224771222546025000173320ustar00rootroot00000000000000Buildbot 0.6.0 was released 30 Sep 2004 ** new features *** /usr/bin/buildbot control tool There is now an executable named 'buildbot'. For now, this just provides a convenient front-end to mktap/twistd/kill, but eventually it will provide access to other client functionality (like the 'try' builds, and a status client). Assuming you put your buildbots in /var/lib/buildbot/master/FOO, you can do 'buildbot create-master /var/lib/buildbot/master/FOO' and it will create the .tap file and set up a sample master.cfg for you. Later, 'buildbot start /var/lib/buildbot/master/FOO' will start the daemon. *** build status now saved in external files, -shutdown.tap unnecessary The status rewrite included a change to save all build status in a set of external files. These files, one per build, are put in a subdirectory of the master's basedir (named according to the 'builddir' parameter of the Builder configuration dictionary). This helps keep the buildmaster's memory consumption small: the (potentially large) build logs are kept on disk instead of in RAM. There is a small cache (2 builds per builder) kept in memory, but everything else lives on disk. The big change is that the buildmaster now keeps *all* status in these files. It is no longer necessary to preserve the buildbot-shutdown.tap file to run a persistent buildmaster. The buildmaster may be launched with 'twistd -f buildbot.tap' each time, in fact the '-n' option can be added to prevent twistd from automatically creating the -shutdown.tap file. There is still one lingering bug with this change: the Expectations object for each builder (which records how long the various steps took, to provide an ETA value for the next time) is not yet saved. The result is that the first build after a restart will not provide an ETA value. 0.6.0 keeps status in a single file per build, as opposed to 0.5.0 which kept status in many subdirectories (one layer for builds, another for steps, and a third for logs). 0.6.0 will detect and delete these subdirectories as it overwrites them. The saved builds are optional. To prevent disk usage from growing without bounds, you may want to set up a cron job to run 'find' and delete any which are too old. The status displays will happily survive without those saved build objects. The set of recorded Changes is kept in a similar file named 'changes.pck'. *** source checkout now uses timestamp/revision Source checkouts are now performed with an appropriate -D TIMESTAMP (for CVS) or -r REVISION (for SVN) marker to obtain the exact sources that were specified by the most recent Change going into the current Build. This avoids a race condition in which a change might be committed after the build has started but before the source checkout has completed, resulting in a mismatched set of source files. Such changes are now ignored. This works by keeping track of repository-wide revision/transaction numbers (for version control systems that offer them, like SVN). The checkout or update is performed with the highest such revision number. For CVS (which does not have them), the timestamp of each commit message is used, and a -D argument is created to place the checkout squarely in the middle of the "tree stable timer"'s window. This also provides the infrastructure for the upcoming 'try' feature. All source-checkout commands can now obtain a base revision marker and a patch from the Build, allowing certain builds to be performed on something other than the most recent sources. See source.xhtml and steps.xhtml for details. *** Darcs and Arch support added There are now build steps which retrieve a source tree from Darcs and Arch repositories. See steps.xhtml for details. Preliminary P4 support has been added, thanks to code from Dave Peticolas. You must manually set up each build slave with an appropriate P4CLIENT: all buildbot does is run 'p4 sync' at the appropriate times. *** Status reporting rewritten Status reporting was completely revamped. The config file now accepts a BuildmasterConfig['status'] entry, with a list of objects that perform status delivery. The old config file entries which controlled the web status port and the IRC bot have been deprecated in favor of adding instances to ['status']. The following status-delivery classes have been implemented, all in the 'buildbot.status' package: client.PBListener(port, username, passwd) html.Waterfall(http_port, distrib_port) mail.MailNotifier(fromaddr, mode, extraRecipients..) words.IRC(host, nick, channels) See the individual docstrings for details about how to use each one. You can create new status-delivery objects by following the interfaces found in the buildbot.interfaces module. *** BuildFactory configuration process changed The basic BuildFactory class is now defined in buildbot.process.factory rather than buildbot.process.base, so you will have to update your config files. factory.BuildFactory is the base class, which accepts a list of Steps to run. See docs/factories.xhtml for details. There are now easier-to-use BuildFactory classes for projects which use GNU Autoconf, perl's MakeMaker (CPAN), python's distutils (but no unit tests), and Twisted's Trial. Each one takes a separate 'source' Step to obtain the source tree, and then fills in the rest of the Steps for you. *** CVS/SVN VC steps unified, simplified The confusing collection of arguments for the CVS step ('clobber=', 'copydir=', and 'export=') have been removed in favor of a single 'mode' argument. This argument describes how you want to use the sources: whether you want to update and compile everything in the same tree (mode='update'), or do a fresh checkout and full build each time (mode='clobber'), or something in between. The SVN (Subversion) step has been unified and accepts the same mode= parameter as CVS. New version control steps will obey the same interface. Most of the old configuration arguments have been removed. You will need to update your configuration files to use the new arguments. See docs/steps.xhtml for a description of all the new parameters. *** Preliminary Debian packaging added Thanks to the contributions of Kirill Lapshin, we can now produce .deb installer packages. These are still experimental, but they include init.d startup/shutdown scripts, which the the new /usr/bin/buildbot to invoke twistd. Create your buildmasters in /var/lib/buildbot/master/FOO, and your slaves in /var/lib/buildbot/slave/BAR, then put FOO and BAR in the appropriate places in /etc/default/buildbot . After that, the buildmasters and slaves will be started at every boot. Pre-built .debs are not yet distributed. Use 'debuild -uc -us' from the source directory to create them. ** minor features *** Source Stamps Each build now has a "source stamp" which describes what sources it used. The idea is that the sources for this particular build can be completely regenerated from the stamp. The stamp is a tuple of (revision, patch), where the revision depends on the VC system being used (for CVS it is either a revision tag like "BUILDBOT-0_5_0" or a datestamp like "2004/07/23", for Subversion it is a revision number like 11455). This must be combined with information from the Builder that is constant across all builds (something to point at the repository, and possibly a branch indicator for CVS and other VC systems that don't fold this into the repository string). The patch is an optional unified diff file, ready to be applied by running 'patch -p0 ' on a builder which is currently performing a build. When that build is finished, the buildbot will make an announcement (including the results of the build). The IRC 'force build' command will also announce when the resulting build has completed. *** the 'force build' option on HTML and IRC status targets can be disabled The html.Waterfall display and the words.IRC bot may be constructed with an allowForce=False argument, which removes the ability to force a build through these interfaces. Future versions will be able to restrict this build-forcing capability to authenticated users. The per-builder HTML page no longer displays the 'Force Build' buttons if it does not have this ability. Thanks to Fred Drake for code and design suggestions. *** master now takes 'projectName' and 'projectURL' settings These strings allow the buildbot to describe what project it is working for. At the moment they are only displayed on the Waterfall page, but in the next release they will be retrieveable from the IRC bot as well. *** survive recent (SVN) Twisted versions The buildbot should run correctly (albeit with plenty of noisy deprecation warnings) under the upcoming Twisted-2.0 release. *** work-in-progress realtime Trial results acquisition Jonathan Simms () has been working on 'retrial', a rewrite of Twisted's unit test framework that will most likely be available in Twisted-2.0 . Although it is not yet complete, the buildbot will be able to use retrial in such a way that build status is reported on a per-test basis, in real time. This will be the beginning of fine-grained test tracking and Problem management, described in docs/users.xhtml . buildbot-0.8.8/docs/relnotes/0.6.1.txt000066400000000000000000000105601222546025000173210ustar00rootroot00000000000000Buildbot 0.6.1 was released 23 Nov 2004 ** win32 improvements/bugfixes Several changes have gone in to improve portability to non-unix systems. It should be possible to run a build slave under windows without major issues (although step-by-step documentation is still greatly desired: check the mailing list for suggestions from current win32 users). *** PBChangeSource: use configurable directory separator, not os.sep The PBChangeSource, which listens on a TCP socket for change notices delivered from tools like contrib/svn_buildbot.py, was splitting source filenames with os.sep . This is inappropriate, because those file names are coming from the VC repository, not the local filesystem, and the repository host may be running a different OS (with a different separator convention) than the buildmaster host. In particular, a win32 buildmaster using a CVS repository running on a unix box would be confused. PBChangeSource now takes a sep= argument to indicate the separator character to use. *** build saving should work better windows cannot do the atomic os.rename() trick that unix can, so under win32 the buildmaster falls back to save/delete-old/rename, which carries a slight risk of losing a saved build log (if the system were to crash between the delete-old and the rename). ** new features *** test-result tracking Work has begun on fine-grained test-result handling. The eventual goal is to be able to track individual tests over time, and create problem reports when a test starts failing (which then are resolved when the test starts passing again). The first step towards this is an ITestResult interface, and code in the TrialTestParser to create such results for all non-passing tests (the ones for which Trial emits exception tracebacks). These test results are currently displayed in a tree-like display in a page accessible from each Build's page (follow the numbered link in the yellow box at the start of each build to get there). This interface is still in flux, as it really wants to be able to accomodate things like compiler warnings and tests that are skipped because of missing libraries or unsupported architectures. ** bug fixes *** VC updates should survive temporary failures Some VC systems (CVS and SVN in particular) get upset when files are turned into directories or vice versa, or when repository items are moved without the knowledge of the VC system. The usual symptom is that a 'cvs update' fails where a fresh checkout succeeds. To avoid having to manually intervene, the build slaves' VC commands have been refactored to respond to update failures by deleting the tree and attempting a full checkout. This may cause some unnecessary effort when, e.g., the CVS server falls off the net, but in the normal case it will only come into play when one of these can't-cope situations arises. *** forget about an existing build when the slave detaches If the slave was lost during a build, the master did not clear the .currentBuild reference, making that builder unavailable for later builds. This has been fixed, so that losing a slave should be handled better. This area still needs some work, I think it's still possible to get both the slave and the master wedged by breaking the connection at just the right time. Eventually I want to be able to resume interrupted builds (especially when the interruption is the result of a network failure and not because the slave or the master actually died). *** large logfiles now consume less memory Build logs are stored as lists of (type,text) chunks, so that stdout/stderr/headers can be displayed differently (if they were distinguishable when they were generated: stdout and stderr are merged when usePTY=1). For multi-megabyte logfiles, a large list with many short strings could incur a large overhead. The new behavior is to merge same-type string chunks together as they are received, aiming for a chunk size of about 10kb, which should bring the overhead down to a more reasonable level. There remains an issue with actually delivering large logfiles over, say, the HTML interface. The string chunks must be merged together into a single string before delivery, which causes a spike in the memory usage when the logfile is viewed. This can also break twisted.web.distrib -type servers, where the underlying PB protocol imposes a 640k limit on the size of strings. This will be fixed (with a proper Producer/Consumer scheme) in the next release. buildbot-0.8.8/docs/relnotes/0.6.2.txt000066400000000000000000000061431222546025000173240ustar00rootroot00000000000000Buildbot 0.6.2 was released 13 Dec 2004 ** new features It is now possible to interrupt a running build. Both the web page and the IRC bot feature 'stop build' commands, which can be used to interrupt the current BuildStep and accelerate the termination of the overall Build. The status reporting for these still leaves something to be desired (an 'interrupt' event is pushed into the column, and the reason for the interrupt is added to a pseudo-logfile for the step that was stopped, but if you only look at the top-level status it appears that the build failed on its own). Builds are also halted if the connection to the buildslave is lost. On the slave side, any active commands are halted if the connection to the buildmaster is lost. ** minor new features The IRC log bot now reports ETA times in a MMSS format like "2m45s" instead of the clunky "165 seconds". ** bug fixes *** Slave Disconnect Slave disconnects should be handled better now: the current build should be abandoned properly. Earlier versions could get into weird states where the build failed to finish, clogging the builder forever (or at least until the buildmaster was restarted). In addition, there are weird network conditions which could cause a buildslave to attempt to connect twice to the same buildmaster. This can happen when the slave is sending large logfiles over a slow link, while using short keepalive timeouts. The buildmaster has been fixed to allow the second connection attempt to take precedence over the first, so that the older connection is jettisoned to make way for the newer one. In addition, the buildslave has been fixed to be less twitchy about timeouts. There are now two parameters: keepaliveInterval (which is controlled by the mktap 'keepalive' argument), and keepaliveTimeout (which requires editing the .py source to change from the default of 30 seconds). The slave expects to see *something* from the master at least once every keepaliveInterval seconds, and will try to provoke a response (by sending a keepalive request) 'keepaliveTimeout' seconds before the end of this interval just in case there was no regular traffic. Any kind of traffic will qualify, including acknowledgements of normal build-status updates. The net result is that, as long as any given PB message can be sent over the wire in less than 'keepaliveTimeout' seconds, the slave should not mistakenly disconnect because of a timeout. There will be traffic on the wire at least every 'keepaliveInterval' seconds, which is what you want to pay attention to if you're trying to keep an intervening NAT box from dropping what it thinks is an abandoned connection. A quiet loss of connection will be detected within 'keepaliveInterval' seconds. *** Large Logfiles The web page rendering code has been fixed to deliver large logfiles in pieces, using a producer/consumer apparatus. This avoids the large spike in memory consumption when the log file body was linearized into a single string and then buffered in the socket's application-side transmit buffer. This should also avoid the 640k single-string limit for web.distrib servers that could be hit by large (>640k) logfiles. buildbot-0.8.8/docs/relnotes/0.6.3.txt000066400000000000000000000125701222546025000173260ustar00rootroot00000000000000Buildbot 0.6.3 was released 25 Apr 2005 ** 'buildbot' tool gets more uses The 'buildbot' executable has acquired three new subcommands. 'buildbot debugclient' brings up the small remote-control panel that connects to a buildmaster (via the slave port and the c['debugPassword']). This tool, formerly in contrib/debugclient.py, lets you reload the config file, force builds, and simulate inbound commit messages. It requires gtk2, glade, and the python bindings for both to be installed. 'buildbot statusgui' brings up a live status client, formerly available by running buildbot/clients/gtkPanes.py as a program. This connects to the PB status port that you create with: c['status'].append(client.PBListener(portnum)) and shows two boxes per Builder, one for the last build, one for current activity. These boxes are updated in realtime. The effect is primitive, but is intended as an example of what's possible with the PB status interface. 'buildbot statuslog' provides a text-based running log of buildmaster events. Note: command names are subject to change. These should get much more useful over time. ** web page has a favicon When constructing the html.Waterfall instance, you can provide the filename of an image that will be provided when the "favicon.ico" resource is requested. Many web browsers display this as an icon next to the URL or bookmark. A goofy little default icon is included. ** web page has CSS Thanks to Thomas Vander Stichele, the Waterfall page is now themable through CSS. The default CSS is located in buildbot/status/classic.css, and creates a page that is mostly identical to the old, non-CSS based table. You can specify a different CSS file to use by passing it as the css= argument to html.Waterfall(). See the docstring for Waterfall for some more details. ** builder "categories" Thomas has added code which places each Builder in an optional "category". The various status targets (Waterfall, IRC, MailNotifier) can accept a list of categories, and they will ignore any activity in builders outside this list. This makes it easy to create some Builders which are "experimental" or otherwise not yet ready for the world to see, or indicate that certain builders should not harass developers when their tests fail, perhaps because the build slaves for them are not yet fully functional. ** Deprecated features *** defining Builders with tuples is deprecated For a long time, the preferred way to define builders in the config file has been with a dictionary. The less-flexible old style of a 4-item tuple (name, slavename, builddir, factory) is now officially deprecated (i.e., it will emit a warning if you use it), and will be removed in the next release. Dictionaries are more flexible: additional keys like periodicBuildTime are simply unavailable to tuple-defined builders. Note: it is a good idea to watch the logfile (usually in twistd.log) when you first start the buildmaster, or whenever you reload the config file. Any warnings or errors in the config file will be found there. *** c['webPortnum'], c['webPathname'], c['irc'] are deprecated All status reporters should be defined in the c['status'] array, using buildbot.status.html.Waterfall or buildbot.status.words.IRC . These have been deprecated for a while, but this is fair warning that these keys will be removed in the next release. *** c['manholePort'] is deprecated Again, this has been deprecated for a while, in favor of: c['manhole'] = master.Manhole(port, username, password) The preferred syntax will eventually let us use other, better kinds of debug shells, such as the experimental curses-based ones in the Twisted sandbox (which would offer command-line editing and history). ** bug fixes The waterfall page has been improved a bit. A circular-reference bug in the web page's TextLog class was fixed, which caused a major memory leak in a long-running buildmaster with large logfiles that are viewed frequently. Modifying the config file in a way which only changed a builder's base directory now works correctly. The 'buildbot' command tries to create slightly more useful master/slave directories, adding a Makefile entry to re-create the .tap file, and removing global-read permissions from the files that may contain buildslave passwords. ** twisted-2.0.0 compatibility Both buildmaster and buildslave should run properly under Twisted-2.0 . There are still some warnings about deprecated functions, some of which could be fixed, but there are others that would require removing compatibility with Twisted-1.3, and I don't expect to do that until 2.0 has been out and stable for at least several months. The unit tests should pass under 2.0, whereas the previous buildbot release had tests which could hang when run against the new "trial" framework in 2.0. The Twisted-specific steps (including Trial) have been updated to match 2.0 functionality. ** win32 compatibility Thankt to Nick Trout, more compatibility fixes have been incorporated, improving the chances that the unit tests will pass on windows systems. There are still some problems, and a step-by-step "running buildslaves on windows" document would be greatly appreciated. ** API docs Thanks to Thomas Vander Stichele, most of the docstrings have been converted to epydoc format. There is a utility in docs/gen-reference to turn these into a tree of cross-referenced HTML pages. Eventually these docs will be auto-generated and somehow published on the buildbot web page. buildbot-0.8.8/docs/relnotes/0.6.4.txt000066400000000000000000000054611222546025000173300ustar00rootroot00000000000000Buildbot 0.6.4 was released 28 Apr 2005 ** major bugs fixed The 'buildbot' tool in 0.6.3, when used to create a new buildmaster, failed unless it found a 'changes.pck' file. As this file is created by a running buildmaster, this made 0.6.3 completely unusable for first-time installations. This has been fixed. ** minor bugs fixed The IRC bot had a bug wherein asking it to watch a certain builder (the "I'll give a shout when the build finishes" message) would cause an exception, so it would not, in fact, shout. The HTML page had an exception in the "change sources" page (reached by following the "Changes" link at the top of the column that shows the names of commiters). Re-loading the config file while builders were already attached would result in a benign error message. The server side of the PBListener status client had an exception when providing information about a non-existent Build (e.g., when the client asks for the Build that is currently running, and the server says "None"). These bugs have all been fixed. The unit tests now pass under python2.2; they were failing before because of some 2.3isms that crept in. More unit tests which failed under windows now pass, only one (test_webPathname_port) is still failing. ** 'buildbot' tool looks for a .buildbot/options file The 'statusgui' and the 'debugclient' subcommands can both look for a .buildbot/ directory, and an 'options' file therein, to extract default values for the location of the buildmaster. This directory is searched in the current directory, its parent, etc, all the way up to the filesystem root (assuming you own the directories in question). It also look in ~/.buildbot/ for this file. This feature allows you to put a .buildbot at the top of your working tree, telling any 'buildbot' invocations you perform therein how to get to the buildmaster associated with that tree's project. Windows users get something similar, using %APPDATA%/buildbot instead of ~/.buildbot . ** windows ShellCommands are launched with 'cmd.exe' The buildslave has been modified to run all list-based ShellCommands by prepending [os.environ['COMSPEC'], '/c'] to the argv list before execution. This should allow the buildslave's PATH to be searched for commands, improving the chances that it can run the same 'trial -o foo' commands as a unix buildslave. The potential downside is that spaces in argv elements might be re-parsed, or quotes might be re-interpreted. The consensus on the mailing list was that this is a useful thing to do, but please report any problems you encounter with it. ** minor features The Waterfall display now shows the buildbot's home timezone at the top of the timestamp column. The default favicon.ico is now much nicer-looking (it is generated with Blender.. the icon.blend file is available in CVS in docs/images/ should you care to play with it). buildbot-0.8.8/docs/relnotes/0.6.5.txt000066400000000000000000000111021222546025000173160ustar00rootroot00000000000000Buildbot 0.6.5 was released 18 May 2005 ** deprecated config keys removed The 'webPortnum', 'webPathname', 'irc', and 'manholePort' config-file keys, which were deprecated in the previous release, have now been removed. In addition, Builders must now always be configured with dictionaries: the support for configuring them with tuples has been removed. ** master/slave creation and startup changed The buildbot no longer uses .tap files to store serialized representations of the buildmaster/buildslave applications. Instead, this release now uses .tac files, which are human-readable scripts that create new instances (rather than .tap files, which were pickles of pre-created instances). 'mktap buildbot' is gone. You will need to update your buildbot directories to handle this. The procedure is the same as creating a new buildmaster or buildslave: use 'buildbot master BASEDIR' or 'buildbot slave BASEDIR ARGS..'. This will create a 'buildbot.tac' file in the target directory. The 'buildbot start BASEDIR' will use twistd to start the application. The 'buildbot start' command now looks for a Makefile.buildbot, and if it finds one (and /usr/bin/make exists), it will use it to start the application instead of calling twistd directly. This allows you to customize startup, perhaps by adding environment variables. The setup commands create a sample file in Makefile.sample, but you must copy this to Makefile.buildbot to actually use it. The previous release looked for a bare 'Makefile', and also installed a 'Makefile', so you were always using the customized approach, even if you didn't ask for it. That old Makefile launched the .tap file, so changing names was also necessary to make sure that the new 'buildbot start' doesn't try to run the old .tap file. 'buildbot stop' now uses os.kill instead of spawning an external process, making it more likely to work under windows. It waits up to 5 seconds for the daemon to go away, so you can now do 'buildbot stop BASEDIR; buildbot start BASEDIR' with less risk of launching the new daemon before the old one has fully shut down. Likewise, 'buildbot start' imports twistd's internals directly instead of spawning an external copy, so it should work better under windows. ** new documentation All of the old Lore-based documents were converted into a new Texinfo-format manual, and considerable new text was added to describe the installation process. The docs are not yet complete, but they're slowly shaping up to form a proper user's manual. ** new features Arch checkouts can now use precise revision stamps instead of always using the latest revision. A separate Source step for using Bazaar (an alternative Arch client) instead of 'tla' was added. A Source step for Cogito (the new linux kernel VC system) was contributed by Brandon Philips. All Source steps now accept a retry= argument to indicate that failing VC checkouts should be retried a few times (SF#1200395), note that this requires an updated buildslave. The 'buildbot sendchange' command was added, to be used in VC hook scripts to send changes at a pb.PBChangeSource . contrib/arch_buildbot.py was added to use this tool; it should be installed using the 'Arch meta hook' scheme. Changes can now accept a branch= parameter, and Builders have an isBranchImportant() test that acts like isFileImportant(). Thanks to Thomas Vander Stichele. Note: I renamed his tag= to branch=, in anticipation of an upcoming feature to build specific branches. "tag" seemed too CVS-centric. LogFiles have been rewritten to stream the incoming data directly to disk rather than keeping a copy in memory all the time (SF#1200392). This drastically reduces the buildmaster's memory requirements and makes 100MB+ log files feasible. The log files are stored next to the serialized Builds, in files like BASEDIR/builder-dir/12-log-compile-output, so you'll want a cron job to delete old ones just like you do with old Builds. Old-style Builds from 0.6.4 and earlier are converted when they are first read, so the first load of the Waterfall display after updating to this release may take quite some time. ** build process updates BuildSteps can now return a status of EXCEPTION, which terminates the build right away. This allows exceptions to be caught right away, but still make sure the build stops quickly. ** bug fixes Some more windows incompatibilities were fixed. The test suite now has two failing tests remaining, both of which appear to be Twisted issues that should not affect normal operation. The test suite no longer raises any deprecation warnings when run against twisted-2.0 (except for the ones which come from Twisted itself). buildbot-0.8.8/docs/relnotes/0.6.6.txt000066400000000000000000000027361222546025000173340ustar00rootroot00000000000000Buildbot 0.6.6 was released 23 May 2005 ** bugs fixed The 'sendchange', 'stop', and 'sighup' subcommands were broken, simple bugs that were not caught by the test suite. Sorry. The 'buildbot master' command now uses "raw" strings to create .tac files that will still function under windows (since we must put directory names that contain backslashes into that file). The keep-on-disk behavior added in 0.6.5 included the ability to upgrade old in-pickle LogFile instances. This upgrade function was not added to the HTMLLogFile class, so an exception would be raised when attempting to load or display any build with one of these logs (which are normally used only for showing build exceptions). This has been fixed. Several unnecessary imports were removed, so the Buildbot should function normally with just Twisted-2.0.0's "Core" module installed. (of course you will need TwistedWeb, TwistedWords, and/or TwistedMail if you use status targets that require them). The test suite should skip all tests that cannot be run because of missing Twisted modules. The master/slave's basedir is now prepended to sys.path before starting the daemon. This used to happen implicitly (as a result of twistd's setup preamble), but 0.6.5 internalized the invocation of twistd and did not copy this behavior. This change restores the ability to access "private.py"-style modules in the basedir from the master.cfg file with a simple "import private" statement. Thanks to Thomas Vander Stichele for the catch. buildbot-0.8.8/docs/relnotes/0.7.0.txt000066400000000000000000000121261222546025000173210ustar00rootroot00000000000000Buildbot 0.7.0 was released 24 Oct 2005 ** new features *** new c['schedulers'] config-file element (REQUIRED) The code which decides exactly *when* a build is performed has been massively refactored, enabling much more flexible build scheduling. YOU MUST UPDATE your master.cfg files to match: in general this will merely require you to add an appropriate c['schedulers'] entry. Any old ".treeStableTime" settings on the BuildFactory instances will now be ignored. The user's manual has complete details with examples of how the new Scheduler classes work. *** c['interlocks'] removed, Locks and Dependencies now separate items The c['interlocks'] config element has been removed, and its functionality replaced with two separate objects. Locks are used to tell the buildmaster that certain Steps or Builds should not run at the same time as other Steps or Builds (useful for test suites that require exclusive access to some external resource: of course the real fix is to fix the tests, because otherwise your developers will be suffering from the same limitations). The Lock object is created in the config file and then referenced by a Step specification tuple or by the 'locks' key of the Builder specification dictionary. Locks come in two flavors: MasterLocks are buildmaster-wide, while SlaveLocks are specific to a single buildslave. When you want to have one Build run or not run depending upon whether some other set of Builds have passed or failed, you use a special kind of Scheduler defined in the scheduler.Dependent class. This scheduler watches an upstream Scheduler for builds of a given source version to complete, and only fires off its own Builders when all of the upstream's Builders have built that version successfully. Both features are fully documented in the user's manual. *** 'buildbot try' The 'try' feature has finally been added. There is some configuration involved, both in the buildmaster config and on the developer's side, but once in place this allows the developer to type 'buildbot try' in their locally-modified tree and to be given a report of what would happen if their changes were to be committed. This works by computing a (base revision, patch) tuple that describes the developer's tree, sending that to the buildmaster, then running a build with that source on a given set of Builders. The 'buildbot try' tool then emits status messages until the builds have finished. 'try' exists to allow developers to run cross-platform tests on their code before committing it, reducing the chances they will inconvenience other developers by breaking the build. The UI is still clunky, but expect it to change and improve over the next few releases. Instructions for developers who want to use 'try' (and the configuration changes necessary to enable its use) are in the user's manual. *** Build-On-Branch When suitably configured, the buildbot can be used to build trees from a variety of related branches. You can set up Schedulers to build a tree using whichever branch was last changed, or users can request builds of specific branches through IRC, the web page, or (eventually) the CLI 'buildbot force' subcommand. The IRC 'force' command now takes --branch and --revision arguments (not that they always make sense). Likewise the HTML 'force build' button now has an input field for branch and revision. Your build's source-checkout step must be suitably configured to support this: for SVN it involves giving both a base URL and a default branch. Other VC systems are configured differently. The ChangeSource must also provide branch information: the 'buildbot sendchange' command now takes a --branch argument to help hook script writers accomplish this. *** Multiple slaves per Builder You can now attach multiple buildslaves to each Builder. This can provide redundancy or primitive load-balancing among many machines equally capable of running the build. To use this, define a key in the Builder specification dictionary named 'slavenames' with a list of buildslave names (instead of the usual 'slavename' that contains just a single slavename). *** minor new features The IRC and email status-reporting facilities now provide more specific URLs for particular builds, in addition to the generic buildmaster home page. The HTML per-build page now has more information. The Twisted-specific test classes have been modified to match the argument syntax preferred by Trial as of Twisted-2.1.0 and newer. The generic trial steps are still suitable for the Trial that comes with older versions of Twisted, but may produce deprecation warnings or errors when used with the latest Trial. ** bugs fixed DNotify, used by the maildir-watching ChangeSources, had problems on some 64-bit systems relating to signed-vs-unsigned constants and the DN_MULTISHOT flag. A workaround was provided by Brad Hards. The web status page should now be valid XHTML, thanks to a patch by Brad Hards. The charset parameter is specified to be UTF-8, so VC comments, builder names, etc, should probably all be in UTF-8 to be displayed properly. ** creeping version dependencies The IRC 'force build' command now requires python2.3 (for the shlex.split function). buildbot-0.8.8/docs/relnotes/0.7.1.txt000066400000000000000000000076251222546025000173320ustar00rootroot00000000000000Buildbot 0.7.1 was released 26 Nov 2005 ** new features *** scheduler.Nightly Dobes Vandermeer contributed a cron-style 'Nightly' scheduler. Unlike the more-primitive Periodic class (which only lets you specify the duration between build attempts), Nightly lets you schedule builds for specific times of day, week, month, or year. The interface is very much like the crontab(5) file. See the buildbot.scheduler.Nightly docstring for complete details. ** minor new features *** step.Trial can work with Trial from Twisted >2.1.0 The 'Trial' step now accepts the trialMode= argument, which should be a list of strings to be added to trial's argv array. This defaults to ["-to"], which is appropriate for the Trial that ships in Twisted-2.1.0 and earlier, and tells Trial to emit non-colorized verbose output. To use this step with trials from later versions of Twisted, this should be changed to ["--reporter=bwverbose"]. In addition, you can now set other Trial command-line parameters through the trialArgs= argument. This is a list of strings, and defaults to an empty list. *** Added a 'resubmit this build' button to the web page *** Make the VC-checkout step's description more useful Added the word "[branch]" to the VC step's description (used in the Step's box on the Waterfall page, among others) when we're checking out a non-default branch. Also add "rNNN" where appropriate to indicate which revision is being checked out. Thanks to Brad Hards and Nathaniel Smith for the suggestion. ** bugs fixed Several patches from Dobes Vandermeer: Escape the URLs in email, in case they have spaces and such. Fill otherwise-empty elements, as a workaround for buggy browsers that might optimize them away. Also use binary mode when opening status pickle files, to make windows work better. The AnyBranchScheduler now works even when you don't provide a fileIsImportant= argument. Stringify the base revision before stuffing it into a 'try' jobfile, helping SVN and Arch implement 'try' builds better. Thanks to Steven Walter for the patch. Fix the compare_attrs list in PBChangeSource, FreshCVSSource, and Waterfall. Before this, certain changes to these objects in the master.cfg file were ignored, such that you would have to stop and re-start the buildmaster to make them take effect. The config file is now loaded serially, shutting down old (or replaced) Status/ChangeSource plugins before starting new ones. This fixes a bug in which changing an aspect of, say, the Waterfall display would cause an exception as both old and new instances fight over the same TCP port. This should also fix a bug whereby new Periodic Schedulers could fire a build before the Builders have finished being added. There was a bug in the way Locks were handled when the config file was reloaded: changing one Builder (but not the others) and reloading master.cfg would result in multiple instances of the same Lock object, so the Locks would fail to prevent simultaneous execution of Builds or Steps. This has been fixed. ** other changes For a long time, certain StatusReceiver methods (like buildStarted and stepStarted) have been able to return another StatusReceiver instance (usually 'self') to indicate that they wish to subscribe to events within the new object. For example, if the buildStarted() method returns 'self', the status receiver will also receive events for the new build, like stepStarted() and buildETAUpdate(). Returning a 'self' from buildStarted() is equivalent to calling build.subscribe(self). Starting with buildbot-0.7.1, this auto-subscribe convenience will also register to automatically unsubscribe the target when the build or step has finished, just as if build.unsubscribe(self) had been called. Also, the unsubscribe() method has been changed to not explode if the same receiver is unsubscribed multiple times. (note that it will still explode is the same receiver is *subscribed* multiple times, so please continue to refrain from doing that). buildbot-0.8.8/docs/relnotes/0.7.10.txt000066400000000000000000000131131222546025000173770ustar00rootroot00000000000000Buildbot 0.7.10 was released 25 Feb 2009 This release is mainly a collection of user-submitted patches since the last release. ** New Features *** Environment variables in a builder (#100) It is useful to be able to pass environment variables to all steps in a builder. This is now possible by adding { .. 'env': { 'var' : 'value' }, ... } to the builder specification. *** IRC status plugin improvements (#330, #357, #378, #280, #381, #411, #368) *** usePTY specified in master.cfg, defaults to False (#158, #255) Using a pty has some benefits in terms of supporting "Stop Build", but causes numerous problems with simpler jobs which can be killed by a SIGHUP when their standard input is closed. With this change, PTYs are not used by default, although you can enable them either on slaves (with the --usepty option to create-slave) or on the master. *** More information about buildslaves via the web plugin (#110) A new page, rooted at /buildslave/$SLAVENAME, gives extensive information about the buildslave. *** More flexible merging of requests (#415) The optional c['mergeRequests'] configuration parameter takes a function which can decide whether two requests are mergeable. *** Steps can be made to run even if the build has halted (#414) Adding alwaysRun=True to a step will cause it to run even if some other step has failed and has haltOnFailure=True. *** Compress buildstep logfiles (#26) Logs for each buildstep, which can take a lot of space on a busy buildmaster, are automatically compressed after the step has finished. *** Support for "latent" buildslaves The buildslaves that are started on-demand are called "latent" buildslaves. Buildbot ships with an abstract base class for building latent buildslaves, and a concrete implementation for AWS EC2. *** Customized MailNotifier messages (#175) MailNotifier now takes an optional function to build the notification message, allowing ultimate site-level control over the format of buildbot's notification emails. *** Nightly scheduler support for building only if changes have occurred With the addition of onlyIfChanged=True, the Nightly scheduler will not schedule a new build if no changes have been made since its last scheduled build. *** Add ATOM/RSS feeds to WebStatus (#372) Two new pages, /atom and /rss, provide feeds of build events to any feed reader. These paths take the same "category" and "branch" arguments as the waterfall and grid. *** Add categories to Schedulers and Changes (#182) This allows a moderate amount of support for multiple projects built in a single buildmaster. *** Gracefully shut down a buildslave after its build is complete The /buildslaves/$SLAVENAME pages have a "Gracefully Shutdown" button which will cause the corresponding slave to shut itself down when it finishes its current build. This is a good way to do work on a slave without causing a spurious build failure. *** SVN source steps can send usernames and passwords (#41) Adding username="foo" and/or password="bar" to an SVN step will cause --username and --password arguments to be passed to 'svn' on the slave side. Passwords are suitably obfuscated in logfiles. ** New Steps *** DirectoryUpload (#393) This step uploads an entire directory to the master, and can be useful when a build creates several products (e.g., a client and server package). *** MasterShellCommand This step runs a shell command on the server, and can be useful for post-processing build products, or performing other maintenance tasks on the master. *** PyLint (#259) A PyLint step is available to complement the existing PyFlakes step. ** Bugs Fixed *** Process output from new versions of Test::Harness (#346) *** Fixes to the try client and scheduler *** Remove redundant loop in MailNotifier (#315) *** Display correct $PWD in logfiles (#179) *** Do not assume a particular python version on Windows (#401) *** Sort files in changes (#402) *** Sort buildslaves lexically (#416) *** Send properties to all builds initiated by AnyBranchScheduler *** Dependent Schedulers are more robust to reconfiguration (#35) *** Fix properties handling in triggered buidls (#392) *** Use "call" on Windows to avoid errors (#417) *** Support setDefaultWorkdir in FileUpload and FileDownload (#209) *** Support WithProperties in FileUpload and FileDownload (#210) *** Fix a bug where changes could be lost on a master crash (#202) *** Remove color settings from non-presentation code (#251) *** Fix builders which stopped working after a PING (#349, #85) *** Isolate Python exceptions in status plugins (#388) *** Notify about slaves missing at master startup (#302) *** Fix tracebacks in web display after a reconfig (#176) ** Version-Control Changes *** Many Mercurial fixes - Inrepo branch support finalized (source step + changegroup hook + test case) (#65 #185 #187) - Reduced amount of full clones by separating clone with update into clone/pull/update steps (#186, #227) (see #412 for future work here) - Fixed mercurial changegroup hook to work with Mercurial 1.1 API (#181, #380) *** Many git fixes *** Add got_revision to Perforce support (#127) *** Use "git foo" everywhere instead of deprecated "git-foo" ** Minor Changes *** factory.addSteps (#317) If you have a common list of steps that are included in multiple factories, you can use f.addSteps(steplist) to add them all at once. *** Twisted logfile rotation and cleanup (#108) By default, Buildbot now rotates and cleans up the (potentially voluminous) twistd.log files. *** Prioritize build requests based on the time they wre submitted (#334) Balancing of load is a bit more fair, although not true load balancing. buildbot-0.8.8/docs/relnotes/0.7.11.txt000066400000000000000000000040311222546025000173770ustar00rootroot00000000000000Buildbot 0.7.11p was released July 16, 2009 Fixes a few test failures in 0.7.11, and gives a default value for branchType if it is not specified by the master. Buildbot 0.7.11 was released July 5, 2009 Developers too numerous to mention contributed to this release. Buildbot has truly become a community-maintained application. Much hard work is not mentioned here, so please consult the git logs for the detailed changes in this release. ** Better Memory Performance, Disk Cleanup Buildbot handles its memory usage a bit better, and can automatically purge old history to keep memory and disk usage low. Look for eventHorizon, buildHorizon, logHorizon, and changeHorizon. ** Password Protection for Force Build and Stop actions It is now possible to require authentication to force build and stop via the WebStatus interface. To use this, set the 'auth' field of WebStatus to a valid IAuth implementation. Current implementations are: BasicAuth with a list of user/passwords HTPasswdAuth with an .htpasswd file By default, the unauthenticated behavior will occur. ** Web Status changes The "Graceful Shutdown" feature, as a kind of "force", now obeys allowForce. The waterfall and other pages are more deeply interlinked. Pending builds can be individually cancelled, or cancelled in bulk. ** Fixed Transfer Steps Transfer step classes are more reliable; DirectoryUpload and DirectoryDownload use tarfile instead of manually framing files. The DirectoryUpload step also now supports compression. ** Conditional Steps Steps now take a doStepIf parameter which can be used to implement simple conditional execution of a step. ** Colorized Steps Steps are now hilighted with a color in the build view to indicate their success or failure. ** Improved build prioritization Bugfixes and fairer scheduling ** Transposed Grid Similar to the grid view, but with the axes reversed and showing different info. Located at /tgrid. ** Trigger steps improvements Trigger now supports copy_properties, to send selected properties to the triggered build. buildbot-0.8.8/docs/relnotes/0.7.12.txt000066400000000000000000000050321222546025000174020ustar00rootroot00000000000000Buildbot 0.7.12 was released 21 Jan 2010 ** New 'console' display This is a new web status view combining the best of the (t)grid and waterfall views. ** New 'extended' stylesheet Buildbot has a new, much nicer stylesheet available. Copy the file buildbot/status/web/extended.css over your existing public_html/buildbot.css to se it. ** Builders can be configured with an object Instead of a list of dictionaries, builders can now specified using a BuilderConfig object in the configuration file. This will allow for better argument checking and default values, and also makes it easier for users to create subclasses to handle site-specific builder details. The old, dictionary-based method of configuration is still supported. ** Check for common mis-configuration in addStep When adding a new step to a factory, either of these are acceptable: f.addStep(ShellCommand(command="echo hello, world", description="say hi")) f.addStep(ShellCommand, command="echo hello, world", description="say hi") but trying to mix these syntaxes is a common misconfiguration: f.addStep(ShellCommand(command="echo hello, world"), description="say hi") in which case the description argument was silently ignored. This is now an error. ** Support for log compression Log files can be compressed on the master side using either gzip or bzip2. ** Builder.ping no longer accepts timeout argument (bug #664). The implementation was not robust enough and could cause the master to unexpectedly disconnect the slave. ** MailNotifier's customMesg replaced by messageFormatter The customMesg mechanism had the unfortunate side effect of loading all data for a build into memory simultaneously, which for some builds could cause memory exhaustion. ** Suppression of selected compiler warnings The WarningCountingShellCommand class has been extended with the ability to upload from the slave a file contain warnings to be ignored. See the documentation of the suppressionFile argument to the Compile build step. ** New buildstep `MTR' A new class buildbot.process.mtrlogobserver.MTR was added. This buildstep is used to run test suites using mysql-test-run. It parses the stdio output for test failures and summarises them on the waterfall page. It also makes server error logs available for debugging failures, and optionally inserts information about test runs and test failures into an external database. ** Python API Docs The docstrings for buildbot are now available in a web-friendly format: http://buildbot.net/buildbot/docs/latest/reference ** Many, many bugfixes buildbot-0.8.8/docs/relnotes/0.7.2.txt000066400000000000000000000054371222546025000173320ustar00rootroot00000000000000Buildbot 0.7.2 was released 17 Feb 2006 ** new features *** all TCP port numbers in config file now accept a strports string Sometimes it is useful to restrict certain TCP ports that the buildmaster listens on to use specific network interfaces. In particular, if the buildmaster and SVN repository live on the same machine, you may want to restrict the PBChangeSource to only listen on the loopback interface, insuring that no external entities can inject Changes into the buildbot. Likewise, if you are using something like Apache's reverse-proxy feature to provide access to the buildmaster's HTML status page, you might want to hide the real Waterfall port by having it only bind to the loopback interface. To accomplish this, use a string like "tcp:12345:interface=127.0.0.1" instead of a number like 12345. These strings are called "strports specification strings", and are documented in twisted's twisted.application.strports module (you can probably type 'pydoc twisted.application.strports' to see this documentation). Pretty much everywhere the buildbot takes a port number will now accept a strports spec, and any bare numbers are translated into TCP port numbers (listening on all network interfaces) for compatibility. *** buildslave --umask control Twisted's daemonization utility (/usr/bin/twistd) automatically sets the umask to 077, which means that all files generated by both the buildmaster and the buildslave will only be readable by the account under which the respective daemon is running. This makes it unnecessarily difficult to share build products (e.g. by symlinking ~/public_html/current_docs/ to a directory within the slave's build directory where each build puts the results of a "make docs" step). The 'buildbot slave ' command now accepts a --umask argument, which can be used to override the umask set by twistd. If you create the buildslave with '--umask=022', then all build products will be world-readable, making it easier for other processes (run under other accounts) to access them. ** bug fixes The 0.7.1 release had a bug whereby reloading the config file could break all configured Schedulers, causing them to raise an exception when new changes arrived but not actually schedule a new build. This has been fixed. Fixed a bug which caused the AnyBranchScheduler to explode when branch==None. Thanks to Kevin Turner for the catch. I also think I fixed a bug whereby the TryScheduler would explode when it was given a Change (which it is supposed to simply ignore). The Waterfall display now does more quoting of names (including Builder names, BuildStep names, etc), so it is more likely that these names can contain unusual characters like spaces, quotes, and slashes. There may still be some problems with these kinds of names, however.. please report any bugs to the mailing list. buildbot-0.8.8/docs/relnotes/0.7.3.txt000066400000000000000000000061631222546025000173300ustar00rootroot00000000000000Buildbot 0.7.3 was released 23 May 2006 ** compatibility This release is compatible with Twisted-1.3.0, but the next one will not be. Please upgrade to at least Twisted-2.0.x soon, as the next buildbot release will require it. ** new features *** Mercurial support Support for Mercurial version control system (http://selenic.com/mercurial) has been added. This adds a buildbot.process.step.Mercurial BuildStep. A suitable hook script to deliver changes to the buildmaster is still missing. *** 'buildbot restart' command The 'buildbot restart BASEDIR' command will perform a 'buildbot stop' and 'buildbot start', and will attempt to wait for the buildbot process to shut down in between. This is useful when you need to upgrade the code on your buildmaster or buildslave and want to take it down for a minimum amount of time. *** build properties Each build now has a set of named "Build Properties", which can be set by steps and interpolated into ShellCommands. The 'revision' and 'got_revision' properties are the most interesting ones available at this point, and can be used e.g. to get the VC revision number into the filename of a generated tarball. See the user's manual section entited "Build Properties" for more details. ** minor features *** IRC now takes password= argument Useful for letting your bot claim a persistent identity. *** svn_buildbot.py is easier to modify to understand branches *** BuildFactory has a new .addStep method *** p4poller has new arguments *** new contrib scripts: viewcvspoll, svnpoller, svn_watcher These poll an external VC repository to watch for changes, as opposed to adding a hook script to the repository that pushes changes into the buildmaster. This means higher latency but may be easier to configure, especially if you do not have authority on the repository host. *** VC build property 'got_revision' The 'got_revision' property reports what revision a VC step actually acquired, which may be useful to know when building from HEAD. *** improved CSS in Waterfall The Waterfall display has a few new class= tags, which may make it easier to write custom CSS to make it look prettier. *** robots_txt= argument in Waterfall You can now pass a filename to the robots_txt= argument, which will be served as the "robots.txt" file. This can be used to discourage search engine spiders from crawling through the numerous build-status pages. ** bugfixes *** tests more likely to pass on non-English systems The unit test suite now sets $LANG='C' to make subcommands emit error messages in english instead of whatever native language is in use on the host. This improves the chances that the unit tests will pass on such systems. This affects certain VC-related subcommands too. test_vc was assuming that the system time was expressed with a numeric timezone, which is not always the case, especially under windows. This probably works better now than it did before. This only affects the CVS tests. 'buildbot try' (for CVS) now uses UTC instead of the local timezone. The 'got_revision' property is also expressed in UTC. Both should help deal with buggy versions of CVS that don't parse numeric timezones properly. buildbot-0.8.8/docs/relnotes/0.7.4.txt000066400000000000000000000133031222546025000173230ustar00rootroot00000000000000Buildbot 0.7.4 was released 23 Aug 2006 ** Things You Need To Know The PBChangeSource's prefix= argument has changed, you probably need to add a slash now. This is mostly used by sites which use Subversion and svn_buildbot.py. The subcommands that are used to create a buildmaster or a buildslave have changed. They used to be called 'buildbot master' and 'buildbot slave'. Now they are called 'buildbot create-master' and 'buildbot create-slave'. Zipf's Law suggests that these are more appropriate names for these infrequently-used commands. The syntax for the c['manhole'] feature has changed. ** new features *** full Perforce support SF#1473939: large patch from Scott Lamb, with docs and unit tests! This includes both the step.P4 source-checkout BuildStep, and the changes.p4poller ChangeSource you'll want to feed it. P4 is now supported just as well as all the other VC systems. Thanks Scott! *** SSH-based Manhole The 'manhole' feature allows buildbot developers to get access to a python read/eval/print loop (REPL) inside the buildmaster through a network connection. Previously, this ran over unencrypted telnet, using a simple username/password for access control. The new release defaults to encrypted SSH access, using either username/password or an authorized_keys file (just like sshd). There also exists an unencrypted telnet form, but its use is discouraged. The syntax for setting up a manhole has changed, so master.cfg files that use them must be updated. The "Debug options" section in the user's manual provides a complete description. *** Multiple Logfiles BuildSteps can watch multiple log files in realtime, not just stdout/stderr. This works in a similar fashion to 'tail -f': the file is polled once per second, and any new data is sent to the buildmaster. This requires a buildslave running 0.7.4 or later, and a warning message is produced if used against an old buildslave (which will otherwise produce no data). Use "logfiles={'name': 'filename'}" to take advantage of this feature from master.cfg, and see the "ShellCommand" section of the user's manual for full documentation. The 'Trial' buildstep has been updated to use this, to display _trial_temp/test.log in realtime. It also knows to fall back to the previous "cat" command if the buildslave is too old. *** BuildStep URLs BuildSteps can now add arbitrary URLs which will be displayed on the Waterfall page in the same place that Logs are presented. This is intended to provide a link to generated HTML pages, such as the output of a code coverage tool. The step is responsible for somehow uploading the HTML to a web server: this feature merely provides an easy way to present the HREF link to the user. See the "BuildStep URLs" section of the user's manual for details and examples. *** LogObservers BuildSteps can now attach LogObservers to various logfiles, allowing them to get real-time log output. They can use this to watch for progress-indicating events (like counting the number of files compiled, or the number of tests which have run), and update both ETA/progress-tracking and step text. This allows for more accurate ETA information, and more information passed to the user about how much of the process has completed. The 'Trial' buildstep has been updated to use this for progress tracking, by counting how many test cases have run. ** new documentation What classes are useful in your master.cfg file? A table of them has been added to the user's manual, in a section called "Index of Useful Classes". Want a list of all the keys in master.cfg? Look in the "Index of master.cfg keys" section. A number of pretty diagrams have been added to the "System Architecture" portion of the manual, explaining how all the buildbot pieces fit together. An HTML form of the user's manual is now shipped in the source tarball. This makes it a bit bigger: sorry about that. The old PyCon-2003 paper has been removed from the distribution, as it is mostly supplanted by the user's manual by this point. ** bugfixes SF#1217699 + SF#1381867: The prefix= argument to PBChangeSource has been changed: now it does just a simple string-prefix match and strip. The previous behavior was buggy and unhelpful. NOTE: if you were using prefix= before, you probably need to add a slash to the end of it. SF#1398174: ignore SVN property changes better, fixed by Olivier Bonnet SF#1452801: don't double-escape the build URL, fixed by Olivier Bonnet SF#1401121: add support for running py2exe on windows, by Mark Hammond reloading unchanged config files with WithProperties shouldn't change anything. All svn commands now include --non-interactive so they won't ask for passwords. Instead, the command will fail if it cannot be performed without user input. Deprecation warnings with newer versions of Twisted have been hushed. ** compatibility I haven't actually removed support for Twisted-1.3.0 yet, but I'd like to. The step_twisted default value for --reporter matches modern Twisteds, though, and won't work under 1.3.0. ShellCommand.flunkOnFailure now defaults to True, so any shell command which fails counts as a build failure. Set this to False if you don't want this behavior. ** minor features contrib/darcs_buildbot.py contains a new script suitable for use in a darcs commit-hook. Hovering a cursor over the yellow "Build #123" box in the Waterfall display will pop up an HTML tooltip to show the reason for the build. Thanks to Zandr Milewski for the suggestion. contrib/CSS/*.css now contains several contributed stylesheets to make the Waterfall display a bit less ugly. Thanks to John O'Duinn for gathering them. ShellCommand and its derivatives can now accept either a string or a list of strings in the description= and descriptionDone= arguments. Thanks to Paul Winkler for the catch. buildbot-0.8.8/docs/relnotes/0.7.5.txt000066400000000000000000000150531222546025000173300ustar00rootroot00000000000000Buildbot 0.7.5 was released 10 Dec 2006 ** Things You Need To Know *** The Great BuildStep Renaming All BuildSteps have moved! They used to be classes in buildbot.process.step, but now they all have separate modules in buildbot.steps.* . They have been split out into separate categories: for example, the source checkout steps are now buildbot.steps.source.CVS, buildbot.steps.source.Darcs, etc. The most commonly used one is probably buildbot.steps.shell.ShellCommand . The python-specific steps are in buildbot.steps.python, and the Twisted-specific steps are in buildbot.steps.python_twisted . You will need to update your master.cfg files to use the new names. The old names are deprecated and will be removed altogether in the next release. *** Compatibility Buildbot now requires python-2.3 or later. Buildbot now requires Twisted-2.0.0 or later. Support for earlier versions of both has finally been removed. If you discover it works with unsupported versions, please return your Buildbot to the factory for repairs :-). Buildbot has *not* yet been tested against the recent python-2.5 release. It has been tested against the latest SVN version of Twisted, but only in conjunction with python-2.4 . ** new features *** reconfiguring a Builder no longer causes a disconnect/reconnect cycle This means that sending SIGHUP to the master or running 'buildbot reconfig MASTERDIR' command no longer interrupts any current builds, nor does it lose pending builds like it did before. This involved a fairly substantial refactoring of the various internal BotPerspective/BotMaster/Builder classes. Note that reconfiguring Schedulers still loses any Changes that were waiting for the tree to become stable: hopefully this will be fixed in the next release. *** 'buildbot start/restart/reconfig' now show logs until startup is complete These commands now have additional code to follow twistd.log and display all the lines that are emitted from the beginning of the start/reconfig action until it has completed. This gives you a chance to see any problems detected in the config file without needing to manually look in twistd.log or use another shell to 'tail -f' it. This also makes it clear which config file is being used. This functionality is not available under windows. In addition, if any problems are detected during 'start' or 'restart' (but not reconfig), the buildbot command will terminate with a non-zero exit status, making it easier to use in scripts. Closes SF#1517975. *** Locks now take maxCount=N to allow multiple simultaneous owners This allows Locks to be non-exclusive but still limit maximum concurrency. Thanks to James Knight for the patch. Closes SF#1434997. *** filetransfer steps buildbot.steps.transfer.FileUpload is a buildstep that will move files from the slave to the master. Likewise, FileDownload will move files from the master down to the buildslave. Many thanks to Albert Hofkamp for contributing these classes. Closes SF#1504631. *** pyflakes step buildbot.steps.python.PyFlakes will run the simple 'pyflakes' static analysis tool and parse the results to tell you about undefined names, unused imports, etc. You'll need to tell it how to run pyflakes, usually with something like command=["pyflakes", "src/packagedir"] or the like. The default command is "make pyflakes", which assumes that you have a suitable target in your top-level Makefile. *** Monotone support Nathaniel Smith has contributed initial support for the Monotone version control system. The code still needs docs and tests, but on the other hand it has been in use by the Monotone buildbot for a long time now, so it is probably fairly stable. *** Tinderbox support Ben Hearsum and the Mozilla crew have contributed some classes to allow Buildbot to work with Tinderbox clients. One piece is buildbot.changes.bonsaipoller.BonsaiPoller, which is a ChangeSource that polls a Bonsai server (which is a kind of web-vased viewcvs CGI script) to discover source code changes. The other piece is buildbot.status.tinderbox.TinderboxMailNotifier, which is a status plugin that sends email in the same format as Tinderbox does, which allows a number of Tinderbox tools to be driven by Buildbot instead. *** SVN Poller Niklaus Giger contributed a ChangeSource (buildbot.changes.svnpoller) which polls a remote SVN repository on a periodic basis. This is useful when, for whatever reason, you cannot add a post-commit hook script to the repository. This obsoletes the external contrib/svn_watcher.py script. ** notes for plugin developers *** IStatusLog.readlines() This new method makes it easier for a status plugin (or a BuildStep.createSummary method) to walk through a StatusLog one line at a time. For example, if you wanted to create an extra logfile that just contained all the GCC warnings from the main log, you could use the following: def createSummary(self, log): warnings = [] for line in log.readlines(): if "warning:" in line: warnings.append() self.addCompleteLog('warnings', "".join(warnings)) The "BuildStep LogFiles" section of the user's manual contains more information. This method is not particularly memory-efficient yet (it reads the whole logfile into memory first, then splits it into lines); this will be improved in a future release. ** bug fixes *** Update source.SVN to work with the new SVN-1.4.0 The latest subversion changed the behavior in an unusual situation which caused the unit tests to fail. This was unlikely to cause a problem in actual usage, but the tests have been updated to pass with the new version. *** update svn_buildbot.py to avoid mangling filenames Older versions of this script were stripping the wrong number of columns from the output of 'svnlook changed', and would sometimes mangle filenames. This has been fixed. Closes SF#1545146. *** logfiles= caused subsequent build failures under Windows Earlier versions of buildbot didn't explicitly close any logfiles= file handles when the build finished. On windows (where you cannot delete a file that someone else is reading), this could cause the next build to fail as the source checkout step was unable to delete the old working directory. This has been fixed. Closes SF#1568415. *** logfiles= didn't work on OS-X Macintosh OS-X has a different behavior when reading files that have reached EOF, the result was that logfiles= sometimes didn't work. Thanks to Mark Rowe for the patch. ** other changes The 'buildbot sighup MASTERDIR' command has been replaced with 'buildbot reconfig MASTERDIR', since that seems to be a slightly more meaningful name. The 'sighup' form will remain as an alias. buildbot-0.8.8/docs/relnotes/0.7.6.txt000066400000000000000000000212731222546025000173320ustar00rootroot00000000000000Buildbot 0.7.6 was released 30 Sep 2007 ** Things You Need To Know *** 'buildbot upgrade-master' Each time you install a new version of Buildbot, you should run the new 'buildbot upgrade-master' command on each of your pre-existing buildmasters. This will add files and fix (or at least detect) incompatibilities between your old config and the new code. *** new WebStatus page The Waterfall has been replaced by the more general WebStatus display, described below. WebStatus serves static files from a new public_html/ directory that lives in the buildmaster's basedir. Files like index.html, buildbot.css, and robots.txt are served directly from that directory, so any modifications you wish to make should be made to those files. In particular, any custom CSS you've written should be copied into public_html/buildbot.css. The 'upgrade-master' command will populate this directory for you. The old Waterfall page is deprecated, but it should continue to work for another few releases. It is now a subclass of WebStatus which just replaces the default root URL with another copy of the /waterfall resource. *** Compatibility: Python-2.3 or newer, Twisted-2.0 or newer No compatiblity losses here, buildbot-0.7.6 is compatible with the same versions of python and twisted that 0.7.5 was. Buildbot is tested on a regular basis (http://buildbot.buildbot.net) against nearly a full matrix of Python-(2.3,2.4,2.5) * Twisted-(2.0,2.1,2.2,2.4,2.5). *** New Buildbot Home Page Buildbot has moved to a new Trac instance at http://buildbot.net/ , and all new bugs and tickets should be filed there. The old sourceforge bugs at http://buildbot.sf.net/ will slowly be migrated over. Mailing lists are still managed at sourceforge, and downloads are still available there. *** Changed/Deprecated master.cfg Keys and Classes c['sources'] (plural) has been replaced by c['change_source'] (singular). c['bots'] has been replaced by c['buildslaves'], and it expects a list of BuildSlave instances instead of tuples. See below for more details. The 'freshcvsmail' change source has been deprecated, and will be removed in the next release. The html.Waterfall status target has been deprecated, and replaced by html.WebStatus . ** New Features *** WebStatus The new WebStatus display is a superset of the old Waterfall. It contains a waterfall as a sub-page, but it also contains pages with more compact representations of recent build status. The "one_line_per_build" page contains just that, and "one_box_per_builder" shows just the information from the top of the waterfall page (last-finished-build and current-activity). The initial page (when you hit the root of the web site) is served from index.html, and provides links to the Waterfall as well as the other pages. Most of these pages can be filtered by adding query arguments to the URL. Adding "?builder=XYZ" will cause the page to only show results for the given builder. Adding "?builder=XYZ&builder=ABC" will show results for either builder. "?branch=trunk" will limit the results to builds that involved code from the trunk. The /waterfall page has arguments to hide those annoying "buildslave connected" messages, to start and and at arbitrary times, and to auto-refresh at a chosen interval (with a hardcoded minimum of 15 seconds). It also has a "help" page with forms that will help you add all of these nifty filtering arguments. The recommended practice is to modify the index.html file to include links to the filtered pages that you find most useful. Note that WebStatus defaults to allowForce=False, meaning that the display will not offer or accept "Force Build" or "Stop Build" controls. (The old Waterfall defaults to allowForce=True). The new WebStatus pages try very hard to use only relative links, making life better when the Buildbot sits behind an HTTP reverse proxy. In addition, there is a rudimentary XMLRPC server run by the WebStatus object. It only has two methods so far, but it will acquire more in the future. The first customer of this is a project to add a buildbot plugin to Trac. *** BuildFactory.addStep(Step(args)) BuildFactories can be set up either with a complete list of steps, or by calling the .addStep() method repeatedly. The preferred way to provide a step is by instantiating it, rather than giving a class/kwargs pair. This gives the BuildStep class a chance to examine the arguments (and complain about anything it doesn't like) while the config file is being read and problems are being logged. For example, the old-style: from buildbot.process.factory import BuildFactory, s steps = [s(CVS, cvsroot="blah", mode="copy"), s(Compile, command=["make", "all"]), s(Test, command=["make", "test"]), ] f = BuildFactory(steps) is now: f = BuildFactory() f.addStep( CVS(cvsroot="blah", mode="copy") ) f.addStep( Compile(command=["make", "all"]) ) f.addStep( Test(command=["make", "test"]) ) Authors of BuildStep subclasses which override __init__ to add new arguments must register them with self.addFactoryArguments(**newargs) to make sure that those classes will work with this new style, otherwise the new arguments will be lost. Using class/kwargs pairs is deprecated, and will be removed in a future release. *** BuildSlave instances, max_builds=, notify_on_missing= Buildslave specification has changed a lot in this release. The old config: c['bots'] = [ ("bot1name", "bot1passwd"), ("bot2name", "bot2passwd") ] is now: from buildbot.buildslave import BuildSlave c['slaves'] = [ BuildSlave("bot1name", "bot1passwd"), BuildSlave("bot2name", "bot2passwd") ] This new form gives us the ability to add new controls. The first is "max_builds=", which imposes a concurrency limit that is like the usual SlaveLock, but gives the buildmaster the opportunity to find a different slave to run the build. (the buildslave is chosen before the SlaveLock is claimed, so pure SlaveLocks don't let you take full advantage of build farms). The other addition is "notify_on_missing=", which accepts an email address (or list of addresses), and sends a message when the buildslave has been disconnected for more than an hour (configurable with missing_timeout=). This may be useful when you expect that the buildslave hosts should be available most of the time, and want to investigate the reasons that it went offline. ** Other Improvements The IRC bot has been refactored to make it easier to add instant-messaging status delivery in the future. The IM plugins are not yet written, though. When multiple buildslaves are available for a given build, one of them will be picked at random. In previous releases, the first one on the list was always picked. This helps to add a certain measure of load-balancing. More improvements will be made in the future. When the buildslave does a VC checkout step that requires clobbering the build directory (i.e. in all modes except for 'update'), the buildslave will first set the permissions on all build files to allow their deletion, before it attempts to delete them. This should fix some problems in which a build process left non-user-writable files lying around (frequently a result of enthusiastic unit tests). The BuildStep's workdir= argument can now accept a WithProperties() specification, allowing greater control over the workdir. Support for the 'Bazaar' version control system (/usr/bin/bzr) has been added, using the buildbot.steps.source.Bzr class. This is a replacement for the old 'Arch' (/usr/bin/tla and /usr/bin/baz) systems, which are still supported by Buildbot with the source.Arch and source.Bazaar classes, respectively. Unfortunately the old baz system claimed the 'Bazaar' classname early, so the new system must use source.Bzr instead of the desired source.Bazaar . A future release might change this. A rudimentary Gnome Panel applet is provided in contrib/bb_applet.py, which provides 'buildbot statusgui' -like colored status boxes inside the panel. Installing it is a bit tricky, though. The 'buildbot try' command now accepts a '--diff=foo.patch' argument, to let you provide a pre-computed patch. This makes it easier to test out patches that you've looked over for safety, without first applying them to your local source tree. A new Mercurial change source was added, hg_buildbot.py, which runs as an in-process post-commit hook. This gives us access to much more information about the change, as well as being much faster. The email-based changesource have been refactored, to make it easier to write new mail parsers. A parser for the SVN "commit-email.pl" script has been added. ** Bugs Fixed Far too many to count. Please see http://buildbot.net/trac/query?status=closed&milestone=0.7.6 for a partial list of tickets closed for this release, and the ChangeLog for a complete list of all changes since 0.7.5 . buildbot-0.8.8/docs/relnotes/0.7.7.txt000066400000000000000000000073301222546025000173310ustar00rootroot00000000000000Buildbot 0.7.7 was released 28 Mar 2008 ** Things You Need To Know *** builder names must not start with an underscore (`_'). These are now reserved for internal buildbot purposes, such as the magic "_all" pseudo-builder that the web pages use to allow force-build buttons that start builds on all Builders at once. ** New Features *** "buildbot checkconfig" The "buildbot checkconfig" command will look at your master.cfg file and tell you if there are any problems with it. This can be used to test potential changes to your config file before submitting them to the running buildmaster. This is particularly useful to run just before doing "buildbot restart", since the restart will fail if the config file has an error. By running "buildbot checkconfig master.cfg && buildbot restart", you'll only perform the restart if the config file was ok. Many thanks to Ben Hearsum for the patch. *** Waterfall "?category=FOO" query-arguments The Waterfall page now accepts one or more "category=" query arguments in the URL, to filter the display by categories. These behave a lot like the "builder=" query argument. Thanks to Jermo Davann for the patch. ** Bugs Fixed Many bugs were fixed, and many minor features were added. Many thanks to Dustin Mitchell who fixed and coordinated many of these. Here is a terse list, for more details, please see the Trac page for the 0.7.7 release, at http://buildbot.net/trac/query?status=closed&milestone=0.7.7 : Many of the URLs generated by the buildbot were wrong. Display of last-heard-from timestamps on the buildslaves web page were wrong. Asking an IRC bot about a build waiting on a Lock should no longer crash. Same for the web viewer. Stop treating the encouraged info/ directory as leftover. Add more force/stop build buttons. Timestamps displayed on the waterfall now handle daylight savings properly. p4poller no longer quits after a single failure. Improved Git support, including 'try', branch, and revisions. Buildslaves now use 'git', not 'cogito'. Make older hg client/servers handle specific-revision builds properly. Fix twisted.scripts._twistw problem on twisted-2.5.0 and windows. Fix workdir= and env= on ShellCommands Fix logfile-watching in 'buildbot start' on OS-X. Fix ShellCommand crashes when the program emits >640kB of output per chunk. New WarningCountingShellCommand step. Fix TreeSize step. Fix transfer.FileUpload/FileDownload crashes for large files. Make 'buildbor reconfig' on windows tell you that it doesn't work. Add a To: header to the mail sent by the slave-missing timeout. Disable usePTY= for most unit tests, it makes some debian systems flunk tests. Add 'absolute source stamps' Add 'triggerable schedulers', and a buildstep to trigger them. Remove buildbot.changes.freshcvsmail Add new XMLRPC methods: getAllBuilders, getStatus, getLastBuilds. Accept WithProperties in more places: env=, workdir=, others. Use --no-auth-cache with SVN commands to avoid clobbering shared svn state. Add hours/minutes/seconds in the waterfall's ETA display. Trial: count Doctest lines too. ShellCommand: record more info in the headers: stdin closing, PTY usage. Make it possible to stop builds across reconfig boundaries. SVN revision numbers are now passed as strings, which was breaking MailNotifier ** Deprecation Schedule The changes.freshcvsmail change source was replaced by changes.mail.FCMaildirSource in 0.7.6, and has been removed in 0.7.7 . c['sources'] (plural) was replaced by c['change_source'] (singular) in 0.7.6, and will be removed by 0.8.0. c['bots'] was replaced by c['buildslaves'] in 0.7.6, and will be removed by 0.8.0 . c['bots'] only accepts BuildSlave instances, not name/passwd tuples. The html.Waterfall status target was replaced by html.WebStatus in 0.7.6, and will be removed by 0.8.0. buildbot-0.8.8/docs/relnotes/0.7.8.txt000066400000000000000000000102121222546025000173230ustar00rootroot00000000000000Buildbot 0.7.8 was released 24 Jul 2008 ** New features The IRC bot will respond to three new commands: 'notify' subscribes the channel (or the sender, if the command is sent as a private "/msg") to hear about build events. 'join' tells the bot to join some new IRC channel. 'leave' tells it to leave a channel. See the "IRC Bot" section of the User's Manual for details. (#171) Build Steps now have "statistics", in addition to logfiles. These are used to count things like how many tests passed or failed. There are methods to sum these counters across all steps and display the results in the Build status. The Waterfall display now shows the count of failed tests on the top-most box in each column, using this mechanism. The new buildbot.steps.shell.PerlModuleTest step was added, to run Perl unit tests. This is a wrapper around the regular ShellCommand that parses the output of the standard perl unit test system and counts how many tests passed/failed/etc. The results are put into the step's summary text, and a count of tests passed/failed/skipped are tracked in the steps's statistics. The factory.CPAN build factory has been updated to use this, so configuring a Buildbot to test a perl module available from CPAN should be as easy as: s = source.CVS(cvsroot, cvsmodule) f = factory.CPAN(s) Build Properties have been generalized: they remain associated with a single Build, but the properties can be set from a variety of sources. In previous releases, the Build itself would set properties like 'buildername', 'branch', and 'revision' (the latter two indicating which version of the source code it was trying to get), and the source-checkout BuildSteps would set a property named 'got_revision' (to indicate what version of the soruce code it actually got). In this release, the 'scheduler' property is set to indicate which Scheduler caused the build to be started. In addition, the config file can specify properties to be set on all Builds, or on all Builds for a specific Builder. All these properties are available for interpolation into ShellCommands and environment variables by using the WithProperties() marker. It may be easier to implement simple build parameterization (e.g. to upload generated binaries to a specific directory, or to only perform long-running tests on a nightly build instead of upon every checkin) by using these Build Properties than to write custom BuildSteps. ** Other improvements The /buildslaves web page shows which slaves are currently running builds. Offline slaves are displayed in bold. Buildbot's setup.py now provides metadata to setuptools (if installed): an entry_points script was added, and a dependency upon twisted-2.4.x or newer was declared. This makes it more likely that 'easy_install buildbot' will work. The MailNotifier class acquired a mode="passing" flag: in this mode, the buildbot will only send mail about passing builds (versus only on failing builds, or only on builds which failed when the previous build had passed). ** Bugs fixed Don't display force/stop build buttons when build control is disabled (#246) When a build is waiting on a lock, don't claim that it has started (#107) Make SVN mode=copy tolerate symlinks on freebsd, "cp -rp" -> "cp -RPp" (#86) The svnpoller changesource now ignores branch deletion (#261) The Git unit tests should run even if the user has not told Git about their username/email. The WebStatus /xmlrpc server's getStatus() method was renamed to the more-accurate getLastBuildResults(). The TinderboxMailNotifier status output acquired an useChangeTime= argument. The bonsaipoller changesource got some fixes. ** Deprecation Schedule No features have been deprecated in this release, and no deprecated features have been removed. As a reminder, the following deprecated features are scheduled for removal in an upcoming release: c['sources'] (plural) was replaced by c['change_source'] (singular) in 0.7.6, and will be removed by 0.8.0. c['bots'] was replaced by c['buildslaves'] in 0.7.6, and will be removed by 0.8.0 . c['bots'] only accepts BuildSlave instances, not name/passwd tuples. The html.Waterfall status target was replaced by html.WebStatus in 0.7.6, and will be removed by 0.8.0. buildbot-0.8.8/docs/relnotes/0.7.9.txt000066400000000000000000000107621222546025000173360ustar00rootroot00000000000000Buildbot 0.7.9 was released 15 Sep 2008 ** New Features *** Configurable public_html directory (#162) The public_html/ directory, which provides static content for the WebStatus() HTTP server, is now configurable. The default location is still the public_html/ subdirectory of the buildmaster's base directory, but you can change this by passing a suitable argument when creating the WebStatus() instance in your master.cfg file: c['status'].append( WebStatus(8080, public_html="/var/www/buildbot") ) *** Lock access modes (#313) Albert Hofkamp added code to provide two distinct access modes to Locks: "counting" and "exclusive". Locks can accept a configurable number of "counting"-mode users, or a single "exclusive"-mode. For example, a Lock is defined with maxCount=3, and then a 'compile' BuildStep uses this lock in counting mode, while a 'cleanup' BuildStep uses this lock in exclusive mode. Then, there can be one, two, or three simultaneous Builds in the compile step (as long as there are no builds in the cleanup step). Only one build can be in the cleanup step at a time, and if there is such a build in the cleanup step, then the compile steps in other builds will wait for it to finish. Please see the "Interlocks" section of the user's manual for more details. ** Bugs Fixed *** Buildslave missing_timeout= fired too quickly (#211) By providing a missing_timeout= argument when creating the BuildSlave instance, you can ask the buildmaster to send email if a buildslave is disconnected for too long. A bug in the previous version caused this notification to be sent too soon, rather than waiting until the timeout period expired. This should be fixed now. *** Test command display fixed (#332) In the previous version, a steps.shell.Test step would display the parsed test results (in the step's box on the waterfall display) in lieu of any other descriptive text the step might provide. In this release, these two pieces of information are combined. ** Minor Changes The buildmaster's version is logged to its twistd.log file at startup. The buildslave does the same, to its own logfile. Remote commands now record how long each command took. The "elapsedTime=" message will appear in the step's main logfile. The "buildbot restart" command no longer fails if the buildbot wasn't already running. The FileUpload and FileDownload steps now create their target directories (and any missing intermediate directories) before writing to the destination file. The per-build and per-step web pages now show the start, finish, and elapsed time of their build or step. If a Subversion-based build is started with a mixture of Changes that specify particular numeric revisions and "HEAD" Changes (which indicate that a trunk checkout is desired), the build will use a trunk checkout. Previously this would probably cause an error. It is not clear how this situation might arise. ** Compability With Other Tools The mercurial commit hook (buildbot.changes.hgbuildbot) in the previous version doesn't work with hg-1.0 or later (it uses an API function that was present in the hg-0.9.5 release, but was removed from hg-1.0). This incompability has been fixed: the new version of buildbot should be compatible with hg-1.0 and newer (and it probably retains compability with hg-0.9.5 and earlier too). (#328) The Git tool has traditionally provided two ways to run each command, either as subcommands of /usr/bin/git (like "git checkout"), or as individual tools (like /usr/bin/git-checkout). The latter form is being removed in the upcoming 1.6 Git release. Previous versions of Buildbot have used the git-checkout form, and will break when Git is upgraded to 1.6 or beyond. The new Buildbot release switches to the subcommand form. Note that this is a change on the buildslave side. The Git checkout command will now use the default branch (as set in the steps.source.Git() step definition) if the changes that it is building do not specify some other branch to build. (#340) ** Deprecation Schedule No features have been deprecated in this release, and no deprecated features have been removed. As a reminder, the following deprecated features are scheduled for removal in an upcoming release: c['sources'] (plural) was replaced by c['change_source'] (singular) in 0.7.6, and will be removed by 0.8.0. c['bots'] was replaced by c['buildslaves'] in 0.7.6, and will be removed by 0.8.0 . c['bots'] only accepts BuildSlave instances, not name/passwd tuples. The html.Waterfall status target was replaced by html.WebStatus in 0.7.6, and will be removed by 0.8.0. buildbot-0.8.8/docs/relnotes/0.8.0.txt000066400000000000000000000061641222546025000173270ustar00rootroot00000000000000Buildbot 0.8.0 was released 25 May 2010 ** (NOTE!) Scheduler requires keyword arguments If you are creating your Scheduler like this: Scheduler("mysched", "mybranch", 0, ["foo", "bar"]) then it's time to change that to specify each of the arguments with a keyword: Scheduler(name="mysched", branch="mybranch", treeStableTimer=0, builderNames=["foo", "bar"]) ** Database Backend Scheduler, change, and build request information is now stored in a database - by default, in SQLite, although MySQL is also supported. With this change, scheduled builds will persist over buildmaster restarts, as will interrelationships between schedulers (e.g., Triggerable and Dependent). Upgrading to the new database backend is easy, although it brings additional requirements on the buildmaster. See the Buildbot documentation for more information. ** Visual Studio / VC++ Compile Steps ** New Change/SourceStamp attributes 'project' and 'repository' These attributes can be used to further refine matching by schedulers. Repository completes the SourceStamp: the tuple of (repository, branch, revision) completely specifies a source code tree. Likewise, the project attribute can be used to support building several distinct projects within one buildmaster, replacing the use of category for this purpose. Matching can be done using regular expressions, so it's even possible to support nested projects! ** ShellCommands expand environment variables If you pass to a shell command an environment variable like this: ShellCommand(..., env={"FOO": "${BAR}"}) then, on the slave side the variable FOO will have the same value as the alread existing BAR variable on the slave. This is mostly used to expand variable like this: "PATH": "/my/directory:${PATH}" where PATH will have "/my/directory" prepended to it. ** Builders can setup properties There is a new parameter to the builders to setup properties on a per-builder basis. ** New /json web status This view has lots of useful information perfectly formed for serving as input to JavaScript status displays. See /json/help for details. ** Jinja All web status is now generated using the Jinja templating engine, which gives buildbot a much more attractive and maintainable appearance. Buildbot's output is also now XHTML-compliant! ** Authorization Framework The web-based status displays now provide fine-grained control over who can do what - force builds, stop builds, cancel builds, etc. See the manual for configuration details. ** Mercurial uses full revisions Mercurial now sets got_revision to the full 40-character revision id instead of the short IDs. ** Cleanup, Bug Fixes, and Test Fixes Thanks to help from a number of devoted contributors, this version of Buildbot has seen a lot of house-cleaning, and even passes all of its own unit tests! ** Removals *** Removed buildbot.status.html.Waterfall (deprecated in 0.7.6) Note that this does not remove the waterfall -- just an old version of it which did not include the rest of the WebStatus pages. *** BuildmasterConfig no longer accepts 'bots' and 'sources' as keys (deprecated in 0.7.6). Use 'slaves' and 'change_source' instead. buildbot-0.8.8/docs/relnotes/0.8.1.txt000066400000000000000000000041261222546025000173240ustar00rootroot00000000000000Buildbot 0.8.1 was released 16 June 2010 ** Slave Split into separate component Installing 'buildbot' will no longer allow you to run a slave - for that, you'll now need the 'buildslave' component, which is available by easy_install. This is merely a packaging change - the buildslave and buildbot components are completely inter-compatible, just as they always have been. ** Features *** Add googlecode_atom.py to contrib (ticket #842) *** Implement clean master shutdown, available through WebStatus ** Fixes *** Pass local environment variables along with getProcessOutput. Required for ssh agent authentication. *** IRC doc fixes (ticket #852) *** Remove builder count from one_line_per_build (ticket #854) *** Set the 'revision' property more often (ticket #101) *** Change property priority ordering (ticket #809) *** Fixes to MaildirSource for CVS *** Use shutil.rmtree on POSIX systems *** Fix NameError in MailNotifier (ticket #758) *** Reduce verbosity of patches in twistd.log (ticket #803) *** Documentation updates to reflect UI customization via templates (ticket #866) ** Deprecations *** Arch, Bazaar, and Monotone to be removed in 0.8.2 This decision isn't final, but support for these VC's will be removed in version 0.8.2 unless a maintainers steps forward to document, test, and update them. *** Support for starting buildmaster from Makefiles to be removed in 0.8.2 In a little-used feature, 'buildbot start' would run 'make start' if a Makefile.buildbot existed in the master directory. This functionality will be removed in Buildbot-0.8.2, and the create-master command will no longer create a Makefile.sample. Of course, Buildbot still supports build processes on the slave using make! * Slave Changes ** First release of buildslave as a separate package ** Fixes *** Command-line options changed Added new `-n|--no-logrotate` flag to create-slave command which disables internal logging and log rotation mechanism in buildbot.tac (ticket #973) *** Delete srcdir before retrying git clone (ticket #884) *** Fix setup.py to install a launcher script properly in all cases. buildbot-0.8.8/docs/relnotes/0.8.2.txt000066400000000000000000000057601222546025000173320ustar00rootroot00000000000000Buildbot 0.8.2 was released 29 Oct 2010 ** Upgrading Upgrading to from the previous version will require an 'upgrade-master' run. However, the schema changes are backward-compatible, so if a downgrade is required, it will not be difficult. ** New Requirements The Buildmaster now requires Jinja-2.1 or higher. Both master and slave now require Twisted-8.0.0. Although Twisted-2.5.0 may still work, it is not tested and not supported. ** Command-line options changed To resolve conflicting command-line options (ticket #972) for sendchange command the following changes were done: * `-m` option now means `--master` * `-c` option now means `--comments` * `-C` option now means `--category` Added new `-n|--no-logrotate` flag to create-master command which disables internal logging and log rotation mechanism in buildbot.tac (ticket #973) ** MasterShellCommand semantics change The MasterShellCommand now provides the buildmaster's environment to the step by default; pass env={} to pass a clean environment, instead. ** Log Rotation The default 'create-master' output now rotates ten twistd.log files, each of about 10MiB. This is a change from older versions, which would rotate an unbounded number of 1MiB files. ** New configuration key, 'changeCacheSize' This sets the number of changes that buildbot will keep in memory at once. Users of distributed version control systems should consider setting this to a high value (e.g. 10,000) ** New libvirt-based Latent Buildslave Support This extends the support already included for EC2 buildslaves to include any virtualization platform supported by libvirt. ** Canceling Pending Builds for a Change Change pages on the webstatus now have buttons to cancel any pending builds that include that change (across all builders). The corresponding authz privledge to control access to this feature is 'stopChange'. ** New Change source *** CVSMaildirSource This parses mail sent by buildbot_cvs_mail.py in contrib directory. See docs for more info. ** New Steps *** VC++ 9, VS2008, VCExpress9 - part of the vstudio suite of steps ** Deprecations and Removals *** Removed sendchange's --revision_number argument (use --revision) *** Deprecating old CVS MairdirSources: Please post to the list if you are using FreshCVS FCMaildirSource Syncmail SyncmailMaildirSource Bonsai BonsaiMaildirSource *** statusgui is deprecated in this version and will be removed in the next release. Please file a bug at http://buildbot.net if you wish to reverse this decision. *** The Twisted-only steps BuildDebs and ProcessDocs have been removed. * Slave Changes ** Log Rotation The default 'create-slave' output now rotates ten twistd.log files, each of about 10MiB. This is a change from older versions, which would rotate an unbounded number of 1MiB files. ** twistd.hostname On startup, the buildslave writes its hostname to twistd.hostname. This is intended to contextualize twistd.pid, which does not specify the host on which the buildslave is running. buildbot-0.8.8/docs/relnotes/0.8.3.txt000066400000000000000000000044411222546025000173260ustar00rootroot00000000000000Buildbot 0.8.3 was released 19 Dec 2010 ** Deprecations and Removals *** Change sources can no longer call change-related methods on self.parent. Instead, use self.master methods, e.g., self.master.addChange. ** PBChangeSource now supports authentication PBChangeSource now supports the `user` and `passwd` arguments. Users with a publicly exposed PB port should use these parameters to limit sendchange access. Previous versions of Buildbot should never be configured with a PBChangeSource and a publicly accessible slave port, as that arrangement allows anyone to connect and inject a change into the Buildmaster without any authentication at all, aside from the hard-coded 'change'/'changepw' credentials. In many cases, this can lead to arbitrary code injection on slaves. ** Experiemental Gerrit and Repo support A new ChangeSource (GerritChangeSource), status listener (GerritStatusPush), and source step (Repo) are available in this version. These are not fully documented and still have a number of known bugs outstanding (see http://buildbot.net/trac/wiki/RepoProject), and as such are considered experimental in this release. ** WithProperties now supports lambda substitutions WithProperties now has the option to pass callable functions as keyword arguments to substitute in the results of more complex Python code at evaluation-time. ** New 'SetPropertiesFromEnv' step This step uses the slave environment to set build properties. ** Deprecations and Removals *** The console view previously had an undocumented feature that would strip leading digits off the category name. This was undocumented and apparently non-functional, and has been removed. (#1059) *** contrib/hg_buildbot.py was removed in favor of buildbot.changes.hgbuildbot. *** The misnamed sendchange option 'username' has been renamed to 'who'; the old option continues to work, but is deprecated and will be removed. (#1711) * Slave Changes ** Slave-initiated Graceful Shutdown If the allow_shutdown parameter in buildbot.tac is set, then the slave can be gracefully shut down locally by the slave admin. The shutdown operates by the slave informing the master that it would like to shut down; the master then finishes any active builds on the slave, and instructs the slave to shut down. See the documentation for more information. buildbot-0.8.8/docs/relnotes/0.8.4.txt000066400000000000000000000112071222546025000173250ustar00rootroot00000000000000Buildbot 0.8.4 was released 12 Jun 2010 ** Buildmaster Metrics The buildmaster now actively measures a number of quantities that can be useful in debugging and tuning its performance. See the documentation for more information. ** Monotone support Monotone support has returned to Buildbot, thanks to Richard Levitte. ** `Blocker` step A "beta" version of the Blocker step has been added; this step allows multiple concurrent builds to be synchronized. It is "beta" in the sense that it may contain significant bugs, is only documented in the source code, and has an interface that is subject to non-compatible change in later versions of Buildbot. See `contrib/blockertest` for a test and demonstration of the new step's functionality. ** Deprecations, Removals, and Non-Compatible Changes *** Init script now uses /etc/default/buildmaster for instance configuration. Also MASTER_ENABLED used in /etc/default/buildmaster now accepts 'true|yes|1' to enable instance and 'false|no|0' to disable(not case sensitive). Other values will be considered as syntax error. *** 'buildbot.status.words.IRC' now defaults to `AllowForce=False` to prevent IRC bots from being allowed to force builds by default. *** MasterShellCommand and all of the transfer steps now default to haltOnFailure=True and flunkOnFailure=True *** GitPoller's 'workdir' parameter should always be supplied; using the default (/tmp/gitpoller_work) is deprecated and will not be supported in future versions. *** ChangeFilter should now be imported from `buildbot.changes.filter'; the old import path will still work. *** What used to be called simply 'Scheduler' should now be instantiated as 'SingleBranchScheduler', and its branch argument is mandatory. *** The Dependent scheduler is now in its own module, 'buildbot.schedulers.dependent', although the old name will continue to work. *** The mergeRequests parameters are now more flexible, but an incompatible change was made: if the BuilderConfig mergeRequests argument is explicitly set to True, then the default merge method will be used. In earlier versions, this configuration fell back to the global c['mergeRequests'] parameter's value. To avoid this, remove `mergeRequests=True` from any BuilderConfig constructor invocations. *** The `Status.getBuildSets` method now returns its result via Deferred. *** The `BuilderControl.getPendingBuilds` method has been renamed to `getPendingBuildRequestControls`; `BuilderStatus.getPendingBuilds` has been renamed to `getPendingBuildStatuses`. Both now return their results via Deferred. *** The utility method `Builder.getOldesetRequestTime` now returns its result via a Deferred, and that result is now a DateTime object. *** The remote BuildSetStatus method `waitForSuccess` is no longer available. *** The BuildRequestStatus methods `getSubmitTime` and `getSourceStamp` now return their results via a Deferred. The `asDict` method omits these values, as it retuns synchronously. *** Buildbot now uses temporary tables, which can cause problems with replication in MySQL. See "Database Specification" in the manual for more details. ** Scheduler Improvements *** Nightly scheduler now accepts a change_filter argument ** SQLAlchemy & SQLAlchemy-Migrate Buildbot now uses SQLAlchemy as a database abstraction layer. This gives greater inter-database compatibility and a more stable and reliable basis for this core component of the framework. SQLAlchemy-Migrate is used to manage changes to the database schema from version to version. *** Postgres support Buildbot should now work with a Postgres backend just as well as it does with MySQL or SQLite. Buildbot is actively tested against all three backends. ** Less garish color scheme The default color scheme for Buildbot has been modified to make it slightly less, well, neon. Note: This will not affect already-created masters, as their default.css file has already been created. If you currently use the default and want to get the new version, just overwrite public_html/default.css with the copy in this version. * Slave Changes ** Monotone support Monotone support has returned to Buildbot, thanks to Richard Levitte. ** Buildslave now places all spawned commands into process groups on POSIX systems. This means that in most cases child processes are cleaned up properly, and removes the most common use for usePTY. As of this version, usePTY should be set to False for almost all users of Buildbot. ** Init script now uses /etc/default/buildslave for instance configuration. Also SLAVE_ENABLED used in /etc/default/buildslave now accepts 'true|yes|1' to enable instance and 'false|no|0' to disable(not case sensitive). Other values will be considered as syntax error. buildbot-0.8.8/docs/relnotes/0.8.5.txt000066400000000000000000000071661222546025000173370ustar00rootroot00000000000000Buildbot 0.8.5 was released 3 Sept 2010 ** Updated, sphinx-based documentation The Buildbot documentation has been ported to Sphinx and significantly refactored and extended. ** Better support for users in Buildbot (GSoC project) Buildbot now tracks user identity across version-control commits, IRC and web interactions, and Try submissions. ** New and improved Source steps (GSoC project) Source steps have been rewritten to have a simpler, more consistent configuration, and to run on the master instead of the slave, allowing much more control over their behavior. ** EC2 instances are now terminated instead of stopped. This is really only relevant for EBS-backed instances, as Buildbot will now free the instance and associated EBS storage when shutting down the slave. ** SQLite databases use write-ahead logging WAL mode offers much greater concurrency (preventing the dreaded 'database is locked' errors) and is also more efficient and durable. ** Deprecations, Removals, and Non-Compatible Changes *** Any custom IStatusListener providers which do not inherit from StatusListener should provide a checkConfig(all_statuses): method. This is to verify at startup that there are no conflicting status configurations. *** The db.buildrequests.claimBuildRequests method can no longer re-claim already-claimed requests; use reclaimBuildRequests instead. The database no longer tracks master instances, so the unclaimOldIncarnationRequests method has been removed. Note that several of the methods in this module now perform fewer consistency checks, for efficiency. *** Upgrades directly from versions older than 0.6.5 will no longer automatically migrate logfiles. *** Any custom change_hook_dialects should now return a (changes, src) tuple from its getChange method, instead of just the changes. The src is used for noting what VCS the changes came from, and is just a string such as 'git'. *** Scripts in the contrib directory that use addChange() to send Changes to the buildmaster now require an additional `src` argument when calling addChange(). This lets the buildmaster know which VCS the Change is coming from, such as 'git' or 'svn'. This means that you need to use the version of your contrib script that corresponds to your buildmaster. *** The un-documented P4Sync source step has been deprecated and will be removed in the next version. ** Customizable validation regexps The global c['validation'] parameter can be used to adjust the regular expressions used to validate branches, revisions, and properties input by the user. ** Logging for SVNPoller cleaned up All logging for SVNPoller now starts with "SVNPoller: ". Previously it was mixed case and not uniform. ** Source steps have logEnviron parameter Similar to shell commands, a logEnviron parameter is now supported for Source steps. ** Interested users for Try Try jobs can now include the name of an interested user, which will be kept with the patch and displayed in the web status. ** 'buildbot checkconfig' improved This command no longer copies the configuration to a temporary directory. This change allows more complex configurations to be tested with checkconfig. * Slave Changes ** Retry on UnauthorizedLogin In previous versions, if a slave received UnauthorizedLogin from the master, it would stop retrying and exit. This has proven to be less helpful than simply retrying, so as of this version the slave will continue to retry. ** Deprecations, Removals, and Non-Compatible Changes *** The format of the data that determines whether a directory requires a new checkout has changed for Perforce. The first build (only) after an upgrade may do an unnecessary full checkout. buildbot-0.8.8/docs/relnotes/0.8.6.rst000066400000000000000000000211571222546025000173250ustar00rootroot00000000000000Release Notes for Buildbot v0.8.6p1 =================================== .. Any change that adds a feature or fixes a bug should have an entry here. Most simply need an additional bulleted list item, but more significant changes can be given a subsection of their own. The following are the release notes for Buildbot v0.8.6p1. Buildbot v0.8.6 was released on March 11, 2012. Buildbot v0.8.6p1 was released on March 25, 2012. 0.8.6p1 ------- In addition to what's listed below, the 0.8.6p1 release adds the following. * Builders are no longer displayed in the order they were configured. This was never intended behavior, and will become impossible in the distributed architecture planned for Buildbot-0.9.x. As of 0.8.6p1, builders are sorted naturally: lexically, but with numeric segments sorted numerically. * Slave properties in the configuration are now handled correctly. * The web interface buttons to cancel individual builds now appear when configured. * The ForceScheduler's properties are correctly updated on reconfig - :bb:bug:`2248`. * If a slave is lost while waiting for locks, it is properly cleaned up - :bb:bug:`2247`. * Crashes when adding new steps to a factory in a reconfig are fixed - :bb:bug:`2252`. * MailNotifier AttributeErrors are fixed - :bb:bug:`2254`. * Cleanup from failed builds is improved - :bb:bug:`2253`. Master ------ * If you are using the GitHub hook, carefully consider the security implications of allowing un-authenticated change requests, which can potentially build arbitrary code. See :bb:bug:`2186`. Deprecations, Removals, and Non-Compatible Changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * Forced builds now require that a :bb:sched:`ForceScheduler` be defined in the Buildbot configuration. For compatible behavior, this should look like:: from buildbot.schedulers.forcesched import ForceScheduler c['schedulers'].append(ForceScheduler( name="force", builderNames=["b1", "b2", ... ])) Where all of the builder names in the configuration are listed. See the documentation for the *much* more flexiblie configuration options now available. * This is the last release of Buildbot that will be compatible with Python 2.4. The next version will minimally require Python-2.5. See :bb:bug:`2157`. * This is the last release of Buildbot that will be compatible with Twisted-8.x.y. The next version will minimally require Twisted-9.0.0. See :bb:bug:`2182`. * ``buildbot start`` no longer invokes make if a ``Makefile.buildbot`` exists. If you are using this functionality, consider invoking make directly. * The ``buildbot sendchange`` option ``--username`` has been removed as promised in :bb:bug:`1711`. * StatusReceivers' checkConfig method should now take an additional `errors` parameter and call its :py:meth:`~buildbot.config.ConfigErrors.addError` method to indicate errors. * The gerrit status callback now gets an additional parameter (the master status). If you use this callback, you will need to adjust its implementation. * SQLAlchemy-Migrate version 0.6.0 is no longer supported. See :ref:`Buildmaster-Requirements`. * Older versions of SQLite which could limp along for previous versions of Buildbot are no longer supported. The minimum version is 3.4.0, and 3.7.0 or higher is recommended. * The master-side Git step now checks out 'HEAD' by default, rather than master, which translates to the default branch on the upstream repository. See :bb:pull:`301`. * The format of the repository strings created by ``hgbuildbot`` has changed to contain the entire repository URL, based on the ``web.baseurl`` value in ``hgrc``. To continue the old (incorrect) behavior, set ``hgbuildbot.baseurl`` to an empty string as suggested in :ref:`the Buildbot manual `. * Master Side :bb:step:`SVN` Step has been corrected to properly use ``--revision`` when ``alwaysUseLatest`` is set to ``False`` when in the ``full`` mode. See :bb:bug:`2194` * Master Side :bb:step:`SVN` Step paramater svnurl has been renamed repourl, to be consistent with other master-side source steps. * Master Side :bb:step:`Mercurial` step parameter ``baseURL`` has been merged with ``repourl`` parameter. The behavior of the step is already controled by ``branchType`` parameter, so just use a single argument to specify the repository. * Passing a :py:class:`buildbot.process.buildstep.BuildStep` subclass (rather than instance) to :py:meth:`buildbot.process.factory.BuildFactory.addStep` has long been deprecated, and will be removed in version 0.8.7. * The `hgbuildbot` tool now defaults to the 'inrepo' branch type. Users who do not explicitly set a branch type would previously have seen empty branch strings, and will now see a branch string based on the branch in the repository (e.g., `default`). Changes for Developers ~~~~~~~~~~~~~~~~~~~~~~ * The interface for runtime access to the master's configuration has changed considerably. See :doc:`/developer/config` for more details. * The DB connector methods ``completeBuildset``, ``completeBuildRequest``, and ``claimBuildRequest`` now take an optional ``complete_at`` parameter to specify the completion time explicitly. * Buildbot now sports sourcestamp sets, which collect multiple sourcestamps used to generate a single build, thanks to Harry Borkhuis. See :bb:pull:`287`. * Schedulers no longer have a ``schedulerid``, but rather an ``objectid``. In a related change, the ``schedulers`` table has been removed, along with the :py:meth:`buildbot.db.schedulers.SchedulersConnectorComponent.getSchedulerId` method. * The Dependent scheduler tracks its upstream buildsets using :py:class:`buildbot.db.schedulers.StateConnectorComponent`, so the ``scheduler_upstream_buildsets`` table has been removed, along with corresponding (undocumented) :py:class:`buildbot.db.buildsets.BuildsetsConnector` methods. * Errors during configuration (in particular in :py:class:`BuildStep` constructors), should be reported by calling :py:func:`buildbot.config.error`. Features ~~~~~~~~ * The IRC status bot now display build status in colors by default. It is controllable and may be disabled with useColors=False in constructor. * Buildbot can now take advantage of authentication done by a front-end web server - see :bb:pull:`266`. * Buildbot supports a simple cookie-based login system, so users no longer need to enter a username and password for every request. See the earlier commits in :bb:pull:`278`. * The master-side SVN step now has an `export` method which is similar to `copy`, but the build directory does not contain Subversion metdata. (:bb:bug:`2078`) * :py:class:`Property` instances will now render any properties in the default value if necessary. This makes possible constructs like :: command=Property('command', default=Property('default-command')) * Buildbot has a new web hook to handle push notifications from Google Code - see :bb:pull:`278`. * Revision links are now generated by a flexible runtime conversion configured by :bb:cfg:`revlink` - see :bb:pull:`280`. * Shell command steps will now "flatten" nested lists in the ``command`` argument. This allows substitution of multiple command-line arguments using properties. See :bb:bug:`2150`. * Steps now take an optional ``hideStepIf`` parameter to suppress the step from the waterfall and build details in the web. (:bb:bug:`1743`) * :py:class:`Trigger` steps with ``waitForFinish=True`` now receive a URL to all the triggered builds. This URL is displayed in the waterfall and build details. See :bb:bug:`2170`. * The :bb:src:`master/contrib/fakemaster.py`` script allows you to run arbitrary commands on a slave by emulating a master. See the file itself for documentation. * MailNotifier allows multiple notification modes in the same instance. See :bb:bug:`2205`. * SVNPoller now allows passing extra arguments via argument ``extra_args``. See :bb:bug:`1766` Slave ----- Deprecations, Removals, and Non-Compatible Changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * BitKeeper support is in the "Last-Rites" state, and will be removed in the next version unless a maintainer steps forward. Features ~~~~~~~~ Details ------- For a more detailed description of the changes made in this version, see the Git log itself:: git log buildbot-0.8.5..buildbot-0.8.6 Older Versions -------------- Release notes for older versions of Buildbot are available in the :bb:src:`master/docs/release-notes/` directory of the source tree, or in the archived documentation for those versions at http://buildbot.net/buildbot/docs. buildbot-0.8.8/docs/relnotes/0.8.7.rst000066400000000000000000000257701222546025000173330ustar00rootroot00000000000000Release Notes for Buildbot v0.8.7 ================================= .. Any change that adds a feature or fixes a bug should have an entry here. Most simply need an additional bulleted list item, but more significant changes can be given a subsection of their own. The following are the release notes for Buildbot v0.8.7. Buildbot v0.8.7 was released on September 22, 2012. Buildbot 0.8.7p1 was released on November 21, 2012. 0.8.7p1 ------- In addition to what's listed below, the 0.8.7p1 release adds the following. * The ``SetPropertiesFromEnv`` step now correctly gets environment variables from the slave, rather than those set on the master. Also, it logs the changes made to properties. * The master-side ``Git`` source step now doesn't try to clone a branch called ``HEAD``. This is what ``git`` does by default, and specifying it explicitly doesn't work as expected. * The ``Git`` step properly deals with the case when there is a file called ``FETCH_HEAD`` in the checkout. * Buildbot no longer forks when told not to daemonize. * Buildbot's startup is now more robust. See :bb:bug:`1992`. * The ``Trigger`` step uses the provided list of source stamps exactly, if given, instead of adding them to the sourcestamps of the current build. In 0.8.7, they were combined with the source stamps for the current build. * The ``Trigger`` step again completely ignores the source stamp of the current build, if ``alwaysUseLatest`` is set. In 0.8.7, this was mistakenly changed to only ignore the specified revision of the source stamp. * The ``Triggerable`` scheduler is again properly passing changes through to the scheduled builds. See :bb:bug:`2376`. * Web change hooks log errors, allowing debugging. * The ``base`` change hook now properly decodes the provided date. * ``CVSMailDir`` has been fixed. * Importing ``buildbot.test`` no longer causes python to exit, if ``mock`` insn't installed. The fixes ``pydoc -k`` when buildbot is installed. * ``Mercurial`` properly updates to the correct branch, when using ``inrepo`` branches. * Buildbot now doesn't fail on invalid UTF-8 in a number of places. * Many documenation updates and fixes. Master ------ Features ~~~~~~~~ * Buildbot now supports building projects composed of multiple codebases. New schedulers can aggregate changes to multiple codebases into source stamp sets (with one source stamp for each codebase). Source steps then check out each codebase as required, and the remainder of the build process proceeds normally. See the :ref:`Multiple-Codebase-Builds` for details. * The format of the ``got_revision`` property has changed for multi-codebase builds. It is now a dictionary keyed by codebase. * ``Source`` and ``ShellCommand`` steps now have an optional ``descriptionSuffix``, a suffix to the ``description``/``descriptionDone`` values. For example this can help distinguish between multiple ``Compile`` steps that are applied to different codebases. * The ``Git`` step has a new ``getDescription`` option, which will run ``git describe`` after checkout normally. See :bb:step:`Git` for details. * A new interpolation placeholder :ref:`Interpolate`, with more regular syntax, is available. * A new ternary substitution operator ``:?`` and ``:#?`` is available with the ``Interpolate`` class. * ``IRenderable.getRenderingFor`` can now return a deferred. * The Mercurial hook now supports multiple masters. See :bb:pull:`436`. * There's a new poller for Mercurial: :bb:chsrc:`HgPoller`. * The new ``HTPasswdAprAuth`` uses libaprutil (through ctypes) to validate the password against the hash from the .htpasswd file. This adds support for all hash types htpasswd can generate. * ``GitPoller`` has been rewritten. It now supports multiple branches and can share a directory between multiple pollers. It is also more resilient to changes in configuration, or in the underlying repository. * Added a new property ``httpLoginUrl`` to ``buildbot.status.web.authz.Authz`` to render a nice Login link in WebStatus for unauthenticated users if ``useHttpHeader`` and ``httpLoginUrl`` are set. * ``ForceScheduler`` has been updated: * support for multiple :ref:`codebases` via the ``codebases`` parameter * ``NestedParameter`` to provide a logical grouping of parameters. * ``CodebaseParameter`` to set the branch/revision/repository/project for a codebase * new HTML/CSS customization points. Each parameter is contained in a ``row`` with multiple 'class' attributes associated with them (eg, 'force-string' and 'force-nested') as well as a unique id to use with Javascript. Explicit line-breaks have been removed from the HTML generator and are now controlled using CSS. * The :bb:chsrc:`SVNPoller` now supports multiple projects and codebases. See :bb:pull:`443`. * The :bb:status:`MailNotifier` now takes a callable to calculate the "previous" build for purposes of determining status changes. See :bb:pull:`489`. * The ``copy_properties`` parameter, given a list of properties to copy into the new build request, has been deprecated in favor of explicit use of ``set_properties``. Deprecations, Removals, and Non-Compatible Changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * Buildbot master now requires at least Python-2.5 and Twisted-9.0.0. * Passing a :py:class:`~buildbot.process.buildstep.BuildStep` subclass (rather than instance) to :py:meth:`~buildbot.process.factory.BuildFactory.addStep` is no longer supported. The ``addStep`` method now takes exactly one argument. * Buildbot master requires ``python-dateutil`` version 1.5 to support the Nightly scheduler. * ``ForceScheduler`` has been updated to support multiple :ref:`codebases`. The branch/revision/repository/project are deprecated; if you have customized these values, simply provide them as ``codebases=[CodebaseParameter(name='', ...)]``. * The POST URL names for ``AnyPropertyParameter`` fields have changed. For example, 'property1name' is now 'property1_name', and 'property1value' is now 'property1_value'. Please update any bookmarked or saved URL's that used these fields. * ``forcesched.BaseParameter`` API has changed quite a bit and is no longer backwards compatible. Updating guidelines: * ``get_from_post`` is renamed to ``getFromKwargs`` * ``update_from_post`` is renamed to ``updateFromKwargs``. This function's parameters are now called via named parameters to allow subclasses to ignore values it doesnt use. Subclasses should add ``**unused`` for future compatibility. A new parameter ``sourcestampset`` is provided to allow subclasses to modify the sourcestamp set, and will probably require you to add the ``**unused`` field. * The parameters to the callable version of ``build.workdir`` have changed. Instead of a single sourcestamp, a list of sourcestamps is passed. Each sourcestamp in the list has a different :ref:`codebase` * The undocumented renderable ``_ComputeRepositoryURL`` is no longer imported to :py:mod:`buildbot.steps.source`. It is still available at :py:mod:`buildbot.steps.source.oldsource`. * ``IProperties.render`` now returns a deferred, so any code rendering properties by hand will need to take this into account. * ``baseURL`` has been removed in :bb:step:`SVN` to use just ``repourl`` - see :bb:bug:`2066`. Branch info should be provided with ``Interpolate``. :: from buildbot.steps.source.svn import SVN factory.append(SVN(baseURL="svn://svn.example.org/svn/")) can be replaced with :: from buildbot.process.properties import Interpolate from buildbot.steps.source.svn import SVN factory.append(SVN(repourl=Interpolate("svn://svn.example.org/svn/%(src::branch)s"))) and :: from buildbot.steps.source.svn import SVN factory.append(SVN(baseURL="svn://svn.example.org/svn/%%BRANCH%%/project")) can be replaced with :: from buildbot.process.properties import Interpolate from buildbot.steps.source.svn import SVN factory.append(SVN(repourl=Interpolate("svn://svn.example.org/svn/%(src::branch)s/project"))) and :: from buildbot.steps.source.svn import SVN factory.append(SVN(baseURL="svn://svn.example.org/svn/", defaultBranch="branches/test")) can be replaced with :: from buildbot.process.properties import Interpolate from buildbot.steps.source.svn import SVN factory.append(SVN(repourl=Interpolate("svn://svn.example.org/svn/%(src::branch:-branches/test)s"))) * The ``P4Sync`` step, deprecated since 0.8.5, has been removed. The ``P4`` step remains. * The ``fetch_spec`` argument to ``GitPoller`` is no longer supported. ``GitPoller`` now only downloads branches that it is polling, so specifies a refspec itself. * The format of the changes produced by :bb:chsrc:`SVNPoller` has changed: directory pathnames end with a forward slash. This allows the ``split_file`` function to distinguish between files and directories. Customized split functions may need to be adjusted accordingly. * :ref:`WithProperties` has been deprecated in favor of :ref:`Interpolate`. `Interpolate` doesn't handle functions as keyword arguments. The following code using ``WithProperties`` :: from buildbot.process.properties import WithProperties def determine_foo(props): if props.hasProperty('bar'): return props['bar'] elif props.hasProperty('baz'): return props['baz'] return 'qux' WithProperties('%(foo)s', foo=determine_foo) can be replaced with :: from zope.interface import implementer from buildbot.interfaces import IRenderable from buildbot.process.properties import Interpolate @implementer(IRenderable) class determineFoo(object): def getRenderingFor(self, props): if props.hasProperty('bar'): return props['bar'] elif props.hasProperty('baz'): return props['baz'] return 'qux' Interpolate('%s(kw:foo)s', foo=determineFoo()) Changes for Developers ~~~~~~~~~~~~~~~~~~~~~~ * ``BuildStep.start`` can now optionally return a deferred and any errback will be handled gracefully. If you use ``inlineCallbacks``, this means that unexpected exceptions and failures raised will be captured and logged and the build shut down normally. * The helper methods ``getState`` and ``setState`` from ``BaseScheduler`` have been factored into ``buildbot.util.state.StateMixin`` for use elsewhere. Slave ----- Features ~~~~~~~~ Deprecations, Removals, and Non-Compatible Changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * The ``P4Sync`` step, deprecated since 0.8.5, has been removed. The ``P4`` step remains. Details ------- For a more detailed description of the changes made in this version, see the Git log itself:: git log v0.8.6..v0.8.7 Older Versions -------------- Release notes for older versions of Buildbot are available in the :bb:src:`master/docs/relnotes/` directory of the source tree. Starting with version 0.8.6, they are also available under the appropriate version at http://buildbot.net/buildbot/docs. buildbot-0.8.8/docs/relnotes/index.rst000066400000000000000000000154271222546025000177660ustar00rootroot00000000000000Release Notes for Buildbot v0.8.8 ================================= .. Any change that adds a feature or fixes a bug should have an entry here. Most simply need an additional bulleted list item, but more significant changes can be given a subsection of their own. The following are the release notes for Buildbot v0.8.8 Buildbot v0.8.8 was released on August 22, 2013 Master ------ Features ~~~~~~~~ * The ``MasterShellCommand`` step now correctly handles environment variables passed as list. * The master now poll the database for pending tasks when running buildbot in multi-master mode. * The algorithm to match build requests to slaves has been rewritten in :bb:pull:`615`. The new algorithm automatically takes locks into account, and will not schedule a build only to have it wait on a lock. The algorithm also introduces a ``canStartBuild`` builder configuration option which can be used to prevent a build request being assigned to a slave. * ``buildbot stop`` and ``buildbot restart`` now accept ``--clean`` to stop or restart the master cleanly (allowing all running builds to complete first). * The :bb:status:`IRC` bot now supports clean shutdown and immediate shutdown by using the command 'shutdown'. To allow the command to function, you must provide `allowShutdown=True`. * :bb:step:`CopyDirectory` has been added. * :bb:sched:`BuildslaveChoiceParameter` has been added to provide a way to explicitly choose a buildslave for a given build. * default.css now wraps preformatted text by default. * Slaves can now be paused through the web status. * The latent buildslave support is less buggy, thanks to :bb:pull:`646`. * The ``treeStableTimer`` for ``AnyBranchScheduler`` now maintains separate timers for separate branches, codebases, projects, and repositories. * :bb:step:`SVN` has a new option `preferLastChangedRev=True` to use the last changed revision for ``got_revision`` * The build request DB connector method :py:meth:`~buildbot.db.buildrequests.BuildRequestsConnectorComponent.getBuildRequests` can now filter by branch and repository. * A new :bb:step:`SetProperty` step has been added in ``buildbot.steps.master`` which can set a property directly without accessing the slave. * The new :bb:step:`LogRenderable` step logs Python objects, which can contain renderables, to the logfile. This is helpful for debugging property values during a build. * 'buildbot try' now has an additional :option:`--property` option to set properties. Unlike the existing :option:`--properties` option, this new option supports setting only a single property and therefore allows commas to be included in the property name and value. * The ``Git`` step has a new ``config`` option, which accepts a dict of git configuration options to pass to the low-level git commands. See :bb:step:`Git` for details. * In :bb:step:`ShellCommand` ShellCommand now validates its arguments during config and will identify any invalid arguments before a build is started. * The list of force schedulers in the web UI is now sorted by name. * OpenStack-based Latent Buildslave support was added. See :bb:pull:`666`. * Master-side support for P4 is available, and provides a great deal more flexibility than the old slave-side step. See :bb:pull:`596`. * Master-side support for Repo is available. The step parameters changed to camelCase. ``repo_downloads``, and ``manifest_override_url`` properties are no longer hardcoded, but instead consult as default values via renderables. Renderable are used in favor of callables for ``syncAllBranches`` and ``updateTarball``. * Builder configurations can now include a ``description``, which will appear in the web UI to help humans figure out what the builder does. * GNUAutoconf and other pre-defined factories now work correctly (:bb:bug:`2402`) * The pubDate in RSS feeds is now rendered correctly (:bb:bug:`2530`) Deprecations, Removals, and Non-Compatible Changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * The ``split_file`` function for :bb:chsrc:`SVNPoller` may now return a dictionary instead of a tuple. This allows it to add extra information about a change (such as ``project`` or ``repository``). * The ``workdir`` build property has been renamed to ``builddir``. This change accurately reflects its content; the term "workdir" means something different. ``workdir`` is currently still supported for backwards compatability, but will be removed eventually. * The ``Blocker`` step has been removed. * Several polling ChangeSources are now documented to take a ``pollInterval`` argument, instead of ``pollinterval``. The old name is still supported. * StatusReceivers' checkConfig method should no longer take an `errors` parameter. It should indicate errors by calling :py:func:`~buildbot.config.error`. * Build steps now require that their name be a string. Previously, they would accept anything, but not behave appropriately. * The web status no longer displays a potentially misleading message, indicating whether the build can be rebuilt exactly. * The ``SetProperty`` step in ``buildbot.steps.shell`` has been renamed to :bb:step:`SetPropertyFromCommand`. * The EC2 and libvirt latent slaves have been moved to ``buildbot.buildslave.ec2`` and ``buildbot.buildslave.libirt`` respectively. * Pre v0.8.7 versions of buildbot supported passing keyword arguments to ``buildbot.process.BuildFactory.addStep``, but this was dropped. Support was added again, while still being deprecated, to ease transition. Changes for Developers ~~~~~~~~~~~~~~~~~~~~~~ * Added an optional build start callback to ``buildbot.status.status_gerrit.GerritStatusPush`` This release includes the fix for :bb:bug:`2536`. * An optional ``startCB`` callback to :bb:status:`GerritStatusPush` can be used to send a message back to the committer. See the linked documentation for details. * bb:sched:`ChoiceStringParameter` has a new method ``getChoices`` that can be used to generate content dynamically for Force scheduler forms. Slave ----- Features ~~~~~~~~ * The fix for Twisted bug #5079 is now applied on the slave side, too. This fixes a perspective broker memory leak in older versions of Twisted. This fix was added on the master in Buildbot-0.8.4 (see :bb:bug:`1958`). * The ``--nodaemon`` option to ``buildslave start`` now correctly prevents the slave from forking before running. Deprecations, Removals, and Non-Compatible Changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Details ------- For a more detailed description of the changes made in this version, see the git log itself:: git log v0.8.7..v0.8.8 Older Versions -------------- Release notes for older versions of Buildbot are available in the :bb:src:`master/docs/relnotes/` directory of the source tree. Newer versions are also available here: .. toctree:: :maxdepth: 1 0.8.7 0.8.6 buildbot-0.8.8/docs/relnotes/index.rst~000066400000000000000000000151651222546025000201630ustar00rootroot00000000000000Release Notes for Buildbot |version| ==================================== .. Any change that adds a feature or fixes a bug should have an entry here. Most simply need an additional bulleted list item, but more significant changes can be given a subsection of their own. The following are the release notes for Buildbot |version|. * The ``MasterShellCommand`` step now correctly handles environment variables passed as list. * The master now poll the database for pending tasks when running buildbot in multi-master mode. Master ------ Features ~~~~~~~~ * The algorithm to match build requests to slaves has been rewritten in :bb:pull:`615`. The new algorithm automatically takes locks into account, and will not schedule a build only to have it wait on a lock. The algorithm also introduces a ``canStartBuild`` builder configuration option which can be used to prevent a build request being assigned to a slave. * ``buildbot stop`` and ``buildbot restart`` now accept ``--clean`` to stop or restart the master cleanly (allowing all running builds to complete first). * The :bb:status:`IRC` bot now supports clean shutdown and immediate shutdown by using the command 'shutdown'. To allow the command to function, you must provide `allowShutdown=True`. * :bb:step:`CopyDirectory` has been added. * :bb:sched:`BuildslaveChoiceParameter` has been added to provide a way to explicitly choose a buildslave for a given build. * default.css now wraps preformatted text by default. * Slaves can now be paused through the web status. * The latent buildslave support is less buggy, thanks to :bb:pull:`646`. * The ``treeStableTimer`` for ``AnyBranchScheduler`` now maintains separate timers for separate branches, codebases, projects, and repositories. * :bb:step:`SVN` has a new option `preferLastChangedRev=True` to use the last changed revision for ``got_revision`` * The build request DB connector method :py:meth:`~buildbot.db.buildrequests.BuildRequestsConnectorComponent.getBuildRequests` can now filter by branch and repository. * A new :bb:step:`SetProperty` step has been added in ``buildbot.steps.master`` which can set a property directly without accessing the slave. * The new :bb:step:`LogRenderable` step logs Python objects, which can contain renderables, to the logfile. This is helpful for debugging property values during a build. * 'buildbot try' now has an additional :option:`--property` option to set properties. Unlike the existing :option:`--properties` option, this new option supports setting only a single property and therefore allows commas to be included in the property name and value. * The ``Git`` step has a new ``config`` option, which accepts a dict of git configuration options to pass to the low-level git commands. See :bb:step:`Git` for details. * In :bb:step:`ShellCommand` ShellCommand now validates its arguments during config and will identify any invalid arguments before a build is started. * The list of force schedulers in the web UI is now sorted by name. * OpenStack-based Latent Buildslave support was added. See :bb:pull:`666`. * Master-side support for P4 is available, and provides a great deal more flexibility than the old slave-side step. See :bb:pull:`596`. * Master-side support for Repo is available. The step parameters changed to camelCase. ``repo_downloads``, and ``manifest_override_url`` properties are no longer hardcoded, but instead consult as default values via renderables. Renderable are used in favor of callables for ``syncAllBranches`` and ``updateTarball``. * Builder configurations can now include a ``description``, which will appear in the web UI to help humans figure out what the builder does. * GNUAutoconf and other pre-defined factories now work correctly (:bb:bug:`2402`) Deprecations, Removals, and Non-Compatible Changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * The ``split_file`` function for :bb:chsrc:`SVNPoller` may now return a dictionary instead of a tuple. This allows it to add extra information about a change (such as ``project`` or ``repository``). * The ``workdir`` build property has been renamed to ``builddir``. This change accurately reflects its content; the term "workdir" means something different. ``workdir`` is currently still supported for backwards compatability, but will be removed eventually. * The ``Blocker`` step has been removed. * Several polling ChangeSources are now documented to take a ``pollInterval`` argument, instead of ``pollinterval``. The old name is still supported. * StatusReceivers' checkConfig method should no longer take an `errors` parameter. It should indicate errors by calling :py:func:`~buildbot.config.error`. * Build steps now require that their name be a string. Previously, they would accept anything, but not behave appropriately. * The web status no longer displays a potentially misleading message, indicating whether the build can be rebuilt exactly. * The ``SetProperty`` step in ``buildbot.steps.shell`` has been renamed to :bb:step:`SetPropertyFromCommand`. * The EC2 and libvirt latent slaves have been moved to ``buildbot.buildslave.ec2`` and ``buildbot.buildslave.libirt`` respectively. * Pre v0.8.7 versions of buildbot supported passing keyword arguments to ``buildbot.process.BuildFactory.addStep``, but this was dropped. Support was added again, while still being deprecated, to ease transition. Changes for Developers ~~~~~~~~~~~~~~~~~~~~~~ * Added an optional build start callback to ``buildbot.status.status_gerrit.GerritStatusPush`` * An optional ``startCB`` callback to :bb:status:`GerritStatusPush` can be used to send a message back to the committer. See the linked documentation for details. * bb:sched:`ChoiceStringParameter` has a new method ``getChoices`` that can be used to generate content dynamically for Force scheduler forms. Slave ----- Features ~~~~~~~~ * The fix for Twisted bug #5079 is now applied on the slave side, too. This fixes a perspective broker memory leak in older versions of Twisted. This fix was added on the master in Buildbot-0.8.4 (see :bb:bug:`1958`). * The ``--nodaemon`` option to ``buildslave start`` now correctly prevents the slave from forking before running. Deprecations, Removals, and Non-Compatible Changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Details ------- For a more detailed description of the changes made in this version, see the git log itself:: git log v0.8.7..v0.8.8 Older Versions -------------- Release notes for older versions of Buildbot are available in the :bb:src:`master/docs/relnotes/` directory of the source tree. Newer versions are also available here: .. toctree:: :maxdepth: 1 0.8.7 0.8.6 buildbot-0.8.8/docs/tutorial/000077500000000000000000000000001222546025000161245ustar00rootroot00000000000000buildbot-0.8.8/docs/tutorial/_images/000077500000000000000000000000001222546025000175305ustar00rootroot00000000000000buildbot-0.8.8/docs/tutorial/_images/force-build.png000066400000000000000000001754771222546025000224560ustar00rootroot00000000000000PNG  IHDR5gAMA asRGB cHRMz&u0`:pQ< pHYs   vpAg51IDATx^gtcɕ և;̚yo{[joJJURImKdf{{O < $ azʪ}}zފ'NDC{G3%ݜV~\3k]ܯdh z7jgMs&O;410/Rs4,ys2,/bn\~aIFN [qq]q//'Yދ ^n~%3rq'1&QN^\x3~ѿpM]ǧ Ӧ;e̛n]4u#<]9c7 XY8yحyQ *Р4@an"Fhg۞)TGSRnX~<_=U3g6VMTwށrouolNg{WacWNdv ɭIL[%SM<_Y؄=kLNBG Ů9tFI3ӇPCЧMWc}zg3ljѬt\K"'xqbfh;d՜Iٸ% .bLܹ3)Сc6r-#5{.WG.j\TA u~Q;Rf|il]6W0/3KYqEsغ;eX[E%aheZH3ct"F!Je֏v/q+U[Y:W1;sTuck)OV4b%AMJ:SZ^VvSW*M}Ɗ:_R:a#'wҺ>|#9oZi{e&f)3T: <.45 թݛ-wHS>R'Kب3ce4Nf h(ѥwчX!t >wOkrx K׆[eFGȸ΍Ԯʹ^U~ߪdtt'7@-+8hJfFbX`4HS9:pV][u<){5wh;_12#j^~5ѻ=hSwG3kyc3&V^n'9c5 ՜9 ⛖o菳GZXʹaKhMt >>S=S:SнɾUgUᅰwk-o~`@]:S:Gw݁q]qDmM 4N~%ez=o-[1=ͬ)׷wΩ,NfQ*ΕAj ,(*<'fu1HvZ cs{fLUJZZfsn#YV?!o gFQKvw7OE+8CXR}Fr:Q33pO&w`_ل`T@cs ~^8i9U'IzWEe˴"ii^qLtv=>s5,U*9*mz*@Ƕ֌gyømfTѤNt&Nt4dұzh:T T\Z"mXwg~T/OF<󙎨WS {w`+{Psu<-*RMX޲ʘ29N&/<]hLPRf^i@F\lpe,ErnСvƔ|Jo;H/Նn}fir->#v<˂t'gerMCݭR7UNJf;7:Ϝn)Ž + k_Q4>]JZ}3[1>^D|hμ{Hk>Q/ݪZUDg2*$N磼YJzGN-V8;KPiŅD1цYo=oog]Grz6o,]dv1/*&ƍQ%}`Y[T#BJ1Vкr(`8]cD0ݒa3Het)im_Qw૩>MZFj췿iP [fDڒFYFlR4MGU ']ҤqIjdlR uZUxJcZx}̭&&"Jlvo$JRnN[dSږ#hQKHc!^oL1MR̝{͘YjL3@j~\/ꔴ*nrqP8m? ؎*B*A^5AARTqj| $huh2g` Av|ޯ<8i]6}pt;c=c=GX/~u SۉUyRw)ݛd7D7*EV p]|^vNڳu^*OP@&q|{(zZު^FMT/,R{{oVwjWrF['Iڈc]V/Ï8Xެ?h\Pwj$q*q8r69fHwǴ1έjql:-UD.g^hCLp;2`(.RGܜGkd|qiz;QF7t(8mO 8a1 O|/9ܪ=IB K94D7FՈjVr&19}gܮ6waj!N5iaF!AZuL&w*RC|F\"ϐҫN!>tqLCgCSX1@6#_[3{{/"n#.7?!#OE#bc眬 hr#'4 OqEN,2 ~92Ap/}8r 4rV.7/H^H\D|Rq"Dr'/.|¿xɳ@$FhvI\}v>=/uy/?&Ev|0dA}X<lBg nCAe4>mдU2Kӆ&6_<\/e_++a_+^^!Vx F*];o_WKش#5疟/ 'I1ټsΓ]\vԣcB ]Ndbp|8dדf#\SD?c{ˣ>iOs'|R92}|~iYz1Կ#^' N>%Rsv.D8dH'm"G\'X}7n ܻ>xػ9l]ؿ5xt{p'ׇ<6txsÇ7 ^95|tw7ǎn]=5qoqn<=vz 6~zcu?7vvg4j$m8w'ϰ5E=8?y~wδ3jvoSii}qݟ=q>===s?s>sߟu=s=u?uEϻ32C7f'>Åsy#'z~~OϏƀ 'o+u1847bi8J2=K!Le{REO'qѝ~b,W˕r3qxy4tKFS'ϣ&ᑞ<0?ģ=7=s'z5{8g<x͢{0<އ{ޜ/}=d=^=ZE?` L#&cfm'>+ ưB1ǬPb%,&;x1eSxǰql*KP p1ISxdȧxؤQd.§R>( ̧t>,$~ *>IH%akjQ $&RETB;(QIJ1*&NPKYRP.QiOHe 6؛ LPie*CJ Ӥ3%TJ=*zt!-T dI)$fgX7~5 )N'/ :O]t^>sd5`RXΔE':iW(rb\IDAq&Ñg~& W}).@*[N=H?R#`JE|*}_<=P&W _n'yd)\5 WppLY1}d$cKU*g94t15~pe\ћy5 %+A"|>dgTTǥ ǗK0`qs;B%ղ%ԭiT~rTB*' 2i\*FrϏP5dA3h"HӬМ$kfyDT8hF A{veF2R搽lt}* mB\rW8,"Km^FKnJ^~2k5_x'2]$O5:^ Aw^R2$֏0k%/uuajIO<)ufۉ+'w#,xkoG.o|/ -JE'y?o_/6<,]N\;wkU%,^$)qe]oDe\i/mdm{vJ1.-=/Wz=67_هznƗk?=no >țw|璘r:z-37*_$bZ^>Hd:cOc \ 'O2/z3h+'¢W܎o~;cyNjV;ϿZ;t3bfiSy0-e__R1X EC$Ũ7c<έZ_?H糁7c+ne~Wؽ~Fbޠ?ejʪiŻFL*wS>C+Ē{\%5N?(\u/9;a^Ȓ/~4czq'oOPf|{ANlY' DT͈O}!{+ͬ=J+mUS/G= |3Ff~z;I}[VԚ}݇9>Mҧpܱh{=V4f`0`8͞.@R4r=<ל"SZpbaJkY$b1PO 87|y?Ȍ8LH'c&ɔ-GOLo<ĂR [ Hbp d0x-D^`(]i` : E$$ H!CEERB`RKDȖQ%MS#eiǰc6t=-u^R,ꍝ5râ߶u޼Ԟc; NNv[S2G Ј,:ʢNЬ!d\!X#,LB( `("y UHA"hNѻV8ġ yFyk/BU ]ktXNX\ר_Ɨ6ΜQi Ͽ{c~;\OjB=$+f|Hn/]2,Μԥz荔W=>մ.^69/K6R_Y*$g_<ՊQQVrzjw{~Vӗ~V x-FB΂ FRILr^s~x=v%+YoπTR޲M *?m,,\f0퟿_2|q)ވ8[)݈(I Ճgw+&St%zrY߸֫~x%&yGo^ms_rArnnGM[53'}ՏWX@gYq "껯,UxVfDݯ+7ZfrVbewK C>Bf⹷͜RwxoGftP1W]/ 8COBl+_^xcK^t?#: 9wBwHarz4wx*ѬBqIn" gܜ XjɆJZ>0D'޸yo,БPrɗ1A(o& D+%I\Pɗ%CaǤD")IR eD4P7`Q0@#Z"EDC$# #$TRBp2z" )! DsKW/Eӫ_\~/%;x||\\שפ}պ-_i7tJՎf[Ql`1mNչk,|fԬ cP Z!|Jd|h5D'LttF"2TBPNM"P(>r4&"f@:U } ā+b@ `CQoP"â g)Ƨbz`O{GY)KAV'OhRQ*P$7p bfTfTFIIIeXjFߥ0w1Wb 5}/-Ѭ\V5`.SA?;X@JS* .%+iwso`ƭPWnۦJľ 0z>AI&[^mD&|ћ1 ,O~t_}?o\\5n{f5oB x` ;;rSePfj:r;n)x8#:؝OC…dBq ZB}hq! R"ڠ6ue&U&DЕl*ޤ*oQڊ^O?ԮA "ˑ.}!DJe T&ժjւlg12Ȉ}0ؼEZUH{2׍@\6+_ c6U̠O ':Oj)r]j2wZ׼BBHMΒAkSLtwJ.\&`S>jXg'@11Ƣd&5YP'̓X,gX%٫jjX'wJ?z5x zQXEVN!]uL?bڰrAn%\E$HBSGN cpSDF!p{ 0܄V `D/^wd9k<`1&'R< ŮN ez܀Q2PX dS<ľ0P)TxZJfb@")ȅ O>)ќ`:Ǘ"\'J`?**!# :!XKayDq30Fl!4IcB fH̵u@Adn B-!SpǡexFBg9pwt/t~x+߽_޹\<<618r|44V7¨tpu.(i*[tf{*iD5T2Q˔] WmˈCUnQ12s³G1 L 2,,O&TXM,"_ct"5'vyhyJ蛿|.$hؼ8'I P CD@w" |GnC$_Ka tD  e%݋CTT5mpЦJEUbmWd@EVO$ƅH8"o\';u-RCMKC XyY{sGl>7s8ܻ8/?i?i{vb=>b#ΧqbQ147'ƭQ#Vl;ÖN& !"h1.8 C==#XЍ&0. ܠb&D>b#ˆJD0ȍHX  X/y9$'Aq>$(#6Ӹ4Ds) A%xP0Od|@0!u.2J4!^B3"2-WB\4I$ ²FBp6G "D4J@ I;Ci0ae6H4ΐ4-,>NTy0A  EE2RSY_8?o]ҷ?o~G߾#X٠۷,&0t:۶Ʈr{zNU7=kӘ,.Uiu`$}&m.DaM !ԃM2\ͭp^5h@p-. TVZEՐp:\WPVS(k}!FU:UDI-{>5ldOH. >-}-.G}a|mkxkg+}*ČEI_`Y$$vFK?x=XPIZ"G4h蠤7!"J}$6:(#'_ሷ9xJY(yd'}ѝ¡SK\O2ύ D+6}P Q*Nj Cor'OST @*,$*c?yϔ9L_"x i1p s!pHV; p2Jp/G6'8aP `x2'IKaӈDU&{O@+a %TDC$]  HDrF$TNI]㨿?QԗB[}~r?ԪԺm͎^j뎬&V\աujr=&ʊc{kWUrӺ̴!3otRAfuaA%5±,\ "A,vy+!(IU0͗;µ[Z @ B I5!"UT=KSGpJNWQ%!m* ^C*6Ugejm*-pX 3~q]bntV)3tN/uQo< 휆fN&fqQ'#'5]4 8e:c|8m.iӇu>}rQOWU~ls)izJjOSgd - Y0#//Pi vI4C ,y2L( g<ǗG3/KWH|x܏W4A J =`Z"2 )E0M 'E(S &$P8il0N-@`$\6)IP!I-U-/PG4h&:4PxjI$Q׼*ddѬd$`tTQj\֟_מ?n?Co}O{/~߼YNQYj~Kejm۶ξ<2kOlCآ:hMUl٨XUK$:٪rEZ Ӄ}k' HDt|˕aZ z"Y$#Њd4P*"`G==hЀ6:\&%|hA6ՈhIw'i1Q*jMGPJJCD`W1ỳ|q :uKycEk .<;>(%ĵ9KfZ9GTrѼs1N4cМasAsE^b=|[k0unkqK>M vһ Fw|kbbprJt肽\ /h/"]4C .g}H{,ӎOc/K#%1/mOio1C:sg!Z@I^ydi24MpFlDa)tg2#q#yJȀTl"ks¡yK)<¦TOL 9A|oߋDR7 \t!5OgOޓZg a"_6EJǼjP% f٢ , `&-LPFHp2UdA"1$QCR:F 4IDE=+b"DD QM $遞U#E G'H`/&+&:{]FlJP0,TMɳ{??כ͏k[è!jSRizUmj,۪u~}ͪ^7n[4{zκlw{3>< &WjVKf5ށAhFE$"ڇMD0"s08ҴM5oSM$24nLl4aYhи B/-0FiR _冿Qp#a/zB.7+C8h&DBE_Vm/yBOr%T0znnVu:*gsiuzluH:ܺn=z-` j=ѵȪ̪SKܴe)|sZ`쇹vi~O%:t]s:R01M52{/\:P~dvۇsèE7wCtH-Z8%8aC"_4wsmtTv`m5K;Mk}Jo50 w:}>O[<#/a\LFH=O㜥qSR43 %S?J9y.$/"M҅&VК(B(P TƐx`PIdЗ=> )ɟT2DYaR\-r!>({9 ġP$T B eDxDڴV"<"9lS"0$SMYoVZ{堛0_L6åT1"5CURC@4$9M&Р\NQA<鍚ꉵ11CaERF/ґp {ð"y@W<'߿/=Ƿ-ȫ_ٛ?{I L&Yi6ѺV5jdٴT qSaUڍc;V5Jy`7e"y#2R "<8 zm|in­Z]K訖p.ܮ -Wj%ee&<w)%va5 `iG*a&ئ [F 2S [US! VZ1T/:$]??ƚ<䕳j5X8vzjldJQV NImˮ[˙7N/$kk:8[=̚ XTё[-ܣFRGɹ5ߌ^Vws>˝z_Æq?r'TƖq4JWn~^zRa; 7ϯWZY*}{ɍskKAλS9 5Lh|dmaO^b۾ zדt%mcO9TF)G5^bث$6Q ʄq쳬 ;8 6 ΁aM+ 5q"~O9M:7?|xKr叿{O^xdDV%júĔ߾^}hT+zŊ^l$ڱYoWklJzuecyEyܵ-q`;@-^P@8) 0®V-Duv]SpɦІa15еylZ`UGíPաZȽj,a6"m hmۡg߽2*aPJQB VLqS,tURw;lTH;RNjnɮ}t/<$*57.5;(#j|5r?+1E/qflnUAQuE]s*߽z'+Wg~Ws/!+v|VZy[\zQF@`F7aOϫQ_ &VY ti}@7O*j|ZĒ~/'$; ; wR7 0"DS{'/b^l^R?Dv]S{՞q8#Spfvؗ=gs ~9IJ8gCel#GљsBpP/pDY<$/c;jZ% oЇ׭Ho`}9p(Kߛ'PaM/0CJoY<k@?o[( &i O,| 6!EM?I*;GĈ&VC`SJ()(z?/Qcy6'tMDPXw0,V @pJxk-} ͣ]=35Mc S¶7s՛Z8z e /\Joڏ|M?՗w⇷Ƿ"o~{_+?GސyʪE]Cmm-t+uZ&լg{nK)j9֖VvfXtTHvmEP0R nՅvLP xho:#p.}]Au1a+=FHw}&S{+gi/ō"agIcY1ܳCw1x=Co>|[eK^pXH>zJW1=?D5+VBKB^*\]6<~8mZ 5-U=삜Ҭq"7qWRK˛SZ :'SQ?8P1v+.OoƤfUVuZ(ʝG?=qeսn,` ,Ӻ=TlQSu[Y>$݈MPu ?zu !j#/lz?iz)a[駒;n=N)awAO,OqDCƑ_|ؘBwLE]:.OmT9Ev!uY>e. N2we r0,<ptE"8`H-`  5h"GdU _NU`6^<.p&J~+\\H6m4 [&gye6W<>89,] j&z;zXuͳU%KòJvMl8U3?߽?W??a}?ևkxR_~)reKBzͬZm꠳!6M@%XԪC755JXWRhG.=7*ՖЧJ"2h`AtJt3R/Bwߡ H>#mPi v!27r1RFjFk}$}zkT7Q.k&MԠ)cR93/?ί CU5HJ-τ狚|B.;.k˖oQ** QEqkg@G`SIwCye~GUj > {-*VLjyÌwF6Uv ft%7d4ꟽ^I7V%QI9-Qٕ]Ei3ZeY\@RS JzVfESZYS}FtAC 㫶W޻3+Y\i^*(*fHbl[ɬ|EB!Ou!! 6>B+}Fi֓MRF#0JL꩚1M1vyyYBЖϣD̞?s<+MpΡ9F:" P.\Ck%O\7by"TzsȻJSH4y >h|H*$/e(`ďW d:Th%6(JVe9~P)2VeJ:#rƆP"" ։!ETkLJ@o"~ i 7lI 7C%$Ө )N&eY`uM)1B.wU 2dkS6"L$ Es̆ڞجΘ{Ń=M]t-y z|n/+x'E E_ԇM N$P" I(%4I`,P<ɂPLU&Utl- %N$)\BȆHWJ$ M:#T}h*R$ `Vy7z0̋jVRx g RN٨@5 4fՂ5ۺu@$_)؜MͶk? Ç<ѕcfBQS'ԸC,{TgmԌ Ì]j~b)-ܗV3QVjL \8vH~sL-9hmJ=OS쪑%RjS*oꃓ*!TJ,ia K8kۇY<9%?֏MfX s,!9h͡'=REk_4Fz w,D2]1,ZC;ڃ,S(V1cd9m@rD-S!P]9'uyuO7&~֞hw$6A߃)vz5}TU%"tJ`"bЋ(`VSX ^&r"؋[,=E Kk"o$[,$4=TW,l^AMRb`S~__!2R&Z4A2(&j_,] "ˡ U*:RKp*)9vLU d*n"  `#%n[MǑꉪT4R*>xm^I˜qoS$ͻbXoHD 5Y)*rh1lprviKmq9t:d4Zƴ##srQQnn8Kã mmw;|߾x|{_~On7;[$ܝ;#UYޔ(7lS;Vodbrcߴk:+0)j(@Piml](Я#@Vޢ\;NN-S !d5S#&bpF!b`ل5rMo{æ4ʣ=*DSD"Oڣ>*=Sj(.>=9,F-#d=97'GT#Ø2dpb:Go _,A,DxB:r-U 4#/5*d͔4}|z-T&:'[&-K_*r dS*!,,eŴT-{JK}b(#)T -zR`ˀ-_2QI4!_̇؇Ve+0T. V%POkJl+Ppp*yJbʌ(&&&߅@$Hz@_g$i&,V /6# 7KjC 2 0U/caͩx}["Z.jwwF^_,fyI:}bݵZOݶpIX_a%lnmhz͛x/~x^OnQҽ?U?_/}/n|[s7ߺ^Q1&`RB&QVp@r]+WdJ (L[O!ZZ_ZRId玝֤Y8bڪju͠QidS9g“Pf θ% cD)kvjP G領v )aAq"TXUG{Pe] A/Gc.2IR˻gsfײiZ~V=**h9fiVz>AiX[$5t==isƂEGv1zWdg|!r#]ŰW?9+b֧5]O,>D$^Z@MㅌX6Wp2Z^#V_ C {WȜ/S!THat]%㩔x+JZ歒x+V|^V嫾Jr_g V~RGM*h}Y W4dSR$ծk$EVWoj7uPfZu$R#!B 5~6VItֈxDszA+<#HvX44 :`;k0E3p&4r.T J(,m5}L&aC s%N/kT=IZ+mk5[J1[k4;-ׯD7>wn|ʣٽCŸ|/}?=מ/G}Gz^ڏnoK,4C*˗%[~k])d; ئƛ7JX.TG&ù)\P&m&fr8CP!fm$[A:!DDӂ qH Pp/HK{t/,ݣiC%@bn{qiQ$a@FZMnOZb3ΠݦJ k=3~|I7LgxFޅ?p>٥AGHcI%q Bꟲ|2S/|:Zd̋$@ۧ?]I:҈]tt|r/M#(ybm/6it%\nv}FE Loiم%z CFڰCRdYoYFi}Zl/'51.}EmƵIm`\9O'M24@f(iЇiC!02gi'82I 3.6IO& 3&5(M\dB͑J JoicXX`3hCôP,+)͞%r[-VqbCTwffGQoPOrRk6n1'[LތYMf~E]&omʯhqK߈.$FJݭIU7+?-{^kw?)0%3d|PAtɇ+|D" ;;;:ҩJTTJjKnR,z݀SF)Po*tjeGmG6yBO=G쀒Q+G$mdd1vLQ#J~x8uL6ONҞ5pN(TŽcD6KPvJm`SXXsJ8.N39)ZF 6,X2|Ej:?K ?]I)$=e% #\C).ԙ2.ʡO`^Ğ#,"h~I:'w]FH佰ˑ,mBD $!4 k*bOe 1!TZaE6=MEF6aNE phlIV:R&hhfS ԥTMb}`J矺 ؜mH 7MU`!Դ 3͝N6m M³lXxn'r,af؄c&%شHEEF_'H iNoٻيa~avdjU SMOerkJӚB-nh4zr :m ζnk}aGY5W%oF]MT}5zb%Jl޼QLWK^YZIl;ދ.~66פ+fnn#2±S+++kJlsE:m^XSJWlfîxg۷Y4J|oGaPiIq%?ǀ 8< O*LSJ}Fm%uFiωM 6@US ;`p*.a=H&9 2[%в0H}Hk+B+}3k.l'5Zҡ7OYړz$ӧCE_ECEFvaMd7 uydF)%XPadӆO'H{ۧ"x_2bER\SJHyQńC+ڰIO|YD E:J P<;=>"0ɮUzFggKrʛ%T\K8Yѹ-rd7IJ$⣘wr bZ~Xs5Qn[Nyok,W*{&~cT Z,-8MImԁFlV<ۍ&@<7V%,3Ω-'P`CN=u mV Z@ *Z S+{!#]^wQgx(,}hSr'X&V,'φbEP4@ l^.i@6 1aL2ѻv,ch\44&-/_ BhO5'rDhѸJ,klz,R?=7<13<1}aß̅gͧ KOI mpf"HPFtSg7K OKf&gIOz&fF&gG&gF{:ZۇJg M = S#LGt-87ƟbHe+$bՂd*&v6+g9391?35>:25>69245<89407<8ZF kliGMCv566 \aXPٽ|>Ԝ2Ao(Ɔ99@A n9tp }a{ xҸe:L"\Yߔ7"&]Uek5o8zEG̪7Z`-ʹ;΄O)#cncEΓ~qHsdWhFvi &=Ҋs&]zyoHMġK[dtzIG[KJ*ynhLDnzmbD3bfbJRl6j~bruMs݃L@.(,2j럞aAթJqxx"$ܑd L. c~N*:f#chq'[ J\‚R|V#&\[\Sl[(ַH=>k(ao TbG:%T4L;2m^5uFR=:ja>d܉"!wk>5Zl38V+Ҫ@ QP}RÈцH$.1:ƘZb_Ϥs)~ϸl56B,p'$0PBRoouN7DQ0Ei PTvP‡]G6# "{cJߓhA/!>mxHwA3s]^zHq"zyN"pё>P&rQ]md^35ዻ Q$E٣3S ^A"úsϧAB<9y\>r-e4102-ljdWrաtl1aZt@kSlUV:9<.__E^{vr 87]('WT|NO]G'3tyx?>'v:.1~x<:q:#أ3 `0 ??~8BtUG Ba/}ɦNs8%ntm>ۧkVsfs}>fT(l0s]:[՝( '\ut칹5h#^e Mgi6qd+YiyohSՎ׍+5cɍ ma:źs֑\A+wFBuXQb㱶:+aChva`>ڠ\Ժۇ]HP'.YCwqyО{qF^.?bdzh uf yÔѾxz+{ `&ԟ` 'nGxt0rT:awbE{'NXfǓ5Hו!4yIKOpxsD@vpQ0NGqBr y |p{N߇ 8^ۋ -=:sǧV񀝣LJg8.jA ĹY{{GQ>=;?8:ΜN\?ºű)fWj]{b6fid1ĒJ ;z>Ƣ`t5<2SX1>8=>zttov3/OK_?zV>bXǡپ1žo=:{6Ҁ4F{w/ԃcձB~`?PÃ3x\.y~D=suv~~v*'Jy^ ̻i5?[Q23>5Q_Kի8\RqO^~>AKYB.hFL{t*k+/bUWtͫucEr?R] |$Z:z'YEqtI363˫ҵ)PN-@  RyEt旔+;Gg юm<^LNuC `Z ʪҲq,RrFnf^$y烫(qvJV^V~q6f8:qa/`3\kZϽHt bm~pLS2h??=:TToyUh/A^t|yQ9`s{<^8D5>8==PT̬P"ZSm(5-F@K|k&6_ȵX--zfdbzI>=dEsdhDž-U>u:=]X\ޣ3 %cv5uH(γ8<$}@.0 B~? /'Ӣ" z/.S7X`?T7srrNCa/mAa <~aaCLj"VY-%>FbU6 [puMnsdrn[;8:ZEYq~ygi$Xӏԥ=x(up҂.~FŠ=hcYI㽿ꃤo~ٙ]i91JuYPٝZ}7 /9uuU2BvLbJ^QLKg晟ܼ70'5w5dQ]d;ڻ,QzN~zv>Xw| N$um01ԀYy5Iٖ݃ܢ2U0dow$k`ѶWPZ 5XN]^C}K8qbک3:5=83Zr Ku bpc=6ʇɓLzɎ%UuaIyCs-clt mllrqHw _㥥eWWPQYT[ګ9ܡ?646WTLM%@hBpC"D1tztzD(Ha?$)yN=ǧ'Ț{2;QFέorkKi_*c3FlEB.9H"0k|rwLŊ / ی;ݝ]sbLiiYNAw[?񅌦,P۶w[E9dѦJ;19g;Ʒ҅&>s;`"d <Ron%|1b.щclȫ]]"]Nu qcwF .Amf"n&)iV/18񥊦~V7;nt5HL %BD$P "ڪhY*HGf*B~d~Y[[XJi^W6sփʲҲ䄸V~ybe_Ioc&󳯤 g4VjYBspsXq3,Τ&ɶ:\|o^w&0S`iOWLHUG+Sfw#.[7, ~!5uWlc=!V0W>0.1H*gF8B &9=_S׀1+" fyDgUTllnA"Y`jP]]}cSV0@Cã5u5`1B =$8Ĵ׋TBF~YCp d/30J)/X[oA=4N1هQPo Dօ5ݐG+ξaƺf}mtXֵp'?O}rߛ._H~-{؍%NV4)Xg ,lB =gƳZT "`gxeɺ$}.&#$ԡHhr:}$`ݡh]{˕?{ŷ_z/Rrg$7M*Ʀ>N5vIs97#i:j4jvo&|99J+i:6 0!9C^]:LEMJN1! b.nbҵupIt$rm{$+R9S5EC-!X0v@"eni`(/Î {*J "5LM k;M%)"^f9ĈO]AZl+ |AƥH``@.™tz ;\ThpڨCdsN_N(@:Cȷ*!$WAVQaR5#I4BPfX 8J-dv2'\J,e8DӸc,:!id&m HHa!P$AčC}XЀDLBZCA,` KWIZ _W8v$ˎۇp{yۅ2\'F.>b1)NؐifJtF獌ٽ+*FsK++ Pԛj_}\AF{|}5瓬g'5v$VbZ%tjV;)o&6Sn|I\Mo0غ[ME==<"& YL+nMX>Yɶg#(wPWm~V6l+6?@J;BE_[JLnٍAtGbEutci@!]9 o\c3 O(%"Ɋ #6DxC. RSXR*|8{pD6=s!H3?®6?6=L<꠽c#1#BL{ȃ~j]za6;vQB`ׅcߵ~j6n1-BafBlw^b6cQFC 6(mp0ٹAP`@hAimPM)Ycb6ņWɼ Pcbd:vhHoOpdrt%&l0Z0aDzc০ ݰl_vc@{ //*-8C655b =ŮQn8w|fi~[XݚS}^rLju[pi FU\K7ub:Ӟ}߅{ƳcӦmd։iZŦ RniqP^0@FmLjAѪ4Zr|h½`GJ4؋[aǕ[mxy(čd‚{TbAkZ;YQ6{u\K^INCXrqj(~vդ7nI̕[5-m=S3߸~j˕?hFR뫉\M8Vzg\~ok/&sA7Vѥ/+})c^.}1 a/138?50]Z%2Êb/D~h Z./ra2bħkqhGj. EorYl.ȶ"v"RH(,6ٻa9 ca,rPf.\D%D\DK$D X,I:z"c9O3sYH#&Yd陙ٹY&ékc#d1 DXffFabh92>Y\N k 35>1=Ɖ3::5Ajfg`"y?6>=/I߹9r 8əy\.mnv|l6s3kn69ˮY(hȮɩb4η-k;{Yrl~vӝ.I.Z\Qt^즟\8ewh|lbrb||nf hjꇵvtbq ;);ȮVҠQii33уM} sx=q&͜|"n49_Į9" gadL{Aޠ|aAȵn7uo<,ya;K__oe~'׳^*z=GeWWzl uտ[qrMbnSMsoQUgSGVY y%Ż?0p [bTɫ UڽFʺʮ.owf疶65"]';wa'RJ4b.w'F7x~Pʃ?IMwF Ÿ*~⢤iڣQP{ԟ Ō ;FT4^@W-̻(M[po][v jԻ^RRSܾ2,l[cUʪhU;ɒͯ2r޲zCiX௔ۻ7?+_;{g{{wws^m0` &(h D @hF9gr9"D#5ofHZէNU};Uut9w*^~ʰ1 /߲j,^iBV>#1o!S+MZt LFmw~٤+Wn߾e}Y3ۮg3f{G}sG_ۡ' فeC玙<7FX:o߻M 8jБÆ2e]OvME?;yog>Lgzu×̐aVL]G /{~[6{_6yvn\`ˊE,[pe ؁_?=?/O=*ݮ參~7wQ'^y_|ozC/_`w^鑗y[Η{^=].]׋~»:_tgy߽tݝqokP; Ro5hW{>zȲn/y;=`#fo޴k}r|ms'oc։ȍZ}U |E ,$?z\dLA䯠D B[@A[w_?YcbǕ>:w^xv~>ZرpӡE-rH`=욻v;&-2qq 6L\i}K7_q->qqs\}ힻjK,<v]uʭ*7]enƱ3t壧-.l'>qȊc.3u13xEag;`*,X1q7>?0f#' ;i셓/2ni O}Gք='9c<{SG% _k4oqSgv~l&/=y⥫Ϙ;z'/4e5aؙsۖ3g|%_V.Y>{Բ}xN7*/YrFuyuȱ GcYj-;No~~4nzi݇n٘y=L~Ёcgވ_{7cGJ|}Ow}Ov ]nb ;-K.>yÒYCs%|6߼?g[Ǎ[߾{-_?q]3?.h ~z?ί֯o|F k݂{QCzrڿ:vx+|<O}ï<3hظS]4|g~cy٘#6X[9ó_1c¸E'\p%ݵM_ Ϗ/~?˟?<~ev.W {;w.Se|%_tA:!.?ew")}gm;_N?O;^r?şşv};_}]iR.Uc}/h޲вVrgO ~coŎc/"U_q2+v}1˜¯*2yI˴#ߪ|X>VD]sHȨPȫrQۏ,vd gZвߒ-Xiߢ΅\ ,ظo=xgz]6잿v׼1k֙7]ejݢ,rӖO\zv9ݹl;'ߺ㱞7?鮃:&<~Mbwk7={C{h n~:=}w N7bEy{&<_z?ֿ?樂?w?6߿wp6¶;On7?]x?׿7?|kn89SΚ4ۣ157nw~.rx-w`kew>x|`%[V5lԴ^4|)O^>oZ圙{7ý:~x?-|du˃~mOMKtv'trS niw/j᧷uy om6v6M+x)_mt?[]|{oi]϶}wtϛ׭~~kKn緵s=';?NGv{feƎ׭[ac]_!TG!Ն_-_ʈG%iGG:ei3'S VEV@խ 9vm`DRW8VWs7dݤb5ׯ۸)¹buUr ~kV󪕫d6P=1W*<֪+ 7-'<ŮO~ z JnΛK+=UePBQм|/nY]YYI~K!ը%KJ+hUtS\bja+Ȋʕ+W\brl(qKU.{%zdܯ\u[]cނq`ⷼr2B؅r1`խ,…k{ۺm R^3ʱ|pfV\gRk7mT5TC~U[ \[~]r5piPa}W6em uXU`_* TEVIt)( zNVHqcw~^V_eU_ӑH$1V\=?dԹۧ'+7gV06)쇓N||r+G^ov#+z7"m %ť9("=_G{VיIeE_MyPӋ? ȗrB9ɓ%_*/c̰qo""sf V5VV/I>!#`aKt߽9{䉉ҫH4!>lա}gxmTL޲qä [5甊H9rxyf-] /Y][#!hB[a+2CBۼmR}dت =T@BÇ7wtFeӦ")?F9R8%2(U)u&@B"`qcFg7ogN2o3[%i/MWB `AXjuk,[]V:|M mUQ尨paRs|aSم(\1aɸ$bgϏ=Ʉ@ ԆH"E nU$ @F\UkVq!? W-Y `Faz2dȽ+\Խˎ;`k?wA4nŜO) @U$a[m//.yʲz-TTTأ[o޽;{`֧O;ΡC.]:rÇ 뮻ثh(FƨK.xJ̜9s 5I}49s& g" > @FQJk}'@SVVo|Vwqo[1*Hǝ:ut.h1cnԮ_|f޽}چAq" :sYGdϬڠb׮]YPk) T95hΟ~iiKD L,cDżyG#6,<  0pL)*FFU2&#F`e˖7|Ӿ}̛mϻ yl:vE~B[tMm`3,gjd+γ՛>۹y}6Yh-5GF%rɳm6߿"GyEW<(%_}ȧ_ '|Hub3QE;^~LUA{衇*YAHFI43K A{c+ÐB]opr6Z A!.%金gL1pR2*t-tiOR=\uhoxvL$3m= :W᭖PNJCC?ٓD}n~/I/@sbfARY!a<%[9i$ZI-GkN^P wXcCdz>="J7̸|'iK|Q35#|7Ǜ^\K4J"{cKjHօ,Ʋ#܈ÀjI~_aL/LsmlZʐ (m,\HXIpF;W +T .u経kZAsYbFYX+ţ?*G%'q~4ZwR2 W YkfFvUf #?8%šSHy hc(I!Z֐HSSY{@&#Y W55Ԕ C Ąs@/bʲ8:n,Bưd*UK7x 1@=xiX^S(,,z;ud^}?z.LpS@GI/]i [PRQ+]aa  O=D'YsEeHmphxA֘Wa8,BpJ̓NE x * nУjxD*-O`\]°gxEJ5~hJրE߲iMFJkbAIEirGn#ܕD$?gxO8?ERydp O#DFUb|iKܷ^4/ңaڡ0B4b=GxLBOP"-'{ m&/lofpGU#+kФ dVZC{ц{҆M(Q51TaI3)í,SHH1' %\sV<ɓq(sV RIK@ þVNnđR~Q";,LI#?0("x +wY8+mRlvF^7yE B&J]eGT  ʞLWTԷj^UbonLv6ȢCwKGI7*dB8F #&:_7_ m ";{G#RkG qSG Y9f3#2|#(Jb”0Y"9RYd!-\8*rSTr #YcFQy QY g+bDw@L ܼk+/m٪ij`UJ|TidJiJB|n|ܫN.=O045QyS8'!( V%)eRH$[w !h$j)2!Hlށ@By PbʾfЩC QNd O<)UB !pn(1[UL| z^u(Q8E&ҰU J,Xgbe9ӇgT-*g^ȠU׬NjX[̎,ޭo3z6 @Ұ  U8 ˰@ ~2b1nY4dT1ˈK/eAi%K(n_|lo) I1W\q4FڦpB !|([|f0d`,b/ޥg' jc}ʐmtXŸW*KÀ+;|;{UvGй;^O%J|O*yB !!PbAbЌ-0N/Ⱦ _/o&/Ž_=1?m8"e9f,ַ|7ƴ삂 ⵟ't퀥@b'Z %f+[#+Vo~,Ѱ=@B"PcKH's" &7;n H mDPMZ{变z7[OgL 7JH!PJn}V0`8j eԘt+!hFJD(+WdFO@Ty&!Pb-H$Vg`JH%[%S6 " L @)i輻`j:',E#U&SLmLem|Q^qYYT`:~$σW$ExV$ùb%)s1y`Rx5= =[Q,5R@)*BoK{xGo 537셮fGp\fŋ+/p_]yL$gW'.3L\|3-m$!?k/|%XE9JɋϽHUY(0$##: ТeC4l +Y)s.d/"Gf]vNn Ugabi(ghS5+~yx"f K2\@pVwZ| Fv( [+%bm;a„0qOre>5Hoqxdž ! llqrx00HGwq]wk¦G*eb=KtGx3j*)fUKeG.m>Q}Qc = ͏w͌iN ,!%ꢹ"BgV׿+)‡zI+XRVtpeԞ9q"dR% Nj- aPY; ,9  tbxd,yL؍e(uHEXRq[K- .Ϛ4.G)`^ xrk (?jJ Kuo31`ǟx[XM( [Q/ ^{Nsmj.o'w]@gDSho^#ii4Wc{3< 6Gg9pW_|PKmXPH FB^P0=m.G4Z3"^+5BqK؆u֍I 1STHMo:w,96Q*aFe3 HXPq`R$t]HSA^h mn[o'ʋ6Y(bm)~gө`VXc.|灒QGP6kMTڮ];e\HCx䙑]PRTϑ(婻@ ZQb@g Mxɂ ʚ -[쩧ҟ\]vuۉEd5Kr1`)^f@$ݻwHRb,Nk9X@'KOAy4BYk1z LIG U~.P]cRԠ#? =rĹΊI%WlwIM^4K]w([M]Sb1\RG=)@!Ĝ G˧##wTrP`$tâ+cC1Y ;bQK  xAgPɩҫECSJ 5/] +`F)[< 1p7 RU "t"ѐ&.!2Inȡa 85&c݈Ey0*2eP",kbSى0JR$٥Zrje"zޙ$!%ĤP|ͪ@mVEE*Je##ߨf(RejM6`" ER5OMXYg7Kq =.QdQP"'KOʭ2d)t`JVTpȇ͋Նum2ym*jӐ/T)d>Ӽ|ğR90Ȩ(g]x5D;|JjԜ.6xIDFHݪC@٪7zd$SLCdQ)ORGܫK_n*_9fwX/I^VJ93픷 Po>||fEv/p 0JV- ƫNQ-yFN u#J#LG@O7M$[$S] #꣏mݲy-eˎX, ր@aٷwϰ!F>F1lڎ:r[M(/[lgfϜeH+oƭd+ bsccGܼigce+kb%_6U+id2z=(s8GdaL J:<)=jcMҚLNVEhEu/ [wzaUWXH=6[Jv%UR/ ,̬٬2CeșQ+eeUg~lf°].%I\D6eԗlF͢B1a+ňƣWelгgk'(&J[ cZ;ٱQXۃݿ[b9y}ֹR%Jlu/|RЌ([`PoG@f zIV͋^ O-ط$cAUbk =cO6&6-![ ``3nv觅@*r)v81}1LV$$bC`ۭĶ$%nѦYNYTͲv[(|ܰ~@pZJԜ(1[Eճs&M(_fnjic&X9V-\vMvٜ^Tք@(=[U)X"CFH.8a-`׿$h 4?AlN}TMBێ|˗ܱjφ-}۶۷}=w/7wUdت)4T PbʦKPъt+ {6 .xj#i9&|XT@՚U+W\z ukV-Y`Y_&哏 TN8:Yon^T w;ҊN s6y†ׯY,_p܆`ty#U=&_Xot#Kb9{aY"zXUVTsɻs^Ti `7' g͜iv}k8o6>x[TweAW, UX;2_UL 7֑Z#I!X*褙wO ߥ|EY8dfJ UN$eO۽]uN?w3>{=7lU+p72W<놉_] ,2w–r 9:&0sx9eN 0nęzl !2KW- ٝ*%: /4:r+×]tޚ]l{f VsA(_ލ>|8?b7`@,U%#(lդrJΝ35K޾mǔتI0u#GٵsG [p~+-o];mDaoݯEhX^}\$uX٘ƌJec fˊlL_T@p4|ْ%Ᶎ>{TykM@ŋl@GOIm>PVlLt63R !hܾUSŐ'p:u¦EЦRDU@S'@Wײ*?=m$ 4Z>[)ie-j}\T (lURCJuj:2oEDë`uS%|6M>P[muj"NYL]u%uhC=qjOWZ$߱Ӕ(=[4@;as'8ɫ[%5o)i"PJ NYi N O6oT[jO4d@C#yY*㾦HZ[5SF%Al( I"C@VvP`c4lhyBXuX&E 5vlU&(jC&7MJVlH),lp#=_W ݰaM7ݤās 5, m2I0 f' E>Pd3$5L>Px[N(=[y?՟ ɌctplYe/'T`Bg %Oj74oKت 6T:(%[Ց͹}{&}/S DQm!kJ]HUhT'E |,TدB"c mP1YT@UfǕ͜>m)ӧ~ӦN9cz+Ia&RTÇmݲiQp5Wr mfaǁ}GBi"к*߉#-DmeQǎ9r*װ#1Ab&_hQĶ!;gƍ eho99O.f1dBbsш&[a T˗-5BH2 @U_Y}$AEl(/OɣewڵkȤ[NK4RU4~je93)1oUD1y&0:VEEҥKEF¬_~VkDfb]KZ6nkd6g+6Չw.n|)"PJʲfM3<3sLnmĈ8sȰFfTTF,=uM6q:ё#G r%b0(kĄڷoGU؉߽k䒽!Zd##[U~C[:l"cT$]:d䥪#mg+ I˗.^^ 9gmtNؿ*B}&<~TGLjCQ*c:kq7&3DyT 2lPX}ihshu/!ٷ"|ǃ'iZ0mx[g@UV SDL>+a>ImH,/NUMԸjR`[:g% uO>ѷeL_牭멖Yɫ קqz_p,r"I+D ©SSyVCg)՚*GLW<(zk&tC"g[3+=[V?5qD4 wš)7cj:2"uF3\*̜[JW_}uPX תŗ*J"sudcjk~gcJl+g%#l0y|\ yjg`B1Jl=Vg 2c W}2[o1qh:mڴsn۶-;Y_23@N /a0Ґ뮻βx]>'n46:b섣ds `eKbQh\4=NS?8gi2)p(Ol,TB豏]2`'||[Q:ؾ ||>e r1X1L~ـn mTM1Y2Βw߭O\h7<2WY g GyD͡a+g/A%*z|_[U8-y0>b1gW5؏=ٍ󠯪%#[eU;[1QY)~ yUn0}%&Fy!%Px0$\8f)7TZagsyYc˺5_UlWeVaRjAOlcCw=O>)[3Z–fBg٪ƲzxfEIKˬaԘZ}eIqd @7,{wxUaY0_ฺӶ2t{N(lp=nm5* UqeimZ9@ݿo+3 7vˌ}ͨ-VIɓ&fJ2m쥯[1nڸ9:Ùk={vW' o3Z =s./ 0 sV/\i%7bƶ?gX-7[ǎ}hS?:v - ޜ^ǟ;wh ~Ln=ZTA;fT`q#1FΟ7wΝԝQyΰ:>ۀ4s5ZeMFjL<͞(_Ͳ'7L}fO9`7^Xo+a5S,{l'gs3eכwvު_[U/eV<<1N^I>phiCQ,N:vVw,`N%ɳ^+PibmBmOk]7|m!\u@E(*!p; Zʟ*V$8 X쩉LV&oZjcRpV݆,4KCWbhN93s푡0™x}LV6VP_pŪ9F/Չ>a#ǁXm Iϲ<$*FVHluW >oUyl{i +<.C"nELJ"q6⫫VcFo<Ŵ#ҳЫ-j~T ٚ厣+qiCm/̙ʎ1&xm֗Fn4;d1V‘E:"]7Lh J2Z$q8N,@7xp6"@'S {4s&Yۻ?[=yX>J8v f|4RLB !мhڳ^?P|!l' !0Tv7 ln-{=bv2iV\ip%<([gLتy h ҷ#3qfOl+[ovZuݻ;l&L@@\fMv^7r~4uTN"l.!A|sYAj=[SdB9"Hluo23Myy7pÌ3x /U XlD+ @"J}4?WAXtR*h3Us|)S5"l2plo1bĦMpt.2رc={%PY/Q#_n<> ~|WL, *ӑH4wf@Gc=xAq'} v{Uʇ B[=l߾͛/[套of5 =zNݶu ڛo(;-Yh.7_VHt}{+&#!h4[3gEǭ=t?B^pu`?fO>v şn_G,VH[uKL܍?sɭ"HO#IK=9UsISU0*&9z=@L:N'E!-q֑e' ,=ZP3 M)[#!h -?yiS&Ot/ߺySJ @iVVgۻ{mfom:e)'-Z. @SC* ¹!ʓHVW*!HԆ@bڐI @B Uz4 @m$ H4-[5JHԆ@wlH$DR1T>lU\LB !h(d H;مʦvLGB !Da'clXly1rܹy i1hTJVmڴ~iӦqƵh" ʼnFطo_}窆-t= |dۊ+xԊyh~>-'T}:t|%.C p_eeeM;W;¡_/ć7eЁ۷яdj6lߗB$GTK/TF=!/YqW56ƙ9r~} 5cԨQ'N4Ѩ|@Fŷl2f̘gyF8WHOAPW{w* ϴ,&M^{m@;=lԗ2)OԈ@)*OqihH[z񔶤hkEL2کS''`~u#Pؽ{w]_;[n\x x (YzRvܙBy\ڧTĸ뮻#\o߾3Do#)Z}衇TУԶmРj*"wJ[y"9c[&|aÇc^jqgeǏQ{mKy/Ɨb'@J3F~ęRYFXO3f #!(%[_imXflX#V?\D M4T4? 8~z! /h:zjz rDshPڵ&a{D)/+܄_o75o,)SEG xI#Ww\ At14SqG8H .X^KU_u{DsU-$rDOVy]W9h@^PdUN/G@ZM S_|ɄZ+j 䥋dIK[܄A!@ ˜KoKN1)uo(Rb k+tJ@S=f#!(%[EU5oCu֩11# o~]+2v8gV*>Vy3pm"^ U:/P dKņY;|.^h4 M(bQm21唗!>5_ .MytܥDSwREoBѓ|Fz"яsW?%PSD׬( DB|(/;θKC䫛FBW5QMeX;+Tt|u*XVsB#PJfm53l6Wcj7,H< 0EHԛ0,2ۢIEcLߘBV(ozcLSfy|ȓig0#Mָ@vfLu2eH"hrZv#ᆊ l @%!,L~.g.͗}7#t9/PP(oY.J⃦0桤joe*^B:dLkiMjx^kc&}:XC# eS0xǗ2`]vZpa,;2oݬfp4lYp@Q CE An[.ͩh! Trw -&})b#YIK%]4cI77D8n*2V}ȋ} *>EB|Tl_ݺ+@*DLd:TJVZpYЄ@C( Iۢh\sM,z1 7ܠ壾>E:u4lu4u]!Y*;y^FmZk%GCAK2@FTw}bXj9B,3ʦÅ=kttt/܊`+Iha (f[/GIb4dC*޼⋣7!Ilt`JV~,x#G*=&-%FQ}Y%Uw^$׹ _z)U c.i~Ns=Ih=}z@-ѓBndSc)qNCT)٩ZtPzIN^*ѧS}3ƀ(IaNb4 Jgu[Vt!@zF1LTՂ[iZ PJSj`m:׆RTz)ٴBmS|Be (lk7$A")ȟ) D84d#]:|Y'n2K@d-:C(Y(a6oJbá*F9!"hx7E!POX7!8{lJUH$!!)@B$jXI4!8$:৬  ت`%ф@B"N$@bD s@bs~:!hVM$!!)@B$jXI4!8$:৬  ت`%ф@B"N$@}פeN7b`¯q2& F@ ȱ _1hGLcޮcwt'jԓʄ@B[WgĴwL5hқ&0s#ʧ%j8)EB !(֣G̙1O޿{vnٷkS&Ol(' !p KO8;xի'O M FAV Vuʔɓ&M?|5W~>C]qV% ݻvضkvn6A_B !h  wЀ~~VX_rc'%tEXtdate:create2011-03-13T22:59:24-07:00̳%tEXtdate:modify2011-03-13T22:59:24-07:00tIENDB`buildbot-0.8.8/docs/tutorial/_images/index.png000066400000000000000000002312771222546025000213610ustar00rootroot00000000000000PNG  IHDR82gAMA asRGB cHRMz&u0`:pQ< pHYs   vpAg8v2IDATx^wtcuY~~ߚ֬Y3%r-yJƒZRխV[+W*s9LY̙ HD" @$r}Ͻ${>sϹ؝ ]s/.G`6父#Kdn.s']}Cc3srda˷Y7;:ӶZo4ls"73'Y\?ql, Y'eՃcE5{WTf+{v0T݄Q1U.,%= -%Ҫƶ̼#\ɚ׵cQ(FXS(ۺF'+w#/3lξ*DaMJOʻmRڛzb`/-2`>2jWpNaT,8s&yم!o(-?<(scuMO0wb=;6a&?[:0К_W\1<>ވOIZ`aNЇ&*F$VWRX3_%nV7)q%Thf#:Scb|+ ;ĥs(QHb 095l IPh.tIApҡ *ˇʆp03 ƙ63ChFk W:9!2MU؇ЧhX13>;X8yQsf¸ptgFhㅝiɇ>JDžCwŬcKq ;f-enJ"5{C_(F 4p0ֳ?w8;=s9pu$ 0w>1= gC̝Dyq/>)q_\&h̖ b(Ώd6rR1#;G8c|C[=N[x*QG``V~V^Ņ1!9ZE䤿\ uD9\.^f!p2a=sҠc_Ux*TgT͜9C8Zŀ(q0~=f4+3\dC &6GBșNgF@ ;zyyDocv '#*-gxn'>Nwޑ|V1[r_tK/n/:_3y^|T.M.p6i2=2+%K扣=}ٽoNZ~tr*ÊJA'aᩛ>n{P6; z5:iT5A6\7{]`ٱ<-eWR]}J$vTKX[#Q((D./ Ӓ4;oy֋|a1?wuO/H&A%\bvfJĶveѰlP2c hP<߿t%idD_6ff;7by*tTO hh#>\; n$>Y-@uL03B0Cո3.- d>d>:6/ldw&0i6 A/U0rIVaqv4 >=KB_g?N?7ؙў>x}fbg}}ь9^\]>S\z:.<_ŵ_Ss&8ǟe]h&5,M-K 8i+Y0U5s4sȄG[8h0Y=I7Gk`oC&*(wA]^Y^W?911obR^,[/P2[7#1cfatiTgˊ i~]v.M.YLlm_[X{U m2'yS:ANBtJf97lH|^/h #+V.Eϴ*S .uLC%F֥7gjVS:Tm&f!Wu,AuNIӧ2{4cFGZђ)\ŀ|RK~գʼnһ+hdl̄O9 AaBV6C˯dt c?X[7r+FukvhFdӳQ@j!O0&@ZF?i=T153:fw>Eϗ+OD'ZIWqVv靌Rl*.?,o|qW%fNՔnyJT|*J=o!iIrؒPaR?aS9r@jHiP/^_sj5R&XVž 44.S51g8wjw%ccEs~mnXA\-8ť@do/)߹7Zؿޮ(=@zCu;c z4rDe<PSOގ_k}.S2[y߭w?W">C{h޽[]jAfAiLݴXo+>PIf*A @55|S* ֱ]#|X;yŨ&֪R_TS$5[ꭇ5܁FEhUIX[4۲ZEvQ #S鵷9-+'Fq+{uucNWn᪰}~PL#WO# Uеnִl}ǴgEe~Yq5P?sheˆDn2zuouerMʙG5JQl"U*|7,K"nC$.Ƶ!߶"UN!H,*kIϥ>U2^?sf?oNVprito~TiaS@a{c}+ubzQRc:(g] (~텇<) @7lD:@FYZzBrd_}IʌE$=(#Ev|UvqV2Zu3|^(%̧cJ2N=Ã4z!O8_)ݫ^j֍G紐eKHuOa #$v=i9oM٘L7mec^D|z:ۇb-C/q_yzv1?E~'4wx_A.SJ$0no?w"=c]J|8 V.zT,;;d? ,m@Ag Fצ5#׉0q DFR\| اڤ}'ZwRrb5gG6-g,ݯY& LKPF6(TݯGdIsq+a5 ` ܩ GkeJ ݮƴ,20"EVͣ1AĘhAĶ,D13NS*(I0̇isj Ol_Tq^T7)@З#'v%FY0s9`&È.0'%oĵ.'=]__%_)W9 V%9U#JF0 pN/34Qrr6 T,0֭eDZsK$m5[d=AӦ7<1G[%@oڹN xLhNfa 32ElLfztdz:12X'~v>hsab8\,=sK ]cDf <ɓP%s-_|y~f ?3} Ύml,2YlZ&g3zt6(@4@H@(@n%_QUs.]/_/ponPB'b߿7' _7_x4gT?{ ;h4tR|v(rfm+g3>EG2,c/B;[*9RTܺ~+y8j$}39\kkKHzbѬ#R2 j0uu9vS9z9zpBCًُ    g~˽ZYYݾӳE-H/]^H΍tyҵskz'0v|Ե Az(-wѽ{QXPڽI']O:ftZ.+hI.޵{4z'O´:jՏ|k#z:nv޽սw{FQPf'D?ݽg=7zn@9{xfэ[G!}G7G7o?|sѭ(Ƿo8zk OqΠ5lzwӻçNoz KP[ÇwFmF PFmlaQQ{؈=lƲF"Xpqqqg8`|lǣ 'l3bh!vE;L3rlg+b I)I)IZ8ǰ:P}4匚t>rFr.<8#'QP*( s]dI#є!q<ڣ8h4-Sr4{LqEM;yHm}:\*-<|8GIxSDy$3L >",HG@Rn4Cn{v\_ơH y>mw;xbы>t.o2>m`Drӡ`t1/5"yӡ(GS1<17ͣb!RSq|*D'( %8PЅT"*I=nHBWTJ2K%xTE RTLg, *IB%RPTzTnpv9&qJAIL3!G9*ybL!sTREZI Tq\.T+x&n]ݝs;{Y",xزeTʕSq SI\G"1b؏^,LvHҴ=~>Hx0g&Mp(m֟8MI'OfBO:Lݙs!fL\<$N9o"C&](P9ti*Ύ =U8ux-~ =B ;7y I8cѓV"8)㉓(I41L'3Y ѣG@Q8aCD=?tph?|8|(F?t6|9'T9~4?rrwݡ{Cp" :?p6pt-9!-9;||o^$ n»ä b[ç#'00xGV`&נy`필"löÎkus^(΂{fYq0 z0N\ql7`hp`9};IO$ۃPC(ڣ)I"=$|4h~,r5 pA1t v@Xt `?,{FZ,cDxa0^ &Ѣ`L0N[AcgPL A!.n&D ;‡3:$aLfC Aq0^[( B Pl(IЊ$ORdI(IJf\&9~A}.2:H\((6JRM`, A}-FW{h0_~ړEr*s.}A>,{Az{ά/5z/a'bTZ.oJR0F8e.?*8q4wʜ2ːe-ڤK  SSƕ‘Di3.3g1m%mʨo!zq ˜ iZ=nف!k)2G?>8 =oei=w 쾞hh/b-ISH' nڝѓ{ap19 k!pC苎dt(d[ mUۢqo vD@>ℎ>10"z4t=$$rG7 <"1 "؄Gg`CI wp7VS>xXP,P&?9s|Q^xUc.@(^#n0T:A#;(gD,`.^JX!a00C`E HXzLxDa ldhqk@@A{ sqnWp!B."` H (IIp h7 4aD*q[1 =P:|%kK0{Q;=&㋥˨EJ,kU+W o=X7~V?S5Oy\SJ򴩃edCrx}[S0c-效7o ,fho>J|Rn K'^fuqo[H[)ڎӥs?)J㞾ٝ8uNݧ,~ qk_yɺ'OޫOmFTK$ he?5 p #JhPQ~ EXw!ͻP'WEVT MnϜA6YL&[Wk'/HmYbLr~y#杈o|wv36gXCg4u_ʯ'Fw^xG=yN~gW Z'~kɝsT)~QDRfدRT¥4@7(׷%~]2›Ŷ/ek Of^Ì; ƒ/O^Q^X͜laЛ}pH侕o䵼.AӨI+ "'y+;MqO#;{~3z/"FB!+u15c/]? <'ɬcdv/q;T2/yx;I?oK_QXD?(2b 0 0"٨1d͈s1y0&a̲U$m %G%A2@!Rl$!vI`Cj(w$xID,'<&L@)hXG\oL#x<4&S 7.#8p BA $:ё1%x Pq1""`$"d T̓;c͒2$`D={.Z~G7Nvvl۞U_<2#I?:t@(r0. ]Jz<4!Ї[D#1ҜkHAJ1lxF 3w A`AP"GLE6(H.)lPR(~!3{*@<,/a/YS1|Pw35ks=[DvY<FtGqϯDtw Oyېƻ۞6ֲA%p>[wR^.Kn {UX;_{A{Zp(.n.D̴ XM?Շ܍o5l;w3T$ S OXgM0mLt,ֽѕr9XVCؤעyދ/հ;)EsGim[ӧ cPWs;.Rd+! 1nzCUN!XX ,]#RKdˉe/0w1Yr=F\HO'PP<)WԄ/H9p8"{ P\o4_ E@x/GPP.q j7$sL$SA %7Q ~nAL.MI & TIHJFSѭEHa0l˔饍)ȼ^jҮK ZZ1[m~kSeXwoSغnqyAp b3бU:C"ЇXG0n :  ! #BJLJttЅJD!``CӇAar)Q"D2OT(GB:`)lk0ڧBxhHgk٦^ OndR_;r+nbA Ϙ3.LHhGHGW -97³w~kwS+j?}/"PlO"h=,$t~/E$NڨJjcNWgs&OuooGx&?7u/zqKܯe>I"6~}ܧW?Wțһ/သJBes \Z'QofA>r1?Ci~ƣ SX[ϿqcɕfSӟL]+x?A,!w"s'b:^zv  WUD6n +s]˨rUiIŶ~秿LM8n}\&ზ~G.<֍1OtDַ4wNo?k|[9$# l.mٶ٤ !V09n#Ͱ\&"fHucP3|eQ5,^¹; &QD]"a&Lyc!xOTp!48('jt&Q^ Ň8.H (h":!i ĉE 7DHB$$EHxJ$")$&E$qt6J؍J[|5",:kAүp4 vvʸ(oiw j^kЩU fy٠l&ng˴q`1ۏ,c = OOOU !I@LDv] &4]CBE @ (r@< qD( s.4r=]&Jr({CTr()r-f%TJSmxXko^lXwQF(HUUMQTtyO"6 +d j]eXf`7ܫ<W:+a~Ƶ19MK)P O1P H$  l":Ķ6 DN:!v@f8'[l@(Rmr=@#l!)0 `łJ\7T,t'vrӞ8'~ mFRB &_ ߓ@&< arLdf@1ADA$&3$Kt7Yq 0dT!tFhB!R# :1 r!7^#b_k>?߯\UljVi׬WH+J~tCP 5h6mގcpQS׮پ媖dP(dH>"qHV`"~teD|"Ҙ8G94&-"qvT> )W8@ GXsF.`!e#d3Y@1Z!a[!<*X )4^W)(yT*Ut|e(MA }rUZ@ݬ~Q7nť>*`>&pP-j)Kn0ya55*%հFU/ey挷vR1E_d*a%qӪ?{QNHDtBwj(S, ΤۣPrr%V'0*~zՍr~X.4TJ0S.eԵҩJš']1A٥! FnՠaT3GoԐb%P᜷ W޼?GN{f<+T*y>+oϕa9oWRR_l [`#Y]}?2q[FeXp,Q5Tώ])3iBoUXQ KaDO{N_2+a`*UCB~oAUq'ALGc?5e$sCic#`h:b99 .rGM!\wB?uEIqQiX>P叅ģ&7Z!2M`@/ыq1&I$M vnSR)6%HD$ b ެDi J 4t IcCA&R/?W~>o^yQ|N홵{F^kmlm65zCLmdtZ۞zl`V#rhА|>\]]$!"(T@b1XA!VB)lCX<*UJ,S+B)sU8e*eJ׭,[z¡ȖTT:ժ*T#X \41&ʖ%դUT؝غ)eAgΈT7z5Qj {H{h.P(gtA:RF1´$1U_ŌG;D0=07;4tMp.dnrazܴ=Q&V-c x 0Jd%@.!WBB@DQ<)%U3{wCha|͇ Ų_{)O_D|wn7 wnE {&d-$ :Rulz˖iײcПMρa1Kf2A@q#9) xC4B8$c3RWf!6LЁ$:"`BF+@ &DVC*iaAQj2TJa %U"KUFЖP*T *SQ*|v|JyjfUB~!UI/?LF/h}[OA>:V͒s&\4r01{QmG"U-d:iM\!!B9 XHM}Tɏ_/}?*(W(jAc2ַ&𴋀P5m{G[#ҡ^;4LJI; "Yw,"С @֐w@@ S( 5QJ>!TKB!H9U UQkT-PH5Uj"qVRb_XTT X|AB)Il9 /}<£ßn*cPxϟ0\gc/gx?PèEWzhtߋ&L/'Uڂ*`a{3;X aÖxOxm~gp{m[A-8JRE` y.$ho l'P#mt9H /H..` 1BD^:7^H̊K^'E żfW 0& DD7NG̓|Ɉ$$"XK $|BT?o\Sph&=Y) 7!DnDAd'9` $8MpAad,zAN v,3 ,12 "% PEDd,Te$sZ"Gaen~?Ǜ_k׿x9I<'U+M`#*4o ]qKj,}zoCn\t˞C@ʩt)Nh?ha"2xIJ% *CȨ`QE!vi*Z U ?E\!:=4w*a$z)t*WPzd=T%T:(FWDuZB8__^m(!;-P"w֌=aш9oH*дDtyIih\ؙnt0͞uYκ:rov68݋e1y>lϯbKn?@.V5-|--/|~vz3_BБV4&vMjL"}BGVu$r0$Di#F1[4^9.ш6֓ /0.4z#J0`T[s.&QǛO|ވ:4k4S_ S$$>)!4| L_^~P)K%%LuT1PH ̒P& )Ci3Tq0M̞ "#84?+p-]L0el:C `[4&#I\Cً!0z'Q"y Y!`da)$$IɃX˖Xx_gՏX~}'wo<s}eY^j6+K ă;f^i8<69,#IhYWlm,%ӊ̴*R.eMc!Z  ,\*C-PB5c4}B*(4AT*@'THP30BcѬz!G wW#B5jCJJ) -UE7B#cZ5UGFI*lh+w H d4rvϱ j~Ih#!5BZ:9&~n4B_>˧Nw>[~B{1OAg/nѧ{wG3;(FLF]kp{8V"IAhfę-~J,k@91NbyG* vҁWx=8$!HO|gR+'`D (QJ;d#n8B"d ;QGuq!g)gx"2OPI`R?}$]##J:x$!` O,Rpf gDRKb8TKpB@O:Qu--UǠN&1lЀtA#3"CQXɒZ|եxYgЕ)re"g|0]D5Sry nT+_-Pz7D`gRXn 3xxx' Qr+3w77W_;mzNbѻ@.4`Zrt7zĻ!Ά Ә-3C ;,|ٹ g]>kBQ[*n щÔz',Weښ4m(^FJm ^/O>MxSİH*A$Te*gIx *.3$/Ki*99D5Hsg_c7 Q/!/!HD'( A!$8cՌEr>!21Q*jՄt8DɮרQ]sg֌+{ƙ?ŵGؕ4.))[7>Qy$ʝL9n1Ѐ-CMPjֆ~z%"etaR~|5Z18w5bKg)Vԕ-t`KN9 - wKGJ'T@_YSR;~}#!A\Abn\XzMzUi1+;ђ]obXxiO?}o/\='r7w,#bn]AOW0z\̄QPC x @1"AXjף8#އ%@ɷgDl|k. GG*#M@KX $3$5 IaJw:3NTHRT'sƝ1NEIn#8P@7SLˬ7Kɚix&1b:8JL E $\xRԗ=Ơ̆uJ\R޺/ $8EkhpaLu[ rӫ9]iM#r1YuqBϴ[mh⒖QVTje䃤'EK.l(nz_xREd$>LYt ~? ._h3eL3$T.3W,g#0^}V?gAB"5J/JJ(`|&nu/o.( "[ƌ-s=S2LҀ*E"ĮLȬNPg]O3n(lȌ'SHQ&2Ph-;,OͅH  fK};K|\/wΛ3˙,yȅ ^[ uȂdaAM,$-IG{B" " Pep T **YD/WcJP (PدT‹UVjT t5OӔidxJs#!T<8˭jll**o(f%kb FV$vnRC_鍿~=vϟ?ꗿїsOɟ??on?~» "lV[oiV{zjOfT.mCtE$R͙T]~k]XJ7V ܼp֠Xrw&FOw(! # Ah<5P4lRO7MD)D1IB4S-ʓQA/LT1g Tﵸ~=t6 Ew0T?џ JN[࠵H`(?"! sR9խUةfy(J MjX隮il,h(.,hjWO ᱩS厉w>77}=h|zͫ7VewǍcoߌ̪hz[/}cbkۧGu)tLߠx:ky\gHﳃоҳxRKxzf4xVU{Υ v&@l/m6Ȗ;ٙ!UGbO"~';t#K$ ё"+{֕-vgϺC,;k$UY U{31G^0<<`R8MH)w(e ދ*OE&)EDKKPlDV`HE<#R%BHrt(^Z UCh'$ՂM4iPƅ:xBOAdwzvVXZ[W)ܺzcN*./+2J)rX"(-n,y;",YQg}_p/~v?o]ʷww|7_?ɭ?ɍ?2W45V–ӆdrnfy6w{˳sj\kBXZY_uniV@`D?D 醅S/t ^C`L[(PАݢX!NhB,{5˄+WbMO3/Pi uBO?X6T.չPL&ZEWɼlU:,_pW.z E'9WɜCa_W7;ařMiM u 159CJU>(zCV^R\?(V=eMsĂٵI J+U= zQMʬ n_%猪BM#6#$DciŞLwX г0p"A"di]VQ|0_@l/&:ʼT@&J'v8sE rgݹw8EΖtB<.u"@rwbiBKF;񜭝],}Z9'gTKdr۳uZ^_В]BK|tyG/ Cɳl>"4}n#@l/EX9fgt^0 ZQP@3!G F4 $|`] H |"I}X Dq9 JW 4G)X㾂9E_[E2?#T(X/ZWZ$nTtAĝ`H3Y܍m@Xb/sH%gU,q:f8D): 8h"HER7~ԃ*w2IDU8+< y[(,xxU^$ 'Mŋp|%(% tJRe@)-pPOK%xRTSeˁ@j@*ߊ(_ҮDU;B%:0i&!B= z`PxQM^GI^P^hb|Tbq^,,O K{&ֆ`k'85^zd91Ov̛En}˞O,{fX,hkklzѿw_t?o_o_or?!W7*Z|5 -uRU &Ҳ.W$d޺c8X V%ڢ\,.T6Vhd+4ElQf"Ci5FMR!:#'@jr74K\'.` "R< KNf(~R kj^[md4B:ha|([-jHs{-KZ ]/og֧&lD(?2MRٗ*]Jz@ *cAltar˖fNLάabdn}?3}Ehdڟq!`1 FZH 4dY }fu>fCʓ MW.u9p~H]eR'esRL!xY:RLJi弧)# S/iS BtW,GP%r_hqC)Wi (@W!o|Slu*+%?)+Urr%P ` ! wHG~U-Ar~'LHXG&$(0Լj=RROPIkaP'*:ŒZ&[VW {p&.k9=#j@0[67U˪eZӬ%ӳF׾'W*ʚ߉ҷ\/Kڃ>w?~?|;|_}w_{TΙF'DUɂI7dkm`S$wr j _(KTDtg+pbX[DJob5Nh< $4f}JSst)' aArtv/#B'-)ь#`9pd.G)sޡV, CD^x~vEE3fl' gۧ)B& >=mG{?}t<ԥ*cgڟ'|g;?5{ig}UzzDgUz!LetpXh˘6*HM Z]uF:l"~I[^")v=Cj;!P]7q|? zld?{ǰm>:>?C:߿ctT7úʡ RFpPOäF=!0BQC`A$2 5I542FP:Sb(b^6Qcfjd)lߢB&j0&¡-Cq(>6'ǵ'=rs+o}jq{{hg LP]N\n6fKKK{EӖqsǸi17zqˠ7-Mp`PGk6U5~7>)_u#ҏcJ=.z~/og~ʃ"%O KTQL{aXO/m J[]]U+Uzަ~s}uCb1 uʬlX+&VXYWlhNΉe8|V8pHɎ!do~L8cjtB)Z9)O(1AD}J)OCj+O!F$:!]2֬hOaWO+PN' '!L2wDIChd O3a ȅ  ¡s-3PxyFg1& 󙠋\]&7<\Ygtڈȡϕ 1 " P.3* amxh?3)98?%#I {k]DG )0- 2+aSGa"ޑ (v e@`F=I ?cHl0`"R!0?J6fe f`|3@ ce τe$FTF*& A1C1A3984'P%W!2''R]n@j/mO֭^7-ᵼ9vQi\F5V &4޵[׭FiC9?]T[_VT޻ <*uX[Jo%JSvD\{\xaއ *#R+FTވ-~a #jV狖dFicM.W)d:eS_[ϋ%*b}6vuP ZTe[&޶po?hTʥcjM!P xRP@hiePc$r\h4Q|}6IBN)))xD^ݘ6jFilԆ=d@w E$ɩ0ev\&\@ ʦ%Dȅ*S^nsF7f#ؙ4 -/)όb`/쌟;y~"#OwED!((.qfBZ6):~N!RZ΅>J,xErfs$h٢υkˈ(#1*H1DgBij`>y~tbnMMa"  6i 4@3Z0:Bj?/$8$_!x.S\>Dq!w1YyD#UZ95nAI?~4|vfx|ztxglY5ZdQ9!Z,h5Fjsq /L+z޴c;9[![~dZ,=FuYcO:'+jr>)Sq%a $T]*}Aᝄ۱e*ed5F6=jTy%jTԢёQTb)РTe͒umD(:3;7;'eԌЛ Fx-xF\G>8/ rph{)`k^XiQJ)08Dkh(T pabD;Y@1|Apq/8.0Nm:(28> L2]^&bvKӊ ω1H##&/(F r!grfb0JLJ.l&ܤLl$󋌞 b55s>- &G'h##?636w>LA^8N4 },P蒑sK?OT1^(gmИn֦uїi9OeQ=1$FtsqLoIgWCSkMSWaXFPU;/vpjYO</X[wf{X8Ğͮn[F΀WT*k`^:`FY'mO:6574UB*k[kZG+kk`kh!hIV56wsd zzz{{GdžqgGX#]ӂIC&Ycq${tdA,?;@L!N JžWaqKvca۱v8J!Ӝ~WbMݞoe3u.=md[^_XUߊŽdr+6] Q;%f m!3q3 Y< I,<>& 4U"ր`?~&Ԇ{TP@t٦o\Vtu+M :~DˇW,'Vv78 ,̹.$}˖KmƊČ|pt\ ɤ ŜlFR kZJSj>3UhFQ2ʥg4k QƂA.OT/DirrV'{.:6mT?W;fXC(:m-zv0-q\au~iCy荟i\n,drOKUj}tl {],I[?sk)7 ]үo6<~}pre͈кbYF Օ)>~DO,MOMMޖ k]q{ɸĿqXZ^)Et^.e>r(2b䣸 GfH g]d \U՚u5^^WT*;}P4jP4ؓAvFc5oXLzMpQ⇌֎ ng;;ԓa:oxf4"XU|Sm{綨-jf3$2Dz̆[uPmkO7H Xc\ovjCw`kNon?cr]\fjۨ¦q<Qz=jB/&4)@~JL/!{>|#aZ^T-jb&\T:m'LaIK>lЌ_]ÛJvB%qǰknY'`s{oM•((2(t;2?_|-x) L cGʅ6 (ʋkcLƋ{C7xv9MfzCc ..'@]S9~ Q94onocIVVV F6~ X:VGV΁gFKsY9oA-Rn, {dxh|v]=^u6X9L-YF\mjfLY3R7lwuh&ݰO+׷mry^AQD~s/0.9s nOA3Si7O%@ 9J[?;nj5t6LKtT%i^qsDO]uqF2f }~0DYfMBo: >C6J ckvA\tCxPzD rxv vtT@ E+4c€mE5쫴4;CEtw8}!zbu}AjxtTisޠ 0:yy|v7fwOGԁ[%mh%H:;JEvp;\˃:N "ѣS<.+iu]v$8<^M]vm[{Gowfw[G'6݉Fvnm'&QCܬҶ_Um-mfb nCiw].XC~vno384_?E,f6 s<|fӭY?l2#!wm90[ͤ<0 y{w{@ie{вԃ}՚-K:щfs9nt:@JPF[x^6Áw\^;X텭[1#զ?p!vwfjt|J`sh;84o?ymO*r6H\6tm/=wlw <ntK,}хc+WW;.Q$E SRj9) 3&qjlj8뉸[Z=;O}Ւ Xb}3[!Њ#]'c<1z"_h MhH1i8jO\qqT{>91b)̲eSd>Ǧ<ȵe3~3L/t1fL l/ы>F;/~a(ca2s Bʼ{` Z MKJ+">;-<8S?+.78<>;o@pc?=:+s J?H$|q D >NilJzGFs S2e*Qh7[2ҲrT;J I Bh&RU((tA)P,ō7GŔ^ (UN@x|>ۃC)Pnf\tCIW>8<:ޱ`4`89x]>ɩPLNLO  *ҨZW.JպgoN7unCY `Ș˙[ӂ)8Oy/=k/k[?ffC'>|Ybs"X XN6͖=<0:VS.3 kz6}^s8xqW]S hZE!x74'&sskzX">?њLmpAe5<`ue nc U3{(QoIV7ڒNܢ z O.ݏд *Ym XS|$XUϝ#O bOL)U-HcpFbڇc+ån8 G, }VfU mSģʤBH,QA;IL Y%kyw^FszQSFiSD~oGeWRwb kjR/SǕUy2 vq‚kh,I0, =YĆٰbݼ*KJKK3b6 $6͔t{-F_uDv[%x';/:AW+ [;dL/["KULmPq;vIDAT'c-f56~PKlDDDɢ];8N:G}2=\;%u^lI,ԘD R,NJ쐼Q8=<-ocMC}\~T1O~ztRws*VYfVxu_&XdʺΆ蘜VJ˽#kt|2(0Mry0{|G1 v O g7KOk}ЦA݅eUpp[Ii]ãlx~{`ox _5jkZ*jC[mUjYA 12:.QpvAtx)QeKyEeh_R._]Gɕ-3=gBљ0rf3 BZD>N5 O/ @& =,,`!";6) p }n&`;V;HNԁ ;ͩat9>ӾqH2}v+嵺mýU;`߫}?w>7~tOnLxxZZUOp$vpxV_4#"03bɺ?8>>>yaX.)rx]V?ܯ{trV۩^ͺ4Gzr,{sBYc}@PХP)qU Gol +KΫҔj]SU]k5Ou &Œy{USwpx8nT,/y:-Psfd;0>HI|3 GRr#Rpҝ G3Q {lurijbxMWrl^i/!g$ܖ{O%f$V=u':%&\fM[>HTxcf=+j*K*l $_>G?̚,IgpF%|"TG'5 M[useD8ҎCUu_ie$Oҵ=D҅@,ERUא[Xj4g|BJڂ|,JgijV=#;'ZcqkGemU5B GY7_A(/#^"RENABɋ`A$U3"I %"O3EA BB|$ofr[mT@j؆*YL3h<ySTD0 nټm<̌D@T:_".pDB>8e`mISx >2!nWg X;سx֓ݭ.lskf" ^˾VAX@~Wo[!Tmk6Lc p+N\f gf' f`)״#Z$1v-d) C"aAK xJ~FC J#17HțNЉa}=bmHSD"[ZVk Zގ{r/2,ĺ6G/t rgᅲ7 ('&k[7t*ٰ!^œKsL);<Ƿ² ʣ>Jn4 ,f5 cKD?"mF>%j+meX;pɐ.! 't2ɠ# !Jʎ9 < 28 XMsWdu N6gj;]TUn4ꚟ<,/)4K=˾W]bDP|`~R'3]-QSyw Kқ[{H=Keb{Jg0÷Ʒ rS\>{;95 ǑP,–`tH¯yrDvmn  Bx! ݄_Iv^ӖG ϿZ^~2"/ o/OHj8O,y#&4t *Ժ'f&9%-sյilZQk^#+| lvFY}o6ak`O k,F>O$^ƻW̹_ /[E{S3\Oi+-gWMq#J&[3xgqgʞJ[gZqIfS~q">!Õ'Y'# o{wLt{ -;>l7 5{$}՞KvCj|v WqDLڵ df~AQP,Ğ,>EYhS%(=mXa[* ă-PxA,gI6n2ڲr {+X+#&C$"@?#x=Y?>Av@Ⲓ俎X,Ƴ.x Uw{̖}W'6 l =<»€ވxD:%6@Īl7k:/Y-{ XqMIμ3OZ;qЊ40 i5%H* ;zyv,DgОЍICtHrU `GG/HO+Ѵ"bp~2HzpWD"X_@).Wv0`0Z,{Sl¯)1Fs #0Ӎ|ٱ $<=qL|o7S C#Gb{xg=΁aG֮͛ 5ɑn{# sw6e,HZt!~]x^U3RjȾ`lAf]÷L]|x5#^!,?\'Q!99B'BL H¬@ڽ"#ހs#R>L-B:vNVA0#|0 :Iމ I31BdX;>99Դ;'l0[E, ZݚP4]PR\P=šը79EJimZRjlކpԡ<<>SpdYwp@lD{e9/@#;fv*X i{!rXV D5!ǖ#dwNOv, ڰw0F쏳 +HMLpYvG.,>cKN <}X/=Jo~'N|YUӎ2bbfnUghjUdԧ'q5zS5Iխ-}3:Ony'鍨c&]Mn$㔖ck4$wԴ P]MO z PXL9gt G!6UHKs!/ Ϫ-`m;>q/@ŒCբҺªz(Ymmy/^tĢ>5RŅ(hnxl\4;,,"7R/+* ;3 qdC;1ڱ-)^ycttG6D%m~!h  e1`DLul!Bd%C~x'pA''X 82e`;~r2M0Ñ8Ș)9=?<ƒ%-DX" ]Qp.,kl1 %؝w*>Nw|]Od6VqbT@i Zw-ZЂH~Oa> tx;s8DCbS>_kh\7rfZx}Qa'ֶeħddy72o'X@J6$VbQYeM= oe=\afS1PBQ]`&\%dK` )IK\$PFg2w mxp!}{Fwnmmff(⿃ܔ=xN G Dy岌ۃ]FuWv=}|#sNDÓξ7R?Hj0OZ$F|{גZ5HnHR6RؗTЖΣ襰ÊxP7|͈'xh\ q;{Y*{;Z{zkնR>c UFRWCx(QGIGF2zwsֆxىvw(&B?̳?}?;٬h;hm;H$8:ŝ|iϙ]2B'##\34;azb ƁOr2Nqt =T\/)p&LLN#>w*&QŽI:ɞ`MLLp Cl| bS%\r.*0:Aw1qi( QF94<6<].ScccC##cl{gbc }|b||LNYS|ϯɩ̪hlϪ`UD #lAtNºA>;h4˱ewRcs[~q;Ξ.5fw>}9mݭ]X&u=A 8|(ww]H˓njyIVJ2ƫ]A5962# F18=fp|8k\$ ANBYQ,ܧ(:<FJ~Q~eXa9JyNS_惂7#ߍ){'誷bkވy3'mmGJA^_W^7vEqu< MfvT&d5bDn铴–†ڧ-].}X%ʃjO -xB.y3z}|6'qtqȔY -LNjefaJ홅4Qʅ\x6Nџ ֐$9mЖsMZx7 }㰙73{yy{vŀ.+O5,7a٫^6||˴9˦ZnQKn73i3fa5skfYv͓sZ>?iWƏu^/2bӼ5?(\}ĵ-j=z$_v ^wꂁ_7WQyknϛ;|]н'>açLrQ[r%[\/|O3_xX[=dks#.!ggg~e_I/䴯/Y__|[\|qY__)_Yu >S/rԅ\r5\pI]tY.! jE/E͛vWٰqo |6+m~~yW7~'])C{\{i褫+•?=x>'\ W+;CO?v{s.q=}<ʯ}7.˾w|~3CGyטAꚁ q^0kppt^p05Ͽou=o SG,D7z'|cAmR0C+/+_ g}sMp?qEƲ^_kK?+yc铯-eO~.E.}oxe,zΩ]œ5]ܼuO_ԥ/0uc߲~cfBVg~rg*Q3Mnyx sW=1 cf.;s匥[e\~ڢf/]7a֒;ǁEM7brI7sw? GN;dĔ;GN0lB;9F_gi Ι}sW:>4bȰ1w 7m+d&̸nOx ξnEO<3+Gw=w=5c `Ys{uӀ#6v֌Kg͚{ψ)̺o;`ފu/mX˝s¢sлszΚ1q ^Dҫw{GM?gѬY&MԳW?69s7#nK_/\:.e=f =ˇgCru=W3jo9kw?/v[O覽kG܁q'ylƯ3 ׏G76P]-_<>{G!>ovw?_;:uǾt>w'?_}fہw1g>K t ^.ű'qV+}E/.?ҳN?{MX2k1z);l؋ϯ{tY 7t鬉'G[ZflXlz?6? :om|+;}~ul.?:~sEs|Ͻy^1!qWy=򽏻;^1~ԥ8ݏ;d.Α+z\zS\x7r.uEW>wΥ\rM^vSoqnwwȼ W.[6=O翧EdnW5C1V5:Q׳owh֫{mEח?g+~}S{giMz˼#'ҫK //Ɂ/|䥅(<¢G_Z }a-|.|kSV?9{ӗ<:{96^VrL[:vJ04^947IGO]0rʼ&qG3vS'O9n>FrlZ&O?aʬy{F<0zf CO|}L̜9o)'+{GzoՌESz&=3xY'uᕧ{EWL3^kø9/WuGp :ß?sG?{>w'z'v'|'r'zħv;S/W}vnySΚ4bc6jȭ#g7ۍ/?ICgsw?:/|:KCv?ovGԯz78kͣ~s8ovn#W=+!|v;ov>g.<+=9a|38yѱ\|>_ؓ./{GM~tʳ.C=K,Xp!*..R;I7|QnF6ȑԅhY |"5K -_3ey-=wn\Gx+ehY2܌9g<>niG=onIeT{y8{֜9&OI_yٳ?ggEnT1QYϝelP4E [%ʩlo¦sHQjK.XhE WgΙT<8CyXalm9,\q Ӳ Θ>[)K8jY8kiSO|`,̼O&̚;fbU2AΜ>{ڴS1LS&MGg9.7O4m&M6n-lN6iZ!isyfjUYڷȗ͸'0e' u?6aRn夔y,[8e.7'5uOwuҔ8S&O:eӦL!sf̝x.X0d/'kڤ$:S!D4~'M%&=()Syn R3ϘK;W䯤m""6snzۼtdȻұVrԓWhn=SBB%54( -V<R'deߵԬ[Lz ;ڤ6jeۥ)o7`hK4]QmJݲ4ѽYuI5]nI ]KrSۍ.}e]K]Cizr `HJXXMx&WV9:K+;mnTR}lQf!BD'&PiWoklٌJ_E%rBzfj Sh:#9)DL6mۢo`5]&}{|z+Il*<*ɳ$zI"C95N6)CIdSFfsCn 97$:--jCsd=88y{9oPl\u*yG*q򶣽1S:=ʵ4F-+'UYXχWɱM'5Rә 1d96B7L*x`PT@4TDt:w&My((q]w:cx;J:WSqա!+isYE~,96U yE^E);5b>}cF<%;{c5eѯBNe3ܴko&IiMIȐx WNbLԆ$+ZJ$6R0M68‹.ڰ:ze\D #yqACFWώ>O=eK{5k%-pN:˯pq{ŗw$YpsifLu61&5|}SN+H'xSO;M`7tH.ƍ_|"F߻z+ipzM7qGo9jA~hkTKA*Lok{yȕ YO:aÆiʕAOΝ;n2x׬0i$gM8L Un^8䓇zy=ӈc(,!)߹5uQ4\~)A~tٮ!^zY~_ܸѷaeGA(ƲenEY ǞO4Yp@ۥpÍ^%;;i΀; 2=FsWP~O8ͼO\c߲ea ruY@7ohb5{#*{57bVcuE^54.3ϼ[mz]I'|^äa Ζ:h8cP/Wք=N_cO0`&=ZտSLc;8O<z{7fz_ۛ:;vL:ta-&OoA+ GKO;ZH^*R#Õ+"SOmSu%aF)޻ӫW/ay%n1ۻ{X;T}ק޿4{7g?+6ۤ[У&nHmY!{;d<mw=}/>}R- s3O=T: Dos 7yacԀ bCJү_O<驧kEqcS# MwB}G[Wj|?pv.Ʈgy6fxP{ PD<2bH`c>LVz7g8">rh06=4|yD_\somva191>J%RB#eW,'`7zw%rϞWx"!~=N?kYh .хue%5Qs4O5low.#nFoe,Yqk!:<&{t}fDfO\FO<$-9ko}#4ɍS}5p=*!W_}1t饗2VX٧o?Ge.BZVb8PB j=9^V?%K0p բc00lp>|!CF ilᰏoY4'.]F߃Π 0hM'q2{/+C)'̻ F6RbAMd.w~ /=gjW^zh5cu*UVO~m[ú1Y]Agra)|' ɧd"MkS7kM觖0܄HMŒA=|E Ù>f88Xb 3t4 N|֧Ou:ltAz_As6 WZvͨZHf20t񚓦իtP5Qd`Cʉ߹EA|^1 Z`zTR먲5JmK>*G& v=T024x0IC,rjۨ2LҺf^bD$y(/DFX-(y7ŭ!U_\)"0hieRF3{~;veS|f^]}ԕڒx.JŌ`"(>7Mjf`*v,rTVU C< OA"^KJP* j(G3S~]*;x0t>0dȯl7%:%?׫& $w%NR&AXeѴ.:c!I; `&Mc eP+"\D?QO)r^6Е9W.u:S',JceҐ߃< ?塛ӇX̘UiP{=j&E|I Au!dx;0j}XC2:5|If͠xG>˔#o[oIy.;__foD\ lv|#n NqQQψv~k333Wr+W*UGfr䛓T3k0x ҈%|Wۛn65jc.i@nQ(F)>5ۛ*L4%id b$ԜXl`)$4gNR3&'[Ş\MHҨېUlQ&FQ,*Mϵ%qt*Cs|* 8JRYWLh%7]Six{KQ)L6E*[i3 F2DE;Ւ\5IƤ8=F}j.(%)!'f](vҰ:@$Bn3%LtnL 0r!e%(}x?FLk9WV 6fG;jis\v]6yx[Yuކ\e涶zprjtۍVnByƿ툔J b[--`Ndp%/jNsA*c4+y#5,&${_><|+Eel(PӅiTwHǒI0J|ْ= XmUŒI1_'k}op#fiARFє`X8m^߰t"qlɢ+Ռ/d `/RڊI2?s6ɹl^iLÔk7&S[#b䤮ɣ>ڻjQѾmemꅮG.$Gg1>w/]•UU%9~:Gi`l@"^`DGpKSMt^o%z?$fb-K׶SمqfkM[޶XogrHnˡ!LZ^jM [DZֶ C&zXD/:@"{3PoO s`=~YIM8.;M턫jMUZc)Y-Z9{S-kz(D_z饨40msz󖷖7?0=s\+F'.Qu{s^?NPJVm)iHzr\Yhf; h <1w8iNw\iB,"}qҮ6/^>}jUK2%6kȌ37xjrUgUVuxH'7i7tSTG:EXSB75Ku2$\WZ$vUivZA:$sϝ|w=Pozj{<ƓW9 -g%:=t+(21EMJ`~!>g%ΖOla>&Lr$_*#gs&+]xmuf~+>G342T yH#FF{)ˏ#()l>Oq0Oa} qo/9+e8RQ[(<h Oղ`o% (sTR4[aOsCΜHE`)&e&R4|\ve~5^}楇A494^̕R1RSJjS2 Ay˵螴ѵ)_ܥ?/ĦT]'?ɟ'z2>ʻ:zȉiP g,z>IN @F?M3b 1&T!.dm| ד5#٢Ƞ` " ?.ҿ:eTIND?l޼) "z)||j)S*c=F7%VQ0@%/;'3"0#׭!rIIַ$|$SN9xI79Nȁ!/74}}{p|3>݇[ $1c~$ $ɺ|-Hff> {^0l\EI6nNMNMLLPN#ol̉vv~dv3g6#ll!f#oHy%L2e< &&Hh@UU;-K?&BI58S?=u & #F k/%Tq{qΑ9Źzgr%w"y>7r׿u^{qpTǫ"<:L,$ ez_H{e{h~f+"^{ !Ϲ>]Ol4 ?>ZdeΏ \p'> 9sO4m4n68{'0n~'˯q n\x?O?3& lesˣ;pV /S]TcP3flŽC 28Sΐd*)7׭]'G< p$G2&=A>:B\Tk w3gdᇿChŀar er\riajk>GڼĊ,R `D3:Κ!d+T#Xr80/0 Z;hO{p:jYKC xc`UW^구<3UBsbzlY˺lEQKq>=w $?yՌHp }y 17J; .Ό,8P#p rɅc#F_%Bb_bJƇ?aX%_83 pΔ駟Nc0l7ew}k*q 1bpVI2`9塍94i!]{5"ci:3>O0 L2 ]N  Cd|[:=llGZCjWU!?f\1,7OD1zha~o*ja ϛ5k֐-+D`WgժUKJ|rg6WY6~zB=!0MYFɎҀ2xBK)3 \u . /6S[8i4c* 5Ba<+>A#>8ǢhN|>яT--z)'ĕ oc$fQ4G<B) ={&,,'Ա#,+Jk׮e)+ŇAha"A(G>Vvxz$,,/Nb(Lu(?`kd4xՊ/~GZyUH5eѢE?wmr.+!;yÍX&c֘d@^3S1d[ ZVRkQsBb٢nC/|ZK(b!.F*]-_YU˴ǧi lOx]WQ7l{Dޙ秱n"5j-_?(B٬)(#JaHCA'4̐!H^cdZÚ<%VUj\e xڭhf1Ę=[7h Jti?j2eI6ٮut *l q3MԒѤ8'\b82?~=L -QK]f2Jk$|SiU:o 7|K_:yL+qy z[k^,AS]C\,EjEZB@xS q|I1y_~i&AtЁDgQLVH4L,X't) >XW?) ugf~W(`XqYFZ~5W]O! s~Qj*,\0Ɋ\:*[n#FS-/B5@'O<ڛ;b1W!:ܜY,o42W(Hcj>hb`=f4 ;VOK-}M7}9nH ml!f:sm?7FR0&,akƬٳ\ve0nreoX/'&jwpJӔea[a ax{cǎs5~ NaհTYxhb2ZdC#Z&XlbJMy . L}kO=\1>D>ؚ}#gc='&>0e&5SNnMqmoW6nhlXݯs]vjpl`b6{ʩ/;64 9 B&?ē7.Sxhmx qְ B\CRZ@٢T>rcE>.gm1$O&*>w<7k.uqp!'DہݦSBVs.6q[tPs>(ev/[3nkX fIjէcGߧ%ft& !ɢo6H _fc'C\ ae3>fvl%GU`-_S{`9^5^R ^7e:au s5UU=/95^܉i٭*V蝢v; XĚ, XImj>I2ԆZQFpa_cEgs` SR4elMŽFE KŤJ6?ZRŽօ9i Rta4T-((vH5XqW-VkSb\[ʎ7E'^:g/UF2G~KQ2m>$‘D'jmz-Q<̙[E'qdL4U2IEf-Ͳx[UߨI<ֹ R,fEѠ\7˴ooNh^ fԄШ725%͟jIgsc53֐*jx VFD!SȬ3?mS5Ss޶k$hdVҧ!Ə֌+519\12lEv&F[1nbTⅽ/g=ILPJIG~n}r1ȣ:ţc !ɋz nJ(G|I&}MxFy"]S UDӀjJ*me'2TZ3g [4Ј!TTU5!AG]Qx>""D-XuVD*ZUA:'+iNUUvv;&@J b/%xJf+^ۖ 8Q#nM\ԽX W#U)ͽ5\J)bz|sgΧ%n1*@ F@ZN)TviAl> oghW$ zc83j9ꦖ=2 /O6}o3}t'܀OE2y R ȶn:Ao \|rS1y't(7F%mAk`tDXP D3VCoF(ņf䧮._JǾTݵ1d㏃urMu+֦&jsܚ/~D0Pt}/pz\ gd, : #ɇx C1@Q3SyfGs@AƽG1]h]=1)c*ƒiORkY0ā,UJ,[m@3hY žZhG60A3Q TifNec ~)ȁm Rj'0T,"`O9Q*ƛp8Ȓ*7jMX;8qA*j#jǛn2"# 'P3$CemA 4814DQ1Y'vu[ xe̢-f2 xٺdXeS:>sc +(v])^@j.x*g|ۨzYr΀%L8@DŽ;)rlGaՇ딖IOGgGAZOI>ᠵJ#)KYsY3<$fGiU. qWEn럮zE{A<-H $NsL(#Es ()OSf YPS Qmb6!w $C#K h}'6[gTʕs3 )H͕$)eG{.PQte~3)jNԛ`hWSɨ4dr]T" Fn"Ĺ55wz0;$cn_ң3馌w&ߎiJ]Bk yuVܝOTÛNY0ag1)i &}] MD>֬NGGNKQ~nx|C #CVZ >2d`ed6 ryVzȅta/ŀ`^Ff-Y RI=rzM::C]۠@3叿\ '' 惓*b6سdŬSQSs,~QҬYX,ƵWYptjQAقzUq+tD}' [g|`~ΜM7mkTk`F`lǍQ}YBN\22疖YS&0aE+Upk%͜1Cu;Fm|, =uƱ%Ϟ̳7!2mb,p;oxCLNOkpTk֮`7 LT{ OL;v=@G+[X"h?=5|gHFϚ=4xf[IZm**'`E^pyj9s .Cv ` $JҘc~i12 qk( ӹ%qbaOȳ$֮Y{۱!grk6Ygnzێ`ƟH䧝v5Ê@ DbXA)gB]AB\EorC: FkeQAdKniYG[ʠp [ޖ%LSԴ%Pɹc`R1 |37Mؖ`X ȟ l1N=TC-db[!„w ڨuylXjߝzVl(8Tȓ ){ۮ;Gd?~ 1(Ery0u5{gyrLj8!J h{.U :`Q߱ ko 8ށ BfBP۞2Ơ kmG\p℁;!,l; Q)Zʈk,\xʩOp1RTT-8D r9㏏S&Xc/۬qz()8Bp* t1R+3 b6R0O>h&9x"z(KnI:]tqC<8k<2my2vQ7d q:a 8tK:`Qʸu:*EinѽK$˵SAd=f[RcDƶi^#EaȫHnq#`U_wu'gM~ # -c9H=Zݢ)\,3@,4>2͘9.!"(H ϰ64u V -y bjbQ]hϏG,c EBrYu1/t$S8}! gLIL9Amش[$3i5!|(:,aA#c?sz_SOuk(D.qG~p>%ˏtH1~x% p;ń]T8 xɴI˿9Ȁ=N?qb8I޷_+R~áG_=pMhŗ\,ɳĸR!!|ŕW`^G8BSrH%8-U㊨Ot 3< c]SZZm h3%5ʈL7jS@}܉V=лK!^ VB ,F}HڷO_4ʼ2}׿qӯoL4< alryRcyE<Ŕ1e30{iv ѯvJ(+1qxɴ*X}Bb0Γ)\[U FmG$gD(*|}5.)K}AګjlMÉ̪/tBz"EL MR,]B ~29X$mza{:?*BLpk4Uaku~YZY֮[2R< Ru0[$xl[Ґ?Mo_L^GtYlCUh#'ʉÇ҉ךC/ >1]j] %:(vk?O= r!5Th z=Sߙ,IkXGhXXMVs K 9dvRЦ-Z6l|25#eL=9leŐ(\e5,]t%s-orA'-^d=%r@---z [&2.EPV'OT}|ҝнۑk"u6D$Ii4hjrJRUE`ex\dL?*6Y ]TيG-],iƔ!Vɪ=672,.Jy7tێ[MdtqTQT!xL9%r 2Dd0e#ƐM!S,5~HyDF"OȰ"yBZ>/Ծl檪+V}|G, uHR6ecmj@U{f`D!xڷAQۖ6lXX]:`udtbBSOv JO Hdp)bvHl1gM|zTV#DGXA"#gĈQl #pl$+: v;cfJzf˦~|Vl3Y)< jU8D4pjלwxBz<#X'^مLi2[YoTML%zJV̕YT'G[2HFlUoo]*ts PJ"kYZ+N>d|G|*Ee+Yfm!Tĉq"VJ( ,Y겘"zk[V-dcM>$ENh,ٲ ;Dī}OK*-yO|.R~joH$~VlDU$r !@+qh<.j :A0S 9cFqB-S]`8-꣠}C2<D+B!imSsT+kl}`oiYRx2/:Z 11l 9]뢙 ! B}*x8dS$˚vf0c`V_ Eډzj%[I<%OڿM>lĒK}JKfd|tJJndoa%IN0h0e eHeXlm  *hl,?he A/^FU>am,J+ۋN8Q.Kc 싣pkAcƞ<N yYAYAfOW[,m?+LQoFˆ-'C̾ÉcRďnVcηj M@eoCYѷc巜'?ņ dCXK_f(1IG$CO Ґ;6ȡO[x#[ G%bbHh~#O H{^4ƎDHzʷ07kiZJ>8ČVIչiu.4P'MNtwo1[9   }ǐS`mw ۝*ThZSeR&%C8wkh>VJGzۚ!zFSBZ&Q_ \(!Xqi8 QEהMϱMq-[bMeV}59 c<`R8`_zcF^l2v7HhvgB0) Ĝe)aQKXje,K2#UI >RU;lvNK œY* 754%4UJx[Uz8DQ&͑s+-tS)F88x#$myܣԷ۲@ 9E!kQ k'p(j78uM]JDt;dqŰFw (u1 jJNAͣj N'RZ{07?ޫh.k~mjW}EVHڐ<C|]t!J~4AxR:O HÍzXSP#B1R@`$y:{DGVN)"N **CŤ~jU1v G4y5+RԸ䵐d*dgAPV0$Ȋnɿ^*R eKV;"759[Ldڈ, L3UBAGlb3W-7]zaOnw$T$$$hRbm"ɿr;DdnjErE i\o Z B,wS9NI5xo1Uһ?/)c@նZD(ẆcELUy)҆# 8;_YYimIB$^f$$ٳ/y5>]!jNFN)B:do:$YX KڌQcS*|&aIـeJ!~-p/ O/$Cl'7_vd.v=c>޶T[^!B [P,?e|Eڦ<-O<޶-ҫTql v߭egy&L?"GY$ Cv &:?*|EN2.m{.uMB1@oCڊ>~?_**1l͡+gGூ\59`PzR~G쩓 O'd6y|g.x$˽mV5?]cVfG:vԠEՋI8@܂-1IDATCuT~8pd:PHh҈&oyS ]`iZX5;(lϾuyKǴV6)nltI΍alRZY|H<7yofђəLrpcj4)@crzc:c=YJm>Ydm"$ _].ꭒ}L!شlÏ U&Ԕz;-!4[ nt,k2n/ZpufHc3p*K.flsjr{GbJToy#y#h*:U^ 8I[ t,%xS1#0q&2HglUAQ"t"U*J$`ȩzr+7EQb>YN夃6`j%wDXuj"PI p}d0U2e k9\ p(6 D>?CЖD`ˇ~ft{+Nd3f -m`:(M`20X {P'\ d;GN3(8RHQ;bF+ I*HĄw_I'MĚSW7Ջsh(4eFv"ꕁeTgRF~*AR#"¡QqE2X(îvie.F pZXF9n aUW*$5İ;`P ͇~V1C dZ*B+tP,3]359VX*JZQ{A~G@FNt"U+ ~X#IdUQoUNs4qF;²lǽSnB?-[mUX6 [Y*r"!6yMz0wRmY5IRHLgY'hbjr5,7,C7-~W2G)q9P,7X#YҔ1joOμV2_SOg,i?> xԥѪ`LYl@JjFɠbzQ|ä؄%?N` ^KIw\ zZe#iBh]-S}kF%琀R9j)l;mdNd|Dy.PpƢc[\f%{exU؃o%k&59͞]h61`^-A~"H:8}C"ՄnO/!AtK\&D+l !@wФƬ-HtΠoKY , c$l1F*8{(ĥujqL{>)TxN`DP *ۏcX XkHIeCARav[ 2HeQv ^tP"@*Љ^ID@Q5:ey#["[ w [{];n!̢ _`uΈ$ޡj`PD(IXa)GrU QD6eQVHs\|A!p5#Ƭ0`\ TG4;S3D `D(~Hfd2(kFE:IȂEye8Q$1*a(Cpã8&m v:cH#U`;`Hu"&U])vk%H=@XX}b~"~fgVX@lab(p,٘j X^dSP)%2~F~ʊ"aBFc-&]hتw6vݴZ$@i b ndQAVgyOz@I 8g3Pɣ*ULu)W .d"CAJ~Fx_ b] ]QzUWJ)D7&`e;B*3D#@Lc#>Ν 4 )O!YSP((ӥG|$aRGu2t,XoTvM\`FcpEJj@0kT"ugXXՑtڨ-?Y&|?-X/e–iH|+z,fA%lr5,1agE0X %^Yc!g cwTi"]]UiGhAS#A 9&񂮕rUq` ؂;~˳gMR%³f">r57V٭Cӑ7(-l9Flke) [ַe1Voiajꣳ_$QZ&-] 9\V~' @{q` xH]hPxgsPN8,|ee[wDy.GTo|{'MRx٣3lp*'QAYX/1 |Ku[;۲ݔ1SBV0ƱI&$6"p¹ 쥵|(N6% `6k= ir"gd XjxzFᇂY^. LQXYP$PqJVuJP5>$b,H\ ZRS2D* N!$YG?|[J|Y%$"#>V̲a]XBDf@/xI Fu]sçKLW[`%(ńV}W1JEE|KpE<"; 1*~'w^ W@@;TX,mjyzem[wP1kY*CǺڵ]A?%s@@ED#G,EőNU1z|CyCNTOP%HHH ;ਅjpS ^ҌwkN-AmtB.gM>";`&0Rޒ"FY+@I||Te_.l[xQ)@زi,^TPR¼~*eH`,fw^&SʝtxV=>z+ $Yw`NvO`CEG QVʷAo`h7w+{g"b`FW<.8ӟT?j zaA¶Έ,(T-;gTGBS S8,v̲qP`'x"FNE!<`q؁82p@C0Q9 ZrJ$P>v~11ϯ XQr쓝O sqV I&kÔ-ա-F9R c&糜rKN h'Iq8rA q檠JbY(u78%ly%le)` 1]Lքsܢ,9 ?cKRr[[و"-"+Sǀkη:ָ3,v PRhªo.&~Ƽ,C'>'U2wYŴ 3W0]~ |$2ۘXm KEE zteiH_kFx%|˸HH][XV%ŹU]`%NvA$E e+ ԩuҷh+Kua;]wVܖ E;AΑ/^r7S "U?S";mHd`b,GjN]A\~/~ Xmrйogli 5N>h'Тuօ3rxK6 T%Q2lHǜHrnK:Vڑ`ǘ"4-բ##1C]$:v4"DV_6r.Nr#{ϻ5ZB ⠹VaXAP:yũt'8'. GSz[^Ez`5hMCj{]qU5d?{#S;VN?f}y,#{-:N:^{^ټdCoc1BxyT W6n~c/5^B1L!Eh(i2SG8J j-+T#N)… e@ \8 ax?4<˜ W(^*b!/IC*VTmʆnUм|p6@O|N:?==?'Cl&ab:g1H=i{H븫 ȓU2z| xsn`"y9i0ޟ7lh$C tRH,o{C( |6 0!UOfK<;: a$)|zg0H t`"k5j Xb7dK$@X<߬ hxx0<8Ha^0v54|BZA88aBstPj¿YW2 .:[0j{;KE[#-Xށl`ݎ+U~}AD||~{wVq_/Ƒ&p`PLu)TiP3YN'tsuUJIޒ|R߁ORbUmIV%{JNGGܳ̀J uo-(SiTUzPzurV:YO!\YUTWJ;bzN3d`HF]H;K`K(vDC;RcR)(%yӦe˖^pگ_/(ص$vFnX֬n_*CK+H7?/hXn.m+xH`W]tHHH`$vb\$P$P$(V EE `2]U-((U@@.#XLWF `1P$P$H`+k\>EEEHXTM\$P$P$%gx|S\>EEE!-yhm'`*˧HHH`%yoIJk;k+lEEE)ma6^E*@nG-~V5FuQo|ד: c8 λ(%C)*gU^WDvٱQ[]|E l)~+ɖlE;GɻqUZ+#+"1icXEj W𧺪x`g"RlmdgkY>TTJF~U{UhKǂ[/ҒHK`{+TQ]⎜P6߮rk<ǻPK\dpM[[evS+s[ 2mL9sƭTP%SnrM .ڢ.Nøzڍ[*^` ]nQt(r%[mv9 `K$Z,(\|Ś`E;KvǺA _X]~- p/W g=`te<`Z]v}4\ 2w. WR+< ܅ a&tD9 WE]K`{,, *{wmN){=*UBt]Ei&cP= $BowbC&e .,Pd}Aq8˧H`WYeKN8mqMT2by‹H%]CQ] \ )(6%XU1T\60D'; M:  @tR)@G ;&B4O~'DcJ@ @0Jŏɝ20zsϸ}V(}DFt7=q\q/W_"Žbq*0B[1lbdZ'q.Y0$ЇRC[çbD.i 7:J->HWU RYUyU?  hrAn0ih~u WypG T JUb$_$kIj,j;HO{}k.^yvIe`Y$HTJ*|(8ՈCFQu;cVe@JMS'LbM-hKJVO?F&ZARQ.!(+-㧏`rK$~KId^~ #/=C(h2zCPN{U C@JDe,!jy8[1iEo>󡟪mBi9 )}w_kIH}D9@j5 6=ѴefṞoy?VXIՉ0dY͒6D w"Y0Vl\ZP[DJV-NВyae5,2LT.ow(Ɣpm,v,X{t&ݡj6*mަ!P #j"h!m3$zkM<$Yb'YfH4O>9X+9IBp@LKe*l-Tt#fбf,/鴧mзdV,I~:hhqTд\`0d'h~.@IXFn3`YMa =3}|#8:@tB1,f tZf&C1 N*Fđ("3:!p8`>37nPYK<%>X֤0$- 0mXj1ޛ .K `\@Fp,h^YL'Hu_EpXL`N-һqfH|> a>V 3X(!|aVI5 gii(XKK  QۤOSuIcp=@? auQDϾyT624)5ZxfUW?+n#P]RpX'X1&]u98 U/EL Јw[C%PJ'?2-.(AxkqY   @2 v e0Z$P$P""]Fv*  *cHH`@] EE((e$Pkh@@2v e0Z$P$P""]Fv*  *cHH`@] EE((e$Pkh@@2v e0Z$P$P""]Fv*  *cHH`@] EE((e$Pkh@@#> E(EEESR*(Don޴h=/.V')QEEJ,獼Xݪ_ 3EEHL ;J*((=pU$P$ЉV'B)QEESe {KHH )a'B)QEESg:@NR `u~)\  t"XD  tO R*(D:J*((=pU$P$Љ `u"U$P$=%P{KHH D(%HH{JVU@@'(ՉPJT@@@/""N$P"")Xݳ_ WEEHV'B)QEESg:@NR `u~)\  t"XD  tO R*(D:J*((=pU$P$Љ `u"U$P$=%P{KHH D(%HH{JVU@@'(ՉPJT@@@/""N$P"")Xݳ_ WEEHV'B)QEESg:@NR `u~)\  t"XD  tO R*JÖ_kK)EEN]>شiӳ>% ڒdJ|@{ 5k֬_^ރ.)U  t*x$k+_|E `u*Y$P$H`jѣomĈύ?^ `u*Y$P$H 3XG?s `R,(TXsNS^tSO= `u*Y$P$H 뤓N}8j…-z `Q,(BVu˟~s̉H*V+IEE;[Λ7oݺuO>%\2h 0QkgDHHm%zWdXz+ `J""F΋VL){""@Sz[ EEEm뭷.|>jhmiXV$P$P$$曛[3j^~ōoixS׎x$cXX>;B62lܴ"GI*paEcGкuk׮YfCk֬NO<)~KͿSj\h֮\Vu Yt-:n勉NٶT D ́VF{>?n_;/䘯|n}y=n_Lsȓ͟ /p/|w>7nŤ?뮹jذ:>rw.H<9P=j$[Sϸ5rD=C  t7 j?*!.u%tEXtdate:create2011-03-13T19:11:50-07:00WH%tEXtdate:modify2011-03-13T19:11:50-07:00& IENDB`buildbot-0.8.8/docs/tutorial/_images/irc-testrun.png000066400000000000000000002276711222546025000225340ustar00rootroot00000000000000PNG  IHDR5JgAMA asRGB cHRMz&u0`:pQ< pHYs   vpAg5lIDATx^gtcɕ և;̚yoFjWjdT%TfL2{{ $H 7?̬*Iݷ{OO[ĉc|4g5 a*gfF_:fx6W;F [0oa_0>痛sSݼ7E / o=Љap!.\#= |$qz//܁Z1C7wB:~兇n]o;o;B٤^)NɻN X6˦KS0q'݂[ca Ѡ4@aA9zX3og͖uɊ;_& UMiŔrJ[IٿL{G.oh7k;[V2W5ǢjZßbRѹ^0(R&MhP'`H"*ԤoE}"Eb&~eǘؤXZ22 82 CHg*UcpBG'gNBAA6]I0.ЧKЬdl\>IN& +IkMugn&6J8(ipy&7cb쁝q5:BB.w@ P+as[Ya3laEغvkY`a9~4(erP k״k ֽf=Y+d>Q?_n?^d|BGw+jG%I*W\8S $wl2Dܮf?mbJմQ+3}?4LI֭k@ mj ÷IIthL#/Ѐ0"Wޫ(QjvAՌ>_٧Lh9;ekYL떗Od*auZ> Ӧ+Uh{:Mj7! He9=:GȸĎE^h[XTcg|{Ӭ43Z=W7ookF reh܏iX)}jZ[2^U{}U%K߯,_ i;݁1uŴ>AnLvlNÊͼ^IV}v {3WJ,Cnk~ktIb8gV;b= ۅ}x;Cled P. Yݒb'鑴NK{qIa$S1$hd+ $ ˖ XzL)qS:7&7¾5!EQսMzt3{@ ➔Nha_^WzG9"~N0Q(ZWs{&;,y4qyUZ߱=p/WH mZ:L4/It鍺1Il=4iVll1wmlI \FTdCS5UG.jdIܺӽ9n{a;gpwǻtR`ltzS:s.9 ul`I_2 ef|V=̝@D*l~e/Sݹgwom̛6S۞øczg5_}~ce5 'twyH?nm`{]k8x?ctx^3*)]9:s1LoewsJݑd׺0c0 nK;moS0yV>ٹJѯ,]/JbjXjqvVARk8^\Oni,gp UȰ^]S8gknY*XEX<7X$4 TOntpk<pN.\iKN=+-R5ݳ s{|K*JǸɹQ O4a5Mk3z,rl(%7N͂-#2;: ոe NiZb5Xl,'R.U fSZdJF!5vɟ,1͟(T F p7ӻ&HQ%bJ;N3څ=A)q59|1fdtn25=OsOk^TA{;@"wH^-{ssݢY4[lgRҬvwέՎo"D8̶͛m="3w W_%]83ږq}^oxG~-Rܨ}'@ Mc"y" mImkw 'dmˑ eֵ;ESy [YBL;Ko On_ uu”Rú;Uמ;k)_-S4HȐ/{,k`^%AJ3xf",6v+2Nz8 ^8?ΙCUD𔾗>6[Pˀ΍Y,Qmx>#*3½%Jm]8bۤk͢ڕm#$oSt|7}Re85lv7e=Ԧ%m3 (#yxZfaU׽I)^U|DА³ݼ(g{2=(g=vmWF֑8_n>fw=.ft=i 4n$61,Bˇ˕`_|4K4c)*׌G7IEkgvZgK8s[G;2wÊ&[ F#j*QMap"_ ,*}X{԰$!l?(w&Id#ԾPJIlHw k;^|RpBq B{+ᵫ)ݲ՘v)$ǵmޫZ {֋n `oB{ѝmS)kޯsH^I[A(s+䇎 FHp;ˆ5 uZ%[7HC̣>eܭƴnbث:(}\ip68GO:|0q8b)N&[֭i|&GL>cG _heX.>l@g!Q ڐt3u!I ===gEvqhث3:bQՀ9gh:B'Gd 8\舡ơ m^EЁBW:ʫͫ|R=WC7r\*I=g\5Iujl+?<=/uu/?f؍kOG=&z.1_5(}Aiҝ6^25+YEn,,a+^YĺU&V̺ # ohŋC5׋ 'pl@9ɮ6W]B2mڼ-1z(6eW OX覅蟹EOڠ}4#OFL"-K.b_SC7vvI#ӟt \!WAB͞ NƧ+4xj@pyndEՂPV-P"" jUpwѠ j HKJ>[4{!5bO,}(,[oc>FmpPO;hӿaI/ ޻80&99 xXwn85p?3pxl?;pro[Q<;x|o;C7o :y0t;'FNn 9wPyg8wP= ;=~zk.]^;??;~~θ6&=3AʇSI,|`>m{8i?rG:8:fgsy8GGsh࢛wa3r ށYWi1wc+r|bhe\QL8Θg4tͻ&^pƢxb9I_bh+.qQLc181,bhH, Kt.:pĄQ`kԈ(}Vqfr=K4+eK3dp}(UK{:q ~*FڅC2v/yq0烑sxbc.:q]@ N.Y*DZ8LWȒmD/7,.S]ɋd+J`9ӑt3q {,7o]ĄJ8sѳ珧OM9{<{xIɣ)3gSWp4l4|YiYS0v6yg/|)|I; 뉜w?<9Z<^DF"Q G (1/L[ 0@4 İ(aыl.XhV0fa ı`43Hqqlb l*!6`R0iJRKؤP.QI*K%\*G,K6\*OdTI}"J&za21$IL@e*vPB*EHP=vB%\Rf)+ {Cm7 5"R&Ra+T*1O]%h!2VڣGrJLJ EwW)x6}pӏY&W `|ŌzC l wA+m%z@8x`qHP%DLQ0nbj=(mZvt-:2{͢~wʧni\ז[Wx`C]Gq?׿' _V?(S2xrzxODг9n0转Li dfA_Ҹ^ 8pNpvei5<uY"|غVPNTF|*5`k*Ǔ 0f2ӑ%|3WZ|#b^(NzsVn` g]0bJy+_b۟o a HuŁq|_zp)g:ׂc@+Z[8z?yvJ[*}T ߢș7(O8Tp)hu lE?i|;w:㦍sFc睐Kԛ9ӯ>,P-RܴF;T=R?{H씨3"@"z=}p#06A^"hi @[A9'&P'I 6FC <"> (ݑY$4LQ RD 9O QXn( j;}<W` R+zƁMD3A+Eo,%pE $=Jd~>L8%` /CDR  F/Q\X16yA &T^0 H^A4.B(Ep/) t$J)z) "`7#(t+BSC  It @"8 (]DD*`Kb jcYX fAg,q0:Koo~ʬT *镈O:Eʨ> o"{v?Nl>ֳ3mɛ4fLP9+5pU4v ;_?0sAPޛwcjUԿ\,lOjc ݏpUs;u Y` `zBӧq9ꃼ"g ]+̣ ߌZ@7Z^<약򃼛%RFRD8 䍺՛ oMlPPk|nf1k9ZBOo.>,l͙ o8f@Fˏl"\ut;Yw9޷2{NDZK:foe%YIs<ٳ9Zg͜#5 o'ޕn޵:q~⹽}-É1Bײ_D}X;w*$j^hPaхY#z5; )RFL!6'r?AG$HJN#28$DBN:G/A@z@+D(x)2WѬ$qf(#(&+H`R(a$6Zb="q\tb@A"*v)GT?GrT~@n90OTvH3*4qB!54!L!.#ԀVp!rhV=P0 {0`$RM(aؕ$IMPTre0ID`HG2˞'Jl|7e5wta)͘j/SײX(zb^|ǯ]H(2i@W_fLKhYت\ALC8Jݭ{^B͚{]h{7_0_3Ȝ{'7v|"aUe ^X?ÜGe;ߋ-{~B͜itq3}[VX춟~ }{kE+_]+Y|X>n'._ы|FwTsY-Sv1G3ލyوoF~;_oVv=|j`(a;GH [ _'?QKE&׍aW>rWRրה y HďgH>!̇eߎ*v^~?,{NaG _V)~izPdgqDl,d§3nM]*NA#bd/1; 8L9f T")݄DDQh=EC4AL$ARaJ c1=1 O,:;MXPO84!"ZƱ< $!#D7xte<DD6$: tĠrׂeYЙ%%,⑥! (PZ]uoР:Gfپzܽ[\J$E":.: "+!@ #!J`.h|ZP2M(ј#JMt)DCL $OJ8] Q:MDk$ʥ pGO؋l @& nL6gFWw^tFΘ{IlS2{x}񵔚;ŸYR [鿼zU uD!ESXw-*czvܠʛ#φtf|[е|vB;?UJu픊iY%I,R&eaKxeiX;GôĆ rg ︑zfbsO|0?^ W~;?{ֽ^+=}䐊W(9i{IlgIx[E͞ CUCxI){ȴS׋?}/"dV}tܼSЛ#dI̢⹗aNz=Gk_yvTIWdq7(+!i\Ox>#LGay&"14 =&xOg.,㜏Q(gA%;I16ȴhrEvɅ9g4Ԃ+v7od֜sXdVMd;A 7TH1 ,zB@K`ބEh 0B#|h`/`ǮE2B<%@K *l!HB lB:K#"ºD/qrdx`cjxKP1vV+2ݞ\!6*2Ze5*F՞A2iS39;NΕ]YPGTL:ʤuD&h! #5@UZD+;B'Y'+s !> ^!)WBB4^%'ț66ikA тO>J_ĕ4L_P /mxuOr\5(.]:"y`pe 3&߹7?~+Qu޼ڹ|. Y-ꃔ?W:r7o/_|F oIJx#{/T7k_+Bl Fb'QnĒ[Ue^8ukZZ{a)2}]u+y+>Y\,;dxN~[wbF^inwj޸INnFe|p7:Sp-VRiŲGo١~t-:iohZ/ 8\_z~Nߍ>P9=ܻDQQX<@ bl{o)x㈇c|nFԫ=kwRע#P:AwNS KIk|F9U4QlQ|;奘~j9x< J uAH $as s(z4x5kbz(y~d2M(INA 䏐GjdO4\(9DCLfw(&P2+W,v ͤ2ZzG&[[IwRrG&Z}h5ڏ- = NźnqHfQVS A]4BbPє@4A"*&{=:rybV7e'~<$N&`&,`s3XBoK%`:d(d72+KAHIH)$HjdREЏPN_OGOGy-VxrV^ؘRVlJVEfQ"VmrvgWi AkCjc`a1$WZ&`6CY!pYk$"O삏SH 8yx5Rz %:Bh"4&A"f&U %} āI" ` CQmQ"âfH,fMVf1KֿXs=9x6\o (T*|&`N=袶YK rf+(JW}lHhB0֛eb s Omoڡ29%xުMUE?;[9_Z؅S"DkM$McYH RBO[Q\XfuB riԃ6񵸒-y ղCxpeA u4.[xVg؟F.w붩mP*Z dqO:Y, x:)^ 6rXYsgxz'fj{3"+T:'-:+6,{ PO3wJSD/Ik[ޣc2gN*)U&+ePB v=ILЇ^)iMذ2c<ĭG6]΃>FQ7qEv8X1H7'ٹP"klJX@4GDIq݉ln$An)"MvAF!\"V s$/NHDb:l&P.ɴID ɟ|&Y4>'YmzM=H3$-BPCN"P`A[MBy˟de mUPtpIL[VW"P1m}͵e/X֪ܡ7;[Aa.Fjb5̋$㡞hޢp'5\Sgq,g Y%sT&àUcǮI,'LQbY_:y9s̝amX Fb9B?Vo NY$6by*#W19 "E8N¬9[ $K^cAYXnF pBLa,0@1 WHJDf ˍUHBO6%Ml7RNILQOl%o"QH=8!!\@H%cΚOc{^F$KdRȢd&DyD Aa:o,GVr`2/"InZ4af`H$!z XLA~hI;@S:KV@"i)k[V.5TT,I&X. Vʂġ*d|H0٫^O) ~?~" 'K?kI_+,&FcQofV:4N_=%CzONa4>-F/`= =dXv Ka;8$= Bm1cfNhIϓls籡UOBH!fVE, ڐ!H6A|!YF L$4v- $! )P J 2DZZ:b9kLTxgIhgKXt%ML$B9tɋpdO'yɓE%q})? r  *pPLI qY6)LQ ~ E$ kȢl%2 *ARh ٫A,ΡE 0ը̡SסzyX'eJR%FNW~p/Eُ__½Yx^qgGRn)&bm+*nϬ՘ZJlgP%?ѩUJLr'ꤺm2_ED>@CD3Z'dnOH Ao-z }hp8T*l>h.hCHT.{0T T*  BݦZIIE ȍKőrI,g11j蒼1wea+jB{|(gc}|:N)}a .hY= 9O+]c,ahLoFM?M!{8~@ N + ] 0ƒE Gkdm/ 堘bD&DR<]OJD( @FTUPDNV:X$GPǢ~Eɕv%.֡ǝ)f@IJZ$~*ǛlH%s!(&o`OzS>* mKt/OB&*/iP7!RC$Da1$r0 FxDBJ#h}Dc(АdC;dRVa aXc [#=DEilz,<$'&5o|#'a_Q؟|֗|^ k+ҤF}h&ƪ!Z4JfUJjվJonhT~*߄BΈv#'kR@+IDF"zԃP`ZT>HIa8`:+^&=m-͒`?qؗAg}uk? noJvO)YOLzJֱI{j;-#nmͺ+Jņ~SUF7j*:bqh!qDG$ SWʃ4eB 5" @)`JD!"ĩQTKSKJVSPu%ʪ]* ~Tp`=2rr}lM<4@5#?7d l9C(q 9{Wg{f'evAm<;^6 5S&'12>>1.-|nߴۻ7g/E={2_Wmv(jr1^$o5'5F&#P,DdH$&k$YƺHXH@%IJ%!'.#=؈Ila0 "؀#JXr$,$Rg"\B%x8qɝ&>ۓE37&׶@y&/9F<H`Ŵ @RƲ/sH*X3T!@& a KhT B.J$A.: e8`WDtPFPRM$儗$,220F@+Ѹ C#GHW/.ԣY$`%tRz!O¤? ڋ?O۟n[w~y#sES(A.|O0vL]ڲ+?1{:ũQqnTNuHN 7H΁&Bp,X&B (B0$Y"H1R V(ABz%)c wtDK;QOTD  PHo=ESY;Dlj>ٺH:L=O:8vlfpeQitki.5SKfjlv.j܋ZZ;VOОT4MNgL8 l.ذs]֫=8#h=,.=='аZsω^8 |K.7'D.11ph[,tPzQC!>m=,ԞI7 }I{eyHIMg\ڂ5Q} /C›0RWܽ{L/K2'!jY$A:$ PA<RHMeJGmxb:y}h-XDq % CA.aFZwV^DUx<RB؋rmJEY1j61_jw?}߼/KK߽?g? 7u J?g5vMަĤmmw5xw<\V&6;*fhk$$':pPD0"y hҸK5R!054t@LԓAInи$B/ FiRQRO喷A`a/Ě/ 68hNދ8ϕ|MZq\gT.U5ʬȬRԸln|Njy{Q۰Ok<*9qN˓ Y#c׬ڳd .S0ΏM) q~{i{CyQb]΁]-12qt18aCB_W4wsmԤTv`]5C;iեMLt / +fd*BFVNjn$DmP(-_QVrոZpIzCd7)$ t$ p#Pj?ݿ{%woѷ?ҷ!>G߿;w“bhE4t].5zss[IR|CۖV֩+%G!*GP,^? QI C@+SAf@*آTTj mjR5RQNqi]X T7mvm {{_o [I4P2odդ"(YS|"Dޫ_\r_0&_59u6 Kϫ%'DW4fU=ΩaO|C[?x闳"͈v̖ͨsjN,8);J[s9*7}_~02& o&TQ37%3&wtmDJ,[1{?AZp#./yif?Oj|?/xUw<єdؕz`Onbqzדt%m8T*GvİWNlXTS 15f 9l["Ɔa%#5ı#O[4-m"HP6&"{If;\@$F) 2PNh(> e7`UiYIL2в7H,{)+)/Or 2 Z BE`KDJ$ $Q TD:`)UFQOٰ 0!' l2:o+M2VAaqIHU$k*]yچɞM(zUjڦ`4H:Jd_1ꕸ_}) ❯|?_k?{M^ uH21-x<)5tmocŰMXZ԰gQJ|{߬uwT]?BY`B)D@ ZTա5Jcl v=TS`^۴N.tn7ifkQS-{@jWU3-*? q^`xCc(UFE>e8\r}Ae_y\;f*۸_>6*='jgGRrbS ӓ+dq_N/|Wcr*6? K(-lɪH)kH,oz淯hv/*::<>3^\fjYKlZ8ZS#۶wrzk@ƪ߈LL+oE_(| ^yy$)>#)˶1ָH +;Հ$5{#e/3NheP_S>=t@+akXwlEǖŹbd_ K6S:H{9M{&z<$"%~щTf&k\iP|w6(LL oGEbe~W#ɆwC\>D+lRsɪ] 4@N][KwzMTzǟF|/dv@&vTf]˶5t_|a ke{^(+T%lZ!lʃz]d/tDWS(+V@Ѫ`㝁5?DrklLJP鈞A.A€8P7ࠏȻJV"zTشETlkQRu` nI n:k5,1Y+$kbfeueYX=ֽͬmkl+[W5맭*A^wjjn{;YO?o󷑘oi/5JAiVJ1?xWCsmBXm |B$2*G=ޞAY½M@h7Vm:xhPL\`'-N.a:GCؤzTz.էzuTD.k6g=Q:jHG]kI߹G-?zתH~z>=5vkTsPX6MD~^b0නvNrDhGqo*]Eˎitjnq06OʋZ:*۫*+FlW%awS:Mw3,m$y 3>~O%TJ/ K̎)jKȪ$,fvǖ<'6$G?#JL/{듻卩={#oDď_F 9(д-l3s⓰Aqv-շ?X B*$BEzjgD5B}Yv'RԆ0K&ͤѝ"<\[>~UBӖD׎(>ks\#Pp̦iD" P._ΐC&W;dTN|s =My"T@|O QR+PO|/Q*UT (^ VM"x*+Tn$7DLtLI Y -Un"aфE[Z)1 Mʂ )F%^",Gd|^لX=645[$bdpw*>O*mKz,G<%`tWdvD |Q1Ed/'n}}囟ѷ?ǷG;g?»I]܅5FfKtUbܨ)E+ M;;GZuWVbsYr$jvWavwwL ׿huJZhH (%j S hHB߀fP? Ԥ6g)+5cfͰ`5߄v}`@ /8X60h RfV` I"dkCՄYBw媳B*k$ \Jfi|rnaψI+`]^SՖTP<#'wquw3vZw|\M#Oɒv37@BQCjICGQ(%oreGO=NOĜł5#rO#2[QGu&756 -2׻[9= Ooe wRcFV9S"ՒZXnB7y&v=&Cl+/kEk*$1U+D:jr>WOm(DB2g^#mis<3, pyFybY* rWіB,_}H4''Tr<lXpEU_[rឯU _(RbӺ`Jl-YJU9D'h%, m` !^a Mx "<"TzB%UE@(, B3$j^mDf$Ӷy1SLgfɎ`2bezkSR.׶5fjj6֤zƠVKVfY,|Mpet{4gi)61O_<5/?ֵ_齿?}KVMM {[kV!J0i918=ZSl3HvbҘ[\T".E:fkP#;ohڐ!%Q:M!NCj>o*'1䢮ƄtA1dbp$w0-„ìZOQTBbb" e!r]Eΐl#.$-(EBwUݴ>rB. E"PDhZzVx[=% 4*1x^5O[.J|EPSPOԺX/Y#wˠ6}eB(U";MH96H.x"D `!PKJ*A4!IdG=xr()SRS˭%|9լbnUlRt;fY%/%RS =0a˟Y)vɁU6zGó z^Yhjm/oڋa[O|7۟WnO Ǖ W JV}a֟[^ CuOeQ+A.UB M֮Xm"&E(\\gsFBča=18#B6iI'jD4Iy+5k`[VB+1bY(Jkm t5s2K04„'M ( F2_b W1iĆ̈́YKg"OU+vv^Xq?%8!=Ӑ%du D~@qcBu%M0c Mif>ihOw3OtM*]``)Ba1Z@h'x98<;89 :D5F&W$tOrBhޑk og9B!x>mH!,U -'WU@FBuT*]v,D`/;ɦQL^**^qO+ iZq KV{`ESI(UڄdՍ_DH xyJVby?d[-]/2Q++PC|b[=j#Pᯔz!Hx.:#mcN(X2 IOXHs o_B "Fd~ۖՊ`$a ^LY&˶;]`n*w:aoo F\ǖ}id:;4iԻeD\7m=9hjo|;_%?w/W70W__Ƈ_x7~WK;K+il1䫫RH)^*P&2uOKR2^!۬3J#1[+B(DQ7Y#0Ԅ7PpLD:zMtG85o 5o 20ـ5ȵR![Rl+~NQ}*(!!$>h5-.kry )]Fj+__k){gkfQկ9TJ\0C%Pz^6rc-Rhh0.z j[JrnO_pauamyfЩ~nϏLwyr^^ih'@mb±E^OkBY%`"T/AێPαԈU+*j Y%|R]B\_.#4WU kJ1W]J1Oٚ|Sw[{*ּp#_SXR܀Pt|U0H(ZI⭖^)UJ j65WWmjʺ@v[U(v#" 5u[~Q6VLt(׀w!A7A@%ȥ6Xd'vvC 0v @%Ulo*Boy[c8<8$Jj;\Vlm-qWvˁ^67HlwU;2pQ3O<>黷|?<~?K/~tk/|?a}_{WþԼE&gfj~/XD=٦\,H-PhQݝ]xeSfYr>#%bƆ^slۏԪwB4ZK3fL "jȎ$d }ĺj zSP%8ػ^\>D`oh )5)X-3%=*77KR:mR9&毞j'F?;N]z@_ɮyjt=$ΓAH3Oy=eƼ,I4=Jz/VKW{Ʌ񎪼t},C7M|cj߸ʇQw>1?l>rDI^`r?&I?)m`R$%Lܤk&P>chFZ)TNal SM#5K*)yH~#5Gۼ11 *~Q[Eדo'MXy;Әw xitǑD?LȬ=L_QdQEu}\.W*-ZV[jTPJB)$z¸:hB#RnKJQwl5Z'f~Fy]';A%>A4l\;$( ~p4(;`gg,lӐN$[& ,pjJCЦ60 ¦t)}pFM j(ghlrbY` y}pp H+ d?-)kQZ;ĭg O㕘 JMCj}kgE,_\/*WWvv>pvpxxxo=o1K2Ze5ծξ)U%-u-"r{T~Da7n&VH(PSfxs?.0ڣȌ1E~YU_:fs%kF-.URvנRJWV % 1>-S($5Qo|sco,^\"'Ai@JzܠPl4yU5,H)h<){Ag m,!삒 ]قzPmr |K$'A``I'c ߥi} ހ#/*zs 5@{Rzz:T_t0Toh6/ۄ|~PW'rA@C֏ J)Ȼ,iLjK#f4xbn>iFڛ mː]rLt1TY3ӛt6@1gO!$ÌDA҈xo)L3Q ="@W!$Z0D&(&V% aZPBD  M^AK(I6d,R$@IRĐ ՠZ$C%%VP7@(!r>¿r>"iZu2Kٵc9U% Ud6ZLÖLIg8kk]L]Ti2Şb{woWc6__S^vv~z|vL}rtpoRT.ale]Wq]gvYI5W}Ws/%29")"~JmXJO#e3Ƒ[GgS&`١Kzjӗ^8i6moǧ.mlj?`/ ?C3G$55#mƧCIUSgK M桰Kf'fH㉧]H=]yi3#c}] -umMS }a-ڔ[&L96NίwXpx :66e2Lj-fŘnkmlokommkljkh%hhonoln54Zj[ZqMm-M(Qh;6284؏㎏ M+Fp08#8۩i8cfrqa3^@ԐHʈ,vႧ9]֝ 0lӀ]3DV{7 ]쮓L6V߆'3{f%8V5!4d'Aip"ht1{v"֔6Ճɿd.i={vǥ8/z4>qڶM{E@Y&W!k?gz;]CЛzj~˼=ccCM7m:tpO=h0א=ӆx8&L,Z^ollu]*KV%Re eL{:lڠJkh12.V<)zƿ u,!0KD6fd׎FO=-M8l%j 8nFjB]CK'?dOoEh/)ͨ3z@ec r3t3FK%3Siu2Bؖ*w'&lA8shgo{!ml=-nY`MMXaOV(VDB PoXt1ݣmrQnɤ[mbcS-ܒa*>o!ؖlnK dSFl~c !8-1B|vOKծjOu{:FROa=^ "Qwo<74 LEJ)/'~I@z / >;XQ1]7Wyg[OW\l݆cY5R#x?os4%PEqvNWT"Pu߹X_M>M{S~թG~쑝z%es3NTvbjBevHE##CY29Cg뼋:ߢ cc1ZG2{m-oӢ8^` v^u]ddߓi>%b57&lMo!zwٌђAp >B &<$*c=jMcf?FTEGQ~A vU 5Ef+=O:ʫ+Bx3qf8PgC2N+4#}PM袮F:ޫ:gjw< HF G!g\~l҃u6S \ :hK^7 w,nɔcԠPRn]ϵonm(vd*OlG3Pk9>tY'ٹ"[c=9l6ə |;888ڏ6b'Юs3ɹ‰0=_8Nyz~_0|N ~)Xg:˃{IZ,KM;]mC}=?.f:<5cHÍvtr;=]v;.˰N5:Un :DjPnA8)P2}+0 gFFCc|wĵ>tֶ?_3_oxtLXuda'Dj Fˡid=7I#>VN&ֶˑCNMrf(ݸgNT;.l TQv\Tg?Mx4pAUzTl4 yB vd7&¢d G'Z2`eee%=/􇎃3Ҧtq~zp`8;^'OMRG7 tB}/G<]KjFײjjGҚx)s:}9 G=fl[lo'E ńhGQXk뫃];f'\Dz情z58)Wy}jNK u..gWlJ$+;\cI/ϒ1xQWOI8\bQO_}s^y;ۓ3}0kS(i\,m-+dVw )zɺ&'Do?29m=>+ 3 OڌMͭoJҲF'QsxznzAo:('_oUړ B Hk>8*2t aꛁ$_ %ٗ_Z3s2,Ge{]GCO/,&gfGJMckJVx .g'v/6sTz3F2w̖S~z *k,:p9=޳ >=tg_ZVqtr 9@;\;;*%KKONN6iDw&ȍN'27 $rtY,՗WTʖ*{i`xÏ>Yds+'fA@!!:A]"TXi9<&+yZ]ǧǧgL3au:=έ\&qvXV<$u3 V V HGϨxM gV^k3]G=ݼ6%Ҧa~敿'.;9= 1ӏo}pbDV'gMC3;\ y"а@v1Yl?33-Sx&O.s>>9! kTY.ÎNIAE/*E갈rnżRݼot-7)U=^vgjacZiۃqŊ]O_D\ff%낕UH5<]6sZSɦvhv3}9i9tAAh؊⒒Դآ汤nYZJo^ P;oT^Ol%: !(0$sj…O3fX+tL.rp^+s<{F}|MH8Yӝv{Nī&'UYY8IJ'0X*p}<R6zH q6ff77V+}z,݇ʡoa6߈O46=)͔`6T z538OA>)9J*XS ,.D95h肓W\JR"ihZ:zl,܄jIDAT@^q9ЃUG큉Ꮴ]'PLfX- `rA_ C=e=:MHJN\2r xʚ4ongqN&gCq+m=Ә[FCTV]ͲؘilӘ nJV>kJHiHL), .) ch1Ȩ2*^[kDǚ~qI84D#e[mGq`щ4s&΁?5x.u+Sֳw}|퓛/ZL*a#? |t`Hu1YjY(:p-0XH/ D[1h}tlR-$fI\(gx,m,omˬOlZ6*1a_/_}<~jfd#_4+?wozR]tg~E_Ų)b,A12Զiw=^Lh,W-C RGkF^477 ^,4p>_·yF׏^kp/-<+iRBah]mH5}=] }-S 5C-[ @,9<%AFu}*ӳĂ/ -L՛,p9ŊxH Nc ˫C̲&SـcU]rPPZŘyEopxpdeHaIC=jEß-8O4ֱ H?15c?L1X1aZ^]3F'G'9Ɋ3ؖgTC{ن|ǰ/*UuNl7:>yjYP\Zy6J7=ftl76f)755a/h56>(qOd#{}n6(He#<#3,Ņh0!D㏏=IzwggM,T=H.O )&q"owrbzL.r;=Lqztwvg*&^uLڗ~On˿x#!%I@2lUc3ж4*|@=Y L6$>=g0 s/J6B~NkkyG:I!`APP`[.IR ɖ߬j5ŷ?)'.,]^gTT%ˮlfTvHre~e[UWuT gvcaݳ3'3mdҝN/|Pź[2|^%^ܽٷ#_zQXVIjۧY}esY-ܚ6n\ hHr=?45mV@آvvs`[1{?1>:38`E@f,/` v]b^+ ;'<`U,oKTwbvl{C=+F3il/c˲ń+_NF|Hf-!Otf4fezzx%QsB<<D&fcow뭝=-`؄!ZY*2()Ph A+$AFZk'0 Sf8DhR'R`rp|k3ń ~AX`̏LTwut--ommlJlǺ҆ΖãS&V=7)adGjlNd3! Jg:iwY39 L`BۡH =$lt||3W+~fG˧'G\{a82g(wtj]?:y=2Qr>U |N.jmJI=aK!J ;jX Ռ.n_Ia2Wr;l*}hL5b[d򬚽 &6Cދ ~xx=alh`@˖tǐ(LG"(U-@0 +kdےQ0Kdr#>]TCiUoUYlZ}S`FNW~QiHc ؒA!u Ta)B!y,ۃH"=LWNΜ\8W:ǤǏu?R6Sc0EF`>$Qٜw %ꝞۼypOggnSE=+v,5CV-!^Ȍ. s6h";Bn u@!h8tٖ vIZ"htS8@sd l UaqECVxxԻш .38B{v H'1#A\. lNϱZ:F8 Ʊ E+۲mFcK,C7I%(jz%F+/ƿ?S u{ʼnScbƸf]΁r1*T:. T+R] j"dnO-؋?PEpʇ㰣]-0h"4fQhsGgUYks;:>|odJpbJj2W˒yP_qZ׭y}5#)CNάCeu\oP[,sF*S8spvx^[+N70J&:8~n81i6}.mUX7'r $շfYHWNiޚُ5:Z]ܝT?/V&7iv ؚſW}u8O$?./OTH)ɋձ3^ ,N4>Kh˸I6O(Y$!@: 9#G|ScBMwe1JaNmHcBQ0GivZ"# h< X*a/*1{1O&|9z0F!(Ⱥ$,pr81Sxc$pBDCZ߅q齀&:E? pN&F.?hi#06jv M2!fڝ|ءj!5 ]XuK8^P= 0x2%` a&aIMB+< ?~`6N0k:zƹs֜sy tcsmt|(=]gsT=񅅥s4"df0RFU! h&9x>dѳ idɖˍP@]~(,AC$Qcys+";nw=깛X^?SR[E=CAuQwAr]72gv0.!9=>6N1%72Kny;>J(FZ)MiL)nXZZfaZs`3٢ƑGe =0klt0kobܴ7~ }+9- {7Bo,EzA$#+~`EkoY(o-mh/ml(k.mίjJh?qe'pud[&4x"hMe[X^%^#'-#! U.c^iFW'|z)[134,zOͲoo XPihCjHu%t v]:}S7g C2|W#Ciz$?|`-PM J3Fv`eZ%D{:3AhŠDm&+[Ī No4 ]֚AM`,ՂJz- L74:#FhZ?eeS}``ڛxx` )"kQ=ߡjdup8cs 쒄I)U#,@K#7c+n&ֽ__2>er@R w}r>Y09-T$l.nR]]4 mB*F@RjJFRPKUz h4~U\ɌԾK9z,G%[Qd 5hstS'nS KԶ5ޮOf5Hx^kף?YQVxƇ)ג?Hjv-Ė>LibݴؼƁ$+GE"WJ^,z7ȒJ}TVQE"&:&f+JZ˚*ZDdbl0KspqR[GA ~S/%ChD'FA_値jFؼ<"Z8=hî'{~4OMg| Ek. (!6yXXAv`o3aK\-YĸH-82dħki j/Ms,rB6Bj XKGos{`@S[oS{^$e;مuv-}l6Xws[7ξ涮nlWOo$^\韛k ?4599?3f/2&&qbl< ZdrY5ĚY0K j9:CE=17W?,Q[ {Tz7YZXᛑ%o>.}/ت7cߌ}=͘*NBNcuSOaeGc{_fi3y-EJ_SV_^׀;8<2]UQ;ך_։ٯټɾm̻+ qYڃ I0dx?vlehׯ- YB#\/'O9o4~YG=o6.M͟*)JeI;!S{nl{;~=3=I2%3əIoSR'e&$@${5`+՘ .`0^+O6~myVYZZ~{I[Zq۱Ǯl;v;qjőۏsR~r.T~փKPz|odΕ?[\Q+ܸ@j Sdm;TqlѮ;T(?3{?:tnױY: Ԭ sR R6$n(Xx漘5qk'%pk7WU^8'7y]}uK=}^|`~ϣ{Wz=J7mX!yᬙ,aO{}~ou<~ܔSf ,<<=k钘Ö.N;~ѳ&O]8f–,fW8ZfYi~r݇O8߱+8/ԝ!fݟO6{)ZlJO~{VkS~GOjaS]럄^ХCK^,9pd}k~g\3{~piO:XHYۏdn;dƒ=[J+ص*jeNGlږ^?wo*޹24asɆ3l.6{{z= wUm(ؾh+s7Ǧd'ϋIɎ\%rEFbS-\(bMʍ]4k.<7r^X\6eJMOLMNɈ\aڒ 6,^7mᚡV r!W b^3fN$%#-2-;?q톑3D'Ί]S~Kl⦤鉫W//ƮnFU,E/۾jzsARnA8p~Ԝ,\n~:^DArT㗿hΣ;q_nՑrw㫥Q9*l;Sq boN[wglݳ`ª򫒶'o)݈ZT:s[ 2K6'm_:c-7Ŧd&ˊY%icƼ 9[7nM(HI/ڐSfKvbJVb**RnҺ섵[7&fĭޘ2eaȸՋ'_xyW{MUlKf1ئhY"xE12!95ak"7GG&E/(**,LےaS>'[:>95q5 %R2rקnNLf̻a] oQޝXض(/'361qE*]UQN6vќ+f΋9q1k׆->syjJڲ3/4uy˖&geZީo/ߌOQƆ=oiؕGL~p97{_4?L{ԓCGdd[fʂ-Ə|toۇgD?tӟO~D;'?Nw=Gͣl?$bqTQںͫΊ[<3.lFQsvЁ3r̤ c=dO;h޼eK#,Z8{~;ToQYQ1916cg&lNI_ c}ᖍJϝxhzÀ;~Ӡg?g*=g:|; zӠ?q$S00َa˝~ݘ?dt>߱߿?C_ٽ?į8uW/Z>c"?/\lfwyxJ ĝ+yRv{I&uM%5ttF)>Ҟ 'OPZOƟ*¹ϳd#/Os:2~#Δ#2#;YiYlcn~D.-,Z\P Y~H0?'PL2cq,y(JSSXE*#¼<~yJ\Dhj |+ !|3:ED`*2bAʁ ֏]@*DVbT ґkC5<#KbU,TUq<fϚ9k gX0m>t8~BPrgKq"гm.T*`yH4Y9 u\eeuL#_ms i RiUmJ&Zģ*脮~Pœ$TI9i] |j:tD'i5,&.FfiLJ=Z,)i!.Ժ-E7򗂰ٛ!xj\UyHyvuU eڙuKnC͡*$,pS%p<5n\RN=%_q ʭ{$=$1aY]}{ڋgyx =e"wwUyi I.A~B=I=ų7Wabaѩwl숺N["t(6NSYB, 7oRX:b4ChRׁYg):z,c'ŭPO)܄Bʋ u)W6TZ6/ giBiuNڳ䐞bV7=|+@DA|\Yl?U'bAJj"PGJdCkۆn'AFcBOkCuOtz =`5I(,&〪-Uۙe۴qg;t2)Óvb¢nI*ʍyBEDnY-T>槎nhi^}Puúium \fgi[Nj- cb|`ƬSZrYӆR>VV-J!Y;ѭF+=s3'TmZ_R\3Jb8`tJS#)k+KÉ]P?zM*Sb%+6mZ&oيYS^QV|`1Y"iGafچ( AjRM|Ҡ{L 5ow͕fՏbNf,̘jׄQCK_VsfvTJQEI:4E`UFuW{M+\ʵP ZsPbi׀RR.PUI|({>z`)Am WB4x:%ө֯Kn׮d]MUrPađBJ:I0(޷_9saKi%%QѴ+W~],@:)8111gY/UdI&q#8aiA%fϚ=|b3t9""rǎ)1b$Dٸ;p11q]fبѣ1X{tTLїhdDRtF@Þt}{}=N2ޣ˿:"(@(_~eyY9E8@|F.%6*&2whѢqai DZ?~|QQ1R }sFL}0EZD RB$k"aW8/pE; PSx#?<#2q gҮn`Kz3,^DP)PsQ:9yҮL6r16#OϪUFv+ uN SYgy:Z|VtTnxN`MYf Jr|xS@o5j?Lx1QUl/'N7~шȑ#G?R4 :O޽>LJ^c`1cE9bH0be]vAh( -Z Z͛7?11i1P㸒{n̡%l3wTu̶X'|Ec>,Yr駟FFEf(Esk޼y8h43g7n<;~I&ϝ;6dP566s`#^zfKZ: ̤ѣΝOqƎ򫯾dM;96l/K@mɒe4pM¸q{ÇG}'Ch~vݺԎ;ZEr !!CAEL6OMC!pL\:)Z֭[%K:0ir|\̗ 48%%O*KHHӧ/޷o?0N=vԩ,4 ;aD:xҲ݌|M^*؊ ?M!к^{OөĤQF/G.8yL<0F9>>KρTނT;'OJc=[uE6L4W-P35ؿQ 6u4oaÇ؇~>!$FN?M#5%rJzPtiNڊC@y]{0j9v')A.3~Zj4{ fѽ1&d)ocΤCW_}W(=Wt>Q̚ t Vy >VY/ #DFFCfDܹ /Oh`(Z `rŇ (>۷_e8 oHä;)2*LG+ jFYHlc-[ _ m`@sMwEOPGs΅aac +(,">O߾Z c4p :B]vm?(t{"mIKw$EH>} j qq @aee*"2'(d}^3 & }F l۷/1+;j@o7,ly` ;tPPzj;ö7|#ba0|9䕘 `AQ M*_4jرc` ë`.%i< ΢ŋ-[gqbټ%ֵsg5](&6EŊۘXxQTxɝh]O4)ON?a85 W!Us)RG;:WϊʿՃY4*\ʭ m1rH˖ѲyЗH^Y/^B㊎&hyh28b{*tf^@@jP͜5 |SS7/zgFF}Uep&MG76}IH,|9GES 'hG9r;?$I 2G,Liьsx") Icp3:TǎK2tjj*2:  8 o) ݺ ƥFPҩ-"p*FĠ?G9\`Y a|JJJ_RaEzܹTPCgyI͗y_ZYѡYY_~M0Ҕݎ4Of&kmwR:mlxUhTmdd$== dh*D[BިK.Q^JwNBU$bE(~SN@<҂% eF)-- yi^3FrFGE9hR+&+@$D(/%~p DtxT刊RADAb)/Db1JDK!K#>IwB Ɂ⑘"Mr!3]n%]I8^[iҍYj>2o񒡢rSZ}gC)+W%uHq Ϸ*j)I]9$>N23%O2+=B\"_&ZqNpq+Tܪ 'Q"eg R$rKr)WtE:U_cs+ΩzW-S"mPNni8G{UC6T u8@!9:-.ϡ%wSjU=kJ)u 0wC_2 iGJ꧀P&5msLp9*QcC5諾.O#I)CUڴ%w6|msBG>Ћ.dʌ~# Tn|Jj&T&EkguCk*Y,z1a,f(ɓu g%SI럫 ni"*Cu׻D U#.Ց uJjAڃ'_xc~~kzHc )<]LQ&)Q9jA1IDPc1 X YajeekȓD,r+kCEh\qiCU3QZHjP2/I6:\P˪mYdž?,pG˫= βСLݻ,>|J7jg U^^#X5:x-XPa 1xg3R"Q 7LI 4u\!+HR;?A(PӋ\ҨJt쇫(TO,oS0jlx0f]ҢyKfb2y"1\G;)~A޽gw}nϒ;v2;xLA9r* !} |衇ymL-?*7:Tw֗PJ oB"֯)Q[ U/pyz= ~:ĤTEMRh@Q]Z)V꧴q dL}{ر:yŊGyo 1-GÔn,sby=hՉn"32^|>no1'w]>}0|0tckO>$>so2?1?~ӦM6lI,((/2^~e(cCYXd&>#}۫ȅ}[>;w{O>voߎ=z`@`$) ”-fU:}r$I r(BMmPLA(w|CU' oP .aI(TW E(݁ѾG`"┞7;Y TU-t</f1jc Loca3{7n7nčY@nl zX#+u]رMHL[T[ouwk _7 ;X8b?\ A 1wE.9J(C'Ɔk <#t?o]0 ov&ۭ">>' *3u;̂xV^zuם'Sf\\U+BCڗ^zge -9bbxE)< {Y@c:"\ fm`e\PUI{( /S߿oNޘ;yɧ 0$2aP3dIYy0˿$:$_ꮅPKC*B7_*g:Ҡ_;ﻡ`0 = q nQN6b>n>裫X3a`Og}'L(5a%&&% ;'z,-U^܏>^j&&nPmb}GEAʷz,vF_;zr̳֫GrZĊ'.zM,VP{ÒubnၹhKEGq.M\t(TKP*(uCtB͉ %)2N51^6wZXvd. UPJUSP'gõO OӇEYVP9@V`u^Q;R˹ɥt`Л+W7 JIRCϫ3- 66`{ OJR^BZWƒ:h:dT7.)P 2蔂e},Uuj]dT uw]EIӔVT"T.cCkHF0ն.+WU32CG+Qu~Nr,fm ^<z+ +؂Y1 m-p7fa B_*T۫ !+x  *tLZaswPWZgrW6yֳ|j@y̴eX{k inWmZ#bd|lfUN.a0ϓV3L$bK(xpb#>rNpٷ3 N$^12>G EHJ-4lgk!+8N;+ۡc'w=Qmgz]~ڂuҁD;s yOS[+C! L%?%l ZV#" B+|}Pte>Z΢%c1Ah_fg9&ٵ{wllΝ;-oȆJfW)wfCUTp:iY5eRRT I(hpq֔V-&zIe,X7 R4.݀Ge 9deŁDƾ;؁DX.(,,(,,/WM+Wclt ~ח{`D!Ť7x=)99/ %eH1؀Yb~ko #yD6TIsYi iveCUKC4fq3~S'Ox0 LnnNEZF`ǨѣL:uY߫kȘ1c ҫW>}lJMC*FƎΟuftaaQ\|ܗ_~]bA8a6! ITQQ/7ڵϻ({0K Ƈ#`[wMU^nPT7ԡ]}[a&WM8m PR ˋGMh,KOσG;vT= r TUUa{b(yRU\~=;vldT$8XW,m"c 8g0Bй8(!fXRfșЄ7l[?++V ϊ^WpEgaw*pZ|ioABIVClZHÔzF AWnt(#Y,cSB4t`qv#0LhrSا]BkPŞ(Ϻh?-QHC1H%#ޡRu*AylCMnxujН~W\yIۺCA NK|cq2{̊QŇúUP6-*`|aXͧVk='Զ &(l]a,G2l!QUluM+ F gb,mW3 F[_Oje%Iچ~ە u76$+_MC?ftƆ^j!vH톮SW(:X:["]_(sutN.;a'_f!IRh^Khjp]jFzj^qbG),ߙ@d@vCkNyu!jƍ 5R+|mP݂]*Z=wSz2OC5W ՘0 ujĻDZϪ9|ҺuʅYW]5JՏr!'0Ҁ>N׈0AD)UFb)2؇ۀ IŭjA*tI&ĹikjҮLIIIQ>UF?̜0q}L:u͚*/uyIC7ګUzJD54iC!/_{FH<ݯzs9B8FrBOd嗫& ^_=>l{c?)6gpZjƌ8pȑCIJt lid+zz a 3Q }wygvvvEEƔv0(p c + N헓scp%<A_j1W_aҥ PCaN?*PH2%}o6Yʯw}xƐ??Ca+<⋘Nݲe ȅ}%pJbC݂Ұԑ`:0BE5ҡaC[}U; `o5~=̺,շ_gy>hN_B4?6l@?"r޽׿'$oò C?ah?<2@ رc=p+Ɲk\{lQ0n}^@c=F> $I@7%} jAe% $* ov-)o 4x_i` tP6P:g-鷪*՝?juM GWUWZb#;*u}`[oYò;3l0tB &(Tk>艓'm۶hRDFp0/0 Ƅ)TFӟ8)C0 ct 2<,Rǎ O[-kPO둼PERnP%瑴+jI k~7<}LkPrZ)u x4{t}=2eƆTsqvSO/YG3@;&)fljx>?oRSQ'TtK*vsմ)IWO9vcTt刨ʾ ] =cCdVVV7DśN;}; #YJ |Pl4466=klxβc#9qeIkGT[XSk>-)ϥ7+kGHNZVDfdꇮR~8̣4ueG*ЙpR0͉N|ŶXR 'n,pPJ%|mZ?գl-ԑL6 ]jcc'NYKӎ.3{FDV YnnI/^ Eow/b8zz:=Pe.Sw U@cHMk$M+.-͕CD1[S&DDxԴxR+I\WE@Yi^wW-RHȹIűYFGkچᑁ,#Su$X}Ζ g!oΝ?gI' gYRSec8ǮD Oӳ[ ,R'- ܍+ lN.ѳ, ‰$DgIp-kYKS!>ez S+f]ҒK7uHYկ00 Y,&"z% -^a VO827ָ8tClia)[\ 21׮]60LJ]4}ƃ<QC(Sd LDc$֤bZ!#,Q"3B1fMRmaD ,|6j@\O:@Q$3o}8aF"H4 $RR7b87}U.wEVm[!,wfo^ɽ:RO^UoB%&_!%n&1PLIw7@@Vm^!Ĭ ;tPy'Yڒ8fe@l3hM՚jj%`%B̒c&x{:YYS-Vhf5lŐ3gX}LY_̣`o|-_@wgY* Y2e1cͧl!sTHnʶhf5b {6\a ’ qmÐ"l>dL/{tV l!mdǢDc7 iՉre}ZYcARH67NĒt(f`}r t?nv6 ޽ڵ+))!E; .@h/vڅY̿٨=`ȀpD`g"5&_NȦv#i8AP{Y X @ Ǭ벟.`a'3[Qpdža`t2c LNl?`e욾[NfvGml J`гСH-0/$SRaʌOX5NC.`+=ֻヒe*P+V-G!,K7nܭYг”<Y>PPT$ɠ[--- la3x^[4A_$Ӧ N!$ sa\Yj9rb%+, b e,@:13(#"Y]+4|҄4 F ˌ3 "(͞=[LB"$IHH@_%0MEZR|FZ ]Y5@@~=m(JH1˷`vJW&JJq!f1$k,KPQܢp$B)I*oeaIЗ}IjXkF6@I>`;VM%`V|ŬQZ ` ,uVXiAdDճZpZ֬n& Ǭ蔂Ç u35 [V+,U?L`1סeJf@p̊NߵgnFaj%Ђ%p-*^M̈́Yqb(7 ANB- ugj6헫f7ˀOPO<'lasѳ^_LÜ?oo^>oOk7S_]VMhDB_6aMr%pm=k[w~Źr&3ؚcΝ6qBHEq7߮B0&*](PU2EbfO1cO`@&*qC #wwYdmaAf",֡$?&:*%37 [_f,Eە= 'l/8rHxj*lQd$rZ=ܲG1JL_yƵڵ+$1XXPP`28i""vw;8$Z==ae>}D' 63K.lV&?'6(A̮B~-;UIE R/"S$P6f@pjFE(=> 9VI1aCfСXÆ d7do6lȋm *Jw 0t}v<1\frӣJHZziFD!_`1a.۱c6ŀ0C&ꙝd N:zg@A3dX7AxP& Q]Q) Fxp=d( c`/m" ljyنa^D'BBYdԾ}{ "wlO0@/c f@RUg}j` 2m" 1 >7ĴNYY)} z{c`=aCrrr 5Gy@~2 ¨,;-^zQRtO a,@,Y"`VsݑF1 &̺uxb, ̌qЂg F ѡC&"x x 2ê*AAyy=ԣbӉ BJ' f l`&o^x\(-bGF/rQ'A:&}(8˨)`z,O@u%kx"P$9!A܃0fnYq1Ϻ"h!_e狀:˩a_qD Մ4-)+kJ>z0JpK5Ku3HDBY"NNTr\;8 {C2kI;6bJmOzd'k4cV;*t=O m̝nx;Ow4F $y]( ⑘ w4wyq"qG2%kMrׅPGGV }+Wsjr׬T)D 5Nxꥫ.J% $ EnFF"ISƟ,Dh""#L$ AV:\$-A_t4II 8fEۿWc0K>3$(P|D>=y\qSD:42RH(o{"H*6"1MDvБHML;1| A/~Μ=sI5M~5{@pMۚow_}%GJ'"HoBL A!IّȖ8xBWOSA^>"jPԦT<ٖ5YB/]۶۷1E[JGp8)$ӷT}\/jht>`݊b,bS26I8Jdi€h:~ 'Hl|ye]@ `vr'k*II(OL3-'c8qs앋WZ3tTͨ5 N.~xpq 2fn)R0wqD!%Ԉʾ.=ŋ #"f $ 3߂DA 7jhK*HCr $F.\L3.F7ID:$uCB@>HQ}|U "$dmB,:TL?0,h=C G%k|c|4$8٫-x饗8J;?^QQACg%!Xy@sKUr|/[6E A/n`%Y.JROV``ji EP>Lʐ <&>f6Nf}Y2%dVˬ!Uԕ{}m Դ%0X&tdhÓY:i=~O?4x0ziLҔ)>c%`/=̪0=)&d|'bΘK_#/qJAt` : >Bu@x2.&Y"A`"˜AJċ-'U {&R6xoaUgD>H@vcE*P" xH/,` ^y!!Cwq@*@H$|A"䉃PA"fxU8vEAa_M"P)P~ٜT͇Y6_X2Rt'X&C'i$.IDATh`=0$EWp61=G+(q<"PqG1P>@4tV?>u@\, C;P LINpj 8PJ z0r!t 22G/ zb!"VrP (RbubkD,BAABhJ(!E(i$.Y=:6c8z-2mA܂koX<6 V M,fy|W+/LK@ {R@^w6i4S ҾjXR;4 BeH8$@kU?tYkJ"/FC1:#e$DAZֻf@g  <`VHgA%rCtu<|RH:(cpG5yK!)M-th o?6&NΡ)R;d?-Y|ATTdc{e&b3YuŊ.ZgV()(Sш/ ! P:ҳ ፋJv7o(G(ne~cHP% B3|d)XfK,2hF/y}g!j9#G␧Lp5_OԅT< U.qeYIKpOr KꅄBuJKf/%&*Df285IqB>kjp̒yC*P?MRXKJf)=(2nFc0w)o?svۏ %AD# yRA^BK*7 e p^ c$.Ѥ8^IsʏͰ>'c? |Amg]W/`nʈ2G,A"@+ >Psׂer1\5ۨѝ&+`Vx`$C!K7@?E ]hf HB=q  $vAǑ֝@"";]7,y>%,a"Pk?g샅N,A`JXk” nbGZ&:A`$XbŊ' b1YEb1$X$YɢYT-隀YW!,0U0qs)">-/ 6b!C\HRCxá8˛PK TBS (%),̊/qOv?IÐ vΰ83,b~o|d\]R;FF 8& }PvdqH^D~a|0哓ʇ|Ktքn'oP7'/\Ps"+ BκR/A֣lPyBIKdFhQh>jQŶ\S~!l+PKbV%l[ X 4,f54--++PKbV%l[ X 4,f54--++PKbV%l[ X 4aȨʾ ͿFnJ6--+++-c'L]vt=SVT>*zۀEEl;hQ0zřX56v۰eC˾RPYR8[4-++!00?nndܨus#o~ R6۱aۨk[ +# n\Bݫm-,*),zV۩j[+6!6^\RTX\XP\T83=-/7bVc[+$5+ʯ8;3-?7bV[h[+6"Ym"m1n X̺I*JH $%e>9WZrbD1ǗI$򕝘d¿AVL*/3D8$i}Z 6}tϜ9 $pvnф[NL k!/o^=++H)1d$8nܸn 2eʟ'N xb훘3~xoVǎq x\;8={p}88erjnz$}oyw++6 &,ѕг^ ^ ׿r '}7@;ӯ_?$أGݻ6lXΝ9/8  <5k|g6m4ֻロc8*CmƙTuV$kΌ Vjf@H0 ȱ:u U( _sz~; &++ u0CbHn:W_Hoʹ>a„z())oy4!9`111\VZ,@  ql{0 퉣Y׮]qՁ0+555'' 7 04p@"3$$=W^!icǢ >GaH 9~ꩧDdLڪk2o%`%GZ]L*GedOMnnNݻ9駏>PF>Cɓ'3R~z*p,(w(kLfדO>9c *3 HL;LO8Ag}cAfdž[ j ,0&nOӀX"`אQ+k@$w:$l`7`}!F)xqs(4av˖-Xa6mY܀I0Y4 ؄ayhY},-fIA f)̙3b XƠA-Z,t۠gرM>:X̤2V”#('ioraVnNƇD 1A#S0G#ic[F(VL3]PF"#I|*r}#rK,|皑}$Ixˏlf Ǭ`c+F ; aԤP_ȸ|#_’; w qJ0OLZ$.U//wa" 8 ^eꙑ/'M" \32$ K3:H 8f5Rjmi=Ғh=C4 .H;ùCDڜ? 5 ew|d|LP3]0Ca^e*$s1I/}Δ "0eF((BS ùܙ-b&ID&CHF27O!+r2e-IRR$ X ƒД(#:Bh'!%D&wGC)$LJrQXDjf⤚ ٴ,`!w7m>)=O4&>#Ϟ="D|)!KLKںKA}ȋE#>uj,C9)Чh0'&BY*GL /6Ł"@a\#ce_!DFЧt-L{UW“VdWN* 3z!CX@sܬc R~1KPV+,kI2]t)Lt] UPP@"7xt2 YA^eK.Xgbh/,$s:6FA*yPr$2Ed|駬,8! E+!XB*Ҳ@ YB\K;t@dDMA_CŁ/&_j:"` R,dE,p'@IDFx<:t1-YϐcCWd4JVQt%zXz#b1ACh&ui(#O PAyP u O 5xeH0k H(FyW!@^N&G. DL!- D8Pbāo-_L N OLKbV)7!,4|]Eۥ.Gцhy8ht3M8 xJZ"Pw>i:m|h\CϨMx$'2qŤ2{e pA_xh$ƄmaO>h¤a#!ad$\q'h=2hg~CŃ4}' 8KǖG u%a$0|IK1D Hwfɋ1wvRd[RQX#,}Ǹ܍dYkjV $8H[xKrgA&;I*JJH)1Kb`}QvNN _KC%.F*=tl- eHIvl`hņ~ |Wـ#xPl|++),lӟx U 4lao;~4˱ UH lc'D6`6ӲMglf_! &%sA}v{+٠.h€T#@e֖JJ $Ŗ?|Ɛh9 >E-ze4[nAv#Mwu{t YXYΠNZ`s;Aロ-`&,fVn%Ж$Bbq;@vqI Xѕ'-(0*a\nX @O#\ ?٠b <8qc8bV[j,VH1K~ .;z?8bEa h zl+++HbV}dX X  Xj)5a`VIQaT9}g^VVV7\!,v_H!HI 1$I@Ԯf _(Z 2"(H/'Iymh.~mO!,<ԍ_%fod>BNja$W  ăW}(/8q˗d%/5__I!Wo*wv~#-lhz2 ;YYIYj`O8!k}[.$d#S$H!Q kJH#gbAb/( )(Y~}rx *C7N< BȸEtAי}yAL4M)L*q2STR?U>Su'rDPD^F>mRMYң8994Z$Gs0͑INOMKlBM4k9+K7=~`==;{G؍Ȏkҝ؆FVƳ󑽇0 "Dd*&9H D` 6(>m"K( Yz}s~H+=\V)pΧc١;; ā|(&9&AJ+лwoD8xDڸA l{TG*䈈 IDjAv\ADH5U> ?1­ =;򲳽O>X'HEt0@FLjP6<=IMqMJ)1 I3#Ǧh^q=hw0hE[qM3i|+;Á > 9]v fa.=a0Lҥ nlSvS?bwV׮]aHA3f ٰVG"٧- l@[qI|Q6'n9%%qƱ"$# EyAUC#>Daɷ[nRF,^' wι믿&HN:<4A+&LA4X/A̓ɎNiӎ|sQ^H!CL qȈ!CaTlD(b&-"/<>,ɛXT^9;UA_}lۂ|ɔ0W_*ۤBY7|M4b{֏'Cf`ӻK!>IsDl3o@4StbA b`}P%Ν;!Nr8A+ 8 Qb=1 %8dMG“TD &-@ +nP :!PXP`C\LBmɗNKN(Ѓ5"DV/DB$KL+H) :bpPЀO' x=T$bP+h!t95jYfa 3@6D\@PΝG y@ @AD$(ؠJTqA7R&}`R {@H0'0.*5yOGa@8пh2.h0x3L8@0FOK-;qI)Ih[07[ahh#"ԸhC!PRv j:ɋB\xiѿ.IrD80 DIbĄ[أC$ȋ P Fr$_r #E S@# "5ˆ* bTBulE15 yB~R@z#7DPy$FL7TҠF)AFBu@ O?D&%Ęue}IƆ7a ;M5BY/BhZnWF@H0K^AҾ}=%0,HZCB\BMK]q d$}#orR35%umJa܆mٙP_Y 1Y9PI.pž[^|JYP& C jsCDI+CSL/ nM)lI WnZwۓ@H0Ɗ}c?ICcvpXp`XAN cbŬ mY$&,bSp 1lm#,aD `,nX&x&DE."sRq0<*-fboP[B-LE}6b!M{<5_fC,!`ږ"11ЃbKN/FEkAL|ֆoW #2uZ \BYTj{ʡCSS֦OܼicM[/mӶ hChu W=@A~^fFZVfzedeV飯޹cgUڎ@PE$*1C¬t''&Qb"c"?v>-[䢥7B7Wmyh%bϬ#- [tY!j/$aI 8,f$̒t{Q _2lri{'G AWJK@cVRiqaIQeofN뫟|.$871F1BPp#<2YBٗɛ͇W$%lm`3LU[+JWn,/.).l f "Uf"R#]I - b= MȲFXy >P-fyUB e%$f%&ǢR%;aiK g ,G7s'ߐTY-IQvr+'n;H;,̹ 鞜ɪza5sfy>U/((a+;r0/sp)+!>gŬVQ-J/jX߃vrSꝹY7M0ˈ9| s>;Ѽ8l*7n`@ )Dt&ΎGu@:N?g" DAcg"h'ۀ8NPc#Vc^YloИuhf|Ux39 Zm޴IaYB̓Afz Ř_&L CL)Xs̘1ӴiӀTs8ȖeAq>-R:U*V1*s9G $HDfqϻ6Hvud?ikv Q_??US)3U)Ui3us~wj:ܦ>9(yf_#G3oz_B#׎[g;R=VHOEyau.| >;BjsZvrzWr;^뢣Qਚ1Wi[{xt am E1r?uȮ=) l_ҀN᳛twB\n?mO_S]$|*pq1>+i_b" $N_~|Hw9c4jqiy>2N.C-p_ڋkTbk8^1E+>-4Õqu'#ǮZ%8Vji$QT4SEy[-m-OU2")٢ύˑ1> m*2VJǵW-1jW_¿+n7 5&~zҰyqYð,bTsZ; =pX;hpx n1WsP k6jthvl}ٻ/̒?^yg[q9;k*Kf'w`B_9ӯO'^Yш6_]< lv1%ܮ%~7mbӼZ+6E&[SicZfzf1cir5AR4QN' (szV{ \WnWϚ7;7F[;{ u$S3IUՆ݌UѨnjn%| yڧJ\TNC0PVNWYC򧽪g3G^:s9c\rJ:ޣ]loVw8iir6sIׂaq\Ъbhe]ی[39i9S8#]^tz9g# OCeՌ't>|mw w6]7scXZ8{9}V3rFf yĦ%|bapT]aدٔaMlGpۿy5{z֌T2i\OxwJk@+%WL_2@1Ҵ1A_;NAyj-W%k]nFfRm4a)WX]P9?̙U;c|Mj/rTC\5XVQTxVlcnyɄ>wH#a0h?Yڰu.֍'L+ z#\?Ji^Yp&ͲA9gYNjov16ڗ3:ך&@ѵup롤\EQrf%J+Nq|sNi,O )EROuehjygzb]\)wN\ʂ|>Z?&q ⪑֙> #.-ˇ}W5S'e)GԩҜιu9=˅T{>ޥe&h۪9.9 m|'7.nXěV̀p/z8" 4 K%]v$sjr6MT̘3{_ yL!_y_w*u?VV%DZAXiݧӚQyQ띧S;v8qz@ml(oN4쳗 潳yݕ-ۆnO0V!)5!1<^yz`;U4wl:qid_/LԧN0o+ z%QZMPa*1&CQ: EYi?/{q_[MA $7ؿwW,nsNqx]4wKw3g@ D;\cӰ1=r5N\K5L N9*PUR}Rf"uԋYR[DOzU=NirHAFe:10g?Ml_/`|Ext%۽{xqXYTs~nI ٥ݭ\,?0Sܒv\-0k&gf7p݌MK~pIieir;ű-!Ny";O3W4?kZ-VVSږoLNfmIs;!ٓZoLNM!ܱ!VO;'OZELm_y#:_KRXs &xWHdl"[f֝J A Һm_4o=D8~0gzx@IfNA@&Mob-A_5<˙)3bh^mKFRz^7SʺW+ W)i]|\+(JX:s!&˙Ǯ&Oe]em48ˆf {3d qw [C\/nNT)cv"Z7,G bAzӻ7Lm*aϺIU9v)/uX6U<ջu)D"k-w # A; 坌V 2Dhph:g7_@f+SޫNo_ ~5pzһӺ7~c;+A>e5-5ȞKoT4NEU &V%7[K$,eՋ$<-Җ,/~Ife-%5 ֓[$m#*"KiA<ٌi#tp⌭_kY+\.\]Dǵx!ZAیv>*~sG/_A 1#ѶD?">"}N ()"nXmr0]+ HK="f`[ksU#+}8ţ:qvq5+1@alV=*,!(Y;%)$> -lԌ6ɑą1uIf .m~R:N;7 IIiyX#|XUɏiZIhšNdoB&#t;;<K>:.%wǶb4IN67F} % 0Gĝā61 }/9ܮW+I!׺%94Ɗ\˟EU/ޫ"dO`zieBqBHD.ܭZAQ»UهٓHl[#8$~}Bz|"WڣN%?ؿt/s̩GfoG%t 6Qb[S{{˾p臓4]'/jȩFDDBqQ/zPp8tdP'?R bl.O/GN#?k9n/r qp`ί>uyg'reF(< vHcgrD64rVɓO{/DDn)]􀿢eDy*Ϗ/?@'EvB0xaXl"b؆(F4h}Ҡq1xt #/Ij/%%\bŜE\b0RI]ܐɮV̡ Υ߶8rލ|bEkE]s[^\=,q"]yG <\Ф'w,r"w{SS7 ?{Ϲfr˿Q".dT~RSr><3EG˽O58gS]"5hANC4h<|"G><˄g@7ݫݫEʨj1$딐?iP#ވݭ#EAhv}orrpr?$?:M;N%Л3͹c= ˧;+ fP&]ٌ9Nŝ1qcO>:8G0z3zr?zbc{FwaI9b ΰް%bw/֠֠ݤVF:1ʫsTo1o5 j qc{GZFjnbX ^%N @7NA#߻ڿq.9Vn ;7n ${oؿ5(<3tps᭡C7o:ѣ;#G7FoD[c'5Jc(ކ?1vz'Gag;?;;=~zθ6&ݛ<g&&wQ)ǽ)iǃIgєC̜EO;8`fѳdzΘYףYW̌+zÀ~p?C sX jbf=sC'!aLOxİ<ez"Jn#zyatrܩlO ۓv'q.JfY3H`8H g,vѣi;813'gOϜ<>:z48zéK{0y5q|㇓''ݛ:Eè* '$>(!SQ)$B*]PJ8&1&TAJR41q"؅XrJ#Җ(F`oj$2CBÖ'Rb.%hTB=Ri0G%].JL #ES=4k:6'3) )S/n†I˛qe.Rw% &*Y?jt+#˜,W(aO] ZJĽ c)ߟ# #x,U4B~j]!cW6d`w0Pӕ! "CHM7*KTʂ?Ÿ pUV , K}/}/ʹ<|pTߟ@wߋIT&B <*}0yf:g5; a9 7E<z 2Ec ~˚1Qfyy'a<[)ҟջ3:n:w^_7UJ|ta{J\}<&K0ު%M~ҙ9p,GI@Ԁ&5]MuQ?MZfxژ?߾ (|-[Yד_wdTA]]x!w§ZݤzRkT/_>eZ  [sft ^Q풟T۟+>+V53gFfϛ?0QIG֌ޔK4iMD?{7G+8>￐Rښ]{EWsXuzN]I"4OLFڠ#}Hh&YB!Y h$ C|*]*W}n׼,  } n"!54!@!.#Up!$Aw@ё`d8 "Z'aؕHM,feW,ȣe* p!gwSrɨ7{;Y]K/\yP4|=kމ+Lh(%G\yAreʀ/_)~ǿȨ޺2^' QX]=杤w~aw_5oG)7˛JD";{Jr_|r|'읻I5sbG}~{oD=_~q'wx3d _ݏb2+=ïވK}f>8Wżv+lNqn7aV_]A&w^^w`Wy!}aic_P4z#╏˧V 8ILgi!1+a|G5>Nw*&;)u/w~/g-iuc 6RO!"˘ތ)ݔv7^|7*;?Va'?jNWfLH@9ٓh-?$Y4CY4 $"FsAR Q,GH-?#Z)f$3~H"8=H bnH'@ \,d6}$4O$M(#%hLd$&:QC` @SLJ8]P KC˴ʥ @GM؋l9]E[ 'K)!gLPwNljwRe+i5+ʧ7q؟,k~v6tf\areߕ啛 >QzN_rvfG]o&_ULRnT_~x/aqSBP7ki}06aaQgrgzzrk~(O_/q'wOV>O}㜌I] 8!I.0#^t|/=温[?{;YNbryY2[:h*y?Q'n 08GOo>{ݛ1%]@ Mܫ Sgԏ>Mid+!`;Ej1 >Ӈ3g,6=9#s^$|s?"HrǠR4r=׬#\pbak%&ܱPx7՜3VdK&7I@?&TӛB znBHbY$NB(q!pKA.%$ bW:'Rh"$vz-J""! i& ol"vKFTnEɑ%Ѡ7Ͳu-wUZޞj֯NЭuæ\\7jY34sl|AѩPzkVB 21IVbi 7$ȋ vnꑅStEO&B2*hC $*zW2Cg:4z`$ћaRQh|r~<Q*Fʭi.ݢo?j" Mth%yp{ӧƅҞtP>S( (]wB\?v5ךv?t$: 9{BixHa*sz<눙u@+=q[1&2 rϺ07mhyLl;3Dt፟ёb:&D/Hs, D `xpID4A[9x. z aZ%6@27 $.xx.vC7@:!($]B BHAڈ䌐FiIHȤ[$FԖ.BMz(.P4-ggt-yUh,:Ѡnmn./oW zˠVo7;m#?{Ne;ݛA@ !I@BYHE1@av0 |\"RCCB"#"y0O+ <ʽtphDo*Op!`F5b5*-\.ZWKrmtHaz,f\M.{XV=Jjk ϐaDp=!W*i]=ղ|3*jb خe`c{+g͕'K.EP$īVPQ˙}"ÜayTw~h.UԾV/>㬮ajR%?ye`yI|)H/y=ܖUcfEzVFa"D^ͳ/|2W\*iZ ߮Z$>J*i+qDw/ ˪;E==_JC uّuR=d"*zv]b &CfTz\yOJ|nj\y_jNXѼԦH~,zx )4M˜+%)iڷ $-o FѤ 3;odw/ȃg#Og(cfPI4fPRZ!1,z4PE A J)+Jwλ@yDv sDU Dx&T" D|O\LD1%q<ɗ@"aVESRJLJ#JǁD0d҅tA%B)B%:"9 a( !NCEOScHa "۝R߽g?ŕS5jM.[-LU [զA &l9޷:):\6cwOnTl gP8 "qL14.8%Z%њw&:L#!*!)pPN)Bc!).bhZQ ҷ@AX* 1Te:(2, ,c$$dz`b䚏K{iՓ/򗀯 \qQEѧr n?L,-j"{AIլT4P{d&]T iHc[v%~nM[T&ϕ##ݢF__{ש|G70+G(]pJd@$p%^víԒiְ**­Tؗ+K>HʬPY3A.m&P%:!a!^IlQ%K>|j>FC*zVEKSX|E.+t,6 P)x%X_u BϤy y+(Dv˷RWit`Xڑb~K(vB(|qs4bNN󭌶+E` J(A"ȟ*ꡥ )&!j 1@m_'5,Įut*[>]lP%>6&҆.TUNfVP\ӳbuXXTjB "˔8.*}. EPgTբ 3 l$ɚI6|_b-#6mB[I{pYlr*XHdn &CC@ J 57%4HL LLz%qREXv B%O1Gr! jr hHD2P'>S²$d>r:ءH,,nO|;7{w  *ǻFv˨lMz~{l{NvNo6M=5mϞi3Rv`SBaB6(P`y!I$&S.|@ F@Zʔ{RF%DDZn5u ~J6ϭ5T&1X.UtM|3\.%UI(,Xx\4FHA?ޟJ9aS/?myVXM,_c ,"5'vyhxJ蛷|.$1a4lz(Ho"c?>wD =bX[ KRx3Bg%0OOfAXǓ sX ADhE+~,~JրkH?!`%: nAxM "g"@ H`ڋ;u$-D+5xJO2˝,=\bIO2DM6!C(ʁ'\|*| dx(HaJ ~ )J'5V"<\R4?DP"EY0H'1DV6I!!D~(H "e̖9! T;Q2UGVa=ˑBj굜/W_ⷮ}~W;yMs z]VYv6ˎnCmhwwtVj440kߤ=5v5ʣFmܔ6; IYU"p!!@A E5a't:;Q=4P"PEJ 64@ !*WQҽh@AJRIUm*UTQ~EvRMmP XĀ8i^l11Ej蒼 wia+/k"{/|(g3LϏ8OsOXL>wF;y׏؃I%kXԘ-jq 1KԈް6+"9bIC" N" b>18:L#h87l"V>Û@y%QIt=QI,Z%6SI= 17 ]!H_3a8Y^ $OדIA4fN2r8nҺ.6G}Jv/h(# a#B j$cP^ JQ,wijJ"rI4h7!"$JؠJW"Q0ނkFI\gM7w דvr4ϓȫ>((EI攡:y ?Yki *N8HKJyQX$ 7p d1)@@$w&dH\d^lPp! sgP0)1ɀMd@ AzHb)V範 V(X26y4f!y?GO|GQ_z|_/}>gh{cCRk :͚|eG= Vñ>y;]݆A!Vk2̴.JEza St܀;" 'H&"5TiD R`f HP 0M*\^ZBTRpC 6( &TVmSfU[tS}VKr1n0 8a9g$B=KE9:,ҞE7E{e|$sxOg,`J4}KBykwpNSB4&2L!5J^_w0 7rit I&2h N84$~s"u)~ %6iC~eߛbS||oU/B_d*(3A(g. aOE,#xwݏLJDAS'I`86AGMD(IKTjґDI` /҄B(*Yq0,D"tB0/b"%Lރ>+;PI؋` O;-R^LMt2TNfʊQ1-TM]䅻㻿O~Wè?AjCRY{ekۢ2od5Efө ˆ5F}@yfL%.[*Vh$s$ @VOD0"v@0PҸM5mS*44qLlԓaIkиB/ 0FiP _źApa/ /7)C8hVދ8OmCH<y{MōgГsT,GVΨNt9}`Jl\l/o]ٟߡ9QN{wa5  h<Vd dCjRw5:T8Fi (r yCpNLTR?`oYgVP="{_GeGԂ=` 3mlaTb@> ܧ{Ԝ?i|(?1o6bנ񅡁7CvD Am>C;i͹Mi|Jo` as W?ږsqψq)Si>Vܳt#+K_p~g,) .$G 5J4LVFHL4p <4Dh06 }'B*e<.V'"bY0|/.s9bxqDsPR([>"H"InZ1$6| GR )Ʀ,9x=\LtPO/"!M"X !&jhPJ :x+EKeSՃUj 1myE2rG/ґ {ðP G{}y߄}ƗnnOVd%ںcnm[jچdɸ0l(LUΆ¢Zk֏mX/x*A<2j&>R "\Ȝp& 48U զTvUnR` YC2u+4w*u%va168`iG javUAPO;#=i7F`KBU&\E-Z/Pk<_,vJ߿B,gJ`L~jrtdRVVUIMiͪJxSÝO/f%j#!ǿfVĺ.>7 nƤ=)o ~F0uZXnbѵQKE7]sj᧯2:?dϒ|4{;AVL=.l)niꆱR3x3_jJ+Q6+ [_ajR9vi۾1EKKl7NhLW6sIeď8rdK {Ć7ݓZa^8E), ;epaXnD q3x;'X8{w=O&"{Ie溲<#I DA( 3POB 1)6 Vey3i0zOyBh%S1!T΢/.q  PR0_*QK!)_,$$Oº0J' <J$H <*Y ZQeDF݄H Ј&,pj/_#3k֑DQ8BbÓ7ͮNjIlm韪\ Z W(¤Lf3ܨ^Jho?ceWoo\?~_xsp;߻÷S1"Um%&u~M^^)uK 2(&MeU+K[Yʍ]oo% Ѐ)H-+Ijئk\~Wb˷q9MCckD%$dwN=L,o~[#/9z9/i~zuW/kF<)[QL Pw>zY1Q9p7!k"*.uZr1g뤸ckJ;?{alImIɫx ٷcRF-ԶB3k@1gȮ6j3\bXa{RHyg97tLeBmpZRg =ŋ`lQ PѢ@[ ONPʾnN~vCIf}YIϘWFyiueMuCyNBfvU{ulNCi'6|z3&$Ɗûsz%(*$::-Wb Yq".T&ߘS6>WUzb&KQyF,(,]Bv<e~e#?R?0, .,#[@w&IbLYBwaIʒp -xeғTxd{"ɂ]huB%X0R ~8d :< |lʓrev ( V@ (XJ$i"Q*rޯOHP() p] ʈDUYǿC_4u pf#\ mbm-rϭ'c{M'fKCnv{7iimo1gHV5֬j[{yUA 4?WɏooB^_O~xO'D&O;߼էbO(ԫ&՚UlvcW I!n*ĬVtvΤR,n}M"_|aUQ'P> DŵC T3PιCP;TDND2"{(w;5b}~Fۏ ST7q_6c"Ԁ1ܭRӲ/̭C[5}1":[y"o]"v"'Mn.9$ޢEW"T:zSkGE-U]e}yUxIHyTzشGO&޹QR_6^V_Y5eԓIÜiEKf&mb2h@Ə]sTPRE0G-Xnմu$T(p #.8 m >m.@S p BO ~;o]@(`bl =>n*S8K@4 BIBP&hX( %RDybdWP(d"覕@ yǓ _BK䒀r&N**\B^L$0 M:#`h0%SB%0 V:y-P!ZR+~} l_yWOACDڐh ӐPIa0B(rD`4b 3£0Se LSƣj Œ]jnb!c (VӖRj"D ^84w'f d'@-FJi`qnS%TI=KjJ⮖x*ĮVEbjv~73=R:CN<(Z=;iXⰸCSm rLz^3EAĤx/mC3 W@3$6aa@Ld*GNC1LD zЁPEwĊf5! X^"X)t^Zy !"V(a"pz-erH(<HB7R+byd9d^.nZ yl5P|j/C1!Z%oRL!hVRD=A(A%Mj3԰A:!SBCQ5¼;^\o+fUpdۡ|uhe"W2)ʼc6LpbfqSmv:Nmd Fe9ٍUՎvK]ojmo?~kGP~7~?ſݿͿz߼toz/|~pnBY_P߰rEjR+7V ʍ3Ԇ LlV:MFB (jW 2ٚP[CK$Y)?NNU!v1QFbpF@sؤ)" AҴS6a@Њa sOvb,;ŶQl0FKM<;^6@f GFSc"qsx V2  3MEH}R.<2OmՉSkO'szYz 3MJG 8W&Tm/1=`Qgt&W'jtѦ<p[vV3UVrh C$UaKKzȉUdi\9bu #KhYF82@VmFUfmVC"E.Rt]HD&bW11gx 6//}jS_-YAFK oԋ_$`HrJVe>hII iHusBOĈ¯lZ :Lƻغ{zT- W5yowwgǤlbsX"N==-ɾE.ʖ囪i\3L7o?὿y)?/FK03; wQҝ~旾[w2k:GTIԲeP\HeRmuZr--.$2pbw}IV-ՖlT:=sΚ&Ը3f! Ԙ4n;)5 $r,$v*b vk8‚]ja8XT'8ߥ8vZFv!~Ơ) 5@0n+VhO}rO_vwaOaP,bJ(H&`.J iϔ>,v>'44ϵyfȩ~fKWJ<\.&.?A$pğhk[^%S9V#W-$Ϊ%;b%.hJ1|Y<OFܕ28 \ꭐydJAOٲ|Wo_⫐*pV@@WXR\DqU0`H**I{k~T*jւ5"PWjփʺPF![U$d#!r 5u~'6Jt׀u}A17?$6XLöE/чB*HPmMz˴O4Ѩ3ו[K䉥K12NEl`tyE?K18g/y=y1-p1ɦqg+ wt~\ȳCc9O 9FH-]bw֌6U"|Y]A!#mX!4:,47Oj?]FhBG60 LhcX `sTO, e%i6Q6 MS }`2&w¤Ctg HK#i#͘`asFj6ifI%7Ss1y#5o 3hC4˂ouG۞mLlvMѦ}owbWw P/f f1mmjz{G TjwfjܱM}^Р3{_`W=uM=݃y 袏SoN\y3j\[ ^w5b<|Y{QqbƔ|T ]+*^*zRT׌*Y9:@\]Wo(j٠;[m#<ӣO(8H MǔZ;G*#18!:6OSJsނsBGvV'][OIu `:F8N)&=ip&G^AõGֺtV`^08Zbϊp$ .HtYI#.G \CP爡G ]C۳~GBH*/hg|nFsb`ܦ^Ɉ=#Y"2m{|0Pl #Pu"s4ƴZfK#"5OO]0k A]4 ^I "`as:i°Д1<M (ghflaY0 a1lG4A":}kS:#)ie?U̮˩/ihH5gVlL*D1[^^nm77 +k?[-gLJgr!Z4DZ7882=5=[YU\י]x=zJĪjdD4?nzt76*bdKJiJoK(lm^qR9=-s33={?ټęg^%͆&~PE@33']H=]yn3#c}] -umMS >v}|}d0)FbS=6O2-;*b)ÎьVk3ج.9=1>7=962<96:1<89401?;4RJ ZkhnCM}#vַ44<ۛۚ[ڛZQ{Ѧwldph 6<7280?9:4><8?<80@W 'FGap&GGpScӰq$g~D AΐňmNL?^97`PqRC =rW_5yݳnֶGlKM> mUk`SXݫ;%;*< :L;9LS;v{EKp_0xzwtf5܎ )C  i|.;:̲fZn"3>szH1֭卹-϶ 9'iXNgڰAމ'\dՍ zĤ+ L.+4ie혵, lEڠ`ih126V\t=_VFȎعo!G3z8ؼ#m>ٵ7.ZZ;6'1EN! VzJ\4l~y]g̶HK4#f"1lU Նr[drE!N[LWlS,X](:^^gMM3XhUjn?<)!$PC ꌫ<l/i$3Lu6%r2-y6)O hwތeVhIAƠNA.vg$6nӖxX՞Os|>jP(sM:[ў('<1{x:> <'ZWXB)[]3rYGyTSgmا$AeVU3V;STNweV_u D~Xvx_`^O|mefռv|SP-RG/`U-6}5A*IDp4"thf$f1>|o t)Čt_$q-G;s?G%98z'La!Cv(̺d0JZk:Rs@SO |d;:<;S/Dv<l`' #ӥvy!Gqg8 ̉9vM.,.p.rh-c䈏;"׎[C%} 8]^=(}m}r>sO]'.;=nǭ:>ۋt{Ϝ@ Qcѩ ܧg3:;s:.X0CX']S v~QiDa) A ɖVqW5ƶS`  #;_0Ќ켗)4u6sh^ 1E|ժ>2|4 %mS7'ق~X&cr5u/mL~ 4<AK57~ r4fphp`X{:vO]\AlSj g9Z)x] B^?C >ARڇy~MPg+!Ӆ y10 0&Cih>< p<8սm}Gǧ~VDo[MhK@K|6r~/-fnkx|jQ65.WVxl$sxŧhDo`AqrmvyLV:=<5 d=d3s,pQ,^[Z}-`FfsQOVN{qQci3b {QGn ZįPPX_Ǧb,)fR40+k&M7/du<^9mHv F\7Rt4 Q..Ik,I'&Q\=~%~3Y6`rRbvHzbn[.z&d4q V{$O:Dal RYOSBY_,[RԲ)Y,V JCKc [P*5ӲB)˱ϖ MO2_Tt{܎y.gJfnbKBǹf-t;gm EkvEqPZ‹[XuFwhweded7{ǧK*kW7 "ImS+ r 5`ZfnQyurFyd"#;?~e]L(YzD/x;"Hry  &bhtp$A8CG; 96Wijiz?$O*yT=ǧ'Ț,{NQv/,,?%Wv<&dw'e  \, HEHGϨlYe 'v++vXw ]bLim^s7;U?}w|!;I`{^g{qҌOZNL6oZ@+.,D\,6mlE`A y|zAWءuiErp@E9۪EwslOvMw; 3J mv$z' !>'ו*H937"_-IEItyۘ@okOkٵԦS z<(-.))MHI/jKi A' _/f42LRj&7 v7E2Zس[p,ĪU$mo;&9W;1_^Ʀ"}&nIDATbR=kYDJ{b\=XRКSWWYYUUٷGi-Ǧ+J*S TUc6wt8BkZ@&O`2_0W\ CCچnB tp̳[:rЬoxg S",d|RJjFbin@TYӀm 6*I6T_?#KH{72 563ӓo ;734WZhknŮeEWĒe 52spOB8aG(I6g)r=F%p=E [ɩ>y=\[. =wd-M XVyE.#xt`Lh]><8Yz{GTnRN߭[4uh?}ʇW>˯{˾j0OR9/;v ɩ%Bxp`Ho̕ MUĉP%`YK1F?!\18 ^NⲔ +fyg)L;fnionYlDA =?|C={f= S_lN-iKVjΪB\:=5?hjji_WiE21 ǧcSM#c|Ҏ\DUH$[&Jqi7`W5Fe;8OIҞfUDV8&1J1Z8’2Ӌz_ XXSs5Y'gΑF|Z:8<|樄<S3]}m]{^,a@66r JDҕ.\׃qcvx,^yU-Oc$]6o^).Roȑ!'&%" +RF EUH(h=Ep ,C! A"]}*B!8 ԱҙOIe6Y9bX"F܊W_}|H9[[2 Plv2(UPX\{wr|zMr-N:>:kγS},>QOg^g?-)-e=ZWj&fLvdS<`ͳ\3YXB)9Es3 Ѧ5Xv~񎅤 !Gn ₌"xcI.x񵺡aAoinQgNynV..*|^̮lTv,Ju~eT<̬z?S-V ?t;,*{1svv4Øo4h,ݙ%طKT2)}aw~bVEgT|v5alV )P*~?JjIk(+,۱m?YCRk`_ƫ@zGĴzk`>^ɱxkO% ſ%G,Ƙ<ݼ!ol&Oz0kH Ieq'gg6V%ݓXqp+o1mX1w*ʄDNd$]kQacNk:[GkGzٛ[ZPt~dbpPn%fnml'Uv}La/b)bFH9,dVFVnyuL64O>Mh ž_ZYp 0X39E%ohAR D~նKxֱyO~(]YڍbV;OM_X Bie5.޾"\#Ŷnim/88nkdkj8c72KPcك{@Q2=;`Y<m0jD0-m EkJ@d9|&kA$ßh>msGPYNNd,R{B;혬cr^ox񭺟Xxrtgkv؍R{GV|(5+Oɯ+gYԢƩʑ;C ؼ {9Vzq]c@koý;}U|PƊJQe2ۯ=iX:^̨g/g>,cMHŽvR53_;(+wRksu9435$;&x!WmʻثX7lJ,>/f05qH/$/Z!"1yKGa)9 <ђv{[57,G, Hp]iRo v)) ,P LZqiNW~QiHc^oX߄B,9 'N/ĎmоYXd;LN4Gg.NG)Llm{zF\ Ņ9,yHW ˋMh( VŮrL*_ }0=~)^VŶ j!dD,T&Tq D4yp2PX0ZDA2.h ϋ@ $p;C*1.MT]*)df郰0+o fLgYq<t:JJ,#Z¿>=n1kC,2'x.,!zrrmcKg){XZ=-j@;̶]ƿ;c'u/poSgGnَ2z.un&8{& cGV Chك!7֣|Dd%}ն~p 3T7D=bV4td A[4x=,t_xW`{dǿF'Y'_칑R1'fdJՋuJhe5)ÌY}kFʇ}:;;8 JIk |3BU™[#œwFx\qpIkjd47{Dž-ܦټܶ>ve7K\Lj e;Z6R BpkZl b-=[󒶮fB{ 1u~ز-U}ʍŪta]T'g= ŏS^ @d49|HE7I@,_^#IA=:ɲ`Lɇ<#)j[`1sJamHc"Q0{Hiv.[F`" e|0@$a/*1#î&|9z8 @!(@>H` YPGw cb>+\Q'GrzF crph若X[`C-.cu; `r[!EwGz+$ Ȋ" pB7H !z2 # p@^p HS^,G@[:M= M\JgNj:M9lnamtjJX][z'sZw?iŢs,M8qcE&{:ِ>\腌J,_0ۏvdG cb׼{!?{za>)$߈34 !Y$Eo,eivДW =0{FfyGb1f1er::юQ擮{Y=+;GrJ* Pԙ ^I{)]vZfǙWdե6$4'T`#9dZf;-o&5[nBu6_h0iiM lC Y.\&38[8R5lZ\:nl^WA9/qqu+{W!".ўneoڗv\H!btВ22_RZ^Q>PT\Tۜ_8W|r꥝9 [\ӀՂP$KDeVLcleM,]A'XD e;@z~qv!YVR4+#zf3>icl'132,$վgyl6r}6wQb .DӅX3MVŊi0$黋fG;`>ÏfZm6=^1c,#7l;&V٪۱B 10K`-!DőL]h-`1c5RDv56(IM33^?MQ}Pp7Պ &3B!:99us]Avo7687+jI.IKyu~JlZUk[(сדMͯhfbָow!=:l TiCFl.QllR]m4 m"*7kF@Lmj^QmiPҋ5#.^oz?m6\Ŋt=шrw;*״X64m0O<8 9mT5Ql݃kox+;jZ:bҞqiWRKiIە7[^Mj{?Zzy=-=S5)W^_yX蒗Ujt۱EUzLfLOswLtMVu7745 U YDaVÊb/D~h {lb|6F*#F|OJ~ r88<$a|Ey␽l.ÝgYy6esQa9tglTM%Dr`b0 Fj68L13˘FLȐS33SS3L&zũ5Бlyld3S3eiHR7F}OSN[g_WwGws[WK{76ѫ/nFdp snjbrvzblܐ)sƮ6g~d,p yz]!׾æko<*~Q;J^}P—o?}nodUztKI(;k_{=jJuRNcuSOaeGc{_fi3y%{E?6p WbT ~Uᑹ좮ځԼζNLwud44Naǚ|TMe;Y6熷a'RϔhO^`3}/w]4tLjϟƯ<Pʃ_M{Fs3 C_1qXs> jӰ?q:hQz/|]ߦݴ{Tش{6φͭ7mM[LgpǙ/q6Խ[ڻ쭵u7y-YWV,K$+Q93g `#H`@DsQb~ $8 {{ ^!wV)9rէRvj ,+=r=vΩwK+=x7^RӻOl8Yq"|NܒC;o/?;%bݎU[lUxݶ axM\e[^cgJ+KW\9+.-.q˖-;/:Y2N,T0xUV5y3Y77`QZԼ)O]3nE&/˙:fTV.M\mgU^=oY>-ncMcmt[c{+-?RX| 6*q{A1ڽ!sseyELL3\cfƧ}h~C뷗'o*Q]"-tR.YpMznkL޺aSUKWݽi{쥫g/]j]|҆Ԭrvo-J.ZqǼ-+RvI͙bk̼1 6M];vƨe Ĵ)K6&E.(1iaFBjaƖEe{sgܰ0sKm9uDw8vc6WAR3֭Xd7gdMjkNYYqeQaYZؙrV'$-X&=Kkמl-8w_Ư;)~&%h-syɔy3{_N[~} I)|˝g'j:?8,+r*[rj{i^G$'3:ٛ+d<%*}ZթkO]:U'rWRqJ .?MKeG/OuU9J/;rЅCg/:ppgvVعXENXcy7U&m)Jڜ*sgzvqfNI yyw+ߟ]T]Xl\ 9ErK3deے!`k7]eU҆e7Jʚ4yefV\oݕ ܒa؂rM&g3J^HnGZƦ܌,|ܕz;K7g<#.!+9m̍en]&cQdoQRTP=wWfC9مG=sUgOpnƑ{+ʊ33GO^Sy؁#bG?uUY7.K4s٢i۶/0)7 1>i._fuVII-GŮ4d~LXs.C4?jjq7 8DKDO6^m;c^o7iHcIc[VTh#gLo髝?MOs_}_;o;]Z駯t]~ZӿO?=߮MɫR r*wmI]hݪ[dn=b-2iʬSFΣ_2JPe{wVRmeE<*{UvU%EEJTZ#B~i Wq)ʫ*{zi9yQLUJ^'_T͞2(Ģ,*dNaIaAIrȍq:X#J}wSUUQNŅ XYV eXKʟҔTURX"zwEwU L$M xWW{5U T='T|_OT0 `TtNDE/*PS,U*}; bT κӧN|ׯ]c]dd|Eq(^تPAKԸGaec'ucV_YjO(WզnB:svIuK}$OvKIխtueJ*]RV- YD-q,&.,W{u-MmZԷEH)k߻rW*K~[_Ⱓ ֑)}ή*,d֨0PM'nP\IR}J\oMSMvsqm%IlWܢrk_ q8)+Tլ};q#a}ڍlZn RFj6ROW-oHkfo4R~uRAZ[(SGЮ[@W% }tGU|UnLWkWuU%H={՞!쫕V8,,u:;8P{_٥êκt-"urv񣚰\a0^qCS#"aJRV>)?!]MA2Ri6Z=N XHiu )K*\tVmbg q+)P Њ6AJd mCFFSqTD>4tAe EVſ!Wrv@anF͑ K3x+wFh:X:zRIAzW:keRWnux9\.D+O "ma}",5$uSLڠ:v H"HbaAXWaqkSh#4  f#u³-0hDiZi\%Ko=*M`s6}0Ez ՝]ˁ*\?GJ2:kO}UF=mסl" |W ./RD^=ʓK$ VvRVnzRʩ_r YIU i`=q)7T ׅU#Y{?eJU rJu 40Kuv.º¢,E=ȊKC^*{)V&"@l$q1:u.BHh* q%qqU:S"#`:v($e#m^CBX}P+F爵Ν;߇#c ΉFOc8E9Q>GdIg#EEEݳ/%5^[[fM" F7nt6r(6q/^Λ7 g͚y3f@FF\(u gJ-0a"!9>>{k?~' zNv椴=zTً=b'sK9gLidDR"X]c-giQ~P@ ٳ>|/94>>>:z4r4Awdz#9o߾+W7.8DN-L>}w4 x񒉓b33)l߾=55r'ص+,"(Νz왒v s=tA@֭|_H0~QPD14' jQIq~]Q“Ȉ_#m g^Jb=ҡ,E|iW&GJldCNح~Y w`twlބ*_o<>}2s׸!:yڵ&M>}I8Dϡ}U YK,]|)S~*9qIz^z .=+WAUqq237Ll8 Ν@g#;vV>cL]c8ڵOⅴݻgs̙?DJLL1b>Di߾x(>io#Qآ6%EhE0ʈhb({;6%5mȐ猌A2ujRZ3/o'>ܳg/LvF)x~.]´Ya1^iCxZ# `ܸqI_œw|u-Ջ~NA7B`I%.i>|8Dptx"zPؼʌ%).]ذ>1%fGma=?33_l6|8i:҄AP!qqDacbbȎ:bڿ]ǎ] B C(l!ip"pѣzچN*O,\@fԩl(YD _Ȉ\j͚5ݺuۺ5|vк?1NAKSm:?l$KeANw9VP~- 2X}K: ,ՓX'oUy$ k[;?r^0tԴt~[*F[IMMEtI\~Aƶ5`sI~91뚞;_.Z1c ~8&$^| _`j^䂵Oذa#gV$&%-\D@-@aI,=\Ʊ+s׮d;Kg|ޕElr2,`I`Z†t{~ii-RttDA|>xE .#kkg͞!P'%%0f6],Bݻtl&}~oH3.n9 eP‘&C ]\bāΙGﲳsT%X>,i(`1  p9s¬Ys0TH5IM%#AE#QXTD` #dIj553YsV,qyXd&DV/YΐlNه'CAb' =:,\F0ϩ.C&S 'N9\|9Y0>{C%K`=^t)m9бiI?Rk׮ba1)@~2)A0q'I/^%> T"g?|e`Ϟ)/ bi X]$K-؍I_ Dyn⒔&qb):4k!n$:"Sw:CU(R Zm@ߙvLv8SW@Oijpիs9/T$FF C֪5KVA-uuCmH)qT D'qoH$. qǂy3} Wm;n}BAd2Ue]KT$e번Jgi]W!eR^^+hhZU72'Lu^՟*od:_'qpVj`yd'ir[8*_O\'NtaD(tjjFROKFV.U 6NXT Z$IW.޼)ANRdACawEuԭ%eT*|TrDmN~O S ,z+iƮ A;eK6A ,5vX\Ԙϵҝ!z$l*ms s4v2octn;:9ˎRS֯Sy+'d:qy?U^%zVhB)k=\N"TǎkHji;gAn5RA4Vl87M;EuyJx횶r]N4yi)aX,v]&GxnW *j+xy^/usI5rIulf|P_vQ久ΉN.gVL `nXcFlDJ:FӠ-$-۳Yued3Gm v}_!J)H#%z"R֊޵`fmh!4T@>8?N{vܸ4wK Pf'Jk4tԍV X5]~BH* URyZ-#U=)) #[v9 v5$ԭXP%I:@и!)VTeW*GU wʭUhI)åBCܖ6};6~>+Ru >R1u,6@O}u[Ly*6[d*ԎN\ߔ&e!:FsQɣ>BKD, eMȗRx[+*7OԮ\I_[Jj7]ykvղhxݮZt!UaL| ag OTƥSt߼[E̼ؖ5@^qW-/W*w\ \0eEv5!dw'.Y޽OZZYSOm|FYk .uK+ͲK]=N**`ƪeME08ܭfUΝ|2g>M',؁pJCxCӇ4zrpuVF#-l(,,d+)N@Lz- .x1fx9_'% k}qVAUչ.QP6 zt)G`.4t ns! Z&սE@Px>Yݺ{|tÁ:[㖾!w|B??[ȅk8EUt.?⸨쪇IM'"#ߍ6G?bo>,lǍ(* ,eD Uw[׸[J S-B 锨JչSKu!{eiRVA)plүܜg?h7NH8hqH} !orLJp^v88`1_O>57wծ]McB9׉M{DIKO'Nb͉9VDGKԒRMAn8?vӵoWK+ۿ9RݲVӀ%G|)?4V>)|JU AأsHR*O,%;lk%jUuJ# n/]okq6cr??pR'@eddpX>f䈝=wBa bO(l2U\R3v).ÒIiIC^F[89UJW,HUxN :(`y < j:m(QRS  W܆ǐ8ʲ[aiMj3K0Q#(C3gN맟~:fFmuo3ld au9z?K)by˜ٷo_L3Ohx bU|e #N8X[s˿p78MYX ҴzH(i٣ҿJ)W#5Rz@b%=Jz+_#>(m2t4ú!,,{bjaP+LcA%p͋/xȑں/?'m#S8O4hпۿ1Dm1O~@X'O,((Z;usK믿^R\o}{=ZLa-Zd媕&ݷoРPԩ_^ xPSs9q7V،3oYiw7aVݹ>:K %zR.]Z*: W(UL<äѰjEJSb4Qqi/nҍT։aݫdjmB7a7/ K,QN=p b8< `G SpJ0SL3.pܼy {hTXd2]pqW渚8y‹gb&cqKX X.OF,::DjAO zq(:VOT/aAA?#U53vZ&F#@zDsX8\PJYOKXj٧aYsP5ĒN,\\t/[9Vpo=JM.9<.}buW(Hg[ %zt I'4f}W+c=.h-DQtKHti2%G4T6b Uۭ`|/lJ'RT C W}N8r\,@ DuAWgKp \U:n@t+ŔPσre#DAeOqJ]*(*1{5Iʐº;Qsda-3)u6AX7n>:HJF*&4詓RAX2*Tc[1h?՘R=xʶTHuصT+ǫ~uu3R_sh85$;`TR v *RI\(ߧB+H]W7Y ӱ- KHHsP%xA@+w7׹KA0Jss)KH]PZgUR(հ{ׂKV*uJ[Gv#pKuu.݌u6QzѸ^Rwg+G,lJu=ra)_*6Rwo B&h㓐z*1Vᐪn_^i ŦֆıruZ=qnuZEuZ-DflO"\@"_Jga=+8F)ISIqiWTבƺ96Չ⟦ǧ4oI4'!;gUN7.U5wvu+|ՎGI :)ĝwN,V#VJ䝿~ư<*ꅳ VD*NgKF;衢xV/iqè0QVܤ%Y8F֫;ZM9]{NsVux>q.oSHI`N6!۲Rm;S=O o` MM50t d_KUf:UukTGRQ[m˗ U0B;ɚ;v 1c^=f;/>S)wﶦ$2D<'['>8 &>L4>'VX!5 nuߺp9 {w<@XXBX,Ǻy㺇&|H&`R=qIkԑB/K>DG~ 0ۦQ\yU Vqm1j`M]?a5qu{gF6#tIՌi3M&v?sc _K|}=3#v[V7S+ ,0C~ J!DIJ1K_ kyRUm :=j55maing>(͒ n8 /Ss0)'H}z!qq"F/EXڒpf=8Os3φ>mg|}xǽ7,k{v䩑vzWVI]!z7ȏ|4>q9ׅ뒅 H&,maQ%a1 t.8Aa));W^8ߊSNy|>^`;5N>v v[677wzE#G|OǭmZaOP/~9t=;aQ=:,gߝ}1sCρ{oyE硇vY݅0uKc}fV],$d VNU qw6vh6oy <\ΥxJRֺpXkWjhaBbXMt3gEIQ Ox^Sc]\SO8KD %M3n @@s=5ud]l,nA*vi.oX=T )jmuyt=pvאPYXT<@HrZ#_ 'NĘz5Ҫw;niGd:}Μ#G;oԩӗ-_>vaÆ?bjma)Zoڐ0Ɗq&c1"8S瓯7s܎C?$Yr!-T㭐FJK=`~TtTK5 NpJK D+U$V L޼ɩ{5R4I(noNhiӦ-[n߾}Ӧ͉IE[nhvxץK $%%gmx-RʛOȜ͛7󎯴u0R]T:[+̍&XXLkfk3hH[df5 ѴiCkƥ֦=v|Tmڱj'6Zcj8Gaȴ2YȈbmZUnO!,úEȥOi/U OHn0b=rܡIkj K=$yD_i };p}* 'STrhm @!:F|aݷJkKiu1]j-MxYXbZ~:4ǚh ފcgc"`xg*J kVu:S.~jm=g[o Zx:Op{ (+:>I[X Z0ɿI7nKJi]uk\B%@6 Jv[X7.O}=TT/Ł2ƬNժ.%%^c^aopA s N@7VwUd% bj_'Q&ksFV6U~:E"ghV|VXE.!V,,=$"{ Ҹ4P Ik?A1Jg{W_K /Rjj"GG'VamkQ!f5s `̈F&qCxO܄ ~5& >N Egrd ׄe?%kͮ 5ReRqIk7tH_JC.S'U2鮟ʤ;ˣ}k/ΝŤܘzN՜\9rrjrOqYí=]mm}MjrpF}fYr6~M`Msm#t0m5:0Q"$th[j~z`ݗƨkůJbĉvK RKy$ 8`FݪmD5LY5a1MX^)Ip ./͔+{K8x@)ھWݯ} rR5?_óįzGIıFm۶A}̙s2d?#:=:xN>7b'رwٷo);w'ƍcbbXOjoSl2?~媕3|={vԉLI {>ڿ?{F1{l?BX,o߾=4J{=w={@m-A[ ֵԑTUդzBCboyjTelMFARnDjIPJ`X&.ªӄ% Wz5MJWVnKV.5/}5U^ fI\Ha]j ې|2&U[.kFe40ѼUGrR8X.&JD#)Z^ufш)T4q9HzJ f[7oƁIy|/-^Q 'N>usŰY|j\J0OqAJ:zm( ^(jxxT AπqTu!47VFjF >2VvlFԶ\uKG(4uQa]+Ʌ$OHʃ17TQ^T|q߽yBI9PzVa[]w5S*M\΂ TGS_-#uwan6G(࢕*(4TҖŶWCXjx[Xm %9$%K 1gy.XC.&ȓ5ip}p!I9*>HމzQ&dҽ^P{[ϚԷSԐP*f6ظ r3Gѯ~+VHK ׿fR׮]Y%~>U;sCoݺE,3SD8Xa#,.%, @H!, ala*nrA>` SLaFx˜ PJE70aP THm-!hy[\2dXVZ5caAX!")VJ%^{E~!/LfuXQr[gd' ŪqV` vЉX'YߟwU,%}EG]ɬ*`=\%Ļ3X΢V4a=ٳ'R Ed(T8c5)a.l):5WٵP6kHV{PC?Rmw%չZTSG>=9,=K|tNa><:6h{,wy)0:%'VDXtڱcE>\p^U`C[\ Hy.rc;*݀X ۽{w 7 ۲]Dl0+[n셤\V:L8q*I8ɻ[TwunNmR[#UU+l+",dW'JO[,&&b3ǒ%K1I`ҝ9,Vc`!$ A@0! Ν;BT[a cӅqH`m۶ ;l 4ieMh(2iس sBͷ$%uQq=S›%PCCpKui)>hxIuHw/{R A ԃ/_oV  Ky'?6W[M$0f_yb bvHS'C[ak q[,a"R& 2BGG|d7`(ODBɰyD Zi&AF .=z)8pM-.XH8aɕ,h-IN/"ē0 8c%0t!΂<.&[DL¸|l!CјV5hvUD`P K³χ:x'<Oָ3,e`M]#޻g dcY\MBXeU:J^z27G`?,`F V^p4rJI5u* baIUTju >hO>Bc\&HR]UK$O0;Di']vy;KUI-ox.$R6RZha44a<%ԓzR$7r ŸOsb'nƃ2á+'ǑNfMuhrm~2M&WvՠT:Tx Z O o۫X*߿$1f l_vDp,"ə0:ȡ%Y,mZtW(R''#u2 C\u27|vLrRO]SAA^hE6)y]9nk +ċ9,uFeilwGK]SgFX b?ڊil{5Jn'}QVT NV<"?mDuVU[M&O&=њ870kŒ7_cZcqd7jz;\IXV9 LXSV[gY[G[!ֈrSX[k5V2]ZkY` a=2aFMgZyVaLkf wTYUۭӬiiV"kk~k !g"!G&,%y}{ްn,v`AUV᛬M-g2DV Y11gBit(pGEq+(_q3D>T:3a CX!,J&`u'MX?Qmud 0Dn"D aNh:NXY@@S|F]yJ}C5d7@da\wBo&dkBVHjh}> 递 ԫ㒎1czoݿڰ-us! MYBG֣AN:=8kbׯf|5?snmgWӾ³뚮 kK !zЍ!Л ٚ0h%3Ȱ;tO>j(4(vYݥݼv}鰠WnvƒLijMƔ !j -4Vv궶[n %vе⎝wGZX6''kLO IX,1j e%K Zqy Ç=vW<zv?;lՑrVrxՠh` ǜz#: 0,b+ TMʡ-{YgowDJ6ͰoEmB[FƉ|=d~6’QU[s+(+weM^XXF5 (NuȢ%^VyɊv>S3Xx&Țx+a{2FCXSXaǔ3M$z̓Vfq'æh2Wζf0hFBЬÊ~zΝ'Nܿ%ޚ2 )؊8^f83g8Sqkt#Xu .zKN j,CXaLM*33pV%)CXN8pwp!{ *9W2Yʂ0Ԃ0-5nԄڴiӹsSN aJX2- /( HNS &yGeas_x&W~wXƆ555HC1 a=aaڰ ksDy _b]N|g.+L~g!-(\ChZBX1xԩǎ340cz# [{jTN b9(ʧ1c˿V!O>K/%''9s<7 Kz4tVxN`|=PA`po{1}Woٲeǎ.\ ;аj aACn12qsZCYmU ރQcƄqmjfo2޷oŋӧK.~‚!G#,9s>#:uj3 '}@X=7l?}ߝ}9ya aP"p&U~~>;Ss{P^ZdoW}mgS_~t8/8)_߾ǺmFP:u[n۷ں|rFFe& a="aTwM{KAi7zpiE'N뼲3ǺwZމӲD3Z4Vӕ3V׮]3Uku{ [f/!cXkR߭޳wF=l3G}zO>== T[ՋsxObXS^0v&e& gCbhkH+%X}1qZeh 75P_‡Ju:cmemimi;nڐ:@^ؚke*BsVxI-ha}7L+r]rE89nG?C}dI"ӲZ0->DBh-kF,$Bք1D8",`u ;oܸs|{XBDo=l4oeYYꬶa-uVIz-RV[+i8j(GuUdsHXXCpӦM̙ze˖޽{7LcDsMII9}tBB¼y 0K,?r%ɊF@ FxzvIJZV0wvuի꼒ſ:(>xƍTCL>9 ca5"!D’yyy=k?w{ݾ}{UU{QFٳO$&&?/))a_hڶ 9CL*67 3Պz)JH40VX1i$L*8p ܔa2>ƀ4~u!o8ܹsKHmС" C!,,OªbР֬[e>9? aޮ11V={ͪ/u#VׇGP\] @&A/ W^yw1B8U^@7[ȑ#Y?{1k(VbO_\{+,5DAa~֧ϛEEA[qq,Xٱ~:v޸ڰ a]~Ԩ?mf8+8=Q5Pm%ZV=AҖQ眜OfsrGSV}h,x;kΜ?|W>v[~tXCa$,(Wu@#`+PC0~38\7od܇\n=.(ID|(2ZXH 񚀺pNm}a7l,3SOpge}ƍO<'wr'II_|rյjlY44uRMh!&ӊ@ ٳQMWwHXX^[X0aB5yDzm<8|3/M_ݞ8D&g-I 0fxZѻ4 Fy `"0ؤ; $#B%-uʷ b}]]7pխmQѼ;Dz7U3t"D D@![uM}W'Sܼrqק!, 7>&V0TDdf0PL>1' ?9a͛]J]v13Ģ"|듖v]H*..ЊIoG=̰FgCU!6+!ȄY8x&qF*cɓ'N(Pm۶-===;;Yv\ dD ItEAǎ3f XJtg':bРA,'REd4?QW^`ǑL+W2e ]cէ)AU#D‚ڵkf؁V ~kh7q,],=zq,U%60,aP>..$dSYFi@kG K;d J0^b |`8r߿?>q'<'!!YB3Xc'+p,D';ɝ xJx>3 OM /H+++ anתxd’ҳ.'q\~OE'%dP'ベ$2/rq%YA u2ɑ.DqNͭO:)#sϢqi0e|/'X>E` "D AwgYrnPqK/K"AVE8qsMy3)'K`q݈~ F%Y/%}A].q@I:j/k8ܞrX@U} _fj(A Ȅ%u}a?رcGo kdI'_پ>9]~aK /PPU0ϲuV7ȓ+o3dm=I,B \o߾=ֵLC[#>a H"{Q݈%G 5!XrSbq RRBc +[cTED958mِFbd)QX*;+4' /!J9|I0}\"qqL44G&,tB֣C(lc @:%8X i%T}삖 {h( }}]vec3B$ _Bto/1&x8JxtZr$w {oM>P^a؝t4JGcѴi؊H|uV!X[ߣG ORѹsgvQ OH l]$H{7ݺuw'kBE9i  t¢rvFޡA l A( 6~Ώqݒ~E?dS!'ѡ66KHh :$#}?$[oa`|g>"w4̙={ғI9  OȔ!bPP`)_| d6lO>6)) N hR>;(#Kꫯ`I֋>HY7+9aK bxǧ~J!)9&$I 'E+ixxd’F/[XUȓȰOqT-V 6݌H` q`Lq$Rx1FaCJfg+"v](8VbbX&#c/Pp = tHC E`i %%ACĂfJ ;Ic4ALs ,4 İE }.R١l '*I1@ `^| $re!w:>!gU"Db$E+ҵpI7 9z< D&n.فrP̝Tlʠ dO{B>`FT"#qXN:.%);IDAT7 Vn ,\.bOa"P7ZLX-Zfz#FtK bˈK)3_8%ىȟ >i%pNIrq JRFOt0 LXҫ` `,8/nd!c(Ga"UhʨMh& Ә/g۝5_wg<yO.dG:ŸON:5smb-R(YDD$kR! ЙNӟd<3/3#0b.#3d AГ N1ڼ$wcNaxHd!3\:4fa:fyyȣ:gʟQF/L-qn*Ń?1cJ' PS]FyӅ#S<AX e0/*Hh<ԣӷyXFWvDfr1s0ƃ0&6`18ra>3yk)v2<:TA΂X Qi"7 `y1=>L $spp(i5¤%YDMLS4Ʈ̧A!Ȅ%/ E>g:eHTrgL=(p3bYªO^yUFJ^LRLzK{{O{@@ z xe4UMvcD_YԷ9ڮݫfMv o1v c5cwo͐YWG5CЦnnfA +}G3A&Ҡ~/]|4N_\SC.s|W|O 7ׅQ_Rp-j,@ clu'w~Ľ|"PpRBU G^i#˦kU1eSڑUvܮDޭI\*gVLnWL~';xLڥJT h*wN68]Z.%67* xpÛ%)Ob(B_4@3(eĂ* K6p:t,r бt\"P7JrThi sEw'PL}Ad8cg/0:bsɋF4ٽʔE*x|H\bej|b;]^0Q{oZt2.SQ2L{E5Ջ_&`(60IW[+*~';@NUnj6/Zh4M4:fVSxۦm+19Uҙݙ"nXx}r{'ɚ&E[ R۫vJ9,ډ&blLŵ,AerTGQT|P&6K=RDؖ.YU#e`DV<}bhi:Qi@Т/ՂC+-gNAe Z &[ؼ0`2ѵ:Z_7_a`IL lg(~ZjAS*YYz]m gjH)]sj!3NݥHlΫĸ{8KΠ>;?zνu|Q4@hٔѵr;wbf_&^^4 Sj ;eTwnFx&Z%O{?3Ǿՙ(knv_}8|۷L.\},ՅSœs+=O>͞>:;Oo(Olq%7s}wbb{hi[K&p1J VTt`-ۧ6gl v )[]g_Ilt6;QC2\˞2xK;팵#j HS;"Oj[*㞤A _`D[4i۔hK2՚aŬLi'ǖ 3XndOũ-"w㳜{AbbpҚN6w `"}qgjFlfQ h&sN̳ x}VÍ}T2'S:qV"gp sWYב܁!MѸAdᠺbT>IRAb:䎗M"{U4ѹT> M 6(^.UN!.afULGvZ5OZ2P"owpsAvDa9>/_/Nq O'5w8/D Ν)͢Q*=W )*0O~ ÜʅŬGU"8 ëE;cDen KcxW2ͤ1vtjx UN]emx3DiNi/[r< ~^>(mY *:#т,y{9JZZ}X! $|UO8t !W rMNl)@DpTY ng bN hZ˜J8x+̘}RxY Oh>S֍.'zxҙOf4!SE'|PfuHX7MSJ88dNr@ x?.8S#S<|xr G(E ATJppFIV'"\~ a|V'Wp9qٝ 8kYkZƝ^aLR|ZRw V-RL#IQP;5HMȓ]u$ر1!1 ɽWYOO:^x3o:JJ"PBşfMD PlBGO7# A*ϲ'+?I}ئ>}R;qs ;9(h+:,8 n>"U-YZ;{h"?@ \j1#= tĭnVLp! f6&p{, 4DA[A7&KC7 >5rˡ v N\&].rVO }?pC4{b'7HOUrFf~=ӾjTggڡ7hA(/t&R2~M| |M'#L`ĕ[߸֌,B֭bέ"έBb.[Lb QNw_+787OӇW[yoqǥ"7 7 U_nC-:">=8d. \_3%2 ޱK8OgX΍t'+Oy\W':(|jOc׌yZZ'&*Zlԃ]*2Hٕ1\G<%" (UITVeHFrcq+РZڠ<"W.\yR^4q89c~[ݯUG̜b`T8' ?5pxg; <;xz{O >3||wӻgwGO=7#{{;cGq-t`2f}0n}8n?f?n}0f8n0n ?= A9n 'U'NII{Ȕ=t<|Eyyy(i{=l){81spN;3."3HXf33 8tgxH3 faYD9yGĜ#tFчNLD9QLG3mУ10^,/GtD9#x4_7 }OB[&zxuiyb+IyJOypAI8ٸ'$Z'Ilg"˙rijc]$0/籐y{̬-v=c{ZflS'N"&!ggZNç+ 8}DKY?L==F`Ѹ5d4ʤ 3q2h:q2z 8 ?~DKP ;y4~9%<?uCy0Ñӻhs(d1t4?vv>t"OA!(:vpYi=2w:~ZB=v`kq0`-6f{r$UHEO%#Dڂɒ@$"& -tX<@ H  s+U:h -A™9TDTȷc$vxhʔrT*%XQ|g<T+2"݋=k *ItSjxD6Y¡viʒxL 0^KKH 1>N2ў/p@L/q\'e*SL2hL;}YR?OK{FRX([O^0aY2*rO#DIG$/?MTPq,G"׃Li a>|\yHdT<=mKu{/ng<F1D<*OE|p*8Eoo/H^ +OH½"Gdq \-*dI(ɂʐ4pFpPEQ@B C?E<&xF@>p`I_*p!O 9d!H}U@??z͘mkf?J.^Jjy=4mƒ!> 8x\_-!4j xV7!oߍZw陗zspGݬ{-[ XHX)In22ҙu&Mb5B0ʷr J+vIɜ>jL$S{Sա튟RMm3<3nӂ\Kk{楚}@KԭZ3ƅ4Sf~YXvRk(^29> ,$4]wꅯվ] l+ߔ0a4xRѳgø+B߽vbtG/'LGOa/fr.^+(O2F+GI0$"~ޯYts{BgnV29s_oUhi]N`# 0)q$N ;RK@'!'q8G#;C=zGK"eO)(+ExLD$C:gĴ]4!fqD+B&ٓ99Q{%y#Ҽ;4bnP)a0p:E{b{X-C#E1\?|h.%_"- O"w<_,$BA"X? %"A FSKW^stB˾@8opg,pDO% 0":phHDEψh^wC|x@DRCXXwkP|ؔ h0:*2VF3سPP(+пUs{Bo?.՚ފF~~w6s~./߾Fi!ymeoÏ ~Bs? Oݔ=}T:;ˇ߉,z_tGCk=֨JdHi ^rS\EwHA'9S+ދ7{)E}R[;ҭkQ9EcY,\rXgᓐ0dF-_Obܫ ?^X~~7'~P:xfTHt'h4| BK "YIdǀ0J+ЇǓD S؁'2\2sxQMLW$;`i%_ BM`*r4cu5/8IQNh#X`mGCB K*& F đ!y"RJiW1ɣ (鈁@"b.2DC@u`YPKe- ًžT2%sK^ϓ8s| M0$}ŝFWp HL9+gϿṷ7Ʒ*޽6qrC/ePahމ|^YD$O)AI7boG7ίjcQ&٣xQ孈/^x)4izZ!&Vb'vjet3׮O-&~_|*YJ{/g =@U̜5B+r_ίDV=Ȭ}~BT.=w_ڶW">x)QUʷ%|S:9/ ^A#7[q彷R2:oOʟֽɣCܮ⊋ԣSVH8,lFrYt+$l+x H:}PRX=,GEV (a,oE!jc Na5j Xs ,EExb(bʅey(r3 !|s̻c!`%eD &0=爄l!Fs˥sI _ ׉Gi|$)rHrFOIu# Íbb8MaH(Z= נM*wYZLʶDJv][^0 K[UNcК4:Qqql~ǑL>[eHIFp=tA4q%XAC.;>A8>4hhOG ^hBGd" CI;;pE$Oz Cg74t Aч>\(KD/@EK w)7tHr o  ߹1ek5S$71&ԍiW9=,~)J#pZxj!kx?LoZ胴$:!t*CLe/9;YɍNjkd,}ٗ?5`D^`hY&w?gӪ>[G7nT(%쉭g^8׮^Ay/}; T4a ^Z嵈J^|dPL&_VhyݜwoG>N/y?"7uܛumP]:S}A3HWNH{$0!={wDpp"g.bf.s(3X>w"\𕢑T"Α+f _qA DsXBaBh$ͻٮh [<& N<`@HԖ@GpS,J87Eh$sㅻ }D'hH'NL҈h!(#q $'7iTQI쳟|WEfuV6BI_k[*B_߄yG=3ڏ<{rYO g}jY)xB熠@OI h\br*ܡ["J0"!qbZ$ @{@'q0'聂b(+D*o%PJ>[ R+_35AD^rxvxٜ.G3ZZ6^̏upr~Ͽ{jg7jggw0seycR{ $WE*D#6^Y7i+kKJe)5JgXXو g#`!GCd-lgA&ϽwG>JX^A-߲DY-_NI,nӇ0ɥ}7>ͨ\q&uI߽RqmBDy=&qx?+{TU;հP9]ًqR铘»iՅCҌ~YJ,rSߑ>~L٧Qyl]QqdVas7>-W,=)?-1E1Zͭz#fĀpa~~&!eEד*'jfr޺~/(y$`E0lQQA0b6 \x4{ŀ؁hB%l.bh61J,ض:4.W|(&, =AgXndqlW<ە0S8'<W"qP ܁x%""y,9$( )jHП"|p <"%$}9I"I69ai,J> btEywBR2rkK͵ξI$]Ь**F֫Ի;Fh>>4-;#euXvEiv-@, dL(c.* ٠h!ڀ>"!$񀈃D :1/+*!A*" 8E>|Dd@.,kReUL ?p>._^A e>\+TZsLT> ix[dT+U/ eLVMU-y}TwjȒ<ȪPF,^q8]+N\ _‡CȠ^OEҸ|z_S'Br^AU(}ztkclX坭mfgKwd2lZj]d{&Éeppg,^ž{oRA\BC$ >Wn0vp.-~$"JB?,SH7#d!!kTUB#t,VROt* mDڗ(B%UF+EyT<{xZ66 UN\ P7#ўb;c$c& X.b@`SE<6adZ&$6Kv`=<#3}Cg<BcGlj7$+ȕqYDp ;ryxrI >Q@T?煣%@ }h(3yH,X_B!1e F")I2Oz C>:ufO~~[///o?_g !IU)zu ncݨYvw F{֖eg`G{}b2jdžC_CsaBN2G_>3CBFv4 _* # a֥0cg(/77Mx):E^7;a<AP.C3/)"/4`†{ yD3C@˙PL99.n  h _3"x TP4 ww;ՁJ)q|@ fUhz0.S*PNUzl#P(_%PARU@'BUl? 3x3 {"a ߙ-'DǗz[]m`[ߓЋtw-irà==O⪿xv.j v} +1ة]5)ӄInJP /’0k%BSĂ♗8#!$&eRf,;]E(mG 7^l!$YL<"O"ϑuХ3L?ؔFL⣖ /|S Q!x B&J|^0(MKP(}Y("d 2~HBPɟ! yb\c"1-yd69T]e{GFsGAXa=!jRd`Ϣ%Q $e$Inh*q)PLEG03}Uw^|HT7ڟ_<3&pMJҩ7M^ӨO]ƉQe3k%\jX]\X.,+ljpsA~TI8ED&P@ )@~6HP&H=)RUG'Ci(,dA^j$Ѐtlj6=H+2 U9<{ϑwyRo:ۖwf/:6κxbiLk()1Kb,KSX)%3u.vtyc|mf7m`1iC)?e<a^{Ac# #Ђ24Q#jp&,k[8)Fٴ6g##`AyDش9\JfC.27ɴi PhԑfLLiѹKb(Ib2qOXVl¸=NޑF.YJ$ϲ% Vp5Þ *DځE 8>KdZO䞃Adx%Rx3<*0ω<}"$2I|Ww)`;MNG)tgx2Cݛ.`m/iex=]y2Et %^ I$gI|_&ܑP9؋Ԓ4@"0PIBW/'Fy$CDWIS.(b?42ԁpSeే` ²oRm-|HW%P/p"|!;/կ/>}?t?gχɳ 5-ʬљ5[f͆ykskE[Y4o(WLJD"q6V燔{oS/7[MD2D$"^<noB;@AhP KU&Ո*ECJf`j`} h5lA4\ 4nQHi_Ow#~~ӏ@P.Xg'*3099B'( GdW~Dͬ8g ^fm;mzuzyFe<,#U3økIucbb4.WTv-}:;UV\5J(i^TdN̆mV.U30 R h=WxVGcSmNn8Y&t'<|ׯrSJpY>.r3w|crs(LoZѺQ0(OV(W`j!q2}= Ȥ=Idb9_9J[R=e#B+Al%]*,hb[,~Kd9={;OAɡKZO^.yh4"'C<'vHC,3>?$Kr Kaŝ! 0$e t0Qzi$yxˤ`r%Pn񒰄$ BF4C6ыhP `gP NKUyt-0Tace+T2E,J1ԲJy}D?}ޟ7x~77o֟<{ϟ?ɣEL Tw ֢ـVW2A\5Wkժy}٢Y=լ,%Z}kpZe+= pgB{+AצN .QhޢZ-]hRxQGfRPbS?Sڱrؠ1&B,6U3zU Vj7:0IKr.g0z;TӶ;8B'ز\EO"ֽ<3GxKմpz|lreZx+1a2 놓S3Z;cvbax|`_:hlb>nJY?kHa~#a?LxU=/\sADl^5\z'`Fy[?s#*|R[: s߸VT;%gY~~J0’Qs} Gl LbJuß;sl|J4Dj9"q%ml l@~[*fT/G[US^aI +LOͬN~Lsk{mN]ȩ(nLsQŷ>[/adj7.RGeWE'VUJv% `Jѫ_g߽^pa½W>Zzۑ)ńtPv* {Gx|??+Lz؆k|/H or,X҇] JR% 1ȾKm8G7h_D#AT;G4ni<-kGcϤ%cX36,C.Ls$D]pޑ,dS f":REt+K9 '6s"wahGIx6K wNIbw'`Kɑ%*HJR*֨!R b%>G֎ܪ A~YgXJ fbY䋎6IFGӸF*a;9rw^ ~k{rUgo3w{;5JD V4[[ T&M@e;0KkR*W/-6Tpv6tzB&- NhVmm T:ڃF(' )22PR gxϙ}7uÌc_~9~n݃ڷ@6E1S)#o篇}ϥ:@?Dl/]/J<~Ibbɗ@HlfW4wew* 3j jǗ'ThVČ)GLoni%C9ƻiUɩ1H[Ez3tEu#ӋԼLly֬?yI.^Ss"Ӌ# ]?80)?Wޚ\շ٧Q/^:[9,PfTu܉Ha"i5 'uI52~<4<<[.ߎ2gCVet=oزvYĎLv;>M G$tdADN3LdKrD.lHZIJ: ~^BwD!"<@jI{K^[\'W[K}2o޼_/ї/AQ t^R(_GJ;NHIcd < R-=#0nFDU.>NYj^BJUVuvrXjgXR6W:1d#c^vmtmhetq0{@VW+N7ܭް=1"o_=w[揮?]?goճ;ܝzssrpKhDShZxX+'dFh[gLJ[[XZZHdС&3@]vA nc&:T T1P;TméYi?\C0 Ԡ6S:[c?=g{4n#G Gd'ХQٌ?g1NBZ1U SDU^,uHe .f~;e#PJ@+clhXئtnmnCWHcqgCIwuqOqfs+C9$~}LDj{jJYG1 7C&?/7kz&_~*(^YYs7*yzӞ0:|8;G:X:X&j\üe{&݄ƏnT=>ڃȔܲK?6^d. umQ| =ѮWrm>l2x2/ex#?o Py|{|y\--Cg sȆr_Й-Ub"b`KCL $b|'I]W GHܹ:i"vj)GHF^ N᭦ >ԢL$='Ň\5\rO~k?H"i%W4'WAoܩ3#@<#8J5j-P:ZF 7kŝ90W&\^#mlhbD lk+sM9in( q|Kϔ$/~z_o iϟSU}o?o3w~fLC[?onn}A_UKd#Jq)Ŵ66G#dp\I5K[+neywCyaȓ߉ N! lS}z  @#}=%@04h [HjgR᠙6FԄ2I 5G1v!ާYSvw ݷ{ ^}K` ! bDU^*se2J>R*UȜRGݵd ),! #>(1+)%.!6||Du ٟSgWT '."kLY3Ȝ8-S#֙ECRQd g<;:2&Hl8gNe6 r'Vہa-y;%˯^yse3(h%sqXlNEnMny?a'[y;")vHQwlvyg иd\ @4#a1:{uB|'qF8V#^_ xtC"\ . hͤ|!<<"2ybgșK@q" ʕhEڠ . 8U sɼE2_|Ox$#XBɕy|Ero!U"//<(K%\-/ $B$3BPh lsIx > ) >y/Wm$8J;zK(YjT+PāZUŠhkr^49͗TiǰUm r* ̻ehXVt&N4ǘc&Ƙ5C-S,67wBŐoa/?[׿//__~؆!kkUnY_ANjkI8q{ƶzf1ZfuШhՋ|X*me;`+^eL1X`ܡ&8DK10d'0CQ$01@Ҥ)0KMO>5OQ=D͛<<:a[cepR#5`"eYl6RߘW2WQଔ: U Jݳl}T2X//t^9)5|K8Z>z7\XS[QvJr@Mmf?Ȱfz*XЎ (VeaLrL-Yy=V,i[:7nlѢ C[.,#X$)NSV [.Ȣ VAڐSC.ѶxZBބ 4.8r:Il/Ohm B"{!,A!(((WY u  2$ʗr`]$JhI "R"H.]@ / &/`T$O4.yP WF#d[L?Ӈ҃߾p?,g{`uApKW 6ˎYPH #֞VTPY\2(4rŪHjTiW_vSΛ !4#SP å>$ю5m&0ڣ-cݥQ`B4g!´P,llBi p(yԶkb-^`L7C&P20d [pSUu 5r~V%C5΂^ݦ,؅y r5W_Ll8`2qMxzn6pbudJㆌ; AFŽNoyȀбOFpw{9'8,1jIɘąt/z@rR21(w.!9t $x ZRjC+_bAL@lM|0I Mv6\ZʂOLbTrQdQ"r.Ey(Œ""PJ$"H(%x肱XFU G00H)\ .$. R4T]*wJ[)Ux.!Qʗ|e0MK^(KA0U~kj4W|Uk5?I$OK H(ȎTMH'ԒAR-`R7Q45DMjwT ]^I6Z֩MLJMܖWĢoiUiKp mlKZfYӣ=zfJ l59HnO#pyo_=࿽_=_?WBg?}o߾x^ ۯ鏯nq+u`V2L~hmoidbdU2M=ִ孊D+"J`ݶjM[B6wE,^_^DHTo*VO4s}6Uq ʨ8Jp&"<͂G 5k -"0Y| u%"ڃ /4,Irm;~{C]_կ )7TK+ڤ:՟0F\Ppg]~JynԿpDOxcD~DAʅ$O zB@GyX; N): lX-k`F54>%JUЭ=b0شR=΋iҳGH`Us[J OEy3胰 V׶52E|[oҿeY[okE@RXSSMƆjyM!wt&yk}cyIqhҞZVj$$-6: |XbYlZ6Jc6PV6"8T 8%lBn]t$KI`VO1j0Dռ l"Lm/Jʓ64.ЅK;/Ox56ep/u~jV >5QsK(39Ko9ܥJ{-t]DZKHQ̴Jvin>JZ e`(!0o 0~(NLb4 hAKEP*Ĺ-&tTt 'PLD̙t 4kI@ՠ<9DV`YX6 I9RSh@"(8UCP'GHD B% G{{1kQT:ŝ1&{zr_+5횐.1 |sSVW42sxRciv^NlgG-r~lln:Ϗ'';{Fgfk k:*$LWYl氬Fȃ[qET$V>LnHk+hm I_u32,)}zr+,@;:RZEŋ#A.[Bbql0v.N3th Wki4@sZS vJuN^CcxR#__-2gNDD{apV+GŁ_ncQ:6ҿV=!#|.Lc sC9 0iNY?M/!h1(_GƠ\9 1Q0Bp Kz}a9o\0Ӽə $ctrfh|jplsuRN2(W ,MyzbǹPu)}Q%850650:!mD4M'16=0>=H?p + m!#c}m]M-Me-95ݬ^v}|mdg )VWwik36 ̦ãiw{Ǵ-(uݦD(^nmmkoohomkiimlj6R`k ƖfT56655lIၡ!r^h}}}}#0 AoP72Bdtxbdd| 9sb.׼0c R]ȍ6Th8o(="i`ԿQ]Bsqǥ]C8Fς [< wy׫u:wKHs>>i@_Iq;"sm؉2"gy*{ `sm=U6X\P.7lk:~DAAsVfWLp_jnt=?A+&pjljfi3Y#bzS7je;:t`! \6.ѱ|iyZ 6# 4҃Ot\kHfA<:Bi;<9\\5ehM:q0] 3m2\^>}H,[THK, *6/t_^ LOHr G47V+5զfk1373훖[{YG,]oZ iકZDN]Ak+Ao#cfXv3$ܑmGzDe9R[6tkk5JR,)y[\VVU˫*K+QD>kjMB:"/;b4&=NIbK?PZ--V'k|%v,[b7d#,Ɠ}i6öe,qז-طq_;+0[#Bllί 4.M/lMڶft=](_ t^ t>>oJl2MenpN}7~ɷuVUx7L9s^Gi]]L ?EaW/i" =9K}%udA.eqC}*ZR6ÎaR}O'.^JrR#%أx&?~㼗{2%ʊx77 0s7YvwOOlósm ϟ倢(O .}J#|UĀ([cH{L <#t N-qʗF:{\)P+=x9WҴFK} YAg~%"~j~廴{>(1j]- ~[]6}82ZFǧfe# B'ա`3Tkkgg'>u"[YYn*US~GROεtl3SfcyR{䰮,d帋Jeqtb/Nvmvۉrd=8[/;BW[Eytj?>@6F 3^o^s{u`.\sZ>[c%>A俓cu84SѾ7&{uonvCJnhp`me. 6lu>eNW'Vɓ.s d)\`I׸ k'cc[}:zR[w㘥>d*-2_بY]]Y_dispo'Us\#+i=.P p=|n.\t-x' 򞋩:Fqw|t`_t r$ϚENOu:~7O s^]e nza.߹x.Б˜~WX7471:bR~ɇ-*ϣo/%ހk{Gfˑrl;4kD۸{`?6YNQ bPbdlԼwDw=8E]. b; 9.~r:nlvX܀ Xe"?@mrwrtp p)m|l9<7&j11;ƃC[9i( s 8GÙcHC)X3\`f= }p"~'G#F$;=\}Fը2G,VOf 2R-´ >YrX>>NM}B*xyR y|hgV,\>>a|==(DGζo$~Oh<_l;l fR_VQ~qG?%bDžub&e/#z8Ϩ>ƠX^o!?G+ݣ*dAewOx~nC[3nW-L5+2,Yy{G--5s<>Z;{T. q{I>p4u #"31(kL˪Ji 9m~z C.Y.Q3+4+Wr[,ѸLXYeUxX cK'~.O^&֟qt32U #x̪yCkSVWS;3}8[v Ћ 2ԧ?~A1sf݊zhmٷGU'b7Yf 7GE3qIq]~JcjٖRؔV7=lS752ī!UO1KbقX&*m x9- Yi%5L=[^RX\\]8/S&Yͤ|% 7dZ->Z6_e7^4_QNNoZX|oprS} N-N8[6X{jqvgb3zt/~xJ:ߕCE,;`~kdlmmqbvK {Xb8 Bbu]vNao"0U<\liLH,lj)/)/l.ogTZDx|kfU}hdL,]C Iz|tH+ - k9%hSzT(|ư 6 N0216={tfCTXVl,PۻʪܝZme5W}L|RTl/mIJDX+((!*(X\@КW\&]Zcu %edw 4ZySGσhPQۀ^م%٠o]?29 cQyՅ˅Hop426oxlr#@_h 7tA`0U)(R'VCc@Ri"q2F!#f0ل 99#CNgwP7™)T[$'~Khf3Y %ڭڪz*|`gєAO1r[󣃑s~fFbpyo־ܟDֻa~Bf0 J]^[WG&% KL.(`AY\\ XMX^kGĥ2ExM gp|qrq~qtt O^L\[xW1?+< Fx5MKk5:.aĽ{YU ;5ްkIIDATfuCe;RYō3ܡIW$i(kmOfT&4%C>at;j6|NB_/^o *+rV͇WBXq&ፄ??wZBmJUbKbIM≈U<|!NHaH8ftēT64WA9B 0hq)lhl3{U]#@?sXMH,(J*^8$+,V==3~ɱ%,WBgqH.MN $2| <;!%`ڃNLBz3\iNA42:0 _ =;v-,es:zͱCS3 #$uAO T SC(- 0Bw 9^06PH!T; s20I<_XԚm:i2!ND3:ޡ֔/*t<8g'Zid3,D&u\n;/,Oype}]R s_؎vwz{,&bs%d9N5_?8Yz.o潳 7PJNvmNl_ýD+ Bj}k|6Ca!oa|CzEV7u oU.7boέ{na{ܜc8ۚʉanVyHYRNlkHZexF m]pU!كwr }=>5a2j ۛݝ9ƃ񇕬{ųwKW/_x/ϿzADz~Exj뙽%f~u4[,\z-)YV>{*%QRY&j=+&K=ǔ]:(OZ> >U ?_k\qM_ كHMcj#806,ŕS," g*<97{s k7.( gOR92[,7Ut6O7֎VW W.,%E8V1..Td [!ȣfY[W3:XQmrx2N -mټb`Ƞqb|I7a:-3;5#K`hSTZk9,nƗ IY9nOLIG'>'v3S\YSLș>޳gd *(* WTw *jdŁ96NqD).ٸ?OE $'./eC<䃱⾪OncS܎Nɐ2ss٢ڞ=Ihf 6V,.QM{ou]^ X3跩cUxgjǙ 4pNOS"/OLαxӳLE3sL,,BXy<G(O|m!"A$&ޣ}(7TЋ?q!ey^0545warvVKAOZFַ'D?˚ںek5WTؖ0\9Z9 kw⚙,Lnb~֠=:9`rCYKCCKυhVHOV::&vYUyRmf%d&X9cG؛Njp^.yK[|Xs\p֌#~rY\zaeX&R(\  Ϭ.RW0b.;(hI>ڐ f%%FH62r z K+Ƨ椪-&WA o+"o0V.lrǧ[܂b{O, % 4\/Rp"iu82XF`.^x+vD. @2մw P'V I(ۅfQEӞdGG? ѼC9<9ÓvIAf$u;H6cY;>̊' ;Htc3IBGI}<)}؎1ih2`TVp"녇-:n=CF4F F{G\s!h#Q0ܺǃ엟¾ :'  a b`]8=6.xC bo!# b0C/tOd2}a W.M ZY3jlT.1f' *ۧƧZαŐ[;Kk6uhTv8 5њ:ݔ>պi*NC#"'=(k{G`qj?!,?̀^UP$豽ļFXRvA),-8Wjt^H@M(󣤮;oft躞(.>6):=.&#)xVFI7UaL 5&5LmAtݤΆqMy|s3 0 +GKƋؗZu/[|%\xNΊlĸ]އDͮJ?(n%8hW}#Ae\^Q^T\_L܊ں ޾OJڃ|aJbS|"ʥe|$WO}EEh/yBOd@:ڥ>@܁5>iv5ј;YB+%(A#>xӟ>`(D?#ĎdC#E}T؆C]yB,.#]RKP+=*ʃ]ڊA0+nF,Ex #u]wFh̖!L=8};F|kی=Eؤey3. 3nx=K؀Ћnl@6kah2w2 gomb'Ccss98ceX:nqL314>5/9=Đ9w}q&ߌ*NdEfYKغvg ;wv18EӸZbU D6[6I֬rX%R,F6pN ?l05Z 践Z-k* ~ bo4iўjfGeI%k -kQM+yV4VtwƤfQr'IM%Hk$7[A略Gt5uM4$6썰W‹_~TRHEÐN MY족މAFym_ImW}K_}S_I@}%V,<D_=hB _mO2UGӭrX9)(z\#{}Y>?ݓ6O͟r_?xD*Pµ!P( wTopѹ .*FRrWƧ,h.DHuɞ'˜gefdR6V$g+,E,,2=D35.69̹YatNk~n1333SPc2̹YyɘfƲ3G=3M}\ XƐ.QwZKF8s|[O-yyFו6wNONfLOLvt6wbCs{k$֋f$Uh eGi AVH--]}]}ͭ]M]=;66ʜgwu2LJ;zZP;04=͙eNNLLOMHB0|<xPf<<5\\VxUhG|'E<.qaEoz/xaoES~LUoռSvtĪƮެw#@BU<Â_?,zqk!o=*Nȩ*,Om-hmgvdf7U4 $/|3$">* Z\DZ%DxV^D^ #÷Q _~//x~swsq'흇UוOfeϛNf2q'y/q^57 b ƸPMw!h* EUAHt]{{ttU$Kp9w׵{]{d>5XEߏ:=njr.]p]|8 kD9i}]?G)?Ak9>v]>ǹ?yr|\tf>Gl>&;]E؇ǝr/w9n鼝'88t9dZ;#w{m9y4oGn=5mEV]i}+ 3Wm^a_6Y]{E;הZU /ci%󖮛 5kMթ s,\4oe3Җ&.J=7cԌ-ZVyieeDFK{4b~m5y,X,xΒE+V8o\RNzVkVeP܌y,\;oAm{8 ݃q(/8 qUE%V,??}qffYdаɳV/(Yz݂LI={ּ#Jt@’MQϩa`G>?*%cZ>qщi{tv#ƴn?͏f{ЦۄO|q}^l1!''oq%Ys2gO w?Gb_{{z~'O4Gp<Ϟhr oo^9vlҡm)SNڔ3wC ԩg-۵8|$Nԣ[:tz^xѦܜyiSNM'+VN\z5۰vw o3]oKۯ:"ZյsWNX4weZzEg?fؼṖڲ yEZ{e_)اESO!~O6Fj󉦽z׃z>~χ&=ųamwL:;`̔#&v;S}7xbǞ[߹O;㐩3fLOeJ\Ѿϔ<ýgKbg>H䒃<wi`&e3:'P )"ėVUl8t ܆r/>.<]t>;½~ɢ' wGO;<wqd.< vz`ǡܭl޿vU-_kk6^a53VmH[Yt} +*޵vհ꒙ sgdILϙpMڒyY`KNѡJ.ZquKrӖd_Z<}њ+eL[bR UffNJJ`ṯ20\`q⌹ɳS̛;'Mc',_v3(}>sӳ&%]vݦr/_|uBR)ɩY>9H>FN=nJja CX8ua#f-X97u GN7,˖^ۀǽzg~qLG{O="uԌ9Y݇/y[aSmz AׇanޫuQk]UY[ ِ7i}򉆿{x]}ou?wC0X Hcx5?{ݻ}?]k%\\zs8zF7iآ&ϢڽkMZHJ΄iJU0-uv1~=,ȜEūY]viѪvlpϟ<չ9{lυO׶èGƝn{vyoF^gt~Ats/'v~QgkF] "?^'yȝܰ xi{. :?'vxNoM?֤u禟lնOO7t1.|U~}QQY-~qqi*N9TQn5zUu_|Тb+ KK[""zaB/d5# -,$LnbJu_-_P\\)\`K}8ۅ{S_]\W* IHǦ %dIrsi JNEEr*V 9RrAIKȌ`[G\R!|kܝ?kV%_ġDR _=s׬PTIpCB.rg[=@y]HKsJC %].uEҎuwU%a$ jAc#  R}szwV# 'й 4Ag5(IVsWD.]ǿg/$3J?b;b,*{ g!T*'+َL]NK/Ds[1%p_X) \q* [=q"~ZܲS6G3Pͪ4CLDT$ԏVB/bbAkVh^v.:Jd/wt2d $tR rSqn-K *9z1cilI<5g(`w&*A Iv)D*9-*@k&٫sʠMZD[ITlphӸB.N߭ &AX*m- txʤ}Ng{ X9u鳧NsWZ! VVMqHV +K[6l\qZmPO_*/60V7 zN0π;&sRŐT*U,?;Ҵq6*zҡZ^fqcϹ;YiΕ -sA:[Pr:~hl⣈~94biE529u&FUT#һ;¼114 )>dyTuilp}p[ 7 54oX}Z*b<7,T)аacF8מ|9;dFFyNVKU,'sXhCаXiU<'(sHLs[J{jh7l,D V2$}ْ/=W<+.괄d/TwOc_a.̏'tRdv>=}b'ƏJpy{TUʬR$5p,l ;ۨV""R$+)7E*mT5uVeZhTU}XK~;Bˍ7atKNV GrT2[6ٴ[bJOn[71: ',,1%8\Z%&"cM.1:x,|΋V<q`bdL&ܠ:>NI!1RS֗*X_iNH *'ܻFz5LRAxB΋XKHYEj$YYP 4lF7ݐW/1}(TŽAϵy^Z ئ"r6Vi#^Qڅ*vYBLXD1#FZ;%;dQ#Ǩ)iiZu<(=@g\*0CJtK ?R&RiKS/R7.:2E:A䆏PqxQfھ4U9/:v?\gIw x Nh 0F;:3i0ư^{X˳a5k־{oڴ)ǛRgnڴ.ӗ ۼΚ5VAwlيm-Z &w14pիhQʝ:5;<,.ФIC+?v|޼m/wm.oӷo&óyqظq!C̈:`#1Ԉ$pJLknB5@|@UR&"߿?zR;GfRRfvؑp0 %Id1bd¤s0*u jr o]V7o4}'|J"AXSXg7D (99y94 "i:q2d(`ݼy 9V0`QA/U!C6IIӥ #GNJ*98 Ymٲ>(g*/m9vRAgv <.\4xZ zd8Y popAv{h~niGQIPIX@hO&N=zA)uɜjc֯_? V9,4F@[XŴ*~DZ2 b9P6c4##>mk 4C6lY .]݇w{m+ʮ].h)q~VA1Y {ꝑ1"ع رc߭[ӓ_{5WwwiF2>K 6"Ɂs 6LKu3Sjذ.:4"<9'&&"FF0RyVF׮(?$ֵU#Bzz{g-#0F5oР]۶mEDZY!ҥ&O0{^zmڴi۷7/7%&&;sşӦ% :.^ ԥMO ƙS }'pB)“dp;35W>HHƆoZZzQG# …ً> f[1cƌ?y۔{>jܹ3=vaQciӦk/5姂Vܵkofz;t7S/fϞ[ƍT7vط_2c?g^N7-) Zj5/-+2';gdr:zC(SLѳ'6m:k6U㇄#dAP3'ie({w4?3r/:w]> >y^xcT6+*G4RM4vƇt1bCa2*!~ ԩ2Xtq.]N?N:G&MHT;L/g34UC)zGH b2/hԨ1m׾=90\Ǎ 8iH'00T˖-%322(# 3((͚5GBerrJ@ dx cTSFH;zT^ZppkFfv>f츶LBܙb?laaPy(S`+)hϞ}Ӧ 7nH20ϝVqT( xhZ7TJJX>[ igQq;FSm34LׁSP.`(&\,#ȁ48gBF2%E%11TY='y EfR#Ӕ)%qwSheu pZ4[KAxRn tgrgcA,g͚>}ƔbR%&kv_3X'!1MRSY av(I NR;j&k!ـ9U+h̙3T1$$+W *J3D*H&!_QXAZ'&UK!CEsʓMp$>.)z6Nj @*SjQiuI RyR3xRTȳ ??t'QhN扮8Dxn,M!0dž?,T)6GUb~ aDK^ [\W$|.Aܯn q O5śx% X.!HJIWY RHdyw1˄׫ӧ]* Ke%1ᬄ:!)'2_^ל Pt¥#PΓ[By&p9s!' Vsr%z5h(pF㲒ʺ+D+J(u"[jJ^E|R_+I.jQ}6 W.#J k(XT_d֬3^-^^O*UѵU~0BdI0bph!M/ȫ$zk#9Hrz5>y]~ $9PtJ(=WU׿?W翢xxk4'wY&T_=^mW?mur`Zfz!3ozU2;L\>XŧgŬA+T'窑kz]Z}F@ 11QեOlA(, ddg3b!M.U jhXpB9f%IyCrN,U.vd}dGLGgϱYV+b۹RO 54=04*`BȎvϔ?\$a}Bc*9轓uDXGJ-q2SD.?|Zs\i#(Drid}6 bivT 04ʏc<3kUqfrw8uYޤ*.7h-nCUeH2+>`( d/b.s\Z* v]B Z2W1# I9us|J > ra l܏338. $M]!&~ܫc)IKbЖ{;ݧOp9Bܜ8 TK5r*z?+aI]AP.:兜8#Lj[~K[n ~ٽshXMyW1? & 7t)L ֳ;Nv0bSt4S6$ueΌ`x{~m `s[l@H7yӦͿ/yifQ/~gyF 6!ERT]# ehoĨJ,8Gos!,*K؊uyajR=?я0 q8L9DN$]:w~IC$f}80ʄӛD2$XO&ދq920e)a\x j?;.YaqM7 Pبs\g䋧4*:K~3L+|_Т 5p`}# $5i6mX_l F0`;۴ia3im_]6j蕗_(ڣ>(/y?ΟX[衇^~3> 9evUi,԰[߸UAURVNÒ*s8> ={b5nB㎄1J1;3?7־-!P04'&P#4,>|=c%YSgb#RJ9)!$* eU6)؟gL>6l'?%Q.,4c"c{a'V֠ fa9yC]>$DBbQuѪ^~٭VTf>25`Ft(,TbZF2wu(mnE-A,{c矇 `~wЗp}^Ӱu'6&VnG--/Ԗ-[2XPe˖3ZI?t0A gCULAKתz-T9RVv7K*?XԎYiXSNe˅\?{ꩧpcƈǎ|U+_Xy?o7F2O8]%W_C駱cu]X޺m+uR֭~_d޽YGh}ʰoT<]4,u UB]K^(KCXƠ^VVuS 1ba)0 ó1\Ԁ:t7<;vyVVATŒFfPǘBٳfj -b}k֮EXwjRy bV +ê2f׾QM 7 _JS0H}[_vϓm LG Cl"тt?]WPkٺil_(z5*x֯˜FJ2 ezE1VrTLrn[c;GB/ekři@L W6OxPB~%jR#Mdi=04a { l$ юk  > H4Rե+T9W 14oXPnc*d.)a%rwXa}BTQ=zNXsgPa}C9TmA)Pn@?B^аa#o3\,*a) C)N}f- ahT6oe߈ݕ2{ou{ώQVB5M#jKiKU7߿?6!LҮ]3f7nq={2tLnۿ?itl;Sٜ޻woqo۶O?Hπ\G2[>ȆsTlofñSNup L~]@yWO"d;G^'6_i='O<|ӻwL,/cO>f~ mҤI4hP„K5\+PUQ8 e42w;:{l|KJJU uЂVn{auC/ IWnRb[4z$cחK/޽[owv˸ݱs'r`l3{0p͚5_`J6l1 =k߾P+"cmrQ˴/9Up&5 7~}^Ӫ.^+ל2]1} Rn(:Tu DOTt^q2S(Lwޯ9v%HYeÆ L9p@V33dIKj|5Z2edt "FxԔٗbl9XZxJ77εkZainX+T,RO2ӓoI))x> EgԚgL-aJNr8|e0E*>q r^:H{Ʀ,&dwaO,KLU*B O V-XXY7CMZA + \[[UP)1b.OW/n ׄ\HU.ETH7 t֍;@ z9:_#Dz\.Ask8pcǏag;E+U)=-^JL}Q=xޅ۷ = D*ZA ZhgиyшQ&b#^8ѐx[8}qX(Z\3zh, lٲ:uT͟?}c{[&%Lsg?h*3gğ]H}}&L8q1?nq}yiӦlĞ+SRRZj2SO=۴a7<;-y;sLzת׳?llٲᅬ}RQVaS>obL :nA gCfFCJ*y!#a0y 4T) |l”g 獟{9Pm/Sp ;t7YBUׯGQ$0YkC Cozg( #*?<96xԩ+VocϺ=TjѢETڵk石 6 ua>SO钮%eڨv*r<5c5;vr={`tL Z EYYYNw8tm)3!#r&zΙ;iƍ=1[jڵkcw}k-1 6M `ZL/.w3/h[#GĿ޵BCRE6n]|b~__ :S2_&qRO|XՃA^>ԭqTGsu%*&TLnq|5SP3T eEO654nM4J z|dLNPw]>#<{ tzv\!n, oYrkAIBB "dB*A$="S.<"4GϿ (:*{Em䐔B}( x!.x*cpŖШ:Tr*;q\qFݷw>>νW պPGj TYV?VQa*g]/Y\~aK,3]x SAM+0b)ٓl֭|E|c,c!n߂φ-uFsiKiJZq[**6dw}'q dpŸJCBkc %! !X CѭS;ّL(dѢEhFt:ѭT?veڨPѽlh:hK ~ErY`1Pn87Hf98wy2-BbʆEnZD"h .Nc2B#F8Hۺ8~(5hh7^TMĠ.(J_٤1F( Wxy!2lYa]Rյb|QqQ{7^K)(&xÞCC? X,3=d}yXaBB!N BCgqW7B“; ( eJ];\y:*l4Q"<GaA sl8R%U}di2cĨ~*F%DT>5A“(\(V,Zq7%ÿk1l:qxQC urZt\qN >(Jԉ@nsIv;4߸XE BpA*ۙ[ȩ2NiP1ɰ&ڨ}bbf6fEqt_~y=}bPY-. Rlk@2 F"Z'Jи.}5ܔhJgO yߨ|ܯuKtG?~+CB;z u.Lw=lS@Uٛ]6j:2kƊo[Bиu(*c_?C]hp+kRLدE1ũ.OY~ЛɅPP7*𨶊*((^m䷗k-wY"`hUzRp, p :?h0TCqwf=tME?ϡ* Vz9u6Hɻ=8 HkuX#0 Ea 8k[8ژj"H7+T-1ny:m@mJ< q@1,ZMP>jHOU0<(P#k2-J@]!P3ҹú8O"+2>UO–!`܊Ԇaۛ[_7o.YVFU=sṼ&|04Vl!0@fT:Q~znճs)1U IɃ9yE,CCjCUA:-hUJ7՜)UX7rC0oK7dacc5T},Y–Q3g<|0nZjCUt5qlE!WxV8x2VXbF2{G8؇;B_Þ5ʄB2 H`RlId5*֪Xb5%m He@PஏԪtTUM h!`T@5IJ3 UQƕ E& >2^\̸ +ZaZ߳j/LyUVLBʒWXbyʒW_>+ "Y5Ņֆ,T2˷2?63"OQD38 CsūL* 2H Y>aZ f_>P%PiaͩX"TBa<_-*}i ; %Ya'+Cr)BiL Z^6W4(Aj>Pw#7(;OZ|4I^. >Z PaYO} 㪻J}ɳfT]ve%D,٭u} ω3|^LVU߯ŰAblggg4hξ}roӅD\j7 sYS^p6@gB!O䉴-NؔϑVCA&EE}ȪA ;O!q-l@"q&NHEdJWx|Ja>S5mEFo;]in&PvͨskCUPEb7[L`aQ'pؘ! Rnn.'ZPv}8`L}{I9ַ|r… ' p0POCh8 'q0ŋ@67k0O;r6mڔ g!-əMC$OH&Y7߄y =0 69'PIAkrRxx8`p59I"`|E==/qf? ɇ_!.]P_64Pbؘsߘ4Tu {m +I'=.CaF18~ `A7Su 3 +$ {b|Rs8// P+BWkРX50&_WBo0ACH8oܫW/4^A>"&U03NL~yU_D5q`UJDC_҂uQC8D@IuɫT(BEw!2L}9SWX*?1™y⫿?O$&.yEY1u^ j j̙@zLpPkEh`JL@O"P4LG}Y WERU }#UU?w܁OWez[2SU} Z Bŵ8%8},;n gDR.+A}4~Эq4 .ٯ:|~n~iɪs*U Z/#k8 0߁h<̄)E3UDIѵm$'1"3EsSPfzT H5&LfyLTV᠏~K+`>1U!2p]wȂ&\57@۵QD9%C i(!gHC1 u׃8x&H4)ֶ)ryG`+Ԑ1$h TzRs4]X tuM :k^p_~;s1񤂄OǷkzz#Yy JW$ ԆXCi֬yf]gg,]^\۬5f@:Ў #vt%cЦ"_G'kn-#>S a޽{+ IzR,`gjJUt<n`7TGb,S(_!_E]`r[HDy'QUyjS3VC}m<5GaM}0O Nį B4U؋;zYP.#rS`Cō!WQ+ 1!Y~)]+"B%K/\|ծީXDnTH4,USw QOXP*IGJI$& -qE "|;޷]ZRUp`k6Jq(AG<Ԭׯz C @-BCi@K0 PB!!=FUѷI`UBd C zo0B0 "@UE&!`"`T E0 0 LCE*"`#`T}!QU(D0G*60 C P,!`DQUm`@(FUYC$0 PB!!=FUѷI`UBd C zo0B0 "@UE&!`"`T E0 0 LCE*"`#`T}!QU(D0G*60 C P,!`DQUm`@(FUYC$0 PB!!=FUѷI`UBd C zo0B0 "@UE&!`"`T E0 0 LCE*"`#`T}!QU(D0G*60 C P,!`DQUm`@(FUYC$0 PB!!=FUѷI`UBd C zo0B0 "@UE&!`"`T E0 0 LCE*"`#`T}!QU(D0G*60 C P,!`DQUm`@(FUYC$0 PB!!=FUѷI`UBd C zo0B0 "@UE&!`"`T E0 0 LCE*"`#`T}!QU(D0G*60 C P,!`DQUm`@(FUYC$0 PB!!=FUѷI`UBd C zo0B0 "@UE&!`"`T E0 0 LCE*"`#`T}!QU(D0G*60 C P,!`DQUm`@(FUYC$0 PB!!=FUѷI`UBd C zo0B0 "@UE&!`"`T E0 0 LCE*"`#`T}!QU(D0G*60 C P,!`DQUm`@(FUYC$0 PB!!=FUѷI`UBd C zo0B0 "@UE&!`"`T E0 0 LCE*"`#`T}!QU(D0G*60 C P,!`DQUm`@(FUYC$0 PB!!=FUѷI`UBd C zo0B; 2011-12-04 10:04:40-0600 [-] Setting up http.log rotating 10 files of 10000000 bytes each 2011-12-04 10:04:40-0600 [-] WebStatus using (/home/dustin/tmp/buildbot/master/public_html) 2011-12-04 10:04:40-0600 [-] removing 0 old schedulers, updating 0, and adding 1 2011-12-04 10:04:40-0600 [-] adding 1 new changesources, removing 0 2011-12-04 10:04:40-0600 [-] gitpoller: using workdir '/home/dustin/tmp/buildbot/master/gitpoller-workdir' 2011-12-04 10:04:40-0600 [-] gitpoller: initializing working dir from git://github.com/buildbot/pyflakes.git 2011-12-04 10:04:40-0600 [-] configuration update complete 2011-12-04 10:04:41-0600 [-] gitpoller: checking out master 2011-12-04 10:04:41-0600 [-] gitpoller: finished initializing working dir from git://github.com/buildbot/pyflakes.git at rev 1a4af6ec1dbb724b884ea14f439b272f30439e4d Creating a slave ---------------- Open a new terminal, and first enter the same sandbox you created before:: cd cd tmp/buildbot source sandbox/bin/activate Install buildslave command:: easy_install buildbot-slave Now, create the slave:: buildslave create-slave slave localhost:9989 example-slave pass The user:host pair, username, and password should be the same as the ones in master.cfg; verify this is the case by looking at the section for `c['slaves']` and `c['slavePortnum']`:: cat master/master.cfg Now, start the slave:: buildslave start slave Check the slave's log:: tail -f slave/twistd.log You should see lines like the following at the end of the worker log:: 2009-07-29 20:59:18+0200 [Broker,client] message from master: attached 2009-07-29 20:59:18+0200 [Broker,client] SlaveBuilder.remote_print(buildbot-full): message from master: attached 2009-07-29 20:59:18+0200 [Broker,client] sending application-level keepalives every 600 seconds Meanwhile, in the other terminal, in the master log, if you tail the log you should see lines like this:: 2011-03-13 18:46:58-0700 [Broker,1,127.0.0.1] slave 'example-slave' attaching from IPv4Address(TCP, '127.0.0.1', 41306) 2011-03-13 18:46:58-0700 [Broker,1,127.0.0.1] Got slaveinfo from 'example-slave' 2011-03-13 18:46:58-0700 [Broker,1,127.0.0.1] bot attached 2011-03-13 18:46:58-0700 [Broker,1,127.0.0.1] Buildslave example-slave attached to runtests You should now be able to go to http://localhost:8010, where you will see a web page similar to: .. image:: _images/index.png :alt: index page Click on the `Waterfall Display link `_ and you get this: .. image:: _images/waterfall-empty.png :alt: empty waterfall. That's the end of the first tutorial. A bit underwhelming, you say? Well, that was the point! We just wanted to get you to dip your toes in the water. It's easy to take your first steps, but this is about as far as we can go without touching the configuration. You've got a taste now, but you're probably curious for more. Let's step it up a little in the second tutorial by changing the configuration and doing an actual build. Continue on to :ref:`quick-tour-label` buildbot-0.8.8/docs/tutorial/fiveminutes.rst000066400000000000000000000441131222546025000212170ustar00rootroot00000000000000.. _fiveminutes: =================================================== Buildbot in 5 minutes - a user-contributed tutorial =================================================== (Ok, maybe 10.) Buildbot is really an excellent piece of software, however it can be a bit confusing for a newcomer (like me when I first started looking at it). Typically, at first sight it looks like a bunch of complicated concepts that make no sense and whose relationships with each other are unclear. After some time and some reread, it all slowly starts to be more and more meaningful, until you finally say "oh!" and things start to make sense. Once you get there, you realize that the documentation is great, but only if you already know what it's about. This is what happened to me, at least. Here I'm going to (try to) explain things in a way that would have helped me more as a newcomer. The approach I'm taking is more or less the reverse of that used by the documentation, that is, I'm going to start from the components that do the actual work (the builders) and go up the chain from there up to change sources. I hope purists will forgive this unorthodoxy. Here I'm trying to clarify the concepts only, and will not go into the details of each object or property; the documentation explains those quite well. Installation ------------ I won't cover the installation; both buildbot master and slave are available as packages for the major distributions, and in any case the instructions in the official documentation are fine. This document will refer to buildbot 0.8.5 which was current at the time of writing, but hopefully the concepts are not too different in other versions. All the code shown is of course python code, and has to be included in the master.cfg master configuration file. We won't cover the basic things such as how to define the slaves, project names, or other administrative information that is contained in that file; for that, again the official documentation is fine. Builders: the workhorses ------------------------ Since buildbot is a tool whose goal is the automation of software builds, it makes sense to me to start from where we tell buildbot how to build our software: the builder (or builders, since there can be more than one). Simply put, a builder is an element that is in charge of performing some action or sequence of actions, normally something related to building software (for example, checking out the source, or "make all"), but it can also run arbitrary commands. A builder is configured with a list of slaves that it can use to carry out its task. The other fundamental piece of information that a builder needs is, of course, the list of things it has to do (which will normally run on the chosen slave). In buildbot, this list of things is represented as a BuildFactory object, which is essentially a sequence of steps, each one defining a certain operation or command. Enough talk, let's see an example. For this example, we are going to assume that our super software project can be built using a simple "make all", and there is another target "make packages" that creates rpm, deb and tgz packages of the binaries. In the real world things are usually more complex (for example there may be a "configure" step, or multiple targets), but the concepts are the same; it will just be a matter of adding more steps to a builder, or creating multiple builders, although sometimes the resulting builders can be quite complex. So to perform a manual build of our project we would type this from the command line (assuming we are at the root of the local copy of the repository):: $ make clean # clean remnants of previous builds ... $ svn update ... $ make all ... $ make packages ... # optional but included in the example: copy packages to some central machine $ scp packages/*.rpm packages/*.deb packages/*.tgz someuser@somehost:/repository ... Here we're assuming the repository is SVN, but again the concepts are the same with git, mercurial or any other VCS. Now, to automate this, we create a builder where each step is one of the commands we typed above. A step can be a shell command object, or a dedicated object that checks out the source code (there are various types for different repositories, see the docs for more info), or yet something else:: from buildbot.process.factory import BuildFactory from buildbot.steps.source import SVN from buildbot.steps.shell import ShellCommand # first, let's create the individual step objects # step 1: make clean; this fails if the slave has no local copy, but # is harmless and will only happen the first time makeclean = ShellCommand(name = "make clean", command = ["make", "clean"], description = "make clean") # step 2: svn update (here updates trunk, see the docs for more # on how to update a branch, or make it more generic). checkout = SVN(baseURL = 'svn://myrepo/projects/coolproject/trunk', mode = "update", username = "foo", password = "bar", haltOnFailure = True ) # step 3: make all makeall = ShellCommand(name = "make all", command = ["make", "all"], haltOnFailure = True, description = "make all") # step 4: make packages makepackages = ShellCommand(name = "make packages", command = ["make", "packages"], haltOnFailure = True, description = "make packages") # step 5: upload packages to central server. This needs passwordless ssh # from the slave to the server (set it up in advance as part of slave setup) uploadpackages = ShellCommand(name = "upload packages", description = "upload packages", command = "scp packages/*.rpm packages/*.deb packages/*.tgz someuser@somehost:/repository", haltOnFailure = True) # create the build factory and add the steps to it f_simplebuild = BuildFactory() f_simplebuild.addStep(makeclean) f_simplebuild.addStep(checkout) f_simplebuild.addStep(makeall) f_simplebuild.addStep(makepackages) f_simplebuild.addStep(uploadpackages) # finally, declare the list of builders. In this case, we only have one builder c['builders'] = [ BuilderConfig(name = "simplebuild", slavenames = ['slave1', 'slave2', 'slave3'], factory = f_simplebuild) ] So our builder is called "simplebuild" and can run on either of slave1, slave2 and slave3. If our repository has other branches besides trunk, we could create another one or more builders to build them; in the example, only the checkout step would be different, in that it would need to check out the specific branch. Depending on how exactly those branches have to be built, the shell commands may be recycled, or new ones would have to be created if they are different in the branch. You get the idea. The important thing is that all the builders be named differently and all be added to the c['builders'] value (as can be seen above, it is a list of BuilderConfig objects). Of course the type and number of steps will vary depending on the goal; for example, to just check that a commit doesn't break the build, we could include just up to the "make all" step. Or we could have a builder that performs a more thorough test by also doing "make test" or other targets. You get the idea. Note that at each step except the very first we use haltOnFailure = True because it would not make sense to execute a step if the previous one failed (ok, it wouldn't be needed for the last step, but it's harmless and protects us if one day we add another step after it). Schedulers ---------- Now this is all nice and dandy, but who tells the builder (or builders) to run, and when? This is the job of the scheduler, which is a fancy name for an element that waits for some event to happen, and when it does, based on that information decides whether and when to run a builder (and which one or ones). There can be more than one scheduler. I'm being purposely vague here because the possibilities are almost endless and highly dependent on the actual setup, build purposes, source repository layout and other elements. So a scheduler needs to be configured with two main pieces of information: on one hand, which events to react to, and on the other hand, which builder or builders to trigger when those events are detected. (It's more complex than that, but if you understand this, you can get the rest of the details from the docs). A simple type of scheduler may be a periodic scheduler: when a configurable amount of time has passed, run a certain builder (or builders). In our example, that's how we would trigger a build every hour:: from buildbot.schedulers.timed import Periodic # define the periodic scheduler hourlyscheduler = Periodic(name = "hourly", builderNames = ["simplebuild"], periodicBuildTimer = 3600) # define the available schedulers c['schedulers'] = [ hourlyscheduler ] That's it. Every hour this "hourly" scheduler will run the "simplebuild" builder. If we have more than one builder that we want to run every hour, we can just add them to the builderNames list when defining the scheduler and they will all be run. Or since multiple scheduler are allowed, other schedulers can be defined and added to c['schedulers'] in the same way. Other types of schedulers exist; in particular, there are schedulers that can be more dynamic than the periodic one. The typical dynamic scheduler is one that learns about changes in a source repository (generally because some developer checks in some change), and triggers one or more builders in response to those changes. Let's assume for now that the scheduler "magically" learns about changes in the repository (more about this later); here's how we would define it:: from buildbot.schedulers.basic import SingleBranchScheduler from buildbot.changes import filter # define the dynamic scheduler trunkchanged = SingleBranchScheduler(name = "trunkchanged", change_filter = filter.ChangeFilter(branch = None), treeStableTimer = 300, builderNames = ["simplebuild"]) # define the available schedulers c['schedulers'] = [ trunkchanged ] This scheduler receives changes happening to the repository, and among all of them, pays attention to those happening in "trunk" (that's what branch = None means). In other words, it filters the changes to react only to those it's interested in. When such changes are detected, and the tree has been quiet for 5 minutes (300 seconds), it runs the "simplebuild" builder. The treeStableTimer helps in those situations where commits tend to happen in bursts, which would otherwise result in multiple build requests queuing up. What if we want to act on two branches (say, trunk and 7.2)? First we create two builders, one for each branch (see the builders paragraph above), then we create two dynamic schedulers:: from buildbot.schedulers.basic import SingleBranchScheduler from buildbot.changes import filter # define the dynamic scheduler for trunk trunkchanged = SingleBranchScheduler(name = "trunkchanged", change_filter = filter.ChangeFilter(branch = None), treeStableTimer = 300, builderNames = ["simplebuild-trunk"]) # define the dynamic scheduler for the 7.2 branch branch72changed = SingleBranchScheduler(name = "branch72changed", change_filter = filter.ChangeFilter(branch = 'branches/7.2'), treeStableTimer = 300, builderNames = ["simplebuild-72"]) # define the available schedulers c['schedulers'] = [ trunkchanged, branch72changed ] The syntax of the change filter is VCS-dependent (above is for SVN), but again once the idea is clear, the documentation has all the details. Another feature of the scheduler is that is can be told which changes, within those it's paying attention to, are important and which are not. For example, there may be a documentation directory in the branch the scheduler is watching, but changes under that directory should not trigger a build of the binary. This finer filtering is implemented by means of the fileIsImportant argument to the scheduler (full details in the docs and - alas - in the sources). Change sources -------------- Earlier we said that a dynamic scheduler "magically" learns about changes; the final piece of the puzzle are change sources, which are precisely the elements in buildbot whose task is to detect changes in the repository and communicate them to the schedulers. Note that periodic schedulers don't need a change source, since they only depend on elapsed time; dynamic schedulers, on the other hand, do need a change source. A change source is generally configured with information about a source repository (which is where changes happen); a change source can watch changes at different levels in the hierarchy of the repository, so for example it is possible to watch the whole repository or a subset of it, or just a single branch. This determines the extent of the information that is passed down to the schedulers. There are many ways a change source can learn about changes; it can periodically poll the repository for changes, or the VCS can be configured (for example through hook scripts triggered by commits) to push changes into the change source. While these two methods are probably the most common, they are not the only possibilities; it is possible for example to have a change source detect changes by parsing some email sent to a mailing list when a commit happen, and yet other methods exist. The manual again has the details. To complete our example, here's a change source that polls a SVN repository every 2 minutes:: from buildbot.changes.svnpoller import SVNPoller, split_file_branches svnpoller = SVNPoller(svnurl = "svn://myrepo/projects/coolproject", svnuser = "foo", svnpasswd = "bar", pollinterval = 120, split_file = split_file_branches) c['change_source'] = svnpoller This poller watches the whole "coolproject" section of the repository, so it will detect changes in all the branches. We could have said svnurl = "svn://myrepo/projects/coolproject/trunk" or svnurl = "svn://myrepo/projects/coolproject/branches/7.2" to watch only a specific branch. To watch another project, you need to create another change source -- and you need to filter changes by project. For instance, when you add a changesource watching project 'superproject' to the above example, you need to change:: trunkchanged = SingleBranchScheduler(name = "trunkchanged", change_filter = filter.ChangeFilter(branch = None), ... to e.g.:: trunkchanged = SingleBranchScheduler(name = "trunkchanged", change_filter = filter.ChangeFilter(project = "coolproject", branch = None), ... else coolproject will be built when there's a change in superproject. Since we're watching more than one branch, we need a method to tell in which branch the change occurred when we detect one. This is what the split_file argument does, it takes a callable that buildbot will call to do the job. The split_file_branches function, which comes with buildbot, is designed for exactly this purpose so that's what the example above uses. And of course this is all SVN-specific, but there are pollers for all the popular VCSs. But note: if you have many projects, branches, and builders it probably pays to not hardcode all the schedulers and builders in the configuration, but generate them dynamically starting from list of all projects, branches, targets etc. and using loops to generate all possible combinations (or only the needed ones, depending on the specific setup), as explained in the documentation chapter about :ref:`Customization`. Status targets -------------- Now that the basics are in place, let's go back to the builders, which is where the real work happens. Status targets are simply the means buildbot uses to inform the world about what's happening, that is, how builders are doing. There are many status target: a web interface, a mail notifier, an IRC notifier, and others. They are described fairly well in the manual. One thing I've found useful is the ability to pass a domain name as the lookup argument to a mailNotifier, which allows to take an unqualified username as it appears in the SVN change and create a valid email address by appending the given domain name to it:: from buildbot.status import mail # if jsmith commits a change, mail for the build is sent to jsmith@example.org notifier = mail.MailNotifier(fromaddr = "buildbot@example.org", sendToInterestedUsers = True, lookup = "example.org") c['status'].append(notifier) The mail notifier can be customized at will by means of the messageFormatter argument, which is a function that buildbot calls to format the body of the email, and to which it makes available lots of information about the build. Here all the details. Conclusion ---------- Please note that this article has just scratched the surface; given the complexity of the task of build automation, the possiblities are almost endless. So there's much, much more to say about buildbot. However, hopefully this is a preparation step before reading the official manual. Had I found an explanation as the one above when I was approaching buildbot, I'd have had to read the manual just once, rather than multiple times. Hope this can help someone else. (Thanks to Davide Brini for permission to include this tutorial, derived from one he originally posted at http://backreference.org ) buildbot-0.8.8/docs/tutorial/further.rst000066400000000000000000000002341222546025000203340ustar00rootroot00000000000000Further Reading =============== See the following user-contributed tutorials for other highlights and ideas: .. toctree:: :maxdepth: 2 fiveminutes buildbot-0.8.8/docs/tutorial/index.rst000066400000000000000000000001551222546025000177660ustar00rootroot00000000000000Buildbot Tutorial ================= Contents: .. toctree:: :maxdepth: 2 firstrun tour further buildbot-0.8.8/docs/tutorial/tour.rst000066400000000000000000000344711222546025000176600ustar00rootroot00000000000000.. _quick-tour-label: ============ A Quick Tour ============ Goal ---- This tutorial will expand on the :ref:`first-run-label` tutorial by taking a quick tour around some of the features of buildbot that are hinted at in the comments in the sample configuration. We will simply change parts of the default configuration and explain the activated features. As a part of this tutorial, we will make buildbot do a few actual builds. This section will teach you how to: - make simple configuration changes and activate them - deal with configuration errors - force builds - enable and control the IRC bot - enable ssh debugging - add a 'try' scheduler Setting Project Name and URL ---------------------------- Let's start simple by looking at where you would customize the buildbot's project name and URL. We continue where we left off in the :ref:`first-run-label` tutorial. Open a new terminal, and first enter the same sandbox you created before (where $EDITOR is your editor of choice like vim, gedit, or emacs):: cd cd tmp/buildbot source sandbox/bin/activate $EDITOR master/master.cfg Now, look for the section marked *PROJECT IDENTITY* which reads:: ####### PROJECT IDENTITY # the 'title' string will appear at the top of this buildbot # installation's html.WebStatus home page (linked to the # 'titleURL') and is embedded in the title of the waterfall HTML page. c['title'] = "Pyflakes" c['titleURL'] = "http://divmod.org/trac/wiki/DivmodPyflakes" If you want, you can change either of these links to anything you want to see what happens when you change them. After making a change go into the terminal and type:: buildbot reconfig master You will see a handful of lines of output from the master log, much like this:: 2011-12-04 10:11:09-0600 [-] loading configuration from /home/dustin/tmp/buildbot/master/master.cfg 2011-12-04 10:11:09-0600 [-] configuration update started 2011-12-04 10:11:09-0600 [-] builder runtests is unchanged 2011-12-04 10:11:09-0600 [-] removing IStatusReceiver 2011-12-04 10:11:09-0600 [-] (TCP Port 8010 Closed) 2011-12-04 10:11:09-0600 [-] Stopping factory 2011-12-04 10:11:09-0600 [-] adding IStatusReceiver 2011-12-04 10:11:09-0600 [-] RotateLogSite starting on 8010 2011-12-04 10:11:09-0600 [-] Starting factory 2011-12-04 10:11:09-0600 [-] Setting up http.log rotating 10 files of 10000000 bytes each 2011-12-04 10:11:09-0600 [-] WebStatus using (/home/dustin/tmp/buildbot/master/public_html) 2011-12-04 10:11:09-0600 [-] removing 0 old schedulers, updating 0, and adding 0 2011-12-04 10:11:09-0600 [-] adding 1 new changesources, removing 1 2011-12-04 10:11:09-0600 [-] gitpoller: using workdir '/home/dustin/tmp/buildbot/master/gitpoller-workdir' 2011-12-04 10:11:09-0600 [-] GitPoller repository already exists 2011-12-04 10:11:09-0600 [-] configuration update complete Reconfiguration appears to have completed successfully. The important lines are the ones telling you that it is loading the new configuration at the top, and the one at the bottom saying that the update is complete. Now, if you go back to `the waterfall page `_, you will see that the project's name is whatever you may have changed it to and when you click on the URL of the project name at the bottom of the page it should take you to the link you put in the configuration. Configuration Errors -------------------- It is very common to make a mistake when configuring buildbot, so you might as well see now what happens in that case and what you can do to fix the error. Open up the config again and introduce a syntax error by removing the first single quote in the two lines you changed, so they read:: c[title'] = "Pyflakes" c['titleURL'] = "http://divmod.org/trac/wiki/DivmodPyflakes" This creates a Python SyntaxError. Now go ahead and reconfig the buildmaster:: buildbot reconfig master This time, the output looks like:: 2011-12-04 10:12:28-0600 [-] loading configuration from /home/dustin/tmp/buildbot/master/master.cfg 2011-12-04 10:12:28-0600 [-] configuration update started 2011-12-04 10:12:28-0600 [-] error while parsing config file 2011-12-04 10:12:28-0600 [-] Unhandled Error Traceback (most recent call last): File "/home/dustin/tmp/buildbot/sandbox/lib/python2.7/site-packages/buildbot-0.8.5-py2.7.egg/buildbot/master.py", line 197, in loadTheConfigFile d = self.loadConfig(f) File "/home/dustin/tmp/buildbot/sandbox/lib/python2.7/site-packages/buildbot-0.8.5-py2.7.egg/buildbot/master.py", line 579, in loadConfig d.addCallback(do_load) File "/home/dustin/tmp/buildbot/sandbox/lib/python2.7/site-packages/Twisted-11.1.0-py2.7-linux-x86_64.egg/twisted/internet/defer.py", line 298, in addCallback callbackKeywords=kw) File "/home/dustin/tmp/buildbot/sandbox/lib/python2.7/site-packages/Twisted-11.1.0-py2.7-linux-x86_64.egg/twisted/internet/defer.py", line 287, in addCallbacks self._runCallbacks() --- --- File "/home/dustin/tmp/buildbot/sandbox/lib/python2.7/site-packages/Twisted-11.1.0-py2.7-linux-x86_64.egg/twisted/internet/defer.py", line 545, in _runCallbacks current.result = callback(current.result, *args, **kw) File "/home/dustin/tmp/buildbot/sandbox/lib/python2.7/site-packages/buildbot-0.8.5-py2.7.egg/buildbot/master.py", line 226, in do_load exec f in localDict exceptions.SyntaxError: EOL while scanning string literal (master.cfg, line 17) Never saw reconfiguration finish. This time, it's clear that there was a mistake. in the configuration. Luckily, the buildbot master will ignore the wrong configuration and keep running with the previous configuration. The message is clear enough, so open the configuration again, fix the error, and reconfig the master. Your First Build ---------------- By now you're probably thinking: "All this time spent and still not done a single build ? What was the name of this project again ?" On the `waterfall `_. page, click on the runtests link. You'll see a builder page, and in the upper-right corner is a box where you can login. The default username and password are both "pyflakes". Once you've logged in, you will see some new options that allow you to force a build: .. image:: _images/force-build.png :alt: force a build. Click *Force Build* - there's no need to fill in any of the fields in this case. Next, click on `view in waterfall `_. You will now see: .. image:: _images/runtests-success.png :alt: an successful test run happened. Enabling the IRC Bot -------------------- Buildbot includes an IRC bot that you can tell to join a channel and control to report on the status of buildbot. First, start an IRC client of your choice, connect to irc.freenode.org and join an empty channel. In this example we will use #buildbot-test, so go join that channel. (*Note: please do not join the main buildbot channel!*) Edit the config and look for the *STATUS TARGETS* section. Enter these lines below the WebStatus line in master.cfg:: c['status'].append(html.WebStatus(http_port=8010, authz=authz_cfg)) from buildbot.status import words c['status'].append(words.IRC(host="irc.freenode.org", nick="bbtest", channels=["#buildbot-test"])) Reconfigure the build master then do:: cat master/twistd.log | grep IRC The log output should contain a line like this:: 2009-08-01 15:35:20+0200 [-] adding IStatusReceiver You should see the bot now joining in your IRC client. In your IRC channel, type:: bbtest: commands to get a list of the commands the bot supports. Let's tell the bot to notify certain events, to learn which EVENTS we can notify on:: bbtest: help notify Now let's set some event notifications:: bbtest: notify on started bbtest: notify on finished bbtest: notify on failure The bot should have responded to each of the commands:: <@lsblakk> bbtest: notify on started The following events are being notified: ['started'] <@lsblakk> bbtest: notify on finished The following events are being notified: ['started', 'finished'] <@lsblakk> bbtest: notify on failure The following events are being notified: ['started', 'failure', 'finished'] Now, go back to the web interface and force another build. Notice how the bot tells you about the start and finish of this build:: < bbtest> build #1 of runtests started, including [] < bbtest> build #1 of runtests is complete: Success [build successful] Build details are at http://localhost:8010/builders/runtests/builds/1 You can also use the bot to force a build:: bbtest: force build runtests test build But to allow this, you'll need to have ``allowForce`` in the IRC configuration:: c['status'].append(words.IRC(host="irc.freenode.org", nick="bbtest", allowForce=True, channels=["#buildbot-test"])) This time, the bot is giving you more output, as it's specifically responding to your direct request to force a build, and explicitly tells you when the build finishes:: <@lsblakk> bbtest: force build runtests test build < bbtest> build #2 of runtests started, including [] < bbtest> build forced [ETA 0 seconds] < bbtest> I'll give a shout when the build finishes < bbtest> build #2 of runtests is complete: Success [build successful] Build details are at http://localhost:8010/builders/runtests/builds/2 You can also see the new builds in the web interface. .. image:: _images/irc-testrun.png :alt: a successful test run from IRC happened. Setting Authorized Web Users ---------------------------- Further down, look for the WebStatus configuration:: c['status'] = [] from buildbot.status import html from buildbot.status.web import authz, auth authz_cfg=authz.Authz( # change any of these to True to enable; see the manual for more # options auth=auth.BasicAuth([("pyflakes","pyflakes")]), gracefulShutdown = False, forceBuild = 'auth', # use this to test your slave once it is set up forceAllBuilds = False, pingBuilder = False, stopBuild = False, stopAllBuilds = False, cancelPendingBuild = False, ) c['status'].append(html.WebStatus(http_port=8010, authz=authz_cfg)) The ``auth.BasicAuth()`` define authorized users and their passwords. You can change these or add new ones. See :bb:status:`WebStatus` for more about the WebStatus configuration. Debugging with Manhole ---------------------- You can do some debugging by using manhole, an interactive Python shell. It exposes full access to the buildmaster's account (including the ability to modify and delete files), so it should not be enabled with a weak or easily guessable password. To use this you will need to install an additional package or two to your virtualenv:: cd cd tmp/buildbot source sandbox/bin/activate easy_install pycrypto easy_install pyasn1 In your master.cfg find:: c = BuildmasterConfig = {} Insert the following to enable debugging mode with manhole:: ####### DEBUGGING from buildbot import manhole c['manhole'] = manhole.PasswordManhole("tcp:1234:interface=127.0.0.1","admin","passwd") After restarting the master, you can ssh into the master and get an interactive Python shell:: ssh -p1234 admin@127.0.0.1 # enter passwd at prompt .. note:: The pyasn1-0.1.1 release has a bug which results in an exception similar to this on startup:: exceptions.TypeError: argument 2 must be long, not int If you see this, the temporary solution is to install the previous version of pyasn1:: pip install pyasn1-0.0.13b If you wanted to check which slaves are connected and what builders those slaves are assigned to you could do:: >>> master.botmaster.slaves {'example-slave': } Objects can be explored in more depth using `dir(x)` or the helper function `show(x)`. Adding a 'try' scheduler ------------------------ Buildbot includes a way for developers to submit patches for testing without committing them to the source code control system. (This is really handy for projects that support several operating systems or architectures.) To set this up, add the following lines to master.cfg:: from buildbot.scheduler import Try_Userpass c['schedulers'].append(Try_Userpass( name='try', builderNames=['runtests'], port=5555, userpass=[('sampleuser','samplepass')])) Then you can submit changes using the :bb:cmdline:`try` command. Let's try this out by making a one-line change to pyflakes, say, to make it trace the tree by default:: git clone git://github.com/buildbot/pyflakes.git pyflakes-git cd pyflakes-git/pyflakes $EDITOR checker.py # change "traceTree = False" on line 185 to "traceTree = True" Then run buildbot's try command as follows:: source ~/tmp/buildbot/sandbox/bin/activate buildbot try --connect=pb --master=127.0.0.1:5555 --username=sampleuser --passwd=samplepass --vc=git This will do "git diff" for you and send the resulting patch to the server for build and test against the latest sources from Git. Now go back to the `waterfall `_ page, click on the runtests link, and scroll down. You should see that another build has been started with your change (and stdout for the tests should be chock-full of parse trees as a result). The "Reason" for the job will be listed as "'try' job", and the blamelist will be empty. To make yourself show up as the author of the change, use the --who=emailaddr option on 'buildbot try' to pass your email address. To make a description of the change show up, use the --properties=comment="this is a comment" option on 'buildbot try'. To use ssh instead of a private username/password database, see :bb:sched:`Try_Jobdir`. buildbot-0.8.8/setup.cfg000066400000000000000000000000431222546025000151470ustar00rootroot00000000000000[aliases] test = trial -m buildbot buildbot-0.8.8/setup.py000077500000000000000000000172551222546025000150600ustar00rootroot00000000000000#!/usr/bin/env python # # This file is part of Buildbot. Buildbot 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, version 2. # # 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, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members """ Standard setup script. """ import sys import os import glob from distutils.core import setup from buildbot import version from distutils.command.install_data import install_data from distutils.command.sdist import sdist def include(d, e): """Generate a pair of (directory, file-list) for installation. 'd' -- A directory 'e' -- A glob pattern""" return (d, [f for f in glob.glob('%s/%s'%(d, e)) if os.path.isfile(f)]) class install_data_twisted(install_data): """make sure data files are installed in package. this is evil. copied from Twisted/setup.py. """ def finalize_options(self): self.set_undefined_options('install', ('install_lib', 'install_dir'), ) install_data.finalize_options(self) def run(self): install_data.run(self) # ensure there's a buildbot/VERSION file fn = os.path.join(self.install_dir, 'buildbot', 'VERSION') open(fn, 'w').write(version) self.outfiles.append(fn) class our_sdist(sdist): def make_release_tree(self, base_dir, files): sdist.make_release_tree(self, base_dir, files) # ensure there's a buildbot/VERSION file fn = os.path.join(base_dir, 'buildbot', 'VERSION') open(fn, 'w').write(version) # ensure that NEWS has a copy of the latest release notes, with the # proper version substituted src_fn = os.path.join('docs', 'relnotes/index.rst') src = open(src_fn).read() src = src.replace('|version|', version) dst_fn = os.path.join(base_dir, 'NEWS') open(dst_fn, 'w').write(src) long_description=""" The BuildBot is a system to automate the compile/test cycle required by most software projects to validate code changes. By automatically rebuilding and testing the tree each time something has changed, build problems are pinpointed quickly, before other developers are inconvenienced by the failure. The guilty developer can be identified and harassed without human intervention. By running the builds on a variety of platforms, developers who do not have the facilities to test their changes everywhere before checkin will at least know shortly afterwards whether they have broken the build or not. Warning counts, lint checks, image size, compile time, and other build parameters can be tracked over time, are more visible, and are therefore easier to improve. """ scripts = ["bin/buildbot"] # sdist is usually run on a non-Windows platform, but the buildslave.bat file # still needs to get packaged. if 'sdist' in sys.argv or sys.platform == 'win32': scripts.append("contrib/windows/buildbot.bat") scripts.append("contrib/windows/buildbot_service.py") setup_args = { 'name': "buildbot", 'version': version, 'description': "BuildBot build automation system", 'long_description': long_description, 'author': "Brian Warner", 'author_email': "warner-buildbot@lothar.com", 'maintainer': "Dustin J. Mitchell", 'maintainer_email': "dustin@v.igoro.us", 'url': "http://buildbot.net/", 'license': "GNU GPL", 'classifiers': [ 'Development Status :: 5 - Production/Stable', 'Environment :: No Input/Output (Daemon)', 'Environment :: Web Environment', 'Intended Audience :: Developers', 'License :: OSI Approved :: GNU General Public License (GPL)', 'Topic :: Software Development :: Build Tools', 'Topic :: Software Development :: Testing', ], 'packages': ["buildbot", "buildbot.status", "buildbot.status.web","buildbot.status.web.hooks", "buildbot.changes", "buildbot.buildslave", "buildbot.steps", "buildbot.steps.package", "buildbot.steps.package.deb", "buildbot.steps.package.rpm", "buildbot.steps.source", "buildbot.process", "buildbot.process.users", "buildbot.clients", "buildbot.monkeypatches", "buildbot.schedulers", "buildbot.scripts", "buildbot.db", "buildbot.db.migrate.versions", "buildbot.util", "buildbot.test", "buildbot.test.fake", "buildbot.test.unit", "buildbot.test.util", "buildbot.test.regressions", ], 'data_files': [ ("buildbot", [ "buildbot/buildbot.png", ]), ("buildbot/db/migrate", [ "buildbot/db/migrate/migrate.cfg", ]), include("buildbot/db/migrate/versions", "*.py"), ("buildbot/clients", [ "buildbot/clients/debug.glade", ]), ("buildbot/status/web/files", [ "buildbot/status/web/files/default.css", "buildbot/status/web/files/bg_gradient.jpg", "buildbot/status/web/files/robots.txt", "buildbot/status/web/files/templates_readme.txt", "buildbot/status/web/files/favicon.ico", ]), include("buildbot/status/web/templates", '*.html'), include("buildbot/status/web/templates", '*.xml'), ("buildbot/scripts", [ "buildbot/scripts/sample.cfg", "buildbot/scripts/buildbot_tac.tmpl", ]), ], 'scripts': scripts, 'cmdclass': {'install_data': install_data_twisted, 'sdist': our_sdist}, } # set zip_safe to false to force Windows installs to always unpack eggs # into directories, which seems to work better -- # see http://buildbot.net/trac/ticket/907 if sys.platform == "win32": setup_args['zip_safe'] = False py_26 = sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 6) try: # If setuptools is installed, then we'll add setuptools-specific arguments # to the setup args. import setuptools #@UnusedImport except ImportError: pass else: ## dependencies setup_args['install_requires'] = [ 'twisted >= 9.0.0', 'Jinja2 >= 2.1', # sqlalchemy-0.8 betas show issues with sqlalchemy-0.7.2, so # stick to 0.7.9 'sqlalchemy >= 0.6, <= 0.7.9', # buildbot depends on sqlalchemy internals, and these are the tested # versions. 'sqlalchemy-migrate ==0.6.1, ==0.7.0, ==0.7.1, ==0.7.2', 'python-dateutil==1.5', ] setup_args['tests_require'] = [ 'mock', ] # Python-2.6 and up includes json if not py_26: setup_args['install_requires'].append('simplejson') # Python-2.6 and up includes a working A sqlite (py25's is broken) if not py_26: setup_args['install_requires'].append('pysqlite') if os.getenv('NO_INSTALL_REQS'): setup_args['install_requires'] = None setup_args['tests_require'] = None setup(**setup_args) # Local Variables: # fill-column: 71 # End: