From 5bdf769feaab530cdd8a2c41a3ad017b2667917f Mon Sep 17 00:00:00 2001 From: Julio Biason Date: Sun, 12 Apr 2009 21:16:20 +1000 Subject: [PATCH] Initial import from SVN --- .gitignore | 2 + AUTHORS | 3 + COPYING | 674 ++++++++++++++ MANIFEST | 21 + THANKS | 14 + check | 3 + docs/Makefile | 75 ++ docs/conf.py | 202 +++++ docs/index.rst | 21 + docs/networkbase.rst | 15 + mitter | 106 +++ mitter.desktop | 9 + mitterlib/__init__.py | 50 ++ mitterlib/configopt.py | 302 +++++++ mitterlib/constants.py | 697 ++++++++++++++ mitterlib/network/__init__.py | 229 +++++ mitterlib/network/networkbase.py | 225 +++++ mitterlib/network/twitter.py | 414 +++++++++ mitterlib/ui/__init__.py | 81 ++ mitterlib/ui/console_utils.py | 145 +++ mitterlib/ui/notify.py | 84 ++ mitterlib/ui/timesince.py | 90 ++ mitterlib/ui/ui_cmd.py | 326 +++++++ mitterlib/ui/ui_mh.py | 163 ++++ mitterlib/ui/ui_pygtk.py | 1448 ++++++++++++++++++++++++++++++ mitterlib/ui/ui_tty.py | 130 +++ mitterlib/ui/utils.py | 31 + pixmaps/icon_star_empty.gif | Bin 0 -> 13319 bytes pixmaps/icon_star_full.gif | Bin 0 -> 13623 bytes pixmaps/icon_trash.gif | Bin 0 -> 148 bytes pixmaps/mitter-new.png | Bin 0 -> 1079 bytes pixmaps/mitter.png | Bin 0 -> 903 bytes pixmaps/reply.png | Bin 0 -> 47144 bytes pixmaps/unknown.png | Bin 0 -> 1274 bytes setup.py | 42 + tests.py | 72 ++ utils/pep8.py | 863 ++++++++++++++++++ 37 files changed, 6537 insertions(+) create mode 100644 .gitignore create mode 100644 AUTHORS create mode 100644 COPYING create mode 100644 MANIFEST create mode 100644 THANKS create mode 100755 check create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/networkbase.rst create mode 100755 mitter create mode 100644 mitter.desktop create mode 100644 mitterlib/__init__.py create mode 100644 mitterlib/configopt.py create mode 100644 mitterlib/constants.py create mode 100644 mitterlib/network/__init__.py create mode 100644 mitterlib/network/networkbase.py create mode 100644 mitterlib/network/twitter.py create mode 100644 mitterlib/ui/__init__.py create mode 100644 mitterlib/ui/console_utils.py create mode 100644 mitterlib/ui/notify.py create mode 100644 mitterlib/ui/timesince.py create mode 100644 mitterlib/ui/ui_cmd.py create mode 100644 mitterlib/ui/ui_mh.py create mode 100644 mitterlib/ui/ui_pygtk.py create mode 100644 mitterlib/ui/ui_tty.py create mode 100644 mitterlib/ui/utils.py create mode 100644 pixmaps/icon_star_empty.gif create mode 100644 pixmaps/icon_star_full.gif create mode 100644 pixmaps/icon_trash.gif create mode 100644 pixmaps/mitter-new.png create mode 100644 pixmaps/mitter.png create mode 100644 pixmaps/reply.png create mode 100644 pixmaps/unknown.png create mode 100644 setup.py create mode 100644 tests.py create mode 100644 utils/pep8.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b948985 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.swp +*.pyc diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..952f92f --- /dev/null +++ b/AUTHORS @@ -0,0 +1,3 @@ +Julio Biason +Deepak Sarda +Gerald Kaszuba diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 0000000..64a331a --- /dev/null +++ b/MANIFEST @@ -0,0 +1,21 @@ +mitter +mitter.desktop +setup.py +mitterlib/__init__.py +mitterlib/constants.py +mitterlib/threadhttp.py +mitterlib/twitter.py +mitterlib/ui/__init__.py +mitterlib/ui/console_utils.py +mitterlib/ui/notify.py +mitterlib/ui/timesince.py +mitterlib/ui/ui_cmd.py +mitterlib/ui/ui_pygtk.py +mitterlib/ui/ui_tty.py +mitterlib/ui/utils.py +pixmaps/mitter-new.png +pixmaps/mitter.png +pixmaps/unknown.png +AUTHORS +COPYING +THANKS diff --git a/THANKS b/THANKS new file mode 100644 index 0000000..b79f85a --- /dev/null +++ b/THANKS @@ -0,0 +1,14 @@ +Many thanks to: + +- Wayne Richardson, who actually reminded that some people use Mitter and + when I break stuff, it stops working for them and for the Ctrl+Enter to + send messages. +- Santiago Gala, for the locale parsing patch. +- Mike, who posted a comment on my blog with an issue of the TTY interface. +- Faheem Pervez, for the Maemo deb. +- Sugree Phatanapherom, for the constant testing in weird situations. +- Apirak, who came with some interesting interface ideas. +- Kristian Rietveld, who posted a solution to the auto-word-wrap GtkTreeView + (http://lists-archives.org/gtk/06637-tree-view-cell-size-negotiation.html) +- Wiennat for the retweet patch. + diff --git a/check b/check new file mode 100755 index 0000000..c6b1c89 --- /dev/null +++ b/check @@ -0,0 +1,3 @@ +#!/bin/sh +find . -name '*.py' -exec pyflakes {} \; +python utils/pep8.py --filename=*.py --repeat . diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..ef87680 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,75 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d .build/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html web pickle htmlhelp latex changes linkcheck + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " changes to make an overview over all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + +clean: + -rm -rf .build/* + +html: + mkdir -p .build/html .build/doctrees + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) .build/html + @echo + @echo "Build finished. The HTML pages are in .build/html." + +pickle: + mkdir -p .build/pickle .build/doctrees + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) .build/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +web: pickle + +json: + mkdir -p .build/json .build/doctrees + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) .build/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + mkdir -p .build/htmlhelp .build/doctrees + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) .build/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in .build/htmlhelp." + +latex: + mkdir -p .build/latex .build/doctrees + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) .build/latex + @echo + @echo "Build finished; the LaTeX files are in .build/latex." + @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ + "run these through (pdf)latex." + +changes: + mkdir -p .build/changes .build/doctrees + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) .build/changes + @echo + @echo "The overview file is in .build/changes." + +linkcheck: + mkdir -p .build/linkcheck .build/doctrees + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) .build/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in .build/linkcheck/output.txt." diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..fb0ad26 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +# +# Mitter documentation build configuration file, created by +# sphinx-quickstart on Sun Apr 5 18:19:25 2009. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# The contents of this file are pickled, so don't put values in the namespace +# that aren't pickleable (module imports are okay, they're removed automatically). +# +# 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 + +# If your extensions are in another directory, add it here. If the directory +# is relative to the documentation root, use os.path.abspath to make it +# absolute, like shown here. +sources = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +sys.path.append(sources) + +# And now, for something completely ugly: +mitterlib = os.path.join(sources, 'mitterlib') +sys.path.append(mitterlib) + +mitterlib_network = os.path.join(mitterlib, 'network') +sys.path.append(mitterlib_network) + +# General configuration +# --------------------- + +# 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.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.intersphinx'] + +# 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' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Mitter' +copyright = u'2009, Julio Biason' + +# 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. +version = '1.0' +# The full version, including alpha/beta/rc tags. +release = '1.0' + +# 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 documents that shouldn't be included in the build. +#unused_docs = [] + +# List of directories, relative to source directory, that shouldn't be searched +# for source files. +exclude_trees = ['.build'] + +# 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 = True + +# 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' + + +# Options for HTML output +# ----------------------- + +# The style sheet to use for HTML and HTML Help pages. A file of that name +# must exist either in Sphinx' static/ path, or in one of the custom paths +# given in html_static_path. +html_style = 'default.css' + +# 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 = None + +# 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 = None + +# 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 = True + +# 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_use_modindex = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, the reST sources are included in the HTML build as _sources/. +#html_copy_source = 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 = 'Mitterdoc' + + +# Options for LaTeX output +# ------------------------ + +# The paper size ('letter' or 'a4'). +#latex_paper_size = 'letter' + +# The font size ('10pt', '11pt' or '12pt'). +#latex_font_size = '10pt' + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, document class [howto/manual]). +latex_documents = [ + ('index', 'Mitter.tex', ur'Mitter Documentation', + ur'Julio Biason', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# 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_use_modindex = True + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'http://docs.python.org/dev': None} diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..8b80155 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,21 @@ +.. Mitter documentation master file, created by sphinx-quickstart on Sun Apr 5 18:19:25 2009. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to Mitter's documentation! +================================== + +Contents: + +.. toctree:: + :maxdepth: 2 + + networkbase + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/docs/networkbase.rst b/docs/networkbase.rst new file mode 100644 index 0000000..1a19be5 --- /dev/null +++ b/docs/networkbase.rst @@ -0,0 +1,15 @@ +:mod:`networkbase` -- Base classes for all networks +=================================================== + +.. automodule:: networkbase + +NetworkData -- Unified information about messages +------------------------------------------------- + +.. autoclass:: NetworkData + +NetworkBase -- Base class for all networks +------------------------------------------ + +.. autoclass:: NetworkBase + :members: diff --git a/mitter b/mitter new file mode 100755 index 0000000..d871291 --- /dev/null +++ b/mitter @@ -0,0 +1,106 @@ +#!/usr/bin/python2.5 +# -*- coding: utf-8 -*- + +# Mitter, a Maemo client for Twitter. +# Copyright (C) 2007, 2008 Julio Biason +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import time +import urllib +import os +import logging +import warnings + +import mitterlib.network as network + +from mitterlib.configopt import ConfigOpt +from mitterlib.network import Networks +from mitterlib import ui + +log = logging.getLogger('mitter') + + +def main(): + """Main function.""" + + # options + + options = ConfigOpt() + options.add_group('General', 'General options') + options.add_option('-d', '--debug', + group='General', + option='debug', + action='store_true', + help='Display debugging information.', + default=False, + is_config_option=False) + options.add_option('-i', '--interface', + group='General', + option='interface', + default=None, + metavar='INTERFACE', + help='Interface to be used.') + + # Ask networks to add their options + + connection = Networks(options) + + # Ask interfaces to add their options + + ui.interface_options(options) + + # Parse the command line options and the config file + + options() + + # start the logging service + + if options['General']['debug']: + logging.basicConfig(level=logging.DEBUG) + else: + logging.basicConfig(level=logging.INFO) + log = logging.getLogger('mitter') + + # disable the warnings. Interfaces may choose to receive the warnings + # changing the filter to "error" + warnings.simplefilter('ignore') + + # select an interface (preferably, the one the user selected in the + # command line) + preferred_interface = options['General']['interface'] + log.debug('Config interface: %s', preferred_interface) + + if 'interface' in options.conflicts: + preferred_interface = options.conflicts['interface'] + log.debug('Command line interface: %s', preferred_interface) + + interface = ui.interface(preferred_interface) + + if interface is None: + log.error('Sorry, no interface could be found for your system') + return + + # now start the twitter connection + log.debug('Starting networks') + connection = Networks(options) + display = interface.Interface(connection, options) + + # display the interface (the interface should take care of updating + # itself) + display() + options.save() + +if __name__ == '__main__': + main() diff --git a/mitter.desktop b/mitter.desktop new file mode 100644 index 0000000..ab49ea9 --- /dev/null +++ b/mitter.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Version=0.4 +Encoding=UTF-8 +Name=Mitter +Comment=A client for twitter.com +Exec=/usr/bin/mitter +Icon=mitter +Type=Application +Categories=Network diff --git a/mitterlib/__init__.py b/mitterlib/__init__.py new file mode 100644 index 0000000..764195e --- /dev/null +++ b/mitterlib/__init__.py @@ -0,0 +1,50 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Mitter, a Maemo client for Twitter. +# Copyright (C) 2007, 2008 Julio Biason, Deepak Sarda +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging + + +def find_image(image_name): + """Using the iamge_name, search in the common places. Return the path for + the image or None if the image couldn't be found.""" + + # just because I'm a logging nut + + log = logging.getLogger('mitterlib.find_image') + + import os.path + import sys + + # the order is the priority, so keep global paths before local paths + + current_dir = os.path.abspath(os.path.dirname(__file__)) + + common_paths = [ + os.path.join(sys.prefix, 'share', 'pixmaps'), + os.path.join('.', 'pixmaps'), + os.path.join(current_dir, '..', 'pixmaps')] + + for path in common_paths: + filename = os.path.join(path, image_name) + log.debug('Checking %s...' % (filename)) + if os.access(filename, os.F_OK): + log.debug('Default image is %s' % (filename)) + return filename + + return None diff --git a/mitterlib/configopt.py b/mitterlib/configopt.py new file mode 100644 index 0000000..2cb3ac8 --- /dev/null +++ b/mitterlib/configopt.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python +# ConfigOpt: An OptParse/ConfigParser object. + +import os.path +import ConfigParser + +from optparse import OptionParser, OptionGroup, Option + +_true_values = ('True', 'true', 'Yes', 'yes') +_false_values = ('False', 'false', 'No', 'no') + + +class ReferenceOption(Option): + + def __init__(self, *args, **kwargs): + if 'group' in kwargs: + self.group = kwargs['group'] + del kwargs['group'] + else: + self.group = None + + if 'option' in kwargs: + self.option = kwargs['option'] + del kwargs['option'] + else: + self.option = None + + if 'conflict_group' in kwargs: + self.conflict_group = kwargs['conflict_group'] + del kwargs['conflict_group'] + else: + self.conflict_group = None + + Option.__init__(self, *args, **kwargs) + + +class ConfigOptOption(object): + """An option.""" + + def __init__(self, *args, **kwargs): + if not 'name' in kwargs: + raise AttributeError('Missing option name') + + self.name = kwargs['name'] + del kwargs['name'] + + if 'is_cmd_option' in kwargs: + self._cmd_option = kwargs['is_cmd_option'] + del kwargs['is_cmd_option'] + else: + self._cmd_option = True # default value + + if 'is_config_option' in kwargs: + self._config_option = kwargs['is_config_option'] + del kwargs['is_config_option'] + else: + self._config_option = True + + if 'default' in kwargs: + self._default = kwargs['default'] + del kwargs['default'] # Remove it so we don't pass it to + # OptionParser -- we have our own defaults + # and, passing it to OptionParser would + # make it always return as set in the + # command line, breaking the behaviour we + # want. + else: + self._default = None + + self._params = kwargs + self._args = args + self.config_value = None + self.cmd_value = None + self._set_value = None + + def _get_value(self): + """Return the value of a variable.""" + # this is the priority order: First, the value set by the application; + # if it's not set, use the command line value; if there is no option + # in the command line, use the config value; if this is also not set, + # return the default value for the variable. + if self._set_value is not None: + return self._set_value + + if self.cmd_value is not None: + return self.cmd_value + + if self.config_value is not None: + return self.config_value + + return self._default + + def _set_value(self, x): + """Set the value of the variable. Used by the application to set a + value for it.""" + self._set_value = x + + value = property(_get_value, _set_value) + + @property + def args(self): + return self._args + + @property + def params(self): + return self._params + + @property + def cmd_option(self): + return self._cmd_option + + @property + def config_option(self): + return self._config_option + + +class ConfigOptGroup(object): + """A group of options.""" + + def __init__(self, name, desc): + self.name = name + self.desc = desc + self.options = {} + return + + def add_option(self, *args, **kwargs): + """Add an option in the group.""" + id = kwargs['name'] + if id in self.options: + return + self.options[id]= ConfigOptOption(*args, **kwargs) + return + + def cmd_parser(self, parser): + """Build the command line parser for the option.""" + group = OptionGroup(parser, self.desc) + options = 0 # to avoid adding an empty group + + for option_id in self.options: + option = self.options[option_id] + if option.cmd_option: + internal_name = self.name + '_' + option.name # to avoid + # clashes + option_params = option.params + option_params['dest'] = internal_name + option_params['group'] = self.name + option_params['option'] = option.name + + if not 'metavar' in option_params: + option_params['metavar'] = option.name.upper() + + group.add_option(*option.args, **option_params) + + options += 1 + + if options > 0: + parser.add_option_group(group) + + return + + def __getitem__(self, key): + return self.options[key].value + + def __setitem__(self, key, value): + self.options[key].value = value + + +class ConfigOpt(object): + """Command line and config file option merger.""" + + def __init__(self, app_name=None): + """Class initialization. is used to create the config file + in the user home directory.""" + + if app_name is None: + import sys + filename = os.path.basename(sys.argv[0]) + (name, _) = os.path.splitext(filename) + app_name = name + + + self._config_name = os.path.expanduser(os.path.join('~', '.' + + app_name + '.ini')) + self._cmd_parser = OptionParser(option_class=ReferenceOption) + + self._cmd_parser.add_option('-c', '--config', + dest='config_file', + help='Configuration file.', + default = self._config_name) + + self._groups = {} + self._conflicts = None + + @property + def conflicts(self): + """Return the dictionary with the groups in the conflict groups.""" + return self._conflicts + + def add_group(self, id, desc=None): + """Create a new group of options.""" + if id in self._groups: + return + self._groups[id] = ConfigOptGroup(id, desc) + return + + def add_option(self, *args, **kwargs): + """Add an option in the list of options.""" + assert 'group' in kwargs + assert 'option' in kwargs + + group_id = kwargs['group'] + del kwargs['group'] + + option_id = kwargs['option'] + del kwargs['option'] + + group = self._groups[group_id] + group.add_option(name=option_id, *args, **kwargs) + + def load(self): + """Load the options for the config file.""" + config = ConfigParser.SafeConfigParser() + config.read(self._config_name) + + for section in config.sections(): + if section not in self._groups: + continue # ignore this group + + for option in config.options(section): + if option not in self._groups[section].options: + continue + + value = config.get(section, option) + # convert int values + if value.isdigit(): + value = int(value) + + # Convert boolean values + if value in _true_values or value in _false_values: + value = (value in _true_values) + + self._groups[section].options[option].config_value = value + return + + def save(self): + """Save the config file.""" + config = ConfigParser.SafeConfigParser() + for group in self._groups: + group_added = False + + for option_id in self._groups[group].options: + option = self._groups[group].options[option_id] + + if not option.config_option: + continue + + if not group_added: + # prevents empty groups + config.add_section(group) + group_added = True + + config.set(group, option.name, str(option.value)) + + config_file = file(self._config_name, 'w') + config.write(config_file) + config_file.close() + return + + def __call__(self): + """Callable object, do the parsing of the command line and loads the + config file required.""" + for group in self._groups: + self._groups[group].cmd_parser(self._cmd_parser) + + (options, args) = self._cmd_parser.parse_args() + self._config_name = options.config_file + self._conflicts = {} + + for group in self._cmd_parser.option_groups: + for option in group.option_list: + group_id = option.group + option_id = option.option + value = getattr(options, option.dest) + conflict_group = option.conflict_group + + if value is None: + continue + + if conflict_group and conflict_group in self._conflicts: + if group_id != self._conflicts[conflict_group]: + self._cmd_parser.error( + "You can't mix options from %s and %s." % ( + self._conflicts[conflict_group], group_id)) + return + + self._conflicts[conflict_group] = group_id + self._groups[group_id].options[option_id].cmd_value = value + + self.load() + + def __getitem__(self, key): + return self._groups[key] diff --git a/mitterlib/constants.py b/mitterlib/constants.py new file mode 100644 index 0000000..f859d44 --- /dev/null +++ b/mitterlib/constants.py @@ -0,0 +1,697 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Mitter, a simple client for Twitter. +# Copyright (C) 2007, 2008 The Mitter Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +version = '1.0.0.alpha1' + +gpl_3 = """ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. +""" diff --git a/mitterlib/network/__init__.py b/mitterlib/network/__init__.py new file mode 100644 index 0000000..b00508c --- /dev/null +++ b/mitterlib/network/__init__.py @@ -0,0 +1,229 @@ +#!/usr/bin/python2.5 +# -*- coding: utf-8 -*- + +# Mitter, a simple client for Twitter +# Copyright (C) 2007, 2008 The Mitter Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os.path +import glob +import logging + +from mitterlib.network.networkbase import NetworkData + +_log = logging.getLogger('mitterlib.network.Networks') + +# List of files that are not networks +SKIPPABLES = ('__init__.py', 'networkbase.py') + +#-------------------------------------------------------------------- +# Helper functions +#-------------------------------------------------------------------- + + +def _import_name(module): + (name, _) = os.path.splitext(module) + return 'mitterlib.network.%s' % (name) + + +def _networks(): + network_dir = os.path.dirname(__file__) + + networks = glob.glob(os.path.join(network_dir, '*.py')) + for network in networks: + network_name = os.path.basename(network) + if network_name in SKIPPABLES: + # not a real network + continue + + module_name = _import_name(network_name) + yield module_name + +#-------------------------------------------------------------------- +# Exceptions +#-------------------------------------------------------------------- + + +class NetworksError(Exception): + """Basic Networks exception.""" + pass + + +class NetworksNoSuchNetworkError(NetworksError): + """The request network does not exists.""" + + def __init__(self, network): + self._network = network + + def __str__(self): + return 'Unknown network %s' % (self._network) + pass + + +class NetworksNoNetworkSetupError(NetworksError): + """There are no networks set up.""" + pass + + +class NetworksErrorLoadingNetwork(NetworksError): + """Error loadig one of the networks.""" + + def __init__(self, network, exception): + self._network = network + self._exception = str(exception) + + def __str__(self): + return "Couldn't load %s (%s)" % (self._network, self._exception) + + +class Networks(object): + """Network transparency layer: Keeps a list of available networks and send + requests to all those who have been setup.""" + + def __init__(self, options): + self._networks = {} + self._options = options + self.options() + + @property + def networks(self): + if self._networks: + return self._networks + + for module_name in _networks(): + try: + module = __import__(module_name, fromlist=[module_name]) + connection = module.Connection(self._options) + self._networks[connection.SHORTCUT] = connection + except ImportError, ex: + raise NetworksErrorLoadingNetwork(module_name, ex) + + return self._networks + + def _targets(self, shortcut): + """Select a network based on the shortcut. If the shortcut is None, + returns all available network shortcuts.""" + if shortcut: + if not shortcut in self.networks: + raise NetworksNoSuchNetworkError(shortcut) + else: + targets = [shortcut] + else: + targets = self.networks + + setup = False + for target in targets: + if self.networks[target].is_setup(): + setup = True + yield target + + if not setup: + raise NetworksNoNetworkSetupError + + def settings(self): + """Return a dictionary with the options that the interfaces need to + setup.""" + result = [] + for shortcut in self.networks: + settings = { + 'shortcut': shortcut, + 'name': self.networks[shortcut].NAMESPACE, + 'options': self.networks[shortcut].AUTH, + } + result.append(settings) + return result + + def options(self): + """Request all networks to add their options.""" + for shortcut in self.networks: + conn = self.networks[shortcut] + conn.options(self._options) + + return + + def name(self, shortcut): + """Return the name of a network based on the shortcut.""" + try: + name = self.networks[shortcut].NAMESPACE + except KeyError: + raise NetworksNoSuchNetworkError(shortcut) + return name + + # This is basically a copy of all methods available in NetworkBase, with + # the additional parameter "network" (to request data from just one + # source) + + def messages(self, network=None): + """Return a list of NetworkData objects for the main "timeline" (the + default list presented to the user.)""" + result = [] + for shortcut in self._targets(network): + for message in self.networks[shortcut].messages(): + message.network = shortcut + result.append(message) + return result + + def update(self, status, reply_to=None, network=None): + """Update the user status. Must return the id for the new status.""" + if reply_to and isinstance(reply_to, NetworkData): + # If you pass a NetworkData object, we get the proper network + network = reply_to.network + + for shortcut in self._targets(network): + self.networks[shortcut].update(status, reply_to) + return None + + def delete_message(self, message, network=None): + """Delete an update. Message can be a NetworkData object, in which + case network is not necessary; otherwise a network must be + provided.""" + if isinstance(message, NetworkData): + network = message.network + + self.networks[network].delete_message(message) + return + + def message(self, message_id, network): + """Return a single NetworkData object for a specified message.""" + if not network in self.networks: + raise NetworksNoSuchNetworkError(network) + + data = self.networks[network].message(message_id) + data.network = network + return data + + def replies(self, network=None): + """Return a list of NetworkData objects for the replies for the user + messages.""" + result = [] + for shortcut in self._targets(network): + for message in self.networks[shortcut].replies(): + message.network = shortcut + result.append(message) + return result + + def inbox(self, network=None): + """Return a list of NetworkData objects for the direct messages/inbox + of the user.""" + return [] + + def available_requests(self, network=None): + """Return a dictionary with the available requests the user can + make to each network before getting capped.""" + result = {} + for shortcut in self._targets(network): + requests = self.networks[shortcut].available_requests() + result[shortcut] = requests + return result diff --git a/mitterlib/network/networkbase.py b/mitterlib/network/networkbase.py new file mode 100644 index 0000000..973ada7 --- /dev/null +++ b/mitterlib/network/networkbase.py @@ -0,0 +1,225 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Mitter, a client for Twitter. +# Copyright (C) 2007, 2008 The Mitter Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +.. moduleauthor:: Julio Biason + +The :mod:`networkbase` module defines the base classes for all networks. +""" + +import logging +import datetime + +from mitterlib.constants import version + +# logging +_log = logging.getLogger('mitterlib.network.Network') + + +def auth_options(namespace, options, auths): + """Convert the auth fields into options for the command line.""" + for option in auths: + options.add_option(group=namespace, + option=option['name'], + default=None, + help=option['help'], + *(option['flags'])) + +#-------------------------------------------------------------------- +# Exceptions +#-------------------------------------------------------------------- + + +class NetworkError(Exception): + """Base class for all network related exceptions.""" + pass + + +class NetworkUnknownError(NetworkError): + """Some non-expected error occurred.""" + pass + + +class NetworkLimitExceededError(NetworkError): + """The number of requests available was exceeded.""" + pass + + +class NetworkDNSError(NetworkError): + """A DNS failure prevented the request to continue.""" + pass + + +class NetworkInvalidResponseError(NetworkError): + """The server returned the information in an unexpected way.""" + pass + + +class NetworkLowLevelError(NetworkError): + """A low level error occurred in the network layer.""" + pass + + +class NetworkBadStatusLineError(NetworkError): + """Bad status line exception.""" + pass + + +class NetworkAuthorizationFailError(NetworkError): + """Authorization failure.""" + pass + + +class NetworkPermissionDeniedError(NetworkError): + """Permission denied when accessing the message/list.""" + pass + +#-------------------------------------------------------------------- +# Warnings +#-------------------------------------------------------------------- +class NetworkWarning(Warning): + """Base warning for networks.""" + pass + +class MessageTooLongWarning(NetworkWarning): + """The message is too long for the network.""" + pass + +#-------------------------------------------------------------------- +# The classes +#-------------------------------------------------------------------- + + +class NetworkData(object): + """Provides an uniform way to access information about posts. The + following fields should appear: + + **id** + The message identification. + + **name** + The name to be displayed as author of the message. + + **username** + The message author username in the network. + + **avatar** + URL to the author avatar. + + **message** + The message. + + **message_time** + Message timestamp (as a datetime object). Defaults to None. + + **parent** + The parent of this message, in case of a reply. + + **network** + The network id source of the message. Network classes don't need to + worry about this field themselves; :class:`Networks` will set it when + merging information from all networks. + """ + def __init__(self): + self.id = '' + self.name = '' + self.username = '' + self.avatar = '' + self.message = '' + self.message_time = None + self.parent = '' + self.network = '' + + +class NetworkBase(object): + """Base class for all networks.""" + _user_agent = 'Mitter %s' % (version) + + # TODO: We'll probably need a ICON attribute in the future. + #: Namespace of the network, used to identify options. + NAMESPACE = 'Meta' + + AUTH = [] + """List of fields the interface must request to the user in order to + retrieve information from the network. It's a list of dictionaries, + containing: + + * *name*: Name of the option, used in ConfigOpt (for the name in the + config file and to access it through the options variable); + + * *flags*: The list of command line options for this option (as in + OptParse); + + * *prompt*: The prompt to be used by interfaces when requesting the + variable; + + * *help*: Description for the value; it's used by ConfigOpt to show the + description of the paramater in the command line options and can be used + by interfaces to show tooltips about the field; + + * *type*: The type of the option; valid values are: + + * 'str': A string; + + * 'passwd': Password; string, but interfaces should hide the information + if possible. + """ + + + def is_setup(self): + """Should return a boolean indicating if the network have all + necessary options set up so it can retrieve information. + :class:`Networks` won't send requests to networks that return False to + this function.""" + return False + + def messages(self): + """Return a list of :class:`NetworkData` objects for the main + "timeline" (the default list presented to the user.)""" + return [] + + def update(self, status, reply_to=None): + """Update the user status. *status* should be the string with the + status update; *reply_to* should be used in case the message is a + reply to another message, it could be a simple id or the + :class:`NetworkData` object of the original data. Must return the id + for the new status.""" + return None + + def delete_message(self, message): + """Delete an update. Must return True if the message was deleted or + False if not. *message* can be either an id or a :class:`NetworkData` + object with the information about the message to be deleted.""" + return False + + def message(self, message_id): + """Return a single :class:`NetworkData` object for a specified + message.""" + return None + + def replies(self): + """Return a list of :class:`NetworkData` objects for the replies for + the user messages.""" + return [] + + def available_requests(self): + """Return the number of requests the user can request before being + capped. If such limitation doesn't exist for the network, a negative + number should be returned.""" + return -1 diff --git a/mitterlib/network/twitter.py b/mitterlib/network/twitter.py new file mode 100644 index 0000000..635aaea --- /dev/null +++ b/mitterlib/network/twitter.py @@ -0,0 +1,414 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Mitter, a client for Twitter. +# Copyright (C) 2007, 2008 The Mitter Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import urllib +import urllib2 +import logging +import datetime +import base64 +import htmlentitydefs +import re + +from httplib import BadStatusLine +from socket import error as socketError + +from networkbase import NetworkBase, NetworkData, auth_options, \ + NetworkDNSError, NetworkBadStatusLineError, NetworkLowLevelError, \ + NetworkInvalidResponseError, NetworkPermissionDeniedError + +try: + # Python 2.6/3.0 JSON parser + import json +except ImportError: + # Fallback to SimpleJSON + import simplejson as json + +# logging +_log = logging.getLogger('mitterlib.network.Twitter') + +# the month names come directly from the site, so we are not affected by +# locale settings. +_month_names = [None, 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', + 'Sep', 'Oct', 'Nov', 'Dec'] + + +def _unhtml(text): + """Convert text coming in HTML encoded to UTF-8 representations.""" + new_text = [] + copy_pos = 0 + _log.debug('Original text: %s', text) + for code in re.finditer(r'&(\w+);', text): + new_text.append(text[copy_pos:code.start()]) + entity = text[code.start()+1:code.end()-1] + if entity in htmlentitydefs.name2codepoint: + new_text.append(unichr( + htmlentitydefs.name2codepoint[entity])) + else: + new_text.append(code.group().decode('utf8')) + copy_pos = code.end() + + new_text.append(text[copy_pos:]) + + _log.debug('New text: %s', new_text) + result = u''.join(new_text) + _log.debug('Result: %s', result) + return result + + +def _htmlize(text): + """Convert accented characters to their HTML entities.""" + new = [] + # XXX: This is not very effective, but Twitter only accepts 140 chars, + # so it won't be a big pain. + for char in text: + if ord(char) in htmlentitydefs.codepoint2name: + new.append('&%s;' % (htmlentitydefs.codepoint2name[ord(char)])) + else: + new.append(char) + return ''.join(new) + + +def _to_datetime(server_str): + """Convert a date send by the server to a datetime object. + Ex: + from this: + Tue Mar 13 00:12:41 +0000 2007 + to datetime. + """ + date_info = server_str.split(' ') + month = _month_names.index(date_info[1]) + day = int(date_info[2]) + year = int(date_info[5]) + + time_info = date_info[3].split(':') + hour = int(time_info[0]) + minute = int(time_info[1]) + second = int(time_info[2]) + + return datetime.datetime(year, month, day, hour, minute, second) + + +def _make_datetime(response): + """Converts dates on responses to datetime objects.""" + result = [] + for tweet in response: + result.append(TwitterNetworkData(tweet)) + + return result + + +class TwitterNetworkData(NetworkData): + """A simple wrapper around NetworkData, to make things easier to convert + twitter data into a NetworkData object.""" + + def __init__(self, data): + """Class initialization. Receives a dictionary with a single tweet.""" + NetworkData.__init__(self) + + self.id = data['id'] + self.name = data['user']['name'] + self.username = data['user']['screen_name'] + self.avatar = data['user']['profile_image_url'] + self.message_time = _to_datetime(data['created_at']) + + if 'in_reply_to_status_id' in data and data['in_reply_to_status_id']: + self.parent = int(data['in_reply_to_status_id']) + + # Twitter encodes a lot of HTML entities, which are not good when + # you want to *display* then (e.g., "<" returns to us as "<"). + # So we convert this here. + self.message = _unhtml(data['text']) + + return + + +class Connection(NetworkBase): + """Base class to talk to twitter.""" + + NAMESPACE = 'Twitter' + SHORTCUT = 'tw' # TODO: find a way to move this to the config file + + def is_setup(self): + """Return True or False if the network is setup/enabled.""" + if (self._options[self.NAMESPACE]['username'] and + self._options[self.NAMESPACE]['password']): + # Consider the network enabled if there is an username and + # password + return True + else: + return False + + def __init__(self, options): + self._options = options + + @property + def server(self): + if self._options[self.NAMESPACE]['https']: + return self._options[self.NAMESPACE]['secure_server_url'] + else: + return self._options[self.NAMESPACE]['server_url'] + + def _common_headers(self): + """Returns a string with the normal headers we should add on every + request""" + + auth = base64.b64encode('%s:%s' % ( + self._options[self.NAMESPACE]['username'], + self._options[self.NAMESPACE]['password'])) + + headers = { + 'Authorization': 'Basic %s' % (auth), + 'User-Agent': self._user_agent} + return headers + + def _request(self, resource, headers=None, body=None): + """Send a request to the Twitter server. Once finished, call the + function at callback.""" + + url = '%s%s' % (self.server, resource) + _log.debug('Request %s' % (url)) + + request = urllib2.Request(url=url) + request_headers = self._common_headers() + if headers: + request_headers.update(headers) + + for key in request_headers: + _log.debug('Header: %s=%s' % (key, request_headers[key])) + request.add_header(key, request_headers[key]) + + if body: + _log.debug('Body: %s' % (body)) + request.add_data(body) + + try: + _log.debug('Starting request of %s' % (url)) + response = urllib2.urlopen(request) + data = response.read() + except urllib2.HTTPError, exc: + _log.debug('HTTPError: %d' % (exc.code)) + _log.debug('HTTPError: response body:\n%s' % exc.read()) + # To me, I got a lot of 502 for "replies". It shows the + # "Something is technically wrong" most of the time in the real + # pages. + if exc.code == 403: + # Permission denied. + raise NetworkPermissionDeniedError + raise NetworkInvalidResponseError + except urllib2.URLError, exc: + _log.error('URL error: %s' % exc.reason) + raise NetworkDNSError + except BadStatusLine: + _log.error('Bad status line (Twitter is going bananas)') + raise NetworkBadStatusLineError + except socketError: # That's the worst exception ever. + _log.error('Socket connection error') + raise NetworkLowLevelError + # TODO: Permission denied? + + # Introduced in Twitter in 2009.03.27 + response_headers = response.info() + if 'X-RateLimit-Remaining' in response_headers: + self._rate_limit = int(response_headers['X-RateLimit-Remaining']) + _log.debug('Remaning hits: %d', self._rate_limit) + elif 'x-ratelimit-remaining' in response_headers: + self._rate_limit = int(response_headers['x-ratelimit-remaining']) + _log.debug('Remaning hits: %d', self._rate_limit) + else: + self._rate_limit = None + + _log.debug('Request completed') + _log.debug('info(%s): %s', type(response.info()), response.info()) + + return json.loads(data) + + # + # New network style methods + # + + AUTH = [ + {'name': 'username', + 'flags': ['-u', '--username'], + 'prompt': 'Username', + 'help': 'Your twitter username', + 'type': 'str'}, + {'name': 'password', + 'flags': ['-p', '--password'], + 'prompt': 'Password', + 'help': 'Your twitter password', + 'type': 'passwd'}] + + @classmethod + def options(self, options): + """Add options related to Twitter.""" + options.add_group(self.NAMESPACE, 'Twitter network') + options.add_option('-s', '--no-https', + group=self.NAMESPACE, + option='https', + default=True, # Secure connections by default + help='Disable HTTPS (secure) connection with Twitter.', + action='store_false') + options.add_option( + group=self.NAMESPACE, + option='last_tweet', + default=0, + is_cmd_option=False) + options.add_option( + group=self.NAMESPACE, + option='last_reply', + default=0, + is_cmd_option=False) + options.add_option( + group=self.NAMESPACE, + option='server_url', + default='http://twitter.com', + is_cmd_option=False) + options.add_option( + group=self.NAMESPACE, + option='secure_server_url', + default='https://twitter.com', + is_cmd_option=False) + auth_options(self.NAMESPACE, options, self.AUTH) + return + + def _timeline(self, config_var, url): + """Request one of the lists of tweets.""" + last_id = int(self._options[self.NAMESPACE][config_var]) + _log.debug('%s: %d', config_var, last_id) + + params = {} + + if last_id > 0: + params['since_id'] = last_id + + page = 1 + result = [] + response = [0] # So we stay in the loop. + high_id = 0 + + while response: # Not the cleanest code + if page > 1: + params['page'] = page + + final_url = '?'.join([url, urllib.urlencode(params)]) + response = self._request(final_url) + + _log.debug('Page %d, %d results', page, len(response)) + + if response: + # extract the highest id in the respone and save it so we can + # use it when requesting data again (using the since_id + # parameter) + + top_tweet_id = response[0]['id'] + _log.debug('Top tweet: %d; Highest seen tweet: %d', + top_tweet_id, high_id) + + if top_tweet_id > high_id: + high_id = top_tweet_id + + result.extend(_make_datetime(response)) + page += 1 # Request the next page + + if last_id == 0: + # do not try to download everything if we don't have a + # previous list (or we'll blow the available requests in one + # short) + break + + # only update the "last seen id" if everything goes alright + if high_id > int(self._options[self.NAMESPACE][config_var]): + _log.debug('Last tweet updated: %d', high_id) + self._options[self.NAMESPACE][config_var] = high_id + + return result + + def messages(self): + """Return a list of NetworkData objects for the main "timeline".""" + return self._timeline('last_tweet', '/statuses/friends_timeline.json') + + def message(self, message_id): + """Retrieves the information of one message.""" + response = self._request('/statuses/show/%d.json' % (message_id)) + return TwitterNetworkData(response) + + def replies(self): + """Return a list of NetworkData objects for the replies for the user + messages.""" + return self._timeline('last_reply', '/statuses/replies.json') + + def available_requests(self): + """Return the current user rate limit.""" + if self._rate_limit: + return self._rate_limit + + data = self._request('/account/rate_limit_status.json') + _log.debug('Requests: %s', data) + return int(data['remaining_hits']) + + + def update(self, status, reply_to=None): + """Update the user status.""" + if len(status) > 140: + warnings.warn('Message too long', MessageTooLongWarning) + + # In Python 2.5, urllib.urlencode calls str(), which removes the + # unicodeness of the "status". So we need to convert those peski + # accents to HTML entities, so everything falls into ASCII. + + body = { + 'status': _htmlize(status), + 'source': 'mitter'} + + if reply_to: + if isinstance(reply_to, NetworkData): + body['in_reply_to_status_id'] = reply_to.id + + # This is to protect the user from himself. You don't *need* + # to start a reply with a @, but it looks really + # confusing in the Twiter website. So if the line doesn't + # start with the username of the original user, we add it + # for the user. + + if not status.startswith('@' + reply_to.username): + body['status'] = '@' + reply_to.username + ' ' + \ + status + else: + body['in_reply_to_status_id'] = reply_to + + _log.debug('Body: %s', body) + body = urllib.urlencode(body) + _log.debug('Message to twitter: %s' % (body)) + + data = self._request('/statuses/update.json', body=body) + # TODO: Check if twitter sends an error message when the message is + # too large. + return TwitterNetworkData(data) + + def delete_message(self, message): + """Delete a message.""" + if isinstance(message, NetworkData): + message = message.id # We don't need anything else for Twitter + + # make a body, so _request makes it a post. + body = urllib.urlencode({'id': message}) + resource = '/statuses/destroy/%s.json' % (message) + response = self._request(resource, body=body) + _log.debug('Delete response: %s', response) + return response diff --git a/mitterlib/ui/__init__.py b/mitterlib/ui/__init__.py new file mode 100644 index 0000000..1f7da26 --- /dev/null +++ b/mitterlib/ui/__init__.py @@ -0,0 +1,81 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Mitter, a Maemo client for Twitter. +# Copyright (C) 2007, 2008 Julio Biason +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging + +_log = logging.getLogger('ui.__init__') + +interfaces = [ + 'pygtk', + 'cmd', + 'mh', + 'tty'] + + +def _import_name(interface): + """Return the name of the module for that interface.""" + return 'mitterlib.ui.ui_%s' % (interface) + + +def _interface_list(prefer=None): + """Return a list of UI modules.""" + if prefer: + if prefer in interfaces: + yield _import_name(prefer) + + for interface in interfaces: + module_name = _import_name(interface) + _log.debug('Module %s' % (module_name)) + yield module_name + + +def interface(prefer): + """Try to find an interface that works in the current user system.""" + _log.debug('Preferred interface: %s' % (prefer)) + interface = None + for module_name in _interface_list(prefer): + # try to import each using __import__ + try: + _log.debug('Trying to import %s' % (module_name)) + interface = __import__(module_name, fromlist=[module_name]) + break + except ImportError, exc: + _log.debug('Failed') + _log.debug(str(exc)) + pass + + return interface + + +def interface_options(options): + """Add options in the command line OptParser object for every + interface (yes, every interface, even the ones the user doesn't care).""" + + available_interfaces = [] + for module in _interface_list(): + try: + _log.debug('Importing %s for options' % (module)) + interface = __import__(module, fromlist=[module]) + + interface.options(options) + available_interfaces.append(module.split('_')[-1]) + except ImportError: + pass # so we don't care + + return diff --git a/mitterlib/ui/console_utils.py b/mitterlib/ui/console_utils.py new file mode 100644 index 0000000..5e00227 --- /dev/null +++ b/mitterlib/ui/console_utils.py @@ -0,0 +1,145 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Mitter, a Maemo client for Twitter. +# Copyright (C) 2007, 2008 Julio Biason, Deepak Sarda +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import textwrap +import locale +import getpass + +from mitterlib.network.networkbase import NetworkData + +from timesince import timesince + + +def encode_print(text): + """Try to print the text; if we get any UnicodeEncodeError, we print it + without encoding.""" + try: + print text + except UnicodeEncodeError: + encoding = locale.getdefaultlocale()[1] + if not encoding: + encoding = 'ascii' + print text.encode(encoding, 'replace') + return + + +def print_messages(data, connection, show_numbers=False, indent=0): + """Print the list of messages.""" + count = 0 + # the wrapping thing + indent_text = ' ' * (indent * 3) + wrapper = textwrap.TextWrapper() + wrapper.initial_indent = indent_text + wrapper.subsequent_indent = indent_text + + if isinstance(data, NetworkData): + # If it's a single message, just print it + _display_message(wrapper, data, connection, show_numbers, count, + indent_text) + return + elif isinstance(data, str): + # If it just text, just print it. + _display_text(wrapper, data) + return + + # Twitter sends us the data from the newest to latest, which is not + # good for displaying on a console. So we reverse the list. + # TODO: Check if this is true for ALL networks. Otherwise, we'll have + # problems. + data.reverse() + + print + for message in data: + count += 1 + + if isinstance(message, str): + _display_text(wrapper, message) + else: + _display_message(wrapper, message, connection, + show_numbers, count, indent_text) + return + + +def _display_message(wrapper, message, connection, show_numbers, + count, indent_text): + """Print a single message (NetworkData).""" + display = [] + if show_numbers: + display.append('%d.' % (count)) + + if message.username != message.name: + display.append('%s (%s):' % (message.username, message.name)) + else: + display.append('%s:' % (message.username)) + display.append(message.message) + + msg = ' '.join(display) + for line in wrapper.wrap(msg): + encode_print(line) + + footer = '(%s ago, in %s [%s])' % (timesince(message.message_time), + connection.name(message.network), message.network) + for line in wrapper.wrap(footer): + encode_print(line) + + print + print + + +def _display_text(wrapper, text): + """Print a formated text.""" + text = wrapper.wrap(text) + for line in text: + encode_print(line) + return + + +def authorization(options, config): + for network in options: + namespace = network['name'] + + network_title = '%s credentials:' % (namespace) + print network_title + print '-' * len(network_title) + + for option in network['options']: + name = option['name'] + try: + old_value = config[namespace][name] + except KeyError: + # not setup yet (so it's not in the config -- it would + # probably never happen, since we are adding all options + # in the config right from start, but better safe than sorry) + old_value = None + + if old_value: + old_value_prompt = ' (%s)' % (old_value) + else: + old_value_prompt = '' + + prompt = '%s%s: ' % (option['prompt'], old_value_prompt) + + if option['type'] == 'str': + value = raw_input(prompt) + elif option['type'] == 'passwd': + value = getpass.getpass(prompt) + + if value: + config[namespace][name] = value + print diff --git a/mitterlib/ui/notify.py b/mitterlib/ui/notify.py new file mode 100644 index 0000000..08b198b --- /dev/null +++ b/mitterlib/ui/notify.py @@ -0,0 +1,84 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Mitter, a Maemo client for Twitter. +# Copyright (C) 2007 Julio Biason, Deepak Sarda +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import subprocess +import logging +import re + + +class Notify(): + + def __init__(self, appname, timeout=5): + """appname: a string value specifying the name of the application + sending the message. + timeout: an integer value in seconds specifying the time for which + a notification should be shown""" + + self.log = logging.getLogger('notify') + self.appname = appname + self.timeout = timeout + self.notify = None + + try: + import dbus + bus = dbus.SessionBus() + proxy = bus.get_object('org.freedesktop.Notifications', + '/org/freedesktop/Notifications') + self._dbus_notify = dbus.Interface(proxy, + 'org.freedesktop.Notifications') + self.notify = self._notify_galago + self.log.debug('Using Galago notifications') + except: + self.log.debug('Could not initialize Galago notification ' \ + 'interface') + + if not self.notify and os.getenv('KDE_FULL_SESSION'): + self.notify = self._notify_kde + self.log.debug('Using KDE notifications') + + if not self.notify: + self.notify = self._notify_default + self.log.debug('Using default notifications') + + def _notify_kde(self, msg, x, y): + try: + pid = subprocess.Popen(['kdialog', '--nograb', '--title', + self.appname, + '--geometry', '10x5+%d+%d' % (x, y), + '--passivepopup', str(msg), str(self.timeout)]).pid + except Exception, e: + self.log.error('error %s' % e) + self._notify_default(msg, x, y) + finally: + del pid + + def _notify_galago(self, msg, x, y): + msg = re.sub(r'', '', str(msg)) + msg = re.sub(r'&(?!amp;)', r'&', msg) + + try: + self._dbus_notify.Notify(self.appname, 0, '', self.appname, msg, + [], {'x': x, 'y': y}, 1000*self.timeout) + except Exception, e: + self.log.error('error %s' % e) + self._notify_default(msg, x, y) + + def _notify_default(self, msg, x, y): + self.log.info('notification: %s' % msg) diff --git a/mitterlib/ui/timesince.py b/mitterlib/ui/timesince.py new file mode 100644 index 0000000..7083be2 --- /dev/null +++ b/mitterlib/ui/timesince.py @@ -0,0 +1,90 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Mitter, a Maemo client for Twitter. +# Copyright (C) 2007, 2008 Julio Biason +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime +import math +import time + + +# Adapted from +# http://code.djangoproject.com/browser/django/trunk/django/utils/timesince.py +# My version expects time to be given in UTC & returns timedelta from UTC. + + +def pluralize(singular, plural, count): + if count == 1: + return singular + else: + return plural + + +def timesince(d, now=None): + """ + Takes two datetime objects and returns the time between then and now + as a nicely formatted string, e.g "10 minutes" + Adapted from http://blog.natbat.co.uk/archive/2003/Jun/14/time_since + """ + chunks = ( + (60 * 60 * 24 * 365, lambda n: pluralize('year', 'years', n)), + (60 * 60 * 24 * 30, lambda n: pluralize('month', 'months', n)), + (60 * 60 * 24 * 7, lambda n: pluralize('week', 'weeks', n)), + (60 * 60 * 24, lambda n: pluralize('day', 'days', n)), + (60 * 60, lambda n: pluralize('hour', 'hours', n)), + (60, lambda n: pluralize('minute', 'minutes', n))) + # Convert datetime.date to datetime.datetime for comparison + if d.__class__ is not datetime.datetime: + d = datetime.datetime(d.year, d.month, d.day) + if now: + t = now.timetuple() + else: + t = time.gmtime() + now = datetime.datetime(t[0], t[1], t[2], t[3], t[4], t[5]) + + # ignore microsecond part of 'd' since we removed it from 'now' + delta = now - (d - datetime.timedelta(0, 0, d.microsecond)) + since = delta.days * 24 * 60 * 60 + delta.seconds + if since <= 0: + return 'moments' + + for i, (seconds, name) in enumerate(chunks): + count = since / seconds + if count != 0: + break + + if count < 0: + return '%d milliseconds' % math.floor((now - d).microseconds / 1000) + + s = '%d %s' % (count, name(count)) + if i + 1 < len(chunks): + # Now get the second item + seconds2, name2 = chunks[i + 1] + count2 = (since - (seconds * count)) / seconds2 + if count2 != 0: + s += ', %d %s' % (count2, name2(count2)) + return s + + +def timeuntil(d, now=None): + """ + Like timesince, but returns a string measuring the time until + the given time. + """ + if now == None: + now = datetime.datetime.now() + return timesince(now, d) diff --git a/mitterlib/ui/ui_cmd.py b/mitterlib/ui/ui_cmd.py new file mode 100644 index 0000000..336ee25 --- /dev/null +++ b/mitterlib/ui/ui_cmd.py @@ -0,0 +1,326 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Mitter, micro-blogging client +# Copyright (C) 2007, 2008 the Mitter contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging +import cmd +import mitterlib.ui.console_utils as console_utils +import mitterlib.constants +import datetime +import warnings + +from mitterlib.network import NetworksNoNetworkSetupError, NetworksError +from mitterlib.network.networkbase import NetworkError, \ + NetworkPermissionDeniedError + +namespace = 'cmd' # TODO: rename this var to NAMESPACE (check other files too) +_log = logging.getLogger('ui.cmd') + + +def options(options): + # no options for this interface + return + + +class Interface(cmd.Cmd): + """The command line interface for Mitter.""" + + # ----------------------------------------------------------------------- + # Methods required by cmd.Cmd (our commands) + # ----------------------------------------------------------------------- + + def do_config(self, line=None): + """Setup the networks.""" + options = self._connection.settings() + console_utils.authorization(options, self._options) + return + + def do_timeline(self, line): + """Return a list of new messages in your friends timeline.""" + try: + self._show_messages(self._connection.messages()) + except NetworksNoNetworkSetupError: + # call the config + self.do_config() + except NetworkError: + print 'Network failure. Try again in a few minutes.' + return + + def do_replies(self, line): + """Get a list of replies to you.""" + try: + self._show_messages(self._connection.replies(), is_timeline=False) + except NetworksNoNetworkSetupError: + self.do_config() + except NetworkError: + print 'Network failure. Try again in a few minutes.' + return + + def do_update(self, line): + """Update your status.""" + if self._update(line): + print 'Status updated' + else: + print 'Failed to update your status. Try again in a few minutes.' + + def do_exit(self, line): + """Quit the application.""" + _log.debug('Exiting application') + return True + + def do_EOF(self, line): + """Quit the application (it's the same as "exit"). You can also use + Ctrl+D.""" + print # Cmd doesn't add an empty line after the ^D + return self.do_exit(None) + + def do_rt(self, line): + """"Retweet" a message in your list.""" + pos = int(line) + if not self._check_message(pos): + return + + original_message = self._messages[pos-1] + if not original_message.message.lower().startswith('rt @'): + new_message = 'RT @%s: %s' % (original_message.username, + original_message.message) + else: + # if it is a retweet already, keep the original information + new_message = original_message.message + return self.do_update(new_message) + + def do_r(self, line): + """Same as "reply".""" + return self.do_reply(line) + + def do_reply(self, line): + """Reply to a message. Use "reply"/"r" .""" + line_split = line.split() + pos = int(line_split[0]) # message (cmd strips the + # command already) + if not self._check_message(pos): + return + + message = self._messages[pos - 1] + if self._update(' '.join(line_split[1:]), reply_to=message): + print 'Reply sent.' + else: + print "Couldn't send your reply. Try again in a few minutes." + + return + + def do_delete(self, line): + """Delete a message. You must provide the number of the displayed\ + message.""" + message_id = int(line) + real_message_id = self._messages[message_id - 1] + try: + self._connection.delete_message(real_message_id) + except NetworkPermissionDeniedError: + print 'Permission denied.' + return + + print 'Message deleted.' + return + + def do_thread(self, line): + """Retrieves the thread about a single message (like a reply.) Be + aware that this may consume a lot of your hourly requests if the + thread is too long.""" + message_id = int(line) + _log.debug('Message in pos %d', message_id) + if not self._check_message(message_id): + return + + message = self._messages[message_id - 1] + thread = [message] + self._thread(thread, message.parent, message.network) + return + + def emptyline(self): + """Called when the user doesn't call any command. Default is to repeat + the last command; we are going to call timeline() again.""" + return self.do_timeline(None) + + def default(self, line): + """Called when we receive an unknown command; default is error + message, we are going to call update() instead.""" + return self.do_update(line) + + # ----------------------------------------------------------------------- + # Helper functions + # ----------------------------------------------------------------------- + + def _check_message(self, message_id): + """Check if a message is valid in the current list.""" + if message_id < 1 or message_id > len(self._messages): + print + print 'No such message.' + print + return False + return True + + def _show_messages(self, data, is_timeline=True): + """Function called after we receive the list of messages.""" + + if is_timeline: + self._last_update = datetime.datetime.now() + + self._messages = data + console_utils.print_messages(data, self._connection, + show_numbers=True) + self._update_prompt() + return + + def _post_delete(self, data, error): + """Function called after we delete a message.""" + if error: + if error == 403: + # Ok, we are *assuming* that, if you get a Forbidden + # error, it means it's not your message. + print "You can't delete this message." + # TODO: we are using Logging.Error in the Twitter + # object when we get this error. So the user will + # see connection errors instead of this simple + # message. + else: + print 'Error deleting message.' + else: + print 'Message deleted.' + self._update_prompt() + return + + def _thread(self, thread_list, message_id, network): + """Build a conversation thread.""" + _log.debug('Requesting message %s.%s' % (message_id, network)) + try: + message = self._connection.message(message_id, network) + except NetworkError, exc: + _log.debug('Network error:') + _log.debug(exc) + thread_list.insert(0, 'Network error') + self._print_thread(thread_list) + return + # TODO: Catch a permission denied exception and add a proper message + # for it. + + thread_list.insert(0, message) + if message.parent: + self._thread(thread_list, message.parent, network) + else: + self._print_thread(thread_list) + return + + def _print_thread(self, thread_list): + """Print the conversation thread.""" + pos = 0 + _log.debug('%d messages in thread', len(thread_list)) + for message in thread_list: + console_utils.print_messages(message, self._connection, + show_numbers=False, indent=pos) + pos += 1 + return + + def _update(self, status, reply_to=None): + """Send the update to the server.""" + try: + self._connection.update(status, reply_to=reply_to) + except (NetworksError, NetworkError): + # TODO: capture the proper exception. + # TODO: Also, NetworkError's should never get here. Networks + # should catch that (leaving the status kinda messed.) + return False + except MessageTooLongWarning: + print 'Your message is too long. Update NOT send.' + return False + + self._update_prompt() + return True + + def _update_prompt(self): + """Update the command line prompt.""" + # check the requests limits for every network + requests = self._connection.available_requests() + available = [] + for network in requests: + if requests[network] >= 0: + # just show information for networks that count that + available.append('%s (%s): %d' % ( + self._connection.name(network), + network, + requests[network])) + + if self._last_update: + update_text = self._last_update.strftime('%H:%M') + else: + update_text = 'Never' + self.prompt = ('Last update: %s [%s]\nMitter> ' % + (update_text, ', '.join(available))) + return + + + # ----------------------------------------------------------------------- + # Methods required by the main Mitter code + # ----------------------------------------------------------------------- + + def __init__(self, connection, options): + """Class initialization.""" + + cmd.Cmd.__init__(self) + self._options = options + self._last_update = None + self._connection = connection + self._messages = [] + + intro = ['Welcome to Mitter %s.' % (mitterlib.constants.version), + '', + 'To get a list of available commands, type "help".', + '', + "If you start a line with something that it's not a command, " \ + 'it will be considered ' \ + "a status update (so you don't need to type any commands to " \ + 'just update your status.', + '', + 'An empty line will retrieve the latest updates from your ' \ + 'friends.', + '', + ''] + + import textwrap + wrapper = textwrap.TextWrapper() + + intros = [] + + for line in intro: + if not line: + intros.append('') # textwrap doesn't like empty lines + else: + for reident in wrapper.wrap(line): + intros.append(reident) + + self.intro = '\n'.join(intros) + self.prompt = 'Mitter> ' + + return + + def __call__(self): + """Make the object callable; that's the only requirement for + Mitter.""" + warnings.simplefilter('error') # Warnings are exceptions + self.cmdloop() + return diff --git a/mitterlib/ui/ui_mh.py b/mitterlib/ui/ui_mh.py new file mode 100644 index 0000000..addae91 --- /dev/null +++ b/mitterlib/ui/ui_mh.py @@ -0,0 +1,163 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Mitter, micro-blogging client +# Copyright (C) 2007, 2008 the Mitter contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging +import cmd +import mitterlib.ui.console_utils as console_utils +import mitterlib.constants +import datetime +import warnings + +from mitterlib.network import NetworksNoNetworkSetupError, NetworksError +from mitterlib.network.networkbase import NetworkError, \ + NetworkPermissionDeniedError + +namespace = 'cmd' # TODO: rename this var to NAMESPACE (check other files too) +_log = logging.getLogger('ui.cmd') + + +def options(options): + # no options for this interface + return + + +class Interface(cmd.Cmd): + """A MH-like interface to Mitter.""" + + # ----------------------------------------------------------------------- + # Commands + # ----------------------------------------------------------------------- + + def do_config(self, line=None): + """Setup the networks.""" + options = self._connection.settings() + console_utils.authorization(options, self._options) + return + + def do_fetch(self, line=None): + """Retrieve the list of latest messages.""" + try: + data = self._connection.messages() + except NetworksNoNetworkSetupError: + # call the config + self.do_config() + except NetworkError: + print 'Network failure. Try again in a few minutes.' + + data.reverse() + self._messages.extend(data) + print '%d new messages, %d total messages now' % (len(data), + len(self._messages)) + + def do_next(self, line=None): + """Get the next message in the top of the list. The message is marked + as read and removed from the list.""" + + try: + self._current_message = self._messages.pop(0) + except IndexError: + print 'There are no unread messages.' + return + + console_utils.print_messages(self._current_message, self._connection) + return + + def do_print(self, line=None): + """Print the message in the current pointer.""" + if self._current_message is None: + print 'There is no current message.' + return + console_utils.print_messages(self._current_message, self._connection) + + def do_list(self, line=None): + """Print a summary of the messages in the list.""" + for message in self._messages: + long_line = '%s: %s' % (message.username, message.message) + + if len(long_line) > 75: + last_space = long_line.rfind(' ', 0, 76) + long_line = long_line[:last_space] + '...' + + print long_line + return + + def do_reply(self, line): + """Make a reply to the current message.""" + if self._current_message is None: + print 'There is no current message.' + return + + if self._update(line, reply_to=self._current_message): + print 'Reply send.' + self.lastcmd = None + + def do_update(self, line): + """Update your status.""" + if self._update(line): + print 'Status updated' + self.lastcmd = None + # So we don't send the same message if the user pressed enter + # again + + def do_exit(self, line): + """Quit the application.""" + _log.debug('Exiting application') + return True + + # ----------------------------------------------------------------------- + # Helper functions + # ----------------------------------------------------------------------- + + def _update(self, status, reply_to=None): + """Send the update to the server.""" + try: + self._connection.update(status, reply_to=reply_to) + except (NetworksError, NetworkError): + # TODO: capture the proper exception. + # TODO: Also, NetworkError's should never get here. Networks + # should catch that (leaving the status kinda messed.) + print 'Network error. Try again in a few minutes.' + return False + except MessageTooLongWarning: + print 'Your message is too long. Update NOT send.' + return False + return True + + # ----------------------------------------------------------------------- + # Methods required by the main Mitter code + # ----------------------------------------------------------------------- + + def __init__(self, connection, options): + """Class initialization.""" + + cmd.Cmd.__init__(self) + self._options = options + self._last_update = None + self._connection = connection + self._messages = [] + self._current_message = None + + self.prompt = '> ' + return + + def __call__(self): + """Start the interface.""" + warnings.simplefilter('error') # Warnings are exceptions + self.cmdloop() + return diff --git a/mitterlib/ui/ui_pygtk.py b/mitterlib/ui/ui_pygtk.py new file mode 100644 index 0000000..6baa2b0 --- /dev/null +++ b/mitterlib/ui/ui_pygtk.py @@ -0,0 +1,1448 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Mitter, a Maemo client for Twitter. +# Copyright (C) 2007, 2008 Julio Biason, Deepak Sarda +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pygtk +pygtk.require('2.0') +import gtk +import gobject + +gobject.threads_init() +gtk.gdk.threads_init() + +import datetime +import re +import timesince +import logging + +import mitterlib as util + +from notify import Notify +from mitterlib.constants import gpl_3, version +from mitterlib.ui.utils import str_len + +NAMESPACE = 'pygtk' + + +def options(options): + """Add the options for this interface.""" + options.add_group(NAMESPACE, 'GTK+ Interface') + options.add_option('--refresh-interval', + group=NAMESPACE, + option='refresh_interval', + help='Refresh interval', + type='int', + metavar='MINUTES', + default=5, + conflict_group='interface') + # Most of the options for non-cmd-options are useless, but I'm keeping + # them as documentation. + options.add_option( + group=NAMESPACE, + option='width', + help='Window width', + type='int', + metavar='PIXELS', + default=450, + conflict_group='interface', + is_cmd_option=False) + options.add_option( + group=NAMESPACE, + option='height', + help='Window height', + type='int', + metavar='PIXELS', + default=300, + conflict_group='interface', + is_cmd_option=False) + options.add_option( + group=NAMESPACE, + option='position_x', + help='Window position on the X axis', + type='int', + metavar='PIXELS', + default=5, + conflict_group='interface', + is_cmd_option=False) + options.add_option( + group=NAMESPACE, + option='position_y', + help='Window position on the Y axis', + type='int', + metavar='PIXELS', + default=5, + conflict_group='interface', + is_cmd_option=False) + options.add_option( + group=NAMESPACE, + option='max_status_display', + help='Maximum number of elements to keep internally', + type='int', + metavar='MESSAGES', + default=60, + conflict_group='interface', + is_cmd_option=False) # TODO: Should it be config only? + + +# Constants + +URL_RE = re.compile( + r'((?:(?:https?|ftp)://|www[-\w]*\.)[^\s\n\r]+[-\w+&@#%=~])', re.I) + +_log = logging.getLogger('ui.pygtk') + +class Columns: + (PIC, NAME, MESSAGE, USERNAME, ID, DATETIME, ALL_DATA) = range(7) + + +class _MainWindow(gtk.Window): + """PyGTK main window.""" + + def __init__(self, controller): + super(_MainWindow, self).__init__() + + self._controller = controller + self.connect('destroy', self._controller.destroy) + self.connect('delete-event', self._controller.delete_event) + + grid = self._create_grid(None) # Where is the store? + (menu, toolbar, accelerators) = self._create_menu_and_toolbar() + update_field = self._create_update_box() + statusbar = self._create_statusbar() + + update_box = gtk.VPaned() + update_box.pack1(grid, resize=True, shrink=False) + update_box.pack2(update_field, resize=False, shrink=True) + + box = gtk.VBox(False, 1) + box.pack_start(menu, False, True, 0) + box.pack_start(update_box, True, True, 0) + box.pack_start(statusbar, False, False, 0) + self.add(box) + self.add_accel_group(accelerators) + + return + + def _create_grid(self, grid_store): + """Add the displaying grid.""" +# self.grid_store = gtk.ListStore( +# str, +# str, +# str, +# str, +# str, +# object, +# object) + +# self.grid_store.set_sort_func(Columns.DATETIME, self._sort_by_time) +# self.grid_store.set_sort_column_id(Columns.DATETIME, +# gtk.SORT_DESCENDING) + + self.grid = gtk.TreeView(grid_store) + self.grid.set_property('headers-visible', False) + self.grid.set_rules_hint(True) # change color for each row + + user_renderer = gtk.CellRendererPixbuf() + user_column = gtk.TreeViewColumn('User', user_renderer) + user_column.set_cell_data_func(user_renderer, + self._cell_renderer_user) + self.grid.append_column(user_column) + + message_renderer = gtk.CellRendererText() + message_renderer.set_property('wrap-mode', gtk.WRAP_WORD) + message_renderer.set_property('wrap-width', 200) + message_renderer.set_property('width', 10) + + message_column = gtk.TreeViewColumn('Message', + message_renderer, text=1) + message_column.set_cell_data_func(message_renderer, + self._cell_renderer_message) + self.grid.append_column(message_column) + + self.grid.set_resize_mode(gtk.RESIZE_IMMEDIATE) + #self.grid.connect('cursor-changed', self.check_post) + #self.grid.connect('row-activated', self.open_post) + #self.grid.connect('button-press-event', self.click_post) + #self.grid.connect('popup-menu', + # lambda view: self.show_post_popup(view, None)) + + scrolled_window = gtk.ScrolledWindow() + scrolled_window.set_policy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS) + scrolled_window.add(self.grid) + + return scrolled_window + + def _create_menu_and_toolbar(self): + """Create the main menu and the toolbar.""" + + # tasks (used by the menu and toolbar) + + refresh_action = gtk.Action('Refresh', '_Refresh', + 'Update the listing', gtk.STOCK_REFRESH) + refresh_action.connect('activate', self.refresh) + + quit_action = gtk.Action('Quit', '_Quit', + 'Exit Mitter', gtk.STOCK_QUIT) + quit_action.connect('activate', self.quit) + + settings_action = gtk.Action('Settings', '_Settings', + 'Settings', gtk.STOCK_PREFERENCES) + settings_action.connect('activate', self.show_settings) + + update_action = gtk.Action('Update', '_Update', 'Update your status', + gtk.STOCK_ADD) + update_action.set_property('sensitive', False) + update_action.connect('activate', self._update_status) + + delete_action = gtk.Action('Delete', '_Delete', 'Delete a post', + gtk.STOCK_DELETE) + delete_action.set_property('sensitive', False) + delete_action.connect('activate', self.delete_tweet) + + about_action = gtk.Action('About', '_About', 'About Mitter', + gtk.STOCK_ABOUT) + about_action.connect('activate', self.show_about) + + shrink_url_action = gtk.Action('ShrinkURL', 'Shrink _URL', + 'Shrink selected URL', gtk.STOCK_EXECUTE) + shrink_url_action.connect('activate', self.shrink_url) + + mute_action = gtk.ToggleAction('MuteNotify', '_Mute Notifications', + 'Mutes notifications on new tweets', gtk.STOCK_MEDIA_PAUSE) + mute_action.set_active(False) + + post_action = gtk.Action('Posts', '_Posts', 'Post management', None) + + file_action = gtk.Action('File', '_File', 'File', None) + edit_action = gtk.Action('Edit', '_Edit', 'Edit', None) + help_action = gtk.Action('Help', '_Help', 'Help', None) + + # action group (will have all the actions, 'cause we are not actually + # grouping them, but Gtk requires them that way) + + self.action_group = gtk.ActionGroup('MainMenu') + self.action_group.add_action_with_accel(refresh_action, 'F5') + # None = use the default accelerator, based on the STOCK used. + self.action_group.add_action_with_accel(quit_action, None) + self.action_group.add_action(settings_action) + self.action_group.add_action(delete_action) + self.action_group.add_action(post_action) + self.action_group.add_action(file_action) + self.action_group.add_action(edit_action) + self.action_group.add_action(help_action) + self.action_group.add_action(about_action) + self.action_group.add_action_with_accel(shrink_url_action, 'u') + self.action_group.add_action_with_accel(mute_action, 'm') + self.action_group.add_action_with_accel(update_action, + 'Return') + + # definition of the UI + + uimanager = gtk.UIManager() + uimanager.insert_action_group(self.action_group, 0) + ui = ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + ''' + uimanager.add_ui_from_string(ui) + + main_menu = uimanager.get_widget('/MainMenu') + main_toolbar = uimanager.get_widget('/MainToolbar') + + return (main_menu, main_toolbar, uimanager.get_accel_group()) + + def _create_update_box(self): + """Create the widgets related to the update box""" + self._update_text = gtk.TextView() + text_buffer = self._update_text.get_buffer() + text_buffer.connect('changed', self._controller._count_chars) + + update_button = gtk.Button(stock=gtk.STOCK_ADD) + update_button.connect('clicked', self._controller._update_status) + + update_box = gtk.HBox(False, 0) + update_box.pack_start(self._update_text, expand=True, fill=True, + padding=0) + update_box.pack_start(update_button, expand=False, fill=False, + padding=0) + + info_box = gtk.HBox(False, 0) + self._char_count = gtk.Label() + self._char_count.set_text('(140)') + info_box.pack_start(gtk.Label('What are you doing?')) + info_box.pack_start(self._char_count) + + update_area = gtk.VBox(True, 0) + update_area.pack_start(info_box) + update_area.pack_start(update_box) + + return update_area + + def _create_statusbar(self): + """Create the statusbar.""" + statusbar = gtk.Statusbar() + # TODO: Probaly set the context in the object. + return statusbar + + # ------------------------------------------------------------ + # Cell rendering functions + # ------------------------------------------------------------ + def _cell_renderer_user(self, column, cell, store, position): + """Callback for the user column. Used to created the pixbuf of the + userpic.""" + + pic = store.get_value(position, Columns.PIC) + if not pic in self._user_pics: + cell.set_property('pixbuf', self._default_pixmap) + + # just make sure we download this pic too. + self.queue_pic(pic) + else: + cell.set_property('pixbuf', self._user_pics[pic]) + + return + + def _cell_renderer_message(self, column, cell, store, position): + """Callback for the message column. We need this to adjust the markup + property of the cell, as setting it as text won't do any markup + processing.""" + + user = store.get_value(position, Columns.NAME) + message = store.get_value(position, Columns.MESSAGE) + time = store.get_value(position, Columns.DATETIME) + username = store.get_value(position, Columns.USERNAME) + + time = timesince.timesince(time) + + # unescape escaped entities that pango is okay with + message = re.sub(r'&(?!(amp;|gt;|lt;|quot;|apos;))', r'&', message) + + # highlight URLs + message = url_re.sub(r'\1', + message) + + # use a different highlight for the current user + message = re.sub(r'(@'+self.twitter.username+')', + r'\1', + message) + + markup = '%s (%s):\n%s\n%s' % \ + (user, username, message, time) + cell.set_property('markup', markup) + + return + +class _GtkController(object): + """The interface controller.""" + + def __init__(self): + super(_GtkController, self).__init__() + return + + def destroy(self, widget, user_data=None): + """Called when the window is destroyed.""" + _log.debug('Window destroy') + gtk.main_quit() + return True + + def delete_event(self, widget, event, user_param=None): + _log.debug('Window delete') + gtk.main_quit() + return True + + +class Interface(object): + """Linux/GTK interface for Mitter.""" + + def systray_cb(self, widget, user_param=None): + if self.window.get_property('visible') and self.window.is_active(): + x, y = self.window.get_position() + self.prefs['position_x'] = x + self.prefs['position_y'] = y + self.window.hide() + else: + self.window.move( + self.prefs['position_x'], + self.prefs['position_y']) + self.window.deiconify() + self.window.present() + + def create_settings_dialog(self): + """Creates the settings dialog.""" + + self.settings_window = gtk.Dialog(title="Settings", + parent=self.window, flags=gtk.DIALOG_MODAL | + gtk.DIALOG_DESTROY_WITH_PARENT, + buttons=(gtk.STOCK_CANCEL, 0, gtk.STOCK_OK, 1)) + self.settings_box = gtk.Table(rows=4, columns=2, homogeneous=False) + + username_label = gtk.Label('Username:') + password_label = gtk.Label('Password:') + refresh_label = gtk.Label('Refresh interval (minutes):') + https_label = gtk.Label('Use secure connections (HTTPS):') + + labels = [username_label, password_label, refresh_label, https_label] + for label in labels: + label.set_alignment(0, 0.5) + label.set_padding(2, 0) + + self.username_field = gtk.Entry() + self.password_field = gtk.Entry() + self.password_field.set_visibility(False) + + self.refresh_interval_field = gtk.SpinButton() + self.refresh_interval_field.set_range(1, 99) + self.refresh_interval_field.set_numeric(True) + self.refresh_interval_field.set_value(self.prefs['refresh_interval']) + self.refresh_interval_field.set_increments(1, 5) + + self.https_field = gtk.CheckButton() + self.https_field.set_active(self.https) + + self.settings_box.attach(username_label, 0, 1, 0, 1) + self.settings_box.attach(self.username_field, 1, 2, 0, 1) + self.settings_box.attach(password_label, 0, 1, 1, 2) + self.settings_box.attach(self.password_field, 1, 2, 1, 2) + self.settings_box.attach(refresh_label, 0, 1, 2, 3) + self.settings_box.attach(self.refresh_interval_field, 1, 2, 2, 3) + self.settings_box.attach(https_label, 0, 1, 3, 4) + self.settings_box.attach(self.https_field, 1, 2, 3, 4) + + self.settings_box.show_all() + self.settings_window.vbox.pack_start(self.settings_box, True, + True, 0) + self.settings_window.connect('close', self.close_dialog) + self.settings_window.connect('response', self.update_preferences) + + return + + def show_about(self, widget): + """Show the about dialog.""" + + about_window = gtk.AboutDialog() + about_window.set_name('Mitter') + about_window.set_version(version) + about_window.set_copyright('2007-2008 Mitter Contributors') + about_window.set_license(gpl_3) + about_window.set_website('http://mitter.googlecode.com') + about_window.set_website_label('Mitter on GoogleCode') + about_window.set_authors(['Julio Biason', 'Deepak Sarda', \ + 'Gerald Kaszuba']) + about_window.connect('close', self.close_dialog) + about_window.run() + about_window.hide() + + + # ------------------------------------------------------------ + # Widget creation functions + # ------------------------------------------------------------ + + # ------------------------------------------------------------ + # Grid cell content callback + # ------------------------------------------------------------ + + # Non-widget attached callbacks + def set_auto_refresh(self): + """Configure auto-refresh of tweets every `interval` minutes""" + + if self._refresh_id: + gobject.source_remove(self._refresh_id) + + self._refresh_id = gobject.timeout_add( + self.prefs['refresh_interval']*60*1000, + self.refresh, None) + + return + + def update_friends_list(self): + """Fetch the user's list of twitter friends and add it + to the friends_store for @reply autocompletion""" + + _log.debug('Checking friends list...') + friends = self.twitter.friends_list(self.post_update_friends_list) + return + + def post_update_friends_list(self, friends, error): + """Function called after we fetch the friends list.""" + + _log.debug('Received the friends list') + + if error == 401: # TODO: Constants for this? + # not authorized + _log.error('User is not authorized yet') + return + + if error in (500, 502, 503): + _log.error('Twitter asked us to try getting friends list' \ + ' sometime later') + gobject.timeout_add(5*60*1000, self.update_friends_list) + return + + if error: + # any error + # well, we just don't add any friends, then. + _log.error('Error getting friend list, leaving list empty') + return + + # I'm not really sure if we need to set the thread locking here (as we + # are just updating the store), but better safe than sorry! + + gtk.gdk.threads_enter() + # Sometimes due to twitter API quirks and moon phases, the friends + # list ends up getting populated more than once. So watch for + # duplicates.... + known_friends = [row[0] for row in self.friends_store] + _log.debug('known_friends: %s' % " ".join(known_friends)) + for friend in friends: + try: + screen_name = '@' + friend['screen_name'] + ': ' + if screen_name not in known_friends: + _log.debug('Adding "%s" to the list' % (screen_name)) + self.friends_store.append([screen_name]) + except Exception, e: + # No `error` does not always mean twitter sent us good data + _log.error('Error processing friend list. %s' % str(e)) + + gtk.gdk.threads_leave() + _log.debug('friends list processing complete') + return + + def prune_grid_store(self): + """Prune the grid_store by removing the oldest rows.""" + + if len(self.grid_store) <= MAX_STATUS_DISPLAY: + return True # Required by gobject.idle_add() for this to be called + # again + + _log.debug("prune_grid_store called") + + gtk.gdk.threads_enter() + + self.grid.freeze_child_notify() + self.grid.set_model(None) + + # Since I don't know how to get the last row in grid_store, + # I'll reverse the list and then pop out the first row instead. + + self.grid_store.set_sort_column_id(Columns.DATETIME, + gtk.SORT_ASCENDING) + + iter = self.grid_store.get_iter_first() + + while (len(self.grid_store) > MAX_STATUS_DISPLAY) and iter: + _log.debug("popping off tweet with id %s" % + self.grid_store.get_value(iter, Columns.ID)) + self.grid_store.remove(iter) # iter is auto set to next row + + + self.grid_store.set_sort_column_id(Columns.DATETIME, + gtk.SORT_DESCENDING) + self.grid.set_model(self.grid_store) + self.grid.thaw_child_notify() + + gtk.gdk.threads_leave() + return True + + # Main window callbacks + def size_request(self, widget, requisition, data=None): + """Callback when the window changes its sizes. We use it to set the + proper word-wrapping for the message column.""" + + self.prefs['width'], self.prefs['height'] = self.window.get_size() + + # this is based on a mail of Kristian Rietveld, on gtk maillist + + if not len(self.grid_store): + # nothing to rearrange + return + + column = self.message_column + iter = self.grid_store.get_iter_first() + path = self.grid_store.get_path(iter) + + column_rectangle = self.grid.get_cell_area(path, column) + + width = column_rectangle.width + _log.debug('Width=%d' % (width)) + + # there should be only + renderers = column.get_cell_renderers() + for render in renderers: + _log.debug('Render update') + render.set_property('wrap-width', width) + + while iter: + path = self.grid_store.get_path(iter) + self.grid_store.row_changed(path, iter) + iter = self.grid_store.iter_next(iter) + + return + + def quit(self, widget, user_data=None): + """Callback when the window is destroyed (e.g. when the user closes + the application.""" + + # this is really annoying: if the threads are locked doing some IO + # requests, the application will not quit. Displaying this message is + # the only option we have right now. + + _log.debug('quit callback invoked. exiting now...') + gtk.main_quit() + + def notify_reset(self, widget, event, user_data=None): + if getattr(event, 'in_', False): + self._main_window.set_urgency_hint(False) + if self._systray: + self._systray.set_tooltip('Mitter: Click to toggle ' \ + 'window visibility.') + self._systray.set_from_file(self._app_icon) + self.unread_tweets = 0 + return + + def notify(self, new_tweets=0): + """Set the window hint as urgent, so Mitter window will flash, + notifying the user about the new messages. Also send a notification + message with one of the new tweets.""" + self.window.set_urgency_hint(True) + if self._systray and self.unread_tweets > 0: + self._systray.set_tooltip('Mitter: %s new' % self.unread_tweets) + self._systray.set_from_file(self._app_icon_alert) + + if self.action_group.get_action('MuteNotify').get_active(): + _log.debug('notifications are currently muted') + return + + if new_tweets and len(self.grid_store) > 0: + iter = self.grid_store.get_iter_first() + while iter: + sender = self.grid_store.get_value(iter, Columns.USERNAME) + if sender == self.username_field.get_text(): + iter = self.grid_store.iter_next(iter) + continue + else: + tweet = self.grid_store.get_value(iter, Columns.MESSAGE) + _log.debug('notify_broadcast with this tweet: %s' % + tweet) + break + + if new_tweets > 1: + msg = '%d unread tweets including ' \ + 'this from %s:
%s' % (self.unread_tweets, + sender, tweet) + else: + msg = 'One new tweet from %s:
%s' % (sender, + tweet) + + if self.systray: + gtk.gdk.threads_enter() + screen, rect, orientation = self.systray.get_geometry() + gtk.gdk.threads_leave() + self.notify_broadcast(msg, rect.x, rect.y) + return + + # settings callbacks + def show_settings(self, widget, user_data=None): + """Create and display the settings window.""" + + self.settings_window.show() + self.settings_window.run() + + return + + def close_dialog(self, user_data=None): + """Hide the dialog window.""" + + return True + + def update_preferences(self, widget, response_id=0, user_data=None): + """ + Update the user preferences when the user press the "OK" button in the + settings window.""" + + if response_id == 1: + self.statusbar.push(self.statusbar_context, + 'Saving your profile...') + + self.save_interface_prefs() + + # update the (internal) twitter prefences too! + + self.twitter.username = self.username_field.get_text() + self.twitter.password = self.password_field.get_text() + self.twitter.https = self.https_field.get_active() + refresh_interval = self.refresh_interval_field.get_value_as_int() + self.prefs['refresh_interval'] = refresh_interval + + # update the list + + self.refresh(None) + self.update_friends_list() + self.statusbar.pop(self.statusbar_context) + + # update auto-refresh + + self.set_auto_refresh() + + self.settings_window.hide() + + return True + + # update status + def update_status(self, user_data=None): + """Update the user status on Twitter.""" + + status = self.update_text.get_text() + status = status.strip() + if not str_len(status): + return + + self.update_text.set_sensitive(False) + self.statusbar.push(self.statusbar_context, 'Updating your status...') + + if str_len(status) > 140: + error_message = 'Your message has more than 140 characters and' \ + ' Twitter may truncate it. It would still be visible ' \ + 'on the website. Do you still wish to go ahead?' + if str_len(status) > 160: + error_message = 'Your message has more than 160 characters ' \ + 'and it is very likely Twitter will refuse it. You ' \ + 'can try shortening your URLs before posting. Do ' \ + 'you still wish to go ahead?' + + error_dialog = gtk.MessageDialog(parent=self.window, + type=gtk.MESSAGE_QUESTION, + flags=gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, + message_format="Your status update message is too long.", + buttons=gtk.BUTTONS_YES_NO) + error_dialog.format_secondary_text(error_message) + + response = error_dialog.run() + error_dialog.destroy() + if response == gtk.RESPONSE_NO: + self.statusbar.pop(self.statusbar_context) + self.update_text.set_sensitive(True) + self.window.set_focus(self.update_text) + return + + data = self.twitter.update(status, self.post_update_status) + + def post_update_status(self, data, error): + """Function called after we receive the answer from the update + status.""" + + if error: + gtk.gdk.threads_enter() + error_dialog = gtk.MessageDialog(parent=self.window, + type=gtk.MESSAGE_ERROR, + message_format='Error updating status. Please try again.', + buttons=gtk.BUTTONS_OK) + error_dialog.connect("response", lambda *a: + error_dialog.destroy()) + error_dialog.run() + gtk.gdk.threads_leave() + else: + if data: + # i wonder if this will really work + self.post_refresh([data], None, False) + else: + self.refresh(None, False) + + gtk.gdk.threads_enter() + self.update_text.set_text("") + gtk.gdk.threads_leave() + + gtk.gdk.threads_enter() + self.statusbar.pop(self.statusbar_context) + self.update_text.set_sensitive(True) + self.window.set_focus(self.update_text) + gtk.gdk.threads_leave() + + return True + + def shrink_url(self, widget, user_data=None): + bounds = self.update_text.get_selection_bounds() + if not bounds: + return + else: + start, end = bounds + + longurl = self.update_text.get_chars(start, end).strip() + if not longurl: + return + + _log.debug('shrink url request for: %s' % longurl) + + self.update_text.set_sensitive(False) + self.statusbar.push(self.statusbar_context, 'Shrinking URL...') + + self.twitter.download('http://is.gd/api.php?longurl=' + longurl, + self.post_shrink_url, longurl=longurl, + start=start, end=end) + + def post_shrink_url(self, url, error, longurl, start, end): + if error: + _log.error("Exception in shrinking url. ' \ + 'Error code: %s" % error) + # error dialog + gtk.gdk.threads_enter() + error_dialog = gtk.MessageDialog(parent=self.window, + type=gtk.MESSAGE_ERROR, + message_format='Failed to shrink the URL %s' % longurl, + buttons=gtk.BUTTONS_OK) + error_dialog.connect("response", lambda *a: + error_dialog.destroy()) + error_dialog.run() + gtk.gdk.threads_leave() + else: + _log.debug('Got shrunk url: %s' % url) + char = self.update_text.get_chars(start-1, start) + if start and not char.isspace(): + url = ' '+url + char = self.update_text.get_chars(end, end+1) + if not char.isspace(): + url = url+' ' + + gtk.gdk.threads_enter() + self.update_text.delete_text(start, end) + self.update_text.insert_text(url, start) + self.update_text.set_position(start+len(url)) + gtk.gdk.threads_leave() + + gtk.gdk.threads_enter() + self.statusbar.pop(self.statusbar_context) + self.update_text.set_sensitive(True) + self.update_text.grab_focus() + gtk.gdk.threads_leave() + + # post related callbacks + def reply_tweet(self, widget, user_data=None): + """Reply by putting the username in your input""" + cursor = self.grid.get_cursor() + if not cursor: + return + + path = cursor[0] + iter = self.grid_store.get_iter(path) + username = self.grid_store.get_value(iter, Columns.USERNAME) + text_insert = '@%s: ' % (username) + + _log.debug('Inserting reply text: %s' % (text_insert)) + + status = self.update_text.get_text() + status = text_insert + status + self.update_text.set_text(status) + self.window.set_focus(self.update_text) + self.update_text.set_position(len(status)) + + def retweet(self, widget, user_data=None): + """Retweet by putting the string rt and username in your input""" + + cursor = self.grid.get_cursor() + if not cursor: + return + + path = cursor[0] + iter = self.grid_store.get_iter(path) + username = self.grid_store.get_value(iter, Columns.USERNAME) + msg = self.grid_store.get_value(iter, Columns.MESSAGE) + text_insert = 'RT @%s: %s' % (username, msg) + + _log.debug('Inserting retweet text: %s' % (text_insert)) + + status = text_insert + self.update_text.get_text() + self.update_text.set_text(status) + self.window.set_focus(self.update_text) + self.update_text.set_position(str_len(status)) + + def delete_tweet(self, widget, user_data=None): + """Delete a twit.""" + + cursor = self.grid.get_cursor() + if not cursor: + return + + path = cursor[0] + iter = self.grid_store.get_iter(path) + tweet_id = int(self.grid_store.get_value(iter, Columns.ID)) + _log.debug('Deleting tweet: %d' % (tweet_id)) + + self.statusbar.push(self.statusbar_context, 'Deleting tweet...') + + self.twitter.tweet_destroy(tweet_id, self.post_delete_tweet, + tweet=tweet_id) + + return + + def post_delete_tweet(self, data, error, tweet): + """Function called after we delete a tweet on the server.""" + + if error: + gtk.gdk.threads_enter() + error_dialog = gtk.MessageDialog(parent=self.window, + type=gtk.MESSAGE_ERROR, + message_format='Error deleting tweet. Please try again.', + buttons=gtk.BUTTONS_OK) + error_dialog.connect("response", lambda *a: + error_dialog.destroy()) + error_dialog.run() + gtk.gdk.threads_leave() + else: + # locate that tweet in the store and remove it. + iter = self.grid_store.get_iter_first() + tweet = int(tweet) + while iter: + id = self.grid_store.get_value(iter, Columns.ID) + if int(id) == tweet: + self.grid_store.remove(iter) + break + iter = self.grid_store.iter_next(iter) + + # update the interface + gtk.gdk.threads_enter() + self.statusbar.pop(self.statusbar_context) + self.grid.queue_draw() + gtk.gdk.threads_leave() + + return + + def check_post(self, treeview, user_data=None): + """Callback when one of the rows is selected.""" + cursor = treeview.get_cursor() + if not cursor: + return + + path = cursor[0] + iter = self.grid_store.get_iter(path) + username = self.grid_store.get_value(iter, Columns.USERNAME) + + delete_action = self.action_group.get_action('Delete') + + if username == self.username_field.get_text(): + delete_action.set_property('sensitive', True) + else: + delete_action.set_property('sensitive', False) + + return + + def open_post(self, treeview, path, view_column, user_data=None): + """Callback when one of the rows in activated.""" + + iter = self.grid_store.get_iter(path) + username = self.grid_store.get_value(iter, Columns.USERNAME) + tweet_id = self.grid_store.get_value(iter, Columns.ID) + message = self.grid_store.get_value(iter, Columns.MESSAGE) + urls = url_re.search(message) + if urls: + # message contains a link; go to the link instead + url = urls.groups()[0] + else: + url = 'http://twitter.com/%s/statuses/%s/' % (username, tweet_id) + + self.open_url(path, url) + + def click_post(self, treeview, event, user_data=None): + """Callback when a mouse click event occurs on one of the rows.""" + + if event.button != 3: + # Only right clicks are processed + return False + + x = int(event.x) + y = int(event.y) + + pth = treeview.get_path_at_pos(x, y) + if not pth: + # The click wasn't on a row + return False + + path, col, cell_x, cell_y = pth + treeview.grab_focus() + treeview.set_cursor(path, col, 0) + + self.show_post_popup(treeview, event) + return True + + def show_post_popup(self, treeview, event, user_data=None): + """Shows the popup context menu in the treeview""" + + cursor = treeview.get_cursor() + if not cursor: + return + + path = cursor[0] + row_iter = self.grid_store.get_iter(path) + + popup_menu = gtk.Menu() + popup_menu.set_screen(self.window.get_screen()) + + # An open submenu with various choices underneath + open_menu_items = [] + + tweet = self.grid_store.get_value(row_iter, Columns.ALL_DATA) + + urls = url_re.findall(tweet['text']) + for url in urls: + if len(url) > 20: + item_name = url[:20] + '...' + else: + item_name = url + item = gtk.MenuItem(item_name) + item.connect('activate', self.open_url, url) + open_menu_items.append(item) + + if tweet['in_reply_to_status_id']: + # I wish twitter made it easy to construct target url + # without having to make another API call + reply_to = re.search(r'@(?P\w+)', tweet['text']) + if reply_to: + url = 'http://twitter.com/%s/statuses/%s' % ( + reply_to.group('user'), + tweet['in_reply_to_status_id']) + item = gtk.MenuItem('In reply to') + item.connect('activate', self.open_url, url) + open_menu_items.append(item) + + item = gtk.MenuItem('This tweet') + username = self.grid_store.get_value(row_iter, Columns.USERNAME) + tweet_id = self.grid_store.get_value(row_iter, Columns.ID) + url = 'http://twitter.com/%s/statuses/%s/' % (username, tweet_id) + item.connect('activate', self.open_url, url) + open_menu_items.append(item) + + open_menu = gtk.Menu() + for item in open_menu_items: + open_menu.append(item) + + open_item = gtk.MenuItem("Open") + open_item.set_submenu(open_menu) + popup_menu.append(open_item) + + # Reply, only if it's not yourself + item = gtk.MenuItem("Reply") + item.connect('activate', self.reply_tweet, "Reply") + if username == self.username_field.get_text(): + item.set_property('sensitive', False) + popup_menu.append(item) + + # Retweet, only if it's not yourself + item = gtk.MenuItem("Retweet") + item.connect('activate', self.retweet, "Retweet") + if username == self.username_field.get_text(): + item.set_property('sensitive', False) + popup_menu.append(item) + + item = gtk.MenuItem("Delete") + item.connect('activate', self.delete_tweet, "Delete") + if username != self.username_field.get_text(): + item.set_property('sensitive', False) + + popup_menu.append(item) + + popup_menu.show_all() + + if event: + b = event.button + t = event.time + else: + b = 1 + t = 0 + + popup_menu.popup(None, None, None, b, t) + + return True + + # action callbacks + # (yes, settings should be here, but there are more settings-related + # callbacks, so let's keep them together somewhere else) + def open_url(self, source, url): + """Simply opens specified url in new browser tab. We need source + parameter so that this function can be used as an event callback""" + + _log.debug('opening url: %s' % url) + import webbrowser + webbrowser.open_new_tab(url) + self.window.set_focus(self.update_text) + + def refresh(self, widget, notify=True): + """Update the list of twits.""" + + if self.last_update: + self.statusbar.pop(self.statusbar_context) + self.last_update = datetime.datetime.now() + + _log.debug('Updating list of tweets...') + self.statusbar.push(self.statusbar_context, + 'Updating list of tweets...') + + self.twitter.friends_timeline(self.post_refresh, notify=notify) + + return True # required by gobject.timeout_add + + def post_refresh(self, data, error, notify): + """Function called when the system retrieves the list of new + tweets.""" + + _log.debug('Data: %s' % (str(data))) + + if error == 401: + # Not authorized, popup the settings window + gtk.gdk.threads_enter() + error_dialog = gtk.MessageDialog(parent=self.window, + type=gtk.MESSAGE_ERROR, + message_format='Autorization error, check your login ' \ + 'information in the prefrences', + buttons=gtk.BUTTONS_OK) + error_dialog.connect("response", lambda *a: + error_dialog.destroy()) + error_dialog.run() + gtk.gdk.threads_leave() + return + + if not data: + gtk.gdk.threads_enter() + self.statusbar.pop(self.statusbar_context) + self.show_last_update() + _log.debug('No new data') + gtk.gdk.threads_leave() + return + + known_tweets = [row[Columns.ID] for row in self.grid_store] + need_notify = False + new_tweets = 0 + new_tweets_list = [] + + for tweet in data: + id = tweet['id'] + if str(id) in known_tweets: + _log.debug('Tweet %s is already in the list' % (id)) + continue + + created_at = tweet['created_at'] + display_name = tweet['user']['name'] + username = tweet['user']['screen_name'] + user_pic = tweet['user']['profile_image_url'] + message = tweet['text'] + + new_tweets_list.append((user_pic, display_name, message, username, + id, created_at, tweet)) + self.queue_pic(user_pic) + + _log.debug('New tweet with id %s from %s' % (id, username)) + if not username == self.username_field.get_text(): + # we don't want to be notified about tweets from ourselves, + # but from everyone else it is fine. + new_tweets += 1 + + # add the new tweets in the store + gtk.gdk.threads_enter() + for data in new_tweets_list: + self.grid_store.append(data) + + self.statusbar.pop(self.statusbar_context) + + # there is new stuff, so we move to the top + + p = self.grid_store.get_path(self.grid_store.get_iter_first()) + self.grid.scroll_to_cell(p) + self.show_last_update() + _log.debug('Tweets updated') + gtk.gdk.threads_leave() + + if new_tweets and notify: + self.unread_tweets += new_tweets + self.notify(new_tweets) + + self.refresh_rate_limit() + self.prune_grid_store() + + # ------------------------------------------------------------ + # Helper functions + # ------------------------------------------------------------ + def clear_list(self): + """Clear the list, so we can add more items.""" + + self.grid_store.clear() + + return + + def save_interface_prefs(self): + """Using the save callback, save all this interface preferences.""" + + self.prefs['refresh_interval'] = \ + self.refresh_interval_field.get_value_as_int() + + x, y = self.window.get_position() + self.prefs['position_x'] = x + self.prefs['position_y'] = y + + self.save_callback(self.username_field.get_text(), + self.password_field.get_text(), + self.https_field.get_active(), + NAMESPACE, self.prefs) + + return + + def refresh_rate_limit(self): + """Request the rate limit and check if we are doing okay.""" + self.twitter.rate_limit_status(self.post_refresh_rate_limit) + return + + def post_refresh_rate_limit(self, data, error): + """Callback for the refresh_rate_limit.""" + if error or not data: + _log.error('Error fetching rate limit') + return + + # Check if we are running low on our limit + reset_time = datetime.datetime.fromtimestamp( + int(data['reset_time_in_seconds'])) + + if reset_time < datetime.datetime.now(): + # Clock differences can cause this + return + + time_delta = reset_time - datetime.datetime.now() + mins_till_reset = time_delta.seconds/60 # Good enough! + needed_hits = mins_till_reset/self.prefs['refresh_interval'] + remaining_hits = int(data['remaining_hits']) + + _log.debug('remaining_hits: %s. reset in %s mins.' + % (remaining_hits, mins_till_reset)) + + if needed_hits > remaining_hits: + gtk.gdk.threads_enter() + error_dialog = gtk.MessageDialog(parent=self.window, + type=gtk.MESSAGE_WARNING, + flags=gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, + message_format='Refresh rate too high', + buttons=gtk.BUTTONS_OK) + error_dialog.format_secondary_text( + "You have only %d twitter requests left until your " \ + "request count is reset in %d minutes. But at your " \ + "current refresh rate (every %d minutes), you will " \ + "exhaust your limit within %d minutes. You should " \ + "consider increasing the refresh interval in Mitter's " \ + "Settings dialog." % (remaining_hits, mins_till_reset, + self.prefs['refresh_interval'], + remaining_hits * self.prefs['refresh_interval'])) + error_dialog.connect("response", lambda *a: + error_dialog.destroy()) + error_dialog.run() + gtk.gdk.threads_leave() + + def show_last_update(self): + """Add the last update time in the status bar.""" + + last_update = self.last_update.strftime('%H:%M') + next_update = (self.last_update + + datetime.timedelta(minutes=self.prefs[ + 'refresh_interval'])).strftime('%H:%M') + + message = 'Last update %s, next update %s' % (last_update, + next_update) + self.statusbar.push(self.statusbar_context, message) + return + + def queue_pic(self, pic): + """Check if the pic is in the queue or already downloaded. If it is + not in any of those, add it to the download queue.""" + if pic in self.user_pics: + return + + if pic in self.pic_queue: + return + + self.pic_queue.add(pic) + self.twitter.download(pic, self.post_pic_download, id=pic) + return + + def post_pic_download(self, data, error, id): + """Function called once we downloaded the user pic.""" + + _log.debug('Received pic %s' % (id)) + + if error or not data: + _log.debug('Error with the pic, not loading') + return + + loader = gtk.gdk.PixbufLoader() + loader.write(data) + loader.close() + + self.user_pics[id] = loader.get_pixbuf() + self.pic_queue.discard(id) + + # finally, request the grid to redraw itself + gtk.gdk.threads_enter() + self.grid.queue_draw() + gtk.gdk.threads_leave() + + return + # ------------------------------------------------------------ + # Helper functions + # ------------------------------------------------------------ + def _sort_by_time(self, model, iter1, iter2, data=None): + """The sort function where we sort by the datetime.datetime object""" + + d1 = model.get_value(iter1, Columns.DATETIME) + d2 = model.get_value(iter2, Columns.DATETIME) + + # Why do we get called with None values?! + + if not d1: + return 1 + if not d2: + return -1 + + if d1 < d2: + return -1 + elif d1 > d2: + return 1 + return 0 + + # ------------------------------------------------------------ + # Widget creation functions + # ------------------------------------------------------------ + def _systray_setup(self): + if not (self._app_icon and self._app_icon_alert): + self._systray = None + return + + self._systray = gtk.StatusIcon() + self._systray.set_from_file(self._app_icon) + self._systray.connect('activate', self.systray_cb) + self._systray.set_tooltip('Mitter: Click to toggle window visibility.') + self._systray.set_visible(True) + return + + # ------------------------------------------------------------ + # Widget callback functions + # ------------------------------------------------------------ + + def _count_chars(self, text_buffer): + """Count the number of chars in the edit field and update the + label that shows the available space.""" + + start = text_buffer.get_start_iter() + end = text_buffer.get_end_iter() + + text = text_buffer.get_text(start, end, include_hidden_chars=False) + + self._char_count.set_text('(%d)' % (140 - len(text))) + + return True + + def _update_status(self): + """Update your status.""" + _log.debug('Updating status.') + status = self._update_text.get_text() + status = status.strip() + if not str_len(status): + return + + self.update_text.set_sensitive(False) + self.statusbar.push(self.statusbar_context, 'Updating your status...') + + # ------------------------------------------------------------ + # Required functions for all interfaces + # ------------------------------------------------------------ + + def __init__(self, connection, options): + """Class initialization.""" + + self._connection = connection + self._options = options + + self._user_pics = {} + self._pic_queue = set() + + # Load images + self._app_icon = util.find_image('mitter.png') + self._app_icon_alert = util.find_image('mitter-new.png') + + unknown_pixbuf = util.find_image('unknown.png') + if unknown_pixbuf: + self._default_pixmap = gtk.gdk.pixbuf_new_from_file( + unknown_pixbuf) + else: + self._default_pixmap = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, + has_alpha=False, bits_per_sample=8, width=48, height=48) + + self._main_window = _MainWindow(_GtkController()) + self._main_window.set_title('Mitter') + self._main_window.set_size_request(10, 10) # very small minimal size + self._main_window.resize(self._options[NAMESPACE]['width'], + self._options[NAMESPACE]['height']) + self._main_window.move(self._options[NAMESPACE]['position_x'], + self._options[NAMESPACE]['position_y']) + + if self._app_icon: + self._main_window.set_icon_from_file(self._app_icon) + + + #self._main_window() + #self._systray_setup() + +# self.create_settings_dialog() +# self.username_field.set_text(default_username) +# self.password_field.set_text(default_password) +# self.https_field.set_active(self.https) + + # notification helper +# self.notify_broadcast = Notify('mitter').notify + + # start auto refresh activity + +# self._refresh_id = None +# self.set_auto_refresh() + +# self.window.set_focus(self.update_text) + return + + def __call__(self): + """Call function; displays the interface. This method should appear on + every interface.""" + + self._main_window.show_all() + + gtk.main() diff --git a/mitterlib/ui/ui_tty.py b/mitterlib/ui/ui_tty.py new file mode 100644 index 0000000..ca6f01f --- /dev/null +++ b/mitterlib/ui/ui_tty.py @@ -0,0 +1,130 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Mitter, a Maemo client for Twitter. +# Copyright (C) 2007, 2008 Julio Biason, Deepak Sarda +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +NAMESPACE = 'tty' + +import mitterlib.ui.console_utils as console_utils +import logging +from mitterlib.network.networkbase import NetworkError +from mitterlib.network import NetworksNoNetworkSetupError, NetworksError + +_log = logging.getLogger('ui.tty') + + +def options(options): + """Add command line options for this interface.""" + options.add_group(NAMESPACE, 'TTY interface') # This surely needs a + # better description + options.add_option('--messages', + group=NAMESPACE, + option='messages', + help='Display the latest messages.', + default=False, + action='store_true', + is_config_option=False, + conflict_group='interface') + options.add_option('--update', + group=NAMESPACE, + option='update', + default=None, + help='Update your status', + metavar='STATUS', + type='str', + is_config_option=False, + conflict_group='interface') + options.add_option('--replies', + group=NAMESPACE, + option='replies', + help='Get a list of replies instead of the friends timeline', + default=False, + action='store_true', + is_config_option=False, + conflict_group='interface') + return + + +class Interface(object): + """The console/tty interface for Mitter.""" + + # ----------------------------------------------------------------------- + # Private functions + # ----------------------------------------------------------------------- + + def _config(self): + """Set up the networks.""" + options = self._connection.settings() + console_utils.authorization(options, self._options) + return + + def _messages(self): + """Show the latest messages.""" + try: + data = self._connection.messages() + console_utils.print_messages(data, self._connection) + except NetworksNoNetworkSetupError: + # call the config + self._config() + except NetworkError: + print 'Network failure. Try again in a few minutes.' + return + + def _replies(self): + """Show the replies to you.""" + try: + data = self._connection.replies() + console_utils.print_messages(data, self._connection) + except NetworksNoNetworkSetupError: + # call the config + self._config() + except NetworkError: + print 'Network failure. Try again in a few minutes.' + return + + def _update(self, message): + """Update your status.""" + try: + self._connection.update(message) + except (NetworksError, NetworkError): + print 'Error updating your status. Try again in a few minutes.' + + return + + + # ----------------------------------------------------------------------- + # Methods required by the main Mitter code + # ----------------------------------------------------------------------- + + def __init__(self, connection, options): + """Class initialization.""" + self._connection = connection + self._options = options + + def __call__(self): + """The callable function, used by mitter to start the interface.""" + status_message = self._options[NAMESPACE]['update'] + if status_message: + self._update(status_message) + return + + if self._options[NAMESPACE]['replies']: + self._replies() + return + + self._messages() + return diff --git a/mitterlib/ui/utils.py b/mitterlib/ui/utils.py new file mode 100644 index 0000000..d0efebb --- /dev/null +++ b/mitterlib/ui/utils.py @@ -0,0 +1,31 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Mitter, a Maemo client for Twitter. +# Copyright (C) 2007, 2008 Julio Biason, Deepak Sarda +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +def str_len(s): + """Get length of string which may be unicode or encoded""" + + return len(to_unicode(s)) + + +def to_unicode(s, encoding='utf-8'): + """Converts string to unicode if it isn't one already""" + + if not isinstance(s, unicode): + return unicode(s, encoding) diff --git a/pixmaps/icon_star_empty.gif b/pixmaps/icon_star_empty.gif new file mode 100644 index 0000000000000000000000000000000000000000..9fb641f7f06016d9edb9b5790d6e5c2586d30e3c GIT binary patch literal 13319 zcmeI3e_Rt~9LEoVFfg2;6l8RqW?*c0w~ZgWwh@k|sF=V?A}WsU1{Z8MZ$}$wrI}hu zrbS6bxhP6XN-9b+GEA%}$;f_DQBhLS56o3$RP&A>8~BI4o zot>Q>9UblM?QLyst*xysEiKK>%}q^Bjg5^SkEg!AzOJsWwzgL6hD?1Dd=UjXE72@k zpv7-(hTB1#%NYSJVMSD4Jz$DhWdTP*NmGsY!JF!SvzcNXuKSR9=>Gd`keXT#mJ11A*(?(ZxXYQ;&)z%#VK@g|O|(2pJ8$nuPf zAz!7HDyKk!RRi^m_=*_4x7azbsAtPqrnESyw72Ax)(!&z8s)kJx^SSu$~CW4^I zG)g&9J_#Bx*W!|hNn%E=OJ_Q5{{Mto%zj^vO1@(BwOGtJQ^D96yWkX^kyD*n%#@Ym zZM2|gZFDJvm$FtZ9+*7i{>FAK#zKrG?$71K*8Q*YtPBF)&r18Trf?A;xlk9>1!MpS zhUtR3fD8b^FkMg=kO3eVrVHu@c4wrb3BgpsP zzyFN@!Wfo_?zF z$tM~f_w0P^(fUUoeyDE8gAdf+f8X|P_inwXX3O1o-MM+w#yd9LzJA?px8AaLO?8#q zb#tZQ=Lbn%ThTwiou;k5;;uDRNjpO?GxsuelQmo3d+ve<(E^~&Lv%att`D_r&EE%jxt3ni}eg)U36(^BBF z>~YrSIqP;gYxg?O?r@yl>aGuToZjK6+3r3cR)$&k7hk(Q6o%M$MWut&uXzFj}QcVkoZd>Bcd%17TBT*j&#G)H7$8 z&cL7|KFL!OLJ=woP|9^uVFrd~ zb%r>dE=}IK{YbTeG3gAcMl>u|DD_m{2ldR_Q_pZm#K=89cm&BKL~$M~1SKRB@2T&E z9w=AnRO;kacgusgS{@;cBM6EVQvy*}`8`lqxfJ(^xJOKfbIQaDrL7X{Ypbx z$FZoCIFrU`7%gk$oRRlCQz@xrok2shaeCYZ*SIxzOizVKj%ezbO zKFUYZ=<)tYhVL8{P68ws>Vmp}3;@9}T~HU00U#Kr3+e(g00hHyL0v!wfMA#|s0+vd z5De1=bpaUwf?>L#E+7LyFiaQJ1!MpShUtR3fD8b^FkMg=kO3eVrVHuNJ>o5Fs z-cnb4?(CVJ6ie2k&5!ezn2{TYmcShwnFkx9Qt&Hh%rpmtTDT*@jO)`S_#tAAXSY{(JAflfCZkx3V(d zd?RD+n%7sq_UbDyzm&epY(S#84(^fZ*FLaTqZ>*Nsu@&KqM591kUIA&zU`I=8Wmnrut#N z=#uU~!yq(sT9Rh;?&s#(DjSOqem}Sx_Tuk-#wn6|Jpv tD&lT?E#Tl{c+2#OvDJE>p@quVx-(CfaJUMI?wlO*`DLE<10NO!YXIH>Km`B* literal 0 HcmV?d00001 diff --git a/pixmaps/mitter-new.png b/pixmaps/mitter-new.png new file mode 100644 index 0000000000000000000000000000000000000000..e0ce729d584a3439a2c5b251570b5991b5a0a8da GIT binary patch literal 1079 zcmV-71jze|P)X-txIw08@%0LaSBkqECB!DSvz7z@zEF{!w6H-Zxc#3UfwWoDaY_A>CMC^Cnv(R3 zq&FmOl5~fp6Ox7`MM|PXpFf%C(|uL-AOA+ueX9w0l8#B5)#`R#QncXB#2M-9gQ=f> zMp=$ylDzr+mWlYlN#JRK@lf|8a%#0^4E8(gW0b`VG zh=LETW(3XKA#yM^c%Gl%|CsA>GB0!p_)t*zWIW=t$&f_gtYOF5^O2S+-RIxJjeY?I z>YfKYpb8z4(HT2zE{+-c?KFdzUbSpD{XowLqaoj3iSTXZ8YbCu>-d-K9DSQyFuO!Z zbI=wUUB$bg=j98m-FUZ2x(E2N|5_Zp_gA>8$dX2@3~^xab&56&LDs`jDt2zoA-41! z<=#?I1@3pi?pOq;rXupr@U89zXKczlnmxm_g)!fZnf%QJQA9p6ys}|)hLsJg8j)+` z)U=320POaG_VG|$iCM6lb%D{lAxDd6%qHUGNW@Sj!5V6|tvo9B8fl497$M( z7`n%Q;McCF7!Sqzz}g+aSSWH1{LIb4zE+hkBkvX#t9GpOoEa^Dd$zrCt^&uyipFXlh{cMy(tU zU_;I@OQEK$t2%Nik;Gg8=*$^`t22R1-(}1-NdMzEFlK4)no!AuJw->SZ*=;`-lAho zx~duH@fa`_80>Fg_7@#NnnTXxFi>6NU~dDnr|2ko_3?0I$+e$aEU7nR4oeDG7#nY9 z%nOo|W#W;fBlBXt*$}H_{*$DGi{{lXN#9E%0;X2t{Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007bV*G`2iFV% z0SFiLYiX+h00Rd}L_t(Y$L*HQYg|4Bstk_M7~mh`2hmn6L^>1#>%B;}GyNk!z> zC0#hqz)SjE(w>fw$5H&a*o#`e*0*R9fOX&#fbC4&-Oc%ZzhqcI$HAGlOs<2L{w&Vc*$wcBH#LFC9UI@&`G|oArzv!_kroZT! zi&>cW#^r@D!6dHIuG2{g1ZVh0PNvh?c1AE8uX+aK8;muICqPw_!HiKIlweaEVpL3U z4Q6W3%qAIYd?H~4IOtfF`yodd)l3xmKgNKe#!O{|(esX06iJVoEpRe8<{}ejFumYt zPw3d#$Q!i%c$uvuK1idQsgDC4{%ho7xz|kF7;T3GSWOJG3TnlQsn4sj;8%c^#7Nzk zNnQ0UGLN8oCo~wdN_%9~^Khx_SP8~TF#27`iE36)oaX~zkQ!WRFju;cc)rSct^$WA z9P}H^rLLpr$L-k4W+fHP@Q`$bdzli0|y0*{lZH1$w}rW~L*?0ahAXg9$DI&-%CE d`M=TA$G<%6>F*Er%Z>m5002ovPDHLkV1f#)v7rC} literal 0 HcmV?d00001 diff --git a/pixmaps/reply.png b/pixmaps/reply.png new file mode 100644 index 0000000000000000000000000000000000000000..37b120b0acdf92e5c3cf033e92e8b4498c2ce46d GIT binary patch literal 47144 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 literal 0 HcmV?d00001 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..675fc7b --- /dev/null +++ b/setup.py @@ -0,0 +1,42 @@ +from mitterlib.constants import version + +params = { + 'name': 'mitter', + 'version': version, + 'description': 'Update your Twitter status', + 'author': 'Julio Biason', + 'author_email': 'julio@juliobiason.net', + 'url': 'http://code.google.com/p/mitter/', + 'scripts': ['mitter'], + 'packages': [ + 'mitterlib', + 'mitterlib.ui'], + 'data_files': [ + ('share/pixmaps', + ['pixmaps/mitter.png', + 'pixmaps/mitter-new.png', + 'pixmaps/unknown.png'])], + 'license': 'GPL3', + 'download_url': \ + 'http://mitter.googlecode.com/files/mitter-%s.tar.gz' % (version), + 'classifiers': [ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Environment :: X11 Applications :: GTK', + 'Intended Audience :: End Users/Desktop', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Topic :: Communications :: Chat']} + +from distutils.core import setup +from distutils.version import StrictVersion + +import sys +version_number = '.'.join([str(a) for a in sys.version_info[:3]]) + +if StrictVersion(version_number) < StrictVersion('2.6'): + params['requires'] = ['simplejson'] + +# this bit should be the same for both systems +setup(**params) diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..11b3896 --- /dev/null +++ b/tests.py @@ -0,0 +1,72 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +import unittest +import logging +import cgi + +from mitterlib.network import twitter +from mitterlib.configopt import ConfigOpt + + +def _request(self, resource, headers=None, body=None): + logging.debug('Resource: %s') + logging.debug('Headers: %s') + logging.debug('body: %s') + + return {} + + +def _request_update(self, resource, headers=None, body=None): + """Monkey-patch request for update. Returns a random dictionary for + NetworkData.""" + # body = urllib.urlencode(dict) + body = cgi.parse_qs(body) + result = { + 'id': 1, + 'user': { + 'name': 'Test', + 'screen_name': 'test', + 'profile_image_url': None}, + 'created_at': 'Tue Mar 13 00:12:41 +0000 2007', + 'text': body['status'][0]} # Go figure... + return result + + +class TwitterEncodingTests(unittest.TestCase): + + def setUp(self): + # Generate a set of options required for starting the Twitter + # connection. + options = ConfigOpt() + twitter.Connection.options(options) + self.connection = twitter.Connection(options) + # we don't call options(), so it won't load the options in the config + # file. If tests need to test specific options, they can change + # options directly. + + def test_twitter_unhtml(self): + """Test the _unhtml() function inside the Twitter.""" + + + text = u'RT @fisl10: Abertas as inscrições para o ' \ + 'fisl10 http://tinyurl.com/cqrdsc' + result = u'RT @fisl10: Abertas as inscrições para o fisl10 ' \ + 'http://tinyurl.com/cqrdsc' + + self.assertEqual(result, twitter._unhtml(text)) + + def test_unicode(self): + """Test if sending unicode messages breaks the system.""" + text = 'fisl10: Abertas as inscrições para o fisl10 ' \ + 'http://tinyurl.com/cqrdsc' + + twitter.Connection._request = _request_update + try: + self.connection.update(text) + except UnicodeEncodeError: + self.fail('UnicodeEncodeError') + + +if __name__ == '__main__': + unittest.main() diff --git a/utils/pep8.py b/utils/pep8.py new file mode 100644 index 0000000..ac3a39b --- /dev/null +++ b/utils/pep8.py @@ -0,0 +1,863 @@ +#!/usr/bin/python +# pep8.py - Check Python source code formatting, according to PEP 8 +# Copyright (C) 2006 Johann C. Rocholl +# +# 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. + +""" +Check Python source code formatting, according to PEP 8: +http://www.python.org/dev/peps/pep-0008/ + +For usage and a list of options, try this: +$ python pep8.py -h + +This program and its regression test suite live here: +http://svn.browsershots.org/trunk/devtools/pep8/ +http://trac.browsershots.org/browser/trunk/devtools/pep8/ + +Groups of errors and warnings: +E errors +W warnings +100 indentation +200 whitespace +300 blank lines +400 imports +500 line length +600 deprecation +700 statements + +You can add checks to this program by writing plugins. Each plugin is +a simple function that is called for each line of source code, either +physical or logical. + +Physical line: +- Raw line of text from the input file. + +Logical line: +- Multi-line statements converted to a single line. +- Stripped left and right. +- Contents of strings replaced with 'xxx' of same length. +- Comments removed. + +The check function requests physical or logical lines by the name of +the first argument: + +def maximum_line_length(physical_line) +def extraneous_whitespace(logical_line) +def blank_lines(logical_line, blank_lines, indent_level, line_number) + +The last example above demonstrates how check plugins can request +additional information with extra arguments. All attributes of the +Checker object are available. Some examples: + +lines: a list of the raw lines from the input file +tokens: the tokens that contribute to this logical line +line_number: line number in the input file +blank_lines: blank lines before this one +indent_char: first indentation character in this file (' ' or '\t') +indent_level: indentation (with tabs expanded to multiples of 8) +previous_indent_level: indentation on previous line +previous_logical: previous logical line + +The docstring of each check function shall be the relevant part of +text from PEP 8. It is printed if the user enables --show-pep8. + +""" + +import os +import sys +import re +import time +import inspect +import tokenize +from optparse import OptionParser +from keyword import iskeyword +from fnmatch import fnmatch + +__version__ = '0.2.0' +__revision__ = '$Rev$' + +default_exclude = '.svn,CVS,*.pyc,*.pyo' + +indent_match = re.compile(r'([ \t]*)').match +raise_comma_match = re.compile(r'raise\s+\w+\s*(,)').match + +operators = """ ++ - * / % ^ & | = < > >> << ++= -= *= /= %= ^= &= |= == <= >= >>= <<= +!= <> : +in is or not and +""".split() + +options = None +args = None + + +############################################################################## +# Plugins (check functions) for physical lines +############################################################################## + + +def tabs_or_spaces(physical_line, indent_char): + """ + Never mix tabs and spaces. + + The most popular way of indenting Python is with spaces only. The + second-most popular way is with tabs only. Code indented with a mixture + of tabs and spaces should be converted to using spaces exclusively. When + invoking the Python command line interpreter with the -t option, it issues + warnings about code that illegally mixes tabs and spaces. When using -tt + these warnings become errors. These options are highly recommended! + """ + indent = indent_match(physical_line).group(1) + for offset, char in enumerate(indent): + if char != indent_char: + return offset, "E101 indentation contains mixed spaces and tabs" + + +def tabs_obsolete(physical_line): + """ + For new projects, spaces-only are strongly recommended over tabs. Most + editors have features that make this easy to do. + """ + indent = indent_match(physical_line).group(1) + if indent.count('\t'): + return indent.index('\t'), "W191 indentation contains tabs" + + +def trailing_whitespace(physical_line): + """ + JCR: Trailing whitespace is superfluous. + """ + physical_line = physical_line.rstrip('\n') # chr(10), newline + physical_line = physical_line.rstrip('\r') # chr(13), carriage return + physical_line = physical_line.rstrip('\x0c') # chr(12), form feed, ^L + stripped = physical_line.rstrip() + if physical_line != stripped: + return len(stripped), "W291 trailing whitespace" + + +def trailing_blank_lines(physical_line, lines, line_number): + """ + JCR: Trailing blank lines are superfluous. + """ + if physical_line.strip() == '' and line_number == len(lines): + return 0, "W391 blank line at end of file" + + +def missing_newline(physical_line): + """ + JCR: The last line should have a newline. + """ + if physical_line.rstrip() == physical_line: + return len(physical_line), "W292 no newline at end of file" + + +def maximum_line_length(physical_line): + """ + Limit all lines to a maximum of 79 characters. + + There are still many devices around that are limited to 80 character + lines; plus, limiting windows to 80 characters makes it possible to have + several windows side-by-side. The default wrapping on such devices looks + ugly. Therefore, please limit all lines to a maximum of 79 characters. + For flowing long blocks of text (docstrings or comments), limiting the + length to 72 characters is recommended. + """ + length = len(physical_line.rstrip()) + if length > 79: + return 79, "E501 line too long (%d characters)" % length + + +############################################################################## +# Plugins (check functions) for logical lines +############################################################################## + + +def blank_lines(logical_line, blank_lines, indent_level, line_number, + previous_logical): + """ + Separate top-level function and class definitions with two blank lines. + + Method definitions inside a class are separated by a single blank line. + + Extra blank lines may be used (sparingly) to separate groups of related + functions. Blank lines may be omitted between a bunch of related + one-liners (e.g. a set of dummy implementations). + + Use blank lines in functions, sparingly, to indicate logical sections. + """ + if line_number == 1: + return # Don't expect blank lines before the first line + if previous_logical.startswith('@'): + return # Don't expect blank lines after function decorator + if (logical_line.startswith('def ') or + logical_line.startswith('class ') or + logical_line.startswith('@')): + if indent_level > 0 and blank_lines != 1: + return 0, "E301 expected 1 blank line, found %d" % blank_lines + if indent_level == 0 and blank_lines != 2: + return 0, "E302 expected 2 blank lines, found %d" % blank_lines + if blank_lines > 2: + return 0, "E303 too many blank lines (%d)" % blank_lines + + +def extraneous_whitespace(logical_line): + """ + Avoid extraneous whitespace in the following situations: + + - Immediately inside parentheses, brackets or braces. + + - Immediately before a comma, semicolon, or colon. + """ + line = logical_line + for char in '([{': + found = line.find(char + ' ') + if found > -1: + return found + 1, "E201 whitespace after '%s'" % char + for char in '}])': + found = line.find(' ' + char) + if found > -1 and line[found - 1] != ',': + return found, "E202 whitespace before '%s'" % char + for char in ',;:': + found = line.find(' ' + char) + if found > -1: + return found, "E203 whitespace before '%s'" % char + + +def missing_whitespace(logical_line): + """ + JCR: Each comma, semicolon or colon should be followed by whitespace. + """ + line = logical_line + for index in range(len(line) - 1): + char = line[index] + if char in ',;:' and line[index + 1] != ' ': + before = line[:index] + if char == ':' and before.count('[') > before.count(']'): + continue # Slice syntax, no space required + return index, "E231 missing whitespace after '%s'" % char + + +def indentation(logical_line, previous_logical, indent_char, + indent_level, previous_indent_level): + """ + Use 4 spaces per indentation level. + + For really old code that you don't want to mess up, you can continue to + use 8-space tabs. + """ + if indent_char == ' ' and indent_level % 4: + return 0, "E111 indentation is not a multiple of four" + indent_expect = previous_logical.endswith(':') + if indent_expect and indent_level <= previous_indent_level: + return 0, "E112 expected an indented block" + if indent_level > previous_indent_level and not indent_expect: + return 0, "E113 unexpected indentation" + + +def whitespace_before_parameters(logical_line, tokens): + """ + Avoid extraneous whitespace in the following situations: + + - Immediately before the open parenthesis that starts the argument + list of a function call. + + - Immediately before the open parenthesis that starts an indexing or + slicing. + """ + prev_type = tokens[0][0] + prev_text = tokens[0][1] + prev_end = tokens[0][3] + for index in range(1, len(tokens)): + token_type, text, start, end, line = tokens[index] + if (token_type == tokenize.OP and + text in '([' and + start != prev_end and + prev_type == tokenize.NAME and + (index < 2 or tokens[index - 2][1] != 'class') and + (not iskeyword(prev_text))): + return prev_end, "E211 whitespace before '%s'" % text + prev_type = token_type + prev_text = text + prev_end = end + + +def whitespace_around_operator(logical_line): + """ + Avoid extraneous whitespace in the following situations: + + - More than one space around an assignment (or other) operator to + align it with another. + """ + line = logical_line + for operator in operators: + found = line.find(' ' + operator) + if found > -1: + return found, "E221 multiple spaces before operator" + found = line.find(operator + ' ') + if found > -1: + return found, "E222 multiple spaces after operator" + found = line.find('\t' + operator) + if found > -1: + return found, "E223 tab before operator" + found = line.find(operator + '\t') + if found > -1: + return found, "E224 tab after operator" + + +def whitespace_around_comma(logical_line): + """ + Avoid extraneous whitespace in the following situations: + + - More than one space around an assignment (or other) operator to + align it with another. + + JCR: This should also be applied around comma etc. + """ + line = logical_line + for separator in ',;:': + found = line.find(separator + ' ') + if found > -1: + return found + 1, "E241 multiple spaces after '%s'" % separator + found = line.find(separator + '\t') + if found > -1: + return found + 1, "E242 tab after '%s'" % separator + + +def imports_on_separate_lines(logical_line): + """ + Imports should usually be on separate lines. + """ + line = logical_line + if line.startswith('import '): + found = line.find(',') + if found > -1: + return found, "E401 multiple imports on one line" + + +def compound_statements(logical_line): + """ + Compound statements (multiple statements on the same line) are + generally discouraged. + """ + line = logical_line + found = line.find(':') + if -1 < found < len(line) - 1: + before = line[:found] + if (before.count('{') <= before.count('}') and # {'a': 1} (dict) + before.count('[') <= before.count(']') and # [1:2] (slice) + not re.search(r'\blambda\b', before)): # lambda x: x + return found, "E701 multiple statements on one line (colon)" + found = line.find(';') + if -1 < found: + return found, "E702 multiple statements on one line (semicolon)" + + +def python_3000_has_key(logical_line): + """ + The {}.has_key() method will be removed in the future version of + Python. Use the 'in' operation instead, like: + d = {"a": 1, "b": 2} + if "b" in d: + print d["b"] + """ + pos = logical_line.find('.has_key(') + if pos > -1: + return pos, "W601 .has_key() is deprecated, use 'in'" + + +def python_3000_raise_comma(logical_line): + """ + When raising an exception, use "raise ValueError('message')" + instead of the older form "raise ValueError, 'message'". + + The paren-using form is preferred because when the exception arguments + are long or include string formatting, you don't need to use line + continuation characters thanks to the containing parentheses. The older + form will be removed in Python 3000. + """ + match = raise_comma_match(logical_line) + if match: + return match.start(1), "W602 deprecated form of raising exception" + + +############################################################################## +# Helper functions +############################################################################## + + +def expand_indent(line): + """ + Return the amount of indentation. + Tabs are expanded to the next multiple of 8. + + >>> expand_indent(' ') + 4 + >>> expand_indent('\\t') + 8 + >>> expand_indent(' \\t') + 8 + >>> expand_indent(' \\t') + 8 + >>> expand_indent(' \\t') + 16 + """ + result = 0 + for char in line: + if char == '\t': + result = result / 8 * 8 + 8 + elif char == ' ': + result += 1 + else: + break + return result + + +############################################################################## +# Framework to run all checks +############################################################################## + + +def message(text): + """Print a message.""" + # print >> sys.stderr, options.prog + ': ' + text + # print >> sys.stderr, text + print text + + +def find_checks(argument_name): + """ + Find all globally visible functions where the first argument name + starts with argument_name. + """ + checks = [] + function_type = type(find_checks) + for name, function in globals().iteritems(): + if type(function) is function_type: + args = inspect.getargspec(function)[0] + if len(args) >= 1 and args[0].startswith(argument_name): + checks.append((name, function, args)) + checks.sort() + return checks + + +def mute_string(text): + """ + Replace contents with 'xxx' to prevent syntax matching. + + >>> mute_string('"abc"') + '"xxx"' + >>> mute_string("'''abc'''") + "'''xxx'''" + >>> mute_string("r'abc'") + "r'xxx'" + """ + start = 1 + end = len(text) - 1 + # String modifiers (e.g. u or r) + if text.endswith('"'): + start += text.index('"') + elif text.endswith("'"): + start += text.index("'") + # Triple quotes + if text.endswith('"""') or text.endswith("'''"): + start += 2 + end -= 2 + return text[:start] + 'x' * (end - start) + text[end:] + + +class Checker: + """ + Load a Python source file, tokenize it, check coding style. + """ + + def __init__(self, filename): + self.filename = filename + self.lines = file(filename).readlines() + self.physical_checks = find_checks('physical_line') + self.logical_checks = find_checks('logical_line') + options.counters['physical lines'] = \ + options.counters.get('physical lines', 0) + len(self.lines) + + def readline(self): + """ + Get the next line from the input buffer. + """ + self.line_number += 1 + if self.line_number > len(self.lines): + return '' + return self.lines[self.line_number - 1] + + def readline_check_physical(self): + """ + Check and return the next physical line. This method can be + used to feed tokenize.generate_tokens. + """ + line = self.readline() + if line: + self.check_physical(line) + return line + + def run_check(self, check, argument_names): + """ + Run a check plugin. + """ + arguments = [] + for name in argument_names: + arguments.append(getattr(self, name)) + return check(*arguments) + + def check_physical(self, line): + """ + Run all physical checks on a raw input line. + """ + self.physical_line = line + if self.indent_char is None and len(line) and line[0] in ' \t': + self.indent_char = line[0] + for name, check, argument_names in self.physical_checks: + result = self.run_check(check, argument_names) + if result is not None: + offset, text = result + self.report_error(self.line_number, offset, text, check) + + def build_tokens_line(self): + """ + Build a logical line from tokens. + """ + self.mapping = [] + logical = [] + length = 0 + previous = None + for token in self.tokens: + token_type, text = token[0:2] + if token_type in (tokenize.COMMENT, tokenize.NL, + tokenize.INDENT, tokenize.DEDENT, + tokenize.NEWLINE): + continue + if token_type == tokenize.STRING: + text = mute_string(text) + if previous: + end_line, end = previous[3] + start_line, start = token[2] + if end_line != start_line: # different row + if self.lines[end_line - 1][end - 1] not in '{[(': + logical.append(' ') + length += 1 + elif end != start: # different column + fill = self.lines[end_line - 1][end:start] + logical.append(fill) + length += len(fill) + self.mapping.append((length, token)) + logical.append(text) + length += len(text) + previous = token + self.logical_line = ''.join(logical) + assert self.logical_line.lstrip() == self.logical_line + assert self.logical_line.rstrip() == self.logical_line + + def check_logical(self): + """ + Build a line from tokens and run all logical checks on it. + """ + options.counters['logical lines'] = \ + options.counters.get('logical lines', 0) + 1 + self.build_tokens_line() + first_line = self.lines[self.mapping[0][1][2][0] - 1] + indent = first_line[:self.mapping[0][1][2][1]] + self.previous_indent_level = self.indent_level + self.indent_level = expand_indent(indent) + if options.verbose >= 2: + print self.logical_line[:80].rstrip() + for name, check, argument_names in self.logical_checks: + if options.verbose >= 3: + print ' ', name + result = self.run_check(check, argument_names) + if result is not None: + offset, text = result + if type(offset) is tuple: + original_number, original_offset = offset + else: + for token_offset, token in self.mapping: + if offset >= token_offset: + original_number = token[2][0] + original_offset = (token[2][1] + + offset - token_offset) + self.report_error(original_number, original_offset, + text, check) + self.previous_logical = self.logical_line + + def check_all(self): + """ + Run all checks on the input file. + """ + self.file_errors = 0 + self.line_number = 0 + self.indent_char = None + self.indent_level = 0 + self.previous_logical = '' + self.blank_lines = 0 + self.tokens = [] + parens = 0 + for token in tokenize.generate_tokens(self.readline_check_physical): + # print tokenize.tok_name[token[0]], repr(token) + self.tokens.append(token) + token_type, text = token[0:2] + if token_type == tokenize.OP and text in '([{': + parens += 1 + if token_type == tokenize.OP and text in '}])': + parens -= 1 + if token_type == tokenize.NEWLINE and not parens: + self.check_logical() + self.blank_lines = 0 + self.tokens = [] + if token_type == tokenize.NL and not parens: + self.blank_lines += 1 + self.tokens = [] + if token_type == tokenize.COMMENT: + source_line = token[4] + token_start = token[2][1] + if source_line[:token_start].strip() == '': + self.blank_lines = 0 + return self.file_errors + + def report_error(self, line_number, offset, text, check): + """ + Report an error, according to options. + """ + if options.quiet == 1 and not self.file_errors: + message(self.filename) + self.file_errors += 1 + code = text[:4] + options.counters[code] = options.counters.get(code, 0) + 1 + options.messages[code] = text[5:] + if options.quiet: + return + if options.testsuite: + base = os.path.basename(self.filename)[:4] + if base == code: + return + if base[0] == 'E' and code[0] == 'W': + return + if ignore_code(code): + return + if options.counters[code] == 1 or options.repeat: + message("%s:%s:%d: %s" % + (self.filename, line_number, offset + 1, text)) + if options.show_source: + line = self.lines[line_number - 1] + message(line.rstrip()) + message(' ' * offset + '^') + if options.show_pep8: + message(check.__doc__.lstrip('\n').rstrip()) + + +def input_file(filename): + """ + Run all checks on a Python source file. + """ + if excluded(filename) or not filename_match(filename): + return {} + if options.verbose: + message('checking ' + filename) + options.counters['files'] = options.counters.get('files', 0) + 1 + errors = Checker(filename).check_all() + if options.testsuite and not errors: + message("%s: %s" % (filename, "no errors found")) + + +def input_dir(dirname): + """ + Check all Python source files in this directory and all subdirectories. + """ + dirname = dirname.rstrip('/') + if excluded(dirname): + return + for root, dirs, files in os.walk(dirname): + if options.verbose: + message('directory ' + root) + options.counters['directories'] = \ + options.counters.get('directories', 0) + 1 + dirs.sort() + for subdir in dirs: + if excluded(subdir): + dirs.remove(subdir) + files.sort() + for filename in files: + input_file(os.path.join(root, filename)) + + +def excluded(filename): + """ + Check if options.exclude contains a pattern that matches filename. + """ + basename = os.path.basename(filename) + for pattern in options.exclude: + if fnmatch(basename, pattern): + # print basename, 'excluded because it matches', pattern + return True + + +def filename_match(filename): + """ + Check if options.filename contains a pattern that matches filename. + If options.filename is unspecified, this always returns True. + """ + if not options.filename: + return True + for pattern in options.filename: + if fnmatch(filename, pattern): + return True + + +def ignore_code(code): + """ + Check if options.ignore contains a prefix of the error code. + """ + for ignore in options.ignore: + if code.startswith(ignore): + return True + + +def get_error_statistics(): + """Get error statistics.""" + return get_statistics("E") + + +def get_warning_statistics(): + """Get warning statistics.""" + return get_statistics("W") + + +def get_statistics(prefix=''): + """ + Get statistics for message codes that start with the prefix. + + prefix='' matches all errors and warnings + prefix='E' matches all errors + prefix='W' matches all warnings + prefix='E4' matches all errors that have to do with imports + """ + stats = [] + keys = options.messages.keys() + keys.sort() + for key in keys: + if key.startswith(prefix): + stats.append('%-7s %s %s' % + (options.counters[key], key, options.messages[key])) + return stats + + +def print_statistics(prefix=''): + """Print overall statistics (number of errors and warnings).""" + for line in get_statistics(prefix): + print line + + +def print_benchmark(elapsed): + """ + Print benchmark numbers. + """ + print '%-7.2f %s' % (elapsed, 'seconds elapsed') + keys = ['directories', 'files', + 'logical lines', 'physical lines'] + for key in keys: + if key in options.counters: + print '%-7d %s per second (%d total)' % ( + options.counters[key] / elapsed, key, + options.counters[key]) + + +def process_options(arglist=None): + """ + Process options passed either via arglist or via command line args. + """ + global options, args + usage = "%prog [options] input ..." + parser = OptionParser(usage) + parser.add_option('-v', '--verbose', default=0, action='count', + help="print status messages, or debug with -vv") + parser.add_option('-q', '--quiet', default=0, action='count', + help="report only file names, or nothing with -qq") + parser.add_option('--exclude', metavar='patterns', default=default_exclude, + help="skip matches (default %s)" % default_exclude) + parser.add_option('--filename', metavar='patterns', + help="only check matching files (e.g. *.py)") + parser.add_option('--ignore', metavar='errors', default='', + help="skip errors and warnings (e.g. E4,W)") + parser.add_option('--repeat', action='store_true', + help="show all occurrences of the same error") + parser.add_option('--show-source', action='store_true', + help="show source code for each error") + parser.add_option('--show-pep8', action='store_true', + help="show text of PEP 8 for each error") + parser.add_option('--statistics', action='store_true', + help="count errors and warnings") + parser.add_option('--benchmark', action='store_true', + help="measure processing speed") + parser.add_option('--testsuite', metavar='dir', + help="run regression tests from dir") + parser.add_option('--doctest', action='store_true', + help="run doctest on myself") + options, args = parser.parse_args(arglist) + if options.testsuite: + args.append(options.testsuite) + if len(args) == 0: + parser.error('input not specified') + options.prog = os.path.basename(sys.argv[0]) + options.exclude = options.exclude.split(',') + for index in range(len(options.exclude)): + options.exclude[index] = options.exclude[index].rstrip('/') + if options.filename: + options.filename = options.filename.split(',') + if options.ignore: + options.ignore = options.ignore.split(',') + else: + options.ignore = [] + options.counters = {} + options.messages = {} + + return options, args + + +def _main(): + """ + Parse options and run checks on Python source. + """ + options, args = process_options() + if options.doctest: + import doctest + return doctest.testmod() + start_time = time.time() + for path in args: + if os.path.isdir(path): + input_dir(path) + else: + input_file(path) + elapsed = time.time() - start_time + if options.statistics: + print_statistics() + if options.benchmark: + print_benchmark(elapsed) + + +if __name__ == '__main__': + _main()