From 9286296f46d5281bb4e1a4b576140f2f4770a66c Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 22:48:35 +0100 Subject: [PATCH 001/106] COPYING: add GPL v3+ --- docs/COPYING | 674 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 docs/COPYING diff --git a/docs/COPYING b/docs/COPYING new file mode 100644 index 00000000..94a9ed02 --- /dev/null +++ b/docs/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 +. From b4fdf0ae30e0847a759c01524898d0b9d803c43b Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Mon, 15 Mar 2010 15:47:15 +0100 Subject: [PATCH 002/106] initial revision. See README for what works --HG-- extra : transplant_source : %FBQ%B3%C5%0E%85%E8%0B%154%F5t%19%2A%C6p%A5%1Dd%F1 --- .hgignore | 2 + README | 18 ++++++ cnotmuch/__init__.py | 3 + cnotmuch/database.py | 147 +++++++++++++++++++++++++++++++++++++++++++ cnotmuch/globals.py | 38 +++++++++++ cnotmuch/notmuch.py | 12 ++++ 6 files changed, 220 insertions(+) create mode 100644 .hgignore create mode 100644 README create mode 100644 cnotmuch/__init__.py create mode 100644 cnotmuch/database.py create mode 100644 cnotmuch/globals.py create mode 100644 cnotmuch/notmuch.py diff --git a/.hgignore b/.hgignore new file mode 100644 index 00000000..c4aa446d --- /dev/null +++ b/.hgignore @@ -0,0 +1,2 @@ +.*\.pyc$ +~$ \ No newline at end of file diff --git a/README b/README new file mode 100644 index 00000000..3b96e648 --- /dev/null +++ b/README @@ -0,0 +1,18 @@ +>>>from cnotmuch import notmuch +>>>db = notmuch.Database("/home/spaetz/mail") +>>>db.get_path() +>>>tags = db.get_all_tags() +>>>for tag in tags: +>>> print tag + +#--------------------------------------------- + +>>>db = notmuch.Database("/home/spaetz/mailHAHA") +NotmuchError: Could not open the specified database + +#--------------------------------------------- + +>>>tags = notmuch.Database("/home/spaetz/mail").get_all_tags() +>>>del(tags) +Freeing the Tags now +Freeing the database now \ No newline at end of file diff --git a/cnotmuch/__init__.py b/cnotmuch/__init__.py new file mode 100644 index 00000000..e24dc966 --- /dev/null +++ b/cnotmuch/__init__.py @@ -0,0 +1,3 @@ +__version__=0.1 +__author__ ="Sebastian Spaeth " +#ctypes.util.find_library("notmuch") diff --git a/cnotmuch/database.py b/cnotmuch/database.py new file mode 100644 index 00000000..f3082e9a --- /dev/null +++ b/cnotmuch/database.py @@ -0,0 +1,147 @@ +import ctypes +from ctypes import c_int, c_char_p, c_void_p +from cnotmuch.globals import nmlib, STATUS, NotmuchError + + +class Database(object): + """ Wrapper around a notmuch_database_t + + Do note that as soon as we tear down this object, all derived queries, + threads, and messages will be freed as well. + """ + #constants + MODE_READ_ONLY = 0 + MODE_READ_WRITE = 1 + + _std_db_path = None + """Class attribute of users default database""" + + """notmuch_database_get_path (notmuch_database_t *database)""" + _get_path = nmlib.notmuch_database_get_path + _get_path.restype = c_char_p + + """notmuch_database_open (const char *path, notmuch_database_mode_t mode)""" + _open = nmlib.notmuch_database_open + _open.restype = c_void_p + + """notmuch_database_get_all_tags (notmuch_database_t *database)""" + _get_all_tags = nmlib.notmuch_database_get_all_tags + _get_all_tags.restype = c_void_p + + class notmuch_database_t(ctypes.Structure): + """the opaque database that is returned by functions.""" + pass + + def __init__(self, path=None, create=False, status= MODE_READ_ONLY): + """ Open or create a notmuch database""" + self._db = None + if create == False: + self.open(path, status) + else: + #TODO: implement + raise NotmuchError(message="Not implemented yet") + + #TODO: make a proper function + create=nmlib.notmuch_database_create + """ notmuch_database_create(const char *path):""" + + def open(self, path=None, status= MODE_READ_ONLY): + """calls notmuch_database_open + + If path is None, we will try to read a users notmuch configuration and + use his default database. + :returns: Raises :exc:`notmuch.NotmuchError` in case + of any failure (after printing an error message on stderr). + """ + if path is None: + if Database._std_db_path is None: + from ConfigParser import SafeConfigParser + import os.path + config = SafeConfigParser() + config.read(os.path.expanduser('~/.notmuch-config')) + if not config.has_option('database','path'): + raise NotmuchError(message= + "No DB path specified and no user default found") + Database._std_db_path=config.get('database','path') + path = Database._std_db_path + + res = Database._open(path, status) + + if res is None: + raise NotmuchError( + message="Could not open the specified database") + self._db = res + + def get_path(self): + """notmuch_database_get_path (notmuch_database_t *database); """ + return Database._get_path(self._db) + + #TODO:implement + #If no message is found with the given message_id or if an + #out-of-memory situation occurs, this function returns NULL. + #notmuch_message_t * + #notmuch_database_find_message (notmuch_database_t *database, + # const char *message_id); + + def get_all_tags(self): + """Return a Tags() object (list of all tags found in the database) + + :returns: Tags() object or raises :exc:`NotmuchError` with + STATUS.NULL_POINTER on error + """ + tags_p = Database._get_all_tags (self._db) + if tags_p == None: + raise NotmuchError(STATUS.NULL_POINTER) + return Tags(tags_p, self) + + def __repr__(self): + return "'Notmuch DB " + self.get_path() + "'" + + def __del__(self): + """Close and free the notmuch database if needed""" + if self._db is not None: + print("Freeing the database now") + nmlib.notmuch_database_close(self._db) + + @property + def db_p(self): + """Returns a pointer to the current notmuch_database_t or None""" + return self._db + + +#------------------------------------------------------------------------------ +class Tags(object): + """Wrapper around notmuch_tags_t""" + class notmuch_tags_t(ctypes.Structure): + """the opaque tags struct that is returned by functions.""" + pass + + #notmuch_tags_get + _get = nmlib.notmuch_tags_get + _get.restype = c_char_p + + def __init__(self, tags_p, db=None): + """ Is passed the db these tags are derived from, and saves a + reference to it, so we can automatically delete the db object + once all derived objects are dead.""" + self._tags = tags_p + self._db = db + print "inited tags with %d %s" %(tags_p, str(db)) + + def __iter__(self): + """ Make Tags an iterator """ + return self + + def next(self): + if self._tags is None: + raise StopIteration + nmlib.notmuch_tags_move_to_next(self._tags) + if not nmlib.notmuch_tags_valid(self._tags): + raise StopIteration + return Tags._get (self._tags) + + def __del__(self): + """Close and free the notmuch tags""" + if self._tags is not None: + print("Freeing the Tags now") + nmlib.notmuch_tags_destroy (self._tags) diff --git a/cnotmuch/globals.py b/cnotmuch/globals.py new file mode 100644 index 00000000..ff765167 --- /dev/null +++ b/cnotmuch/globals.py @@ -0,0 +1,38 @@ +from ctypes import CDLL, c_char_p, c_int +#package-global instance of the notmuch library +#TODO: lazy load this on first access? +nmlib = CDLL('/usr/local/lib/libnotmuch.so') + +class STATUS(object): + SUCCESS = 0 + OUT_OF_MEMORY = 1 + READ_ONLY_DATABASE = 2 + XAPIAN_EXCEPTION = 3 + FILE_ERROR = 4 + FILE_NOT_EMAIL = 5 + DUPLICATE_MESSAGE_ID = 6 + NULL_POINTER = 7 + TAG_TOO_LONG = 8 + UNBALANCED_FREEZE_THAW = 9 + LAST_STATUS = 10 + + """Get a string representation of a notmuch_status_t value.""" + status2str = nmlib.notmuch_status_to_string + status2str.restype = c_char_p + status2str.argtypes = [c_int] + + def __init__(self, status): + self._status = status + + def __str__(self): + """Get a string representation of a notmuch_status_t value.""" + return str(STATUS.status2str(self._status)) + +class NotmuchError(Exception): + def __init__(self, status=None, message=None): + """Is initiated with a (notmuch.STATUS[,message=None])""" + super(NotmuchError, self).__init__(message, status) + + def __str__(self): + if self.args[0] is not None: return self.args[0] + else: return str(STATUS(self.args[1])) diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py new file mode 100644 index 00000000..8400f37c --- /dev/null +++ b/cnotmuch/notmuch.py @@ -0,0 +1,12 @@ +import ctypes +from ctypes import c_int, c_char_p +from database import Database,Tags +from cnotmuch.globals import nmlib,STATUS,NotmuchError + +# 114 typedef struct _notmuch_query notmuch_query_t; +# 115 typedef struct _notmuch_threads notmuch_threads_t; +# 116 typedef struct _notmuch_thread notmuch_thread_t; +# 117 typedef struct _notmuch_messages notmuch_messages_t; +# 118 typedef struct _notmuch_message notmuch_message_t; +# 120 typedef struct _notmuch_directory notmuch_directory_t; +# 121 typedef struct _notmuch_filenames notmuch_filenames_t; From 632762cd1af790875c69e8dad4c2221af4b80663 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Mon, 15 Mar 2010 16:00:11 +0100 Subject: [PATCH 003/106] Tags: clarify documentation: we can only iterate once over Tags, and free underlying tags once we iterated through. --HG-- extra : transplant_source : %0BI%C9%899k%80%8C4j%07%85%038%86%04%2A%FE%A6%A3 --- cnotmuch/database.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index f3082e9a..2df3b4cb 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -123,7 +123,14 @@ class Tags(object): def __init__(self, tags_p, db=None): """ Is passed the db these tags are derived from, and saves a reference to it, so we can automatically delete the db object - once all derived objects are dead.""" + once all derived objects are dead. + + Tags() provides an iterator over all contained tags. However, you will + only be able to iterate over the Tags once, because the underlying C + function only allows iterating once. + #TODO: make the iterator work more than once and cache the tags in + the Python object. + """ self._tags = tags_p self._db = db print "inited tags with %d %s" %(tags_p, str(db)) @@ -137,6 +144,8 @@ class Tags(object): raise StopIteration nmlib.notmuch_tags_move_to_next(self._tags) if not nmlib.notmuch_tags_valid(self._tags): + print("Freeing the Tags now") + nmlib.notmuch_tags_destroy (self._tags) raise StopIteration return Tags._get (self._tags) From 90c47567f4a165a62420a79da9a7f5b2ef8addce Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Mon, 15 Mar 2010 17:12:52 +0100 Subject: [PATCH 004/106] improve README example session to reflect reality --HG-- extra : transplant_source : %15P%B8E%1A%CE%0D%DE%CEB%E0/%E04%2B%3F%255%A1%F9 --- README | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README b/README index 3b96e648..3fbdab1b 100644 --- a/README +++ b/README @@ -1,9 +1,15 @@ >>>from cnotmuch import notmuch >>>db = notmuch.Database("/home/spaetz/mail") ->>>db.get_path() +db.get_path() +'/home/spaetz/mail' >>>tags = db.get_all_tags() +inited tags with 44762960 'Notmuch DB /home/spaetz/mail' >>>for tag in tags: >>> print tag +inbox +... +maildir::draft +Freeing the Tags now #--------------------------------------------- From 55f1971437550aa382d0c2347a7be62dddcaa761 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 11:27:31 +0100 Subject: [PATCH 005/106] Implement Message(), Database.find_message(), Database.create() Message() basically has get_message_id get_filename get_tags Plus various bullet proofing and bug fixing. --HG-- extra : transplant_source : O%3B1%EB%E0%D4pYrEY_%E3%0E%BA%C23%11a%B0 --- cnotmuch/database.py | 183 ++++++++++++++++++++++++++++++++++--------- cnotmuch/globals.py | 5 +- 2 files changed, 151 insertions(+), 37 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 2df3b4cb..10251c42 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -24,47 +24,57 @@ class Database(object): _open = nmlib.notmuch_database_open _open.restype = c_void_p + """ notmuch_database_find_message """ + _find_message = nmlib.notmuch_database_find_message + _find_message.restype = c_void_p + """notmuch_database_get_all_tags (notmuch_database_t *database)""" _get_all_tags = nmlib.notmuch_database_get_all_tags _get_all_tags.restype = c_void_p - class notmuch_database_t(ctypes.Structure): - """the opaque database that is returned by functions.""" - pass + """ notmuch_database_create(const char *path):""" + _create = nmlib.notmuch_database_create + _create.restype = c_void_p def __init__(self, path=None, create=False, status= MODE_READ_ONLY): - """ Open or create a notmuch database""" - self._db = None - if create == False: - self.open(path, status) - else: - #TODO: implement - raise NotmuchError(message="Not implemented yet") - - #TODO: make a proper function - create=nmlib.notmuch_database_create - """ notmuch_database_create(const char *path):""" - - def open(self, path=None, status= MODE_READ_ONLY): - """calls notmuch_database_open + """ Open or create a notmuch database If path is None, we will try to read a users notmuch configuration and use his default database. + Throws a NotmuchError in case of failure. + """ + self._db = None + if path is None: + # no path specified. use a user's default database + if Database._std_db_path is None: + #the following line throws a NotmuchError if it fails + Database._std_db_path = self._get_user_default_db() + path = Database._std_db_path + + if create == False: + self.open(path, status) + else: + self.create(path, status) + + def create(self, path, status=MODE_READ_ONLY): + """ notmuch_database_create(const char *path) + :returns: Raises :exc:`notmuch.NotmuchError` in case of any failure (after printing an error message on stderr). """ - if path is None: - if Database._std_db_path is None: - from ConfigParser import SafeConfigParser - import os.path - config = SafeConfigParser() - config.read(os.path.expanduser('~/.notmuch-config')) - if not config.has_option('database','path'): - raise NotmuchError(message= - "No DB path specified and no user default found") - Database._std_db_path=config.get('database','path') - path = Database._std_db_path + res = Database._create(path, status) + if res is None: + raise NotmuchError( + message="Could not create the specified database") + self._db = res + + def open(self, path, status= MODE_READ_ONLY): + """calls notmuch_database_open + + :returns: Raises :exc:`notmuch.NotmuchError` in case + of any failure (after printing an error message on stderr). + """ res = Database._open(path, status) if res is None: @@ -76,12 +86,20 @@ class Database(object): """notmuch_database_get_path (notmuch_database_t *database); """ return Database._get_path(self._db) - #TODO:implement - #If no message is found with the given message_id or if an - #out-of-memory situation occurs, this function returns NULL. - #notmuch_message_t * - #notmuch_database_find_message (notmuch_database_t *database, - # const char *message_id); + def find_message(self, msgid): + """notmuch_database_find_message + :param msgid: The message id + :ptype msgid: string + + :returns: Message() or None if no message is found or if an + out-of-memory situation occurs. + """ + if self._db is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + msg_p = Database._find_message(self._db, msgid) + if msg_p is None: + return None + return Message(msg_p, self) def get_all_tags(self): """Return a Tags() object (list of all tags found in the database) @@ -89,6 +107,9 @@ class Database(object): :returns: Tags() object or raises :exc:`NotmuchError` with STATUS.NULL_POINTER on error """ + if self._db is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + tags_p = Database._get_all_tags (self._db) if tags_p == None: raise NotmuchError(STATUS.NULL_POINTER) @@ -103,6 +124,19 @@ class Database(object): print("Freeing the database now") nmlib.notmuch_database_close(self._db) + def _get_user_default_db(self): + """ Reads a user's notmuch config and returns his db location + + Throws a NotmuchError if it cannot find it""" + from ConfigParser import SafeConfigParser + import os.path + config = SafeConfigParser() + config.read(os.path.expanduser('~/.notmuch-config')) + if not config.has_option('database','path'): + raise NotmuchError(message= + "No DB path specified and no user default found") + return config.get('database','path') + @property def db_p(self): """Returns a pointer to the current notmuch_database_t or None""" @@ -121,7 +155,11 @@ class Tags(object): _get.restype = c_char_p def __init__(self, tags_p, db=None): - """ Is passed the db these tags are derived from, and saves a + """ + msg_p is a pointer to an notmuch_message_t Structure. If it is None, + we will raise an NotmuchError(STATUS.NULL_POINTER). + + Is passed the db these tags are derived from, and saves a reference to it, so we can automatically delete the db object once all derived objects are dead. @@ -131,9 +169,12 @@ class Tags(object): #TODO: make the iterator work more than once and cache the tags in the Python object. """ + if tags_p is None: + NotmuchError(STATUS.NULL_POINTER) + self._tags = tags_p self._db = db - print "inited tags with %d %s" %(tags_p, str(db)) + print "Inited Tags derived from %s" %(str(db)) def __iter__(self): """ Make Tags an iterator """ @@ -154,3 +195,73 @@ class Tags(object): if self._tags is not None: print("Freeing the Tags now") nmlib.notmuch_tags_destroy (self._tags) + +#------------------------------------------------------------------------------ +class Message(object): + """Wrapper around notmuch_message_t""" + + """notmuch_message_get_filename (notmuch_message_t *message)""" + _get_filename = nmlib.notmuch_message_get_filename + _get_filename.restype = c_char_p + """notmuch_message_get_message_id (notmuch_message_t *message)""" + _get_message_id = nmlib.notmuch_message_get_message_id + _get_message_id.restype = c_char_p + + """notmuch_message_get_tags (notmuch_message_t *message)""" + _get_tags = nmlib.notmuch_message_get_tags + _get_tags.restype = c_void_p + + def __init__(self, msg_p, parent=None): + """ + msg_p is a pointer to an notmuch_message_t Structure. If it is None, + we will raise an NotmuchError(STATUS.NULL_POINTER). + + Is a 'parent' object is passed which this message is derived from, + we save a reference to it, so we can automatically delete the parent + object once all derived objects are dead. + """ + if msg_p is None: + NotmuchError(STATUS.NULL_POINTER) + self._msg = msg_p + self._parent = parent + print "Inited Message derived from %s" %(str(parent)) + + + def get_message_id(self): + """ return the msg id + + Raises NotmuchError(STATUS.NOT_INITIALIZED) if not inited + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Message._get_message_id(self._msg) + + + def get_filename(self): + """ return the msg filename + + Raises NotmuchError(STATUS.NOT_INITIALIZED) if not inited + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Message._get_filename(self._msg) + + def get_tags(self): + """ return the msg tags + + Raises NotmuchError(STATUS.NOT_INITIALIZED) if not inited + Raises NotmuchError(STATUS.NULL_POINTER) on error. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + tags_p = Message._get_tags(self._msg) + if tags_p == None: + raise NotmuchError(STATUS.NULL_POINTER) + return Tags(tags_p, self) + + def __del__(self): + """Close and free the notmuch Message""" + if self._msg is not None: + print("Freeing the Message now") + nmlib.notmuch_message_destroy (self._msg) diff --git a/cnotmuch/globals.py b/cnotmuch/globals.py index ff765167..553670a4 100644 --- a/cnotmuch/globals.py +++ b/cnotmuch/globals.py @@ -14,7 +14,7 @@ class STATUS(object): NULL_POINTER = 7 TAG_TOO_LONG = 8 UNBALANCED_FREEZE_THAW = 9 - LAST_STATUS = 10 + NOT_INITIALIZED = 10 """Get a string representation of a notmuch_status_t value.""" status2str = nmlib.notmuch_status_to_string @@ -26,6 +26,9 @@ class STATUS(object): def __str__(self): """Get a string representation of a notmuch_status_t value.""" + # define strings for custom error messages + if self._status == STATUS.NOT_INITIALIZED: + return "Operation on uninitialized DB/MSG/THREAD impossible." return str(STATUS.status2str(self._status)) class NotmuchError(Exception): From 338e68e545bd101c140bfad81f136cc8b74566b3 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 11:52:56 +0100 Subject: [PATCH 006/106] Find the notmuch shared lib rather than hardode a location --HG-- extra : transplant_source : %DEOdF%80%83%3A%3D%98%5C%AE%81%FE%BA%EF%1A%16%82u%E8 --- cnotmuch/globals.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cnotmuch/globals.py b/cnotmuch/globals.py index 553670a4..611c29ed 100644 --- a/cnotmuch/globals.py +++ b/cnotmuch/globals.py @@ -1,7 +1,14 @@ from ctypes import CDLL, c_char_p, c_int +from ctypes.util import find_library + +#----------------------------------------------------------------------------- #package-global instance of the notmuch library #TODO: lazy load this on first access? -nmlib = CDLL('/usr/local/lib/libnotmuch.so') +so = find_library('notmuch') +if so is None: + raise ImportError("Could not find shared 'notmuch' library.") +nmlib = CDLL(so) +#----------------------------------------------------------------------------- class STATUS(object): SUCCESS = 0 From ce50b33cf0150a69a3a7499937eaba9447cc75a6 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 12:21:18 +0100 Subject: [PATCH 007/106] add notmuch stub that will allow me to run the notmuch testsuite over the python bindings. --HG-- extra : transplant_source : %E4%EF%29%A3%9BA%C57%7C%EC%B0%7BF%FB%00%F0%EB%97%92%E3 --- notmuch | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100755 notmuch diff --git a/notmuch b/notmuch new file mode 100755 index 00000000..2f65ae49 --- /dev/null +++ b/notmuch @@ -0,0 +1,137 @@ +#!/usr/bin/env python +"""This is a notmuch implementation in python. It's goal is to allow running the test suite on the cnotmuch python bindings.""" +import sys +#------------------------------------------------------------------------- +HELPTEXT="""The notmuch mail system. + +Usage: notmuch [args...] + +Where and [args...] are as follows: + + setup Interactively setup notmuch for first use. + + new [--verbose] + + Find and import new messages to the notmuch database. + + search [options...] [...] + + Search for messages matching the given search terms. + + show [...] + + Show all messages matching the search terms. + + count [...] + + Count messages matching the search terms. + + reply [options...] [...] + + Construct a reply template for a set of messages. + + tag +|- [...] [--] [...] + + Add/remove tags for all messages matching the search terms. + + dump [] + + Create a plain-text dump of the tags for each message. + + restore + + Restore the tags from the given dump file (see 'dump'). + + search-tags [ [...] ] + + List all tags found in the database or matching messages. + + help [] + + This message, or more detailed help for the named command. + +Use "notmuch help " for more details on each command. +And "notmuch help search-terms" for the common search-terms syntax. +""" + +#TODO: replace the dynamic pieces +USAGE="""Notmuch is configured and appears to have a database. Excellent! + +At this point you can start exploring the functionality of notmuch by +using commands such as: + + notmuch search tag:inbox + + notmuch search to:"Sebastian Spaeth" + + notmuch search from:"Sebastian@SSpaeth.de" + + notmuch search subject:"my favorite things" + +See "notmuch help search" for more details. + +You can also use "notmuch show" with any of the thread IDs resulting +from a search. Finally, you may want to explore using a more sophisticated +interface to notmuch such as the emacs interface implemented in notmuch.el +or any other interface described at http://notmuchmail.org + +And don't forget to run "notmuch new" whenever new mail arrives. + +Have fun, and may your inbox never have much mail. +""" +#------------------------------------------------------------------------- +if __name__ == '__main__': + + # Handle command line options + # No option + if len(sys.argv) == 1: + print USAGE + + elif sys.argv[1] == 'setup': + """ Interactively setup notmuch for first use. """ + print "Not implemented." + + elif sys.argv[1] == 'help': + if len(sys.argv) == 2: print HELPTEXT + else: print "Not implemented" + + else: + # unknown command + print "Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1] + + + #TODO: implement + """ +new [--verbose] + + Find and import new messages to the notmuch database. + +search [options...] [...] + + Search for messages matching the given search terms. + +show [...] + + Show all messages matching the search terms. + +count [...] + + Count messages matching the search terms. + +reply [options...] [...] + + Construct a reply template for a set of messages. + +tag +|- [...] [--] [...] + + Add/remove tags for all messages matching the search terms. + +dump [] + + Create a plain-text dump of the tags for each message. + +restore + search-tags [ [...] ] + + List all tags found in the database or matching messages. + """ From 5cb8dbeab5d168d9a1abef93a46158b62ae07462 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 12:24:52 +0100 Subject: [PATCH 008/106] nonfunctional changes in notmuch stub --HG-- extra : transplant_source : %F2%28%18m%91%F5%DA%08%17%AC/%9E%C2%AE%95%91%92%C4%A3%7F --- notmuch | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/notmuch b/notmuch index 2f65ae49..e30fccd6 100755 --- a/notmuch +++ b/notmuch @@ -1,6 +1,8 @@ #!/usr/bin/env python """This is a notmuch implementation in python. It's goal is to allow running the test suite on the cnotmuch python bindings.""" import sys +from cnotmuch import notmuch + #------------------------------------------------------------------------- HELPTEXT="""The notmuch mail system. @@ -53,7 +55,7 @@ Where and [args...] are as follows: Use "notmuch help " for more details on each command. And "notmuch help search-terms" for the common search-terms syntax. """ - +#------------------------------------------------------------------------- #TODO: replace the dynamic pieces USAGE="""Notmuch is configured and appears to have a database. Excellent! @@ -95,17 +97,16 @@ if __name__ == '__main__': if len(sys.argv) == 2: print HELPTEXT else: print "Not implemented" + elif sys.argv[1] == 'new': + #TODO: handle --verbose + print "Not implemented." else: # unknown command print "Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1] #TODO: implement - """ -new [--verbose] - - Find and import new messages to the notmuch database. - + """ search [options...] [...] Search for messages matching the given search terms. From 7b9ddcb6a63b29f7088674e1590224b484d8a11d Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 12:28:05 +0100 Subject: [PATCH 009/106] some more stubs --HG-- extra : transplant_source : %2A%C5%3C%C0u%B8%0DLx%3Eg%A2%06%F6%1E%B8%99C7%B3 --- notmuch | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/notmuch b/notmuch index e30fccd6..abd44854 100755 --- a/notmuch +++ b/notmuch @@ -1,7 +1,11 @@ #!/usr/bin/env python """This is a notmuch implementation in python. It's goal is to allow running the test suite on the cnotmuch python bindings.""" import sys -from cnotmuch import notmuch + +def init_notmuch(): + import os + from cnotmuch import notmuch + #TODO Handle variable: NOTMUCH-CONFIG #------------------------------------------------------------------------- HELPTEXT="""The notmuch mail system. From 0e90d656fc54cd5dfbc5879ad17dbee0bb3f4de2 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 12:39:47 +0100 Subject: [PATCH 010/106] Query() stub --HG-- extra : transplant_source : %1E%14%7B%E7J%DD%96O%C7%E6%B5%FB%D8V%7B%0F%7C%25ot --- cnotmuch/database.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 10251c42..82c4f2bc 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -142,6 +142,15 @@ class Database(object): """Returns a pointer to the current notmuch_database_t or None""" return self._db +#------------------------------------------------------------------------------ +class Query(object): + """ Wrapper around a notmuch_query_t + + Do note that as soon as we tear down this object, all derived + threads, and messages will be freed as well. + """ + def __init__(self, db, querystr): + pass #------------------------------------------------------------------------------ class Tags(object): From 9ed5e8a0e36ec13def4c783d7694654ac43963d3 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 12:40:20 +0100 Subject: [PATCH 011/106] remove useless tags struct --HG-- extra : transplant_source : %EF%CD%D8K%E5%E1F%95%1E%F9%F3%88%E7%B0%600%21Wwp --- cnotmuch/database.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 82c4f2bc..50c182bd 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -155,9 +155,6 @@ class Query(object): #------------------------------------------------------------------------------ class Tags(object): """Wrapper around notmuch_tags_t""" - class notmuch_tags_t(ctypes.Structure): - """the opaque tags struct that is returned by functions.""" - pass #notmuch_tags_get _get = nmlib.notmuch_tags_get From 92d3c24bc82ab33e6316e3ecf6ed947e8bf9ed99 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 14:28:05 +0100 Subject: [PATCH 012/106] implement Query() and search_messages() However, this is still brittle and often segfaults for unknown reasons --HG-- extra : transplant_source : %D1%FB%2B%FC%EA%E9%EB%3E%D2%13%DFV.o%D6%A6%CE%81%00%8C --- cnotmuch/database.py | 116 +++++++++++++++++++++++++++++++++++++++++-- cnotmuch/notmuch.py | 2 +- 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 50c182bd..93c727c6 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -149,8 +149,63 @@ class Query(object): Do note that as soon as we tear down this object, all derived threads, and messages will be freed as well. """ + # constants + SORT_OLDEST_FIRST = 0 + SORT_NEWEST_FIRST = 1 + SORT_MESSAGE_ID = 2 + + """notmuch_query_create""" + _create = nmlib.notmuch_query_create + _create.restype = c_void_p + + """notmuch_query_search_messages""" + _search_messages = nmlib.notmuch_query_search_messages + _search_messages.restype = c_void_p + def __init__(self, db, querystr): - pass + """TODO: document""" + self._db = None + self._query = None + self.create(db, querystr) + + def create(self, db, querystr): + """db is a Database() and querystr a string + + raises NotmuchError STATUS.NOT_INITIALIZED if db is not inited and + STATUS.NULL_POINTER if the query creation failed (too little memory) + """ + if db.db_p is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + # create reference to parent db to keep it alive + self._db = db + + # create query, return None if too little mem available + query_p = Query._create(db.db_p, querystr) + if query_p is None: + NotmuchError(STATUS.NULL_POINTER) + self._query = query_p + + + def search_messages(self): + """notmuch_query_search_messages + Returns Messages() or a raises a NotmuchError() + """ + if self._query is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + msgs_p = Query._search_messages(self._query) + + if msgs_p is None: + NotmuchError(STATUS.NULL_POINTER) + + return Messages(msgs_p,self) + + + def __del__(self): + """Close and free the Query""" + if self._query is not None: + print("Freeing the Query now") + nmlib.notmuch_query_destroy (self._query) #------------------------------------------------------------------------------ class Tags(object): @@ -184,11 +239,11 @@ class Tags(object): def __iter__(self): """ Make Tags an iterator """ + if self._tags is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) return self def next(self): - if self._tags is None: - raise StopIteration nmlib.notmuch_tags_move_to_next(self._tags) if not nmlib.notmuch_tags_valid(self._tags): print("Freeing the Tags now") @@ -202,6 +257,61 @@ class Tags(object): print("Freeing the Tags now") nmlib.notmuch_tags_destroy (self._tags) + +#------------------------------------------------------------------------------ +class Messages(object): + """Wrapper around notmuch_messages_t""" + + #notmuch_tags_get + _get = nmlib.notmuch_messages_get + _get.restype = c_void_p + + def __init__(self, msgs_p, parent=None): + """ + msg_p is a pointer to an notmuch_messages_t Structure. If it is None, + we will raise an NotmuchError(STATUS.NULL_POINTER). + + If passed the parent query this Messages() is derived from, it saves a + reference to it, so we can automatically delete the parent query object + once all derived objects are dead. + + Messages() provides an iterator over all contained Message()s. + However, you will only be able to iterate over it once, + because the underlying C function only allows iterating once. + #TODO: make the iterator work more than once and cache the tags in + the Python object. + """ + if msgs_p is None: + NotmuchError(STATUS.NULL_POINTER) + + self._msgs = msgs_p + #store parent, so we keep them alive as long as self is alive + self._parent = parent + print "Inited Messages derived from %s" %(str(parent)) + + def __iter__(self): + """ Make Messages an iterator """ + return self + + def next(self): + if self._msgs is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + nmlib.notmuch_messages_move_to_next(self._msgs) + if not nmlib.notmuch_messages_valid(self._msgs): + print("Freeing the Messages now") + nmlib.notmuch_messages_destroy (self._msgs) + self._msgs = None + raise StopIteration + return Message(Messages._get (self._msgs), self) + + def __del__(self): + """Close and free the notmuch Messages""" + if self._msgs is not None: + print("Freeing the Messages now") + nmlib.notmuch_messages_destroy (self._msgs) + + #------------------------------------------------------------------------------ class Message(object): """Wrapper around notmuch_message_t""" diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py index 8400f37c..86e1b71c 100644 --- a/cnotmuch/notmuch.py +++ b/cnotmuch/notmuch.py @@ -1,6 +1,6 @@ import ctypes from ctypes import c_int, c_char_p -from database import Database,Tags +from database import Database,Tags,Query from cnotmuch.globals import nmlib,STATUS,NotmuchError # 114 typedef struct _notmuch_query notmuch_query_t; From c54b2683cdb15197284f936b3da4877c253912a3 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 14:45:01 +0100 Subject: [PATCH 013/106] Fixing the crashes that I was seeing --HG-- extra : transplant_source : %3E%B1%BB%DF%3C%22%A6%CC%90Z%E8v%95%962%DD%B2%A1%11%CC --- cnotmuch/database.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 93c727c6..d1329433 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -246,8 +246,7 @@ class Tags(object): def next(self): nmlib.notmuch_tags_move_to_next(self._tags) if not nmlib.notmuch_tags_valid(self._tags): - print("Freeing the Tags now") - nmlib.notmuch_tags_destroy (self._tags) + self._tags = None raise StopIteration return Tags._get (self._tags) @@ -299,8 +298,6 @@ class Messages(object): nmlib.notmuch_messages_move_to_next(self._msgs) if not nmlib.notmuch_messages_valid(self._msgs): - print("Freeing the Messages now") - nmlib.notmuch_messages_destroy (self._msgs) self._msgs = None raise StopIteration return Message(Messages._get (self._msgs), self) @@ -339,6 +336,7 @@ class Message(object): if msg_p is None: NotmuchError(STATUS.NULL_POINTER) self._msg = msg_p + #keep reference to parent, so we keep it alive self._parent = parent print "Inited Message derived from %s" %(str(parent)) From bb5870b9af7d023d7d61233dc19d73772d84fdc5 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 14:57:07 +0100 Subject: [PATCH 014/106] use logging.debug for debug output. Implement notmuch search-tags --HG-- extra : transplant_source : %BAn%2B%93B%1EkU8%A6-I%5D%E1%E4%2B%D6E%0C%F5 --- cnotmuch/database.py | 18 +++++++++--------- notmuch | 16 ++++++++++------ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index d1329433..cdcd31c0 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -1,7 +1,7 @@ import ctypes from ctypes import c_int, c_char_p, c_void_p from cnotmuch.globals import nmlib, STATUS, NotmuchError - +import logging class Database(object): """ Wrapper around a notmuch_database_t @@ -121,7 +121,7 @@ class Database(object): def __del__(self): """Close and free the notmuch database if needed""" if self._db is not None: - print("Freeing the database now") + logging.debug("Freeing the database now") nmlib.notmuch_database_close(self._db) def _get_user_default_db(self): @@ -204,7 +204,7 @@ class Query(object): def __del__(self): """Close and free the Query""" if self._query is not None: - print("Freeing the Query now") + logging.debug("Freeing the Query now") nmlib.notmuch_query_destroy (self._query) #------------------------------------------------------------------------------ @@ -235,7 +235,7 @@ class Tags(object): self._tags = tags_p self._db = db - print "Inited Tags derived from %s" %(str(db)) + logging.debug("Inited Tags derived from %s" %(str(db))) def __iter__(self): """ Make Tags an iterator """ @@ -253,7 +253,7 @@ class Tags(object): def __del__(self): """Close and free the notmuch tags""" if self._tags is not None: - print("Freeing the Tags now") + logging.debug("Freeing the Tags now") nmlib.notmuch_tags_destroy (self._tags) @@ -286,7 +286,7 @@ class Messages(object): self._msgs = msgs_p #store parent, so we keep them alive as long as self is alive self._parent = parent - print "Inited Messages derived from %s" %(str(parent)) + logging.debug("Inited Messages derived from %s" %(str(parent))) def __iter__(self): """ Make Messages an iterator """ @@ -305,7 +305,7 @@ class Messages(object): def __del__(self): """Close and free the notmuch Messages""" if self._msgs is not None: - print("Freeing the Messages now") + logging.debug("Freeing the Messages now") nmlib.notmuch_messages_destroy (self._msgs) @@ -338,7 +338,7 @@ class Message(object): self._msg = msg_p #keep reference to parent, so we keep it alive self._parent = parent - print "Inited Message derived from %s" %(str(parent)) + logging.debug("Inited Message derived from %s" %(str(parent))) def get_message_id(self): @@ -377,5 +377,5 @@ class Message(object): def __del__(self): """Close and free the notmuch Message""" if self._msg is not None: - print("Freeing the Message now") + logging.debug("Freeing the Message now") nmlib.notmuch_message_destroy (self._msg) diff --git a/notmuch b/notmuch index abd44854..c89777e3 100755 --- a/notmuch +++ b/notmuch @@ -1,11 +1,8 @@ #!/usr/bin/env python """This is a notmuch implementation in python. It's goal is to allow running the test suite on the cnotmuch python bindings.""" -import sys - -def init_notmuch(): - import os - from cnotmuch import notmuch - #TODO Handle variable: NOTMUCH-CONFIG +import sys, os +from cnotmuch.notmuch import Database, Query +#TODO Handle variable: NOTMUCH-CONFIG #------------------------------------------------------------------------- HELPTEXT="""The notmuch mail system. @@ -104,6 +101,13 @@ if __name__ == '__main__': elif sys.argv[1] == 'new': #TODO: handle --verbose print "Not implemented." + + elif sys.argv[1] == 'search-tags': + if len(sys.argv) == 2: + print("\n".join(Database().get_all_tags())) + + else: print "Not implemented" + else: # unknown command print "Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1] From e026813bcb623bff70895a353aeef90364f66795 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 15:00:42 +0100 Subject: [PATCH 015/106] Fix iterator classes to not skip the first element when iterating --HG-- extra : transplant_source : %19wvB%19A%0A%CD%E7%28-%F0%12j%7FG%0DD%16%F4 --- cnotmuch/database.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index cdcd31c0..82f1372c 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -239,16 +239,19 @@ class Tags(object): def __iter__(self): """ Make Tags an iterator """ - if self._tags is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) return self def next(self): - nmlib.notmuch_tags_move_to_next(self._tags) + if self._tags is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + if not nmlib.notmuch_tags_valid(self._tags): self._tags = None raise StopIteration - return Tags._get (self._tags) + + tag = Tags._get (self._tags) + nmlib.notmuch_tags_move_to_next(self._tags) + return tag def __del__(self): """Close and free the notmuch tags""" @@ -296,11 +299,13 @@ class Messages(object): if self._msgs is None: raise NotmuchError(STATUS.NOT_INITIALIZED) - nmlib.notmuch_messages_move_to_next(self._msgs) if not nmlib.notmuch_messages_valid(self._msgs): self._msgs = None raise StopIteration - return Message(Messages._get (self._msgs), self) + + msg = Message(Messages._get (self._msgs), self) + nmlib.notmuch_messages_move_to_next(self._msgs) + return msg def __del__(self): """Close and free the notmuch Messages""" From d099b79fd1c4d1248a3221c9994be51bbff898e8 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 15:40:13 +0100 Subject: [PATCH 016/106] implement quoatation mangling in the notmuch binary --HG-- extra : transplant_source : %E2I%C6%0A%05%3B%F3%27%07%96%DC%D6%91%C3%FA%8E%1B%5B%2B%3D --- cnotmuch/database.py | 24 +++++++++++++++++++++++- notmuch | 25 ++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 82f1372c..8313813c 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -268,6 +268,9 @@ class Messages(object): _get = nmlib.notmuch_messages_get _get.restype = c_void_p + _collect_tags = nmlib.notmuch_messages_collect_tags + _collect_tags.restype = c_void_p + def __init__(self, msgs_p, parent=None): """ msg_p is a pointer to an notmuch_messages_t Structure. If it is None, @@ -290,7 +293,26 @@ class Messages(object): #store parent, so we keep them alive as long as self is alive self._parent = parent logging.debug("Inited Messages derived from %s" %(str(parent))) - + + def collect_tags(self): + """ return the Tags() belonging to the messages + + Do note that collect_tags will iterate over the messages and + therefore will not allow further iterationsl + Raises NotmuchError(STATUS.NOT_INITIALIZED) if not inited + """ + if self._msgs is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + # collect all tags (returns NULL on error) + tags_p = Messages._collect_tags (self._msgs) + #reset _msgs as we iterated over it and can do so only once + self._msgs = None + + if tags_p == None: + raise NotmuchError(STATUS.NULL_POINTER) + return Tags(tags_p, self) + def __iter__(self): """ Make Messages an iterator """ return self diff --git a/notmuch b/notmuch index c89777e3..6f002aac 100755 --- a/notmuch +++ b/notmuch @@ -1,7 +1,8 @@ #!/usr/bin/env python """This is a notmuch implementation in python. It's goal is to allow running the test suite on the cnotmuch python bindings.""" -import sys, os +import sys, os, re, logging from cnotmuch.notmuch import Database, Query +PREFIX=re.compile('(\w+):(.*$)') #TODO Handle variable: NOTMUCH-CONFIG #------------------------------------------------------------------------- @@ -83,6 +84,18 @@ And don't forget to run "notmuch new" whenever new mail arrives. Have fun, and may your inbox never have much mail. """ #------------------------------------------------------------------------- +def quote_query_line(argv): + #mangle arguments wrapping terms with spaces in quotes + for i in xrange(0,len(argv)): + if argv[i].find(' ') >= 0: + #if we use prefix:termWithSpaces, put quotes around term + m = PREFIX.match(argv[i]) + if m: + argv[i] = '%s:"%s"' % (m.group(1), m.group(2)) + else: + argv[i] = '"'+argv[i]+'"' + return ' '.join(argv) + if __name__ == '__main__': # Handle command line options @@ -104,9 +117,15 @@ if __name__ == '__main__': elif sys.argv[1] == 'search-tags': if len(sys.argv) == 2: + #no further search term print("\n".join(Database().get_all_tags())) - - else: print "Not implemented" + else: + #mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[2:]) + logging.debug("search-term "+querystr) + db = Database() + m = Query(db,querystr).search_messages() + print("\n".join([t for t in m.collect_tags()])) else: # unknown command From 3b2d73c684501e2eb906542f5081125d4e9c485d Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 15:53:54 +0100 Subject: [PATCH 017/106] implement message counting --HG-- extra : transplant_source : %8E%83%C2%83%FA%F8X%B6%16%1D%D3X%C4o%A2%A8%28%11G%AF --- cnotmuch/database.py | 18 ++++++++++++++++++ notmuch | 11 +++++++++++ 2 files changed, 29 insertions(+) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 8313813c..19460b33 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -329,6 +329,24 @@ class Messages(object): nmlib.notmuch_messages_move_to_next(self._msgs) return msg + def __len__(self): + """ Returns the number of contained messages + + :note: As this iterates over the messages, we will not be able to + iterate over them again (as in retrieve them)! + """ + if self._msgs is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + i=0 + while nmlib.notmuch_messages_valid(self._msgs): + nmlib.notmuch_messages_move_to_next(self._msgs) + i += 1 + self._msgs = None + return i + + + def __del__(self): """Close and free the notmuch Messages""" if self._msgs is not None: diff --git a/notmuch b/notmuch index 6f002aac..56f961b4 100755 --- a/notmuch +++ b/notmuch @@ -115,6 +115,17 @@ if __name__ == '__main__': #TODO: handle --verbose print "Not implemented." + elif sys.argv[1] == 'count': + db = Database() + if len(sys.argv) == 2: + #no further search term + querystr='' + else: + #mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[2:]) + logging.debug("count "+querystr) + print(len(Query(db,querystr).search_messages())) + elif sys.argv[1] == 'search-tags': if len(sys.argv) == 2: #no further search term From 4081ed75b5e1d0315925bd2fcefb728b315f4f4b Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 16:09:42 +0100 Subject: [PATCH 018/106] implement notmuch dump and Messages.len() --HG-- extra : transplant_source : %03_%C7%99L%16%7E%A8%B1p%11%11%A0%E3h%A3%AB%B6%FB%86 --- cnotmuch/database.py | 7 +++++++ notmuch | 14 +++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 19460b33..8d6124cb 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -253,6 +253,13 @@ class Tags(object): nmlib.notmuch_tags_move_to_next(self._tags) return tag + def __str__(self): + """str() of Tags() is a space separated list of tags + + This iterates over the list of Tags and will therefore 'exhaust' Tags() + """ + return " ".join(self) + def __del__(self): """Close and free the notmuch tags""" if self._tags is not None: diff --git a/notmuch b/notmuch index 56f961b4..172464c1 100755 --- a/notmuch +++ b/notmuch @@ -135,9 +135,17 @@ if __name__ == '__main__': querystr = quote_query_line(sys.argv[2:]) logging.debug("search-term "+querystr) db = Database() - m = Query(db,querystr).search_messages() + q = Query(db,querystr) + #TODO: notmuch_query_set_sort (query, NOTMUCH_SORT_MESSAGE_ID); + m = q.search_messages() print("\n".join([t for t in m.collect_tags()])) + elif sys.argv[1] == 'dump': + db = Database() + msgs = Query(db,'').search_messages() + for msg in msgs: + print("%s (%s)" % (msg.get_message_id(), msg.get_tags())) + else: # unknown command print "Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1] @@ -153,10 +161,6 @@ show [...] Show all messages matching the search terms. -count [...] - - Count messages matching the search terms. - reply [options...] [...] Construct a reply template for a set of messages. From 95f259409ef48dbd31c3670a5e2fe288f05ba2a0 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 16:20:47 +0100 Subject: [PATCH 019/106] implement Query.set_sort() and sort notmuch dump by message id --HG-- extra : transplant_source : 1%BC%F3%ED%3C%C7c%0EFh%06%B6L%7C%8E%8F%EF%A2%8E%A8 --- cnotmuch/database.py | 10 ++++++++++ notmuch | 11 ++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 8d6124cb..09af0df9 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -185,6 +185,16 @@ class Query(object): NotmuchError(STATUS.NULL_POINTER) self._query = query_p + def set_sort(self, sort): + """notmuch_query_set_sort + + :param sort: one of Query.SORT_OLDEST_FIRST|SORT_NEWEST_FIRST|SORT_MESSAGE_ID + :returns: Nothing, but raises NotmuchError if query is not inited + """ + if self._query is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + nmlib.notmuch_query_set_sort(self._query, sort) def search_messages(self): """notmuch_query_search_messages diff --git a/notmuch b/notmuch index 172464c1..478aed3a 100755 --- a/notmuch +++ b/notmuch @@ -135,15 +135,16 @@ if __name__ == '__main__': querystr = quote_query_line(sys.argv[2:]) logging.debug("search-term "+querystr) db = Database() - q = Query(db,querystr) - #TODO: notmuch_query_set_sort (query, NOTMUCH_SORT_MESSAGE_ID); - m = q.search_messages() + m = Query(db,querystr).search_messages() print("\n".join([t for t in m.collect_tags()])) elif sys.argv[1] == 'dump': + #TODO: implement "dump " db = Database() - msgs = Query(db,'').search_messages() - for msg in msgs: + q = Query(db,'') + q.set_sort(Query.SORT_MESSAGE_ID) + m = q.search_messages() + for msg in m: print("%s (%s)" % (msg.get_message_id(), msg.get_tags())) else: From 7b032b6f87b40c3a4849cf7e889bf7a6975a0a57 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 16:41:33 +0100 Subject: [PATCH 020/106] implement stub for notmuch show. Remove finished ToDo items --HG-- extra : transplant_source : 2%0B%97h%40Af%07%DD%F2%5ESX%20%05%3D%C0%C5v%F8 --- cnotmuch/database.py | 14 ++++++++++---- notmuch | 32 +++++++++++++------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 09af0df9..1619e454 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -225,12 +225,12 @@ class Tags(object): _get = nmlib.notmuch_tags_get _get.restype = c_char_p - def __init__(self, tags_p, db=None): + def __init__(self, tags_p, parent=None): """ msg_p is a pointer to an notmuch_message_t Structure. If it is None, we will raise an NotmuchError(STATUS.NULL_POINTER). - Is passed the db these tags are derived from, and saves a + Is passed the parent these tags are derived from, and saves a reference to it, so we can automatically delete the db object once all derived objects are dead. @@ -244,8 +244,9 @@ class Tags(object): NotmuchError(STATUS.NULL_POINTER) self._tags = tags_p - self._db = db - logging.debug("Inited Tags derived from %s" %(str(db))) + #save reference to parent object so we keep it alive + self._parent = parent + logging.debug("Inited Tags derived from %s" %(repr(parent))) def __iter__(self): """ Make Tags an iterator """ @@ -436,6 +437,11 @@ class Message(object): raise NotmuchError(STATUS.NULL_POINTER) return Tags(tags_p, self) + def __str__(self): + """A message() is represented by a 1-line summary""" + tags = str(self.get_tags()) + return "TODO: FIXME Sebastian Krzyszkowiak (2009-08-27) (%s)" % (tags) + def __del__(self): """Close and free the notmuch Message""" if self._msg is not None: diff --git a/notmuch b/notmuch index 478aed3a..4e259c1c 100755 --- a/notmuch +++ b/notmuch @@ -111,6 +111,19 @@ if __name__ == '__main__': if len(sys.argv) == 2: print HELPTEXT else: print "Not implemented" + elif sys.argv[1] == 'show': + db = Database() + if len(sys.argv) == 2: + #no further search term + querystr='' + else: + #mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[2:]) + logging.debug("show "+querystr) + m = Query(db,querystr).search_messages() + for msg in m: + print("%s" % (msg)) + elif sys.argv[1] == 'new': #TODO: handle --verbose print "Not implemented." @@ -155,27 +168,8 @@ if __name__ == '__main__': #TODO: implement """ search [options...] [...] - - Search for messages matching the given search terms. - show [...] - - Show all messages matching the search terms. - reply [options...] [...] - - Construct a reply template for a set of messages. - tag +|- [...] [--] [...] - - Add/remove tags for all messages matching the search terms. - -dump [] - - Create a plain-text dump of the tags for each message. - restore - search-tags [ [...] ] - - List all tags found in the database or matching messages. """ From ee66e0f52c9902b9c4f4ce37eb04a89fa4356696 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 17:10:57 +0100 Subject: [PATCH 021/106] implement Message.get_date() and Message.get_header() --HG-- extra : transplant_source : %90%E9%A5Z%D1%E6%26%1F%D3%8B%CCsbb%E9%17%FA%5CD%9F --- cnotmuch/database.py | 42 +++++++++++++++++++++++++++++++++++++++--- notmuch | 2 +- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 1619e454..8762f393 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -1,7 +1,8 @@ import ctypes -from ctypes import c_int, c_char_p, c_void_p +from ctypes import c_int, c_char_p, c_void_p, c_uint64 from cnotmuch.globals import nmlib, STATUS, NotmuchError import logging +from datetime import date class Database(object): """ Wrapper around a notmuch_database_t @@ -387,6 +388,12 @@ class Message(object): _get_tags = nmlib.notmuch_message_get_tags _get_tags.restype = c_void_p + _get_date = nmlib.notmuch_message_get_date + _get_date.restype = c_uint64 + + _get_header = nmlib.notmuch_message_get_header + _get_header.restype = c_char_p + def __init__(self, msg_p, parent=None): """ msg_p is a pointer to an notmuch_message_t Structure. If it is None, @@ -413,6 +420,28 @@ class Message(object): raise NotmuchError(STATUS.NOT_INITIALIZED) return Message._get_message_id(self._msg) + def get_date(self): + """returns time_t of the message date + + For the original textual representation of the Date header from the + message call notmuch_message_get_header() with a header value of + "date". + Raises NotmuchError(STATUS.NOT_INITIALIZED) if not inited + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Message._get_date(self._msg) + + def get_header(self, header): + """ TODO document me""" + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + #Returns NULL if any error occurs. + header = Message._get_header (self._msg, header) + if header == None: + raise NotmuchError(STATUS.NULL_POINTER) + return header def get_filename(self): """ return the msg filename @@ -439,8 +468,15 @@ class Message(object): def __str__(self): """A message() is represented by a 1-line summary""" - tags = str(self.get_tags()) - return "TODO: FIXME Sebastian Krzyszkowiak (2009-08-27) (%s)" % (tags) + msg = {} + msg['from'] = self.get_header('from') + msg['tags'] = str(self.get_tags()) + msg['date'] = date.fromtimestamp(self.get_date()) + return "%(from)s (%(date)s) (%(tags)s)" % (msg) + + def format_as_text(self): + """ Output like notmuch show """ + return str(self) def __del__(self): """Close and free the notmuch Message""" diff --git a/notmuch b/notmuch index 4e259c1c..de9d7b10 100755 --- a/notmuch +++ b/notmuch @@ -122,7 +122,7 @@ if __name__ == '__main__': logging.debug("show "+querystr) m = Query(db,querystr).search_messages() for msg in m: - print("%s" % (msg)) + print(msg.format_as_text()) elif sys.argv[1] == 'new': #TODO: handle --verbose From 5bcd897253559cb3fd7b224d75f85e8e0c026789 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 20:35:34 +0100 Subject: [PATCH 022/106] hgignore: update to ignore static documentation --HG-- extra : transplant_source : %9C%CCh%60z%FE%7B1%AC%E6%88%DF7%00%22%7D%09i%EDL --- .hgignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.hgignore b/.hgignore index c4aa446d..a6cc1733 100644 --- a/.hgignore +++ b/.hgignore @@ -1,2 +1,4 @@ .*\.pyc$ -~$ \ No newline at end of file +~$ +^docs/html +^docs/build \ No newline at end of file From 9182b97e269e510911a29c9e9c56a7c3bdd76ebf Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 20:36:04 +0100 Subject: [PATCH 023/106] add sphinx docs stubs (they don't make sense yet) --HG-- extra : transplant_source : %F3A%2C%D95%02%BF%1B%A1%D7%17%07%EDqh%23l8%93%20 --- docs/Makefile | 88 +++++++++++++++++++ docs/source/conf.py | 198 ++++++++++++++++++++++++++++++++++++++++++ docs/source/index.rst | 61 +++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..bd6de38c --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,88 @@ +# 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) source + +.PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf build/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) html + @echo + @echo "Build finished. The HTML pages are in html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) build/dirhtml + @echo + @echo "Build finished. The HTML pages are in build/dirhtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) build/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) build/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(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." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) build/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in build/qthelp, like this:" + @echo "# qcollectiongenerator build/qthelp/pyDNS.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile build/qthelp/pyDNS.qhc" + +latex: + $(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: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) build/changes + @echo + @echo "The overview file is in build/changes." + +linkcheck: + $(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." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) build/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in build/doctest/output.txt." diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 00000000..70b39fa5 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- +# +# pyDNS documentation build configuration file, created by +# sphinx-quickstart on Tue Feb 2 10:00:47 2010. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys, os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +sys.path.append(os.path.abspath('../..')) + +# -- General configuration ----------------------------------------------------- + +# 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', 'sphinx.ext.todo'] + +# 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'notmuch' +copyright = u'2010, Sebastian Spaeth' + +# 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 = '0.1' +# The full version, including alpha/beta/rc tags. +release = '0.1' + +# 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 = [] + +# 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 = False + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. Major themes that come with +# Sphinx are currently 'default' and 'sphinxdoc'. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = 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 = ['../html'] + +# 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 = False + +# 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, links to the reST sources are added to the pages. +#html_show_sourcelink = 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 = 'notmuchdoc' + + +# -- 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, documentclass [howto/manual]). +latex_documents = [ + ('index', 'notmuch.tex', u'notmuch Documentation', + u'notmuch contributors', '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/': None} diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 00000000..3c7399a2 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,61 @@ +.. notmuch documentation master file, created by + sphinx-quickstart on Tue Feb 2 10:00:47 2010. + +.. currentmodule:: notmuch.notmuch + +Welcome to notmuch's documentation! +=================================== + +The :mod:`notmuch` module provides an interface to the `notmuch `_ functionality. The main work horse of this module is the class :class:`Notmuch` with important other classes representing a :class:`Thread` and a single :class:`Message`. + +.. moduleauthor:: Sebastian Spaeth + +:License: This module is covered under the GNU GPL v2 (or later). + +This page contains the main API overview. More information on specific topics can be found on the following pages: + +.. toctree:: + :maxdepth: 1 + + + +:mod:`notmuch` -- The Notmuch interface +============================================= + +:class:`Message` -- A single email message +------------------------------------ + +.. autoclass:: Message + :members: + +:class:`Thread` -- Represents a message thread +----------------------------------------------- + +.. autoclass:: Thread + :members: + + .. note:: A Thread is what a call to notmuch.show() will return, containing a bunch of :class:`Message`\ s. + +:class:`Notmuch` -- A notmuch call +------------------------------------ + +.. autoclass:: Notmuch + :members: + + .. data: '__notmuchcmd__' + + This is the actual binary that will be executed in order to run a notmuch command. This is set to *notmuch* and should usually not be changed. + +:exc:`NotmuchError` -- A Notmuch execution error +------------------------------------------------ +.. autoexception:: NotmuchError + :members: + + This execption inherits directly from :exc:`Exception` and is raised on errors during the notmuch execution. + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`search` + From 422ba4becaf5a0accb5ce6e02f671d1f8706f455 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 16 Mar 2010 20:54:43 +0100 Subject: [PATCH 024/106] update documentation so it makes sense --HG-- extra : transplant_source : %3F%E9%23%2B%2A%83%06g%10R%CA%CD%27N5%3E%22g%1E%AD --- cnotmuch/notmuch.py | 2 +- docs/source/conf.py | 2 +- docs/source/index.rst | 49 +++++++++++++++++++++++++++++++------------ 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py index 86e1b71c..4bf593a7 100644 --- a/cnotmuch/notmuch.py +++ b/cnotmuch/notmuch.py @@ -1,6 +1,6 @@ import ctypes from ctypes import c_int, c_char_p -from database import Database,Tags,Query +from database import Database,Tags,Query,Messages,Message,Tags from cnotmuch.globals import nmlib,STATUS,NotmuchError # 114 typedef struct _notmuch_query notmuch_query_t; diff --git a/docs/source/conf.py b/docs/source/conf.py index 70b39fa5..4d8be533 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -37,7 +37,7 @@ source_suffix = '.rst' master_doc = 'index' # General information about the project. -project = u'notmuch' +project = u'cnotmuch' copyright = u'2010, Sebastian Spaeth' # The version info for the project you're documenting, acts as replacement for diff --git a/docs/source/index.rst b/docs/source/index.rst index 3c7399a2..e48c345a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,18 +1,27 @@ -.. notmuch documentation master file, created by +.. cnotmuch documentation master file, created by sphinx-quickstart on Tue Feb 2 10:00:47 2010. -.. currentmodule:: notmuch.notmuch +.. currentmodule:: cnotmuch.notmuch Welcome to notmuch's documentation! =================================== -The :mod:`notmuch` module provides an interface to the `notmuch `_ functionality. The main work horse of this module is the class :class:`Notmuch` with important other classes representing a :class:`Thread` and a single :class:`Message`. +The :mod:`cnotmuch` module provides an interface to the `notmuch `_ functionality, directly interfacing to a shared notmuch library. +The classes :class:`Database`, :class:`Query` provide most of the core functionality, returning :class:`Messages` and :class:`Tags`. .. moduleauthor:: Sebastian Spaeth -:License: This module is covered under the GNU GPL v2 (or later). +:License: This module is covered under the GNU GPL v3 (or later). -This page contains the main API overview. More information on specific topics can be found on the following pages: +This page contains the main API overview. More information on specific topics can be found on the following pages: (none here yet) + +Notmuch can be imported as: + + from cnotmuch import notmuch + +or: + + from cnotmuch.notmuch import Query,Database .. toctree:: :maxdepth: 1 @@ -22,24 +31,38 @@ This page contains the main API overview. More information on specific topics ca :mod:`notmuch` -- The Notmuch interface ============================================= -:class:`Message` -- A single email message ------------------------------------- +Document from cnotmuch.globals import nmlib,STATUS,NotmuchError -.. autoclass:: Message +:class:`Database` -- The underlying notmuch database +----------------------------------------------------- + +.. autoclass:: Database :members: -:class:`Thread` -- Represents a message thread +:class:`Query` -- Represents a notmuch Query ----------------------------------------------- -.. autoclass:: Thread +.. autoclass:: Query :members: .. note:: A Thread is what a call to notmuch.show() will return, containing a bunch of :class:`Message`\ s. -:class:`Notmuch` -- A notmuch call ------------------------------------- +:class:`Messages` -- A bunch of messages +---------------------------------------- -.. autoclass:: Notmuch +.. autoclass:: Messages + :members: + +:class:`Message` -- A single message +---------------------------------------- + +.. autoclass:: Message + :members: + +:class:`Tags` -- A bunch of notmuch tags +---------------------------------------- + +.. autoclass:: Tags :members: .. data: '__notmuchcmd__' From 65061756489566f134b6aeec7ef51cb6164be088 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 17 Mar 2010 09:28:39 +0100 Subject: [PATCH 025/106] README: add link to static docs --- README | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/README b/README index 3fbdab1b..14a35ae8 100644 --- a/README +++ b/README @@ -1,15 +1,33 @@ +cnotmuch -- The python interface to notmuch.so +============================================== +To create the documentation with sphinx installed, go to the docs directory and +"make html". A static version of the documentation is available at: + +http://spaetz.bitbucket.org + +All code is available under the GNU GPLv3+ (see docs/COPYING) unless specified otherwise. + +Usage +----- +For more examples of how to use the cnotmuch interface, have a look at the +notmuch "binary" and the generated documentation. + +Example session: >>>from cnotmuch import notmuch >>>db = notmuch.Database("/home/spaetz/mail") db.get_path() '/home/spaetz/mail' >>>tags = db.get_all_tags() -inited tags with 44762960 'Notmuch DB /home/spaetz/mail' >>>for tag in tags: >>> print tag inbox ... maildir::draft -Freeing the Tags now +#--------------------------------------------- + +q = notmuch.Query(db,'from:Sebastian') +count = len(q.search_messages()) +1300 #--------------------------------------------- @@ -20,5 +38,3 @@ NotmuchError: Could not open the specified database >>>tags = notmuch.Database("/home/spaetz/mail").get_all_tags() >>>del(tags) -Freeing the Tags now -Freeing the database now \ No newline at end of file From b00c892c65ee57d6503b4bafe8fd666a6a274e47 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 17 Mar 2010 11:44:36 +0100 Subject: [PATCH 026/106] Implement an Enum class and make the STATUS object one --- cnotmuch/database.py | 7 +++-- cnotmuch/globals.py | 62 ++++++++++++++++++++++++++------------------ 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 8762f393..b4411ab1 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -1,6 +1,6 @@ import ctypes from ctypes import c_int, c_char_p, c_void_p, c_uint64 -from cnotmuch.globals import nmlib, STATUS, NotmuchError +from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum import logging from datetime import date @@ -10,9 +10,8 @@ class Database(object): Do note that as soon as we tear down this object, all derived queries, threads, and messages will be freed as well. """ - #constants - MODE_READ_ONLY = 0 - MODE_READ_WRITE = 1 + MODE = Enum(['READ_ONLY','READ_WRITE']) + """Constants: Mode in which to open the database""" _std_db_path = None """Class attribute of users default database""" diff --git a/cnotmuch/globals.py b/cnotmuch/globals.py index 611c29ed..d9f8d1b4 100644 --- a/cnotmuch/globals.py +++ b/cnotmuch/globals.py @@ -9,34 +9,45 @@ if so is None: raise ImportError("Could not find shared 'notmuch' library.") nmlib = CDLL(so) #----------------------------------------------------------------------------- +class Enum(object): + """Provides ENUMS as "code=Enum(['a','b','c'])" where code.a=0 etc...""" + def __init__(self, names): + for number, name in enumerate(names): + setattr(self, name, number) -class STATUS(object): - SUCCESS = 0 - OUT_OF_MEMORY = 1 - READ_ONLY_DATABASE = 2 - XAPIAN_EXCEPTION = 3 - FILE_ERROR = 4 - FILE_NOT_EMAIL = 5 - DUPLICATE_MESSAGE_ID = 6 - NULL_POINTER = 7 - TAG_TOO_LONG = 8 - UNBALANCED_FREEZE_THAW = 9 - NOT_INITIALIZED = 10 +#----------------------------------------------------------------------------- +class Status(Enum): + """Enum with a string representation of a notmuch_status_t value.""" + __name__="foo" + _status2str = nmlib.notmuch_status_to_string + _status2str.restype = c_char_p + _status2str.argtypes = [c_int] - """Get a string representation of a notmuch_status_t value.""" - status2str = nmlib.notmuch_status_to_string - status2str.restype = c_char_p - status2str.argtypes = [c_int] + def __init__(self, statuslist): + """It is initialized with a list of strings that are available as + Status().string1 - Status().stringn attributes. + """ + super(Status, self).__init__(statuslist) - def __init__(self, status): - self._status = status + def status2str(self, status): + """Get a string representation of a notmuch_status_t value.""" + # define strings for custom error messages + if status == STATUS.NOT_INITIALIZED: + return "Operation on uninitialized object impossible." + return str(Status._status2str(status)) + +STATUS = Status(['SUCCESS', + 'OUT_OF_MEMORY', + 'READ_ONLY_DATABASE', + 'XAPIAN_EXCEPTION', + 'FILE_ERROR', + 'FILE_NOT_EMAIL', + 'DUPLICATE_MESSAGE_ID', + 'NULL_POINTER', + 'TAG_TOO_LONG', + 'UNBALANCED_FREEZE_THAW', + 'NOT_INITIALIZED']) - def __str__(self): - """Get a string representation of a notmuch_status_t value.""" - # define strings for custom error messages - if self._status == STATUS.NOT_INITIALIZED: - return "Operation on uninitialized DB/MSG/THREAD impossible." - return str(STATUS.status2str(self._status)) class NotmuchError(Exception): def __init__(self, status=None, message=None): @@ -45,4 +56,5 @@ class NotmuchError(Exception): def __str__(self): if self.args[0] is not None: return self.args[0] - else: return str(STATUS(self.args[1])) + else: return STATUS.status2str(self.args[1]) + From 06f627df92b644a73c07096bed716d406d4f649d Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 17 Mar 2010 11:45:13 +0100 Subject: [PATCH 027/106] Improve source documentation --- cnotmuch/database.py | 96 +++++++++++++++++++++++++++++++++---------- docs/source/conf.py | 1 + docs/source/index.rst | 35 +++++++++++++--- 3 files changed, 105 insertions(+), 27 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index b4411ab1..eb54626d 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -5,16 +5,39 @@ import logging from datetime import date class Database(object): - """ Wrapper around a notmuch_database_t + """Represents a notmuch database (wraps notmuch_database_t) - Do note that as soon as we tear down this object, all derived queries, - threads, and messages will be freed as well. + .. note:: Do note that as soon as we tear down this object, all underlying + derived objects such as queries, threads, messages, tags etc will + be freed by the underlying library as well. Accessing these objects + will lead to segfaults and other unexpected behavior. + + We implement reference counting, so that parent objects can be + automatically freed when they are not needed anymore, for example:: + + db = Database('path',create=True) + msgs = Query(db,'from:myself').search_messages() + + This returns a :class:`Messages` which internally contains + a reference to the parent :class:`Query` object. Otherwise + the Query() would be immediately freed, taking our *msgs* + down with it. + + In this case, the above Query() object will be + automatically freed whenever we delete all derived objects, + ie in our case: `del (msgs)` would also delete the parent + Query (but not the parent Database() as that is still + referenced from the variable *db* in which it is stored. + + Pretty much the same is valid for all other objects in the hierarchy, + such as :class:`Query`, :class:`Messages`, :class:`Message`, + and :class:`Tags`. """ MODE = Enum(['READ_ONLY','READ_WRITE']) """Constants: Mode in which to open the database""" _std_db_path = None - """Class attribute of users default database""" + """Class attribute to cache user's default database""" """notmuch_database_get_path (notmuch_database_t *database)""" _get_path = nmlib.notmuch_database_get_path @@ -36,12 +59,24 @@ class Database(object): _create = nmlib.notmuch_database_create _create.restype = c_void_p - def __init__(self, path=None, create=False, status= MODE_READ_ONLY): - """ Open or create a notmuch database + def __init__(self, path=None, create=False, mode= MODE.READ_ONLY): + """If *path* is *None*, we will try to read a users notmuch + configuration and use his default database. If *create* is `True`, + the database will always be created in + :attr:`MODE.READ_WRITE` mode as creating an empty + database for reading only does not make a great deal of sense. - If path is None, we will try to read a users notmuch configuration and - use his default database. - Throws a NotmuchError in case of failure. + :param path: Directory to open/create the database in (see + above for behavior if `None`) + :type path: `str` or `None` + :param create: False to open an existing, True to create a new + database. + :type create: bool + :param mdoe: Mode to open a database in. Always + :attr:`MODE`.READ_WRITE when creating a new one. + :type mode: :attr:`MODE` + :returns: Nothing + :exception: :exc:`NotmuchError` in case of failure. """ self._db = None if path is None: @@ -54,27 +89,40 @@ class Database(object): if create == False: self.open(path, status) else: - self.create(path, status) + self.create(path) - def create(self, path, status=MODE_READ_ONLY): - """ notmuch_database_create(const char *path) + def create(self, path): + """Creates a new notmuch database - :returns: Raises :exc:`notmuch.NotmuchError` in case - of any failure (after printing an error message on stderr). + This function wraps *notmuch_database_create(...)* and creates + a new notmuch database at *path*. It will always return a database in + :attr:`MODE`.READ_WRITE mode as creating an empty database + for reading only does not make a great deal of sense. + + :param path: A directory in which we should create the database. + :type path: str + :returns: Nothing + :exception: :exc:`NotmuchError` in case of any failure + (after printing an error message on stderr). """ - res = Database._create(path, status) + if self._db is not None: + raise NotmuchError( + message="Cannot create db, this Database() already has an open one.") + + res = Database._create(path, MODE.READ_WRITE) if res is None: raise NotmuchError( message="Could not create the specified database") self._db = res - def open(self, path, status= MODE_READ_ONLY): + def open(self, path, status= MODE.READ_ONLY): """calls notmuch_database_open :returns: Raises :exc:`notmuch.NotmuchError` in case of any failure (after printing an error message on stderr). """ + res = Database._open(path, status) if res is None: @@ -88,11 +136,13 @@ class Database(object): def find_message(self, msgid): """notmuch_database_find_message - :param msgid: The message id - :ptype msgid: string - :returns: Message() or None if no message is found or if an + :param msgid: The message id + :type msgid: string + :returns: :class:`Message` or `None` if no message is found or if an out-of-memory situation occurs. + :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if + the database was not intitialized. """ if self._db is None: raise NotmuchError(STATUS.NOT_INITIALIZED) @@ -102,9 +152,9 @@ class Database(object): return Message(msg_p, self) def get_all_tags(self): - """Return a Tags() object (list of all tags found in the database) + """Returns :class:`Tags` with a list of all tags found in the database - :returns: Tags() object or raises :exc:`NotmuchError` with + :returns: :class:`Tags` object or raises :exc:`NotmuchError` with STATUS.NULL_POINTER on error """ if self._db is None: @@ -139,7 +189,9 @@ class Database(object): @property def db_p(self): - """Returns a pointer to the current notmuch_database_t or None""" + """Property returning a pointer to the notmuch_database_t or `None`. + + This should normally not be needed by a user.""" return self._db #------------------------------------------------------------------------------ diff --git a/docs/source/conf.py b/docs/source/conf.py index 4d8be533..ee22837f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -23,6 +23,7 @@ sys.path.append(os.path.abspath('../..')) # 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', 'sphinx.ext.todo'] +autoclass_content = "both" # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/docs/source/index.rst b/docs/source/index.rst index e48c345a..06f21422 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -31,13 +31,35 @@ or: :mod:`notmuch` -- The Notmuch interface ============================================= -Document from cnotmuch.globals import nmlib,STATUS,NotmuchError +Document from cnotmuch.globals import nmlib,STATUS :class:`Database` -- The underlying notmuch database ----------------------------------------------------- -.. autoclass:: Database - :members: +.. autoclass:: Database([path=None[, create=False[, mode=MODE.READ_ONLY]]]) + + .. automethod:: create + + .. automethod:: open(path, status=MODE.READ_ONLY) + + .. automethod:: get_path + + .. automethod:: find_message + + .. automethod:: get_all_tags + + + .. attribute:: Database.MODE + + Defines constants that are used as the mode in which to open a database. + + READ_ONLY + Open the database in read-only mode + + READ_WRITE + Open the database in read-write mode + + .. autoattribute:: db_p :class:`Query` -- Represents a notmuch Query ----------------------------------------------- @@ -45,8 +67,6 @@ Document from cnotmuch.globals import nmlib,STATUS,NotmuchError .. autoclass:: Query :members: - .. note:: A Thread is what a call to notmuch.show() will return, containing a bunch of :class:`Message`\ s. - :class:`Messages` -- A bunch of messages ---------------------------------------- @@ -76,6 +96,11 @@ Document from cnotmuch.globals import nmlib,STATUS,NotmuchError This execption inherits directly from :exc:`Exception` and is raised on errors during the notmuch execution. +:class:`STATUS` -- Notmuch operation return status +-------------------------------------------------- +.. autoclass:: STATUS + :members: + Indices and tables ================== From 61bef71a126efb02b477209a02789cc194a5b784 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 17 Mar 2010 12:32:22 +0100 Subject: [PATCH 028/106] many doc improvements, fixed at least one bug due to parameter renaming --- cnotmuch/__init__.py | 3 -- cnotmuch/database.py | 72 +++++++++++++++++++------------------------ cnotmuch/notmuch.py | 44 +++++++++++++++++++++----- docs/source/index.rst | 21 +++++++------ 4 files changed, 80 insertions(+), 60 deletions(-) diff --git a/cnotmuch/__init__.py b/cnotmuch/__init__.py index e24dc966..e69de29b 100644 --- a/cnotmuch/__init__.py +++ b/cnotmuch/__init__.py @@ -1,3 +0,0 @@ -__version__=0.1 -__author__ ="Sebastian Spaeth " -#ctypes.util.find_library("notmuch") diff --git a/cnotmuch/database.py b/cnotmuch/database.py index eb54626d..2b51e5ea 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -7,31 +7,11 @@ from datetime import date class Database(object): """Represents a notmuch database (wraps notmuch_database_t) - .. note:: Do note that as soon as we tear down this object, all underlying - derived objects such as queries, threads, messages, tags etc will - be freed by the underlying library as well. Accessing these objects - will lead to segfaults and other unexpected behavior. - - We implement reference counting, so that parent objects can be - automatically freed when they are not needed anymore, for example:: - - db = Database('path',create=True) - msgs = Query(db,'from:myself').search_messages() - - This returns a :class:`Messages` which internally contains - a reference to the parent :class:`Query` object. Otherwise - the Query() would be immediately freed, taking our *msgs* - down with it. - - In this case, the above Query() object will be - automatically freed whenever we delete all derived objects, - ie in our case: `del (msgs)` would also delete the parent - Query (but not the parent Database() as that is still - referenced from the variable *db* in which it is stored. - - Pretty much the same is valid for all other objects in the hierarchy, - such as :class:`Query`, :class:`Messages`, :class:`Message`, - and :class:`Tags`. + .. note:: Do remember that as soon as we tear down this object, + all underlying derived objects such as queries, threads, + messages, tags etc will be freed by the underlying library + as well. Accessing these objects will lead to segfaults and + other unexpected behavior. See above for more details. """ MODE = Enum(['READ_ONLY','READ_WRITE']) """Constants: Mode in which to open the database""" @@ -63,8 +43,7 @@ class Database(object): """If *path* is *None*, we will try to read a users notmuch configuration and use his default database. If *create* is `True`, the database will always be created in - :attr:`MODE.READ_WRITE` mode as creating an empty - database for reading only does not make a great deal of sense. + :attr:`MODE`.READ_WRITE mode. :param path: Directory to open/create the database in (see above for behavior if `None`) @@ -72,7 +51,7 @@ class Database(object): :param create: False to open an existing, True to create a new database. :type create: bool - :param mdoe: Mode to open a database in. Always + :param mode: Mode to open a database in. Always :attr:`MODE`.READ_WRITE when creating a new one. :type mode: :attr:`MODE` :returns: Nothing @@ -87,17 +66,19 @@ class Database(object): path = Database._std_db_path if create == False: - self.open(path, status) + self.open(path, mode) else: self.create(path) def create(self, path): """Creates a new notmuch database - This function wraps *notmuch_database_create(...)* and creates - a new notmuch database at *path*. It will always return a database in - :attr:`MODE`.READ_WRITE mode as creating an empty database - for reading only does not make a great deal of sense. + This function is used by __init__() usually does not need + to be called directly. It wraps the underlying + *notmuch_database_create* function and creates a new notmuch + database at *path*. It will always return a database in + :attr:`MODE`.READ_WRITE mode as creating an empty database for + reading only does not make a great deal of sense. :param path: A directory in which we should create the database. :type path: str @@ -116,14 +97,21 @@ class Database(object): message="Could not create the specified database") self._db = res - def open(self, path, status= MODE.READ_ONLY): - """calls notmuch_database_open + def open(self, path, mode= MODE.READ_ONLY): + """Opens an existing database - :returns: Raises :exc:`notmuch.NotmuchError` in case - of any failure (after printing an error message on stderr). + This function is used by __init__() usually does not need + to be called directly. It wraps the underlying + *notmuch_database_open* function. + + :param status: Open the database in read-only or read-write mode + :type status: :attr:`MODE` + :returns: Nothing + :exception: Raises :exc:`notmuch.NotmuchError` in case + of any failure (after printing an error message on stderr). """ - res = Database._open(path, status) + res = Database._open(path, mode) if res is None: raise NotmuchError( @@ -131,11 +119,15 @@ class Database(object): self._db = res def get_path(self): - """notmuch_database_get_path (notmuch_database_t *database); """ + """Returns the file path of an open database + + Wraps notmuch_database_get_path""" return Database._get_path(self._db) def find_message(self, msgid): - """notmuch_database_find_message + """Returns a :class:`Message` as identified by its message ID + + wraps *notmuch_database_find_message* :param msgid: The message id :type msgid: string diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py index 4bf593a7..e945d496 100644 --- a/cnotmuch/notmuch.py +++ b/cnotmuch/notmuch.py @@ -1,12 +1,42 @@ +"""The :mod:`notmuch` module provides most of the functionality that a user is likely to need. + +.. note:: The underlying notmuch library is build on a hierarchical + memory allocator called talloc. All objects derive from a + top-level :class:`Database` object. + + This means that as soon as an object is deleted, all underlying + derived objects such as Queries, Messages, Message, and Tags will + be freed by the underlying library as well. Accessing these + objects will then lead to segfaults and other unexpected behavior. + + We implement reference counting, so that parent objects can be + automatically freed when they are not needed anymore. For + example:: + + db = Database('path',create=True) + msgs = Query(db,'from:myself').search_messages() + + This returns a :class:`Messages` which internally contains a + reference to its parent :class:`Query` object. Otherwise the + Query() would be immediately freed, taking our *msgs* down with + it. + + In this case, the above Query() object will be automatically freed + whenever we delete all derived objects, ie in our case: + `del(msgs)` would also delete the parent Query (but not the parent + Database() as that is still referenced from the variable *db* in + which it is stored. + + Pretty much the same is valid for all other objects in the + hierarchy, such as :class:`Query`, :class:`Messages`, + :class:`Message`, and :class:`Tags`. + +""" import ctypes from ctypes import c_int, c_char_p from database import Database,Tags,Query,Messages,Message,Tags from cnotmuch.globals import nmlib,STATUS,NotmuchError +__LICENSE__="GPL v3+" +__VERSION__=0.1 +__AUTHOR__ ="Sebastian Spaeth " -# 114 typedef struct _notmuch_query notmuch_query_t; -# 115 typedef struct _notmuch_threads notmuch_threads_t; -# 116 typedef struct _notmuch_thread notmuch_thread_t; -# 117 typedef struct _notmuch_messages notmuch_messages_t; -# 118 typedef struct _notmuch_message notmuch_message_t; -# 120 typedef struct _notmuch_directory notmuch_directory_t; -# 121 typedef struct _notmuch_filenames notmuch_filenames_t; diff --git a/docs/source/index.rst b/docs/source/index.rst index 06f21422..d6f9411d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,13 +1,13 @@ .. cnotmuch documentation master file, created by sphinx-quickstart on Tue Feb 2 10:00:47 2010. -.. currentmodule:: cnotmuch.notmuch +.. currentmodule:: cnotmuch -Welcome to notmuch's documentation! -=================================== +Welcome to :mod:`cnotmuch`'s documentation +=========================================== The :mod:`cnotmuch` module provides an interface to the `notmuch `_ functionality, directly interfacing to a shared notmuch library. -The classes :class:`Database`, :class:`Query` provide most of the core functionality, returning :class:`Messages` and :class:`Tags`. +The classes :class:`notmuch.Database`, :class:`notmuch.Query` provide most of the core functionality, returning :class:`notmuch.Messages` and :class:`notmuch.Tags`. .. moduleauthor:: Sebastian Spaeth @@ -15,11 +15,11 @@ The classes :class:`Database`, :class:`Query` provide most of the core functiona This page contains the main API overview. More information on specific topics can be found on the following pages: (none here yet) -Notmuch can be imported as: +Notmuch can be imported as:: from cnotmuch import notmuch -or: +or:: from cnotmuch.notmuch import Query,Database @@ -27,11 +27,12 @@ or: :maxdepth: 1 - :mod:`notmuch` -- The Notmuch interface ============================================= -Document from cnotmuch.globals import nmlib,STATUS +.. automodule:: cnotmuch.notmuch + +:todo: Document nmlib,STATUS :class:`Database` -- The underlying notmuch database ----------------------------------------------------- @@ -53,10 +54,10 @@ Document from cnotmuch.globals import nmlib,STATUS Defines constants that are used as the mode in which to open a database. - READ_ONLY + MODE.READ_ONLY Open the database in read-only mode - READ_WRITE + MODE.READ_WRITE Open the database in read-write mode .. autoattribute:: db_p From d2b4307851f184908bd8aa2217eb1a31f66f7cc7 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 17 Mar 2010 15:14:44 +0100 Subject: [PATCH 029/106] docs: doc improvements --- cnotmuch/database.py | 253 ++++++++++++++++++++++++++++++++---------- cnotmuch/notmuch.py | 2 + docs/source/index.rst | 35 ++++-- 3 files changed, 223 insertions(+), 67 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 2b51e5ea..13915fb1 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -48,10 +48,10 @@ class Database(object): :param path: Directory to open/create the database in (see above for behavior if `None`) :type path: `str` or `None` - :param create: False to open an existing, True to create a new + :param create: Pass `False` to open an existing, `True` to create a new database. :type create: bool - :param mode: Mode to open a database in. Always + :param mode: Mode to open a database in. Is always :attr:`MODE`.READ_WRITE when creating a new one. :type mode: :attr:`MODE` :returns: Nothing @@ -73,7 +73,7 @@ class Database(object): def create(self, path): """Creates a new notmuch database - This function is used by __init__() usually does not need + This function is used by __init__() and usually does not need to be called directly. It wraps the underlying *notmuch_database_create* function and creates a new notmuch database at *path*. It will always return a database in @@ -100,14 +100,14 @@ class Database(object): def open(self, path, mode= MODE.READ_ONLY): """Opens an existing database - This function is used by __init__() usually does not need + This function is used by __init__() and usually does not need to be called directly. It wraps the underlying *notmuch_database_open* function. :param status: Open the database in read-only or read-write mode :type status: :attr:`MODE` :returns: Nothing - :exception: Raises :exc:`notmuch.NotmuchError` in case + :exception: Raises :exc:`NotmuchError` in case of any failure (after printing an error message on stderr). """ @@ -127,9 +127,9 @@ class Database(object): def find_message(self, msgid): """Returns a :class:`Message` as identified by its message ID - wraps *notmuch_database_find_message* + Wraps the underlying *notmuch_database_find_message* function. - :param msgid: The message id + :param msgid: The message ID :type msgid: string :returns: :class:`Message` or `None` if no message is found or if an out-of-memory situation occurs. @@ -146,8 +146,8 @@ class Database(object): def get_all_tags(self): """Returns :class:`Tags` with a list of all tags found in the database - :returns: :class:`Tags` object or raises :exc:`NotmuchError` with - STATUS.NULL_POINTER on error + :returns: :class:`Tags` + :execption: :exc:`NotmuchError` with STATUS.NULL_POINTER on error """ if self._db is None: raise NotmuchError(STATUS.NOT_INITIALIZED) @@ -188,15 +188,22 @@ class Database(object): #------------------------------------------------------------------------------ class Query(object): - """ Wrapper around a notmuch_query_t + """ Represents a search query on an opened :class:`Database`. - Do note that as soon as we tear down this object, all derived - threads, and messages will be freed as well. + A query selects and filters a subset of messages from the notmuch + database we derive from. + + Technically, it wraps the underlying *notmuch_query_t* struct. + + .. note:: Do remember that as soon as we tear down this object, + all underlying derived objects such as queries, threads, + messages, tags etc will be freed by the underlying library + as well. Accessing these objects will lead to segfaults and + other unexpected behavior. See above for more details. """ # constants - SORT_OLDEST_FIRST = 0 - SORT_NEWEST_FIRST = 1 - SORT_MESSAGE_ID = 2 + SORT = Enum(['OLDEST_FIRST','NEWEST_FIRST','MESSAGE_ID']) + """Constants: Sort order in which to return results""" """notmuch_query_create""" _create = nmlib.notmuch_query_create @@ -207,16 +214,32 @@ class Query(object): _search_messages.restype = c_void_p def __init__(self, db, querystr): - """TODO: document""" + """ + :param db: An open database which we derive the Query from. + :type db: :class:`Database` + :param querystr: The query string for the message. + :type querystr: str + """ self._db = None self._query = None self.create(db, querystr) def create(self, db, querystr): - """db is a Database() and querystr a string + """Creates a new query derived from a Database. - raises NotmuchError STATUS.NOT_INITIALIZED if db is not inited and - STATUS.NULL_POINTER if the query creation failed (too little memory) + This function is utilized by __init__() and usually does not need to + be called directly. + + :param db: Database to create the query from. + :type db: :class:`Database` + :param querystr: The query string + :type querystr: str + :returns: Nothing + :exception: :exc:`NotmuchError` + + * STATUS.NOT_INITIALIZED if db is not inited + * STATUS.NULL_POINTER if the query creation failed + (too little memory) """ if db.db_p is None: raise NotmuchError(STATUS.NOT_INITIALIZED) @@ -230,10 +253,14 @@ class Query(object): self._query = query_p def set_sort(self, sort): - """notmuch_query_set_sort + """Set the sort order future results will be delivered in - :param sort: one of Query.SORT_OLDEST_FIRST|SORT_NEWEST_FIRST|SORT_MESSAGE_ID - :returns: Nothing, but raises NotmuchError if query is not inited + Wraps the underlying *notmuch_query_set_sort* function. + + :param sort: Sort order (see :attr:`Query.SORT`) + :returns: Nothing + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if query has not + been initialized. """ if self._query is None: raise NotmuchError(STATUS.NOT_INITIALIZED) @@ -241,8 +268,17 @@ class Query(object): nmlib.notmuch_query_set_sort(self._query, sort) def search_messages(self): - """notmuch_query_search_messages - Returns Messages() or a raises a NotmuchError() + """Filter messages according to query and return + :class:`Messages` in the defined sort order. + + Technically, it wraps the underlying + *notmuch_query_search_messages* function. + + :returns: :class:`Messages` + :exception: :exc:`NotmuchError` + + * STATUS.NOT_INITIALIZED if query is not inited + * STATUS.NULL_POINTER if search_messages failed """ if self._query is None: raise NotmuchError(STATUS.NOT_INITIALIZED) @@ -263,7 +299,30 @@ class Query(object): #------------------------------------------------------------------------------ class Tags(object): - """Wrapper around notmuch_tags_t""" + """Represents a list of notmuch tags + + This object provides an iterator over a list of notmuch tags. Do + note that the underlying library only provides a one-time iterator + (it cannot reset the iterator to the start). Thus iterating over + the function will "exhaust" the list of tags, and a subsequent + iteration attempt will raise a :exc:`NotmuchError` + STATUS.NOT_INITIALIZED. Also note, that any function that uses + iteration (nearly all) will also exhaust the tags. So both:: + + for tag in tags: print tag + + as well as:: + + number_of_tags = len(tags) + + and even a simple:: + + #str() iterates over all tags to construct a space separated list + print(str(tags)) + + will "exhaust" the Tags. If you need to re-iterate over a list of + tags you will need to retrieve a new :class:`Tags` object. + """ #notmuch_tags_get _get = nmlib.notmuch_tags_get @@ -271,18 +330,20 @@ class Tags(object): def __init__(self, tags_p, parent=None): """ - msg_p is a pointer to an notmuch_message_t Structure. If it is None, - we will raise an NotmuchError(STATUS.NULL_POINTER). - - Is passed the parent these tags are derived from, and saves a - reference to it, so we can automatically delete the db object - once all derived objects are dead. - - Tags() provides an iterator over all contained tags. However, you will - only be able to iterate over the Tags once, because the underlying C - function only allows iterating once. - #TODO: make the iterator work more than once and cache the tags in - the Python object. + :param tags_p: A pointer to an underlying *notmuch_tags_t* + structure. These are not publically exposed, so a user + will almost never instantiate a :class:`Tags` object + herself. They are usually handed back as a result, + e.g. in :meth:`Database.get_all_tags`. *tags_p* must be + valid, we will raise an :exc:`NotmuchError` + (STATUS.NULL_POINTER) if it is `None`. + :type tags_p: :class:`ctypes.c_void_p` + :param parent: The parent object (ie :class:`Database` or + :class:`Message` these tags are derived from, and saves a + reference to it, so we can automatically delete the db object + once all derived objects are dead. + :TODO: Make the iterator optionally work more than once by + cache the tags in the Python object(?) """ if tags_p is None: NotmuchError(STATUS.NULL_POINTER) @@ -308,10 +369,33 @@ class Tags(object): nmlib.notmuch_tags_move_to_next(self._tags) return tag - def __str__(self): - """str() of Tags() is a space separated list of tags + def __len__(self): + """len(:class:`Tags`) returns the number of contained tags - This iterates over the list of Tags and will therefore 'exhaust' Tags() + .. note:: As this iterates over the tags, we will not be able + to iterate over them again (as in retrieve them)! If + the tags have been exhausted already, this will raise a + :exc:`NotmuchError` STATUS.NOT_INITIALIZED on + subsequent attempts. + """ + if self._tags is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + i=0 + while nmlib.notmuch_tags_valid(self._msgs): + nmlib.notmuch_tags_move_to_next(self._msgs) + i += 1 + self._tags = None + return i + + def __str__(self): + """The str() representation of Tags() is a space separated list of tags + + .. note:: As this iterates over the tags, we will not be able + to iterate over them again (as in retrieve them)! If + the tags have been exhausted already, this will raise a + :exc:`NotmuchError` STATUS.NOT_INITIALIZED on + subsequent attempts. """ return " ".join(self) @@ -324,7 +408,52 @@ class Tags(object): #------------------------------------------------------------------------------ class Messages(object): - """Wrapper around notmuch_messages_t""" + """Represents a list of notmuch messages + + This object provides an iterator over a list of notmuch messages + (Technically, it provides a wrapper for the underlying + *notmuch_messages_t* structure). Do note that the underlying + library only provides a one-time iterator (it cannot reset the + iterator to the start). Thus iterating over the function will + "exhaust" the list of messages, and a subsequent iteration attempt + will raise a :exc:`NotmuchError` STATUS.NOT_INITIALIZED. Also + note, that any function that uses iteration will also + exhaust the messages. So both:: + + for msg in msgs: print msg + + as well as:: + + number_of_msgs = len(msgs) + + will "exhaust" the Messages. If you need to re-iterate over a list of + messages you will need to retrieve a new :class:`Messages` object. + + Things are not as bad as it seems though, you can store and reuse + the single Message objects as often as you want as long as you + keep the parent Messages object around. (Recall that due to + hierarchical memory allocation, all derived Message objects will + be invalid when we delete the parent Messages() object, even if it + was already "exhausted".) So this works:: + + db = Database() + msgs = Query(db,'').search_messages() #get a Messages() object + msglist = [] + for m in msgs: + msglist.append(m) + + # msgs is "exhausted" now and even len(msgs) will raise an exception. + # However it will be kept around until all retrieved Message() objects are + # also deleted. If you did e.g. an explicit del(msgs) here, the + # following lines would fail. + + # You can reiterate over *msglist* however as often as you want. + # It is simply a list with Message objects. + + print (msglist[0].get_filename()) + print (msglist[1].get_filename()) + print (msglist[0].get_message_id()) + """ #notmuch_tags_get _get = nmlib.notmuch_messages_get @@ -335,18 +464,20 @@ class Messages(object): def __init__(self, msgs_p, parent=None): """ - msg_p is a pointer to an notmuch_messages_t Structure. If it is None, - we will raise an NotmuchError(STATUS.NULL_POINTER). - - If passed the parent query this Messages() is derived from, it saves a - reference to it, so we can automatically delete the parent query object - once all derived objects are dead. - - Messages() provides an iterator over all contained Message()s. - However, you will only be able to iterate over it once, - because the underlying C function only allows iterating once. - #TODO: make the iterator work more than once and cache the tags in - the Python object. + :param msgs_p: A pointer to an underlying *notmuch_messages_t* + structure. These are not publically exposed, so a user + will almost never instantiate a :class:`Messages` object + herself. They are usually handed back as a result, + e.g. in :meth:`Query.search_messages`. *msgs_p* must be + valid, we will raise an :exc:`NotmuchError` + (STATUS.NULL_POINTER) if it is `None`. + :type msgs_p: :class:`ctypes.c_void_p` + :param parent: The parent object + (ie :class:`Query`) these tags are derived from. It saves + a reference to it, so we can automatically delete the db + object once all derived objects are dead. + :TODO: Make the iterator work more than once and cache the tags in + the Python object.(?) """ if msgs_p is None: NotmuchError(STATUS.NULL_POINTER) @@ -357,11 +488,13 @@ class Messages(object): logging.debug("Inited Messages derived from %s" %(str(parent))) def collect_tags(self): - """ return the Tags() belonging to the messages - - Do note that collect_tags will iterate over the messages and - therefore will not allow further iterationsl - Raises NotmuchError(STATUS.NOT_INITIALIZED) if not inited + """Return the unique :class:`Tags` in the contained messages + + :returns: :class:`Tags` + :exceptions: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if not inited + + .. note:: :meth:`collect_tags` will iterate over the messages and + therefore will not allow further iterations. """ if self._msgs is None: raise NotmuchError(STATUS.NOT_INITIALIZED) @@ -392,9 +525,9 @@ class Messages(object): return msg def __len__(self): - """ Returns the number of contained messages + """len(:class:`Messages`) returns the number of contained messages - :note: As this iterates over the messages, we will not be able to + .. note:: As this iterates over the messages, we will not be able to iterate over them again (as in retrieve them)! """ if self._msgs is None: diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py index e945d496..177abd69 100644 --- a/cnotmuch/notmuch.py +++ b/cnotmuch/notmuch.py @@ -1,5 +1,7 @@ """The :mod:`notmuch` module provides most of the functionality that a user is likely to need. +Many of its objects use python's logging module to log some output at DEBUG level. + .. note:: The underlying notmuch library is build on a hierarchical memory allocator called talloc. All objects derive from a top-level :class:`Database` object. diff --git a/docs/source/index.rst b/docs/source/index.rst index d6f9411d..a3bc2654 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -62,17 +62,38 @@ or:: .. autoattribute:: db_p -:class:`Query` -- Represents a notmuch Query +:class:`Query` -- A search query ----------------------------------------------- .. autoclass:: Query - :members: + + .. automethod:: create + + .. attribute:: Query.SORT + + Defines constants that are used as the mode in which to open a database. + + SORT.OLDEST_FIRST + Sort by message date, oldest first. + + SORT.NEWEST_FIRST + Sort by message date, newest first. + + SORT.MESSAGE_ID + Sort by email message ID + + .. automethod:: set_sort + + .. automethod:: search_messages :class:`Messages` -- A bunch of messages ---------------------------------------- .. autoclass:: Messages - :members: + + .. automethod:: collect_tags + + .. automethod:: __len__ :class:`Message` -- A single message ---------------------------------------- @@ -80,15 +101,15 @@ or:: .. autoclass:: Message :members: -:class:`Tags` -- A bunch of notmuch tags ----------------------------------------- +:class:`Tags` -- Notmuch tags +----------------------------- .. autoclass:: Tags :members: - .. data: '__notmuchcmd__' + .. automethod:: __len__ - This is the actual binary that will be executed in order to run a notmuch command. This is set to *notmuch* and should usually not be changed. + .. automethod:: __str__ :exc:`NotmuchError` -- A Notmuch execution error ------------------------------------------------ From 350509028b1213402854e18b213a5a47b1dddc1c Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 17 Mar 2010 17:09:58 +0100 Subject: [PATCH 030/106] Implement Message.add|remove_tag() and documentation --- cnotmuch/database.py | 148 +++++++++++++++++++++++++++++++++++------- cnotmuch/notmuch.py | 6 +- docs/source/index.rst | 19 +++++- 3 files changed, 145 insertions(+), 28 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 13915fb1..79f57ea2 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -181,7 +181,7 @@ class Database(object): @property def db_p(self): - """Property returning a pointer to the notmuch_database_t or `None`. + """Property returning a pointer to the notmuch_database_t or `None` This should normally not be needed by a user.""" return self._db @@ -196,7 +196,7 @@ class Query(object): Technically, it wraps the underlying *notmuch_query_t* struct. .. note:: Do remember that as soon as we tear down this object, - all underlying derived objects such as queries, threads, + all underlying derived objects such as threads, messages, tags etc will be freed by the underlying library as well. Accessing these objects will lead to segfaults and other unexpected behavior. See above for more details. @@ -268,8 +268,8 @@ class Query(object): nmlib.notmuch_query_set_sort(self._query, sort) def search_messages(self): - """Filter messages according to query and return - :class:`Messages` in the defined sort order. + """Filter messages according to the query and return + :class:`Messages` in the defined sort order Technically, it wraps the underlying *notmuch_query_search_messages* function. @@ -551,7 +551,10 @@ class Messages(object): #------------------------------------------------------------------------------ class Message(object): - """Wrapper around notmuch_message_t""" + """Represents a single Email message + + Technically, this wraps the underlying *notmuch_message_t* structure. + """ """notmuch_message_get_filename (notmuch_message_t *message)""" _get_filename = nmlib.notmuch_message_get_filename @@ -572,12 +575,13 @@ class Message(object): def __init__(self, msg_p, parent=None): """ - msg_p is a pointer to an notmuch_message_t Structure. If it is None, - we will raise an NotmuchError(STATUS.NULL_POINTER). - - Is a 'parent' object is passed which this message is derived from, - we save a reference to it, so we can automatically delete the parent - object once all derived objects are dead. + :param msg_p: A pointer to an internal notmuch_message_t + Structure. If it is `None`, we will raise an :exc:`NotmuchError` + STATUS.NULL_POINTER. + :param parent: A 'parent' object is passed which this message is + derived from. We save a reference to it, so we can + automatically delete the parent object once all derived + objects are dead. """ if msg_p is None: NotmuchError(STATUS.NULL_POINTER) @@ -588,28 +592,50 @@ class Message(object): def get_message_id(self): - """ return the msg id + """Return the message ID - Raises NotmuchError(STATUS.NOT_INITIALIZED) if not inited + :returns: String with a message ID + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. """ if self._msg is None: raise NotmuchError(STATUS.NOT_INITIALIZED) return Message._get_message_id(self._msg) def get_date(self): - """returns time_t of the message date + """Returns time_t of the message date For the original textual representation of the Date header from the message call notmuch_message_get_header() with a header value of "date". - Raises NotmuchError(STATUS.NOT_INITIALIZED) if not inited + + :returns: a time_t timestamp + :rtype: c_unit64 + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. """ if self._msg is None: raise NotmuchError(STATUS.NOT_INITIALIZED) return Message._get_date(self._msg) def get_header(self, header): - """ TODO document me""" + """Returns a message header + + This returns any message header that is stored in the notmuch database. + This is only a selected subset of headers, which is currently: + + TODO: add stored headers + + :param header: The name of the header to be retrieved. + It is not case-sensitive (TODO: confirm). + :type header: str + :returns: The header value as string + :exception: :exc:`NotmuchError` + + * STATUS.NOT_INITIALIZED if the message + is not initialized. + * STATUS.NULL_POINTER, if no header was found + """ if self._msg is None: raise NotmuchError(STATUS.NOT_INITIALIZED) @@ -620,19 +646,26 @@ class Message(object): return header def get_filename(self): - """ return the msg filename - - Raises NotmuchError(STATUS.NOT_INITIALIZED) if not inited + """Return the file path of the message file + + :returns: Absolute file path & name of the message file + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. """ if self._msg is None: raise NotmuchError(STATUS.NOT_INITIALIZED) return Message._get_filename(self._msg) def get_tags(self): - """ return the msg tags - - Raises NotmuchError(STATUS.NOT_INITIALIZED) if not inited - Raises NotmuchError(STATUS.NULL_POINTER) on error. + """ Return the message tags + + :returns: Message tags + :rtype: :class:`Tags` + :exception: :exc:`NotmuchError` + + * STATUS.NOT_INITIALIZED if the message + is not initialized. + * STATUS.NULL_POINTER, on error """ if self._msg is None: raise NotmuchError(STATUS.NOT_INITIALIZED) @@ -642,6 +675,73 @@ class Message(object): raise NotmuchError(STATUS.NULL_POINTER) return Tags(tags_p, self) + def add_tag(self, tag): + """Add a tag to the given message + + Adds a tag to the current message. The maximal tag length is defined in + the notmuch library and is currently 200 bytes. + + :param tag: String with a 'tag' to be added. + :returns: STATUS.SUCCESS if the tag was successfully added. + Raises an exception otherwise. + :exception: :exc:`NotmuchError`. They have the following meaning: + + STATUS.NULL_POINTER + The 'tag' argument is NULL + + STATUS.TAG_TOO_LONG + The length of 'tag' is too long + (exceeds Message.NOTMUCH_TAG_MAX) + + STATUS.READ_ONLY_DATABASE + Database was opened in read-only mode so message cannot be + modified. + + STATUS.NOT_INITIALIZED + The message has not been initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + status = nmlib.notmuch_message_add_tag (self._msg, tag) + + if STATUS.SUCCESS == status: + # return on success + return status + + raise NotmuchError(status) + + def remove_tag(self, tag): + """Removes a tag from the given message + + :param tag: String with a 'tag' to be removed. + :returns: STATUS.SUCCESS if the tag was successfully removed. + Raises an exception otherwise. + :exception: :exc:`NotmuchError`. They have the following meaning: + + STATUS.NULL_POINTER + The 'tag' argument is NULL + NOTMUCH_STATUS_TAG_TOO_LONG + The length of 'tag' is too long + (exceeds NOTMUCH_TAG_MAX) + NOTMUCH_STATUS_READ_ONLY_DATABASE + Database was opened in read-only mode so message cannot + be modified. + STATUS.NOT_INITIALIZED + The message has not been initialized. + + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + status = nmlib.notmuch_message_remove_tag(self._msg, tag) + + if STATUS.SUCCESS == status: + # return on success + return status + + raise NotmuchError(status) + def __str__(self): """A message() is represented by a 1-line summary""" msg = {} @@ -651,7 +751,7 @@ class Message(object): return "%(from)s (%(date)s) (%(tags)s)" % (msg) def format_as_text(self): - """ Output like notmuch show """ + """Output like notmuch show (Not implemented)""" return str(self) def __del__(self): diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py index 177abd69..70ee0b90 100644 --- a/cnotmuch/notmuch.py +++ b/cnotmuch/notmuch.py @@ -25,9 +25,9 @@ Many of its objects use python's logging module to log some output at DEBUG leve In this case, the above Query() object will be automatically freed whenever we delete all derived objects, ie in our case: - `del(msgs)` would also delete the parent Query (but not the parent - Database() as that is still referenced from the variable *db* in - which it is stored. + `del(msgs)` would also delete the parent Query. It would not + delete the parent Database() though, as that is still referenced + from the variable *db* in which it is stored. Pretty much the same is valid for all other objects in the hierarchy, such as :class:`Query`, :class:`Messages`, diff --git a/docs/source/index.rst b/docs/source/index.rst index a3bc2654..5b262bba 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -99,7 +99,24 @@ or:: ---------------------------------------- .. autoclass:: Message - :members: + + .. automethod:: get_message_id + + .. automethod:: get_date + + .. automethod:: get_header + + .. automethod:: get_filename + + .. automethod:: get_tags + + .. automethod:: remove_tag + + .. automethod:: add_tag + + .. automethod:: format_as_text + + .. automethod:: __str__ :class:`Tags` -- Notmuch tags ----------------------------- From 99880b7dbe0169fe303ef2e77984b46a55a74560 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 17 Mar 2010 17:11:58 +0100 Subject: [PATCH 031/106] notmuch: fix to use new Query.SORT enum --- notmuch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notmuch b/notmuch index de9d7b10..2ddd9ffc 100755 --- a/notmuch +++ b/notmuch @@ -155,7 +155,7 @@ if __name__ == '__main__': #TODO: implement "dump " db = Database() q = Query(db,'') - q.set_sort(Query.SORT_MESSAGE_ID) + q.set_sort(Query.SORT.MESSAGE_ID) m = q.search_messages() for msg in m: print("%s (%s)" % (msg.get_message_id(), msg.get_tags())) From 23b32a7dfdeec9acc2ad800e1d307b31cf82052d Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 17 Mar 2010 17:32:37 +0100 Subject: [PATCH 032/106] notmuch: implement tag command --- cnotmuch/database.py | 6 +++++- notmuch | 43 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 79f57ea2..9c78b461 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -714,8 +714,12 @@ class Message(object): def remove_tag(self, tag): """Removes a tag from the given message + If the message has no such tag, this is a non-operation and + will report success anyway. + :param tag: String with a 'tag' to be removed. - :returns: STATUS.SUCCESS if the tag was successfully removed. + :returns: STATUS.SUCCESS if the tag was successfully removed or if + the message had no such tag. Raises an exception otherwise. :exception: :exc:`NotmuchError`. They have the following meaning: diff --git a/notmuch b/notmuch index 2ddd9ffc..5862b9ac 100755 --- a/notmuch +++ b/notmuch @@ -100,17 +100,18 @@ if __name__ == '__main__': # Handle command line options # No option + #------------------------------------- if len(sys.argv) == 1: print USAGE - + #------------------------------------- elif sys.argv[1] == 'setup': """ Interactively setup notmuch for first use. """ print "Not implemented." - + #------------------------------------- elif sys.argv[1] == 'help': if len(sys.argv) == 2: print HELPTEXT else: print "Not implemented" - + #------------------------------------- elif sys.argv[1] == 'show': db = Database() if len(sys.argv) == 2: @@ -123,11 +124,11 @@ if __name__ == '__main__': m = Query(db,querystr).search_messages() for msg in m: print(msg.format_as_text()) - + #------------------------------------- elif sys.argv[1] == 'new': #TODO: handle --verbose print "Not implemented." - + #------------------------------------- elif sys.argv[1] == 'count': db = Database() if len(sys.argv) == 2: @@ -138,7 +139,30 @@ if __name__ == '__main__': querystr = quote_query_line(sys.argv[2:]) logging.debug("count "+querystr) print(len(Query(db,querystr).search_messages())) - + #------------------------------------- + elif sys.argv[1] == 'tag': + #build lists of tags to be added and removed + add, remove = [], [] + while not sys.argv[2]=='--' and \ + (sys.argv[2].startswith('+') or sys.argv[2].startswith('-')): + if sys.argv[2].startswith('+'): + #append to add list without initial + + add.append(sys.argv.pop(2)[1:]) + else: + #append to remove list without initial - + remove.append(sys.argv.pop(2)[1:]) + #skip eventual '--' + if sys.argv[2]=='--': sys.argv.pop(2) + #the rest is search terms + querystr = quote_query_line(sys.argv[2:]) + logging.warning("tag search-term "+querystr) + db = Database(mode=Database.MODE.READ_WRITE) + m = Query(db,querystr).search_messages() + for msg in m: + #actually add and remove all tags + map(msg.add_tag, add) + map(msg.remove_tag, remove) + #------------------------------------- elif sys.argv[1] == 'search-tags': if len(sys.argv) == 2: #no further search term @@ -150,7 +174,7 @@ if __name__ == '__main__': db = Database() m = Query(db,querystr).search_messages() print("\n".join([t for t in m.collect_tags()])) - + #------------------------------------- elif sys.argv[1] == 'dump': #TODO: implement "dump " db = Database() @@ -159,7 +183,7 @@ if __name__ == '__main__': m = q.search_messages() for msg in m: print("%s (%s)" % (msg.get_message_id(), msg.get_tags())) - + #------------------------------------- else: # unknown command print "Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1] @@ -167,9 +191,10 @@ if __name__ == '__main__': #TODO: implement """ +setup +new search [options...] [...] show [...] reply [options...] [...] -tag +|- [...] [--] [...] restore """ From 8345aab10c56dc4fe3ae7619042111524a062549 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 18 Mar 2010 08:50:48 +0100 Subject: [PATCH 033/106] implement Database.get_version() and Database.needs_upgrade() --- cnotmuch/database.py | 35 ++++++++++++++++++++++++++++++++++- docs/source/index.rst | 12 ++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 9c78b461..ad84f5eb 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -1,5 +1,5 @@ import ctypes -from ctypes import c_int, c_char_p, c_void_p, c_uint64 +from ctypes import c_int, c_char_p, c_void_p, c_uint, c_uint64, c_bool from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum import logging from datetime import date @@ -23,6 +23,10 @@ class Database(object): _get_path = nmlib.notmuch_database_get_path _get_path.restype = c_char_p + """notmuch_database_get_version""" + _get_version = nmlib.notmuch_database_get_version + _get_version.restype = c_uint + """notmuch_database_open (const char *path, notmuch_database_mode_t mode)""" _open = nmlib.notmuch_database_open _open.restype = c_void_p @@ -124,6 +128,35 @@ class Database(object): Wraps notmuch_database_get_path""" return Database._get_path(self._db) + def get_version(self): + """Returns the database format version + + :returns: The database version as positive integer + :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if + the database was not intitialized. + """ + if self._db is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + return Database._get_version (self._db) + + def needs_upgrade(self): + """Does this database need to be upgraded before writing to it? + + If this function returns TRUE then no functions that modify the + database (:meth:`Database.add_message`, :meth:`Database.add_tag`, + :meth:`Directory.set_mtime`, etc.) will work unless :meth:`upgrade` + is called successfully first. + + :returns: `True` or `False` + :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if + the database was not intitialized. + """ + if self._db is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + return notmuch_database_needs_upgrade(self.db) + def find_message(self, msgid): """Returns a :class:`Message` as identified by its message ID diff --git a/docs/source/index.rst b/docs/source/index.rst index 5b262bba..fc1d307e 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -45,6 +45,18 @@ or:: .. automethod:: get_path + .. automethod:: get_version + + .. automethod:: needs_upgrade + + .. automethod:: upgrade + + .. automethod:: get_directory + + .. automethod:: add_message + + .. automethod:: remove_message + .. automethod:: find_message .. automethod:: get_all_tags From 144953116e8e95e6cdc3b0fe69ddd16e7a3f1d01 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 18 Mar 2010 09:02:59 +0100 Subject: [PATCH 034/106] docs: stub out remaining missing bindings to get a reminder --- docs/source/index.rst | 45 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index fc1d307e..6d0d5fa3 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -96,8 +96,12 @@ or:: .. automethod:: set_sort + .. automethod:: search_threads + .. automethod:: search_messages + .. automethod:: count_messages + :class:`Messages` -- A bunch of messages ---------------------------------------- @@ -114,18 +118,32 @@ or:: .. automethod:: get_message_id + .. automethod:: get_thread_id + + .. automethod:: get_replies + + .. automethod:: get_filename + + .. automethod:: get_flag + + .. automethod:: set_flag + .. automethod:: get_date .. automethod:: get_header - .. automethod:: get_filename - .. automethod:: get_tags .. automethod:: remove_tag .. automethod:: add_tag + .. automethod:: remove_all_tags + + .. automethod:: freeze + + .. automethod:: thaw + .. automethod:: format_as_text .. automethod:: __str__ @@ -140,6 +158,26 @@ or:: .. automethod:: __str__ +:class:`Threads` -- Threads iterator +------------------------------------ + +To be implemented + +:class:`Thread` -- A single thread +------------------------------------ + +To be implemented + +:class:`Filenames` -- An iterator over filenames +------------------------------------------------ + +To be implemented + +:class:`Directoy` -- A directory entry in the database +------------------------------------------------------ + +To be implemented + :exc:`NotmuchError` -- A Notmuch execution error ------------------------------------------------ .. autoexception:: NotmuchError @@ -150,7 +188,8 @@ or:: :class:`STATUS` -- Notmuch operation return status -------------------------------------------------- .. autoclass:: STATUS - :members: + + To be documented Indices and tables ================== From 4ed01d055ac59b182535dfe44a33e52fc271279b Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 18 Mar 2010 09:34:10 +0100 Subject: [PATCH 035/106] Implement Message.freeze(), thaw(), and remove_all_tags() --- cnotmuch/database.py | 126 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 118 insertions(+), 8 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index ad84f5eb..92afa0a0 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -143,8 +143,8 @@ class Database(object): def needs_upgrade(self): """Does this database need to be upgraded before writing to it? - If this function returns TRUE then no functions that modify the - database (:meth:`Database.add_message`, :meth:`Database.add_tag`, + If this function returns True then no functions that modify the + database (:meth:`add_message`, :meth:`add_tag`, :meth:`Directory.set_mtime`, etc.) will work unless :meth:`upgrade` is called successfully first. @@ -721,15 +721,12 @@ class Message(object): STATUS.NULL_POINTER The 'tag' argument is NULL - STATUS.TAG_TOO_LONG The length of 'tag' is too long (exceeds Message.NOTMUCH_TAG_MAX) - STATUS.READ_ONLY_DATABASE Database was opened in read-only mode so message cannot be modified. - STATUS.NOT_INITIALIZED The message has not been initialized. """ @@ -758,15 +755,14 @@ class Message(object): STATUS.NULL_POINTER The 'tag' argument is NULL - NOTMUCH_STATUS_TAG_TOO_LONG + STATUS.TAG_TOO_LONG The length of 'tag' is too long (exceeds NOTMUCH_TAG_MAX) - NOTMUCH_STATUS_READ_ONLY_DATABASE + STATUS.READ_ONLY_DATABASE Database was opened in read-only mode so message cannot be modified. STATUS.NOT_INITIALIZED The message has not been initialized. - """ if self._msg is None: raise NotmuchError(STATUS.NOT_INITIALIZED) @@ -779,6 +775,120 @@ class Message(object): raise NotmuchError(status) + def remove_all_tags(self): + """Removes all tags from the given message. + + See :meth:`freeze` for an example showing how to safely + replace tag values. + + :returns: STATUS.SUCCESS if the tags were successfully removed. + Raises an exception otherwise. + :exception: :exc:`NotmuchError`. They have the following meaning: + + STATUS.READ_ONLY_DATABASE + Database was opened in read-only mode so message cannot + be modified. + STATUS.NOT_INITIALIZED + The message has not been initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + status = nmlib.notmuch_message_remove_all_tags(self._msg) + + if STATUS.SUCCESS == status: + # return on success + return status + + raise NotmuchError(status) + + def freeze(self): + """Freezes the current state of 'message' within the database + + This means that changes to the message state, (via :meth:`add_tag`, + :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be + committed to the database until the message is :meth:`thaw`ed. + + Multiple calls to freeze/thaw are valid and these calls will + "stack". That is there must be as many calls to thaw as to freeze + before a message is actually thawed. + + The ability to do freeze/thaw allows for safe transactions to + change tag values. For example, explicitly setting a message to + have a given set of tags might look like this:: + + msg.freeze() + msg.remove_all_tags() + for tag in new_tags: + msg.add_tag(tag) + msg.thaw() + + With freeze/thaw used like this, the message in the database is + guaranteed to have either the full set of original tag values, or + the full set of new tag values, but nothing in between. + + Imagine the example above without freeze/thaw and the operation + somehow getting interrupted. This could result in the message being + left with no tags if the interruption happened after + :meth:`remove_all_tags` but before :meth:`add_tag`. + + :returns: STATUS.SUCCESS if the message was successfully frozen. + Raises an exception otherwise. + :exception: :exc:`NotmuchError`. They have the following meaning: + + STATUS.READ_ONLY_DATABASE + Database was opened in read-only mode so message cannot + be modified. + STATUS.NOT_INITIALIZED + The message has not been initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + status = nmlib.notmuch_message_freeze(self._msg) + + if STATUS.SUCCESS == status: + # return on success + return status + + raise NotmuchError(status) + + def thaw(self): + """Thaws the current 'message' + + Thaw the current 'message', synchronizing any changes that may have + occurred while 'message' was frozen into the notmuch database. + + See :meth:`freeze` for an example of how to use this + function to safely provide tag changes. + + Multiple calls to freeze/thaw are valid and these calls with + "stack". That is there must be as many calls to thaw as to freeze + before a message is actually thawed. + + :returns: STATUS.SUCCESS if the message was successfully frozen. + Raises an exception otherwise. + :exception: :exc:`NotmuchError`. They have the following meaning: + + STATUS.UNBALANCED_FREEZE_THAW + An attempt was made to thaw an unfrozen message. + That is, there have been an unbalanced number of calls + to :meth:`freeze` and :meth:`thaw`. + STATUS.NOT_INITIALIZED + The message has not been initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + status = nmlib.notmuch_message_thaw(self._msg) + + if STATUS.SUCCESS == status: + # return on success + return status + + raise NotmuchError(status) + + def __str__(self): """A message() is represented by a 1-line summary""" msg = {} From d566ae643c0ec59157d34fe62b8aafdb1a49ef43 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 18 Mar 2010 18:48:36 +0100 Subject: [PATCH 036/106] Database(): honor NOTMUCH_CONFIG env variable for reading the standard database location. Also replaces all Database.MODE.* defaults in the keyword arguments with their numeric replacement, they do not seem to be available yet at that time, so the python parser complained about them as being unknown. --- cnotmuch/database.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 92afa0a0..9c1be675 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -1,4 +1,4 @@ -import ctypes +import ctypes, os from ctypes import c_int, c_char_p, c_void_p, c_uint, c_uint64, c_bool from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum import logging @@ -13,12 +13,12 @@ class Database(object): as well. Accessing these objects will lead to segfaults and other unexpected behavior. See above for more details. """ - MODE = Enum(['READ_ONLY','READ_WRITE']) - """Constants: Mode in which to open the database""" - _std_db_path = None """Class attribute to cache user's default database""" + MODE = Enum(['READ_ONLY','READ_WRITE']) + """Constants: Mode in which to open the database""" + """notmuch_database_get_path (notmuch_database_t *database)""" _get_path = nmlib.notmuch_database_get_path _get_path.restype = c_char_p @@ -43,11 +43,14 @@ class Database(object): _create = nmlib.notmuch_database_create _create.restype = c_void_p - def __init__(self, path=None, create=False, mode= MODE.READ_ONLY): - """If *path* is *None*, we will try to read a users notmuch - configuration and use his default database. If *create* is `True`, - the database will always be created in - :attr:`MODE`.READ_WRITE mode. + def __init__(self, path=None, create=False, mode= 0): + """If *path* is *None*, we will try to read a users notmuch + configuration and use his configured database. The location of the + configuration file can be specified through the environment variable + *NOTMUCH_CONFIG*, falling back to the default `~/.notmuch-config`. + + If *create* is `True`, the database will always be created in + :attr:`MODE`.READ_WRITE mode. Default mode for opening is READ_ONLY. :param path: Directory to open/create the database in (see above for behavior if `None`) @@ -94,14 +97,14 @@ class Database(object): raise NotmuchError( message="Cannot create db, this Database() already has an open one.") - res = Database._create(path, MODE.READ_WRITE) + res = Database._create(path, Database.MODE.READ_WRITE) if res is None: raise NotmuchError( message="Could not create the specified database") self._db = res - def open(self, path, mode= MODE.READ_ONLY): + def open(self, path, mode= 0): """Opens an existing database This function is used by __init__() and usually does not need @@ -204,9 +207,10 @@ class Database(object): Throws a NotmuchError if it cannot find it""" from ConfigParser import SafeConfigParser - import os.path config = SafeConfigParser() - config.read(os.path.expanduser('~/.notmuch-config')) + conf_f = os.getenv('NOTMUCH_CONFIG', + os.path.expanduser('~/.notmuch-config')) + config.read(conf_f) if not config.has_option('database','path'): raise NotmuchError(message= "No DB path specified and no user default found") From 5c936f9135f4fc6874d8826c4ebd505aa1710c69 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 18 Mar 2010 18:51:25 +0100 Subject: [PATCH 037/106] mention NOTMUCH_CONFIG in the notmuch docstring --- notmuch | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/notmuch b/notmuch index 5862b9ac..acebe38e 100755 --- a/notmuch +++ b/notmuch @@ -1,5 +1,10 @@ #!/usr/bin/env python -"""This is a notmuch implementation in python. It's goal is to allow running the test suite on the cnotmuch python bindings.""" +"""This is a notmuch implementation in python. It's goal is to allow running the test suite on the cnotmuch python bindings. + +This "binary" honors the NOTMUCH_CONFIG environmen variable for reading a user's +notmuch configuration (e.g. the database path) + +This code is licensed under the GNU GPL v3+.""" import sys, os, re, logging from cnotmuch.notmuch import Database, Query PREFIX=re.compile('(\w+):(.*$)') From dd8cdd9b6633c6e87aec8b2dc8cade09ec9b0381 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 18 Mar 2010 19:04:49 +0100 Subject: [PATCH 038/106] add original notmuch testsuite --- test/notmuch-test | 542 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 542 insertions(+) create mode 100755 test/notmuch-test diff --git a/test/notmuch-test b/test/notmuch-test new file mode 100755 index 00000000..7bc53eca --- /dev/null +++ b/test/notmuch-test @@ -0,0 +1,542 @@ +#!/bin/bash +set -e + +find_notmuch_binary () +{ + dir=$1 + + while [ -n "$dir" ]; do + bin=$dir/notmuch + if [ -x $bin ]; then + echo $bin + return + fi + dir=$(dirname $dir) + if [ "$dir" = "/" ]; then + break + fi + done + + echo notmuch +} + +increment_mtime_amount=0 +increment_mtime () +{ + dir=$1 + + increment_mtime_amount=$((increment_mtime_amount + 1)) + touch -d "+${increment_mtime_amount} seconds" $dir +} + +# Generate a new message in the mail directory, with a unique message +# ID and subject. The message is not added to the index. +# +# After this function returns, the filename of the generated message +# is available as $gen_msg_filename and the message ID is available as +# $gen_msg_id . +# +# This function supports named parameters with the bash syntax for +# assigning a value to an associative array ([name]=value). The +# supported parameters are: +# +# [dir]=directory/of/choice +# +# Generate the message in directory 'directory/of/choice' within +# the mail store. The directory will be created if necessary. +# +# [body]=text +# +# Text to use as the body of the email message +# +# '[from]="Some User "' +# '[to]="Some User "' +# '[subject]="Subject of email message"' +# '[date]="RFC 822 Date"' +# +# Values for email headers. If not provided, default values will +# be generated instead. +# +# '[cc]="Some User "' +# [reply-to]=some-address +# [in-reply-to]= +# +# Additional values for email headers. If these are not provided +# then the relevant headers will simply not appear in the +# message. +gen_msg_cnt=0 +gen_msg_filename="" +gen_msg_id="" +generate_message () +{ + # This is our (bash-specific) magic for doing named parameters + local -A template="($@)" + local additional_headers + + gen_msg_cnt=$((gen_msg_cnt + 1)) + gen_msg_name=msg-$(printf "%03d" $gen_msg_cnt) + gen_msg_id="${gen_msg_name}@notmuch-test-suite" + + if [ -z "${template[dir]}" ]; then + gen_msg_filename="${MAIL_DIR}/$gen_msg_name" + else + gen_msg_filename="${MAIL_DIR}/${template[dir]}/$gen_msg_name" + mkdir -p $(dirname $gen_msg_filename) + fi + + if [ -z "${template[body]}" ]; then + template[body]="This is just a test message at ${gen_msg_filename}" + fi + + if [ -z "${template[from]}" ]; then + template[from]="Notmuch Test Suite " + fi + + if [ -z "${template[to]}" ]; then + template[to]="Notmuch Test Suite " + fi + + if [ -z "${template[subject]}" ]; then + template[subject]="Test message ${gen_msg_filename}" + fi + + if [ -z "${template[date]}" ]; then + template[date]="Tue, 05 Jan 2010 15:43:57 -0800" + fi + + additional_headers="" + if [ ! -z "${template[reply-to]}" ]; then + additional_headers="Reply-To: ${template[reply-to]} +${additional_headers}" + fi + + if [ ! -z "${template[in-reply-to]}" ]; then + additional_headers="In-Reply-To: ${template[in-reply-to]} +${additional_headers}" + fi + + if [ ! -z "${template[cc]}" ]; then + additional_headers="Cc: ${template[cc]} +${additional_headers}" + fi + +cat <$gen_msg_filename +From: ${template[from]} +To: ${template[to]} +Message-Id: <${gen_msg_id}> +Subject: ${template[subject]} +Date: ${template[date]} +${additional_headers} +${template[body]} +EOF + + # Ensure that the mtime of the containing directory is updated + increment_mtime $(dirname ${gen_msg_filename}) +} + +# Generate a new message and add it to the index. +# +# All of the arguments and return values supported by generate_message +# are alos supported here, so see that function for details. +add_message () +{ + generate_message "$@" + + $NOTMUCH new > /dev/null +} + +NOTMUCH_IGNORED_OUTPUT_REGEXP='^Processed [0-9]*( total)? file|Found [0-9]* total file' +NOTMUCH_THREAD_ID_SQUELCH='s/thread:................/thread:XXX/' +execute_expecting () +{ + args=$1 + expected=$2 + + output=$($NOTMUCH $args | grep -v -E -e "$NOTMUCH_IGNORED_OUTPUT_REGEXP" | sed -e "$NOTMUCH_THREAD_ID_SQUELCH" || true) + if [ "$output" = "$expected" ]; then + echo " PASS" + else + echo " FAIL" + echo " Expected output: $expected" + echo " Actual output: $output" + fi +} + +TEST_DIR=$(pwd)/test.$$ +MAIL_DIR=${TEST_DIR}/mail +export NOTMUCH_CONFIG=${TEST_DIR}/notmuch-config +NOTMUCH=$(find_notmuch_binary $(pwd)) + +rm -rf ${TEST_DIR} +mkdir ${TEST_DIR} +cd ${TEST_DIR} + +mkdir ${MAIL_DIR} + +cat < ${NOTMUCH_CONFIG} +[database] +path=${MAIL_DIR} + +[user] +name=Notmuch Test Suite +primary_email=test_suite@notmuchmail.org +other_email=test_suite_other@notmuchmail.org +EOF + +printf "Testing \"notmuch new\" in several variations:\n" +printf " No new messages...\t\t" +execute_expecting new "No new mail." + +printf " Single new message...\t\t" +generate_message +execute_expecting new "Added 1 new message to the database." + +printf " Multiple new messages...\t" +generate_message +generate_message +execute_expecting new "Added 2 new messages to the database." + +printf " No new messages (non-empty DB)... " +execute_expecting new "No new mail." + +printf " New directories...\t\t" +rm -rf ${MAIL_DIR}/* ${MAIL_DIR}/.notmuch +mkdir ${MAIL_DIR}/def +mkdir ${MAIL_DIR}/ghi +generate_message [dir]=def + +execute_expecting new "Added 1 new message to the database." + +printf " Alternate inode order...\t" + +rm -rf ${MAIL_DIR}/.notmuch +mv ${MAIL_DIR}/ghi ${MAIL_DIR}/abc +rm ${MAIL_DIR}/def/* +generate_message [dir]=abc + +execute_expecting new "Added 1 new message to the database." + +printf " Message moved in...\t\t" +rm -rf ${MAIL_DIR}/* ${MAIL_DIR}/.notmuch +generate_message +tmp_msg_filename=tmp/$gen_msg_filename +mkdir -p $(dirname $tmp_msg_filename) +mv $gen_msg_filename $tmp_msg_filename +increment_mtime ${MAIL_DIR} +$NOTMUCH new > /dev/null +mv $tmp_msg_filename $gen_msg_filename +increment_mtime ${MAIL_DIR} +execute_expecting new "Added 1 new message to the database." + +printf " Renamed message...\t\t" + +generate_message +$NOTMUCH new > /dev/null +mv $gen_msg_filename ${gen_msg_filename}-renamed +increment_mtime ${MAIL_DIR} +execute_expecting new "No new mail. Detected 1 file rename." + +printf " Deleted message...\t\t" + +rm ${gen_msg_filename}-renamed +increment_mtime ${MAIL_DIR} +execute_expecting new "No new mail. Removed 1 message." + +printf " Renamed directory...\t\t" + +generate_message [dir]=dir +generate_message [dir]=dir +generate_message [dir]=dir + +$NOTMUCH new > /dev/null + +mv ${MAIL_DIR}/dir ${MAIL_DIR}/dir-renamed +increment_mtime ${MAIL_DIR} + +execute_expecting new "No new mail. Detected 3 file renames." + +printf " Deleted directory...\t\t" + +rm -rf ${MAIL_DIR}/dir-renamed +increment_mtime ${MAIL_DIR} + +execute_expecting new "No new mail. Removed 3 messages." + +printf " New directory (at end of list)... " + +generate_message [dir]=zzz +generate_message [dir]=zzz +generate_message [dir]=zzz + +execute_expecting new "Added 3 new messages to the database." + +printf " Deleted directory (end of list)... " + +rm -rf ${MAIL_DIR}/zzz +increment_mtime ${MAIL_DIR} + +execute_expecting new "No new mail. Removed 3 messages." + +printf " New symlink to directory...\t" + +rm -rf ${MAIL_DIR}/.notmuch +mv ${MAIL_DIR} ${TEST_DIR}/actual_maildir + +mkdir ${MAIL_DIR} +ln -s ${TEST_DIR}/actual_maildir ${MAIL_DIR}/symlink + +execute_expecting new "Added 1 new message to the database." + +printf " New symlink to a file...\t" +generate_message +external_msg_filename=${TEST_DIR}/external/$(basename $gen_msg_filename) +mkdir -p $(dirname $external_msg_filename) +mv $gen_msg_filename $external_msg_filename +ln -s $external_msg_filename $gen_msg_filename +increment_mtime ${MAIL_DIR} +execute_expecting new "Added 1 new message to the database." + +printf " New two-level directory...\t" + +generate_message [dir]=two/levels +generate_message [dir]=two/levels +generate_message [dir]=two/levels + +execute_expecting new "Added 3 new messages to the database." + +printf " Deleted two-level directory... " + +rm -rf ${MAIL_DIR}/two +increment_mtime ${MAIL_DIR} + +execute_expecting new "No new mail. Removed 3 messages." + +printf "\nTesting \"notmuch search\" in several variations:\n" + +printf " Search body...\t\t\t" +add_message '[subject]="body search"' '[date]="Sat, 01 Jan 2000 12:00:00 -0000"' [body]=bodysearchtest +execute_expecting "search bodysearchtest" "thread:XXX 2000-01-01 [1/1] Notmuch Test Suite; body search (inbox unread)" + +printf " Search by from:...\t\t" +add_message '[subject]="search by from"' '[date]="Sat, 01 Jan 2000 12:00:00 -0000"' [from]=searchbyfrom +execute_expecting "search from:searchbyfrom" "thread:XXX 2000-01-01 [1/1] searchbyfrom; search by from (inbox unread)" + +printf " Search by to:...\t\t" +add_message '[subject]="search by to"' '[date]="Sat, 01 Jan 2000 12:00:00 -0000"' [to]=searchbyto +execute_expecting "search to:searchbyto" "thread:XXX 2000-01-01 [1/1] Notmuch Test Suite; search by to (inbox unread)" + +printf " Search by subject:...\t\t" +add_message [subject]=subjectsearchtest '[date]="Sat, 01 Jan 2000 12:00:00 -0000"' +execute_expecting "search subject:subjectsearchtest" "thread:XXX 2000-01-01 [1/1] Notmuch Test Suite; subjectsearchtest (inbox unread)" + +printf " Search by id:...\t\t" +add_message '[subject]="search by id"' '[date]="Sat, 01 Jan 2000 12:00:00 -0000"' +execute_expecting "search id:${gen_msg_id}" "thread:XXX 2000-01-01 [1/1] Notmuch Test Suite; search by id (inbox unread)" + +printf " Search by tag:...\t\t" +add_message '[subject]="search by tag"' '[date]="Sat, 01 Jan 2000 12:00:00 -0000"' +$NOTMUCH tag +searchbytag id:${gen_msg_id} +execute_expecting "search tag:searchbytag" "thread:XXX 2000-01-01 [1/1] Notmuch Test Suite; search by tag (inbox searchbytag unread)" + +printf " Search by thread:...\t\t" +add_message '[subject]="search by thread"' '[date]="Sat, 01 Jan 2000 12:00:00 -0000"' +thread_id=$($NOTMUCH search id:${gen_msg_id} | sed -e 's/thread:\([a-f0-9]*\).*/\1/') +execute_expecting "search thread:${thread_id}" "thread:XXX 2000-01-01 [1/1] Notmuch Test Suite; search by thread (inbox unread)" + +printf " Search body (phrase)...\t" +add_message '[subject]="body search (phrase)"' '[date]="Sat, 01 Jan 2000 12:00:00 -0000"' '[body]="body search (phrase)"' +execute_expecting "search 'body search (phrase)'" "thread:XXX 2000-01-01 [1/1] Notmuch Test Suite; body search (phrase) (inbox unread)" + +printf " Search by from: (address)...\t" +add_message '[subject]="search by from (address)"' '[date]="Sat, 01 Jan 2000 12:00:00 -0000"' [from]=searchbyfrom@example.com +execute_expecting "search from:searchbyfrom@example.com" "thread:XXX 2000-01-01 [1/1] searchbyfrom@example.com; search by from (address) (inbox unread)" + +printf " Search by from: (name)...\t" +add_message '[subject]="search by from (name)"' '[date]="Sat, 01 Jan 2000 12:00:00 -0000"' '[from]="Search By From Name "' +execute_expecting "search from:'Search By From Name'" "thread:XXX 2000-01-01 [1/1] Search By From Name; search by from (name) (inbox unread)" + +printf " Search by to: (address)...\t" +add_message '[subject]="search by to (address)"' '[date]="Sat, 01 Jan 2000 12:00:00 -0000"' [to]=searchbyto@example.com +execute_expecting "search to:searchbyto@example.com" "thread:XXX 2000-01-01 [1/1] Notmuch Test Suite; search by to (address) (inbox unread)" + +printf " Search by to: (name)...\t" +add_message '[subject]="search by to (name)"' '[date]="Sat, 01 Jan 2000 12:00:00 -0000"' '[to]="Search By To Name "' +execute_expecting "search to:'Search By To Name'" "thread:XXX 2000-01-01 [1/1] Notmuch Test Suite; search by to (name) (inbox unread)" + +printf " Search by subject: (phrase)...\t" +add_message '[subject]="subject search test (phrase)"' '[date]="Sat, 01 Jan 2000 12:00:00 -0000"' +execute_expecting "search subject:'subject search test (phrase)'" "thread:XXX 2000-01-01 [1/1] Notmuch Test Suite; subject search test (phrase) (inbox unread)" + +printf "\nTesting \"notmuch reply\" in several variations:\n" + +printf " Basic reply...\t\t\t" +add_message '[from]="Sender "' \ + [to]=test_suite@notmuchmail.org \ + [subject]=notmuch-reply-test \ + '[date]="Tue, 05 Jan 2010 15:43:56 -0800"' \ + '[body]="basic reply test"' + +execute_expecting "reply id:${gen_msg_id}" "From: Notmuch Test Suite +Subject: Re: notmuch-reply-test +To: Sender +Bcc: test_suite@notmuchmail.org +In-Reply-To: <${gen_msg_id}> +References: <${gen_msg_id}> + +On Tue, 05 Jan 2010 15:43:56 -0800, Sender wrote: +> basic reply test" + +printf " Multiple recipients...\t\t" +add_message '[from]="Sender "' \ + '[to]="test_suite@notmuchmail.org, Someone Else "' \ + [subject]=notmuch-reply-test \ + '[date]="Tue, 05 Jan 2010 15:43:56 -0800"' \ + '[body]="Multiple recipients"' + +execute_expecting "reply id:${gen_msg_id}" "From: Notmuch Test Suite +Subject: Re: notmuch-reply-test +To: Sender , Someone Else +Bcc: test_suite@notmuchmail.org +In-Reply-To: <${gen_msg_id}> +References: <${gen_msg_id}> + +On Tue, 05 Jan 2010 15:43:56 -0800, Sender wrote: +> Multiple recipients" + +printf " Reply with CC...\t\t" +add_message '[from]="Sender "' \ + [to]=test_suite@notmuchmail.org \ + '[cc]="Other Parties "' \ + [subject]=notmuch-reply-test \ + '[date]="Tue, 05 Jan 2010 15:43:56 -0800"' \ + '[body]="reply with CC"' + +execute_expecting "reply id:${gen_msg_id}" "From: Notmuch Test Suite +Subject: Re: notmuch-reply-test +To: Sender +Cc: Other Parties +Bcc: test_suite@notmuchmail.org +In-Reply-To: <${gen_msg_id}> +References: <${gen_msg_id}> + +On Tue, 05 Jan 2010 15:43:56 -0800, Sender wrote: +> reply with CC" + +printf " Reply from alternate address..." +add_message '[from]="Sender "' \ + [to]=test_suite_other@notmuchmail.org \ + [subject]=notmuch-reply-test \ + '[date]="Tue, 05 Jan 2010 15:43:56 -0800"' \ + '[body]="reply from alternate address"' + +execute_expecting "reply id:${gen_msg_id}" "From: Notmuch Test Suite +Subject: Re: notmuch-reply-test +To: Sender +Bcc: test_suite@notmuchmail.org +In-Reply-To: <${gen_msg_id}> +References: <${gen_msg_id}> + +On Tue, 05 Jan 2010 15:43:56 -0800, Sender wrote: +> reply from alternate address" + +printf " Support for Reply-To...\t" +add_message '[from]="Sender "' \ + [to]=test_suite@notmuchmail.org \ + [subject]=notmuch-reply-test \ + '[date]="Tue, 05 Jan 2010 15:43:56 -0800"' \ + '[body]="support for reply-to"' \ + '[reply-to]="Sender "' + +execute_expecting "reply id:${gen_msg_id}" "From: Notmuch Test Suite +Subject: Re: notmuch-reply-test +To: Sender +Bcc: test_suite@notmuchmail.org +In-Reply-To: <${gen_msg_id}> +References: <${gen_msg_id}> + +On Tue, 05 Jan 2010 15:43:56 -0800, Sender wrote: +> support for reply-to" + +printf " Un-munging Reply-To...\t\t" +add_message '[from]="Sender "' \ + '[to]="Some List "' \ + [subject]=notmuch-reply-test \ + '[date]="Tue, 05 Jan 2010 15:43:56 -0800"' \ + '[body]="Un-munging Reply-To"' \ + '[reply-to]="Evil Munging List "' + +execute_expecting "reply id:${gen_msg_id}" "From: Notmuch Test Suite +Subject: Re: notmuch-reply-test +To: Sender , Some List +Bcc: test_suite@notmuchmail.org +In-Reply-To: <${gen_msg_id}> +References: <${gen_msg_id}> + +On Tue, 05 Jan 2010 15:43:56 -0800, Sender wrote: +> Un-munging Reply-To" + +printf "\nTesting handling of uuencoded data:\n" + +add_message [subject]=uuencodetest '[date]="Sat, 01 Jan 2000 12:00:00 -0000"' \ +'[body]="This message is used to ensure that notmuch correctly handles a +message containing a block of uuencoded data. First, we have a marker +this content beforeuudata . Then we beging the uunencoded data itself: + +begin 644 bogus-uuencoded-data +M0123456789012345678901234567890123456789012345678901234567890 +MOBVIOUSLY, THIS IS NOT ANY SORT OF USEFUL UUNECODED DATA. +MINSTEAD THIS IS JUST A WAY TO ENSURE THAT THIS BLOCK OF DATA +MIS CORRECTLY IGNORED WHEN NOTMUCH CREATES ITS INDEX. SO WE +MINCLUDE A DURINGUUDATA MARKER THAT SHOULD NOT RESULT IN ANY +MSEARCH RESULT. +\` +end + +Finally, we have our afteruudata marker as well."' + +printf " Ensure content before uu data is indexed..." +execute_expecting "search beforeuudata" "thread:XXX 2000-01-01 [1/1] Notmuch Test Suite; uuencodetest (inbox unread)" +printf " Ensure uu data is not indexed...\t" +execute_expecting "search DURINGUUDATA" "" +printf " Ensure content after uu data is indexed..." +execute_expecting "search afteruudata" "thread:XXX 2000-01-01 [1/1] Notmuch Test Suite; uuencodetest (inbox unread)" + +printf "\nTesting \"notmuch dump\" and \"notmuch restore\":\n" + +printf " Dumping all tags...\t\t" +$NOTMUCH dump dump.expected +echo " PASS" + +printf " Clearing all tags...\t\t" +sed -e 's/(\([^(]*\))$/()/' < dump.expected > clear.expected +$NOTMUCH restore clear.expected +$NOTMUCH dump clear.actual +if diff clear.expected clear.actual > /dev/null; then + echo " PASS" +else + echo " FAIL" + echo " Expected output: See file clear.expected" + echo " Actual output: See file clear.actual" +fi + +printf " Restoring original tags...\t" +$NOTMUCH restore dump.expected +$NOTMUCH dump dump.actual +if diff dump.expected dump.actual > /dev/null; then + echo " PASS" +else + echo " FAIL" + echo " Expected output: See file dump.expected" + echo " Actual output: See file dump.actual" +fi + +printf " Restore with nothing to do...\t" +$NOTMUCH restore dump.expected +echo " PASS" + +cat < Date: Thu, 18 Mar 2010 19:11:22 +0100 Subject: [PATCH 039/106] notmuch: implement dump to file. Fake notmuch new by calling the real notmuch to enable running the testsuite --- notmuch | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/notmuch b/notmuch index acebe38e..6e41fa21 100755 --- a/notmuch +++ b/notmuch @@ -6,6 +6,7 @@ notmuch configuration (e.g. the database path) This code is licensed under the GNU GPL v3+.""" import sys, os, re, logging +from subprocess import call from cnotmuch.notmuch import Database, Query PREFIX=re.compile('(\w+):(.*$)') #TODO Handle variable: NOTMUCH-CONFIG @@ -113,6 +114,11 @@ if __name__ == '__main__': """ Interactively setup notmuch for first use. """ print "Not implemented." #------------------------------------- + elif sys.argv[1] == 'new': + """ Interactively setup notmuch for first use. """ + #print "Not implemented. We cheat by calling the proper notmuch" + call(['notmuch new'],shell=True) + #------------------------------------- elif sys.argv[1] == 'help': if len(sys.argv) == 2: print HELPTEXT else: print "Not implemented" @@ -182,12 +188,16 @@ if __name__ == '__main__': #------------------------------------- elif sys.argv[1] == 'dump': #TODO: implement "dump " + if len(sys.argv) == 2: + f = sys.stdout + else: + f = open(sys.argv[2],"w") db = Database() q = Query(db,'') q.set_sort(Query.SORT.MESSAGE_ID) m = q.search_messages() for msg in m: - print("%s (%s)" % (msg.get_message_id(), msg.get_tags())) + f.write("%s (%s)\n" % (msg.get_message_id(), msg.get_tags())) #------------------------------------- else: # unknown command From cd19699e0d32eccc6481c0a3294b38c4fe0042e8 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Fri, 19 Mar 2010 08:32:29 +0100 Subject: [PATCH 040/106] Implement Database.count_messages() --- cnotmuch/database.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 9c1be675..6a74f5d5 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -250,6 +250,11 @@ class Query(object): _search_messages = nmlib.notmuch_query_search_messages _search_messages.restype = c_void_p + + """notmuch_query_count_messages""" + _count_messages = _nmlib.notmuch_query_count_messages + _count_messages.restype = c_uint + def __init__(self, db, querystr): """ :param db: An open database which we derive the Query from. @@ -327,6 +332,24 @@ class Query(object): return Messages(msgs_p,self) + def count_messages(self): + """Estimate the number of messages matching the query + + This function performs a search and returns Xapian's best + guess as to the number of matching messages. It is somewhat + faster than performing :meth:`search_messages` and counting + the result with `len()`. Technically, it wraps the underlying + *notmuch_query_count_messages* function. + + :returns: :class:`Messages` + :exception: :exc:`NotmuchError` + + * STATUS.NOT_INITIALIZED if query is not inited + """ + if self._query is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + return Query._count_messages(self._query) def __del__(self): """Close and free the Query""" From ca388481428cd7dfa5e75c474567e6880c2ce60f Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Fri, 19 Mar 2010 08:44:34 +0100 Subject: [PATCH 041/106] Query(): Fix _nmlib -> nmlib type & clarify docs a bit --- cnotmuch/database.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 6a74f5d5..6a118bdf 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -252,7 +252,7 @@ class Query(object): """notmuch_query_count_messages""" - _count_messages = _nmlib.notmuch_query_count_messages + _count_messages = nmlib.notmuch_query_count_messages _count_messages.restype = c_uint def __init__(self, db, querystr): @@ -336,9 +336,10 @@ class Query(object): """Estimate the number of messages matching the query This function performs a search and returns Xapian's best - guess as to the number of matching messages. It is somewhat - faster than performing :meth:`search_messages` and counting - the result with `len()`. Technically, it wraps the underlying + guess as to the number of matching messages. It is much faster + than performing :meth:`search_messages` and counting the + result with `len()` (although it always returned the same + result in my tests). Technically, it wraps the underlying *notmuch_query_count_messages* function. :returns: :class:`Messages` From 540536b98d5bbae84b62dbe03992708d0cff9c43 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Fri, 19 Mar 2010 08:47:14 +0100 Subject: [PATCH 042/106] notmuch: Use Query.count_messages() rather len(Query.search_messages()) In my tests, this is about 10 times faster and returned the same results, although both operations are really fast. --- notmuch | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/notmuch b/notmuch index 6e41fa21..5fc98bbe 100755 --- a/notmuch +++ b/notmuch @@ -149,7 +149,8 @@ if __name__ == '__main__': #mangle arguments wrapping terms with spaces in quotes querystr = quote_query_line(sys.argv[2:]) logging.debug("count "+querystr) - print(len(Query(db,querystr).search_messages())) + print(Query(db,querystr).count_messages()) + #------------------------------------- elif sys.argv[1] == 'tag': #build lists of tags to be added and removed From f2c49bb070f94bcd8ebd19150e640d5111d46777 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Fri, 19 Mar 2010 09:11:22 +0100 Subject: [PATCH 043/106] notmuch: implement restore. We pass now all 'dump' and 'restore' tests in the test suite --- notmuch | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/notmuch b/notmuch index 5fc98bbe..d28c6ea4 100755 --- a/notmuch +++ b/notmuch @@ -5,6 +5,7 @@ This "binary" honors the NOTMUCH_CONFIG environmen variable for reading a user's notmuch configuration (e.g. the database path) This code is licensed under the GNU GPL v3+.""" +from __future__ import with_statement # This isn't required in Python 2.6 import sys, os, re, logging from subprocess import call from cnotmuch.notmuch import Database, Query @@ -167,7 +168,7 @@ if __name__ == '__main__': if sys.argv[2]=='--': sys.argv.pop(2) #the rest is search terms querystr = quote_query_line(sys.argv[2:]) - logging.warning("tag search-term "+querystr) + logging.debug("tag search-term "+querystr) db = Database(mode=Database.MODE.READ_WRITE) m = Query(db,querystr).search_messages() for msg in m: @@ -200,6 +201,45 @@ if __name__ == '__main__': for msg in m: f.write("%s (%s)\n" % (msg.get_message_id(), msg.get_tags())) #------------------------------------- + elif sys.argv[1] == 'restore': + import re + if len(sys.argv) == 2: + print("No filename given. Reading dump from stdin.") + f = sys.stdin + else: + f = open(sys.argv[2],"r") + #split the msg id and the tags + MSGID_TAGS = re.compile("(\S+)\s\((.*)\)$") + db = Database(mode=Database.MODE.READ_WRITE) + + #read each line of the dump file + for line in f: + m = MSGID_TAGS.match(line) + if not m: + sys.stderr.write("Warning: Ignoring invalid input line: %s" % + line) + continue + # split line in components and fetch message + msg_id = m.group(1) + new_tags= set(m.group(2).split()) + msg = db.find_message(msg_id) + + if msg == None: + sys.stderr.write( + "Warning: Cannot apply tags to missing message: %s\n" % id) + continue + + #do nothing if the old set of tags is the same as the new one + old_tags = set(msg.get_tags()) + if old_tags == new_tags: continue + + #set the new tags + msg.freeze() + msg.remove_all_tags() + for tag in new_tags: msg.add_tag(tag) + msg.thaw() + + #------------------------------------- else: # unknown command print "Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1] From 7739b6e46966150be6ac6e1467bdf0a3c7da5a02 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Fri, 19 Mar 2010 09:38:43 +0100 Subject: [PATCH 044/106] hgignore: ignore test/test* --- .hgignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.hgignore b/.hgignore index a6cc1733..b4c079d5 100644 --- a/.hgignore +++ b/.hgignore @@ -1,4 +1,5 @@ .*\.pyc$ ~$ ^docs/html -^docs/build \ No newline at end of file +^docs/build +^test/test \ No newline at end of file From cd109ef55916389c481fc6974a4739fdf1899c32 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Fri, 19 Mar 2010 09:39:23 +0100 Subject: [PATCH 045/106] notmuch restore: only delete a message's old tags if they are not a subset of the new ones. --- notmuch | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/notmuch b/notmuch index d28c6ea4..e4e07bcf 100755 --- a/notmuch +++ b/notmuch @@ -235,7 +235,8 @@ if __name__ == '__main__': #set the new tags msg.freeze() - msg.remove_all_tags() + #only remove tags if the new ones are not a superset anyway + if not (new_tags > old_tags): msg.remove_all_tags() for tag in new_tags: msg.add_tag(tag) msg.thaw() From b42fdb0f70c23d72b0c7e91c6e09f48192a8206c Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Fri, 19 Mar 2010 09:46:44 +0100 Subject: [PATCH 046/106] implement Message.get_thread_id() --- cnotmuch/database.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 6a118bdf..0ae24ec2 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -620,10 +620,15 @@ class Message(object): """notmuch_message_get_filename (notmuch_message_t *message)""" _get_filename = nmlib.notmuch_message_get_filename _get_filename.restype = c_char_p + """notmuch_message_get_message_id (notmuch_message_t *message)""" _get_message_id = nmlib.notmuch_message_get_message_id _get_message_id.restype = c_char_p + """notmuch_message_get_thread_id""" + _get_thread_id = nmlib.notmuch_message_get_thread_id + _get_thread_id.restype = c_char_p + """notmuch_message_get_tags (notmuch_message_t *message)""" _get_tags = nmlib.notmuch_message_get_tags _get_tags.restype = c_void_p @@ -653,7 +658,7 @@ class Message(object): def get_message_id(self): - """Return the message ID + """Returns the message ID :returns: String with a message ID :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message @@ -663,6 +668,24 @@ class Message(object): raise NotmuchError(STATUS.NOT_INITIALIZED) return Message._get_message_id(self._msg) + def get_thread_id(self): + """Returns the thread ID + + The returned string belongs to 'message' will only be valid for as + long as the message is valid. + + This function will not return None since Notmuch ensures that every + message belongs to a single thread. + + :returns: String with a thread ID + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + return Message._get_thread_id (self._msg); + def get_date(self): """Returns time_t of the message date From c6fa1ae8d5ee7cd27b4754d696d827655b27ff4a Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Fri, 19 Mar 2010 10:51:35 +0100 Subject: [PATCH 047/106] Database(): Factor out the test for an initialized self._db We need it in basically every function, so let's split this out. --- cnotmuch/database.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 0ae24ec2..952bab41 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -77,6 +77,11 @@ class Database(object): else: self.create(path) + def _verify_initialized_db(self): + """Raises a NotmuchError in case self._db is still None""" + if self._db is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + def create(self, path): """Creates a new notmuch database @@ -129,6 +134,9 @@ class Database(object): """Returns the file path of an open database Wraps notmuch_database_get_path""" + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + return Database._get_path(self._db) def get_version(self): @@ -138,8 +146,8 @@ class Database(object): :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if the database was not intitialized. """ - if self._db is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + # Raise a NotmuchError if not initialized + self._verify_initialized_db() return Database._get_version (self._db) @@ -155,8 +163,8 @@ class Database(object): :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if the database was not intitialized. """ - if self._db is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + # Raise a NotmuchError if not initialized + self._verify_initialized_db() return notmuch_database_needs_upgrade(self.db) @@ -172,8 +180,9 @@ class Database(object): :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if the database was not intitialized. """ - if self._db is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + msg_p = Database._find_message(self._db, msgid) if msg_p is None: return None @@ -185,8 +194,8 @@ class Database(object): :returns: :class:`Tags` :execption: :exc:`NotmuchError` with STATUS.NULL_POINTER on error """ - if self._db is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) + # Raise a NotmuchError if not initialized + self._verify_initialized_db() tags_p = Database._get_all_tags (self._db) if tags_p == None: From 1ceabfc269f32a871d63e71b085f9a31a66fa082 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Fri, 19 Mar 2010 10:52:15 +0100 Subject: [PATCH 048/106] docs: Various typo fixes in docstrings --- cnotmuch/database.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 952bab41..f611777c 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -227,14 +227,16 @@ class Database(object): @property def db_p(self): - """Property returning a pointer to the notmuch_database_t or `None` + """Property returning a pointer to `notmuch_database_t` or `None` - This should normally not be needed by a user.""" + This should normally not be needed by a user (and is not yet + guaranteed to remain stable in future versions). + """ return self._db #------------------------------------------------------------------------------ class Query(object): - """ Represents a search query on an opened :class:`Database`. + """Represents a search query on an opened :class:`Database`. A query selects and filters a subset of messages from the notmuch database we derive from. @@ -276,7 +278,7 @@ class Query(object): self.create(db, querystr) def create(self, db, querystr): - """Creates a new query derived from a Database. + """Creates a new query derived from a Database This function is utilized by __init__() and usually does not need to be called directly. From c90c28ded713936080b9862d4bc8502bc47da83a Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Fri, 19 Mar 2010 10:53:14 +0100 Subject: [PATCH 049/106] Database(): implement as of yet untested add_message() and remove_message() --- cnotmuch/database.py | 93 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index f611777c..cad73c69 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -1,5 +1,5 @@ import ctypes, os -from ctypes import c_int, c_char_p, c_void_p, c_uint, c_uint64, c_bool +from ctypes import c_int, c_char_p, c_void_p, c_uint, c_uint64, c_bool, byref from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum import logging from datetime import date @@ -168,6 +168,97 @@ class Database(object): return notmuch_database_needs_upgrade(self.db) + def add_message(self, filename): + """Adds a new message to the database + + `filename` should be a path relative to the path of the open + database (see :meth:`get_path`), or else should be an absolute + filename with initial components that match the path of the + database. + + The file should be a single mail message (not a multi-message mbox) + that is expected to remain at its current location, since the + notmuch database will reference the filename, and will not copy the + entire contents of the file. + + :returns: On success, we return + + 1) a :class:`Message` object that can be used for things + such as adding tags to the just-added message. + 2) one of the following STATUS values: + + STATUS.SUCCESS + Message successfully added to database. + STATUS.DUPLICATE_MESSAGE_ID + Message has the same message ID as another message already + in the database. The new filename was successfully added + to the message in the database. + + :rtype: 2-tuple(:class:`Message`, STATUS) + + :exception: Raises a :exc:`NotmuchError` with the following meaning. + If such an exception occurs, nothing was added to the database. + + STATUS.FILE_ERROR + An error occurred trying to open the file, (such as + permission denied, or file not found, etc.). + STATUS.FILE_NOT_EMAIL + The contents of filename don't look like an email message. + STATUS.READ_ONLY_DATABASE + Database was opened in read-only mode so no message can + be added. + STATUS.NOT_INITIALIZED + The database has not been initialized. + """ + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + + msg_p = c_void_p() + status = nmlib.notmuch_database_add_message(self._db, + filename, + byref(msg_p)) + + if not status in [STATUS.SUCCESS,STATUS.DUPLICATE_MESSAGE_ID]: + raise NotmuchError(status) + + #construct Message() and return + msg = Message(msg_p, self) + return (msg, status) + + def remove_message(self, filename): + """Removes a message from the given notmuch database + + Note that only this particular filename association is removed from + the database. If the same message (as determined by the message ID) + is still available via other filenames, then the message will + persist in the database for those filenames. When the last filename + is removed for a particular message, the database content for that + message will be entirely removed. + + :returns: A STATUS.* value with the following meaning: + + STATUS.SUCCESS + The last filename was removed and the message was removed + from the database. + STATUS.DUPLICATE_MESSAGE_ID + This filename was removed but the message persists in the + database with at least one other filename. + + :exception: Raises a :exc:`NotmuchError` with the following meaning. + If such an exception occurs, nothing was removed from the database. + + STATUS.READ_ONLY_DATABASE + Database was opened in read-only mode so no message can be + removed. + STATUS.NOT_INITIALIZED + The database has not been initialized. + """ + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + + status = nmlib.notmuch_database_remove_message(self._db, + filename) + def find_message(self, msgid): """Returns a :class:`Message` as identified by its message ID From 0b57cb8ed9850d1315a60ff23113e343b531170e Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Fri, 19 Mar 2010 10:55:06 +0100 Subject: [PATCH 050/106] Add Database.create_query() as a shorthand for db=Database();q=Query(db,"") This is a convenience extension to the C API. I hardly saves any typing, but let's us automatically free the top-level Database() object when we delete the Query(). --- cnotmuch/database.py | 22 ++++++++++++++++++++++ docs/source/index.rst | 1 + 2 files changed, 23 insertions(+) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index cad73c69..c815e440 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -293,6 +293,28 @@ class Database(object): raise NotmuchError(STATUS.NULL_POINTER) return Tags(tags_p, self) + def create_query(self, querystring): + """Returns a :class:`Query` derived from this database + + This is a shorthand method for doing:: + # short version + # Automatically frees the Database() when 'q' is deleted + + q = Database(dbpath).create_query('from:"Biene Maja"') + + # long version, which is functionally equivalent but will keep the + # Database in the 'db' variable around after we delete 'q': + + db = Database(dbpath) + q = Query(db,'from:"Biene Maja"') + + This function is a python extension and not in the underlying C API. + """ + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + + return Query(self._db, querystring) + def __repr__(self): return "'Notmuch DB " + self.get_path() + "'" diff --git a/docs/source/index.rst b/docs/source/index.rst index 6d0d5fa3..e04bc843 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -61,6 +61,7 @@ or:: .. automethod:: get_all_tags + .. automethod:: create_query .. attribute:: Database.MODE From e3aa28f05531723aa3b182be5194614372a8ee54 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Fri, 19 Mar 2010 11:01:19 +0100 Subject: [PATCH 051/106] docs: Add missing newline that broke formatting --- cnotmuch/database.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index c815e440..1f06d433 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -297,6 +297,7 @@ class Database(object): """Returns a :class:`Query` derived from this database This is a shorthand method for doing:: + # short version # Automatically frees the Database() when 'q' is deleted From 5186611851f6a38e397287cb94cd7ef502c1c902 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Fri, 19 Mar 2010 14:24:40 +0100 Subject: [PATCH 052/106] Implement Message.get_replies() --- cnotmuch/database.py | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 1f06d433..b759391a 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -714,7 +714,13 @@ class Messages(object): """len(:class:`Messages`) returns the number of contained messages .. note:: As this iterates over the messages, we will not be able to - iterate over them again (as in retrieve them)! + iterate over them again! So this will fail:: + + #THIS FAILS + msgs = Database().create_query('').search_message() + if len(msgs) > 0: #this 'exhausts' msgs + # next line raises NotmuchError(STATUS.NOT_INITIALIZED)!!! + for msg in msgs: print msg """ if self._msgs is None: raise NotmuchError(STATUS.NOT_INITIALIZED) @@ -754,6 +760,10 @@ class Message(object): _get_thread_id = nmlib.notmuch_message_get_thread_id _get_thread_id.restype = c_char_p + """notmuch_message_get_replies""" + _get_replies = nmlib.notmuch_message_get_replies + _get_replies.restype = c_void_p + """notmuch_message_get_tags (notmuch_message_t *message)""" _get_tags = nmlib.notmuch_message_get_tags _get_tags.restype = c_void_p @@ -811,6 +821,33 @@ class Message(object): return Message._get_thread_id (self._msg); + def get_replies(self): + """Gets all direct replies to this message as :class:`Messages` iterator + + .. note:: This call only makes sense if 'message' was + ultimately obtained from a :class:`Thread` object, (such as + by coming directly from the result of calling + :meth:`Thread.get_toplevel_messages` or by any number of + subsequent calls to :meth:`get_replies`). If this message was + obtained through some non-thread means, (such as by a call + to :meth:`Query.search_messages`), then this function will + return `None`. + + :returns: :class:`Messages` or `None` if there are no replies to + this message. + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + msgs_p = Message._get_replies(self._msg); + + if msgs_p is None: + return None + + return Messages(msgs_p,self) + def get_date(self): """Returns time_t of the message date @@ -1071,7 +1108,9 @@ class Message(object): msg['from'] = self.get_header('from') msg['tags'] = str(self.get_tags()) msg['date'] = date.fromtimestamp(self.get_date()) - return "%(from)s (%(date)s) (%(tags)s)" % (msg) + replies = self.get_replies() + msg['replies'] = len(replies) if replies is not None else -1 + return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg) def format_as_text(self): """Output like notmuch show (Not implemented)""" From a0add768c9523626aecd9511fa3d006e8f78e4d7 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Mon, 22 Mar 2010 10:40:08 +0100 Subject: [PATCH 053/106] Added tag v0.1 for changeset 27f38f75d27d --- .hgtags | 1 + 1 file changed, 1 insertion(+) create mode 100644 .hgtags diff --git a/.hgtags b/.hgtags new file mode 100644 index 00000000..5ba80a4a --- /dev/null +++ b/.hgtags @@ -0,0 +1 @@ +27f38f75d27d1dd5abbb1d5d9d5929372f84b2b2 v0.1 From c3239d3eb47eeaa1ed96a3169c0effc4e68185de Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Mon, 22 Mar 2010 10:48:03 +0100 Subject: [PATCH 054/106] setup.py and MANIFEST.in for the 0.1 release. Unfortunately post-version tag. --- .hgignore | 5 ++++- MANIFEST.in | 2 ++ setup.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 MANIFEST.in create mode 100644 setup.py diff --git a/.hgignore b/.hgignore index b4c079d5..1d235a30 100644 --- a/.hgignore +++ b/.hgignore @@ -1,5 +1,8 @@ .*\.pyc$ ~$ +^MANIFEST ^docs/html ^docs/build -^test/test \ No newline at end of file +^test/test +^dist +^build \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..c83be4b1 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include notmuch +#recursive-include docs/html * \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..ba41de19 --- /dev/null +++ b/setup.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python + +from distutils.core import setup + +setup(name='cnotmuch', + version='0.1', + description='Python binding og the notmuch mail search and indexing library.', + author='Sebastian Spaeth', + author_email='Sebastian@SSpaeth.de', + url='http://bitbucket.org/spaetz/cnotmuch/', + download_url='http://bitbucket.org/spaetz/cnotmuch/get/v0.1.tar.gz', + packages=['cnotmuch'], + keywords = ["library", "email"], + long_description="""The cnotmuch module provides an interface to the `notmuch `_ functionality, directly interfacing with a shared notmuch library. Notmuch provides a maildatabase that allows for extremely quick searching and filtering of your email according to various criteria. + +The documentation for the latest cnotmuch release can be `viewed online `_. + +The classes notmuch.Database, notmuch.Query provide most of the core functionality, returning notmuch.Messages and notmuch.Tags. +""", + classifiers=['Development Status :: 2 - Pre-Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 3', + 'Topic :: Communications :: Email', + 'Topic :: Software Development :: Libraries' + ], + platforms='', + license='http://www.gnu.org/licenses/gpl-3.0.txt', + ) From 63c5a6d77d2b51104305e91676720099f4667e92 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Mon, 22 Mar 2010 11:19:51 +0100 Subject: [PATCH 055/106] Fix Database().create_query() to actually initialize the Query with the correct object. We were passing the internal pointer to the notmuch_database_t object to Query(), but it should be initialized with the Database() object itself. This rightfully raised an exception. Yay, for thorough sanity checking. --- cnotmuch/database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index b759391a..dde7da16 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -314,7 +314,7 @@ class Database(object): # Raise a NotmuchError if not initialized self._verify_initialized_db() - return Query(self._db, querystring) + return Query(self, querystring) def __repr__(self): return "'Notmuch DB " + self.get_path() + "'" From 2b32acacbd02ad27e96a2eb234ff3d92de5e87d4 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Mon, 22 Mar 2010 11:20:00 +0100 Subject: [PATCH 056/106] notmuch: use new function Database().create_query() for 'count' --- notmuch | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/notmuch b/notmuch index e4e07bcf..92f68150 100755 --- a/notmuch +++ b/notmuch @@ -142,15 +142,13 @@ if __name__ == '__main__': print "Not implemented." #------------------------------------- elif sys.argv[1] == 'count': - db = Database() if len(sys.argv) == 2: - #no further search term + #no further search term, count all querystr='' else: #mangle arguments wrapping terms with spaces in quotes querystr = quote_query_line(sys.argv[2:]) - logging.debug("count "+querystr) - print(Query(db,querystr).count_messages()) + print(Database().create_query(querystr).count_messages()) #------------------------------------- elif sys.argv[1] == 'tag': From 2dd0cf8f5bbf63c0e0a423344de95e9d433bf7af Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Mon, 22 Mar 2010 12:32:44 +0100 Subject: [PATCH 057/106] Make __VERSION__ available via cnotmuch.notmuch.__VERSION__ and improve README Rather than hardcoding the version in various places, we export it from the module itself. Also improve the README somewhat. --- README | 18 ++++++++++++++++-- cnotmuch/notmuch.py | 5 ++--- docs/source/conf.py | 9 +++++---- docs/source/index.rst | 2 +- setup.py | 8 ++++---- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/README b/README index 14a35ae8..9caf32dc 100644 --- a/README +++ b/README @@ -1,12 +1,26 @@ cnotmuch -- The python interface to notmuch.so ============================================== -To create the documentation with sphinx installed, go to the docs directory and + +This module makes the functionality of the notmuch library +(`http://notmuchmail.org`_) available to python. Successful import of +this modul depends on a libnotmuch.so|dll being available on the +user's system. + +If you have downloaded the full source tarball, you can create the +documentation with sphinx installed, go to the docs directory and "make html". A static version of the documentation is available at: -http://spaetz.bitbucket.org +`http://spaetz.bitbucket.org`_ +The current source code is being hosted at +http://bitbucket.org/spaetz/cnotmuch which also provides an issue +tracker, and release downloads. This package is tracked by the python +package index repository at `http://pypi.python.org/pypi/cnotmuch`_ and can thus be installed on a user's computer easily via "sudo easy_install cnotmuch" (you will still need to install the notmuch shared library separately as it is not included in this package). + +The original source has been provided by (c)Sebastian Spaeth, 2010. All code is available under the GNU GPLv3+ (see docs/COPYING) unless specified otherwise. + Usage ----- For more examples of how to use the cnotmuch interface, have a look at the diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py index 70ee0b90..f06929d7 100644 --- a/cnotmuch/notmuch.py +++ b/cnotmuch/notmuch.py @@ -39,6 +39,5 @@ from ctypes import c_int, c_char_p from database import Database,Tags,Query,Messages,Message,Tags from cnotmuch.globals import nmlib,STATUS,NotmuchError __LICENSE__="GPL v3+" -__VERSION__=0.1 -__AUTHOR__ ="Sebastian Spaeth " - +__VERSION__='0.1.1' +__AUTHOR__ ='Sebastian Spaeth ' diff --git a/docs/source/conf.py b/docs/source/conf.py index ee22837f..ca959894 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -16,8 +16,9 @@ import sys, os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.append(os.path.abspath('../..')) +sys.path.insert(0,os.path.abspath('../..')) +from cnotmuch.notmuch import __VERSION__,__AUTHOR__ # -- General configuration ----------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be extensions @@ -39,16 +40,16 @@ master_doc = 'index' # General information about the project. project = u'cnotmuch' -copyright = u'2010, Sebastian Spaeth' +copyright = u'2010, ' + __AUTHOR__ # 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 = '0.1' +version = __VERSION__ # The full version, including alpha/beta/rc tags. -release = '0.1' +release = __VERSION__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/source/index.rst b/docs/source/index.rst index e04bc843..468a3c91 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -13,7 +13,7 @@ The classes :class:`notmuch.Database`, :class:`notmuch.Query` provide most of th :License: This module is covered under the GNU GPL v3 (or later). -This page contains the main API overview. More information on specific topics can be found on the following pages: (none here yet) +This page contains the main API overview of cnotmuch |release|. More information on specific topics can be found on the following pages: (none here yet) Notmuch can be imported as:: diff --git a/setup.py b/setup.py index ba41de19..22d44d3e 100644 --- a/setup.py +++ b/setup.py @@ -1,14 +1,14 @@ #!/usr/bin/env python from distutils.core import setup - +from cnotmuch.notmuch import __VERSION__ setup(name='cnotmuch', - version='0.1', - description='Python binding og the notmuch mail search and indexing library.', + version=__VERSION__, + description='Python binding of the notmuch mail search and indexing library.', author='Sebastian Spaeth', author_email='Sebastian@SSpaeth.de', url='http://bitbucket.org/spaetz/cnotmuch/', - download_url='http://bitbucket.org/spaetz/cnotmuch/get/v0.1.tar.gz', + download_url='http://bitbucket.org/spaetz/cnotmuch/get/v'+__VERSION__+'.tar.gz', packages=['cnotmuch'], keywords = ["library", "email"], long_description="""The cnotmuch module provides an interface to the `notmuch `_ functionality, directly interfacing with a shared notmuch library. Notmuch provides a maildatabase that allows for extremely quick searching and filtering of your email according to various criteria. From f3cfa749c1df0f30832f59483b55160ab8a95ca6 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Mon, 22 Mar 2010 12:50:33 +0100 Subject: [PATCH 058/106] Added tag v0.1.1 for changeset 4fdbf2935cdf --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 5ba80a4a..d0bb01b6 100644 --- a/.hgtags +++ b/.hgtags @@ -1 +1,2 @@ 27f38f75d27d1dd5abbb1d5d9d5929372f84b2b2 v0.1 +4fdbf2935cdf58865745d8bbc5e9b6be008ecb1c v0.1.1 From bb57345740da993584794d4071c63294da7beaa8 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 24 Mar 2010 11:05:54 +0100 Subject: [PATCH 059/106] docs: Update documentation Update the docs and include a page describing the notmuch "binary" --- README | 26 ++++++++++++++-- docs/source/index.rst | 46 ++++++++++++++++++++++++++-- docs/source/notmuch.rst | 68 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 docs/source/notmuch.rst diff --git a/README b/README index 9caf32dc..d7107b7d 100644 --- a/README +++ b/README @@ -6,9 +6,11 @@ This module makes the functionality of the notmuch library this modul depends on a libnotmuch.so|dll being available on the user's system. -If you have downloaded the full source tarball, you can create the -documentation with sphinx installed, go to the docs directory and -"make html". A static version of the documentation is available at: +If you have downloaded the full source tarball (available from +bitbucket.org, the source distribution and binary distribution come +without the documentation), you can create the documentation with +sphinx installed, go to the docs directory and "make html". A static +version of the documentation is available at: `http://spaetz.bitbucket.org`_ @@ -21,6 +23,24 @@ The original source has been provided by (c)Sebastian Spaeth, 2010. All code is available under the GNU GPLv3+ (see docs/COPYING) unless specified otherwise. +INSTALLATION & DEINSTALL +------------------------ + +cnotmuch is available on pypi.python.org. This means you can do +"easy_install cnotmuch" on your linux box and it will get installed +into: + +/usr/local/lib/python2.x/dist-packages/ + +For uninstalling, you'll need to remove the "cnotmuch-0.1-py2.x.egg" +directory and delete one entry in the "easy-install.pth" file in that +directory. + +It needs to have a libnotmuch.so or libnotmuch.so.1 available in some +library folder or will raise an exception when loading. +"OSError: libnotmuch.so.1: cannot open shared object file: No such file or directory" + + Usage ----- For more examples of how to use the cnotmuch interface, have a look at the diff --git a/docs/source/index.rst b/docs/source/index.rst index 468a3c91..f2b995a6 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -13,7 +13,7 @@ The classes :class:`notmuch.Database`, :class:`notmuch.Query` provide most of th :License: This module is covered under the GNU GPL v3 (or later). -This page contains the main API overview of cnotmuch |release|. More information on specific topics can be found on the following pages: (none here yet) +This page contains the main API overview of cnotmuch |release|. Notmuch can be imported as:: @@ -23,9 +23,12 @@ or:: from cnotmuch.notmuch import Query,Database +More information on specific topics can be found on the following pages: + .. toctree:: :maxdepth: 1 + notmuch :mod:`notmuch` -- The Notmuch interface ============================================= @@ -63,6 +66,9 @@ or:: .. automethod:: create_query + .. note:: :meth:`create_query` was broken in release + 0.1 and is fixed since 0.1.1. + .. attribute:: Database.MODE Defines constants that are used as the mode in which to open a database. @@ -97,6 +103,12 @@ or:: .. automethod:: set_sort + .. attribute:: sort + + Instance attribute :attr:`sort` contains the sort order (see + :attr:`Query.SORT`) if explicitely specified via + :meth:`set_sort`. By default it is set to `None`. + .. automethod:: search_threads .. automethod:: search_messages @@ -159,15 +171,43 @@ or:: .. automethod:: __str__ + +.. ----------------------------------------------------------------- +.. currentmodule:: cnotmuch.thread + :class:`Threads` -- Threads iterator ------------------------------------ -To be implemented +.. autoclass:: Threads + + .. automethod:: __len__ + + .. automethod:: __str__ :class:`Thread` -- A single thread ------------------------------------ -To be implemented +.. autoclass:: Thread + + .. automethod:: get_thread_id + + .. automethod:: get_total_messages + + .. automethod:: get_toplevel_messages + + .. automethod:: get_matched_messages + + .. automethod:: get_authors + + .. automethod:: get_subject + + .. automethod:: get_oldest_date + + .. automethod:: get_newest_date + + .. automethod:: get_tags + + .. automethod:: __str__ :class:`Filenames` -- An iterator over filenames ------------------------------------------------ diff --git a/docs/source/notmuch.rst b/docs/source/notmuch.rst new file mode 100644 index 00000000..32e17833 --- /dev/null +++ b/docs/source/notmuch.rst @@ -0,0 +1,68 @@ +The notmuch 'binary' +==================== + +The cnotmuch module provides *notmuch*, a python reimplementation of the standard notmuch binary for two purposes: first, to allow running the standard notmuch testsuite over the cnotmuch bindings (for correctness and performance testing) and second, to give some examples as to how to use cnotmuch. 'Notmuch' provides a command line interface to your mail database. + +A standard install via `easy_install cnotmuch` will not install the notmuch binary, however it is available in the `cnotmuch source code repository `_. + + +It is invoked with the following pattern: `notmuch [args...]`. + +Where and [args...] are as follows: + + **setup** Interactively setup notmuch for first use. + This has not yet been implemented, and will probably not be + implemented unless someone puts in the effort. + + **new** [--verbose] + Find and import new messages to the notmuch database. + + This has not been implemented yet. We cheat by calling + the regular "notmuch" binary (which must be in your path + somewhere). + + **search** [options...] [...] Search for messages matching the given search terms. + + This has been implemented but for the `--format` and + `--sort` options. + + **show** [...] + Show all messages matching the search terms. + + This has been partially implemented, we show a stub for each + found message, but do not output the full message body yet. + + **count** [...] + Count messages matching the search terms. + + This has been fully implemented. + + **reply** [options...] [...] + Construct a reply template for a set of messages. + + This has not been implemented yet. + + **tag** +|- [...] [--] [...] + Add/remove tags for all messages matching the search terms. + + This has been fully implemented. + + **dump** [] + Create a plain-text dump of the tags for each message. + + This has been fully implemented. + **restore** + Restore the tags from the given dump file (see 'dump'). + + This has been fully implemented. + + **search-tags** [ [...] ] + List all tags found in the database or matching messages. + + This has been fully implemented. + + **help** [] + This message, or more detailed help for the named command. + + The 'help' page has been implemented, help for single + commands are missing though. Patches are welcome. From 2a14b523b0982a6641e45b48099e65dfe1bb4f6a Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 24 Mar 2010 11:07:22 +0100 Subject: [PATCH 060/106] Implement Thread() and Threads() Most of Thread() is implemented now and all of Threads(). Reorganized the source somewhat and various minor fixes throughout. --- cnotmuch/database.py | 164 +++++++--------------- cnotmuch/notmuch.py | 6 +- cnotmuch/tags.py | 108 +++++++++++++++ cnotmuch/thread.py | 319 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 478 insertions(+), 119 deletions(-) create mode 100644 cnotmuch/tags.py create mode 100644 cnotmuch/thread.py diff --git a/cnotmuch/database.py b/cnotmuch/database.py index dde7da16..5af2b938 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -1,6 +1,8 @@ import ctypes, os from ctypes import c_int, c_char_p, c_void_p, c_uint, c_uint64, c_bool, byref from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum +from cnotmuch.thread import Thread, Threads +from cnotmuch.tags import Tags import logging from datetime import date @@ -355,6 +357,10 @@ class Query(object): A query selects and filters a subset of messages from the notmuch database we derive from. + Query() provides an instance attribute :attr:`sort`, which + contains the sort order (if specified via :meth:`set_sort`) or + `None`. + Technically, it wraps the underlying *notmuch_query_t* struct. .. note:: Do remember that as soon as we tear down this object, @@ -371,6 +377,10 @@ class Query(object): _create = nmlib.notmuch_query_create _create.restype = c_void_p + """notmuch_query_search_threads""" + _search_threads = nmlib.notmuch_query_search_threads + _search_threads.restype = c_void_p + """notmuch_query_search_messages""" _search_messages = nmlib.notmuch_query_search_messages _search_messages.restype = c_void_p @@ -389,6 +399,7 @@ class Query(object): """ self._db = None self._query = None + self.sort = None self.create(db, querystr) def create(self, db, querystr): @@ -432,8 +443,35 @@ class Query(object): if self._query is None: raise NotmuchError(STATUS.NOT_INITIALIZED) + self.sort = sort nmlib.notmuch_query_set_sort(self._query, sort) + def search_threads(self): + """Execute a query for threads + + Execute a query for threads, returning a :class:`Threads` iterator. + The returned threads are owned by the query and as such, will only be + valid until the Query is deleted. + + Technically, it wraps the underlying + *notmuch_query_search_threads* function. + + :returns: :class:`Threads` + :exception: :exc:`NotmuchError` + + * STATUS.NOT_INITIALIZED if query is not inited + * STATUS.NULL_POINTER if search_messages failed + """ + if self._query is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + threads_p = Query._search_threads(self._query) + + if threads_p is None: + NotmuchError(STATUS.NULL_POINTER) + + return Threads(threads_p,self) + def search_messages(self): """Filter messages according to the query and return :class:`Messages` in the defined sort order @@ -483,115 +521,6 @@ class Query(object): logging.debug("Freeing the Query now") nmlib.notmuch_query_destroy (self._query) -#------------------------------------------------------------------------------ -class Tags(object): - """Represents a list of notmuch tags - - This object provides an iterator over a list of notmuch tags. Do - note that the underlying library only provides a one-time iterator - (it cannot reset the iterator to the start). Thus iterating over - the function will "exhaust" the list of tags, and a subsequent - iteration attempt will raise a :exc:`NotmuchError` - STATUS.NOT_INITIALIZED. Also note, that any function that uses - iteration (nearly all) will also exhaust the tags. So both:: - - for tag in tags: print tag - - as well as:: - - number_of_tags = len(tags) - - and even a simple:: - - #str() iterates over all tags to construct a space separated list - print(str(tags)) - - will "exhaust" the Tags. If you need to re-iterate over a list of - tags you will need to retrieve a new :class:`Tags` object. - """ - - #notmuch_tags_get - _get = nmlib.notmuch_tags_get - _get.restype = c_char_p - - def __init__(self, tags_p, parent=None): - """ - :param tags_p: A pointer to an underlying *notmuch_tags_t* - structure. These are not publically exposed, so a user - will almost never instantiate a :class:`Tags` object - herself. They are usually handed back as a result, - e.g. in :meth:`Database.get_all_tags`. *tags_p* must be - valid, we will raise an :exc:`NotmuchError` - (STATUS.NULL_POINTER) if it is `None`. - :type tags_p: :class:`ctypes.c_void_p` - :param parent: The parent object (ie :class:`Database` or - :class:`Message` these tags are derived from, and saves a - reference to it, so we can automatically delete the db object - once all derived objects are dead. - :TODO: Make the iterator optionally work more than once by - cache the tags in the Python object(?) - """ - if tags_p is None: - NotmuchError(STATUS.NULL_POINTER) - - self._tags = tags_p - #save reference to parent object so we keep it alive - self._parent = parent - logging.debug("Inited Tags derived from %s" %(repr(parent))) - - def __iter__(self): - """ Make Tags an iterator """ - return self - - def next(self): - if self._tags is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - if not nmlib.notmuch_tags_valid(self._tags): - self._tags = None - raise StopIteration - - tag = Tags._get (self._tags) - nmlib.notmuch_tags_move_to_next(self._tags) - return tag - - def __len__(self): - """len(:class:`Tags`) returns the number of contained tags - - .. note:: As this iterates over the tags, we will not be able - to iterate over them again (as in retrieve them)! If - the tags have been exhausted already, this will raise a - :exc:`NotmuchError` STATUS.NOT_INITIALIZED on - subsequent attempts. - """ - if self._tags is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - i=0 - while nmlib.notmuch_tags_valid(self._msgs): - nmlib.notmuch_tags_move_to_next(self._msgs) - i += 1 - self._tags = None - return i - - def __str__(self): - """The str() representation of Tags() is a space separated list of tags - - .. note:: As this iterates over the tags, we will not be able - to iterate over them again (as in retrieve them)! If - the tags have been exhausted already, this will raise a - :exc:`NotmuchError` STATUS.NOT_INITIALIZED on - subsequent attempts. - """ - return " ".join(self) - - def __del__(self): - """Close and free the notmuch tags""" - if self._tags is not None: - logging.debug("Freeing the Tags now") - nmlib.notmuch_tags_destroy (self._tags) - - #------------------------------------------------------------------------------ class Messages(object): """Represents a list of notmuch messages @@ -721,6 +650,12 @@ class Messages(object): if len(msgs) > 0: #this 'exhausts' msgs # next line raises NotmuchError(STATUS.NOT_INITIALIZED)!!! for msg in msgs: print msg + + Most of the time, using the + :meth:`Query.count_messages` is therefore more + appropriate (and much faster). While not guaranteeing + that it will return the exact same number than len(), + in my tests it effectively always did so. """ if self._msgs is None: raise NotmuchError(STATUS.NOT_INITIALIZED) @@ -855,7 +790,7 @@ class Message(object): message call notmuch_message_get_header() with a header value of "date". - :returns: a time_t timestamp + :returns: A time_t timestamp. :rtype: c_unit64 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message is not initialized. @@ -892,7 +827,7 @@ class Message(object): return header def get_filename(self): - """Return the file path of the message file + """Returns the file path of the message file :returns: Absolute file path & name of the message file :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message @@ -903,10 +838,9 @@ class Message(object): return Message._get_filename(self._msg) def get_tags(self): - """ Return the message tags + """Returns the message tags - :returns: Message tags - :rtype: :class:`Tags` + :returns: A :class:`Tags` iterator. :exception: :exc:`NotmuchError` * STATUS.NOT_INITIALIZED if the message @@ -922,7 +856,7 @@ class Message(object): return Tags(tags_p, self) def add_tag(self, tag): - """Add a tag to the given message + """Adds a tag to the given message Adds a tag to the current message. The maximal tag length is defined in the notmuch library and is currently 200 bytes. diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py index f06929d7..ba5c150d 100644 --- a/cnotmuch/notmuch.py +++ b/cnotmuch/notmuch.py @@ -34,10 +34,8 @@ Many of its objects use python's logging module to log some output at DEBUG leve :class:`Message`, and :class:`Tags`. """ -import ctypes -from ctypes import c_int, c_char_p -from database import Database,Tags,Query,Messages,Message,Tags -from cnotmuch.globals import nmlib,STATUS,NotmuchError +from database import Database, Query +from cnotmuch.globals import nmlib, STATUS, NotmuchError __LICENSE__="GPL v3+" __VERSION__='0.1.1' __AUTHOR__ ='Sebastian Spaeth ' diff --git a/cnotmuch/tags.py b/cnotmuch/tags.py new file mode 100644 index 00000000..00898ef9 --- /dev/null +++ b/cnotmuch/tags.py @@ -0,0 +1,108 @@ +from ctypes import c_char_p +from cnotmuch.globals import nmlib, STATUS, NotmuchError + +#------------------------------------------------------------------------------ +class Tags(object): + """Represents a list of notmuch tags + + This object provides an iterator over a list of notmuch tags. Do + note that the underlying library only provides a one-time iterator + (it cannot reset the iterator to the start). Thus iterating over + the function will "exhaust" the list of tags, and a subsequent + iteration attempt will raise a :exc:`NotmuchError` + STATUS.NOT_INITIALIZED. Also note, that any function that uses + iteration (nearly all) will also exhaust the tags. So both:: + + for tag in tags: print tag + + as well as:: + + number_of_tags = len(tags) + + and even a simple:: + + #str() iterates over all tags to construct a space separated list + print(str(tags)) + + will "exhaust" the Tags. If you need to re-iterate over a list of + tags you will need to retrieve a new :class:`Tags` object. + """ + + #notmuch_tags_get + _get = nmlib.notmuch_tags_get + _get.restype = c_char_p + + def __init__(self, tags_p, parent=None): + """ + :param tags_p: A pointer to an underlying *notmuch_tags_t* + structure. These are not publically exposed, so a user + will almost never instantiate a :class:`Tags` object + herself. They are usually handed back as a result, + e.g. in :meth:`Database.get_all_tags`. *tags_p* must be + valid, we will raise an :exc:`NotmuchError` + (STATUS.NULL_POINTER) if it is `None`. + :type tags_p: :class:`ctypes.c_void_p` + :param parent: The parent object (ie :class:`Database` or + :class:`Message` these tags are derived from, and saves a + reference to it, so we can automatically delete the db object + once all derived objects are dead. + :TODO: Make the iterator optionally work more than once by + cache the tags in the Python object(?) + """ + if tags_p is None: + NotmuchError(STATUS.NULL_POINTER) + + self._tags = tags_p + #save reference to parent object so we keep it alive + self._parent = parent + + def __iter__(self): + """ Make Tags an iterator """ + return self + + def next(self): + if self._tags is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + if not nmlib.notmuch_tags_valid(self._tags): + self._tags = None + raise StopIteration + + tag = Tags._get (self._tags) + nmlib.notmuch_tags_move_to_next(self._tags) + return tag + + def __len__(self): + """len(:class:`Tags`) returns the number of contained tags + + .. note:: As this iterates over the tags, we will not be able + to iterate over them again (as in retrieve them)! If + the tags have been exhausted already, this will raise a + :exc:`NotmuchError` STATUS.NOT_INITIALIZED on + subsequent attempts. + """ + if self._tags is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + i=0 + while nmlib.notmuch_tags_valid(self._msgs): + nmlib.notmuch_tags_move_to_next(self._msgs) + i += 1 + self._tags = None + return i + + def __str__(self): + """The str() representation of Tags() is a space separated list of tags + + .. note:: As this iterates over the tags, we will not be able + to iterate over them again (as in retrieve them)! If + the tags have been exhausted already, this will raise a + :exc:`NotmuchError` STATUS.NOT_INITIALIZED on + subsequent attempts. + """ + return " ".join(self) + + def __del__(self): + """Close and free the notmuch tags""" + if self._tags is not None: + nmlib.notmuch_tags_destroy (self._tags) diff --git a/cnotmuch/thread.py b/cnotmuch/thread.py new file mode 100644 index 00000000..972f426b --- /dev/null +++ b/cnotmuch/thread.py @@ -0,0 +1,319 @@ +from ctypes import c_char_p, c_void_p, c_uint64 +from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum +from cnotmuch.tags import Tags +from datetime import date + +#------------------------------------------------------------------------------ +class Threads(object): + """Represents a list of notmuch threads + + This object provides an iterator over a list of notmuch threads + (Technically, it provides a wrapper for the underlying + *notmuch_threads_t* structure). Do note that the underlying + library only provides a one-time iterator (it cannot reset the + iterator to the start). Thus iterating over the function will + "exhaust" the list of threads, and a subsequent iteration attempt + will raise a :exc:`NotmuchError` STATUS.NOT_INITIALIZED. Also + note, that any function that uses iteration will also + exhaust the messages. So both:: + + for thread in threads: print thread + + as well as:: + + number_of_msgs = len(threads) + + will "exhaust" the threads. If you need to re-iterate over a list of + messages you will need to retrieve a new :class:`Threads` object. + + Things are not as bad as it seems though, you can store and reuse + the single Thread objects as often as you want as long as you + keep the parent Threads object around. (Recall that due to + hierarchical memory allocation, all derived Threads objects will + be invalid when we delete the parent Threads() object, even if it + was already "exhausted".) So this works:: + + db = Database() + threads = Query(db,'').search_threads() #get a Threads() object + threadlist = [] + for thread in threads: + threadlist.append(thread) + + # threads is "exhausted" now and even len(threads) will raise an + # exception. + # However it will be kept around until all retrieved Thread() objects are + # also deleted. If you did e.g. an explicit del(threads) here, the + # following lines would fail. + + # You can reiterate over *threadlist* however as often as you want. + # It is simply a list with Thread objects. + + print (threadlist[0].get_thread_id()) + print (threadlist[1].get_thread_id()) + print (threadlist[0].get_total_messages()) + """ + + #notmuch_threads_get + _get = nmlib.notmuch_threads_get + _get.restype = c_void_p + + def __init__(self, threads_p, parent=None): + """ + :param threads_p: A pointer to an underlying *notmuch_threads_t* + structure. These are not publically exposed, so a user + will almost never instantiate a :class:`Threads` object + herself. They are usually handed back as a result, + e.g. in :meth:`Query.search_threads`. *threads_p* must be + valid, we will raise an :exc:`NotmuchError` + (STATUS.NULL_POINTER) if it is `None`. + :type threads_p: :class:`ctypes.c_void_p` + :param parent: The parent object + (ie :class:`Query`) these tags are derived from. It saves + a reference to it, so we can automatically delete the db + object once all derived objects are dead. + :TODO: Make the iterator work more than once and cache the tags in + the Python object.(?) + """ + if threads_p is None: + NotmuchError(STATUS.NULL_POINTER) + + self._threads = threads_p + #store parent, so we keep them alive as long as self is alive + self._parent = parent + + def __iter__(self): + """ Make Threads an iterator """ + return self + + def next(self): + if self._threads is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + if not nmlib.notmuch_threads_valid(self._threads): + self._threads = None + raise StopIteration + + thread = Thread(Threads._get (self._threads), self) + nmlib.notmuch_threads_move_to_next(self._threads) + return thread + + def __len__(self): + """len(:class:`Threads`) returns the number of contained Threads + + .. note:: As this iterates over the threads, we will not be able to + iterate over them again! So this will fail:: + + #THIS FAILS + threads = Database().create_query('').search_threads() + if len(threads) > 0: #this 'exhausts' threads + # next line raises NotmuchError(STATUS.NOT_INITIALIZED)!!! + for thread in threads: print thread + """ + if self._threads is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + i=0 + # returns 'bool'. On out-of-memory it returns None + while nmlib.notmuch_threads_valid(self._threads): + nmlib.notmuch_threads_move_to_next(self._threads) + i += 1 + # reset self._threads to mark as "exhausted" + self._threads = None + return i + + + + def __del__(self): + """Close and free the notmuch Threads""" + if self._threads is not None: + nmlib.notmuch_messages_destroy (self._threads) + +#------------------------------------------------------------------------------ +class Thread(object): + """Represents a single message thread.""" + + """notmuch_thread_get_thread_id""" + _get_thread_id = nmlib.notmuch_thread_get_thread_id + _get_thread_id.restype = c_char_p + + """notmuch_thread_get_authors""" + _get_authors = nmlib.notmuch_thread_get_authors + _get_authors.restype = c_char_p + + """notmuch_thread_get_subject""" + _get_subject = nmlib.notmuch_thread_get_subject + _get_subject.restype = c_char_p + + _get_newest_date = nmlib.notmuch_thread_get_newest_date + _get_newest_date.restype = c_uint64 + + _get_oldest_date = nmlib.notmuch_thread_get_oldest_date + _get_oldest_date.restype = c_uint64 + + """notmuch_thread_get_tags""" + _get_tags = nmlib.notmuch_thread_get_tags + _get_tags.restype = c_void_p + + def __init__(self, thread_p, parent=None): + """ + :param thread_p: A pointer to an internal notmuch_thread_t + Structure. These are not publically exposed, so a user + will almost never instantiate a :class:`Thread` object + herself. They are usually handed back as a result, + e.g. when iterating through :class:`Threads`. *thread_p* + must be valid, we will raise an :exc:`NotmuchError` + (STATUS.NULL_POINTER) if it is `None`. + + :param parent: A 'parent' object is passed which this message is + derived from. We save a reference to it, so we can + automatically delete the parent object once all derived + objects are dead. + """ + if thread_p is None: + NotmuchError(STATUS.NULL_POINTER) + self._thread = thread_p + #keep reference to parent, so we keep it alive + self._parent = parent + + def get_thread_id(self): + """Get the thread ID of 'thread' + + The returned string belongs to 'thread' and will only be valid + for as long as the thread is valid. + + :returns: String with a message ID + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the thread + is not initialized. + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Thread._get_thread_id(self._thread) + + def get_total_messages(self): + """Get the total number of messages in 'thread' + + :returns: The number of all messages in the database + belonging to this thread. Contrast with + :meth:`get_matched_messages`. + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the thread + is not initialized. + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return nmlib.notmuch_thread_get_total_messages(self._thread) + + + ###TODO: notmuch_messages_t * notmuch_thread_get_toplevel_messages (notmuch_thread_t *thread); + + def get_matched_messages(self): + """Returns the number of messages in 'thread' that matched the query + + :returns: The number of all messages belonging to this thread that + matched the :class:`Query`from which this thread was created. + Contrast with :meth:`get_total_messages`. + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the thread + is not initialized. + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return nmlib.notmuch_thread_get_matched_messages(self._thread) + + def get_authors(self): + """Returns the authors of 'thread' + + The returned string is a comma-separated list of the names of the + authors of mail messages in the query results that belong to this + thread. + + The returned string belongs to 'thread' and will only be valid for + as long as this Thread() is not deleted. + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Thread._get_authors(self._thread) + + def get_subject(self): + """Returns the Subject of 'thread' + + The returned string belongs to 'thread' and will only be valid for + as long as this Thread() is not deleted. + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Thread._get_subject(self._thread) + + def get_newest_date(self): + """Returns time_t of the newest message date + + :returns: A time_t timestamp. + :rtype: c_unit64 + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Thread._get_newest_date(self._thread) + + def get_oldest_date(self): + """Returns time_t of the oldest message date + + :returns: A time_t timestamp. + :rtype: c_unit64 + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Thread._get_oldest_date(self._thread) + + def get_tags(self): + """ Returns the message tags + + In the Notmuch database, tags are stored on individual + messages, not on threads. So the tags returned here will be all + tags of the messages which matched the search and which belong to + this thread. + + The :class:`Tags` object is owned by the thread and as such, will only + be valid for as long as this :class:`Thread` is valid (e.g. until the + query from which it derived is explicitely deleted). + + :returns: A :class:`Tags` iterator. + :exception: :exc:`NotmuchError` + + * STATUS.NOT_INITIALIZED if the thread + is not initialized. + * STATUS.NULL_POINTER, on error + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + tags_p = Thread._get_tags(self._thread) + if tags_p == None: + raise NotmuchError(STATUS.NULL_POINTER) + return Tags(tags_p, self) + + def __str__(self): + """A str(Thread()) is represented by a 1-line summary""" + thread = {} + thread['id'] = self.get_thread_id() + + ###TODO: How do we find out the current sort order of Threads? + ###Add a "sort" attribute to the Threads() object? + #if (sort == NOTMUCH_SORT_OLDEST_FIRST) + # date = notmuch_thread_get_oldest_date (thread); + #else + # date = notmuch_thread_get_newest_date (thread); + thread['date'] = date.fromtimestamp(self.get_newest_date()) + thread['matched'] = self.get_matched_messages() + thread['total'] = self.get_total_messages() + thread['authors'] = self.get_authors() + thread['subject'] = self.get_subject() + thread['tags'] = self.get_tags() + + return "thread:%(id)s %(date)12s [%(matched)d/%(total)d] %(authors)s; %(subject)s (%(tags)s)" % (thread) + + def __del__(self): + """Close and free the notmuch Thread""" + if self._thread is not None: + nmlib.notmuch_thread_destroy (self._thread) From 9b23eb2baae1fd5eb72b50e79e2c75227c5b76ab Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 24 Mar 2010 11:08:43 +0100 Subject: [PATCH 061/106] notmuch: Implement 'notmuch search' This passes all search tests for the notmuch test suite. We don't have the nice recent date formatting, no --format and no --sort option implemented though. --- notmuch | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/notmuch b/notmuch index 92f68150..2a1540a1 100755 --- a/notmuch +++ b/notmuch @@ -106,13 +106,13 @@ def quote_query_line(argv): if __name__ == '__main__': # Handle command line options - # No option #------------------------------------- + # No option given, print USAGE and exit if len(sys.argv) == 1: print USAGE #------------------------------------- elif sys.argv[1] == 'setup': - """ Interactively setup notmuch for first use. """ + """Interactively setup notmuch for first use.""" print "Not implemented." #------------------------------------- elif sys.argv[1] == 'new': @@ -124,6 +124,19 @@ if __name__ == '__main__': if len(sys.argv) == 2: print HELPTEXT else: print "Not implemented" #------------------------------------- + elif sys.argv[1] == 'search': + db = Database() + if len(sys.argv) == 2: + #no further search term + querystr='' + else: + #mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[2:]) + logging.debug("search "+querystr) + t = Query(db,querystr).search_threads() + for thread in t: + print(str(thread)) + #------------------------------------- elif sys.argv[1] == 'show': db = Database() if len(sys.argv) == 2: @@ -137,10 +150,6 @@ if __name__ == '__main__': for msg in m: print(msg.format_as_text()) #------------------------------------- - elif sys.argv[1] == 'new': - #TODO: handle --verbose - print "Not implemented." - #------------------------------------- elif sys.argv[1] == 'count': if len(sys.argv) == 2: #no further search term, count all From 898341f7f7412bc1fd405b1b9778f84780ac15c5 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 24 Mar 2010 11:32:30 +0100 Subject: [PATCH 062/106] Factor Messages() and Message() out into their own file. rename tags.py to tag.py for consistency. --HG-- rename : cnotmuch/tags.py => cnotmuch/tag.py --- cnotmuch/database.py | 548 +---------------------------------- cnotmuch/message.py | 534 ++++++++++++++++++++++++++++++++++ cnotmuch/notmuch.py | 2 - cnotmuch/{tags.py => tag.py} | 0 cnotmuch/thread.py | 38 ++- 5 files changed, 574 insertions(+), 548 deletions(-) create mode 100644 cnotmuch/message.py rename cnotmuch/{tags.py => tag.py} (100%) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 5af2b938..321b9611 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -1,10 +1,9 @@ import ctypes, os -from ctypes import c_int, c_char_p, c_void_p, c_uint, c_uint64, c_bool, byref +from ctypes import c_int, c_char_p, c_void_p, c_uint, byref from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum -from cnotmuch.thread import Thread, Threads -from cnotmuch.tags import Tags -import logging -from datetime import date +from cnotmuch.thread import Threads +from cnotmuch.message import Messages +from cnotmuch.tag import Tags class Database(object): """Represents a notmuch database (wraps notmuch_database_t) @@ -324,7 +323,6 @@ class Database(object): def __del__(self): """Close and free the notmuch database if needed""" if self._db is not None: - logging.debug("Freeing the database now") nmlib.notmuch_database_close(self._db) def _get_user_default_db(self): @@ -460,7 +458,7 @@ class Query(object): :exception: :exc:`NotmuchError` * STATUS.NOT_INITIALIZED if query is not inited - * STATUS.NULL_POINTER if search_messages failed + * STATUS.NULL_POINTER if search_threads failed """ if self._query is None: raise NotmuchError(STATUS.NOT_INITIALIZED) @@ -518,540 +516,4 @@ class Query(object): def __del__(self): """Close and free the Query""" if self._query is not None: - logging.debug("Freeing the Query now") nmlib.notmuch_query_destroy (self._query) - -#------------------------------------------------------------------------------ -class Messages(object): - """Represents a list of notmuch messages - - This object provides an iterator over a list of notmuch messages - (Technically, it provides a wrapper for the underlying - *notmuch_messages_t* structure). Do note that the underlying - library only provides a one-time iterator (it cannot reset the - iterator to the start). Thus iterating over the function will - "exhaust" the list of messages, and a subsequent iteration attempt - will raise a :exc:`NotmuchError` STATUS.NOT_INITIALIZED. Also - note, that any function that uses iteration will also - exhaust the messages. So both:: - - for msg in msgs: print msg - - as well as:: - - number_of_msgs = len(msgs) - - will "exhaust" the Messages. If you need to re-iterate over a list of - messages you will need to retrieve a new :class:`Messages` object. - - Things are not as bad as it seems though, you can store and reuse - the single Message objects as often as you want as long as you - keep the parent Messages object around. (Recall that due to - hierarchical memory allocation, all derived Message objects will - be invalid when we delete the parent Messages() object, even if it - was already "exhausted".) So this works:: - - db = Database() - msgs = Query(db,'').search_messages() #get a Messages() object - msglist = [] - for m in msgs: - msglist.append(m) - - # msgs is "exhausted" now and even len(msgs) will raise an exception. - # However it will be kept around until all retrieved Message() objects are - # also deleted. If you did e.g. an explicit del(msgs) here, the - # following lines would fail. - - # You can reiterate over *msglist* however as often as you want. - # It is simply a list with Message objects. - - print (msglist[0].get_filename()) - print (msglist[1].get_filename()) - print (msglist[0].get_message_id()) - """ - - #notmuch_tags_get - _get = nmlib.notmuch_messages_get - _get.restype = c_void_p - - _collect_tags = nmlib.notmuch_messages_collect_tags - _collect_tags.restype = c_void_p - - def __init__(self, msgs_p, parent=None): - """ - :param msgs_p: A pointer to an underlying *notmuch_messages_t* - structure. These are not publically exposed, so a user - will almost never instantiate a :class:`Messages` object - herself. They are usually handed back as a result, - e.g. in :meth:`Query.search_messages`. *msgs_p* must be - valid, we will raise an :exc:`NotmuchError` - (STATUS.NULL_POINTER) if it is `None`. - :type msgs_p: :class:`ctypes.c_void_p` - :param parent: The parent object - (ie :class:`Query`) these tags are derived from. It saves - a reference to it, so we can automatically delete the db - object once all derived objects are dead. - :TODO: Make the iterator work more than once and cache the tags in - the Python object.(?) - """ - if msgs_p is None: - NotmuchError(STATUS.NULL_POINTER) - - self._msgs = msgs_p - #store parent, so we keep them alive as long as self is alive - self._parent = parent - logging.debug("Inited Messages derived from %s" %(str(parent))) - - def collect_tags(self): - """Return the unique :class:`Tags` in the contained messages - - :returns: :class:`Tags` - :exceptions: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if not inited - - .. note:: :meth:`collect_tags` will iterate over the messages and - therefore will not allow further iterations. - """ - if self._msgs is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - # collect all tags (returns NULL on error) - tags_p = Messages._collect_tags (self._msgs) - #reset _msgs as we iterated over it and can do so only once - self._msgs = None - - if tags_p == None: - raise NotmuchError(STATUS.NULL_POINTER) - return Tags(tags_p, self) - - def __iter__(self): - """ Make Messages an iterator """ - return self - - def next(self): - if self._msgs is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - if not nmlib.notmuch_messages_valid(self._msgs): - self._msgs = None - raise StopIteration - - msg = Message(Messages._get (self._msgs), self) - nmlib.notmuch_messages_move_to_next(self._msgs) - return msg - - def __len__(self): - """len(:class:`Messages`) returns the number of contained messages - - .. note:: As this iterates over the messages, we will not be able to - iterate over them again! So this will fail:: - - #THIS FAILS - msgs = Database().create_query('').search_message() - if len(msgs) > 0: #this 'exhausts' msgs - # next line raises NotmuchError(STATUS.NOT_INITIALIZED)!!! - for msg in msgs: print msg - - Most of the time, using the - :meth:`Query.count_messages` is therefore more - appropriate (and much faster). While not guaranteeing - that it will return the exact same number than len(), - in my tests it effectively always did so. - """ - if self._msgs is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - i=0 - while nmlib.notmuch_messages_valid(self._msgs): - nmlib.notmuch_messages_move_to_next(self._msgs) - i += 1 - self._msgs = None - return i - - - - def __del__(self): - """Close and free the notmuch Messages""" - if self._msgs is not None: - logging.debug("Freeing the Messages now") - nmlib.notmuch_messages_destroy (self._msgs) - - -#------------------------------------------------------------------------------ -class Message(object): - """Represents a single Email message - - Technically, this wraps the underlying *notmuch_message_t* structure. - """ - - """notmuch_message_get_filename (notmuch_message_t *message)""" - _get_filename = nmlib.notmuch_message_get_filename - _get_filename.restype = c_char_p - - """notmuch_message_get_message_id (notmuch_message_t *message)""" - _get_message_id = nmlib.notmuch_message_get_message_id - _get_message_id.restype = c_char_p - - """notmuch_message_get_thread_id""" - _get_thread_id = nmlib.notmuch_message_get_thread_id - _get_thread_id.restype = c_char_p - - """notmuch_message_get_replies""" - _get_replies = nmlib.notmuch_message_get_replies - _get_replies.restype = c_void_p - - """notmuch_message_get_tags (notmuch_message_t *message)""" - _get_tags = nmlib.notmuch_message_get_tags - _get_tags.restype = c_void_p - - _get_date = nmlib.notmuch_message_get_date - _get_date.restype = c_uint64 - - _get_header = nmlib.notmuch_message_get_header - _get_header.restype = c_char_p - - def __init__(self, msg_p, parent=None): - """ - :param msg_p: A pointer to an internal notmuch_message_t - Structure. If it is `None`, we will raise an :exc:`NotmuchError` - STATUS.NULL_POINTER. - :param parent: A 'parent' object is passed which this message is - derived from. We save a reference to it, so we can - automatically delete the parent object once all derived - objects are dead. - """ - if msg_p is None: - NotmuchError(STATUS.NULL_POINTER) - self._msg = msg_p - #keep reference to parent, so we keep it alive - self._parent = parent - logging.debug("Inited Message derived from %s" %(str(parent))) - - - def get_message_id(self): - """Returns the message ID - - :returns: String with a message ID - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message - is not initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - return Message._get_message_id(self._msg) - - def get_thread_id(self): - """Returns the thread ID - - The returned string belongs to 'message' will only be valid for as - long as the message is valid. - - This function will not return None since Notmuch ensures that every - message belongs to a single thread. - - :returns: String with a thread ID - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message - is not initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - return Message._get_thread_id (self._msg); - - def get_replies(self): - """Gets all direct replies to this message as :class:`Messages` iterator - - .. note:: This call only makes sense if 'message' was - ultimately obtained from a :class:`Thread` object, (such as - by coming directly from the result of calling - :meth:`Thread.get_toplevel_messages` or by any number of - subsequent calls to :meth:`get_replies`). If this message was - obtained through some non-thread means, (such as by a call - to :meth:`Query.search_messages`), then this function will - return `None`. - - :returns: :class:`Messages` or `None` if there are no replies to - this message. - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message - is not initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - msgs_p = Message._get_replies(self._msg); - - if msgs_p is None: - return None - - return Messages(msgs_p,self) - - def get_date(self): - """Returns time_t of the message date - - For the original textual representation of the Date header from the - message call notmuch_message_get_header() with a header value of - "date". - - :returns: A time_t timestamp. - :rtype: c_unit64 - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message - is not initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - return Message._get_date(self._msg) - - def get_header(self, header): - """Returns a message header - - This returns any message header that is stored in the notmuch database. - This is only a selected subset of headers, which is currently: - - TODO: add stored headers - - :param header: The name of the header to be retrieved. - It is not case-sensitive (TODO: confirm). - :type header: str - :returns: The header value as string - :exception: :exc:`NotmuchError` - - * STATUS.NOT_INITIALIZED if the message - is not initialized. - * STATUS.NULL_POINTER, if no header was found - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - #Returns NULL if any error occurs. - header = Message._get_header (self._msg, header) - if header == None: - raise NotmuchError(STATUS.NULL_POINTER) - return header - - def get_filename(self): - """Returns the file path of the message file - - :returns: Absolute file path & name of the message file - :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message - is not initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - return Message._get_filename(self._msg) - - def get_tags(self): - """Returns the message tags - - :returns: A :class:`Tags` iterator. - :exception: :exc:`NotmuchError` - - * STATUS.NOT_INITIALIZED if the message - is not initialized. - * STATUS.NULL_POINTER, on error - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - tags_p = Message._get_tags(self._msg) - if tags_p == None: - raise NotmuchError(STATUS.NULL_POINTER) - return Tags(tags_p, self) - - def add_tag(self, tag): - """Adds a tag to the given message - - Adds a tag to the current message. The maximal tag length is defined in - the notmuch library and is currently 200 bytes. - - :param tag: String with a 'tag' to be added. - :returns: STATUS.SUCCESS if the tag was successfully added. - Raises an exception otherwise. - :exception: :exc:`NotmuchError`. They have the following meaning: - - STATUS.NULL_POINTER - The 'tag' argument is NULL - STATUS.TAG_TOO_LONG - The length of 'tag' is too long - (exceeds Message.NOTMUCH_TAG_MAX) - STATUS.READ_ONLY_DATABASE - Database was opened in read-only mode so message cannot be - modified. - STATUS.NOT_INITIALIZED - The message has not been initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - status = nmlib.notmuch_message_add_tag (self._msg, tag) - - if STATUS.SUCCESS == status: - # return on success - return status - - raise NotmuchError(status) - - def remove_tag(self, tag): - """Removes a tag from the given message - - If the message has no such tag, this is a non-operation and - will report success anyway. - - :param tag: String with a 'tag' to be removed. - :returns: STATUS.SUCCESS if the tag was successfully removed or if - the message had no such tag. - Raises an exception otherwise. - :exception: :exc:`NotmuchError`. They have the following meaning: - - STATUS.NULL_POINTER - The 'tag' argument is NULL - STATUS.TAG_TOO_LONG - The length of 'tag' is too long - (exceeds NOTMUCH_TAG_MAX) - STATUS.READ_ONLY_DATABASE - Database was opened in read-only mode so message cannot - be modified. - STATUS.NOT_INITIALIZED - The message has not been initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - status = nmlib.notmuch_message_remove_tag(self._msg, tag) - - if STATUS.SUCCESS == status: - # return on success - return status - - raise NotmuchError(status) - - def remove_all_tags(self): - """Removes all tags from the given message. - - See :meth:`freeze` for an example showing how to safely - replace tag values. - - :returns: STATUS.SUCCESS if the tags were successfully removed. - Raises an exception otherwise. - :exception: :exc:`NotmuchError`. They have the following meaning: - - STATUS.READ_ONLY_DATABASE - Database was opened in read-only mode so message cannot - be modified. - STATUS.NOT_INITIALIZED - The message has not been initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - status = nmlib.notmuch_message_remove_all_tags(self._msg) - - if STATUS.SUCCESS == status: - # return on success - return status - - raise NotmuchError(status) - - def freeze(self): - """Freezes the current state of 'message' within the database - - This means that changes to the message state, (via :meth:`add_tag`, - :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be - committed to the database until the message is :meth:`thaw`ed. - - Multiple calls to freeze/thaw are valid and these calls will - "stack". That is there must be as many calls to thaw as to freeze - before a message is actually thawed. - - The ability to do freeze/thaw allows for safe transactions to - change tag values. For example, explicitly setting a message to - have a given set of tags might look like this:: - - msg.freeze() - msg.remove_all_tags() - for tag in new_tags: - msg.add_tag(tag) - msg.thaw() - - With freeze/thaw used like this, the message in the database is - guaranteed to have either the full set of original tag values, or - the full set of new tag values, but nothing in between. - - Imagine the example above without freeze/thaw and the operation - somehow getting interrupted. This could result in the message being - left with no tags if the interruption happened after - :meth:`remove_all_tags` but before :meth:`add_tag`. - - :returns: STATUS.SUCCESS if the message was successfully frozen. - Raises an exception otherwise. - :exception: :exc:`NotmuchError`. They have the following meaning: - - STATUS.READ_ONLY_DATABASE - Database was opened in read-only mode so message cannot - be modified. - STATUS.NOT_INITIALIZED - The message has not been initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - status = nmlib.notmuch_message_freeze(self._msg) - - if STATUS.SUCCESS == status: - # return on success - return status - - raise NotmuchError(status) - - def thaw(self): - """Thaws the current 'message' - - Thaw the current 'message', synchronizing any changes that may have - occurred while 'message' was frozen into the notmuch database. - - See :meth:`freeze` for an example of how to use this - function to safely provide tag changes. - - Multiple calls to freeze/thaw are valid and these calls with - "stack". That is there must be as many calls to thaw as to freeze - before a message is actually thawed. - - :returns: STATUS.SUCCESS if the message was successfully frozen. - Raises an exception otherwise. - :exception: :exc:`NotmuchError`. They have the following meaning: - - STATUS.UNBALANCED_FREEZE_THAW - An attempt was made to thaw an unfrozen message. - That is, there have been an unbalanced number of calls - to :meth:`freeze` and :meth:`thaw`. - STATUS.NOT_INITIALIZED - The message has not been initialized. - """ - if self._msg is None: - raise NotmuchError(STATUS.NOT_INITIALIZED) - - status = nmlib.notmuch_message_thaw(self._msg) - - if STATUS.SUCCESS == status: - # return on success - return status - - raise NotmuchError(status) - - - def __str__(self): - """A message() is represented by a 1-line summary""" - msg = {} - msg['from'] = self.get_header('from') - msg['tags'] = str(self.get_tags()) - msg['date'] = date.fromtimestamp(self.get_date()) - replies = self.get_replies() - msg['replies'] = len(replies) if replies is not None else -1 - return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg) - - def format_as_text(self): - """Output like notmuch show (Not implemented)""" - return str(self) - - def __del__(self): - """Close and free the notmuch Message""" - if self._msg is not None: - logging.debug("Freeing the Message now") - nmlib.notmuch_message_destroy (self._msg) diff --git a/cnotmuch/message.py b/cnotmuch/message.py new file mode 100644 index 00000000..d1498f0d --- /dev/null +++ b/cnotmuch/message.py @@ -0,0 +1,534 @@ +from ctypes import c_char_p, c_void_p, c_uint64 +from datetime import date +from cnotmuch.globals import nmlib, STATUS, NotmuchError +from cnotmuch.tag import Tags +#------------------------------------------------------------------------------ +class Messages(object): + """Represents a list of notmuch messages + + This object provides an iterator over a list of notmuch messages + (Technically, it provides a wrapper for the underlying + *notmuch_messages_t* structure). Do note that the underlying + library only provides a one-time iterator (it cannot reset the + iterator to the start). Thus iterating over the function will + "exhaust" the list of messages, and a subsequent iteration attempt + will raise a :exc:`NotmuchError` STATUS.NOT_INITIALIZED. Also + note, that any function that uses iteration will also + exhaust the messages. So both:: + + for msg in msgs: print msg + + as well as:: + + number_of_msgs = len(msgs) + + will "exhaust" the Messages. If you need to re-iterate over a list of + messages you will need to retrieve a new :class:`Messages` object. + + Things are not as bad as it seems though, you can store and reuse + the single Message objects as often as you want as long as you + keep the parent Messages object around. (Recall that due to + hierarchical memory allocation, all derived Message objects will + be invalid when we delete the parent Messages() object, even if it + was already "exhausted".) So this works:: + + db = Database() + msgs = Query(db,'').search_messages() #get a Messages() object + msglist = [] + for m in msgs: + msglist.append(m) + + # msgs is "exhausted" now and even len(msgs) will raise an exception. + # However it will be kept around until all retrieved Message() objects are + # also deleted. If you did e.g. an explicit del(msgs) here, the + # following lines would fail. + + # You can reiterate over *msglist* however as often as you want. + # It is simply a list with Message objects. + + print (msglist[0].get_filename()) + print (msglist[1].get_filename()) + print (msglist[0].get_message_id()) + """ + + #notmuch_tags_get + _get = nmlib.notmuch_messages_get + _get.restype = c_void_p + + _collect_tags = nmlib.notmuch_messages_collect_tags + _collect_tags.restype = c_void_p + + def __init__(self, msgs_p, parent=None): + """ + :param msgs_p: A pointer to an underlying *notmuch_messages_t* + structure. These are not publically exposed, so a user + will almost never instantiate a :class:`Messages` object + herself. They are usually handed back as a result, + e.g. in :meth:`Query.search_messages`. *msgs_p* must be + valid, we will raise an :exc:`NotmuchError` + (STATUS.NULL_POINTER) if it is `None`. + :type msgs_p: :class:`ctypes.c_void_p` + :param parent: The parent object + (ie :class:`Query`) these tags are derived from. It saves + a reference to it, so we can automatically delete the db + object once all derived objects are dead. + :TODO: Make the iterator work more than once and cache the tags in + the Python object.(?) + """ + if msgs_p is None: + NotmuchError(STATUS.NULL_POINTER) + + self._msgs = msgs_p + #store parent, so we keep them alive as long as self is alive + self._parent = parent + + def collect_tags(self): + """Return the unique :class:`Tags` in the contained messages + + :returns: :class:`Tags` + :exceptions: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if not inited + + .. note:: :meth:`collect_tags` will iterate over the messages and + therefore will not allow further iterations. + """ + if self._msgs is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + # collect all tags (returns NULL on error) + tags_p = Messages._collect_tags (self._msgs) + #reset _msgs as we iterated over it and can do so only once + self._msgs = None + + if tags_p == None: + raise NotmuchError(STATUS.NULL_POINTER) + return Tags(tags_p, self) + + def __iter__(self): + """ Make Messages an iterator """ + return self + + def next(self): + if self._msgs is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + if not nmlib.notmuch_messages_valid(self._msgs): + self._msgs = None + raise StopIteration + + msg = Message(Messages._get (self._msgs), self) + nmlib.notmuch_messages_move_to_next(self._msgs) + return msg + + def __len__(self): + """len(:class:`Messages`) returns the number of contained messages + + .. note:: As this iterates over the messages, we will not be able to + iterate over them again! So this will fail:: + + #THIS FAILS + msgs = Database().create_query('').search_message() + if len(msgs) > 0: #this 'exhausts' msgs + # next line raises NotmuchError(STATUS.NOT_INITIALIZED)!!! + for msg in msgs: print msg + + Most of the time, using the + :meth:`Query.count_messages` is therefore more + appropriate (and much faster). While not guaranteeing + that it will return the exact same number than len(), + in my tests it effectively always did so. + """ + if self._msgs is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + i=0 + while nmlib.notmuch_messages_valid(self._msgs): + nmlib.notmuch_messages_move_to_next(self._msgs) + i += 1 + self._msgs = None + return i + + + + def __del__(self): + """Close and free the notmuch Messages""" + if self._msgs is not None: + nmlib.notmuch_messages_destroy (self._msgs) + + +#------------------------------------------------------------------------------ +class Message(object): + """Represents a single Email message + + Technically, this wraps the underlying *notmuch_message_t* structure. + """ + + """notmuch_message_get_filename (notmuch_message_t *message)""" + _get_filename = nmlib.notmuch_message_get_filename + _get_filename.restype = c_char_p + + """notmuch_message_get_message_id (notmuch_message_t *message)""" + _get_message_id = nmlib.notmuch_message_get_message_id + _get_message_id.restype = c_char_p + + """notmuch_message_get_thread_id""" + _get_thread_id = nmlib.notmuch_message_get_thread_id + _get_thread_id.restype = c_char_p + + """notmuch_message_get_replies""" + _get_replies = nmlib.notmuch_message_get_replies + _get_replies.restype = c_void_p + + """notmuch_message_get_tags (notmuch_message_t *message)""" + _get_tags = nmlib.notmuch_message_get_tags + _get_tags.restype = c_void_p + + _get_date = nmlib.notmuch_message_get_date + _get_date.restype = c_uint64 + + _get_header = nmlib.notmuch_message_get_header + _get_header.restype = c_char_p + + def __init__(self, msg_p, parent=None): + """ + :param msg_p: A pointer to an internal notmuch_message_t + Structure. If it is `None`, we will raise an :exc:`NotmuchError` + STATUS.NULL_POINTER. + :param parent: A 'parent' object is passed which this message is + derived from. We save a reference to it, so we can + automatically delete the parent object once all derived + objects are dead. + """ + if msg_p is None: + NotmuchError(STATUS.NULL_POINTER) + self._msg = msg_p + #keep reference to parent, so we keep it alive + self._parent = parent + + + def get_message_id(self): + """Returns the message ID + + :returns: String with a message ID + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Message._get_message_id(self._msg) + + def get_thread_id(self): + """Returns the thread ID + + The returned string belongs to 'message' will only be valid for as + long as the message is valid. + + This function will not return None since Notmuch ensures that every + message belongs to a single thread. + + :returns: String with a thread ID + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + return Message._get_thread_id (self._msg); + + def get_replies(self): + """Gets all direct replies to this message as :class:`Messages` iterator + + .. note:: This call only makes sense if 'message' was + ultimately obtained from a :class:`Thread` object, (such as + by coming directly from the result of calling + :meth:`Thread.get_toplevel_messages` or by any number of + subsequent calls to :meth:`get_replies`). If this message was + obtained through some non-thread means, (such as by a call + to :meth:`Query.search_messages`), then this function will + return `None`. + + :returns: :class:`Messages` or `None` if there are no replies to + this message. + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + msgs_p = Message._get_replies(self._msg); + + if msgs_p is None: + return None + + return Messages(msgs_p,self) + + def get_date(self): + """Returns time_t of the message date + + For the original textual representation of the Date header from the + message call notmuch_message_get_header() with a header value of + "date". + + :returns: A time_t timestamp. + :rtype: c_unit64 + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Message._get_date(self._msg) + + def get_header(self, header): + """Returns a message header + + This returns any message header that is stored in the notmuch database. + This is only a selected subset of headers, which is currently: + + TODO: add stored headers + + :param header: The name of the header to be retrieved. + It is not case-sensitive (TODO: confirm). + :type header: str + :returns: The header value as string + :exception: :exc:`NotmuchError` + + * STATUS.NOT_INITIALIZED if the message + is not initialized. + * STATUS.NULL_POINTER, if no header was found + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + #Returns NULL if any error occurs. + header = Message._get_header (self._msg, header) + if header == None: + raise NotmuchError(STATUS.NULL_POINTER) + return header + + def get_filename(self): + """Returns the file path of the message file + + :returns: Absolute file path & name of the message file + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Message._get_filename(self._msg) + + def get_tags(self): + """Returns the message tags + + :returns: A :class:`Tags` iterator. + :exception: :exc:`NotmuchError` + + * STATUS.NOT_INITIALIZED if the message + is not initialized. + * STATUS.NULL_POINTER, on error + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + tags_p = Message._get_tags(self._msg) + if tags_p == None: + raise NotmuchError(STATUS.NULL_POINTER) + return Tags(tags_p, self) + + def add_tag(self, tag): + """Adds a tag to the given message + + Adds a tag to the current message. The maximal tag length is defined in + the notmuch library and is currently 200 bytes. + + :param tag: String with a 'tag' to be added. + :returns: STATUS.SUCCESS if the tag was successfully added. + Raises an exception otherwise. + :exception: :exc:`NotmuchError`. They have the following meaning: + + STATUS.NULL_POINTER + The 'tag' argument is NULL + STATUS.TAG_TOO_LONG + The length of 'tag' is too long + (exceeds Message.NOTMUCH_TAG_MAX) + STATUS.READ_ONLY_DATABASE + Database was opened in read-only mode so message cannot be + modified. + STATUS.NOT_INITIALIZED + The message has not been initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + status = nmlib.notmuch_message_add_tag (self._msg, tag) + + if STATUS.SUCCESS == status: + # return on success + return status + + raise NotmuchError(status) + + def remove_tag(self, tag): + """Removes a tag from the given message + + If the message has no such tag, this is a non-operation and + will report success anyway. + + :param tag: String with a 'tag' to be removed. + :returns: STATUS.SUCCESS if the tag was successfully removed or if + the message had no such tag. + Raises an exception otherwise. + :exception: :exc:`NotmuchError`. They have the following meaning: + + STATUS.NULL_POINTER + The 'tag' argument is NULL + STATUS.TAG_TOO_LONG + The length of 'tag' is too long + (exceeds NOTMUCH_TAG_MAX) + STATUS.READ_ONLY_DATABASE + Database was opened in read-only mode so message cannot + be modified. + STATUS.NOT_INITIALIZED + The message has not been initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + status = nmlib.notmuch_message_remove_tag(self._msg, tag) + + if STATUS.SUCCESS == status: + # return on success + return status + + raise NotmuchError(status) + + def remove_all_tags(self): + """Removes all tags from the given message. + + See :meth:`freeze` for an example showing how to safely + replace tag values. + + :returns: STATUS.SUCCESS if the tags were successfully removed. + Raises an exception otherwise. + :exception: :exc:`NotmuchError`. They have the following meaning: + + STATUS.READ_ONLY_DATABASE + Database was opened in read-only mode so message cannot + be modified. + STATUS.NOT_INITIALIZED + The message has not been initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + status = nmlib.notmuch_message_remove_all_tags(self._msg) + + if STATUS.SUCCESS == status: + # return on success + return status + + raise NotmuchError(status) + + def freeze(self): + """Freezes the current state of 'message' within the database + + This means that changes to the message state, (via :meth:`add_tag`, + :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be + committed to the database until the message is :meth:`thaw`ed. + + Multiple calls to freeze/thaw are valid and these calls will + "stack". That is there must be as many calls to thaw as to freeze + before a message is actually thawed. + + The ability to do freeze/thaw allows for safe transactions to + change tag values. For example, explicitly setting a message to + have a given set of tags might look like this:: + + msg.freeze() + msg.remove_all_tags() + for tag in new_tags: + msg.add_tag(tag) + msg.thaw() + + With freeze/thaw used like this, the message in the database is + guaranteed to have either the full set of original tag values, or + the full set of new tag values, but nothing in between. + + Imagine the example above without freeze/thaw and the operation + somehow getting interrupted. This could result in the message being + left with no tags if the interruption happened after + :meth:`remove_all_tags` but before :meth:`add_tag`. + + :returns: STATUS.SUCCESS if the message was successfully frozen. + Raises an exception otherwise. + :exception: :exc:`NotmuchError`. They have the following meaning: + + STATUS.READ_ONLY_DATABASE + Database was opened in read-only mode so message cannot + be modified. + STATUS.NOT_INITIALIZED + The message has not been initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + status = nmlib.notmuch_message_freeze(self._msg) + + if STATUS.SUCCESS == status: + # return on success + return status + + raise NotmuchError(status) + + def thaw(self): + """Thaws the current 'message' + + Thaw the current 'message', synchronizing any changes that may have + occurred while 'message' was frozen into the notmuch database. + + See :meth:`freeze` for an example of how to use this + function to safely provide tag changes. + + Multiple calls to freeze/thaw are valid and these calls with + "stack". That is there must be as many calls to thaw as to freeze + before a message is actually thawed. + + :returns: STATUS.SUCCESS if the message was successfully frozen. + Raises an exception otherwise. + :exception: :exc:`NotmuchError`. They have the following meaning: + + STATUS.UNBALANCED_FREEZE_THAW + An attempt was made to thaw an unfrozen message. + That is, there have been an unbalanced number of calls + to :meth:`freeze` and :meth:`thaw`. + STATUS.NOT_INITIALIZED + The message has not been initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + status = nmlib.notmuch_message_thaw(self._msg) + + if STATUS.SUCCESS == status: + # return on success + return status + + raise NotmuchError(status) + + + def __str__(self): + """A message() is represented by a 1-line summary""" + msg = {} + msg['from'] = self.get_header('from') + msg['tags'] = str(self.get_tags()) + msg['date'] = date.fromtimestamp(self.get_date()) + replies = self.get_replies() + msg['replies'] = len(replies) if replies is not None else -1 + return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg) + + def format_as_text(self): + """Output like notmuch show (Not implemented)""" + return str(self) + + def __del__(self): + """Close and free the notmuch Message""" + if self._msg is not None: + nmlib.notmuch_message_destroy (self._msg) diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py index ba5c150d..ad480288 100644 --- a/cnotmuch/notmuch.py +++ b/cnotmuch/notmuch.py @@ -1,7 +1,5 @@ """The :mod:`notmuch` module provides most of the functionality that a user is likely to need. -Many of its objects use python's logging module to log some output at DEBUG level. - .. note:: The underlying notmuch library is build on a hierarchical memory allocator called talloc. All objects derive from a top-level :class:`Database` object. diff --git a/cnotmuch/tags.py b/cnotmuch/tag.py similarity index 100% rename from cnotmuch/tags.py rename to cnotmuch/tag.py diff --git a/cnotmuch/thread.py b/cnotmuch/thread.py index 972f426b..0eb643e9 100644 --- a/cnotmuch/thread.py +++ b/cnotmuch/thread.py @@ -1,6 +1,7 @@ from ctypes import c_char_p, c_void_p, c_uint64 -from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum -from cnotmuch.tags import Tags +from cnotmuch.globals import nmlib, STATUS, NotmuchError +from cnotmuch.message import Messages +from cnotmuch.tag import Tags from datetime import date #------------------------------------------------------------------------------ @@ -144,6 +145,10 @@ class Thread(object): _get_subject = nmlib.notmuch_thread_get_subject _get_subject.restype = c_char_p + """notmuch_thread_get_toplevel_messages""" + _get_toplevel_messages = nmlib.notmuch_thread_get_toplevel_messages + _get_toplevel_messages.restype = c_void_p + _get_newest_date = nmlib.notmuch_thread_get_newest_date _get_newest_date.restype = c_uint64 @@ -203,7 +208,34 @@ class Thread(object): return nmlib.notmuch_thread_get_total_messages(self._thread) - ###TODO: notmuch_messages_t * notmuch_thread_get_toplevel_messages (notmuch_thread_t *thread); + def get_toplevel_messages(self): + """Returns a :class:`Messages` iterator for the top-level messages in + 'thread' + + This iterator will not necessarily iterate over all of the messages + in the thread. It will only iterate over the messages in the thread + which are not replies to other messages in the thread. + + To iterate over all messages in the thread, the caller will need to + iterate over the result of :meth:`Message.get_replies` for each + top-level message (and do that recursively for the resulting + messages, etc.). + + :returns: :class:`Messages` + :exception: :exc:`NotmuchError` + + * STATUS.NOT_INITIALIZED if query is not inited + * STATUS.NULL_POINTER if search_messages failed + """ + if self._thread is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + msgs_p = Thread._get_toplevel_messages(self._thread) + + if msgs_p is None: + NotmuchError(STATUS.NULL_POINTER) + + return Messages(msgs_p,self) def get_matched_messages(self): """Returns the number of messages in 'thread' that matched the query From 6efd3c82014e5f121139f89f29267714cb9c9f59 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 24 Mar 2010 11:45:31 +0100 Subject: [PATCH 063/106] time_t is a signed int, not an unsigned one... --- cnotmuch/message.py | 4 ++-- cnotmuch/thread.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cnotmuch/message.py b/cnotmuch/message.py index d1498f0d..e8b24474 100644 --- a/cnotmuch/message.py +++ b/cnotmuch/message.py @@ -1,4 +1,4 @@ -from ctypes import c_char_p, c_void_p, c_uint64 +from ctypes import c_char_p, c_void_p, c_int64 from datetime import date from cnotmuch.globals import nmlib, STATUS, NotmuchError from cnotmuch.tag import Tags @@ -183,7 +183,7 @@ class Message(object): _get_tags.restype = c_void_p _get_date = nmlib.notmuch_message_get_date - _get_date.restype = c_uint64 + _get_date.restype = c_int64 _get_header = nmlib.notmuch_message_get_header _get_header.restype = c_char_p diff --git a/cnotmuch/thread.py b/cnotmuch/thread.py index 0eb643e9..97b58b69 100644 --- a/cnotmuch/thread.py +++ b/cnotmuch/thread.py @@ -1,4 +1,4 @@ -from ctypes import c_char_p, c_void_p, c_uint64 +from ctypes import c_char_p, c_void_p, c_int64 from cnotmuch.globals import nmlib, STATUS, NotmuchError from cnotmuch.message import Messages from cnotmuch.tag import Tags @@ -150,10 +150,10 @@ class Thread(object): _get_toplevel_messages.restype = c_void_p _get_newest_date = nmlib.notmuch_thread_get_newest_date - _get_newest_date.restype = c_uint64 + _get_newest_date.restype = c_int64 _get_oldest_date = nmlib.notmuch_thread_get_oldest_date - _get_oldest_date.restype = c_uint64 + _get_oldest_date.restype = c_int64 """notmuch_thread_get_tags""" _get_tags = nmlib.notmuch_thread_get_tags From 81a041d4cbc4aefae5d5e8c6f753fd68af62c27c Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 24 Mar 2010 11:51:12 +0100 Subject: [PATCH 064/106] try c_long rather c_int64 for time_t --- cnotmuch/message.py | 4 ++-- cnotmuch/thread.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cnotmuch/message.py b/cnotmuch/message.py index e8b24474..3ff3e7f1 100644 --- a/cnotmuch/message.py +++ b/cnotmuch/message.py @@ -1,4 +1,4 @@ -from ctypes import c_char_p, c_void_p, c_int64 +from ctypes import c_char_p, c_void_p, c_long from datetime import date from cnotmuch.globals import nmlib, STATUS, NotmuchError from cnotmuch.tag import Tags @@ -183,7 +183,7 @@ class Message(object): _get_tags.restype = c_void_p _get_date = nmlib.notmuch_message_get_date - _get_date.restype = c_int64 + _get_date.restype = c_long _get_header = nmlib.notmuch_message_get_header _get_header.restype = c_char_p diff --git a/cnotmuch/thread.py b/cnotmuch/thread.py index 97b58b69..5a2505c5 100644 --- a/cnotmuch/thread.py +++ b/cnotmuch/thread.py @@ -1,4 +1,4 @@ -from ctypes import c_char_p, c_void_p, c_int64 +from ctypes import c_char_p, c_void_p, c_long from cnotmuch.globals import nmlib, STATUS, NotmuchError from cnotmuch.message import Messages from cnotmuch.tag import Tags @@ -150,10 +150,10 @@ class Thread(object): _get_toplevel_messages.restype = c_void_p _get_newest_date = nmlib.notmuch_thread_get_newest_date - _get_newest_date.restype = c_int64 + _get_newest_date.restype = c_long _get_oldest_date = nmlib.notmuch_thread_get_oldest_date - _get_oldest_date.restype = c_int64 + _get_oldest_date.restype = c_long """notmuch_thread_get_tags""" _get_tags = nmlib.notmuch_thread_get_tags From c4e88d3aba90d8daf14e766968b50c7754bd624d Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 24 Mar 2010 12:28:17 +0100 Subject: [PATCH 065/106] bump __VERSION__ to 0.2.0 --- cnotmuch/notmuch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py index ad480288..29d30c4f 100644 --- a/cnotmuch/notmuch.py +++ b/cnotmuch/notmuch.py @@ -35,5 +35,5 @@ from database import Database, Query from cnotmuch.globals import nmlib, STATUS, NotmuchError __LICENSE__="GPL v3+" -__VERSION__='0.1.1' +__VERSION__='0.2.0' __AUTHOR__ ='Sebastian Spaeth ' From 99a24452bef8d1fde2e528bed233edb79dc1c978 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 24 Mar 2010 12:34:48 +0100 Subject: [PATCH 066/106] README: update changelog --- README | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README b/README index d7107b7d..c36648be 100644 --- a/README +++ b/README @@ -12,7 +12,7 @@ without the documentation), you can create the documentation with sphinx installed, go to the docs directory and "make html". A static version of the documentation is available at: -`http://spaetz.bitbucket.org`_ +http://packages.python.org/cnotmuch/ The current source code is being hosted at http://bitbucket.org/spaetz/cnotmuch which also provides an issue @@ -72,3 +72,10 @@ NotmuchError: Could not open the specified database >>>tags = notmuch.Database("/home/spaetz/mail").get_all_tags() >>>del(tags) + + +Changelog +--------- +0.1 First public release +0.1.1 Fixed Database.create_query() +0.2.0 Implemented Thread() and Threads() methods \ No newline at end of file From f55c9af9abc7782f7bb5c5fb4e91732b18919da7 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 24 Mar 2010 12:34:55 +0100 Subject: [PATCH 067/106] Added tag v0.2.0 for changeset 36e2dd4e739a --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index d0bb01b6..784cf428 100644 --- a/.hgtags +++ b/.hgtags @@ -1,2 +1,3 @@ 27f38f75d27d1dd5abbb1d5d9d5929372f84b2b2 v0.1 4fdbf2935cdf58865745d8bbc5e9b6be008ecb1c v0.1.1 +36e2dd4e739a9da87837773467777d6d67e319f5 v0.2.0 From 9058e3d1b55ed35cda2df6426578435934af19de Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 24 Mar 2010 17:18:33 +0100 Subject: [PATCH 068/106] fix documentations, and add a very brittle notmuch reply command --- cnotmuch/notmuch.py | 3 + docs/source/index.rst | 13 +++- notmuch | 176 +++++++++++++++++++++++++++++++++++++++++- setup.py | 2 +- 4 files changed, 190 insertions(+), 4 deletions(-) diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py index 29d30c4f..306940e9 100644 --- a/cnotmuch/notmuch.py +++ b/cnotmuch/notmuch.py @@ -33,6 +33,9 @@ """ from database import Database, Query +from message import Messages +from thread import Threads +from tag import Tags from cnotmuch.globals import nmlib, STATUS, NotmuchError __LICENSE__="GPL v3+" __VERSION__='0.2.0' diff --git a/docs/source/index.rst b/docs/source/index.rst index f2b995a6..4a6c574b 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,7 +7,7 @@ Welcome to :mod:`cnotmuch`'s documentation =========================================== The :mod:`cnotmuch` module provides an interface to the `notmuch `_ functionality, directly interfacing to a shared notmuch library. -The classes :class:`notmuch.Database`, :class:`notmuch.Query` provide most of the core functionality, returning :class:`notmuch.Messages` and :class:`notmuch.Tags`. +The classes :class:`notmuch.Database`, :class:`notmuch.Query` provide most of the core functionality, returning :class:`notmuch.Threads`, :class:`notmuch.Messages` and :class:`notmuch.Tags`. .. moduleauthor:: Sebastian Spaeth @@ -115,6 +115,9 @@ More information on specific topics can be found on the following pages: .. automethod:: count_messages +.. ############################################# +.. currentmodule:: cnotmuch.message + :class:`Messages` -- A bunch of messages ---------------------------------------- @@ -161,6 +164,9 @@ More information on specific topics can be found on the following pages: .. automethod:: __str__ +.. ############################################# +.. currentmodule:: cnotmuch.tag + :class:`Tags` -- Notmuch tags ----------------------------- @@ -172,7 +178,7 @@ More information on specific topics can be found on the following pages: .. automethod:: __str__ -.. ----------------------------------------------------------------- +.. ############################################# .. currentmodule:: cnotmuch.thread :class:`Threads` -- Threads iterator @@ -209,6 +215,9 @@ More information on specific topics can be found on the following pages: .. automethod:: __str__ +.. ############################################# +.. currentmodule:: cnotmuch.notmuch + :class:`Filenames` -- An iterator over filenames ------------------------------------------------ diff --git a/notmuch b/notmuch index 2a1540a1..b08334d7 100755 --- a/notmuch +++ b/notmuch @@ -12,6 +12,27 @@ from cnotmuch.notmuch import Database, Query PREFIX=re.compile('(\w+):(.*$)') #TODO Handle variable: NOTMUCH-CONFIG +#------------------------------------------------------------------------- +def get_user_email_addresses(): + """ Reads a user's notmuch config and returns his email addresses as list (name, primary_address, other_address1,...)""" + import email.utils + from ConfigParser import SafeConfigParser + config = SafeConfigParser() + conf_f = os.getenv('NOTMUCH_CONFIG', + os.path.expanduser('~/.notmuch-config')) + config.read(conf_f) + if not config.has_option('user','name'): name = "" + else:name = config.get('user','name') + + if not config.has_option('user','primary_email'): mail = "" + else:mail = config.get('user','primary_email') + + if not config.has_option('user','other_email'): other = [] + else:other = config.get('user','other_email').rstrip(';').split(';') + + other.insert(0, mail) + other.insert(0, name) + return other #------------------------------------------------------------------------- HELPTEXT="""The notmuch mail system. @@ -91,6 +112,145 @@ And don't forget to run "notmuch new" whenever new mail arrives. Have fun, and may your inbox never have much mail. """ #------------------------------------------------------------------------- +def quote_reply(oldbody ,date, from_address): + """Transform a mail body into a quote text starting with On blah, x wrote: + :param body: a str with a mail body + :returns: The new payload of the email.message() + """ + from cStringIO import StringIO + + #we get handed a string, wrap it in a file-like object + oldbody = StringIO(oldbody) + newbody = StringIO() + + newbody.write("On %s, %s wrote:\n" % (date, from_address)) + + for line in oldbody: + newbody.write("> " + line) + + return newbody.getvalue() + +def format_reply(msgs): + """Gets handed Messages() and displays the reply to them""" + import email + + for msg in msgs: + f = open(msg.get_filename(),"r") + reply = email.message_from_file(f) + + #handle the easy non-multipart case: + if not reply.is_multipart(): + reply.set_payload(quote_reply(reply.get_payload(), + reply['date'],reply['from'])) + else: + #handle the tricky multipart case + deleted = "" + """A string describing which nontext attachements have been deleted""" + delpayloads = [] + """A list of payload indices to be deleted""" + + payloads = reply.get_payload() + + for i, part in enumerate(payloads): + + mime_main = part.get_content_maintype() + if mime_main not in ['multipart', 'message', 'text']: + deleted += "Non-text part: %s\n" % (part.get_content_type()) + payloads[i].set_payload("Non-text part: %s" % (part.get_content_type())) + payloads[i].set_type('text/plain') + delpayloads.append(i) + else: + # payloads[i].set_payload("Text part: %s" % (part.get_content_type())) + payloads[i].set_payload(quote_reply(payloads[i].get_payload(),reply['date'],reply['from'])) + + + # Delete those payloads that we don't need anymore + for i in reversed(sorted(delpayloads)): + del payloads[i] + + #Back to single- and multipart handling + + my_addresses = get_user_email_addresses() + used_address = None + # filter our email addresses from all to: cc: and bcc: fields + # if we find one of "my" addresses being used, + # it is stored in used_address + for header in ['To', 'CC', 'Bcc']: + if not header in reply: + #only handle fields that exist + continue + addresses = email.utils.getaddresses(reply.get_all(header,[])) + purged_addr = [] + for name, mail in addresses: + if mail in my_addresses[1:]: + used_address = email.utils.formataddr((my_addresses[0],mail)) + else: + purged_addr.append(email.utils.formataddr((name,mail))) + + if len(purged_addr): + reply.replace_header(header, ", ".join(purged_addr)) + else: + #we deleted all addresses, delete the header + del reply[header] + + # Use our primary email address to the From + # (save original from line, we still need it) + orig_from = reply['From'] + del reply['From'] + reply['From'] = used_address if used_address \ + else email.utils.formataddr((my_addresses[0],my_addresses[1])) + + #reinsert the Subject after the From + orig_subject = reply['Subject'] + del reply['Subject'] + reply['Subject'] = 'Re: ' + orig_subject + + # Calculate our new To: field + new_to = orig_from + # add all remaining original 'To' addresses + if 'To' in reply: + new_to += ", " + reply['To'] + del reply['To'] + reply.add_header('To', new_to) + + # Add our primary email address to the BCC + new_bcc = my_addresses[1] + if reply.has_key('Bcc'): + new_bcc += ', ' + reply['Bcc'] + del reply['Bcc'] + reply['Bcc'] = new_bcc + + # Set replies 'In-Reply-To' header to original's Message-ID + if reply.has_key('Message-ID') : + del reply['In-Reply-To'] + reply['In-Reply-To'] = reply['Message-ID'] + + #Add original's Message-ID to replies 'References' header. + if reply.has_key('References'): + ref = reply['References'] + ' ' +reply['Message-ID'] + else: + ref = reply['Message-ID'] + del reply['References'] + reply['References'] = ref + + # Delete the original Message-ID. + del(reply['Message-ID']) + + # filter all existing headers but a few and delete them from 'reply' + delheaders = filter(lambda x: x not in ['From','To','Subject','CC','Bcc', + 'In-Reply-To', 'References', + 'Content-Type'],reply.keys()) + map(reply.__delitem__, delheaders) + + """ +From: Sebastian Spaeth +Subject: Re: Template =?iso-8859-1?b?Zvxy?= das Kochrezept +In-Reply-To: <4A6D55F9.6040405@SSpaeth.de> +References: <4A6D55F9.6040405@SSpaeth.de> + """ + #return without Unixfrom + return reply.as_string(False) +#------------------------------------------------------------------------- def quote_query_line(argv): #mangle arguments wrapping terms with spaces in quotes for i in xrange(0,len(argv)): @@ -149,6 +309,21 @@ if __name__ == '__main__': m = Query(db,querystr).search_messages() for msg in m: print(msg.format_as_text()) + + #------------------------------------- + elif sys.argv[1] == 'reply': + db = Database() + if len(sys.argv) == 2: + #no search term. abort + print("Error: notmuch reply requires at least one search term.") + sys.exit() + + #mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[2:]) + logging.debug("reply "+querystr) + msgs = Query(db,querystr).search_messages() + print (format_reply(msgs)) + #------------------------------------- elif sys.argv[1] == 'count': if len(sys.argv) == 2: @@ -257,7 +432,6 @@ if __name__ == '__main__': """ setup new -search [options...] [...] show [...] reply [options...] [...] restore diff --git a/setup.py b/setup.py index 22d44d3e..ad411c01 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setup(name='cnotmuch', keywords = ["library", "email"], long_description="""The cnotmuch module provides an interface to the `notmuch `_ functionality, directly interfacing with a shared notmuch library. Notmuch provides a maildatabase that allows for extremely quick searching and filtering of your email according to various criteria. -The documentation for the latest cnotmuch release can be `viewed online `_. +The documentation for the latest cnotmuch release can be `viewed online `_. The classes notmuch.Database, notmuch.Query provide most of the core functionality, returning notmuch.Messages and notmuch.Tags. """, From e80daac2931d99da10bf2d1d106fb35455455470 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 25 Mar 2010 08:39:21 +0100 Subject: [PATCH 069/106] Implement Message().get|set_flag() These were the last 2 missing methods from the Message object, which should be feature complete now. --- cnotmuch/database.py | 6 +++++- cnotmuch/message.py | 43 +++++++++++++++++++++++++++++++++++++++++-- cnotmuch/notmuch.py | 4 ++-- docs/source/index.rst | 10 +++++++++- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 321b9611..e540bb3c 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -1,4 +1,4 @@ -import ctypes, os +import os from ctypes import c_int, c_char_p, c_void_p, c_uint, byref from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum from cnotmuch.thread import Threads @@ -451,6 +451,10 @@ class Query(object): The returned threads are owned by the query and as such, will only be valid until the Query is deleted. + The method sets :attr:`Message.FLAG`\.MATCH for those messages that + match the query. The method :meth:`Message.get_flag` allows us + to get the value of this flag. + Technically, it wraps the underlying *notmuch_query_search_threads* function. diff --git a/cnotmuch/message.py b/cnotmuch/message.py index 3ff3e7f1..4ed48028 100644 --- a/cnotmuch/message.py +++ b/cnotmuch/message.py @@ -1,6 +1,6 @@ -from ctypes import c_char_p, c_void_p, c_long +from ctypes import c_char_p, c_void_p, c_long, c_bool from datetime import date -from cnotmuch.globals import nmlib, STATUS, NotmuchError +from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum from cnotmuch.tag import Tags #------------------------------------------------------------------------------ class Messages(object): @@ -166,6 +166,10 @@ class Message(object): _get_filename = nmlib.notmuch_message_get_filename _get_filename.restype = c_char_p + """notmuch_message_get_flag""" + _get_flag = nmlib.notmuch_message_get_flag + _get_flag.restype = c_bool + """notmuch_message_get_message_id (notmuch_message_t *message)""" _get_message_id = nmlib.notmuch_message_get_message_id _get_message_id.restype = c_char_p @@ -188,6 +192,9 @@ class Message(object): _get_header = nmlib.notmuch_message_get_header _get_header.restype = c_char_p + #Constants: Flags that can be set/get with set_flag + FLAG = Enum(['MATCH']) + def __init__(self, msg_p, parent=None): """ :param msg_p: A pointer to an internal notmuch_message_t @@ -315,6 +322,38 @@ class Message(object): raise NotmuchError(STATUS.NOT_INITIALIZED) return Message._get_filename(self._msg) + def get_flag(self, flag): + """Checks whether a specific flag is set for this message + + The method :meth:`Query.search_threads` sets + *Message.FLAG.MATCH* for those messages that match the + query. This method allows us to get the value of this flag. + + :param flag: One of the :attr:`Message.FLAG` values (currently only + *Message.FLAG.MATCH* + :returns: A bool, indicating whether the flag is set. + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + return Message._get_flag(self._msg, flag) + + def set_flag(self, flag, value): + """Sets/Unsets a specific flag for this message + + :param flag: One of the :attr:`Message.FLAG` values (currently only + *Message.FLAG.MATCH* + :param value: A bool indicating whether to set or unset the flag. + + :returns: Nothing + :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message + is not initialized. + """ + if self._msg is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + nmlib.notmuch_message_set_flag(self._msg, flag, value) + def get_tags(self): """Returns the message tags diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py index 306940e9..019da2af 100644 --- a/cnotmuch/notmuch.py +++ b/cnotmuch/notmuch.py @@ -33,8 +33,8 @@ """ from database import Database, Query -from message import Messages -from thread import Threads +from message import Messages, Message +from thread import Threads, Thread from tag import Tags from cnotmuch.globals import nmlib, STATUS, NotmuchError __LICENSE__="GPL v3+" diff --git a/docs/source/index.rst b/docs/source/index.rst index 4a6c574b..89b8d6c1 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -140,7 +140,15 @@ More information on specific topics can be found on the following pages: .. automethod:: get_filename - .. automethod:: get_flag + .. attribute:: FLAG + + FLAG.MATCH + This flag is automatically set by a + Query.search_threads on those messages that match the + query. This allows us to distinguish matches from the rest + of the messages in that thread. + + .. automethod:: get_flag .. automethod:: set_flag From a27c480048127c744909022ed8f633f8b2896ba6 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 25 Mar 2010 11:39:31 +0100 Subject: [PATCH 070/106] Implement Database().get_directory() and Directory() class. --- cnotmuch/database.py | 203 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 201 insertions(+), 2 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index e540bb3c..71429034 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -1,5 +1,5 @@ import os -from ctypes import c_int, c_char_p, c_void_p, c_uint, byref +from ctypes import c_int, c_char_p, c_void_p, c_uint, c_long, byref from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum from cnotmuch.thread import Threads from cnotmuch.message import Messages @@ -20,6 +20,10 @@ class Database(object): MODE = Enum(['READ_ONLY','READ_WRITE']) """Constants: Mode in which to open the database""" + """notmuch_database_get_directory""" + _get_directory = nmlib.notmuch_database_get_directory + _get_directory.restype = c_void_p + """notmuch_database_get_path (notmuch_database_t *database)""" _get_path = nmlib.notmuch_database_get_path _get_path.restype = c_char_p @@ -167,7 +171,50 @@ class Database(object): # Raise a NotmuchError if not initialized self._verify_initialized_db() - return notmuch_database_needs_upgrade(self.db) + return notmuch_database_needs_upgrade(self._db) + + def get_directory(self, path): + """Returns a :class:`Directory` of path, + (creating it if it does not exist(?)) + + .. warning:: This call needs a writeable database in + Database.MODE.READ_WRITE mode. The underlying library will exit the + program if this method is used on a read-only database! + + :param path: A str containing the path relative to the path of database + (see :meth:`get_path`), or else should be an absolute path + with initial components that match the path of 'database'. + + :returns: :class:`Directory` or raises an exception. + :exception: :exc:`NotmuchError` + + STATUS.NOT_INITIALIZED + If the database was not intitialized. + + STATUS.FILE_ERROR + If path is not relative database or absolute with initial + components same as database. + + """ + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + + # sanity checking if path is valid, and make path absolute + if path[0] == os.sep: + # we got an absolute path + if not path.startswith(self.get_path()): + # but its initial components are not equal to the db path + raise NotmuchError(STATUS.FILE_ERROR, + message="Database().get_directory() called with a wrong absolute path.") + abs_dirpath = path + else: + #we got a relative path, make it absolute + abs_dirpath = os.path.abspath(os.path.join(self.get_path(),path)) + + dir_p = Database._get_directory(self._db, path); + + # return the Directory, init it with the absolute path + return Directory(abs_dirpath, dir_p, self) def add_message(self, filename): """Adds a new message to the database @@ -521,3 +568,155 @@ class Query(object): """Close and free the Query""" if self._query is not None: nmlib.notmuch_query_destroy (self._query) + + +#------------------------------------------------------------------------------ +class Directory(object): + """Represents a directory entry in the notmuch directory + + Modifying attributes of this object will modify the + database, not the real directory attributes. + + The Directory object is usually derived from another object + e.g. via :meth:`Database.get_directory`, and will automatically be + become invalid whenever that parent is deleted. You should + therefore initialized this object handing it a reference to the + parent, preventing the parent from automatically being garbage + collected. + """ + + """notmuch_directory_get_mtime""" + _get_mtime = nmlib.notmuch_directory_get_mtime + _get_mtime.restype = c_long + + """notmuch_directory_set_mtime""" + _set_mtime = nmlib.notmuch_directory_set_mtime + _set_mtime.argtypes = [c_char_p, c_long] + + """notmuch_directory_get_child_directories""" + _get_child_directories = nmlib.notmuch_directory_get_child_directories + _get_child_directories.restype = c_void_p + + def _verify_dir_initialized(self): + """Raises a NotmuchError(STATUS.NOT_INITIALIZED) if the dir_p is None""" + if self._dir_p is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + def __init__(self, path, dir_p, parent): + """ + :param path: The absolute path of the directory object. + :param dir_p: The pointer to an internal notmuch_directory_t object. + :param parent: The object this Directory is derived from + (usually a Database()). We do not directly use + this, but store a reference to it as long as + this Directory object lives. This keeps the + parent object alive. + """ + #TODO, sanity checking that the path is really absolute? + self._path = path + self._dir_p = dir_p + self._parent = parent + + + def set_mtime (self, mtime): + """Sets the mtime value of this directory in the database + + The intention is for the caller to use the mtime to allow efficient + identification of new messages to be added to the database. The + recommended usage is as follows: + + * Read the mtime of a directory from the filesystem + + * Call :meth:`Database.add_message` for all mail files in + the directory + + * Call notmuch_directory_set_mtime with the mtime read from the + filesystem. Then, when wanting to check for updates to the + directory in the future, the client can call :meth:`get_mtime` + and know that it only needs to add files if the mtime of the + directory and files are newer than the stored timestamp. + + .. note:: :meth:`get_mtime` function does not allow the caller + to distinguish a timestamp of 0 from a non-existent + timestamp. So don't store a timestamp of 0 unless you are + comfortable with that. + + :param mtime: A (time_t) timestamp + :returns: Nothing on success, raising an exception on failure. + :exception: :exc:`NotmuchError`: + + STATUS.XAPIAN_EXCEPTION + A Xapian exception occurred, mtime not stored. + STATUS.READ_ONLY_DATABASE + Database was opened in read-only mode so directory + mtime cannot be modified. + STATUS.NOT_INITIALIZED + The directory has not been initialized + """ + #Raise a NotmuchError(STATUS.NOT_INITIALIZED) if the dir_p is None + self._verify_dir_initialized() + + #TODO: make sure, we convert the mtime parameter to a 'c_long' + status = Directory._set_mtime(self._dir_p, mtime) + + #return on success + if status == STATUS.SUCCESS: + return + #fail with Exception otherwise + raise NotmuchError(status) + + def get_mtime (self): + """Gets the mtime value of this directory in the database + + Retrieves a previously stored mtime for this directory. + + :param mtime: A (time_t) timestamp + :returns: Nothing on success, raising an exception on failure. + :exception: :exc:`NotmuchError`: + + STATUS.NOT_INITIALIZED + The directory has not been initialized + """ + #Raise a NotmuchError(STATUS.NOT_INITIALIZED) if self.dir_p is None + self._verify_dir_initialized() + + return Directory._get_mtime (self._dir_p) + + + # Make mtime attribute a property of Directory() + mtime = property(get_mtime, set_mtime, doc="""Property that allows getting + and setting of the Directory *mtime*""") + + def get_child_files(self): + """Gets a Filenames iterator listing all the filenames of + messages in the database within the given directory. + + The returned filenames will be the basename-entries only (not + complete paths. + """ + pass + #notmuch_filenames_t * notmuch_directory_get_child_files (notmuch_directory_t *directory); + + def get_child_directories(self): + """Gets a Filenams iterator listing all the filenames of + sub-directories in the database within the given directory + + The returned filenames will be the basename-entries only (not + complete paths. + """ + #notmuch_filenames_t * notmuch_directory_get_child_directories (notmuch_directory_t *directory); + pass + + @property + def path(self): + """Returns the absolute path of this Directory""" + return self._path + + def __repr__(self): + """Object representation""" + return "" % self._path + + def __del__(self): + """Close and free the Directory""" + if self._dir_p is not None: + nmlib.notmuch_directory_destroy(self._dir_p) From d49f95602728f24b8b0e57c84e32a7d0729b701a Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 25 Mar 2010 12:01:20 +0100 Subject: [PATCH 071/106] Implement Filenames() iterator. The API should be complete at this point. We are still missing Database.upgrade() as I am not sure how to implement the callback, and it's not that important for now. Documentation for the new classes is written inline, but not integrated in the docs yet. --- cnotmuch/database.py | 93 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 10 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 71429034..3c142494 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -593,6 +593,10 @@ class Directory(object): _set_mtime = nmlib.notmuch_directory_set_mtime _set_mtime.argtypes = [c_char_p, c_long] + """notmuch_directory_get_child_files""" + _get_child_files = nmlib.notmuch_directory_get_child_files + _get_child_files.restype = c_void_p + """notmuch_directory_get_child_directories""" _get_child_directories = nmlib.notmuch_directory_get_child_directories _get_child_directories.restype = c_void_p @@ -612,7 +616,6 @@ class Directory(object): this Directory object lives. This keeps the parent object alive. """ - #TODO, sanity checking that the path is really absolute? self._path = path self._dir_p = dir_p self._parent = parent @@ -688,14 +691,17 @@ class Directory(object): and setting of the Directory *mtime*""") def get_child_files(self): - """Gets a Filenames iterator listing all the filenames of - messages in the database within the given directory. + """Gets a Filenames iterator listing all the filenames of + messages in the database within the given directory. - The returned filenames will be the basename-entries only (not - complete paths. - """ - pass - #notmuch_filenames_t * notmuch_directory_get_child_files (notmuch_directory_t *directory); + The returned filenames will be the basename-entries only (not + complete paths. + """ + #Raise a NotmuchError(STATUS.NOT_INITIALIZED) if self._dir_p is None + self._verify_dir_initialized() + + files_p = Directory._get_child_files(self._dir_p) + return Filenames(files_p, self) def get_child_directories(self): """Gets a Filenams iterator listing all the filenames of @@ -704,8 +710,11 @@ class Directory(object): The returned filenames will be the basename-entries only (not complete paths. """ - #notmuch_filenames_t * notmuch_directory_get_child_directories (notmuch_directory_t *directory); - pass + #Raise a NotmuchError(STATUS.NOT_INITIALIZED) if self._dir_p is None + self._verify_dir_initialized() + + files_p = Directory._get_child_directories(self._dir_p) + return Filenames(files_p, self) @property def path(self): @@ -720,3 +729,67 @@ class Directory(object): """Close and free the Directory""" if self._dir_p is not None: nmlib.notmuch_directory_destroy(self._dir_p) + +#------------------------------------------------------------------------------ +class Filenames(object): + """An iterator over File- or Directory names that are stored in the notmuch database + """ + + #notmuch_filenames_get + _get = nmlib.notmuch_filenames_get + _get.restype = c_char_p + + def __init__(self, files_p, parent): + """ + :param files_p: The pointer to an internal notmuch_filenames_t object. + :param parent: The object this Directory is derived from + (usually a Directory()). We do not directly use + this, but store a reference to it as long as + this Directory object lives. This keeps the + parent object alive. + """ + self._files_p = files_p + self._parent = parent + + def __iter__(self): + """ Make Filenames an iterator """ + return self + + def next(self): + if self._files_p is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + if not nmlib.notmuch_filenames_valid(self._files_p): + self._files_p = None + raise StopIteration + + file = Filenames._get (self._files_p) + nmlib.notmuch_filenames_move_to_next(self._files_p) + return file + + def __len__(self): + """len(:class:`Filenames`) returns the number of contained files + + .. note:: As this iterates over the files, we will not be able to + iterate over them again! So this will fail:: + + #THIS FAILS + files = Database().get_directory('').get_child_files() + if len(files) > 0: #this 'exhausts' msgs + # next line raises NotmuchError(STATUS.NOT_INITIALIZED)!!! + for file in files: print file + """ + if self._files_p is None: + raise NotmuchError(STATUS.NOT_INITIALIZED) + + i=0 + while nmlib.notmuch_filenames_valid(self._files_p): + nmlib.notmuch_filenames_move_to_next(self._files_p) + i += 1 + self._files_p = None + return i + + def __del__(self): + """Close and free Filenames""" + if self._files_p is not None: + nmlib.notmuch_filenames_destroy(self._files_p) From bef8bdbd04c37d6eaba4bed573046243de39d899 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 25 Mar 2010 13:13:44 +0100 Subject: [PATCH 072/106] Implement Database.upgrade() to get the last bit of API. We are complete now. --- cnotmuch/database.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 3c142494..c88c21c5 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -36,6 +36,10 @@ class Database(object): _open = nmlib.notmuch_database_open _open.restype = c_void_p + """notmuch_database_upgrade""" + _upgrade = nmlib.notmuch_database_upgrade + _upgrade.argtypes = [c_void_p, c_void_p, c_void_p] + """ notmuch_database_find_message """ _find_message = nmlib.notmuch_database_find_message _find_message.restype = c_void_p @@ -173,6 +177,27 @@ class Database(object): return notmuch_database_needs_upgrade(self._db) + def upgrade(self): + """Upgrades the current database + + After opening a database in read-write mode, the client should + check if an upgrade is needed (notmuch_database_needs_upgrade) and + if so, upgrade with this function before making any modifications. + + NOT IMPLEMENTED: The optional progress_notify callback can be + used by the caller to provide progress indication to the + user. If non-NULL it will be called periodically with + 'progress' as a floating-point value in the range of [0.0 + .. 1.0] indicating the progress made so far in the upgrade + process. + """ + # Raise a NotmuchError if not initialized + self._verify_initialized_db() + + status = Database._upgrade (self._db, None, None) + #TODO: catch exceptions, document return values and etc + return status + def get_directory(self, path): """Returns a :class:`Directory` of path, (creating it if it does not exist(?)) From 7390c869c71cd7d65a99b3dacb17ca659aa09c8b Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 25 Mar 2010 14:28:56 +0100 Subject: [PATCH 073/106] docs: Improve documentations --- cnotmuch/database.py | 54 ++++++++++++++++--------------- docs/source/index.rst | 75 +++++++++++++++++++++++++++---------------- 2 files changed, 75 insertions(+), 54 deletions(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index c88c21c5..44fd3154 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -24,7 +24,7 @@ class Database(object): _get_directory = nmlib.notmuch_database_get_directory _get_directory.restype = c_void_p - """notmuch_database_get_path (notmuch_database_t *database)""" + """notmuch_database_get_path""" _get_path = nmlib.notmuch_database_get_path _get_path.restype = c_char_p @@ -32,7 +32,7 @@ class Database(object): _get_version = nmlib.notmuch_database_get_version _get_version.restype = c_uint - """notmuch_database_open (const char *path, notmuch_database_mode_t mode)""" + """notmuch_database_open""" _open = nmlib.notmuch_database_open _open.restype = c_void_p @@ -40,20 +40,20 @@ class Database(object): _upgrade = nmlib.notmuch_database_upgrade _upgrade.argtypes = [c_void_p, c_void_p, c_void_p] - """ notmuch_database_find_message """ + """ notmuch_database_find_message""" _find_message = nmlib.notmuch_database_find_message _find_message.restype = c_void_p - """notmuch_database_get_all_tags (notmuch_database_t *database)""" + """notmuch_database_get_all_tags""" _get_all_tags = nmlib.notmuch_database_get_all_tags _get_all_tags.restype = c_void_p - """ notmuch_database_create(const char *path):""" + """notmuch_database_create""" _create = nmlib.notmuch_database_create _create.restype = c_void_p def __init__(self, path=None, create=False, mode= 0): - """If *path* is *None*, we will try to read a users notmuch + """If *path* is `None`, we will try to read a users notmuch configuration and use his configured database. The location of the configuration file can be specified through the environment variable *NOTMUCH_CONFIG*, falling back to the default `~/.notmuch-config`. @@ -97,8 +97,8 @@ class Database(object): This function is used by __init__() and usually does not need to be called directly. It wraps the underlying *notmuch_database_create* function and creates a new notmuch - database at *path*. It will always return a database in - :attr:`MODE`.READ_WRITE mode as creating an empty database for + database at *path*. It will always return a database in :attr:`MODE` + .READ_WRITE mode as creating an empty database for reading only does not make a great deal of sense. :param path: A directory in which we should create the database. @@ -142,7 +142,7 @@ class Database(object): def get_path(self): """Returns the file path of an open database - Wraps notmuch_database_get_path""" + Wraps *notmuch_database_get_path*.""" # Raise a NotmuchError if not initialized self._verify_initialized_db() @@ -163,10 +163,10 @@ class Database(object): def needs_upgrade(self): """Does this database need to be upgraded before writing to it? - If this function returns True then no functions that modify the - database (:meth:`add_message`, :meth:`add_tag`, - :meth:`Directory.set_mtime`, etc.) will work unless :meth:`upgrade` - is called successfully first. + If this function returns `True` then no functions that modify the + database (:meth:`add_message`, + :meth:`Message.add_tag`, :meth:`Directory.set_mtime`, + etc.) will work unless :meth:`upgrade` is called successfully first. :returns: `True` or `False` :exception: :exc:`NotmuchError` with STATUS.NOT_INITIALIZED if @@ -187,9 +187,10 @@ class Database(object): NOT IMPLEMENTED: The optional progress_notify callback can be used by the caller to provide progress indication to the user. If non-NULL it will be called periodically with - 'progress' as a floating-point value in the range of [0.0 - .. 1.0] indicating the progress made so far in the upgrade - process. + 'progress' as a floating-point value in the range of [0.0..1.0] + indicating the progress made so far in the upgrade process. + + :TODO: catch exceptions, document return values and etc... """ # Raise a NotmuchError if not initialized self._verify_initialized_db() @@ -207,9 +208,8 @@ class Database(object): program if this method is used on a read-only database! :param path: A str containing the path relative to the path of database - (see :meth:`get_path`), or else should be an absolute path - with initial components that match the path of 'database'. - + (see :meth:`get_path`), or else should be an absolute path + with initial components that match the path of 'database'. :returns: :class:`Directory` or raises an exception. :exception: :exc:`NotmuchError` @@ -308,7 +308,7 @@ class Database(object): is removed for a particular message, the database content for that message will be entirely removed. - :returns: A STATUS.* value with the following meaning: + :returns: A STATUS value with the following meaning: STATUS.SUCCESS The last filename was removed and the message was removed @@ -636,7 +636,7 @@ class Directory(object): :param path: The absolute path of the directory object. :param dir_p: The pointer to an internal notmuch_directory_t object. :param parent: The object this Directory is derived from - (usually a Database()). We do not directly use + (usually a :class:`Database`). We do not directly use this, but store a reference to it as long as this Directory object lives. This keeps the parent object alive. @@ -710,10 +710,12 @@ class Directory(object): return Directory._get_mtime (self._dir_p) - # Make mtime attribute a property of Directory() mtime = property(get_mtime, set_mtime, doc="""Property that allows getting - and setting of the Directory *mtime*""") + and setting of the Directory *mtime* (read-write) + + See :meth:`get_mtime` and :meth:`set_mtime` for usage and + possible exceptions.""") def get_child_files(self): """Gets a Filenames iterator listing all the filenames of @@ -729,7 +731,7 @@ class Directory(object): return Filenames(files_p, self) def get_child_directories(self): - """Gets a Filenams iterator listing all the filenames of + """Gets a :class:`Filenames` iterator listing all the filenames of sub-directories in the database within the given directory The returned filenames will be the basename-entries only (not @@ -743,7 +745,7 @@ class Directory(object): @property def path(self): - """Returns the absolute path of this Directory""" + """Returns the absolute path of this Directory (read-only)""" return self._path def __repr__(self): @@ -757,7 +759,7 @@ class Directory(object): #------------------------------------------------------------------------------ class Filenames(object): - """An iterator over File- or Directory names that are stored in the notmuch database + """An iterator over File- or Directory names that are stored in the database """ #notmuch_filenames_get diff --git a/docs/source/index.rst b/docs/source/index.rst index 89b8d6c1..2354190a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,13 +1,13 @@ .. cnotmuch documentation master file, created by sphinx-quickstart on Tue Feb 2 10:00:47 2010. -.. currentmodule:: cnotmuch +.. currentmodule:: cnotmuch.notmuch Welcome to :mod:`cnotmuch`'s documentation =========================================== The :mod:`cnotmuch` module provides an interface to the `notmuch `_ functionality, directly interfacing to a shared notmuch library. -The classes :class:`notmuch.Database`, :class:`notmuch.Query` provide most of the core functionality, returning :class:`notmuch.Threads`, :class:`notmuch.Messages` and :class:`notmuch.Tags`. +Within :mod:`cnotmuch.notmuch`, the classes :class:`Database`, :class:`Query` provide most of the core functionality, returning :class:`Threads`, :class:`Messages` and :class:`Tags`. .. moduleauthor:: Sebastian Spaeth @@ -30,17 +30,17 @@ More information on specific topics can be found on the following pages: notmuch -:mod:`notmuch` -- The Notmuch interface -============================================= +:mod:`cnotmuch.notmuch` -- The Notmuch interface +================================================= .. automodule:: cnotmuch.notmuch :todo: Document nmlib,STATUS -:class:`Database` -- The underlying notmuch database ------------------------------------------------------ +:class:`cnotmuch.notmuch.Database` -- The underlying notmuch database +--------------------------------------------------------------------- -.. autoclass:: Database([path=None[, create=False[, mode=MODE.READ_ONLY]]]) +.. autoclass:: cnotmuch.notmuch.Database([path=None[, create=False[, mode=MODE.READ_ONLY]]]) .. automethod:: create @@ -81,10 +81,10 @@ More information on specific topics can be found on the following pages: .. autoattribute:: db_p -:class:`Query` -- A search query ------------------------------------------------ +:class:`cnotmuch.notmuch.Query` -- A search query +------------------------------------------------- -.. autoclass:: Query +.. autoclass:: cnotmuch.notmuch.Query .. automethod:: create @@ -115,8 +115,6 @@ More information on specific topics can be found on the following pages: .. automethod:: count_messages -.. ############################################# -.. currentmodule:: cnotmuch.message :class:`Messages` -- A bunch of messages ---------------------------------------- @@ -172,8 +170,6 @@ More information on specific topics can be found on the following pages: .. automethod:: __str__ -.. ############################################# -.. currentmodule:: cnotmuch.tag :class:`Tags` -- Notmuch tags ----------------------------- @@ -186,13 +182,10 @@ More information on specific topics can be found on the following pages: .. automethod:: __str__ -.. ############################################# -.. currentmodule:: cnotmuch.thread +:class:`cnotmuch.notmuch.Threads` -- Threads iterator +----------------------------------------------------- -:class:`Threads` -- Threads iterator ------------------------------------- - -.. autoclass:: Threads +.. autoclass:: cnotmuch.notmuch.Threads .. automethod:: __len__ @@ -223,18 +216,30 @@ More information on specific topics can be found on the following pages: .. automethod:: __str__ -.. ############################################# -.. currentmodule:: cnotmuch.notmuch :class:`Filenames` -- An iterator over filenames ------------------------------------------------ -To be implemented +.. autoclass:: cnotmuch.database.Filenames -:class:`Directoy` -- A directory entry in the database ------------------------------------------------------- + .. automethod:: cnotmuch.database.Filenames.__len__ -To be implemented +:class:`cnotmuch.database.Directoy` -- A directory entry in the database +------------------------------------------------------------------------ + +.. autoclass:: cnotmuch.database.Directory + + .. automethod:: cnotmuch.database.Directory.get_child_files + + .. automethod:: cnotmuch.database.Directory.get_child_directories + + .. automethod:: cnotmuch.database.Directory.get_mtime + + .. automethod:: cnotmuch.database.Directory.set_mtime + + .. autoattribute:: cnotmuch.database.Directory.mtime + + .. autoattribute:: cnotmuch.database.Directory.path :exc:`NotmuchError` -- A Notmuch execution error ------------------------------------------------ @@ -245,9 +250,23 @@ To be implemented :class:`STATUS` -- Notmuch operation return status -------------------------------------------------- -.. autoclass:: STATUS - To be documented +.. data:: STATUS + + STATUS is a class, whose attributes provide constants that serve as return indicators for notmuch functions. Currently the following ones are defined. For possible return values and specific meaning for each method, see the method description. + + * SUCCESS + * OUT_OF_MEMORY + * READ_ONLY_DATABASE + * XAPIAN_EXCEPTION + * FILE_ERROR + * FILE_NOT_EMAIL + * DUPLICATE_MESSAGE_ID + * NULL_POINTER + * TAG_TOO_LONG + * UNBALANCED_FREEZE_THAW + * NOT_INITIALIZED + Indices and tables ================== From 5bab5968aa7a7e5a054a785a43ff620e344868f2 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 25 Mar 2010 14:30:50 +0100 Subject: [PATCH 074/106] bump __VERSION__ to 0.2.1 --- README | 3 ++- cnotmuch/notmuch.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README b/README index c36648be..fc558314 100644 --- a/README +++ b/README @@ -78,4 +78,5 @@ Changelog --------- 0.1 First public release 0.1.1 Fixed Database.create_query() -0.2.0 Implemented Thread() and Threads() methods \ No newline at end of file +0.2.0 Implemented Thread() and Threads() methods +0.2.1 Implemented the remaining API methods, notably Directory() and Filenames() \ No newline at end of file diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py index 019da2af..13a5d4a4 100644 --- a/cnotmuch/notmuch.py +++ b/cnotmuch/notmuch.py @@ -38,5 +38,5 @@ from thread import Threads, Thread from tag import Tags from cnotmuch.globals import nmlib, STATUS, NotmuchError __LICENSE__="GPL v3+" -__VERSION__='0.2.0' +__VERSION__='0.2.1' __AUTHOR__ ='Sebastian Spaeth ' From ffe8c46f857ec0ad1cde087686f5fe53a789657d Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 25 Mar 2010 14:30:58 +0100 Subject: [PATCH 075/106] Added tag v0.2.1 for changeset 8f496a1f9b34 --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 784cf428..4e68fffe 100644 --- a/.hgtags +++ b/.hgtags @@ -1,3 +1,4 @@ 27f38f75d27d1dd5abbb1d5d9d5929372f84b2b2 v0.1 4fdbf2935cdf58865745d8bbc5e9b6be008ecb1c v0.1.1 36e2dd4e739a9da87837773467777d6d67e319f5 v0.2.0 +8f496a1f9b3431609ba0e4bc5524d6179c6d7155 v0.2.1 From bdc3a95bb7de2e333d31ef31f733e83bada078ff Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 25 Mar 2010 14:48:44 +0100 Subject: [PATCH 076/106] push __VERSION__ to post release 0.2.1+ --- cnotmuch/notmuch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py index 13a5d4a4..484bf7fd 100644 --- a/cnotmuch/notmuch.py +++ b/cnotmuch/notmuch.py @@ -38,5 +38,5 @@ from thread import Threads, Thread from tag import Tags from cnotmuch.globals import nmlib, STATUS, NotmuchError __LICENSE__="GPL v3+" -__VERSION__='0.2.1' +__VERSION__='0.2.1+' __AUTHOR__ ='Sebastian Spaeth ' From 46d06838ae171b744c443b1ec812cebe82996cfa Mon Sep 17 00:00:00 2001 From: Jesse Rosenthal Date: Thu, 25 Mar 2010 15:17:31 +0100 Subject: [PATCH 077/106] notmuch: Make modifications to implement notmuch search --- notmuch | 45 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/notmuch b/notmuch index b08334d7..6111a7ec 100755 --- a/notmuch +++ b/notmuch @@ -1,10 +1,14 @@ #!/usr/bin/env python -"""This is a notmuch implementation in python. It's goal is to allow running the test suite on the cnotmuch python bindings. +"""This is a notmuch implementation in python. +It's goal is to allow running the test suite on the cnotmuch python bindings. This "binary" honors the NOTMUCH_CONFIG environmen variable for reading a user's -notmuch configuration (e.g. the database path) +notmuch configuration (e.g. the database path). -This code is licensed under the GNU GPL v3+.""" + (c) 2010 by Sebastian Spaeth + Jesse Rosenthal + This code is licensed under the GNU GPL v3+. +""" from __future__ import with_statement # This isn't required in Python 2.6 import sys, os, re, logging from subprocess import call @@ -299,16 +303,45 @@ if __name__ == '__main__': #------------------------------------- elif sys.argv[1] == 'show': db = Database() + out_format="text" if len(sys.argv) == 2: #no further search term querystr='' + elif sys.argv[2].startswith("--format="): + out_format = sys.argv[2].split("=")[1].strip() + + if not out_format in ("json", "text"): + raise Exception("unknown format") + + if len(sys.argv) == 3: + querystr = '' + else: + querystr = quote_query_line(sys.argv[3:]) else: #mangle arguments wrapping terms with spaces in quotes querystr = quote_query_line(sys.argv[2:]) + logging.debug("show "+querystr) - m = Query(db,querystr).search_messages() - for msg in m: - print(msg.format_as_text()) + t = Query(db,querystr).search_threads() + + first_toplevel=True + if out_format.lower()=="json": + sys.stdout.write("[") + + for thrd in t: + msgs = thrd.get_toplevel_messages() + + if not first_toplevel: + if format.lower()=="json": + sys.stdout.write(", ") + + first_toplevel = False + + msgs.show_messages(out_format, 0, True) + + if out_format.lower() == "json": + sys.stdout.write("]") + sys.stdout.write("\n") #------------------------------------- elif sys.argv[1] == 'reply': From 48ec49f05daad3b0e53e8735bbd8d7a1171676c5 Mon Sep 17 00:00:00 2001 From: Jesse Rosenthal Date: Thu, 25 Mar 2010 15:29:01 +0100 Subject: [PATCH 078/106] Message(): Implement show message as text or json --- cnotmuch/message.py | 188 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 184 insertions(+), 4 deletions(-) diff --git a/cnotmuch/message.py b/cnotmuch/message.py index 4ed48028..6ae55647 100644 --- a/cnotmuch/message.py +++ b/cnotmuch/message.py @@ -2,6 +2,13 @@ from ctypes import c_char_p, c_void_p, c_long, c_bool from datetime import date from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum from cnotmuch.tag import Tags +import sys +import email +import types +try: + import simplejson as json +except ImportError: + import json #------------------------------------------------------------------------------ class Messages(object): """Represents a list of notmuch messages @@ -154,6 +161,53 @@ class Messages(object): if self._msgs is not None: nmlib.notmuch_messages_destroy (self._msgs) + def show_messages(self, format, indent=0, entire_thread=True): + if format.lower() == "text": + set_start = "" + set_end = "" + set_sep = "" + elif format.lower() == "json": + set_start = "[" + set_end = "]" + set_sep = ", " + else: + raise Exception + + first_set = True + + sys.stdout.write(set_start) + + for msg in self: + # if not msg: + # break + if not first_set: + sys.stdout.write(set_sep) + first_set = False + + sys.stdout.write(set_start) + match = msg.is_match() + next_indent = indent + + if (match or entire_thread): + if format.lower() == "text": + sys.stdout.write(msg.format_message_as_text(indent)) + elif format.lower() == "json": + sys.stdout.write(msg.format_message_as_json(indent)) + else: + raise NotmuchError + next_indent = indent + 1 + + + replies = msg.get_replies() + # if isinstance(replies, types.NoneType): + # break + if not replies is None: + sys.stdout.write(set_sep) + replies.show_messages(format, next_indent, entire_thread) + + + sys.stdout.write(set_end) + sys.stdout.write(set_end) #------------------------------------------------------------------------------ class Message(object): @@ -552,7 +606,11 @@ class Message(object): raise NotmuchError(status) - + + def is_match(self): + """(Not implemented)""" + return self.get_flag(self.FLAG.MATCH) + def __str__(self): """A message() is represented by a 1-line summary""" msg = {} @@ -563,9 +621,131 @@ class Message(object): msg['replies'] = len(replies) if replies is not None else -1 return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg) - def format_as_text(self): - """Output like notmuch show (Not implemented)""" - return str(self) + + def get_message_parts(self): + """Output like notmuch show""" + fp = open(self.get_filename()) + email_msg = email.message_from_file(fp) + fp.close() + + # A subfunction to recursively unpack the message parts into a + # list. + def msg_unpacker_gen(msg): + if not msg.is_multipart(): + yield msg + else: + for part in msg.get_payload(): + for subpart in msg_unpacker_gen(part): + yield subpart + + return list(msg_unpacker_gen(email_msg)) + + def format_message_internal(self): + """Create an internal representation of the message parts, + which can easily be output to json, text, or another output + format. The argument match tells whether this matched a + query.""" + output = {} + output["id"] = self.get_message_id() + output["match"] = self.is_match() + output["filename"] = self.get_filename() + output["tags"] = list(self.get_tags()) + + headers = {} + for h in ["subject", "from", "to", "cc", "bcc", "date"]: + headers[h] = self.get_header(h) + output["headers"] = headers + + body = [] + parts = self.get_message_parts() + for i in xrange(len(parts)): + msg = parts[i] + part_dict = {} + part_dict["id"] = i + 1 + # We'll be using this is a lot, so let's just get it once. + cont_type = msg.get_content_type() + part_dict["content_type"] = cont_type + # NOTE: + # Now we emulate the current behaviour, where it ignores + # the html if there's a text representation. + # + # This is being worked on, but it will be easier to fix + # here in the future than to end up with another + # incompatible solution. + disposition = msg["Content-Disposition"] + if disposition: + if disposition.lower().startswith("attachment"): + part_dict["filename"] = msg.get_filename() + else: + if cont_type.lower() == "text/plain": + part_dict["content"] = msg.get_payload() + elif (cont_type.lower() == "text/html" and + i == 0): + part_dict["content"] = msg.get_payload() + body.append(part_dict) + output["body"] = body + + return output + + def format_message_as_json(self, indent=0): + """Outputs the message as json. This is essentially the same + as python's dict format, but we run it through, just so we + don't have to worry about the details.""" + return json.dumps(self.format_message_internal()) + + def format_message_as_text(self, indent=0): + """Outputs it in the old-fashioned notmuch text form. Will be + easy to change to a new format when the format changes.""" + + format = self.format_message_internal() + output = "\n\fmessage{ id:%s depth:%d filename:%s" % (format["id"], + indent, + format["filename"]) + output += "\n\fheader{" + + #Todo: this date is supposed to be cleaned up, as in the index. + output += "\n%s (%s) (" % (format["headers"]["from"], + format["headers"]["date"]) + output += ", ".join(format["tags"]) + output += ")\n" + + + output += "\nSubject: %s" % format["headers"]["subject"] + output += "\nFrom: %s" % format["headers"]["from"] + output += "\nTo: %s" % format["headers"]["to"] + if format["headers"]["cc"]: + output += "\nCc: %s" % format["headers"]["cc"] + if format["headers"]["bcc"]: + output += "\nBcc: %s" % format["headers"]["bcc"] + output += "\nDate: %s" % format["headers"]["date"] + output += "\nheader}\f" + + output += "\n\fbody{" + + parts = format["body"] + parts.sort(key=lambda(p): p["id"]) + for p in parts: + if not p.has_key("filename"): + output += "\n\fpart{ " + output += "ID: %d, Content-type:%s\n" % (p["id"], + p["content_type"]) + if p.has_key("content"): + output += "\n%s\n" % p["content"] + else: + output += "Non-text part: %s\n" % p["content_type"] + output += "\n\fpart}" + else: + output += "\n\fattachment{ " + output += "ID: %d, Content-type:%s\n" % (p["id"], + p["content_type"]) + output += "Attachment: %s\n" % p["filename"] + output += "\n\fattachment}\n" + + output += "\n\fbody}\n" + output += "\n\fmessage}\n" + + return output + def __del__(self): """Close and free the notmuch Message""" From ff7756184d2c9aebb654c9f5d223dac555275b5f Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 25 Mar 2010 15:36:20 +0100 Subject: [PATCH 079/106] message.yp: add GPL v3 boilerplate and add Jesse as copyright holder --- cnotmuch/message.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cnotmuch/message.py b/cnotmuch/message.py index 6ae55647..29a914dd 100644 --- a/cnotmuch/message.py +++ b/cnotmuch/message.py @@ -1,3 +1,21 @@ +# This file is part of cnotmuch. +# +# cnotmuch is free software: 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. +# +# cnotmuch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with cnotmuch. If not, see . +# +# (C) Copyright 2010 Sebastian Spaeth +# Jesse Rosenthal + from ctypes import c_char_p, c_void_p, c_long, c_bool from datetime import date from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum From 5c4af8ce39ee5bc5868075f4f20e9abed5102156 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 25 Mar 2010 15:55:23 +0100 Subject: [PATCH 080/106] Message(): add 'match' result in text output. Fix is_match to use class, not instance, attributes. --- cnotmuch/message.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cnotmuch/message.py b/cnotmuch/message.py index 29a914dd..411d84da 100644 --- a/cnotmuch/message.py +++ b/cnotmuch/message.py @@ -627,7 +627,7 @@ class Message(object): def is_match(self): """(Not implemented)""" - return self.get_flag(self.FLAG.MATCH) + return self.get_flag(Message.FLAG.MATCH) def __str__(self): """A message() is represented by a 1-line summary""" @@ -716,9 +716,8 @@ class Message(object): easy to change to a new format when the format changes.""" format = self.format_message_internal() - output = "\n\fmessage{ id:%s depth:%d filename:%s" % (format["id"], - indent, - format["filename"]) + output = "\n\fmessage{ id:%s depth:%d match:%d filename:%s" \ + % (format['id'], indent, format['match'], format['filename']) output += "\n\fheader{" #Todo: this date is supposed to be cleaned up, as in the index. From 62a73f7eb016b5bbd115b6a6286bd47873ce47bb Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 25 Mar 2010 16:22:59 +0100 Subject: [PATCH 081/106] rename show_messages to print_messages and fix up the arg parsing for notmuch show a bit --- cnotmuch/message.py | 19 +++++++++++-------- notmuch | 37 ++++++++++++++++++++++--------------- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/cnotmuch/message.py b/cnotmuch/message.py index 411d84da..7274c6da 100644 --- a/cnotmuch/message.py +++ b/cnotmuch/message.py @@ -172,14 +172,19 @@ class Messages(object): self._msgs = None return i - - def __del__(self): """Close and free the notmuch Messages""" if self._msgs is not None: nmlib.notmuch_messages_destroy (self._msgs) - def show_messages(self, format, indent=0, entire_thread=True): + def print_messages(self, format, indent=0, entire_thread=False): + """Outputs messages as needed for 'notmuch show' to sys.stdout + + :param format: A string of either 'text' or 'json'. + :param indent: A number indicating the reply depth of these messages. + :param entire_thread: A bool, indicating whether we want to output + whole threads or only the matching messages. + """ if format.lower() == "text": set_start = "" set_end = "" @@ -195,6 +200,7 @@ class Messages(object): sys.stdout.write(set_start) + # iterate through all toplevel messages in this thread for msg in self: # if not msg: # break @@ -221,8 +227,7 @@ class Messages(object): # break if not replies is None: sys.stdout.write(set_sep) - replies.show_messages(format, next_indent, entire_thread) - + replies.print_messages(format, next_indent, entire_thread) sys.stdout.write(set_end) sys.stdout.write(set_end) @@ -720,13 +725,12 @@ class Message(object): % (format['id'], indent, format['match'], format['filename']) output += "\n\fheader{" - #Todo: this date is supposed to be cleaned up, as in the index. + #Todo: this date is supposed to be prettified, as in the index. output += "\n%s (%s) (" % (format["headers"]["from"], format["headers"]["date"]) output += ", ".join(format["tags"]) output += ")\n" - output += "\nSubject: %s" % format["headers"]["subject"] output += "\nFrom: %s" % format["headers"]["from"] output += "\nTo: %s" % format["headers"]["to"] @@ -763,7 +767,6 @@ class Message(object): return output - def __del__(self): """Close and free the notmuch Message""" if self._msg is not None: diff --git a/notmuch b/notmuch index 6111a7ec..85736a9a 100755 --- a/notmuch +++ b/notmuch @@ -302,24 +302,31 @@ if __name__ == '__main__': print(str(thread)) #------------------------------------- elif sys.argv[1] == 'show': + entire_thread = False db = Database() out_format="text" - if len(sys.argv) == 2: - #no further search term - querystr='' - elif sys.argv[2].startswith("--format="): - out_format = sys.argv[2].split("=")[1].strip() + querystr='' + first_search_term = None - if not out_format in ("json", "text"): - raise Exception("unknown format") + #ugly homegrown option parsing + #TODO: use OptionParser + for (i, arg) in enumerate(sys.argv[1:]): + if arg == '--entire-thread': + entire_thread = True + elif arg.startswith("--format="): + out_format = arg.split("=")[1] + if out_format == 'json': + #for compatibility use --entire-thread for json + entire_thread = True + if not out_format in ("json", "text"): + raise Exception("unknown format") + elif not arg.startswith('--'): + #save the position of the first sys.argv that is a search term + first_search_term = i+1 - if len(sys.argv) == 3: - querystr = '' - else: - querystr = quote_query_line(sys.argv[3:]) - else: - #mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[2:]) + if first_search_term: + #mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[first_search_term:]) logging.debug("show "+querystr) t = Query(db,querystr).search_threads() @@ -337,7 +344,7 @@ if __name__ == '__main__': first_toplevel = False - msgs.show_messages(out_format, 0, True) + msgs.print_messages(out_format, 0, True) if out_format.lower() == "json": sys.stdout.write("]") From 8b84f00cce6e64336e0ac62573ab181348112464 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Thu, 25 Mar 2010 16:32:26 +0100 Subject: [PATCH 082/106] fix wrongly names variable format->out_format --- notmuch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notmuch b/notmuch index 85736a9a..c01dab34 100755 --- a/notmuch +++ b/notmuch @@ -339,7 +339,7 @@ if __name__ == '__main__': msgs = thrd.get_toplevel_messages() if not first_toplevel: - if format.lower()=="json": + if out_format.lower()=="json": sys.stdout.write(", ") first_toplevel = False From 61a547bd3e2760f1f835762f92c83cff2c1c71e5 Mon Sep 17 00:00:00 2001 From: Jesse Rosenthal Date: Sun, 28 Mar 2010 00:02:44 +0100 Subject: [PATCH 083/106] implement sort order for notmuch show --- cnotmuch/message.py | 10 +++++----- notmuch | 36 +++++++++++++++++++++++++++--------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/cnotmuch/message.py b/cnotmuch/message.py index 7274c6da..6a68c92d 100644 --- a/cnotmuch/message.py +++ b/cnotmuch/message.py @@ -721,7 +721,7 @@ class Message(object): easy to change to a new format when the format changes.""" format = self.format_message_internal() - output = "\n\fmessage{ id:%s depth:%d match:%d filename:%s" \ + output = "\fmessage{ id:%s depth:%d match:%d filename:%s" \ % (format['id'], indent, format['match'], format['filename']) output += "\n\fheader{" @@ -729,7 +729,7 @@ class Message(object): output += "\n%s (%s) (" % (format["headers"]["from"], format["headers"]["date"]) output += ", ".join(format["tags"]) - output += ")\n" + output += ")" output += "\nSubject: %s" % format["headers"]["subject"] output += "\nFrom: %s" % format["headers"]["from"] @@ -739,7 +739,7 @@ class Message(object): if format["headers"]["bcc"]: output += "\nBcc: %s" % format["headers"]["bcc"] output += "\nDate: %s" % format["headers"]["date"] - output += "\nheader}\f" + output += "\n\fheader}" output += "\n\fbody{" @@ -748,7 +748,7 @@ class Message(object): for p in parts: if not p.has_key("filename"): output += "\n\fpart{ " - output += "ID: %d, Content-type:%s\n" % (p["id"], + output += "ID: %d, Content-type: %s\n" % (p["id"], p["content_type"]) if p.has_key("content"): output += "\n%s\n" % p["content"] @@ -763,7 +763,7 @@ class Message(object): output += "\n\fattachment}\n" output += "\n\fbody}\n" - output += "\n\fmessage}\n" + output += "\n\fmessage}" return output diff --git a/notmuch b/notmuch index c01dab34..2bb496f3 100755 --- a/notmuch +++ b/notmuch @@ -290,16 +290,34 @@ if __name__ == '__main__': #------------------------------------- elif sys.argv[1] == 'search': db = Database() - if len(sys.argv) == 2: - #no further search term - querystr='' - else: - #mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[2:]) + query_string = '' + sort_order="newest-first" + first_search_term = None + for (i, arg) in enumerate(sys.argv[1:]): + if arg.startswith('--sort='): + sort_order=arg.split("=")[1] + if not sort_order in ("oldest-first", "newest-first"): + raise Exception("unknown sort order") + elif not arg.startswith('--'): + #save the position of the first sys.argv that is a search term + first_search_term = i+1 + + if first_search_term: + #mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[first_search_term:]) + + logging.debug("search "+querystr) - t = Query(db,querystr).search_threads() + qry = Query(db,querystr) + if sort_order == "oldest-first": + qry.set_sort(Query.SORT.OLDEST_FIRST) + else: + qry.set_sort(Query.SORT.NEWEST_FIRST) + t = qry.search_threads() + for thread in t: - print(str(thread)) + print(str(thread)) + #------------------------------------- elif sys.argv[1] == 'show': entire_thread = False @@ -344,7 +362,7 @@ if __name__ == '__main__': first_toplevel = False - msgs.print_messages(out_format, 0, True) + msgs.print_messages(out_format, 0, entire_thread) if out_format.lower() == "json": sys.stdout.write("]") From c0db88a95c3009e29cf6940f4c97e82d745e1840 Mon Sep 17 00:00:00 2001 From: Jesse Rosenthal Date: Tue, 30 Mar 2010 08:59:40 +0200 Subject: [PATCH 084/106] [REV2] adding part, simplifying Message.get_parts(), and fixing json to work with dme's json ui Sebastian: This replaces the patch it responds to. With this patch, we can now use the cnotmuch with David's json ui. There are still issues, but this allows interaction with emacs. --- --- cnotmuch/message.py | 43 ++++++++++++++++++++++++++++--------------- notmuch | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/cnotmuch/message.py b/cnotmuch/message.py index 6a68c92d..0dc8812f 100644 --- a/cnotmuch/message.py +++ b/cnotmuch/message.py @@ -220,8 +220,8 @@ class Messages(object): else: raise NotmuchError next_indent = indent + 1 - - + + #sys.stdout.write(set_end) replies = msg.get_replies() # if isinstance(replies, types.NoneType): # break @@ -653,15 +653,28 @@ class Message(object): # A subfunction to recursively unpack the message parts into a # list. - def msg_unpacker_gen(msg): + # def msg_unpacker_gen(msg): + # if not msg.is_multipart(): + # yield msg + # else: + # for part in msg.get_payload(): + # for subpart in msg_unpacker_gen(part): + # yield subpart + # + # return list(msg_unpacker_gen(email_msg)) + out = [] + for msg in email_msg.walk(): if not msg.is_multipart(): - yield msg - else: - for part in msg.get_payload(): - for subpart in msg_unpacker_gen(part): - yield subpart + out.append(msg) + return out - return list(msg_unpacker_gen(email_msg)) + def get_part(self, num): + parts = self.get_message_parts() + if (num <= 0 or num > len(parts)): + return "" + else: + out_part = parts[(num - 1)] + return out_part.get_payload(decode=True) def format_message_internal(self): """Create an internal representation of the message parts, @@ -675,7 +688,7 @@ class Message(object): output["tags"] = list(self.get_tags()) headers = {} - for h in ["subject", "from", "to", "cc", "bcc", "date"]: + for h in ["Subject", "From", "To", "Cc", "Bcc", "Date"]: headers[h] = self.get_header(h) output["headers"] = headers @@ -687,7 +700,7 @@ class Message(object): part_dict["id"] = i + 1 # We'll be using this is a lot, so let's just get it once. cont_type = msg.get_content_type() - part_dict["content_type"] = cont_type + part_dict["content-type"] = cont_type # NOTE: # Now we emulate the current behaviour, where it ignores # the html if there's a text representation. @@ -696,16 +709,16 @@ class Message(object): # here in the future than to end up with another # incompatible solution. disposition = msg["Content-Disposition"] - if disposition: - if disposition.lower().startswith("attachment"): - part_dict["filename"] = msg.get_filename() + if disposition and disposition.lower().startswith("attachment"): + part_dict["filename"] = msg.get_filename() else: if cont_type.lower() == "text/plain": part_dict["content"] = msg.get_payload() elif (cont_type.lower() == "text/html" and i == 0): part_dict["content"] = msg.get_payload() - body.append(part_dict) + body.append(part_dict) + output["body"] = body return output diff --git a/notmuch b/notmuch index 2bb496f3..9f63e210 100755 --- a/notmuch +++ b/notmuch @@ -288,6 +288,43 @@ if __name__ == '__main__': if len(sys.argv) == 2: print HELPTEXT else: print "Not implemented" #------------------------------------- + elif sys.argv[1] == 'part': + db = Database() + query_string = '' + part_num=0 + first_search_term = None + for (i, arg) in enumerate(sys.argv[1:]): + if arg.startswith('--part='): + part_num_str=arg.split("=")[1] + try: + part_num = int(part_num_str) + except ValueError: + # just emulating behavior + sys.exit() + elif not arg.startswith('--'): + #save the position of the first sys.argv that is a search term + first_search_term = i+1 + + if first_search_term: + #mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[first_search_term:]) + + + logging.debug("part "+querystr) + qry = Query(db,querystr) + msgs = qry.search_messages() + msg_list = [] + for m in msgs: + msg_list.append(m) + + if len(msg_list) == 0: + sys.exit() + elif len(msg_list) > 1: + raise Exception("search term did not match precisely one message") + else: + msg = msg_list[0] + print(msg.get_part(part_num)) + #------------------------------------- elif sys.argv[1] == 'search': db = Database() query_string = '' From ad597b153c305fb50187e93416d0666b88cd7ace Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 30 Mar 2010 09:04:48 +0200 Subject: [PATCH 085/106] Revert some unneeded white space changes --- cnotmuch/message.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cnotmuch/message.py b/cnotmuch/message.py index 0dc8812f..f98f7879 100644 --- a/cnotmuch/message.py +++ b/cnotmuch/message.py @@ -220,8 +220,8 @@ class Messages(object): else: raise NotmuchError next_indent = indent + 1 - - #sys.stdout.write(set_end) + + replies = msg.get_replies() # if isinstance(replies, types.NoneType): # break From bac66abdd2a02c0af677c3e503ea3bae705287c6 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 30 Mar 2010 09:34:40 +0200 Subject: [PATCH 086/106] message.py: small doc changes and unused code removal --- cnotmuch/message.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/cnotmuch/message.py b/cnotmuch/message.py index f98f7879..ba93d8fe 100644 --- a/cnotmuch/message.py +++ b/cnotmuch/message.py @@ -221,10 +221,8 @@ class Messages(object): raise NotmuchError next_indent = indent + 1 - + # get replies and print them also out (if there are any) replies = msg.get_replies() - # if isinstance(replies, types.NoneType): - # break if not replies is None: sys.stdout.write(set_sep) replies.print_messages(format, next_indent, entire_thread) @@ -651,17 +649,6 @@ class Message(object): email_msg = email.message_from_file(fp) fp.close() - # A subfunction to recursively unpack the message parts into a - # list. - # def msg_unpacker_gen(msg): - # if not msg.is_multipart(): - # yield msg - # else: - # for part in msg.get_payload(): - # for subpart in msg_unpacker_gen(part): - # yield subpart - # - # return list(msg_unpacker_gen(email_msg)) out = [] for msg in email_msg.walk(): if not msg.is_multipart(): @@ -669,6 +656,7 @@ class Message(object): return out def get_part(self, num): + """Returns the nth message body part""" parts = self.get_message_parts() if (num <= 0 or num > len(parts)): return "" From 3d6590be0207d7f0babfda3d26cf9b7c76451fab Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 30 Mar 2010 09:54:41 +0200 Subject: [PATCH 087/106] Need to do capitalization changes in dict keys from rev 45d2012fcf51 for all usages --- cnotmuch/message.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/cnotmuch/message.py b/cnotmuch/message.py index ba93d8fe..7980f921 100644 --- a/cnotmuch/message.py +++ b/cnotmuch/message.py @@ -727,19 +727,19 @@ class Message(object): output += "\n\fheader{" #Todo: this date is supposed to be prettified, as in the index. - output += "\n%s (%s) (" % (format["headers"]["from"], - format["headers"]["date"]) + output += "\n%s (%s) (" % (format["headers"]["From"], + format["headers"]["Date"]) output += ", ".join(format["tags"]) output += ")" - output += "\nSubject: %s" % format["headers"]["subject"] - output += "\nFrom: %s" % format["headers"]["from"] - output += "\nTo: %s" % format["headers"]["to"] - if format["headers"]["cc"]: - output += "\nCc: %s" % format["headers"]["cc"] - if format["headers"]["bcc"]: - output += "\nBcc: %s" % format["headers"]["bcc"] - output += "\nDate: %s" % format["headers"]["date"] + output += "\nSubject: %s" % format["headers"]["Subject"] + output += "\nFrom: %s" % format["headers"]["From"] + output += "\nTo: %s" % format["headers"]["To"] + if format["headers"]["Cc"]: + output += "\nCc: %s" % format["headers"]["Cc"] + if format["headers"]["Bcc"]: + output += "\nBcc: %s" % format["headers"]["Bcc"] + output += "\nDate: %s" % format["headers"]["Date"] output += "\n\fheader}" output += "\n\fbody{" @@ -750,16 +750,16 @@ class Message(object): if not p.has_key("filename"): output += "\n\fpart{ " output += "ID: %d, Content-type: %s\n" % (p["id"], - p["content_type"]) + p["content-type"]) if p.has_key("content"): output += "\n%s\n" % p["content"] else: - output += "Non-text part: %s\n" % p["content_type"] + output += "Non-text part: %s\n" % p["content-type"] output += "\n\fpart}" else: output += "\n\fattachment{ " output += "ID: %d, Content-type:%s\n" % (p["id"], - p["content_type"]) + p["content-type"]) output += "Attachment: %s\n" % p["filename"] output += "\n\fattachment}\n" From 3c8ba0f18f826c13b67eda08dd7620a65ee92065 Mon Sep 17 00:00:00 2001 From: Jesse Rosenthal Date: Tue, 30 Mar 2010 11:00:27 +0200 Subject: [PATCH 088/106] message.py: fix indentation --- cnotmuch/message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cnotmuch/message.py b/cnotmuch/message.py index 7980f921..73660c5e 100644 --- a/cnotmuch/message.py +++ b/cnotmuch/message.py @@ -705,7 +705,7 @@ class Message(object): elif (cont_type.lower() == "text/html" and i == 0): part_dict["content"] = msg.get_payload() - body.append(part_dict) + body.append(part_dict) output["body"] = body From 21538d18afc374649b5555143aef3f6afc25e5ba Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 30 Mar 2010 11:42:52 +0200 Subject: [PATCH 089/106] message.py: Don't use ctypes.c_bool as that does not exist in python 2.5 --- cnotmuch/message.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cnotmuch/message.py b/cnotmuch/message.py index 73660c5e..0e5057f4 100644 --- a/cnotmuch/message.py +++ b/cnotmuch/message.py @@ -16,7 +16,7 @@ # (C) Copyright 2010 Sebastian Spaeth # Jesse Rosenthal -from ctypes import c_char_p, c_void_p, c_long, c_bool +from ctypes import c_char_p, c_void_p, c_long, c_uint from datetime import date from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum from cnotmuch.tag import Tags @@ -243,7 +243,7 @@ class Message(object): """notmuch_message_get_flag""" _get_flag = nmlib.notmuch_message_get_flag - _get_flag.restype = c_bool + _get_flag.restype = c_uint """notmuch_message_get_message_id (notmuch_message_t *message)""" _get_message_id = nmlib.notmuch_message_get_message_id @@ -406,7 +406,7 @@ class Message(object): :param flag: One of the :attr:`Message.FLAG` values (currently only *Message.FLAG.MATCH* - :returns: A bool, indicating whether the flag is set. + :returns: An unsigned int (0/1), indicating whether the flag is set. :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message is not initialized. """ From 43f7ce29d670d7ebe5791c203483fb6a668bfbe6 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 30 Mar 2010 12:07:53 +0200 Subject: [PATCH 090/106] Debian pkg creation stuff --- debian/changelog | 5 +++++ debian/compat | 1 + debian/control | 18 ++++++++++++++++++ debian/copyright | 42 ++++++++++++++++++++++++++++++++++++++++++ debian/docs | 1 + debian/rules | 4 ++++ 6 files changed, 71 insertions(+) create mode 100644 debian/changelog create mode 100644 debian/compat create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/docs create mode 100755 debian/rules diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 00000000..b9446918 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +cnotmuch (0.2.1-1) unstable; urgency=low + + * Initial release + + -- Sebastian Spaeth Tue, 30 Mar 2010 11:31:39 +0200 diff --git a/debian/compat b/debian/compat new file mode 100644 index 00000000..7f8f011e --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +7 diff --git a/debian/control b/debian/control new file mode 100644 index 00000000..5135f1bb --- /dev/null +++ b/debian/control @@ -0,0 +1,18 @@ +Source: cnotmuch +Section: python +Priority: extra +Maintainer: Sebastian Spaeth +Build-Depends: debhelper (>= 7.3.0), python-support (>= 0.5.3) +Build-Depends-Indep: python (>= 2.5), python-support +Standards-Version: 3.8.1 +Homepage: http://pypi.python.org/pypi/cnotmuch + +Package: cnotmuch +Architecture: all +XB-Python-Version: ${python:Versions} +Depends: ${misc:Depends},${python:Depends}, notmuch (> 0.0+201001211401) +Description: Interface to the notmuch mail search and index library + The cnotmuch module provides an interface to the notmuch functionality, + directly interfacing with a shared notmuch library. + Notmuch provides a maildatabase that allows for extremely quick searching and + filtering of your email according to various criteria. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 00000000..d762cb93 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,42 @@ +This package was debianized by: + + Sebastian Spaeth on Tue, 30 Mar 2010 10:02:22 +0200 + +It was downloaded from: + + http://pypi.python.org/packages/source/c/cnotmuch/cnotmuch-0.2.1.tar.gz + +Upstream Author(s): + + Sebastian Spaeth + +Copyright: + + Copyright (C) 2010 Sebastian Spaeth + +License: + + This package is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This package is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this package; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +On Debian systems, the complete text of the GNU General +Public License version 2 can be found in `/usr/share/common-licenses/GPL-2'. + +The Debian packaging is: + + Copyright (C) 2010 Sebastian Spaeth + +and is licensed under the GPL version 3, +see `/usr/share/common-licenses/GPL-3'. + diff --git a/debian/docs b/debian/docs new file mode 100644 index 00000000..e845566c --- /dev/null +++ b/debian/docs @@ -0,0 +1 @@ +README diff --git a/debian/rules b/debian/rules new file mode 100755 index 00000000..2d33f6ac --- /dev/null +++ b/debian/rules @@ -0,0 +1,4 @@ +#!/usr/bin/make -f + +%: + dh $@ From f70b84cb39075a8e2404ccfbf7f749b481a30f4b Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 30 Mar 2010 13:50:11 +0200 Subject: [PATCH 091/106] Ubuntu debian file creation documentation --- README | 7 ++++++- debian/changelog | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README b/README index fc558314..12ed8f3f 100644 --- a/README +++ b/README @@ -74,9 +74,14 @@ NotmuchError: Could not open the specified database >>>del(tags) +Building for a Debian package +------------------------------ +dpkg-buildpackage -i"\.hg|\/build" + + Changelog --------- 0.1 First public release 0.1.1 Fixed Database.create_query() 0.2.0 Implemented Thread() and Threads() methods -0.2.1 Implemented the remaining API methods, notably Directory() and Filenames() \ No newline at end of file +0.2.1 Implemented the remaining API methods, notably Directory() and Filenames() diff --git a/debian/changelog b/debian/changelog index b9446918..39ac7bb0 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -cnotmuch (0.2.1-1) unstable; urgency=low +cnotmuch (0.2.1-1) karmic; urgency=low * Initial release From 775d3bc31384726e707057d30ba84b0906a286c1 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Tue, 30 Mar 2010 16:10:07 +0200 Subject: [PATCH 092/106] database.py: also need to import Message --- cnotmuch/database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index 44fd3154..bb8a9632 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -2,7 +2,7 @@ import os from ctypes import c_int, c_char_p, c_void_p, c_uint, c_long, byref from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum from cnotmuch.thread import Threads -from cnotmuch.message import Messages +from cnotmuch.message import Messages, Message from cnotmuch.tag import Tags class Database(object): From 93298e11dce8b299868f427181cde6c01f8c91aa Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 31 Mar 2010 08:39:14 +0200 Subject: [PATCH 093/106] notmuch: refactor stuff into a Notmuch class This commit contains many minor and bigger changes, such as the removal of the logging import too. --- notmuch | 529 +++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 336 insertions(+), 193 deletions(-) diff --git a/notmuch b/notmuch index 9f63e210..46f65791 100755 --- a/notmuch +++ b/notmuch @@ -10,35 +10,337 @@ notmuch configuration (e.g. the database path). This code is licensed under the GNU GPL v3+. """ from __future__ import with_statement # This isn't required in Python 2.6 -import sys, os, re, logging -from subprocess import call -from cnotmuch.notmuch import Database, Query +import sys, os, re, stat +from cnotmuch.notmuch import Database, Query, NotmuchError, STATUS PREFIX=re.compile('(\w+):(.*$)') -#TODO Handle variable: NOTMUCH-CONFIG +#------------------------------------------------------------------------- +def quote_query_line(argv): + #mangle arguments wrapping terms with spaces in quotes + for i in xrange(0,len(argv)): + if argv[i].find(' ') >= 0: + #if we use prefix:termWithSpaces, put quotes around term + m = PREFIX.match(argv[i]) + if m: + argv[i] = '%s:"%s"' % (m.group(1), m.group(2)) + else: + argv[i] = '"'+argv[i]+'"' + return ' '.join(argv) #------------------------------------------------------------------------- -def get_user_email_addresses(): - """ Reads a user's notmuch config and returns his email addresses as list (name, primary_address, other_address1,...)""" +class Notmuch: + + def __init__(self): + self._config = None + + def cmd_usage(self): + """Print the usage text and exits""" + data={} + names = self.get_user_email_addresses() + data['fullname'] =names[0] if names[0] else 'My Name' + data['mailaddress']=names[1] if names[1] else 'My@email.address' + print (Notmuch.USAGE % data) + + def cmd_new(self): + """Run 'notmuch new'""" + #get the database directory + db = Database(mode=Database.MODE.READ_WRITE) + path = db.get_path() + + (added, moved, removed) = self._add_new_files_recursively(path, db) + print (added, moved, removed) + + def cmd_help(self, subcmd=None): + """Print help text for 'notmuch help'""" + if len(subcmd) > 1: + print "Help for specific commands not implemented" + return + + print (Notmuch.HELPTEXT) + + def _get_user_notmuch_config(self): + """Returns the ConfigParser of the user's notmuch-config""" + # return the cached config parser if we read it already + if self._config is not None: + return self._config + + from ConfigParser import SafeConfigParser + config = SafeConfigParser() + conf_f = os.getenv('NOTMUCH_CONFIG', + os.path.expanduser('~/.notmuch-config')) + config.read(conf_f) + self._config = config + return config + + def _add_new_files_recursively(self, path, db): + """:returns: (added, moved, removed)""" + print "Enter add new files with path %s" % path + (added, moved, removed) = (0,)*3 + + try: + #get the Directory() object for this path + db_dir = db.get_directory(path) + except NotmuchError: + #Occurs if we have wrong absolute paths in the db, for example + return (0,0,0) + + + #for folder in subdirs: + # (new_added, new_moved, new_removed) = \ + # self._add_new_files_recursively( + # os.path.join(db_dir.path, folder), db) + # added += new_added + # moved += new_moved + # removed += new_removed + + #TODO, retrieve dir mtime here and store it later + #as long as Filenames() does not allow multiple iteration, we need to + #use this kludgy way to get a sorted list of filenames + #db_files is a list of subdirectories and filenames in this folder + db_files = set() + db_folders = set() + for subdir in db_dir.get_child_directories(): + db_folders.add(os.path.normpath(subdir)) + for file in db_dir.get_child_files(): + db_files.add(file) + + fs_files = set(os.listdir(db_dir.path)) + + #list of folders in both db and fs. Just descend into dirs + for fs_file in (fs_files | db_folders): + absfile = os.path.normpath(os.path.join(db_dir.path, fs_file)) + if os.path.isdir(absfile): + #This is a directory + if fs_file in ['.notmuch','tmp','.']: + continue + self._add_new_files_recursively(absfile, db) + # we are not interested in anything but directories here + + #list of files and folders in the fs, but not the db + for fs_file in (fs_files - db_files): + absfile = os.path.normpath(os.path.join(db_dir.path, fs_file)) + statinfo = os.stat(absfile) + + if stat.S_ISDIR(statinfo.st_mode): + #This is a directory + if fs_file in ['.notmuch','.']: + continue + print "descending into %s" % absfile + #self._add_new_files_recursively(absfile, db) + elif stat.S_ISLNK(statinfo.st_mode): + print ("%s is a symbolic link (%d)" % (absfile, statinfo.st_mode)) + else: + print "This file needs to be added %s" % (absfile) + #TODO + #(msg, status) = db.add_message(os.path.join(db_dir.path, db_file)) + #if status == STATUS.DUPLICATE_MESSAGE_ID: + # #This message was already in the database, continue with next one + # continue + + #list of files and folders in the database, but not the filesystem + for db_file in (db_files - fs_files): + absfile = os.path.normpath(os.path.join(db_dir.path, db_file)) + statinfo = os.stat(absfile) + + if stat.S_ISDIR(statinfo.st_mode): + #This is a directory + if db_file in ['.notmuch', '.']: + continue + print "descending into %s" % absfile + self._add_new_files_recursively(absfile, db) + #TODO, is there no way to REMOVE a directory entry from the db? + else: + #remove a mail message from the db + print ("%s is not on the fs anymore. Delete" % absfile) + status = db.remove_message(absfile) + if status == STATUS.SUCCESS: + # we just deleted the last reference, so this was a remove + removed += 1 + elif status == STATUS.DUPLICATE_MESSAGE_ID: + # The filename exists already somewhere else, so this is a move + moved += 1 + else: + print "This must not happen. %s " % (absfile) + sys.exit(1) + + return (added, moved, removed) + #Read the mtime of a directory from the filesystem + # + #* Call :meth:`Database.add_message` for all mail files in + # the directory + + #* Call notmuch_directory_set_mtime with the mtime read from the + # filesystem. Then, when wanting to check for updates to the + # directory in the future, the client can call :meth:`get_mtime` + # and know that it only needs to add files if the mtime of the + # directory and files are newer than the stored timestamp. + + def get_user_email_addresses(self): + """ Reads a user's notmuch config and returns his email addresses as + list (name, primary_address, other_address1,...)""" import email.utils - from ConfigParser import SafeConfigParser - config = SafeConfigParser() - conf_f = os.getenv('NOTMUCH_CONFIG', - os.path.expanduser('~/.notmuch-config')) - config.read(conf_f) - if not config.has_option('user','name'): name = "" - else:name = config.get('user','name') - if not config.has_option('user','primary_email'): mail = "" - else:mail = config.get('user','primary_email') + #read the config file + config = self._get_user_notmuch_config() - if not config.has_option('user','other_email'): other = [] - else:other = config.get('user','other_email').rstrip(';').split(';') + if not config.has_option('user','name'): name = "" + else:name = config.get('user','name') - other.insert(0, mail) - other.insert(0, name) - return other -#------------------------------------------------------------------------- -HELPTEXT="""The notmuch mail system. + if not config.has_option('user','primary_email'): mail = "" + else:mail = config.get('user','primary_email') + + if not config.has_option('user','other_email'): other = [] + else:other = config.get('user','other_email').rstrip(';').split(';') + + other.insert(0, mail) + other.insert(0, name) + return other + + def quote_msg_body(self, oldbody ,date, from_address): + """Transform a mail body into a quoted text, + starting with On blah, x wrote: + + :param body: a str with a mail body + :returns: The new payload of the email.message() + """ + from cStringIO import StringIO + + #we get handed a string, wrap it in a file-like object + oldbody = StringIO(oldbody) + newbody = StringIO() + + newbody.write("On %s, %s wrote:\n" % (date, from_address)) + + for line in oldbody: + newbody.write("> " + line) + + return newbody.getvalue() + + def format_reply(self, msgs): + """Gets handed Messages() and displays the reply to them + + This is pretty ugly and hacky. It tries to mimic the "real" + notmuch output as much as it can to pass the test suite. It + could deserve a healthy bit of love. It is also buggy because + it returns after the first message it has handled.""" + import email + + for msg in msgs: + f = open(msg.get_filename(),"r") + reply = email.message_from_file(f) + + #handle the easy non-multipart case: + if not reply.is_multipart(): + reply.set_payload(self.quote_msg_body(reply.get_payload(), + reply['date'],reply['from'])) + else: + #handle the tricky multipart case + deleted = "" + """A string describing which nontext attachements that + have been deleted""" + delpayloads = [] + """A list of payload indices to be deleted""" + + payloads = reply.get_payload() + + for i, part in enumerate(payloads): + + mime_main = part.get_content_maintype() + if mime_main not in ['multipart', 'message', 'text']: + deleted += "Non-text part: %s\n" % (part.get_content_type()) + payloads[i].set_payload("Non-text part: %s" % (part.get_content_type())) + payloads[i].set_type('text/plain') + delpayloads.append(i) + elif mime_main == 'text': + payloads[i].set_payload(self.quote_msg_body(payloads[i].get_payload(),reply['date'],reply['from'])) + else: + #TODO handle deeply nested multipart messages + sys.stderr.write ("FIXME: Ignoring multipart part. Handle me\n") + + # Delete those payloads that we don't need anymore + for i in reversed(sorted(delpayloads)): + del payloads[i] + + #Back to single- and multipart handling + my_addresses = self.get_user_email_addresses() + used_address = None + # filter our email addresses from all to: cc: and bcc: fields + # if we find one of "my" addresses being used, + # it is stored in used_address + for header in ['To', 'CC', 'Bcc']: + if not header in reply: + #only handle fields that exist + continue + addresses = email.utils.getaddresses(reply.get_all(header,[])) + purged_addr = [] + for name, mail in addresses: + if mail in my_addresses[1:]: + used_address = email.utils.formataddr((my_addresses[0],mail)) + else: + purged_addr.append(email.utils.formataddr((name,mail))) + + if len(purged_addr): + reply.replace_header(header, ", ".join(purged_addr)) + else: + #we deleted all addresses, delete the header + del reply[header] + + # Use our primary email address to the From + # (save original from line, we still need it) + orig_from = reply['From'] + del reply['From'] + reply['From'] = used_address if used_address \ + else email.utils.formataddr((my_addresses[0],my_addresses[1])) + + #reinsert the Subject after the From + orig_subject = reply['Subject'] + del reply['Subject'] + reply['Subject'] = 'Re: ' + orig_subject + + # Calculate our new To: field + new_to = orig_from + # add all remaining original 'To' addresses + if 'To' in reply: + new_to += ", " + reply['To'] + del reply['To'] + reply.add_header('To', new_to) + + # Add our primary email address to the BCC + new_bcc = my_addresses[1] + if reply.has_key('Bcc'): + new_bcc += ', ' + reply['Bcc'] + del reply['Bcc'] + reply['Bcc'] = new_bcc + + # Set replies 'In-Reply-To' header to original's Message-ID + if reply.has_key('Message-ID') : + del reply['In-Reply-To'] + reply['In-Reply-To'] = reply['Message-ID'] + + #Add original's Message-ID to replies 'References' header. + if reply.has_key('References'): + ref = reply['References'] + ' ' +reply['Message-ID'] + else: + ref = reply['Message-ID'] + del reply['References'] + reply['References'] = ref + + # Delete the original Message-ID. + del(reply['Message-ID']) + + # filter all existing headers but a few and delete them from 'reply' + delheaders = filter(lambda x: x not in ['From','To','Subject','CC', + 'Bcc','In-Reply-To', + 'References','Content-Type'], + reply.keys()) + map(reply.__delitem__, delheaders) + + # TODO: OUCH, we return after the first msg we have handled rather than + # handle all of them + #return resulting message without Unixfrom + return reply.as_string(False) + + + HELPTEXT="""The notmuch mail system. Usage: notmuch [args...] @@ -89,18 +391,17 @@ Where and [args...] are as follows: Use "notmuch help " for more details on each command. And "notmuch help search-terms" for the common search-terms syntax. """ -#------------------------------------------------------------------------- -#TODO: replace the dynamic pieces -USAGE="""Notmuch is configured and appears to have a database. Excellent! + + USAGE="""Notmuch is configured and appears to have a database. Excellent! At this point you can start exploring the functionality of notmuch by using commands such as: notmuch search tag:inbox - notmuch search to:"Sebastian Spaeth" + notmuch search to:"%(fullname)s" - notmuch search from:"Sebastian@SSpaeth.de" + notmuch search from:"%(mailaddress)s" notmuch search subject:"my favorite things" @@ -115,178 +416,28 @@ And don't forget to run "notmuch new" whenever new mail arrives. Have fun, and may your inbox never have much mail. """ + +# MAIN #------------------------------------------------------------------------- -def quote_reply(oldbody ,date, from_address): - """Transform a mail body into a quote text starting with On blah, x wrote: - :param body: a str with a mail body - :returns: The new payload of the email.message() - """ - from cStringIO import StringIO - - #we get handed a string, wrap it in a file-like object - oldbody = StringIO(oldbody) - newbody = StringIO() - - newbody.write("On %s, %s wrote:\n" % (date, from_address)) - - for line in oldbody: - newbody.write("> " + line) - - return newbody.getvalue() - -def format_reply(msgs): - """Gets handed Messages() and displays the reply to them""" - import email - - for msg in msgs: - f = open(msg.get_filename(),"r") - reply = email.message_from_file(f) - - #handle the easy non-multipart case: - if not reply.is_multipart(): - reply.set_payload(quote_reply(reply.get_payload(), - reply['date'],reply['from'])) - else: - #handle the tricky multipart case - deleted = "" - """A string describing which nontext attachements have been deleted""" - delpayloads = [] - """A list of payload indices to be deleted""" - - payloads = reply.get_payload() - - for i, part in enumerate(payloads): - - mime_main = part.get_content_maintype() - if mime_main not in ['multipart', 'message', 'text']: - deleted += "Non-text part: %s\n" % (part.get_content_type()) - payloads[i].set_payload("Non-text part: %s" % (part.get_content_type())) - payloads[i].set_type('text/plain') - delpayloads.append(i) - else: - # payloads[i].set_payload("Text part: %s" % (part.get_content_type())) - payloads[i].set_payload(quote_reply(payloads[i].get_payload(),reply['date'],reply['from'])) - - - # Delete those payloads that we don't need anymore - for i in reversed(sorted(delpayloads)): - del payloads[i] - - #Back to single- and multipart handling - - my_addresses = get_user_email_addresses() - used_address = None - # filter our email addresses from all to: cc: and bcc: fields - # if we find one of "my" addresses being used, - # it is stored in used_address - for header in ['To', 'CC', 'Bcc']: - if not header in reply: - #only handle fields that exist - continue - addresses = email.utils.getaddresses(reply.get_all(header,[])) - purged_addr = [] - for name, mail in addresses: - if mail in my_addresses[1:]: - used_address = email.utils.formataddr((my_addresses[0],mail)) - else: - purged_addr.append(email.utils.formataddr((name,mail))) - - if len(purged_addr): - reply.replace_header(header, ", ".join(purged_addr)) - else: - #we deleted all addresses, delete the header - del reply[header] - - # Use our primary email address to the From - # (save original from line, we still need it) - orig_from = reply['From'] - del reply['From'] - reply['From'] = used_address if used_address \ - else email.utils.formataddr((my_addresses[0],my_addresses[1])) - - #reinsert the Subject after the From - orig_subject = reply['Subject'] - del reply['Subject'] - reply['Subject'] = 'Re: ' + orig_subject - - # Calculate our new To: field - new_to = orig_from - # add all remaining original 'To' addresses - if 'To' in reply: - new_to += ", " + reply['To'] - del reply['To'] - reply.add_header('To', new_to) - - # Add our primary email address to the BCC - new_bcc = my_addresses[1] - if reply.has_key('Bcc'): - new_bcc += ', ' + reply['Bcc'] - del reply['Bcc'] - reply['Bcc'] = new_bcc - - # Set replies 'In-Reply-To' header to original's Message-ID - if reply.has_key('Message-ID') : - del reply['In-Reply-To'] - reply['In-Reply-To'] = reply['Message-ID'] - - #Add original's Message-ID to replies 'References' header. - if reply.has_key('References'): - ref = reply['References'] + ' ' +reply['Message-ID'] - else: - ref = reply['Message-ID'] - del reply['References'] - reply['References'] = ref - - # Delete the original Message-ID. - del(reply['Message-ID']) - - # filter all existing headers but a few and delete them from 'reply' - delheaders = filter(lambda x: x not in ['From','To','Subject','CC','Bcc', - 'In-Reply-To', 'References', - 'Content-Type'],reply.keys()) - map(reply.__delitem__, delheaders) - - """ -From: Sebastian Spaeth -Subject: Re: Template =?iso-8859-1?b?Zvxy?= das Kochrezept -In-Reply-To: <4A6D55F9.6040405@SSpaeth.de> -References: <4A6D55F9.6040405@SSpaeth.de> - """ - #return without Unixfrom - return reply.as_string(False) -#------------------------------------------------------------------------- -def quote_query_line(argv): - #mangle arguments wrapping terms with spaces in quotes - for i in xrange(0,len(argv)): - if argv[i].find(' ') >= 0: - #if we use prefix:termWithSpaces, put quotes around term - m = PREFIX.match(argv[i]) - if m: - argv[i] = '%s:"%s"' % (m.group(1), m.group(2)) - else: - argv[i] = '"'+argv[i]+'"' - return ' '.join(argv) - if __name__ == '__main__': # Handle command line options #------------------------------------- # No option given, print USAGE and exit if len(sys.argv) == 1: - print USAGE + Notmuch().cmd_usage() #------------------------------------- elif sys.argv[1] == 'setup': """Interactively setup notmuch for first use.""" print "Not implemented." #------------------------------------- elif sys.argv[1] == 'new': - """ Interactively setup notmuch for first use. """ - #print "Not implemented. We cheat by calling the proper notmuch" - call(['notmuch new'],shell=True) + """Check for new and removed messages.""" + Notmuch().cmd_new() #------------------------------------- elif sys.argv[1] == 'help': - if len(sys.argv) == 2: print HELPTEXT - else: print "Not implemented" + """Print the help text""" + Notmuch().cmd_help(sys.argv[1:]) #------------------------------------- elif sys.argv[1] == 'part': db = Database() @@ -308,9 +459,7 @@ if __name__ == '__main__': if first_search_term: #mangle arguments wrapping terms with spaces in quotes querystr = quote_query_line(sys.argv[first_search_term:]) - - logging.debug("part "+querystr) qry = Query(db,querystr) msgs = qry.search_messages() msg_list = [] @@ -343,8 +492,6 @@ if __name__ == '__main__': #mangle arguments wrapping terms with spaces in quotes querystr = quote_query_line(sys.argv[first_search_term:]) - - logging.debug("search "+querystr) qry = Query(db,querystr) if sort_order == "oldest-first": qry.set_sort(Query.SORT.OLDEST_FIRST) @@ -383,7 +530,6 @@ if __name__ == '__main__': #mangle arguments wrapping terms with spaces in quotes querystr = quote_query_line(sys.argv[first_search_term:]) - logging.debug("show "+querystr) t = Query(db,querystr).search_threads() first_toplevel=True @@ -415,9 +561,8 @@ if __name__ == '__main__': #mangle arguments wrapping terms with spaces in quotes querystr = quote_query_line(sys.argv[2:]) - logging.debug("reply "+querystr) msgs = Query(db,querystr).search_messages() - print (format_reply(msgs)) + print (Notmuch().format_reply(msgs)) #------------------------------------- elif sys.argv[1] == 'count': @@ -445,7 +590,6 @@ if __name__ == '__main__': if sys.argv[2]=='--': sys.argv.pop(2) #the rest is search terms querystr = quote_query_line(sys.argv[2:]) - logging.debug("tag search-term "+querystr) db = Database(mode=Database.MODE.READ_WRITE) m = Query(db,querystr).search_messages() for msg in m: @@ -460,7 +604,6 @@ if __name__ == '__main__': else: #mangle arguments wrapping terms with spaces in quotes querystr = quote_query_line(sys.argv[2:]) - logging.debug("search-term "+querystr) db = Database() m = Query(db,querystr).search_messages() print("\n".join([t for t in m.collect_tags()])) From a5596f375b4867c3a86298b74ddd7a494df2cf71 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 31 Mar 2010 10:12:55 +0200 Subject: [PATCH 094/106] database(): Actually return a value on remove_message() --- cnotmuch/database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cnotmuch/database.py b/cnotmuch/database.py index bb8a9632..fd2d9a99 100644 --- a/cnotmuch/database.py +++ b/cnotmuch/database.py @@ -329,7 +329,7 @@ class Database(object): # Raise a NotmuchError if not initialized self._verify_initialized_db() - status = nmlib.notmuch_database_remove_message(self._db, + return nmlib.notmuch_database_remove_message(self._db, filename) def find_message(self, msgid): From dfa9eb8afa66b79800addc2102b4bd153f4495ab Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 31 Mar 2010 10:23:29 +0200 Subject: [PATCH 095/106] notmuch new implementation It can not add and remove messages. However, message moves are not detected and we do not modify or honor the Directory entries yet. --- cnotmuch/globals.py | 1 + notmuch | 114 ++++++++++++++++++++++++-------------------- 2 files changed, 64 insertions(+), 51 deletions(-) diff --git a/cnotmuch/globals.py b/cnotmuch/globals.py index d9f8d1b4..ef2686fc 100644 --- a/cnotmuch/globals.py +++ b/cnotmuch/globals.py @@ -29,6 +29,7 @@ class Status(Enum): """ super(Status, self).__init__(statuslist) + @classmethod def status2str(self, status): """Get a string representation of a notmuch_status_t value.""" # define strings for custom error messages diff --git a/notmuch b/notmuch index 46f65791..278d6d41 100755 --- a/notmuch +++ b/notmuch @@ -85,13 +85,7 @@ class Notmuch: #for folder in subdirs: - # (new_added, new_moved, new_removed) = \ - # self._add_new_files_recursively( - # os.path.join(db_dir.path, folder), db) - # added += new_added - # moved += new_moved - # removed += new_removed - + #TODO, retrieve dir mtime here and store it later #as long as Filenames() does not allow multiple iteration, we need to #use this kludgy way to get a sorted list of filenames @@ -99,68 +93,86 @@ class Notmuch: db_files = set() db_folders = set() for subdir in db_dir.get_child_directories(): - db_folders.add(os.path.normpath(subdir)) + db_folders.add(subdir) for file in db_dir.get_child_files(): db_files.add(file) fs_files = set(os.listdir(db_dir.path)) - #list of folders in both db and fs. Just descend into dirs - for fs_file in (fs_files | db_folders): + #list of files (and folders) on the fs, but not the db + for fs_file in ((fs_files - db_files) - db_folders): absfile = os.path.normpath(os.path.join(db_dir.path, fs_file)) - if os.path.isdir(absfile): + statinfo = os.stat(absfile) + + if stat.S_ISDIR(statinfo.st_mode): #This is a directory if fs_file in ['.notmuch','tmp','.']: continue - self._add_new_files_recursively(absfile, db) - # we are not interested in anything but directories here + print "%s %s" % (fs_file, db_folders) + print "Directory not in db yet. Descending into %s" % absfile + (new_added, new_moved, new_removed) = \ + self._add_new_files_recursively(absfile, db) + added += new_added + moved += new_moved + removed += new_removed - #list of files and folders in the fs, but not the db - for fs_file in (fs_files - db_files): - absfile = os.path.normpath(os.path.join(db_dir.path, fs_file)) - statinfo = os.stat(absfile) - - if stat.S_ISDIR(statinfo.st_mode): - #This is a directory - if fs_file in ['.notmuch','.']: - continue - print "descending into %s" % absfile - #self._add_new_files_recursively(absfile, db) elif stat.S_ISLNK(statinfo.st_mode): - print ("%s is a symbolic link (%d)" % (absfile, statinfo.st_mode)) + print ("%s is a symbolic link (%d). FIXME!!!" % (absfile, statinfo.st_mode)) + sys.exit() else: + #This is a regular file, not in the db yet. Add it. print "This file needs to be added %s" % (absfile) - #TODO - #(msg, status) = db.add_message(os.path.join(db_dir.path, db_file)) - #if status == STATUS.DUPLICATE_MESSAGE_ID: - # #This message was already in the database, continue with next one - # continue + (msg, status) = db.add_message(absfile) + # We increases 'added', even on dupe messages. If it is a moved + # message, we will deduct it later and increase 'moved' instead + added += 1 - #list of files and folders in the database, but not the filesystem + if status == STATUS.DUPLICATE_MESSAGE_ID: + #This message was already in the database + print "Added msg was in the db" + else: + print "New message." + + # Finally a list of files (not folders) in the database, + # but not the filesystem for db_file in (db_files - fs_files): absfile = os.path.normpath(os.path.join(db_dir.path, db_file)) - statinfo = os.stat(absfile) - if stat.S_ISDIR(statinfo.st_mode): - #This is a directory - if db_file in ['.notmuch', '.']: + #remove a mail message from the db + print ("%s is not on the fs anymore. Delete" % absfile) + status = db.remove_message(absfile) + if status == STATUS.SUCCESS: + # we just deleted the last reference, so this was a remove + removed += 1 + sys.stderr.write("SUCCESS %d %s %s.\n" % (status, STATUS.status2str(status), absfile)) + elif status == STATUS.DUPLICATE_MESSAGE_ID: + # The filename exists already somewhere else, so this is a move + moved += 1 + added -= 1 + sys.stderr.write("DUPE %d %s %s.\n" % (status, STATUS.status2str(status), absfile)) + else: + #This should not occur + sys.stderr.write("This should not occur %d %s %s.\n" % (status, STATUS.status2str(status), absfile)) + + #list of folders in the filesystem. Just descend into dirs + for fs_file in fs_files: + absfile = os.path.normpath(os.path.join(db_dir.path, fs_file)) + if os.path.isdir(absfile): + #This is a directory. + # Remove it from the db_folder list. All remaining db_folders + # at the end will be not present on the file system. + db_folders.remove(fs_file) + if fs_file in ['.notmuch','tmp','.']: continue - print "descending into %s" % absfile - self._add_new_files_recursively(absfile, db) - #TODO, is there no way to REMOVE a directory entry from the db? - else: - #remove a mail message from the db - print ("%s is not on the fs anymore. Delete" % absfile) - status = db.remove_message(absfile) - if status == STATUS.SUCCESS: - # we just deleted the last reference, so this was a remove - removed += 1 - elif status == STATUS.DUPLICATE_MESSAGE_ID: - # The filename exists already somewhere else, so this is a move - moved += 1 - else: - print "This must not happen. %s " % (absfile) - sys.exit(1) + (new_added, new_moved, new_removed) = \ + self._add_new_files_recursively(absfile, db) + added += new_added + moved += new_moved + removed += new_removed + + # we are not interested in anything but directories here + #TODO: All remaining elements of db_folders are not in the filesystem + #delete those return (added, moved, removed) #Read the mtime of a directory from the filesystem From f378f45893bb4263402008b2abd8aab522901fb6 Mon Sep 17 00:00:00 2001 From: Cedric Cabessa Date: Mon, 5 Apr 2010 03:03:51 +0200 Subject: [PATCH 096/106] find_library does not read LD_LIBRARY_PATH, but CDLL does. --- cnotmuch/globals.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/cnotmuch/globals.py b/cnotmuch/globals.py index ef2686fc..fa20ae81 100644 --- a/cnotmuch/globals.py +++ b/cnotmuch/globals.py @@ -3,17 +3,17 @@ from ctypes.util import find_library #----------------------------------------------------------------------------- #package-global instance of the notmuch library -#TODO: lazy load this on first access? -so = find_library('notmuch') -if so is None: - raise ImportError("Could not find shared 'notmuch' library.") -nmlib = CDLL(so) +try: + nmlib = CDLL("libnotmuch.so.1") +except: + raise ImportError("Could not find shared 'notmuch' library.") + #----------------------------------------------------------------------------- class Enum(object): - """Provides ENUMS as "code=Enum(['a','b','c'])" where code.a=0 etc...""" - def __init__(self, names): - for number, name in enumerate(names): - setattr(self, name, number) + """Provides ENUMS as "code=Enum(['a','b','c'])" where code.a=0 etc...""" + def __init__(self, names): + for number, name in enumerate(names): + setattr(self, name, number) #----------------------------------------------------------------------------- class Status(Enum): From 2a5884633c341660e5cc4c841dfe6898e47b9a60 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 7 Apr 2010 11:43:29 +0200 Subject: [PATCH 097/106] Extent pypi documentation. Set version to 0.2.2 --- cnotmuch/notmuch.py | 2 +- setup.py | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/cnotmuch/notmuch.py b/cnotmuch/notmuch.py index 484bf7fd..f6a9a232 100644 --- a/cnotmuch/notmuch.py +++ b/cnotmuch/notmuch.py @@ -38,5 +38,5 @@ from thread import Threads, Thread from tag import Tags from cnotmuch.globals import nmlib, STATUS, NotmuchError __LICENSE__="GPL v3+" -__VERSION__='0.2.1+' +__VERSION__='0.2.2' __AUTHOR__ ='Sebastian Spaeth ' diff --git a/setup.py b/setup.py index ad411c01..256fd4e5 100644 --- a/setup.py +++ b/setup.py @@ -11,11 +11,33 @@ setup(name='cnotmuch', download_url='http://bitbucket.org/spaetz/cnotmuch/get/v'+__VERSION__+'.tar.gz', packages=['cnotmuch'], keywords = ["library", "email"], - long_description="""The cnotmuch module provides an interface to the `notmuch `_ functionality, directly interfacing with a shared notmuch library. Notmuch provides a maildatabase that allows for extremely quick searching and filtering of your email according to various criteria. + long_description="""Overview +============== + +The cnotmuch module provides an interface to the `notmuch `_ functionality, directly interfacing with a shared notmuch library. Notmuch provides a maildatabase that allows for extremely quick searching and filtering of your email according to various criteria. The documentation for the latest cnotmuch release can be `viewed online `_. The classes notmuch.Database, notmuch.Query provide most of the core functionality, returning notmuch.Messages and notmuch.Tags. + +Installation and Deinstallation +------------------------------- + +cnotmuch is packaged on http://pypi.python.org. This means you can do +"easy_install cnotmuch" (or using pip) on your linux box and it will +get installed into: + +/usr/local/lib/python2.x/dist-packages/ + +For uninstalling, you will need to remove the "cnotmuch-0.1-py2.x.egg" +directory and delete one entry refering to cnotmuch in the +"easy-install.pth" file in that directory. There should be no trace +left of cnotmuch then. + +Requirements +------------ + +You need to have notmuch installed (or rather libnotmuch.so.1). The release version 0.1 should work fine. Also, cnotmuch makes use of the ctypes library, and has only been tested with python 2.5. It will not work on earlier python versions. """, classifiers=['Development Status :: 2 - Pre-Alpha', 'Intended Audience :: Developers', From 6218951743304b3ceae2071061a062007e903af9 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Wed, 7 Apr 2010 11:43:43 +0200 Subject: [PATCH 098/106] Added tag v0.2.2 for changeset 0122a27667ad --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 4e68fffe..36f9e461 100644 --- a/.hgtags +++ b/.hgtags @@ -2,3 +2,4 @@ 4fdbf2935cdf58865745d8bbc5e9b6be008ecb1c v0.1.1 36e2dd4e739a9da87837773467777d6d67e319f5 v0.2.0 8f496a1f9b3431609ba0e4bc5524d6179c6d7155 v0.2.1 +0122a27667adbed8e246729fcdd82b4e4e9ae420 v0.2.2 From 702f88ea9021afc26f4d58a3e920912d0faebb28 Mon Sep 17 00:00:00 2001 From: acoolon Date: Wed, 7 Apr 2010 16:26:42 +0200 Subject: [PATCH 099/106] cleanup style, hopefully no functional changes. --- notmuch | 1185 +++++++++++++++++++++++++++---------------------------- 1 file changed, 575 insertions(+), 610 deletions(-) diff --git a/notmuch b/notmuch index 278d6d41..73ee33b6 100755 --- a/notmuch +++ b/notmuch @@ -1,5 +1,5 @@ #!/usr/bin/env python -"""This is a notmuch implementation in python. +"""This is a notmuch implementation in python. It's goal is to allow running the test suite on the cnotmuch python bindings. This "binary" honors the NOTMUCH_CONFIG environmen variable for reading a user's @@ -9,412 +9,56 @@ notmuch configuration (e.g. the database path). Jesse Rosenthal This code is licensed under the GNU GPL v3+. """ -from __future__ import with_statement # This isn't required in Python 2.6 -import sys, os, re, stat +import sys +import os + +import re +import stat +import email + from cnotmuch.notmuch import Database, Query, NotmuchError, STATUS -PREFIX=re.compile('(\w+):(.*$)') -#------------------------------------------------------------------------- -def quote_query_line(argv): - #mangle arguments wrapping terms with spaces in quotes - for i in xrange(0,len(argv)): - if argv[i].find(' ') >= 0: - #if we use prefix:termWithSpaces, put quotes around term - m = PREFIX.match(argv[i]) - if m: - argv[i] = '%s:"%s"' % (m.group(1), m.group(2)) - else: - argv[i] = '"'+argv[i]+'"' - return ' '.join(argv) +from ConfigParser import SafeConfigParser +from cStringIO import StringIO -#------------------------------------------------------------------------- -class Notmuch: - - def __init__(self): - self._config = None - - def cmd_usage(self): - """Print the usage text and exits""" - data={} - names = self.get_user_email_addresses() - data['fullname'] =names[0] if names[0] else 'My Name' - data['mailaddress']=names[1] if names[1] else 'My@email.address' - print (Notmuch.USAGE % data) - - def cmd_new(self): - """Run 'notmuch new'""" - #get the database directory - db = Database(mode=Database.MODE.READ_WRITE) - path = db.get_path() - - (added, moved, removed) = self._add_new_files_recursively(path, db) - print (added, moved, removed) - - def cmd_help(self, subcmd=None): - """Print help text for 'notmuch help'""" - if len(subcmd) > 1: - print "Help for specific commands not implemented" - return - - print (Notmuch.HELPTEXT) - - def _get_user_notmuch_config(self): - """Returns the ConfigParser of the user's notmuch-config""" - # return the cached config parser if we read it already - if self._config is not None: - return self._config - - from ConfigParser import SafeConfigParser - config = SafeConfigParser() - conf_f = os.getenv('NOTMUCH_CONFIG', - os.path.expanduser('~/.notmuch-config')) - config.read(conf_f) - self._config = config - return config - - def _add_new_files_recursively(self, path, db): - """:returns: (added, moved, removed)""" - print "Enter add new files with path %s" % path - (added, moved, removed) = (0,)*3 - - try: - #get the Directory() object for this path - db_dir = db.get_directory(path) - except NotmuchError: - #Occurs if we have wrong absolute paths in the db, for example - return (0,0,0) - - - #for folder in subdirs: - - #TODO, retrieve dir mtime here and store it later - #as long as Filenames() does not allow multiple iteration, we need to - #use this kludgy way to get a sorted list of filenames - #db_files is a list of subdirectories and filenames in this folder - db_files = set() - db_folders = set() - for subdir in db_dir.get_child_directories(): - db_folders.add(subdir) - for file in db_dir.get_child_files(): - db_files.add(file) - - fs_files = set(os.listdir(db_dir.path)) - - #list of files (and folders) on the fs, but not the db - for fs_file in ((fs_files - db_files) - db_folders): - absfile = os.path.normpath(os.path.join(db_dir.path, fs_file)) - statinfo = os.stat(absfile) - - if stat.S_ISDIR(statinfo.st_mode): - #This is a directory - if fs_file in ['.notmuch','tmp','.']: - continue - print "%s %s" % (fs_file, db_folders) - print "Directory not in db yet. Descending into %s" % absfile - (new_added, new_moved, new_removed) = \ - self._add_new_files_recursively(absfile, db) - added += new_added - moved += new_moved - removed += new_removed - - elif stat.S_ISLNK(statinfo.st_mode): - print ("%s is a symbolic link (%d). FIXME!!!" % (absfile, statinfo.st_mode)) - sys.exit() - else: - #This is a regular file, not in the db yet. Add it. - print "This file needs to be added %s" % (absfile) - (msg, status) = db.add_message(absfile) - # We increases 'added', even on dupe messages. If it is a moved - # message, we will deduct it later and increase 'moved' instead - added += 1 - - if status == STATUS.DUPLICATE_MESSAGE_ID: - #This message was already in the database - print "Added msg was in the db" - else: - print "New message." - - # Finally a list of files (not folders) in the database, - # but not the filesystem - for db_file in (db_files - fs_files): - absfile = os.path.normpath(os.path.join(db_dir.path, db_file)) - - #remove a mail message from the db - print ("%s is not on the fs anymore. Delete" % absfile) - status = db.remove_message(absfile) - if status == STATUS.SUCCESS: - # we just deleted the last reference, so this was a remove - removed += 1 - sys.stderr.write("SUCCESS %d %s %s.\n" % (status, STATUS.status2str(status), absfile)) - elif status == STATUS.DUPLICATE_MESSAGE_ID: - # The filename exists already somewhere else, so this is a move - moved += 1 - added -= 1 - sys.stderr.write("DUPE %d %s %s.\n" % (status, STATUS.status2str(status), absfile)) - else: - #This should not occur - sys.stderr.write("This should not occur %d %s %s.\n" % (status, STATUS.status2str(status), absfile)) - - #list of folders in the filesystem. Just descend into dirs - for fs_file in fs_files: - absfile = os.path.normpath(os.path.join(db_dir.path, fs_file)) - if os.path.isdir(absfile): - #This is a directory. - # Remove it from the db_folder list. All remaining db_folders - # at the end will be not present on the file system. - db_folders.remove(fs_file) - if fs_file in ['.notmuch','tmp','.']: - continue - (new_added, new_moved, new_removed) = \ - self._add_new_files_recursively(absfile, db) - added += new_added - moved += new_moved - removed += new_removed - - # we are not interested in anything but directories here - #TODO: All remaining elements of db_folders are not in the filesystem - #delete those - - return (added, moved, removed) - #Read the mtime of a directory from the filesystem - # - #* Call :meth:`Database.add_message` for all mail files in - # the directory - - #* Call notmuch_directory_set_mtime with the mtime read from the - # filesystem. Then, when wanting to check for updates to the - # directory in the future, the client can call :meth:`get_mtime` - # and know that it only needs to add files if the mtime of the - # directory and files are newer than the stored timestamp. - - def get_user_email_addresses(self): - """ Reads a user's notmuch config and returns his email addresses as - list (name, primary_address, other_address1,...)""" - import email.utils - - #read the config file - config = self._get_user_notmuch_config() - - if not config.has_option('user','name'): name = "" - else:name = config.get('user','name') - - if not config.has_option('user','primary_email'): mail = "" - else:mail = config.get('user','primary_email') - - if not config.has_option('user','other_email'): other = [] - else:other = config.get('user','other_email').rstrip(';').split(';') - - other.insert(0, mail) - other.insert(0, name) - return other - - def quote_msg_body(self, oldbody ,date, from_address): - """Transform a mail body into a quoted text, - starting with On blah, x wrote: - - :param body: a str with a mail body - :returns: The new payload of the email.message() - """ - from cStringIO import StringIO - - #we get handed a string, wrap it in a file-like object - oldbody = StringIO(oldbody) - newbody = StringIO() - - newbody.write("On %s, %s wrote:\n" % (date, from_address)) - - for line in oldbody: - newbody.write("> " + line) - - return newbody.getvalue() - - def format_reply(self, msgs): - """Gets handed Messages() and displays the reply to them - - This is pretty ugly and hacky. It tries to mimic the "real" - notmuch output as much as it can to pass the test suite. It - could deserve a healthy bit of love. It is also buggy because - it returns after the first message it has handled.""" - import email - - for msg in msgs: - f = open(msg.get_filename(),"r") - reply = email.message_from_file(f) - - #handle the easy non-multipart case: - if not reply.is_multipart(): - reply.set_payload(self.quote_msg_body(reply.get_payload(), - reply['date'],reply['from'])) - else: - #handle the tricky multipart case - deleted = "" - """A string describing which nontext attachements that - have been deleted""" - delpayloads = [] - """A list of payload indices to be deleted""" - - payloads = reply.get_payload() - - for i, part in enumerate(payloads): - - mime_main = part.get_content_maintype() - if mime_main not in ['multipart', 'message', 'text']: - deleted += "Non-text part: %s\n" % (part.get_content_type()) - payloads[i].set_payload("Non-text part: %s" % (part.get_content_type())) - payloads[i].set_type('text/plain') - delpayloads.append(i) - elif mime_main == 'text': - payloads[i].set_payload(self.quote_msg_body(payloads[i].get_payload(),reply['date'],reply['from'])) - else: - #TODO handle deeply nested multipart messages - sys.stderr.write ("FIXME: Ignoring multipart part. Handle me\n") - - # Delete those payloads that we don't need anymore - for i in reversed(sorted(delpayloads)): - del payloads[i] - - #Back to single- and multipart handling - my_addresses = self.get_user_email_addresses() - used_address = None - # filter our email addresses from all to: cc: and bcc: fields - # if we find one of "my" addresses being used, - # it is stored in used_address - for header in ['To', 'CC', 'Bcc']: - if not header in reply: - #only handle fields that exist - continue - addresses = email.utils.getaddresses(reply.get_all(header,[])) - purged_addr = [] - for name, mail in addresses: - if mail in my_addresses[1:]: - used_address = email.utils.formataddr((my_addresses[0],mail)) - else: - purged_addr.append(email.utils.formataddr((name,mail))) - - if len(purged_addr): - reply.replace_header(header, ", ".join(purged_addr)) - else: - #we deleted all addresses, delete the header - del reply[header] - - # Use our primary email address to the From - # (save original from line, we still need it) - orig_from = reply['From'] - del reply['From'] - reply['From'] = used_address if used_address \ - else email.utils.formataddr((my_addresses[0],my_addresses[1])) - - #reinsert the Subject after the From - orig_subject = reply['Subject'] - del reply['Subject'] - reply['Subject'] = 'Re: ' + orig_subject - - # Calculate our new To: field - new_to = orig_from - # add all remaining original 'To' addresses - if 'To' in reply: - new_to += ", " + reply['To'] - del reply['To'] - reply.add_header('To', new_to) - - # Add our primary email address to the BCC - new_bcc = my_addresses[1] - if reply.has_key('Bcc'): - new_bcc += ', ' + reply['Bcc'] - del reply['Bcc'] - reply['Bcc'] = new_bcc - - # Set replies 'In-Reply-To' header to original's Message-ID - if reply.has_key('Message-ID') : - del reply['In-Reply-To'] - reply['In-Reply-To'] = reply['Message-ID'] - - #Add original's Message-ID to replies 'References' header. - if reply.has_key('References'): - ref = reply['References'] + ' ' +reply['Message-ID'] - else: - ref = reply['Message-ID'] - del reply['References'] - reply['References'] = ref - - # Delete the original Message-ID. - del(reply['Message-ID']) - - # filter all existing headers but a few and delete them from 'reply' - delheaders = filter(lambda x: x not in ['From','To','Subject','CC', - 'Bcc','In-Reply-To', - 'References','Content-Type'], - reply.keys()) - map(reply.__delitem__, delheaders) - - # TODO: OUCH, we return after the first msg we have handled rather than - # handle all of them - #return resulting message without Unixfrom - return reply.as_string(False) - - - HELPTEXT="""The notmuch mail system. +PREFIX = re.compile('(\w+):(.*$)') +HELPTEXT = """The notmuch mail system. Usage: notmuch [args...] Where and [args...] are as follows: - setup Interactively setup notmuch for first use. - new [--verbose] - Find and import new messages to the notmuch database. - search [options...] [...] - Search for messages matching the given search terms. - show [...] - Show all messages matching the search terms. - count [...] - Count messages matching the search terms. - reply [options...] [...] - Construct a reply template for a set of messages. - tag +|- [...] [--] [...] - Add/remove tags for all messages matching the search terms. - dump [] - Create a plain-text dump of the tags for each message. - restore - Restore the tags from the given dump file (see 'dump'). - search-tags [ [...] ] - List all tags found in the database or matching messages. - help [] - This message, or more detailed help for the named command. Use "notmuch help " for more details on each command. And "notmuch help search-terms" for the common search-terms syntax. """ - USAGE="""Notmuch is configured and appears to have a database. Excellent! +USAGE = """Notmuch is configured and appears to have a database. Excellent! At this point you can start exploring the functionality of notmuch by using commands such as: - notmuch search tag:inbox - notmuch search to:"%(fullname)s" - notmuch search from:"%(mailaddress)s" - notmuch search subject:"my favorite things" See "notmuch help search" for more details. @@ -429,260 +73,581 @@ And don't forget to run "notmuch new" whenever new mail arrives. Have fun, and may your inbox never have much mail. """ -# MAIN #------------------------------------------------------------------------- -if __name__ == '__main__': +def quote_query_line(argv): + # mangle arguments wrapping terms with spaces in quotes + for (num, item) in enumerate(argv): + if item.find(' ') >= 0: + # if we use prefix:termWithSpaces, put quotes around term + match = PREFIX.match(item) + if match: + argv[num] = '%s:"%s"' %(match.group(1), match.group(2)) + else: + argv[num] = '"%s"' % item + return ' '.join(argv) - # Handle command line options - #------------------------------------- - # No option given, print USAGE and exit - if len(sys.argv) == 1: - Notmuch().cmd_usage() - #------------------------------------- - elif sys.argv[1] == 'setup': +#------------------------------------------------------------------------- + + +class Notmuch(object): + + def __init__(self, configpath="~/.notmuch-config)"): + self._config = None + self._configpath = os.getenv('NOTMUCH_CONFIG', + os.path.expanduser(configpath)) + + def cmd_usage(self): + """Print the usage text and exits""" + data={} + names = self.get_user_email_addresses() + data['fullname'] = names[0] if names[0] else 'My Name' + data['mailaddress'] = names[1] if names[1] else 'My@email.address' + print USAGE % data + + def cmd_new(self): + """Run 'notmuch new'""" + #get the database directory + db = Database(mode=Database.MODE.READ_WRITE) + path = db.get_path() + print self._add_new_files_recursively(path, db) + + def cmd_help(self, subcmd=None): + """Print help text for 'notmuch help'""" + if len(subcmd) > 1: + print "Help for specific commands not implemented" + return + print HELPTEXT + + def _get_user_notmuch_config(self): + """Returns the ConfigParser of the user's notmuch-config""" + # return the cached config parser if we read it already + if self._config: + return self._config + + config = SafeConfigParser() + config.read(self._configpath) + self._config = config + return config + + def _add_new_files_recursively(self, path, db): + """:returns: (added, moved, removed)""" + print "Enter add new files with path %s" % path + + try: + #get the Directory() object for this path + db_dir = db.get_directory(path) + added = moved = removed = 0 + except NotmuchError: + # Occurs if we have wrong absolute paths in the db, for example + return (0,0,0) + + + # for folder in subdirs: + + # TODO, retrieve dir mtime here and store it later + # as long as Filenames() does not allow multiple iteration, we need to + # use this kludgy way to get a sorted list of filenames + # db_files is a list of subdirectories and filenames in this folder + db_files = set() + db_folders = set() + for subdir in db_dir.get_child_directories(): + db_folders.add(subdir) +# file is a keyword (remove this ;)) + for mail in db_dir.get_child_files(): + db_files.add(mail) + + fs_files = set(os.listdir(db_dir.path)) + + # list of files (and folders) on the fs, but not the db + for fs_file in ((fs_files - db_files) - db_folders): + absfile = os.path.normpath(os.path.join(db_dir.path, fs_file)) + statinfo = os.stat(absfile) + + if stat.S_ISDIR(statinfo.st_mode): + # This is a directory + if fs_file in ['.notmuch','tmp','.']: + continue + print "%s %s" % (fs_file, db_folders) + print "Directory not in db yet. Descending into %s" % absfile + new = self._add_new_files_recursively(absfile, db) + added += new[0] + moved += new[1] + removed += new[2] + + elif stat.S_ISLNK(statinfo.st_mode): + print ("%s is a symbolic link (%d). FIXME!!!" % + (absfile, statinfo.st_mode)) + exit(1) + + else: + # This is a regular file, not in the db yet. Add it. + print "This file needs to be added %s" % (absfile) + (msg, status) = db.add_message(absfile) + # We increases 'added', even on dupe messages. If it is a moved + # message, we will deduct it later and increase 'moved' instead + added += 1 + + if status == STATUS.DUPLICATE_MESSAGE_ID: + print "Added msg was in the db" + else: + print "New message." + + # Finally a list of files (not folders) in the database, + # but not the filesystem + for db_file in (db_files - fs_files): + absfile = os.path.normpath(os.path.join(db_dir.path, db_file)) + + # remove a mail message from the db + print ("%s is not on the fs anymore. Delete" % absfile) + status = db.remove_message(absfile) + + if status == STATUS.SUCCESS: + # we just deleted the last reference, so this was a remove + removed += 1 + sys.stderr.write("SUCCESS %d %s %s.\n" % + (status, STATUS.status2str(status), absfile)) + elif status == STATUS.DUPLICATE_MESSAGE_ID: + # The filename exists already somewhere else, so this is a move + moved += 1 + added -= 1 + sys.stderr.write("DUPE %d %s %s.\n" % + (status, STATUS.status2str(status), absfile)) + else: + # This should not occur + sys.stderr.write("This should not occur %d %s %s.\n" % + (status, STATUS.status2str(status), absfile)) + + # list of folders in the filesystem. Just descend into dirs + for fs_file in fs_files: + absfile = os.path.normpath(os.path.join(db_dir.path, fs_file)) + if os.path.isdir(absfile): + # This is a directory. Remove it from the db_folder list. + # All remaining db_folders at the end will be not present + # on the file system. + db_folders.remove(fs_file) + if fs_file in ['.notmuch','tmp','.']: + continue + new = self._add_new_files_recursively(absfile, db) + added += new[0] + moved += new[0] + removed += new[0] + + # we are not interested in anything but directories here + #TODO: All remaining elements of db_folders are not in the filesystem + #delete those + + return added, moved, removed + #Read the mtime of a directory from the filesystem + # + #* Call :meth:`Database.add_message` for all mail files in + # the directory + + #* Call notmuch_directory_set_mtime with the mtime read from the + # filesystem. Then, when wanting to check for updates to the + # directory in the future, the client can call :meth:`get_mtime` + # and know that it only needs to add files if the mtime of the + # directory and files are newer than the stored timestamp. + + def get_user_email_addresses(self): + """ Reads a user's notmuch config and returns his email addresses as + list (name, primary_address, other_address1,...)""" + + #read the config file + config = self._get_user_notmuch_config() + + conf = {'name': '', 'primary_email': ''} + for entry in conf: + if config.has_option('user', entry): + conf[entry] = config.get('user', entry) + + if config.has_option('user','other_email'): + other = config.get('user','other_email') + other = [mail.strip() for mail in other.split(';') if mail] + else: + other = [] + # for being compatible. It would be nicer to return a dict. + return conf.keys() + other + + def quote_msg_body(self, oldbody ,date, from_address): + """Transform a mail body into a quoted text, + starting with On foo, bar wrote: + + :param body: a str with a mail body + :returns: The new payload of the email.message() + """ + + # we get handed a string, wrap it in a file-like object + oldbody = StringIO(oldbody) + newbody = StringIO() + + newbody.write("On %s, %s wrote:\n" % (date, from_address)) + + for line in oldbody: + newbody.write("> " + line) + + return newbody.getvalue() + + def format_reply(self, msgs): + """Gets handed Messages() and displays the reply to them + + This is pretty ugly and hacky. It tries to mimic the "real" + notmuch output as much as it can to pass the test suite. It + could deserve a healthy bit of love. It is also buggy because + it returns after the first message it has handled.""" + + for msg in msgs: + f = open(msg.get_filename(), "r") + reply = email.message_from_file(f) + + # handle the easy non-multipart case: + if not reply.is_multipart(): + reply.set_payload(self.quote_msg_body(reply.get_payload(), + reply['date'], reply['from'])) + else: + # handle the tricky multipart case + deleted = "" + """A string describing which nontext attachements + that have been deleted""" + delpayloads = [] + """A list of payload indices to be deleted""" + payloads = reply.get_payload() + + for (num, part) in enumerate(payloads): + mime_main = part.get_content_maintype() + if mime_main not in ['multipart', 'message', 'text']: + deleted += "Non-text part: %s\n" % (part.get_content_type()) + payloads[num].set_payload("Non-text part: %s" % + (part.get_content_type())) + payloads[num].set_type('text/plain') + delpayloads.append(num) + elif mime_main == 'text': + payloads[num].set_payload(self.quote_msg_body( + payloads[num].get_payload(), + reply['date'], reply['from'])) + else: + # TODO handle deeply nested multipart messages + sys.stderr.write ("FIXME: Ignoring multipart part. Handle me\n") + # Delete those payloads that we don't need anymore + for item in reversed(sorted(delpayloads)): + del payloads[item] + + # Back to single- and multipart handling + my_addresses = self.get_user_email_addresses() + used_address = None + # filter our email addresses from all to: cc: and bcc: fields + # if we find one of "my" addresses being used, + # it is stored in used_address + for header in ['To', 'CC', 'Bcc']: + if not header in reply: + #only handle fields that exist + continue + addresses = email.utils.getaddresses(reply.get_all(header, [])) + purged_addr = [] + for (name, mail) in addresses: + if mail in my_addresses[1:]: + used_address = email.utils.formataddr( + (my_addresses[0], mail)) + else: + purged_addr.append(email.utils.formataddr((name, mail))) + + if purged_addr: + reply.replace_header(header, ", ".join(purged_addr)) + else: + # we deleted all addresses, delete the header + del reply[header] + + # Use our primary email address to the From + # (save original from line, we still need it) + new_to = reply['From'] + if used_address: + reply['From'] = used_address + else: + email.utils.formataddr((my_addresses[0], my_addresses[1])) + + reply['Subject'] = 'Re: ' + reply['Subject'] + + # Calculate our new To: field + # add all remaining original 'To' addresses + if 'To' in reply: + new_to += ", " + reply['To'] + reply.add_header('To', new_to) + + # Add our primary email address to the BCC + new_bcc = my_addresses[1] + if 'Bcc' in reply: + new_bcc += ', ' + reply['Bcc'] + reply['Bcc'] = new_bcc + + # Set replies 'In-Reply-To' header to original's Message-ID + if 'Message-ID' in reply: + reply['In-Reply-To'] = reply['Message-ID'] + + #Add original's Message-ID to replies 'References' header. + if 'References' in reply: + reply['References'] = ' '.join([reply['References'], reply['Message-ID']]) + else: + reply['References'] = reply['Message-ID'] + + # Delete the original Message-ID. + del(reply['Message-ID']) + + # filter all existing headers but a few and delete them from 'reply' + delheaders = filter(lambda x: x not in ['From', 'To', 'Subject', 'CC', + 'Bcc', 'In-Reply-To', + 'References', 'Content-Type'], + reply.keys()) + map(reply.__delitem__, delheaders) + + # TODO: OUCH, we return after the first msg we have handled rather than + # handle all of them + # return resulting message without Unixfrom + return reply.as_string(False) + + +def main(): + # Handle command line options + #------------------------------------ + # No option given, print USAGE and exit + if len(sys.argv) == 1: + Notmuch().cmd_usage() + #------------------------------------ + elif sys.argv[1] == 'setup': """Interactively setup notmuch for first use.""" - print "Not implemented." - #------------------------------------- - elif sys.argv[1] == 'new': - """Check for new and removed messages.""" - Notmuch().cmd_new() - #------------------------------------- - elif sys.argv[1] == 'help': - """Print the help text""" - Notmuch().cmd_help(sys.argv[1:]) - #------------------------------------- - elif sys.argv[1] == 'part': - db = Database() - query_string = '' - part_num=0 - first_search_term = None - for (i, arg) in enumerate(sys.argv[1:]): - if arg.startswith('--part='): - part_num_str=arg.split("=")[1] - try: - part_num = int(part_num_str) - except ValueError: - # just emulating behavior - sys.exit() - elif not arg.startswith('--'): - #save the position of the first sys.argv that is a search term - first_search_term = i+1 + exit("Not implemented.") + #------------------------------------- + elif sys.argv[1] == 'new': + """Check for new and removed messages.""" + Notmuch().cmd_new() + #------------------------------------- + elif sys.argv[1] == 'help': + """Print the help text""" + Notmuch().cmd_help(sys.argv[1:]) + #------------------------------------- + elif sys.argv[1] == 'part': + part() + #------------------------------------- + elif sys.argv[1] == 'search': + search() + #------------------------------------- + elif sys.argv[1] == 'show': + show() + #------------------------------------- + elif sys.argv[1] == 'reply': + db = Database() + if len(sys.argv) == 2: + # no search term. abort + exit("Error: notmuch reply requires at least one search term.") + # mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[2:]) + msgs = Query(db, querystr).search_messages() + print Notmuch().format_reply(msgs) + #------------------------------------- + elif sys.argv[1] == 'count': + if len(sys.argv) == 2: + # no further search term, count all + querystr = '' + else: + # mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[2:]) + print Database().create_query(querystr).count_messages() + #------------------------------------- + elif sys.argv[1] == 'tag': + # build lists of tags to be added and removed + add = [] + remove = [] + while not sys.argv[2] == '--' and \ + (sys.argv[2].startswith('+') or sys.argv[2].startswith('-')): + if sys.argv[2].startswith('+'): + # append to add list without initial + + add.append(sys.argv.pop(2)[1:]) + else: + # append to remove list without initial - + remove.append(sys.argv.pop(2)[1:]) + # skip eventual '--' + if sys.argv[2] == '--': sys.argv.pop(2) + # the rest is search terms + querystr = quote_query_line(sys.argv[2:]) + db = Database(mode=Database.MODE.READ_WRITE) + msgs = Query(db, querystr).search_messages() + for msg in msgs: + # actually add and remove all tags + map(msg.add_tag, add) + map(msg.remove_tag, remove) + #------------------------------------- + elif sys.argv[1] == 'search-tags': + if len(sys.argv) == 2: + # no further search term + print "\n".join(Database().get_all_tags()) + else: + # mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[2:]) + db = Database() + msgs = Query(db, querystr).search_messages() + print "\n".join([t for t in msgs.collect_tags()]) + #------------------------------------- + elif sys.argv[1] == 'dump': + # TODO: implement "dump " + if len(sys.argv) == 2: + f = sys.stdout + else: + f = open(sys.argv[2], "w") + db = Database() + query = Query(db, '') + query.set_sort(Query.SORT.MESSAGE_ID) + msgs = query.search_messages() + for msg in msgs: + f.write("%s (%s)\n" % (msg.get_message_id(), msg.get_tags())) + #------------------------------------- + elif sys.argv[1] == 'restore': + if len(sys.argv) == 2: + print("No filename given. Reading dump from stdin.") + f = sys.stdin + else: + f = open(sys.argv[2], "r") - if first_search_term: - #mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[first_search_term:]) - - qry = Query(db,querystr) - msgs = qry.search_messages() - msg_list = [] - for m in msgs: - msg_list.append(m) - - if len(msg_list) == 0: - sys.exit() - elif len(msg_list) > 1: - raise Exception("search term did not match precisely one message") - else: - msg = msg_list[0] - print(msg.get_part(part_num)) - #------------------------------------- - elif sys.argv[1] == 'search': - db = Database() - query_string = '' - sort_order="newest-first" - first_search_term = None - for (i, arg) in enumerate(sys.argv[1:]): - if arg.startswith('--sort='): - sort_order=arg.split("=")[1] - if not sort_order in ("oldest-first", "newest-first"): - raise Exception("unknown sort order") - elif not arg.startswith('--'): - #save the position of the first sys.argv that is a search term - first_search_term = i+1 + # split the msg id and the tags + MSGID_TAGS = re.compile("(\S+)\s\((.*)\)$") + db = Database(mode=Database.MODE.READ_WRITE) - if first_search_term: - #mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[first_search_term:]) + #read each line of the dump file + for line in f: + msgs = MSGID_TAGS.match(line) + if not msgs: + sys.stderr.write("Warning: Ignoring invalid input line: %s" % + line) + continue + # split line in components and fetch message + msg_id = msgs.group(1) + new_tags = set(msgs.group(2).split()) + msg = db.find_message(msg_id) - qry = Query(db,querystr) - if sort_order == "oldest-first": - qry.set_sort(Query.SORT.OLDEST_FIRST) - else: - qry.set_sort(Query.SORT.NEWEST_FIRST) - t = qry.search_threads() + if msg == None: + sys.stderr.write( + "Warning: Cannot apply tags to missing message: %s\n" % msg_id) + continue - for thread in t: - print(str(thread)) + # do nothing if the old set of tags is the same as the new one + old_tags = set(msg.get_tags()) + if old_tags == new_tags: continue - #------------------------------------- - elif sys.argv[1] == 'show': - entire_thread = False - db = Database() - out_format="text" - querystr='' - first_search_term = None + # set the new tags + msg.freeze() + # only remove tags if the new ones are not a superset anyway + if not (new_tags > old_tags): msg.remove_all_tags() + for tag in new_tags: msg.add_tag(tag) + msg.thaw() + #------------------------------------- + else: + # unknown command + exit("Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1]) - #ugly homegrown option parsing - #TODO: use OptionParser - for (i, arg) in enumerate(sys.argv[1:]): - if arg == '--entire-thread': - entire_thread = True - elif arg.startswith("--format="): - out_format = arg.split("=")[1] - if out_format == 'json': - #for compatibility use --entire-thread for json +def part(): + db = Database() + query_string = '' + part_num = 0 + first_search_term = 0 + for (num, arg) in enumerate(sys.argv[1:]): + if arg.startswith('--part='): + part_num_str = arg.split("=")[1] + try: + part_num = int(part_num_str) + except ValueError: + # just emulating behavior + exit(1) + elif not arg.startswith('--'): + # save the position of the first sys.argv + # that is a search term + first_search_term = num + 1 + if first_search_term: + # mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[first_search_term:]) + qry = Query(db,querystr) + msgs = [msg for msg in qry.search_messages()] + + if not msgs: + sys.exit(1) + elif len(msgs) > 1: + raise Exception("search term did not match precisely one message") + else: + msg = msgs[0] + print msg.get_part(part_num) + +def search(): + db = Database() + query_string = '' + sort_order = "newest-first" + first_search_term = 0 + for (num, arg) in enumerate(sys.argv[1:]): + if arg.startswith('--sort='): + sort_order=arg.split("=")[1] + if not sort_order in ("oldest-first", "newest-first"): + raise Exception("unknown sort order") + elif not arg.startswith('--'): + # save the position of the first sys.argv that is a search term + first_search_term = num + 1 + + if first_search_term: + # mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[first_search_term:]) + + qry = Query(db, querystr) + if sort_order == "oldest-first": + qry.set_sort(Query.SORT.OLDEST_FIRST) + else: + qry.set_sort(Query.SORT.NEWEST_FIRST) + threads = qry.search_threads() + + for thread in threads: + print thread + +def show(): + entire_thread = False + db = Database() + out_format = "text" + querystr = '' + first_search_term = None + + # ugly homegrown option parsing + # TODO: use OptionParser + for (num, arg) in enumerate(sys.argv[1:]): + if arg == '--entire-thread': + entire_thread = True + elif arg.startswith("--format="): + out_format = arg.split("=")[1] + if out_format == 'json': + # for compatibility use --entire-thread for json entire_thread = True - if not out_format in ("json", "text"): + if not out_format in ("json", "text"): raise Exception("unknown format") - elif not arg.startswith('--'): - #save the position of the first sys.argv that is a search term - first_search_term = i+1 + elif not arg.startswith('--'): + # save the position of the first sys.argv that is a search term + first_search_term = num + 1 - if first_search_term: - #mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[first_search_term:]) + if first_search_term: + # mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[first_search_term:]) - t = Query(db,querystr).search_threads() + threads = Query(db, querystr).search_threads() + first_toplevel = True + if out_format == "json": + sys.stdout.write("[") + for thread in threads: + msgs = thread.get_toplevel_messages() + if not first_toplevel: + if out_format == "json": + sys.stdout.write(", ") + first_toplevel = False + msgs.print_messages(out_format, 0, entire_thread) - first_toplevel=True - if out_format.lower()=="json": - sys.stdout.write("[") + if out_format == "json": + sys.stdout.write("]") + sys.stdout.write("\n") - for thrd in t: - msgs = thrd.get_toplevel_messages() +if __name__ == '__main__': + main() - if not first_toplevel: - if out_format.lower()=="json": - sys.stdout.write(", ") - - first_toplevel = False - - msgs.print_messages(out_format, 0, entire_thread) - - if out_format.lower() == "json": - sys.stdout.write("]") - sys.stdout.write("\n") - - #------------------------------------- - elif sys.argv[1] == 'reply': - db = Database() - if len(sys.argv) == 2: - #no search term. abort - print("Error: notmuch reply requires at least one search term.") - sys.exit() - - #mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[2:]) - msgs = Query(db,querystr).search_messages() - print (Notmuch().format_reply(msgs)) - - #------------------------------------- - elif sys.argv[1] == 'count': - if len(sys.argv) == 2: - #no further search term, count all - querystr='' - else: - #mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[2:]) - print(Database().create_query(querystr).count_messages()) - - #------------------------------------- - elif sys.argv[1] == 'tag': - #build lists of tags to be added and removed - add, remove = [], [] - while not sys.argv[2]=='--' and \ - (sys.argv[2].startswith('+') or sys.argv[2].startswith('-')): - if sys.argv[2].startswith('+'): - #append to add list without initial + - add.append(sys.argv.pop(2)[1:]) - else: - #append to remove list without initial - - remove.append(sys.argv.pop(2)[1:]) - #skip eventual '--' - if sys.argv[2]=='--': sys.argv.pop(2) - #the rest is search terms - querystr = quote_query_line(sys.argv[2:]) - db = Database(mode=Database.MODE.READ_WRITE) - m = Query(db,querystr).search_messages() - for msg in m: - #actually add and remove all tags - map(msg.add_tag, add) - map(msg.remove_tag, remove) - #------------------------------------- - elif sys.argv[1] == 'search-tags': - if len(sys.argv) == 2: - #no further search term - print("\n".join(Database().get_all_tags())) - else: - #mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[2:]) - db = Database() - m = Query(db,querystr).search_messages() - print("\n".join([t for t in m.collect_tags()])) - #------------------------------------- - elif sys.argv[1] == 'dump': - #TODO: implement "dump " - if len(sys.argv) == 2: - f = sys.stdout - else: - f = open(sys.argv[2],"w") - db = Database() - q = Query(db,'') - q.set_sort(Query.SORT.MESSAGE_ID) - m = q.search_messages() - for msg in m: - f.write("%s (%s)\n" % (msg.get_message_id(), msg.get_tags())) - #------------------------------------- - elif sys.argv[1] == 'restore': - import re - if len(sys.argv) == 2: - print("No filename given. Reading dump from stdin.") - f = sys.stdin - else: - f = open(sys.argv[2],"r") - #split the msg id and the tags - MSGID_TAGS = re.compile("(\S+)\s\((.*)\)$") - db = Database(mode=Database.MODE.READ_WRITE) - - #read each line of the dump file - for line in f: - m = MSGID_TAGS.match(line) - if not m: - sys.stderr.write("Warning: Ignoring invalid input line: %s" % - line) - continue - # split line in components and fetch message - msg_id = m.group(1) - new_tags= set(m.group(2).split()) - msg = db.find_message(msg_id) - - if msg == None: - sys.stderr.write( - "Warning: Cannot apply tags to missing message: %s\n" % id) - continue - - #do nothing if the old set of tags is the same as the new one - old_tags = set(msg.get_tags()) - if old_tags == new_tags: continue - - #set the new tags - msg.freeze() - #only remove tags if the new ones are not a superset anyway - if not (new_tags > old_tags): msg.remove_all_tags() - for tag in new_tags: msg.add_tag(tag) - msg.thaw() - - #------------------------------------- - else: - # unknown command - print "Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1] - - - #TODO: implement - """ -setup -new -show [...] -reply [options...] [...] -restore - """ +# TODO: implement +""" +setup (not?) +new (halfway there) +""" From 3c6321cdb2cbc11fe654fe49e4beaa5c2479e280 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Mon, 19 Apr 2010 21:14:47 +0200 Subject: [PATCH 100/106] cnotmuch -> notmuch and GPL v3+ boilerplate code to each source file. --HG-- rename : cnotmuch/__init__.py => notmuch/__init__.py rename : cnotmuch/database.py => notmuch/database.py rename : cnotmuch/globals.py => notmuch/globals.py rename : cnotmuch/message.py => notmuch/message.py rename : cnotmuch/notmuch.py => notmuch/notmuch.py rename : cnotmuch/tag.py => notmuch/tag.py rename : cnotmuch/thread.py => notmuch/thread.py --- notmuch | 653 ------------------------------ {cnotmuch => notmuch}/__init__.py | 0 {cnotmuch => notmuch}/database.py | 19 + {cnotmuch => notmuch}/globals.py | 19 + {cnotmuch => notmuch}/message.py | 37 +- {cnotmuch => notmuch}/notmuch.py | 19 + {cnotmuch => notmuch}/tag.py | 18 + {cnotmuch => notmuch}/thread.py | 19 + setup.py | 8 +- 9 files changed, 118 insertions(+), 674 deletions(-) delete mode 100755 notmuch rename {cnotmuch => notmuch}/__init__.py (100%) rename {cnotmuch => notmuch}/database.py (97%) rename {cnotmuch => notmuch}/globals.py (74%) rename {cnotmuch => notmuch}/message.py (97%) rename {cnotmuch => notmuch}/notmuch.py (71%) rename {cnotmuch => notmuch}/tag.py (85%) rename {cnotmuch => notmuch}/thread.py (95%) diff --git a/notmuch b/notmuch deleted file mode 100755 index 73ee33b6..00000000 --- a/notmuch +++ /dev/null @@ -1,653 +0,0 @@ -#!/usr/bin/env python -"""This is a notmuch implementation in python. -It's goal is to allow running the test suite on the cnotmuch python bindings. - -This "binary" honors the NOTMUCH_CONFIG environmen variable for reading a user's -notmuch configuration (e.g. the database path). - - (c) 2010 by Sebastian Spaeth - Jesse Rosenthal - This code is licensed under the GNU GPL v3+. -""" -import sys -import os - -import re -import stat -import email - -from cnotmuch.notmuch import Database, Query, NotmuchError, STATUS -from ConfigParser import SafeConfigParser -from cStringIO import StringIO - -PREFIX = re.compile('(\w+):(.*$)') - -HELPTEXT = """The notmuch mail system. -Usage: notmuch [args...] - -Where and [args...] are as follows: - setup Interactively setup notmuch for first use. - new [--verbose] - Find and import new messages to the notmuch database. - search [options...] [...] - Search for messages matching the given search terms. - show [...] - Show all messages matching the search terms. - count [...] - Count messages matching the search terms. - reply [options...] [...] - Construct a reply template for a set of messages. - tag +|- [...] [--] [...] - Add/remove tags for all messages matching the search terms. - dump [] - Create a plain-text dump of the tags for each message. - restore - Restore the tags from the given dump file (see 'dump'). - search-tags [ [...] ] - List all tags found in the database or matching messages. - help [] - This message, or more detailed help for the named command. - -Use "notmuch help " for more details on each command. -And "notmuch help search-terms" for the common search-terms syntax. -""" - -USAGE = """Notmuch is configured and appears to have a database. Excellent! - -At this point you can start exploring the functionality of notmuch by -using commands such as: - notmuch search tag:inbox - notmuch search to:"%(fullname)s" - notmuch search from:"%(mailaddress)s" - notmuch search subject:"my favorite things" - -See "notmuch help search" for more details. - -You can also use "notmuch show" with any of the thread IDs resulting -from a search. Finally, you may want to explore using a more sophisticated -interface to notmuch such as the emacs interface implemented in notmuch.el -or any other interface described at http://notmuchmail.org - -And don't forget to run "notmuch new" whenever new mail arrives. - -Have fun, and may your inbox never have much mail. -""" - -#------------------------------------------------------------------------- -def quote_query_line(argv): - # mangle arguments wrapping terms with spaces in quotes - for (num, item) in enumerate(argv): - if item.find(' ') >= 0: - # if we use prefix:termWithSpaces, put quotes around term - match = PREFIX.match(item) - if match: - argv[num] = '%s:"%s"' %(match.group(1), match.group(2)) - else: - argv[num] = '"%s"' % item - return ' '.join(argv) - -#------------------------------------------------------------------------- - - -class Notmuch(object): - - def __init__(self, configpath="~/.notmuch-config)"): - self._config = None - self._configpath = os.getenv('NOTMUCH_CONFIG', - os.path.expanduser(configpath)) - - def cmd_usage(self): - """Print the usage text and exits""" - data={} - names = self.get_user_email_addresses() - data['fullname'] = names[0] if names[0] else 'My Name' - data['mailaddress'] = names[1] if names[1] else 'My@email.address' - print USAGE % data - - def cmd_new(self): - """Run 'notmuch new'""" - #get the database directory - db = Database(mode=Database.MODE.READ_WRITE) - path = db.get_path() - print self._add_new_files_recursively(path, db) - - def cmd_help(self, subcmd=None): - """Print help text for 'notmuch help'""" - if len(subcmd) > 1: - print "Help for specific commands not implemented" - return - print HELPTEXT - - def _get_user_notmuch_config(self): - """Returns the ConfigParser of the user's notmuch-config""" - # return the cached config parser if we read it already - if self._config: - return self._config - - config = SafeConfigParser() - config.read(self._configpath) - self._config = config - return config - - def _add_new_files_recursively(self, path, db): - """:returns: (added, moved, removed)""" - print "Enter add new files with path %s" % path - - try: - #get the Directory() object for this path - db_dir = db.get_directory(path) - added = moved = removed = 0 - except NotmuchError: - # Occurs if we have wrong absolute paths in the db, for example - return (0,0,0) - - - # for folder in subdirs: - - # TODO, retrieve dir mtime here and store it later - # as long as Filenames() does not allow multiple iteration, we need to - # use this kludgy way to get a sorted list of filenames - # db_files is a list of subdirectories and filenames in this folder - db_files = set() - db_folders = set() - for subdir in db_dir.get_child_directories(): - db_folders.add(subdir) -# file is a keyword (remove this ;)) - for mail in db_dir.get_child_files(): - db_files.add(mail) - - fs_files = set(os.listdir(db_dir.path)) - - # list of files (and folders) on the fs, but not the db - for fs_file in ((fs_files - db_files) - db_folders): - absfile = os.path.normpath(os.path.join(db_dir.path, fs_file)) - statinfo = os.stat(absfile) - - if stat.S_ISDIR(statinfo.st_mode): - # This is a directory - if fs_file in ['.notmuch','tmp','.']: - continue - print "%s %s" % (fs_file, db_folders) - print "Directory not in db yet. Descending into %s" % absfile - new = self._add_new_files_recursively(absfile, db) - added += new[0] - moved += new[1] - removed += new[2] - - elif stat.S_ISLNK(statinfo.st_mode): - print ("%s is a symbolic link (%d). FIXME!!!" % - (absfile, statinfo.st_mode)) - exit(1) - - else: - # This is a regular file, not in the db yet. Add it. - print "This file needs to be added %s" % (absfile) - (msg, status) = db.add_message(absfile) - # We increases 'added', even on dupe messages. If it is a moved - # message, we will deduct it later and increase 'moved' instead - added += 1 - - if status == STATUS.DUPLICATE_MESSAGE_ID: - print "Added msg was in the db" - else: - print "New message." - - # Finally a list of files (not folders) in the database, - # but not the filesystem - for db_file in (db_files - fs_files): - absfile = os.path.normpath(os.path.join(db_dir.path, db_file)) - - # remove a mail message from the db - print ("%s is not on the fs anymore. Delete" % absfile) - status = db.remove_message(absfile) - - if status == STATUS.SUCCESS: - # we just deleted the last reference, so this was a remove - removed += 1 - sys.stderr.write("SUCCESS %d %s %s.\n" % - (status, STATUS.status2str(status), absfile)) - elif status == STATUS.DUPLICATE_MESSAGE_ID: - # The filename exists already somewhere else, so this is a move - moved += 1 - added -= 1 - sys.stderr.write("DUPE %d %s %s.\n" % - (status, STATUS.status2str(status), absfile)) - else: - # This should not occur - sys.stderr.write("This should not occur %d %s %s.\n" % - (status, STATUS.status2str(status), absfile)) - - # list of folders in the filesystem. Just descend into dirs - for fs_file in fs_files: - absfile = os.path.normpath(os.path.join(db_dir.path, fs_file)) - if os.path.isdir(absfile): - # This is a directory. Remove it from the db_folder list. - # All remaining db_folders at the end will be not present - # on the file system. - db_folders.remove(fs_file) - if fs_file in ['.notmuch','tmp','.']: - continue - new = self._add_new_files_recursively(absfile, db) - added += new[0] - moved += new[0] - removed += new[0] - - # we are not interested in anything but directories here - #TODO: All remaining elements of db_folders are not in the filesystem - #delete those - - return added, moved, removed - #Read the mtime of a directory from the filesystem - # - #* Call :meth:`Database.add_message` for all mail files in - # the directory - - #* Call notmuch_directory_set_mtime with the mtime read from the - # filesystem. Then, when wanting to check for updates to the - # directory in the future, the client can call :meth:`get_mtime` - # and know that it only needs to add files if the mtime of the - # directory and files are newer than the stored timestamp. - - def get_user_email_addresses(self): - """ Reads a user's notmuch config and returns his email addresses as - list (name, primary_address, other_address1,...)""" - - #read the config file - config = self._get_user_notmuch_config() - - conf = {'name': '', 'primary_email': ''} - for entry in conf: - if config.has_option('user', entry): - conf[entry] = config.get('user', entry) - - if config.has_option('user','other_email'): - other = config.get('user','other_email') - other = [mail.strip() for mail in other.split(';') if mail] - else: - other = [] - # for being compatible. It would be nicer to return a dict. - return conf.keys() + other - - def quote_msg_body(self, oldbody ,date, from_address): - """Transform a mail body into a quoted text, - starting with On foo, bar wrote: - - :param body: a str with a mail body - :returns: The new payload of the email.message() - """ - - # we get handed a string, wrap it in a file-like object - oldbody = StringIO(oldbody) - newbody = StringIO() - - newbody.write("On %s, %s wrote:\n" % (date, from_address)) - - for line in oldbody: - newbody.write("> " + line) - - return newbody.getvalue() - - def format_reply(self, msgs): - """Gets handed Messages() and displays the reply to them - - This is pretty ugly and hacky. It tries to mimic the "real" - notmuch output as much as it can to pass the test suite. It - could deserve a healthy bit of love. It is also buggy because - it returns after the first message it has handled.""" - - for msg in msgs: - f = open(msg.get_filename(), "r") - reply = email.message_from_file(f) - - # handle the easy non-multipart case: - if not reply.is_multipart(): - reply.set_payload(self.quote_msg_body(reply.get_payload(), - reply['date'], reply['from'])) - else: - # handle the tricky multipart case - deleted = "" - """A string describing which nontext attachements - that have been deleted""" - delpayloads = [] - """A list of payload indices to be deleted""" - payloads = reply.get_payload() - - for (num, part) in enumerate(payloads): - mime_main = part.get_content_maintype() - if mime_main not in ['multipart', 'message', 'text']: - deleted += "Non-text part: %s\n" % (part.get_content_type()) - payloads[num].set_payload("Non-text part: %s" % - (part.get_content_type())) - payloads[num].set_type('text/plain') - delpayloads.append(num) - elif mime_main == 'text': - payloads[num].set_payload(self.quote_msg_body( - payloads[num].get_payload(), - reply['date'], reply['from'])) - else: - # TODO handle deeply nested multipart messages - sys.stderr.write ("FIXME: Ignoring multipart part. Handle me\n") - # Delete those payloads that we don't need anymore - for item in reversed(sorted(delpayloads)): - del payloads[item] - - # Back to single- and multipart handling - my_addresses = self.get_user_email_addresses() - used_address = None - # filter our email addresses from all to: cc: and bcc: fields - # if we find one of "my" addresses being used, - # it is stored in used_address - for header in ['To', 'CC', 'Bcc']: - if not header in reply: - #only handle fields that exist - continue - addresses = email.utils.getaddresses(reply.get_all(header, [])) - purged_addr = [] - for (name, mail) in addresses: - if mail in my_addresses[1:]: - used_address = email.utils.formataddr( - (my_addresses[0], mail)) - else: - purged_addr.append(email.utils.formataddr((name, mail))) - - if purged_addr: - reply.replace_header(header, ", ".join(purged_addr)) - else: - # we deleted all addresses, delete the header - del reply[header] - - # Use our primary email address to the From - # (save original from line, we still need it) - new_to = reply['From'] - if used_address: - reply['From'] = used_address - else: - email.utils.formataddr((my_addresses[0], my_addresses[1])) - - reply['Subject'] = 'Re: ' + reply['Subject'] - - # Calculate our new To: field - # add all remaining original 'To' addresses - if 'To' in reply: - new_to += ", " + reply['To'] - reply.add_header('To', new_to) - - # Add our primary email address to the BCC - new_bcc = my_addresses[1] - if 'Bcc' in reply: - new_bcc += ', ' + reply['Bcc'] - reply['Bcc'] = new_bcc - - # Set replies 'In-Reply-To' header to original's Message-ID - if 'Message-ID' in reply: - reply['In-Reply-To'] = reply['Message-ID'] - - #Add original's Message-ID to replies 'References' header. - if 'References' in reply: - reply['References'] = ' '.join([reply['References'], reply['Message-ID']]) - else: - reply['References'] = reply['Message-ID'] - - # Delete the original Message-ID. - del(reply['Message-ID']) - - # filter all existing headers but a few and delete them from 'reply' - delheaders = filter(lambda x: x not in ['From', 'To', 'Subject', 'CC', - 'Bcc', 'In-Reply-To', - 'References', 'Content-Type'], - reply.keys()) - map(reply.__delitem__, delheaders) - - # TODO: OUCH, we return after the first msg we have handled rather than - # handle all of them - # return resulting message without Unixfrom - return reply.as_string(False) - - -def main(): - # Handle command line options - #------------------------------------ - # No option given, print USAGE and exit - if len(sys.argv) == 1: - Notmuch().cmd_usage() - #------------------------------------ - elif sys.argv[1] == 'setup': - """Interactively setup notmuch for first use.""" - exit("Not implemented.") - #------------------------------------- - elif sys.argv[1] == 'new': - """Check for new and removed messages.""" - Notmuch().cmd_new() - #------------------------------------- - elif sys.argv[1] == 'help': - """Print the help text""" - Notmuch().cmd_help(sys.argv[1:]) - #------------------------------------- - elif sys.argv[1] == 'part': - part() - #------------------------------------- - elif sys.argv[1] == 'search': - search() - #------------------------------------- - elif sys.argv[1] == 'show': - show() - #------------------------------------- - elif sys.argv[1] == 'reply': - db = Database() - if len(sys.argv) == 2: - # no search term. abort - exit("Error: notmuch reply requires at least one search term.") - # mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[2:]) - msgs = Query(db, querystr).search_messages() - print Notmuch().format_reply(msgs) - #------------------------------------- - elif sys.argv[1] == 'count': - if len(sys.argv) == 2: - # no further search term, count all - querystr = '' - else: - # mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[2:]) - print Database().create_query(querystr).count_messages() - #------------------------------------- - elif sys.argv[1] == 'tag': - # build lists of tags to be added and removed - add = [] - remove = [] - while not sys.argv[2] == '--' and \ - (sys.argv[2].startswith('+') or sys.argv[2].startswith('-')): - if sys.argv[2].startswith('+'): - # append to add list without initial + - add.append(sys.argv.pop(2)[1:]) - else: - # append to remove list without initial - - remove.append(sys.argv.pop(2)[1:]) - # skip eventual '--' - if sys.argv[2] == '--': sys.argv.pop(2) - # the rest is search terms - querystr = quote_query_line(sys.argv[2:]) - db = Database(mode=Database.MODE.READ_WRITE) - msgs = Query(db, querystr).search_messages() - for msg in msgs: - # actually add and remove all tags - map(msg.add_tag, add) - map(msg.remove_tag, remove) - #------------------------------------- - elif sys.argv[1] == 'search-tags': - if len(sys.argv) == 2: - # no further search term - print "\n".join(Database().get_all_tags()) - else: - # mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[2:]) - db = Database() - msgs = Query(db, querystr).search_messages() - print "\n".join([t for t in msgs.collect_tags()]) - #------------------------------------- - elif sys.argv[1] == 'dump': - # TODO: implement "dump " - if len(sys.argv) == 2: - f = sys.stdout - else: - f = open(sys.argv[2], "w") - db = Database() - query = Query(db, '') - query.set_sort(Query.SORT.MESSAGE_ID) - msgs = query.search_messages() - for msg in msgs: - f.write("%s (%s)\n" % (msg.get_message_id(), msg.get_tags())) - #------------------------------------- - elif sys.argv[1] == 'restore': - if len(sys.argv) == 2: - print("No filename given. Reading dump from stdin.") - f = sys.stdin - else: - f = open(sys.argv[2], "r") - - # split the msg id and the tags - MSGID_TAGS = re.compile("(\S+)\s\((.*)\)$") - db = Database(mode=Database.MODE.READ_WRITE) - - #read each line of the dump file - for line in f: - msgs = MSGID_TAGS.match(line) - if not msgs: - sys.stderr.write("Warning: Ignoring invalid input line: %s" % - line) - continue - # split line in components and fetch message - msg_id = msgs.group(1) - new_tags = set(msgs.group(2).split()) - msg = db.find_message(msg_id) - - if msg == None: - sys.stderr.write( - "Warning: Cannot apply tags to missing message: %s\n" % msg_id) - continue - - # do nothing if the old set of tags is the same as the new one - old_tags = set(msg.get_tags()) - if old_tags == new_tags: continue - - # set the new tags - msg.freeze() - # only remove tags if the new ones are not a superset anyway - if not (new_tags > old_tags): msg.remove_all_tags() - for tag in new_tags: msg.add_tag(tag) - msg.thaw() - #------------------------------------- - else: - # unknown command - exit("Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1]) - -def part(): - db = Database() - query_string = '' - part_num = 0 - first_search_term = 0 - for (num, arg) in enumerate(sys.argv[1:]): - if arg.startswith('--part='): - part_num_str = arg.split("=")[1] - try: - part_num = int(part_num_str) - except ValueError: - # just emulating behavior - exit(1) - elif not arg.startswith('--'): - # save the position of the first sys.argv - # that is a search term - first_search_term = num + 1 - if first_search_term: - # mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[first_search_term:]) - qry = Query(db,querystr) - msgs = [msg for msg in qry.search_messages()] - - if not msgs: - sys.exit(1) - elif len(msgs) > 1: - raise Exception("search term did not match precisely one message") - else: - msg = msgs[0] - print msg.get_part(part_num) - -def search(): - db = Database() - query_string = '' - sort_order = "newest-first" - first_search_term = 0 - for (num, arg) in enumerate(sys.argv[1:]): - if arg.startswith('--sort='): - sort_order=arg.split("=")[1] - if not sort_order in ("oldest-first", "newest-first"): - raise Exception("unknown sort order") - elif not arg.startswith('--'): - # save the position of the first sys.argv that is a search term - first_search_term = num + 1 - - if first_search_term: - # mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[first_search_term:]) - - qry = Query(db, querystr) - if sort_order == "oldest-first": - qry.set_sort(Query.SORT.OLDEST_FIRST) - else: - qry.set_sort(Query.SORT.NEWEST_FIRST) - threads = qry.search_threads() - - for thread in threads: - print thread - -def show(): - entire_thread = False - db = Database() - out_format = "text" - querystr = '' - first_search_term = None - - # ugly homegrown option parsing - # TODO: use OptionParser - for (num, arg) in enumerate(sys.argv[1:]): - if arg == '--entire-thread': - entire_thread = True - elif arg.startswith("--format="): - out_format = arg.split("=")[1] - if out_format == 'json': - # for compatibility use --entire-thread for json - entire_thread = True - if not out_format in ("json", "text"): - raise Exception("unknown format") - elif not arg.startswith('--'): - # save the position of the first sys.argv that is a search term - first_search_term = num + 1 - - if first_search_term: - # mangle arguments wrapping terms with spaces in quotes - querystr = quote_query_line(sys.argv[first_search_term:]) - - threads = Query(db, querystr).search_threads() - first_toplevel = True - if out_format == "json": - sys.stdout.write("[") - for thread in threads: - msgs = thread.get_toplevel_messages() - if not first_toplevel: - if out_format == "json": - sys.stdout.write(", ") - first_toplevel = False - msgs.print_messages(out_format, 0, entire_thread) - - if out_format == "json": - sys.stdout.write("]") - sys.stdout.write("\n") - -if __name__ == '__main__': - main() - -# TODO: implement -""" -setup (not?) -new (halfway there) -""" diff --git a/cnotmuch/__init__.py b/notmuch/__init__.py similarity index 100% rename from cnotmuch/__init__.py rename to notmuch/__init__.py diff --git a/cnotmuch/database.py b/notmuch/database.py similarity index 97% rename from cnotmuch/database.py rename to notmuch/database.py index fd2d9a99..f02ec408 100644 --- a/cnotmuch/database.py +++ b/notmuch/database.py @@ -1,3 +1,22 @@ +""" +This file is part of notmuch. + +Notmuch is free software: 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. + +Notmuch is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with notmuch. If not, see . + +Copyright 2010 Sebastian Spaeth ' +""" + import os from ctypes import c_int, c_char_p, c_void_p, c_uint, c_long, byref from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum diff --git a/cnotmuch/globals.py b/notmuch/globals.py similarity index 74% rename from cnotmuch/globals.py rename to notmuch/globals.py index fa20ae81..8b0d8d0b 100644 --- a/cnotmuch/globals.py +++ b/notmuch/globals.py @@ -1,3 +1,22 @@ +""" +This file is part of notmuch. + +Notmuch is free software: 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. + +Notmuch is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with notmuch. If not, see . + +Copyright 2010 Sebastian Spaeth ' +""" + from ctypes import CDLL, c_char_p, c_int from ctypes.util import find_library diff --git a/cnotmuch/message.py b/notmuch/message.py similarity index 97% rename from cnotmuch/message.py rename to notmuch/message.py index 0e5057f4..f8d7d5ec 100644 --- a/cnotmuch/message.py +++ b/notmuch/message.py @@ -1,20 +1,23 @@ -# This file is part of cnotmuch. -# -# cnotmuch is free software: 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. -# -# cnotmuch is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with cnotmuch. If not, see . -# -# (C) Copyright 2010 Sebastian Spaeth -# Jesse Rosenthal +""" +This file is part of notmuch. + +Notmuch is free software: 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. + +Notmuch is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with notmuch. If not, see . + +Copyright 2010 Sebastian Spaeth ' + Jesse Rosenthal +""" + from ctypes import c_char_p, c_void_p, c_long, c_uint from datetime import date diff --git a/cnotmuch/notmuch.py b/notmuch/notmuch.py similarity index 71% rename from cnotmuch/notmuch.py rename to notmuch/notmuch.py index f6a9a232..513209d5 100644 --- a/cnotmuch/notmuch.py +++ b/notmuch/notmuch.py @@ -32,6 +32,25 @@ :class:`Message`, and :class:`Tags`. """ + +""" +This file is part of notmuch. + +Notmuch is free software: 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. + +Notmuch is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with notmuch. If not, see . + +Copyright 2010 Sebastian Spaeth ' +""" from database import Database, Query from message import Messages, Message from thread import Threads, Thread diff --git a/cnotmuch/tag.py b/notmuch/tag.py similarity index 85% rename from cnotmuch/tag.py rename to notmuch/tag.py index 00898ef9..c89e4feb 100644 --- a/cnotmuch/tag.py +++ b/notmuch/tag.py @@ -1,3 +1,21 @@ +""" +This file is part of notmuch. + +Notmuch is free software: 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. + +Notmuch is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with notmuch. If not, see . + +Copyright 2010 Sebastian Spaeth ' +""" from ctypes import c_char_p from cnotmuch.globals import nmlib, STATUS, NotmuchError diff --git a/cnotmuch/thread.py b/notmuch/thread.py similarity index 95% rename from cnotmuch/thread.py rename to notmuch/thread.py index 5a2505c5..19bef1a9 100644 --- a/cnotmuch/thread.py +++ b/notmuch/thread.py @@ -1,3 +1,22 @@ +""" +This file is part of notmuch. + +Notmuch is free software: 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. + +Notmuch is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with notmuch. If not, see . + +Copyright 2010 Sebastian Spaeth ' +""" + from ctypes import c_char_p, c_void_p, c_long from cnotmuch.globals import nmlib, STATUS, NotmuchError from cnotmuch.message import Messages diff --git a/setup.py b/setup.py index 256fd4e5..7a4b43f5 100644 --- a/setup.py +++ b/setup.py @@ -1,20 +1,20 @@ #!/usr/bin/env python from distutils.core import setup -from cnotmuch.notmuch import __VERSION__ -setup(name='cnotmuch', +from notmuch import __VERSION__ +setup(name='notmuch', version=__VERSION__, description='Python binding of the notmuch mail search and indexing library.', author='Sebastian Spaeth', author_email='Sebastian@SSpaeth.de', url='http://bitbucket.org/spaetz/cnotmuch/', download_url='http://bitbucket.org/spaetz/cnotmuch/get/v'+__VERSION__+'.tar.gz', - packages=['cnotmuch'], + packages=['notmuch'], keywords = ["library", "email"], long_description="""Overview ============== -The cnotmuch module provides an interface to the `notmuch `_ functionality, directly interfacing with a shared notmuch library. Notmuch provides a maildatabase that allows for extremely quick searching and filtering of your email according to various criteria. +The notmuch module provides an interface to the `notmuch `_ functionality, directly interfacing with a shared notmuch library. Notmuch provides a maildatabase that allows for extremely quick searching and filtering of your email according to various criteria. The documentation for the latest cnotmuch release can be `viewed online `_. From d0b00bc4b1d3f6246420e4b54627504b26073ff5 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Mon, 19 Apr 2010 21:21:38 +0200 Subject: [PATCH 101/106] rename module to just 'notmuch' So you need to do e.g. 'from notmuch import Database' rather than the previous from cnotmuch.notmuch import Database. Alse recreate our fake python 'notmuch' binary in the docs directory for now --HG-- rename : notmuch/notmuch.py => notmuch/__init__.py --- docs/notmuch | 647 ++++++++++++++++++++++++++++++++++++++++++++ notmuch/__init__.py | 61 +++++ notmuch/notmuch.py | 61 ----- 3 files changed, 708 insertions(+), 61 deletions(-) create mode 100755 docs/notmuch delete mode 100644 notmuch/notmuch.py diff --git a/docs/notmuch b/docs/notmuch new file mode 100755 index 00000000..85726129 --- /dev/null +++ b/docs/notmuch @@ -0,0 +1,647 @@ +#!/usr/bin/env python +"""This is a notmuch implementation in python. +It's goal is to allow running the test suite on the cnotmuch python bindings. + +This "binary" honors the NOTMUCH_CONFIG environmen variable for reading a user's +notmuch configuration (e.g. the database path). + + (c) 2010 by Sebastian Spaeth + Jesse Rosenthal + This code is licensed under the GNU GPL v3+. +""" +import sys +import os + +import re +import stat +import email + +from notmuch import Database, Query, NotmuchError, STATUS +from ConfigParser import SafeConfigParser +from cStringIO import StringIO + +PREFIX = re.compile('(\w+):(.*$)') + +HELPTEXT = """The notmuch mail system. +Usage: notmuch [args...] + +Where and [args...] are as follows: + setup Interactively setup notmuch for first use. + new [--verbose] + Find and import new messages to the notmuch database. + search [options...] [...] + Search for messages matching the given search terms. + show [...] + Show all messages matching the search terms. + count [...] + Count messages matching the search terms. + reply [options...] [...] + Construct a reply template for a set of messages. + tag +|- [...] [--] [...] + Add/remove tags for all messages matching the search terms. + dump [] + Create a plain-text dump of the tags for each message. + restore + Restore the tags from the given dump file (see 'dump'). + search-tags [ [...] ] + List all tags found in the database or matching messages. + help [] + This message, or more detailed help for the named command. + +Use "notmuch help " for more details on each command. +And "notmuch help search-terms" for the common search-terms syntax. +""" + +USAGE = """Notmuch is configured and appears to have a database. Excellent! + +At this point you can start exploring the functionality of notmuch by +using commands such as: + notmuch search tag:inbox + notmuch search to:"%(fullname)s" + notmuch search from:"%(mailaddress)s" + notmuch search subject:"my favorite things" + +See "notmuch help search" for more details. + +You can also use "notmuch show" with any of the thread IDs resulting +from a search. Finally, you may want to explore using a more sophisticated +interface to notmuch such as the emacs interface implemented in notmuch.el +or any other interface described at http://notmuchmail.org + +And don't forget to run "notmuch new" whenever new mail arrives. + +Have fun, and may your inbox never have much mail. +""" + +#------------------------------------------------------------------------- +def quote_query_line(argv): + # mangle arguments wrapping terms with spaces in quotes + for (num, item) in enumerate(argv): + if item.find(' ') >= 0: + # if we use prefix:termWithSpaces, put quotes around term + match = PREFIX.match(item) + if match: + argv[num] = '%s:"%s"' %(match.group(1), match.group(2)) + else: + argv[num] = '"%s"' % item + return ' '.join(argv) + +#------------------------------------------------------------------------- + + +class Notmuch(object): + + def __init__(self, configpath="~/.notmuch-config)"): + self._config = None + self._configpath = os.getenv('NOTMUCH_CONFIG', + os.path.expanduser(configpath)) + + def cmd_usage(self): + """Print the usage text and exits""" + data={} + names = self.get_user_email_addresses() + data['fullname'] = names[0] if names[0] else 'My Name' + data['mailaddress'] = names[1] if names[1] else 'My@email.address' + print USAGE % data + + def cmd_new(self): + """Run 'notmuch new'""" + #get the database directory + db = Database(mode=Database.MODE.READ_WRITE) + path = db.get_path() + print self._add_new_files_recursively(path, db) + + def cmd_help(self, subcmd=None): + """Print help text for 'notmuch help'""" + if len(subcmd) > 1: + print "Help for specific commands not implemented" + return + print HELPTEXT + + def _get_user_notmuch_config(self): + """Returns the ConfigParser of the user's notmuch-config""" + # return the cached config parser if we read it already + if self._config: + return self._config + + config = SafeConfigParser() + config.read(self._configpath) + self._config = config + return config + + def _add_new_files_recursively(self, path, db): + """:returns: (added, moved, removed)""" + print "Enter add new files with path %s" % path + + try: + #get the Directory() object for this path + db_dir = db.get_directory(path) + added = moved = removed = 0 + except NotmuchError: + # Occurs if we have wrong absolute paths in the db, for example + return (0,0,0) + + + # for folder in subdirs: + + # TODO, retrieve dir mtime here and store it later + # as long as Filenames() does not allow multiple iteration, we need to + # use this kludgy way to get a sorted list of filenames + # db_files is a list of subdirectories and filenames in this folder + db_files = set() + db_folders = set() + for subdir in db_dir.get_child_directories(): + db_folders.add(subdir) +# file is a keyword (remove this ;)) + for mail in db_dir.get_child_files(): + db_files.add(mail) + + fs_files = set(os.listdir(db_dir.path)) + + # list of files (and folders) on the fs, but not the db + for fs_file in ((fs_files - db_files) - db_folders): + absfile = os.path.normpath(os.path.join(db_dir.path, fs_file)) + statinfo = os.stat(absfile) + + if stat.S_ISDIR(statinfo.st_mode): + # This is a directory + if fs_file in ['.notmuch','tmp','.']: + continue + print "%s %s" % (fs_file, db_folders) + print "Directory not in db yet. Descending into %s" % absfile + new = self._add_new_files_recursively(absfile, db) + added += new[0] + moved += new[1] + removed += new[2] + + elif stat.S_ISLNK(statinfo.st_mode): + print ("%s is a symbolic link (%d). FIXME!!!" % + (absfile, statinfo.st_mode)) + exit(1) + + else: + # This is a regular file, not in the db yet. Add it. + print "This file needs to be added %s" % (absfile) + (msg, status) = db.add_message(absfile) + # We increases 'added', even on dupe messages. If it is a moved + # message, we will deduct it later and increase 'moved' instead + added += 1 + + if status == STATUS.DUPLICATE_MESSAGE_ID: + print "Added msg was in the db" + else: + print "New message." + + # Finally a list of files (not folders) in the database, + # but not the filesystem + for db_file in (db_files - fs_files): + absfile = os.path.normpath(os.path.join(db_dir.path, db_file)) + + # remove a mail message from the db + print ("%s is not on the fs anymore. Delete" % absfile) + status = db.remove_message(absfile) + + if status == STATUS.SUCCESS: + # we just deleted the last reference, so this was a remove + removed += 1 + sys.stderr.write("SUCCESS %d %s %s.\n" % + (status, STATUS.status2str(status), absfile)) + elif status == STATUS.DUPLICATE_MESSAGE_ID: + # The filename exists already somewhere else, so this is a move + moved += 1 + added -= 1 + sys.stderr.write("DUPE %d %s %s.\n" % + (status, STATUS.status2str(status), absfile)) + else: + # This should not occur + sys.stderr.write("This should not occur %d %s %s.\n" % + (status, STATUS.status2str(status), absfile)) + + # list of folders in the filesystem. Just descend into dirs + for fs_file in fs_files: + absfile = os.path.normpath(os.path.join(db_dir.path, fs_file)) + if os.path.isdir(absfile): + # This is a directory. Remove it from the db_folder list. + # All remaining db_folders at the end will be not present + # on the file system. + db_folders.remove(fs_file) + if fs_file in ['.notmuch','tmp','.']: + continue + new = self._add_new_files_recursively(absfile, db) + added += new[0] + moved += new[0] + removed += new[0] + + # we are not interested in anything but directories here + #TODO: All remaining elements of db_folders are not in the filesystem + #delete those + + return added, moved, removed + #Read the mtime of a directory from the filesystem + # + #* Call :meth:`Database.add_message` for all mail files in + # the directory + + #* Call notmuch_directory_set_mtime with the mtime read from the + # filesystem. Then, when wanting to check for updates to the + # directory in the future, the client can call :meth:`get_mtime` + # and know that it only needs to add files if the mtime of the + # directory and files are newer than the stored timestamp. + + def get_user_email_addresses(self): + """ Reads a user's notmuch config and returns his email addresses as + list (name, primary_address, other_address1,...)""" + + #read the config file + config = self._get_user_notmuch_config() + + conf = {'name': '', 'primary_email': ''} + for entry in conf: + if config.has_option('user', entry): + conf[entry] = config.get('user', entry) + + if config.has_option('user','other_email'): + other = config.get('user','other_email') + other = [mail.strip() for mail in other.split(';') if mail] + else: + other = [] + # for being compatible. It would be nicer to return a dict. + return conf.keys() + other + + def quote_msg_body(self, oldbody ,date, from_address): + """Transform a mail body into a quoted text, + starting with On foo, bar wrote: + + :param body: a str with a mail body + :returns: The new payload of the email.message() + """ + + # we get handed a string, wrap it in a file-like object + oldbody = StringIO(oldbody) + newbody = StringIO() + + newbody.write("On %s, %s wrote:\n" % (date, from_address)) + + for line in oldbody: + newbody.write("> " + line) + + return newbody.getvalue() + + def format_reply(self, msgs): + """Gets handed Messages() and displays the reply to them + + This is pretty ugly and hacky. It tries to mimic the "real" + notmuch output as much as it can to pass the test suite. It + could deserve a healthy bit of love. It is also buggy because + it returns after the first message it has handled.""" + + for msg in msgs: + f = open(msg.get_filename(), "r") + reply = email.message_from_file(f) + + # handle the easy non-multipart case: + if not reply.is_multipart(): + reply.set_payload(self.quote_msg_body(reply.get_payload(), + reply['date'], reply['from'])) + else: + # handle the tricky multipart case + deleted = "" + """A string describing which nontext attachements + that have been deleted""" + delpayloads = [] + """A list of payload indices to be deleted""" + payloads = reply.get_payload() + + for (num, part) in enumerate(payloads): + mime_main = part.get_content_maintype() + if mime_main not in ['multipart', 'message', 'text']: + deleted += "Non-text part: %s\n" % (part.get_content_type()) + payloads[num].set_payload("Non-text part: %s" % + (part.get_content_type())) + payloads[num].set_type('text/plain') + delpayloads.append(num) + elif mime_main == 'text': + payloads[num].set_payload(self.quote_msg_body( + payloads[num].get_payload(), + reply['date'], reply['from'])) + else: + # TODO handle deeply nested multipart messages + sys.stderr.write ("FIXME: Ignoring multipart part. Handle me\n") + # Delete those payloads that we don't need anymore + for item in reversed(sorted(delpayloads)): + del payloads[item] + + # Back to single- and multipart handling + my_addresses = self.get_user_email_addresses() + used_address = None + # filter our email addresses from all to: cc: and bcc: fields + # if we find one of "my" addresses being used, + # it is stored in used_address + for header in ['To', 'CC', 'Bcc']: + if not header in reply: + #only handle fields that exist + continue + addresses = email.utils.getaddresses(reply.get_all(header, [])) + purged_addr = [] + for (name, mail) in addresses: + if mail in my_addresses[1:]: + used_address = email.utils.formataddr( + (my_addresses[0], mail)) + else: + purged_addr.append(email.utils.formataddr((name, mail))) + + if purged_addr: + reply.replace_header(header, ", ".join(purged_addr)) + else: + # we deleted all addresses, delete the header + del reply[header] + + # Use our primary email address to the From + # (save original from line, we still need it) + new_to = reply['From'] + if used_address: + reply['From'] = used_address + else: + email.utils.formataddr((my_addresses[0], my_addresses[1])) + + reply['Subject'] = 'Re: ' + reply['Subject'] + + # Calculate our new To: field + # add all remaining original 'To' addresses + if 'To' in reply: + new_to += ", " + reply['To'] + reply.add_header('To', new_to) + + # Add our primary email address to the BCC + new_bcc = my_addresses[1] + if 'Bcc' in reply: + new_bcc += ', ' + reply['Bcc'] + reply['Bcc'] = new_bcc + + # Set replies 'In-Reply-To' header to original's Message-ID + if 'Message-ID' in reply: + reply['In-Reply-To'] = reply['Message-ID'] + + #Add original's Message-ID to replies 'References' header. + if 'References' in reply: + reply['References'] = ' '.join([reply['References'], reply['Message-ID']]) + else: + reply['References'] = reply['Message-ID'] + + # Delete the original Message-ID. + del(reply['Message-ID']) + + # filter all existing headers but a few and delete them from 'reply' + delheaders = filter(lambda x: x not in ['From', 'To', 'Subject', 'CC', + 'Bcc', 'In-Reply-To', + 'References', 'Content-Type'], + reply.keys()) + map(reply.__delitem__, delheaders) + + # TODO: OUCH, we return after the first msg we have handled rather than + # handle all of them + # return resulting message without Unixfrom + return reply.as_string(False) + + +def main(): + # Handle command line options + #------------------------------------ + # No option given, print USAGE and exit + if len(sys.argv) == 1: + Notmuch().cmd_usage() + #------------------------------------ + elif sys.argv[1] == 'setup': + """Interactively setup notmuch for first use.""" + exit("Not implemented.") + #------------------------------------- + elif sys.argv[1] == 'new': + """Check for new and removed messages.""" + Notmuch().cmd_new() + #------------------------------------- + elif sys.argv[1] == 'help': + """Print the help text""" + Notmuch().cmd_help(sys.argv[1:]) + #------------------------------------- + elif sys.argv[1] == 'part': + part() + #------------------------------------- + elif sys.argv[1] == 'search': + search() + #------------------------------------- + elif sys.argv[1] == 'show': + show() + #------------------------------------- + elif sys.argv[1] == 'reply': + db = Database() + if len(sys.argv) == 2: + # no search term. abort + exit("Error: notmuch reply requires at least one search term.") + # mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[2:]) + msgs = Query(db, querystr).search_messages() + print Notmuch().format_reply(msgs) + #------------------------------------- + elif sys.argv[1] == 'count': + if len(sys.argv) == 2: + # no further search term, count all + querystr = '' + else: + # mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[2:]) + print Database().create_query(querystr).count_messages() + #------------------------------------- + elif sys.argv[1] == 'tag': + # build lists of tags to be added and removed + add = [] + remove = [] + while not sys.argv[2] == '--' and \ + (sys.argv[2].startswith('+') or sys.argv[2].startswith('-')): + if sys.argv[2].startswith('+'): + # append to add list without initial + + add.append(sys.argv.pop(2)[1:]) + else: + # append to remove list without initial - + remove.append(sys.argv.pop(2)[1:]) + # skip eventual '--' + if sys.argv[2] == '--': sys.argv.pop(2) + # the rest is search terms + querystr = quote_query_line(sys.argv[2:]) + db = Database(mode=Database.MODE.READ_WRITE) + msgs = Query(db, querystr).search_messages() + for msg in msgs: + # actually add and remove all tags + map(msg.add_tag, add) + map(msg.remove_tag, remove) + #------------------------------------- + elif sys.argv[1] == 'search-tags': + if len(sys.argv) == 2: + # no further search term + print "\n".join(Database().get_all_tags()) + else: + # mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[2:]) + db = Database() + msgs = Query(db, querystr).search_messages() + print "\n".join([t for t in msgs.collect_tags()]) + #------------------------------------- + elif sys.argv[1] == 'dump': + # TODO: implement "dump " + if len(sys.argv) == 2: + f = sys.stdout + else: + f = open(sys.argv[2], "w") + db = Database() + query = Query(db, '') + query.set_sort(Query.SORT.MESSAGE_ID) + msgs = query.search_messages() + for msg in msgs: + f.write("%s (%s)\n" % (msg.get_message_id(), msg.get_tags())) + #------------------------------------- + elif sys.argv[1] == 'restore': + if len(sys.argv) == 2: + print("No filename given. Reading dump from stdin.") + f = sys.stdin + else: + f = open(sys.argv[2], "r") + + # split the msg id and the tags + MSGID_TAGS = re.compile("(\S+)\s\((.*)\)$") + db = Database(mode=Database.MODE.READ_WRITE) + + #read each line of the dump file + for line in f: + msgs = MSGID_TAGS.match(line) + if not msgs: + sys.stderr.write("Warning: Ignoring invalid input line: %s" % + line) + continue + # split line in components and fetch message + msg_id = msgs.group(1) + new_tags = set(msgs.group(2).split()) + msg = db.find_message(msg_id) + + if msg == None: + sys.stderr.write( + "Warning: Cannot apply tags to missing message: %s\n" % msg_id) + continue + + # do nothing if the old set of tags is the same as the new one + old_tags = set(msg.get_tags()) + if old_tags == new_tags: continue + + # set the new tags + msg.freeze() + # only remove tags if the new ones are not a superset anyway + if not (new_tags > old_tags): msg.remove_all_tags() + for tag in new_tags: msg.add_tag(tag) + msg.thaw() + #------------------------------------- + else: + # unknown command + exit("Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1]) + +def part(): + db = Database() + query_string = '' + part_num = 0 + first_search_term = 0 + for (num, arg) in enumerate(sys.argv[1:]): + if arg.startswith('--part='): + part_num_str = arg.split("=")[1] + try: + part_num = int(part_num_str) + except ValueError: + # just emulating behavior + exit(1) + elif not arg.startswith('--'): + # save the position of the first sys.argv + # that is a search term + first_search_term = num + 1 + if first_search_term: + # mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[first_search_term:]) + qry = Query(db,querystr) + msgs = [msg for msg in qry.search_messages()] + + if not msgs: + sys.exit(1) + elif len(msgs) > 1: + raise Exception("search term did not match precisely one message") + else: + msg = msgs[0] + print msg.get_part(part_num) + +def search(): + db = Database() + query_string = '' + sort_order = "newest-first" + first_search_term = 0 + for (num, arg) in enumerate(sys.argv[1:]): + if arg.startswith('--sort='): + sort_order=arg.split("=")[1] + if not sort_order in ("oldest-first", "newest-first"): + raise Exception("unknown sort order") + elif not arg.startswith('--'): + # save the position of the first sys.argv that is a search term + first_search_term = num + 1 + + if first_search_term: + # mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[first_search_term:]) + + qry = Query(db, querystr) + if sort_order == "oldest-first": + qry.set_sort(Query.SORT.OLDEST_FIRST) + else: + qry.set_sort(Query.SORT.NEWEST_FIRST) + threads = qry.search_threads() + + for thread in threads: + print thread + +def show(): + entire_thread = False + db = Database() + out_format = "text" + querystr = '' + first_search_term = None + + # ugly homegrown option parsing + # TODO: use OptionParser + for (num, arg) in enumerate(sys.argv[1:]): + if arg == '--entire-thread': + entire_thread = True + elif arg.startswith("--format="): + out_format = arg.split("=")[1] + if out_format == 'json': + # for compatibility use --entire-thread for json + entire_thread = True + if not out_format in ("json", "text"): + raise Exception("unknown format") + elif not arg.startswith('--'): + # save the position of the first sys.argv that is a search term + first_search_term = num + 1 + + if first_search_term: + # mangle arguments wrapping terms with spaces in quotes + querystr = quote_query_line(sys.argv[first_search_term:]) + + threads = Query(db, querystr).search_threads() + first_toplevel = True + if out_format == "json": + sys.stdout.write("[") + for thread in threads: + msgs = thread.get_toplevel_messages() + if not first_toplevel: + if out_format == "json": + sys.stdout.write(", ") + first_toplevel = False + msgs.print_messages(out_format, 0, entire_thread) + + if out_format == "json": + sys.stdout.write("]") + sys.stdout.write("\n") + +if __name__ == '__main__': + main() diff --git a/notmuch/__init__.py b/notmuch/__init__.py index e69de29b..513209d5 100644 --- a/notmuch/__init__.py +++ b/notmuch/__init__.py @@ -0,0 +1,61 @@ +"""The :mod:`notmuch` module provides most of the functionality that a user is likely to need. + +.. note:: The underlying notmuch library is build on a hierarchical + memory allocator called talloc. All objects derive from a + top-level :class:`Database` object. + + This means that as soon as an object is deleted, all underlying + derived objects such as Queries, Messages, Message, and Tags will + be freed by the underlying library as well. Accessing these + objects will then lead to segfaults and other unexpected behavior. + + We implement reference counting, so that parent objects can be + automatically freed when they are not needed anymore. For + example:: + + db = Database('path',create=True) + msgs = Query(db,'from:myself').search_messages() + + This returns a :class:`Messages` which internally contains a + reference to its parent :class:`Query` object. Otherwise the + Query() would be immediately freed, taking our *msgs* down with + it. + + In this case, the above Query() object will be automatically freed + whenever we delete all derived objects, ie in our case: + `del(msgs)` would also delete the parent Query. It would not + delete the parent Database() though, as that is still referenced + from the variable *db* in which it is stored. + + Pretty much the same is valid for all other objects in the + hierarchy, such as :class:`Query`, :class:`Messages`, + :class:`Message`, and :class:`Tags`. + +""" + +""" +This file is part of notmuch. + +Notmuch is free software: 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. + +Notmuch is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with notmuch. If not, see . + +Copyright 2010 Sebastian Spaeth ' +""" +from database import Database, Query +from message import Messages, Message +from thread import Threads, Thread +from tag import Tags +from cnotmuch.globals import nmlib, STATUS, NotmuchError +__LICENSE__="GPL v3+" +__VERSION__='0.2.2' +__AUTHOR__ ='Sebastian Spaeth ' diff --git a/notmuch/notmuch.py b/notmuch/notmuch.py deleted file mode 100644 index 513209d5..00000000 --- a/notmuch/notmuch.py +++ /dev/null @@ -1,61 +0,0 @@ -"""The :mod:`notmuch` module provides most of the functionality that a user is likely to need. - -.. note:: The underlying notmuch library is build on a hierarchical - memory allocator called talloc. All objects derive from a - top-level :class:`Database` object. - - This means that as soon as an object is deleted, all underlying - derived objects such as Queries, Messages, Message, and Tags will - be freed by the underlying library as well. Accessing these - objects will then lead to segfaults and other unexpected behavior. - - We implement reference counting, so that parent objects can be - automatically freed when they are not needed anymore. For - example:: - - db = Database('path',create=True) - msgs = Query(db,'from:myself').search_messages() - - This returns a :class:`Messages` which internally contains a - reference to its parent :class:`Query` object. Otherwise the - Query() would be immediately freed, taking our *msgs* down with - it. - - In this case, the above Query() object will be automatically freed - whenever we delete all derived objects, ie in our case: - `del(msgs)` would also delete the parent Query. It would not - delete the parent Database() though, as that is still referenced - from the variable *db* in which it is stored. - - Pretty much the same is valid for all other objects in the - hierarchy, such as :class:`Query`, :class:`Messages`, - :class:`Message`, and :class:`Tags`. - -""" - -""" -This file is part of notmuch. - -Notmuch is free software: 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. - -Notmuch is distributed in the hope that it will be useful, but WITHOUT -ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License -for more details. - -You should have received a copy of the GNU General Public License -along with notmuch. If not, see . - -Copyright 2010 Sebastian Spaeth ' -""" -from database import Database, Query -from message import Messages, Message -from thread import Threads, Thread -from tag import Tags -from cnotmuch.globals import nmlib, STATUS, NotmuchError -__LICENSE__="GPL v3+" -__VERSION__='0.2.2' -__AUTHOR__ ='Sebastian Spaeth ' From eb10841de7f988f8cf1475cb4495793749d13c51 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Mon, 19 Apr 2010 21:25:05 +0200 Subject: [PATCH 102/106] python: adapt docs to the notmuch module renaming --- docs/source/conf.py | 2 +- docs/source/index.rst | 52 +++++++++++++++++++++---------------------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index ca959894..e0ee39cd 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -18,7 +18,7 @@ import sys, os # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0,os.path.abspath('../..')) -from cnotmuch.notmuch import __VERSION__,__AUTHOR__ +from notmuch import __VERSION__,__AUTHOR__ # -- General configuration ----------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be extensions diff --git a/docs/source/index.rst b/docs/source/index.rst index 2354190a..c90492c8 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,27 +1,27 @@ -.. cnotmuch documentation master file, created by +.. notmuch documentation master file, created by sphinx-quickstart on Tue Feb 2 10:00:47 2010. -.. currentmodule:: cnotmuch.notmuch +.. currentmodule:: notmuch -Welcome to :mod:`cnotmuch`'s documentation +Welcome to :mod:`notmuch`'s documentation =========================================== -The :mod:`cnotmuch` module provides an interface to the `notmuch `_ functionality, directly interfacing to a shared notmuch library. -Within :mod:`cnotmuch.notmuch`, the classes :class:`Database`, :class:`Query` provide most of the core functionality, returning :class:`Threads`, :class:`Messages` and :class:`Tags`. +The :mod:`notmuch` module provides an interface to the `notmuch `_ functionality, directly interfacing to a shared notmuch library. +Within :mod:`notmuch`, the classes :class:`Database`, :class:`Query` provide most of the core functionality, returning :class:`Threads`, :class:`Messages` and :class:`Tags`. .. moduleauthor:: Sebastian Spaeth :License: This module is covered under the GNU GPL v3 (or later). -This page contains the main API overview of cnotmuch |release|. +This page contains the main API overview of notmuch |release|. Notmuch can be imported as:: - from cnotmuch import notmuch + import notmuch or:: - from cnotmuch.notmuch import Query,Database + from notmuch import Query,Database More information on specific topics can be found on the following pages: @@ -30,17 +30,17 @@ More information on specific topics can be found on the following pages: notmuch -:mod:`cnotmuch.notmuch` -- The Notmuch interface +:mod:`notmuch` -- The Notmuch interface ================================================= -.. automodule:: cnotmuch.notmuch +.. automodule:: notmuch :todo: Document nmlib,STATUS -:class:`cnotmuch.notmuch.Database` -- The underlying notmuch database +:class:`notmuch.Database` -- The underlying notmuch database --------------------------------------------------------------------- -.. autoclass:: cnotmuch.notmuch.Database([path=None[, create=False[, mode=MODE.READ_ONLY]]]) +.. autoclass:: notmuch.Database([path=None[, create=False[, mode=MODE.READ_ONLY]]]) .. automethod:: create @@ -81,10 +81,10 @@ More information on specific topics can be found on the following pages: .. autoattribute:: db_p -:class:`cnotmuch.notmuch.Query` -- A search query +:class:`notmuch.Query` -- A search query ------------------------------------------------- -.. autoclass:: cnotmuch.notmuch.Query +.. autoclass:: notmuch.Query .. automethod:: create @@ -182,10 +182,10 @@ More information on specific topics can be found on the following pages: .. automethod:: __str__ -:class:`cnotmuch.notmuch.Threads` -- Threads iterator +:class:`notmuch.Threads` -- Threads iterator ----------------------------------------------------- -.. autoclass:: cnotmuch.notmuch.Threads +.. autoclass:: notmuch.Threads .. automethod:: __len__ @@ -220,26 +220,26 @@ More information on specific topics can be found on the following pages: :class:`Filenames` -- An iterator over filenames ------------------------------------------------ -.. autoclass:: cnotmuch.database.Filenames +.. autoclass:: notmuch.database.Filenames - .. automethod:: cnotmuch.database.Filenames.__len__ + .. automethod:: notmuch.database.Filenames.__len__ -:class:`cnotmuch.database.Directoy` -- A directory entry in the database +:class:`notmuch.database.Directoy` -- A directory entry in the database ------------------------------------------------------------------------ -.. autoclass:: cnotmuch.database.Directory +.. autoclass:: notmuch.database.Directory - .. automethod:: cnotmuch.database.Directory.get_child_files + .. automethod:: notmuch.database.Directory.get_child_files - .. automethod:: cnotmuch.database.Directory.get_child_directories + .. automethod:: notmuch.database.Directory.get_child_directories - .. automethod:: cnotmuch.database.Directory.get_mtime + .. automethod:: notmuch.database.Directory.get_mtime - .. automethod:: cnotmuch.database.Directory.set_mtime + .. automethod:: notmuch.database.Directory.set_mtime - .. autoattribute:: cnotmuch.database.Directory.mtime + .. autoattribute:: notmuch.database.Directory.mtime - .. autoattribute:: cnotmuch.database.Directory.path + .. autoattribute:: notmuch.database.Directory.path :exc:`NotmuchError` -- A Notmuch execution error ------------------------------------------------ From 2123bdd7fc61ede26a68f6c521fcdc44cb80ab77 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Mon, 19 Apr 2010 21:29:40 +0200 Subject: [PATCH 103/106] Remove all traces of cnotmuch import cnotmuch.notmuch is dead. Long live import notmuch! --- notmuch/__init__.py | 2 +- notmuch/database.py | 10 +++++----- notmuch/message.py | 4 ++-- notmuch/tag.py | 2 +- notmuch/thread.py | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/notmuch/__init__.py b/notmuch/__init__.py index 513209d5..56a4f2a4 100644 --- a/notmuch/__init__.py +++ b/notmuch/__init__.py @@ -55,7 +55,7 @@ from database import Database, Query from message import Messages, Message from thread import Threads, Thread from tag import Tags -from cnotmuch.globals import nmlib, STATUS, NotmuchError +from notmuch.globals import nmlib, STATUS, NotmuchError __LICENSE__="GPL v3+" __VERSION__='0.2.2' __AUTHOR__ ='Sebastian Spaeth ' diff --git a/notmuch/database.py b/notmuch/database.py index f02ec408..f141c03e 100644 --- a/notmuch/database.py +++ b/notmuch/database.py @@ -19,10 +19,10 @@ Copyright 2010 Sebastian Spaeth ' import os from ctypes import c_int, c_char_p, c_void_p, c_uint, c_long, byref -from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum -from cnotmuch.thread import Threads -from cnotmuch.message import Messages, Message -from cnotmuch.tag import Tags +from notmuch.globals import nmlib, STATUS, NotmuchError, Enum +from notmuch.thread import Threads +from notmuch.message import Messages, Message +from notmuch.tag import Tags class Database(object): """Represents a notmuch database (wraps notmuch_database_t) @@ -769,7 +769,7 @@ class Directory(object): def __repr__(self): """Object representation""" - return "" % self._path + return "" % self._path def __del__(self): """Close and free the Directory""" diff --git a/notmuch/message.py b/notmuch/message.py index f8d7d5ec..613cc4ab 100644 --- a/notmuch/message.py +++ b/notmuch/message.py @@ -21,8 +21,8 @@ Copyright 2010 Sebastian Spaeth ' from ctypes import c_char_p, c_void_p, c_long, c_uint from datetime import date -from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum -from cnotmuch.tag import Tags +from notmuch.globals import nmlib, STATUS, NotmuchError, Enum +from notmuch.tag import Tags import sys import email import types diff --git a/notmuch/tag.py b/notmuch/tag.py index c89e4feb..cf1152a1 100644 --- a/notmuch/tag.py +++ b/notmuch/tag.py @@ -17,7 +17,7 @@ along with notmuch. If not, see . Copyright 2010 Sebastian Spaeth ' """ from ctypes import c_char_p -from cnotmuch.globals import nmlib, STATUS, NotmuchError +from notmuch.globals import nmlib, STATUS, NotmuchError #------------------------------------------------------------------------------ class Tags(object): diff --git a/notmuch/thread.py b/notmuch/thread.py index 19bef1a9..eebd6cb5 100644 --- a/notmuch/thread.py +++ b/notmuch/thread.py @@ -18,9 +18,9 @@ Copyright 2010 Sebastian Spaeth ' """ from ctypes import c_char_p, c_void_p, c_long -from cnotmuch.globals import nmlib, STATUS, NotmuchError -from cnotmuch.message import Messages -from cnotmuch.tag import Tags +from notmuch.globals import nmlib, STATUS, NotmuchError +from notmuch.message import Messages +from notmuch.tag import Tags from datetime import date #------------------------------------------------------------------------------ From 391ad89ff8c7576d63136906b2bd0fd6b7df2c85 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Mon, 19 Apr 2010 21:30:43 +0200 Subject: [PATCH 104/106] python: Rename notmuch binary to notmuch.py If we have it in the toplevel directory we can run it without having to install the library, which is great for testing purposes. --HG-- rename : docs/notmuch => notmuch.py --- docs/notmuch => notmuch.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/notmuch => notmuch.py (100%) diff --git a/docs/notmuch b/notmuch.py similarity index 100% rename from docs/notmuch rename to notmuch.py From 634b4fbe233ee9007d190327df11779fe2113fb8 Mon Sep 17 00:00:00 2001 From: Sebastian Spaeth Date: Mon, 19 Apr 2010 21:36:14 +0200 Subject: [PATCH 105/106] python: Adapt README somewhat --- README | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README b/README index 12ed8f3f..cdf9d7ae 100644 --- a/README +++ b/README @@ -1,4 +1,4 @@ -cnotmuch -- The python interface to notmuch.so +notmuch -- The python interface to notmuch.so ============================================== This module makes the functionality of the notmuch library @@ -6,11 +6,9 @@ This module makes the functionality of the notmuch library this modul depends on a libnotmuch.so|dll being available on the user's system. -If you have downloaded the full source tarball (available from -bitbucket.org, the source distribution and binary distribution come -without the documentation), you can create the documentation with -sphinx installed, go to the docs directory and "make html". A static -version of the documentation is available at: +If you have downloaded the full source tarball, you can create the +documentation with sphinx installed, go to the docs directory and +"make html". A static version of the documentation is available at: http://packages.python.org/cnotmuch/ @@ -47,7 +45,7 @@ For more examples of how to use the cnotmuch interface, have a look at the notmuch "binary" and the generated documentation. Example session: ->>>from cnotmuch import notmuch +>>>import notmuch >>>db = notmuch.Database("/home/spaetz/mail") db.get_path() '/home/spaetz/mail' @@ -85,3 +83,5 @@ Changelog 0.1.1 Fixed Database.create_query() 0.2.0 Implemented Thread() and Threads() methods 0.2.1 Implemented the remaining API methods, notably Directory() and Filenames() +0.2.2 Bug fixes +0.3.0 Incorporated in the notmuchmail.org git repository \ No newline at end of file From 3b558de7811a765c3295a58bd53e2156eca0e32e Mon Sep 17 00:00:00 2001 From: Carl Worth Date: Wed, 21 Apr 2010 17:23:49 -0700 Subject: [PATCH 106/106] Move everything down into a bindings/python directory. In preparation for merging the python bindings into the notmuch repository. --- MANIFEST.in => bindings/python/MANIFEST.in | 0 README => bindings/python/README | 0 {debian => bindings/python/debian}/changelog | 0 {debian => bindings/python/debian}/compat | 0 {debian => bindings/python/debian}/control | 0 {debian => bindings/python/debian}/copyright | 0 {debian => bindings/python/debian}/docs | 0 {debian => bindings/python/debian}/rules | 0 {docs => bindings/python/docs}/COPYING | 0 {docs => bindings/python/docs}/Makefile | 0 {docs => bindings/python/docs}/source/conf.py | 0 {docs => bindings/python/docs}/source/index.rst | 0 {docs => bindings/python/docs}/source/notmuch.rst | 0 notmuch.py => bindings/python/notmuch.py | 0 {notmuch => bindings/python/notmuch}/__init__.py | 0 {notmuch => bindings/python/notmuch}/database.py | 0 {notmuch => bindings/python/notmuch}/globals.py | 0 {notmuch => bindings/python/notmuch}/message.py | 0 {notmuch => bindings/python/notmuch}/tag.py | 0 {notmuch => bindings/python/notmuch}/thread.py | 0 setup.py => bindings/python/setup.py | 0 {test => bindings/python/test}/notmuch-test | 0 22 files changed, 0 insertions(+), 0 deletions(-) rename MANIFEST.in => bindings/python/MANIFEST.in (100%) rename README => bindings/python/README (100%) rename {debian => bindings/python/debian}/changelog (100%) rename {debian => bindings/python/debian}/compat (100%) rename {debian => bindings/python/debian}/control (100%) rename {debian => bindings/python/debian}/copyright (100%) rename {debian => bindings/python/debian}/docs (100%) rename {debian => bindings/python/debian}/rules (100%) rename {docs => bindings/python/docs}/COPYING (100%) rename {docs => bindings/python/docs}/Makefile (100%) rename {docs => bindings/python/docs}/source/conf.py (100%) rename {docs => bindings/python/docs}/source/index.rst (100%) rename {docs => bindings/python/docs}/source/notmuch.rst (100%) rename notmuch.py => bindings/python/notmuch.py (100%) rename {notmuch => bindings/python/notmuch}/__init__.py (100%) rename {notmuch => bindings/python/notmuch}/database.py (100%) rename {notmuch => bindings/python/notmuch}/globals.py (100%) rename {notmuch => bindings/python/notmuch}/message.py (100%) rename {notmuch => bindings/python/notmuch}/tag.py (100%) rename {notmuch => bindings/python/notmuch}/thread.py (100%) rename setup.py => bindings/python/setup.py (100%) rename {test => bindings/python/test}/notmuch-test (100%) diff --git a/MANIFEST.in b/bindings/python/MANIFEST.in similarity index 100% rename from MANIFEST.in rename to bindings/python/MANIFEST.in diff --git a/README b/bindings/python/README similarity index 100% rename from README rename to bindings/python/README diff --git a/debian/changelog b/bindings/python/debian/changelog similarity index 100% rename from debian/changelog rename to bindings/python/debian/changelog diff --git a/debian/compat b/bindings/python/debian/compat similarity index 100% rename from debian/compat rename to bindings/python/debian/compat diff --git a/debian/control b/bindings/python/debian/control similarity index 100% rename from debian/control rename to bindings/python/debian/control diff --git a/debian/copyright b/bindings/python/debian/copyright similarity index 100% rename from debian/copyright rename to bindings/python/debian/copyright diff --git a/debian/docs b/bindings/python/debian/docs similarity index 100% rename from debian/docs rename to bindings/python/debian/docs diff --git a/debian/rules b/bindings/python/debian/rules similarity index 100% rename from debian/rules rename to bindings/python/debian/rules diff --git a/docs/COPYING b/bindings/python/docs/COPYING similarity index 100% rename from docs/COPYING rename to bindings/python/docs/COPYING diff --git a/docs/Makefile b/bindings/python/docs/Makefile similarity index 100% rename from docs/Makefile rename to bindings/python/docs/Makefile diff --git a/docs/source/conf.py b/bindings/python/docs/source/conf.py similarity index 100% rename from docs/source/conf.py rename to bindings/python/docs/source/conf.py diff --git a/docs/source/index.rst b/bindings/python/docs/source/index.rst similarity index 100% rename from docs/source/index.rst rename to bindings/python/docs/source/index.rst diff --git a/docs/source/notmuch.rst b/bindings/python/docs/source/notmuch.rst similarity index 100% rename from docs/source/notmuch.rst rename to bindings/python/docs/source/notmuch.rst diff --git a/notmuch.py b/bindings/python/notmuch.py similarity index 100% rename from notmuch.py rename to bindings/python/notmuch.py diff --git a/notmuch/__init__.py b/bindings/python/notmuch/__init__.py similarity index 100% rename from notmuch/__init__.py rename to bindings/python/notmuch/__init__.py diff --git a/notmuch/database.py b/bindings/python/notmuch/database.py similarity index 100% rename from notmuch/database.py rename to bindings/python/notmuch/database.py diff --git a/notmuch/globals.py b/bindings/python/notmuch/globals.py similarity index 100% rename from notmuch/globals.py rename to bindings/python/notmuch/globals.py diff --git a/notmuch/message.py b/bindings/python/notmuch/message.py similarity index 100% rename from notmuch/message.py rename to bindings/python/notmuch/message.py diff --git a/notmuch/tag.py b/bindings/python/notmuch/tag.py similarity index 100% rename from notmuch/tag.py rename to bindings/python/notmuch/tag.py diff --git a/notmuch/thread.py b/bindings/python/notmuch/thread.py similarity index 100% rename from notmuch/thread.py rename to bindings/python/notmuch/thread.py diff --git a/setup.py b/bindings/python/setup.py similarity index 100% rename from setup.py rename to bindings/python/setup.py diff --git a/test/notmuch-test b/bindings/python/test/notmuch-test similarity index 100% rename from test/notmuch-test rename to bindings/python/test/notmuch-test