diff --git a/script.service.kodi.callbacks/LICENSE.txt b/script.service.kodi.callbacks/LICENSE.txt new file mode 100644 index 0000000000..23e7ed4954 --- /dev/null +++ b/script.service.kodi.callbacks/LICENSE.txt @@ -0,0 +1,682 @@ + +THE PATHTOOLS MODULE IS UTILIZED UNDER AN MIT LICENSE. SEE THE PATHTOOLS SUBDIRECTORY FOR A COPY OF THE LICENSE + +THE WATCHDOG MODULE IS UTILIZED UNDER AN APACHE LICENSE. SEE THE WATCHDOG SUBDIRECTORY FOR A COPY OF THE LICENSE + +ALL OTHER CODE IS PROVIDED UNDER THE FOLLOWING LICENSE: + + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/script.service.kodi.callbacks/README.md b/script.service.kodi.callbacks/README.md new file mode 100644 index 0000000000..11f65bb5b3 --- /dev/null +++ b/script.service.kodi.callbacks/README.md @@ -0,0 +1,9 @@ +script.service.kodi.callbacks +======================= +Props to pilluli for initial idea + +This is an all new version using a Publisher-Subscriber model. + +For usage details, please see the wiki: http://kodi.wiki/view/Add-on:Kodi_Callbacks + +Please report problems on the kodi forum at: http://forum.kodi.tv/showthread.php?tid=256170 diff --git a/script.service.kodi.callbacks/addon.xml b/script.service.kodi.callbacks/addon.xml new file mode 100644 index 0000000000..849fe64372 --- /dev/null +++ b/script.service.kodi.callbacks/addon.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + Callbacks for Kodi + Provides user definable actions for specific events within Kodi. Credit to Yesudeep Mangalapilly (gorakhargosh on github) and contributors for watchdog and pathtools modules. + + For bugs, requests or general questions visit the Kodi forums. + all + https://github.com/KenV99/script.service.kodi.callbacks + http://kodi.wiki/view/Add-on:Kodi_Callbacks + http://forum.kodi.tv/showthread.php?tid=256170 + GNU GENERAL PUBLIC LICENSE. Version 3, June 2007 + en + + + diff --git a/script.service.kodi.callbacks/changelog.txt b/script.service.kodi.callbacks/changelog.txt new file mode 100644 index 0000000000..beac064c79 --- /dev/null +++ b/script.service.kodi.callbacks/changelog.txt @@ -0,0 +1,67 @@ +v0.9.9 (2016-03-22) +Version changed to meet official repo rules + +v0.9.8.5 (2016-03-15) +Fixed log bug and testWatchdogStartup bugs + +v0.9.8.4 (2016-03-09) +- Added JSON Notify task +- Corrected settings bug where one could not select tasks properly + +v0.9.8.3 (2016-03-07) +- Removed github code from master branch + +v0.9.8.2 (2016-03-05) +- Refactored settings xml generation +- Made unconfigured tasks unselectable in settings + +v0.9.8 (2016-02-26) +- Merged nonrepo branch onto master +- Change xml to be official repo compliant +- Added ability to write settings to log - useful if need to put log on pastebin +- Hide unused tasks and events in settings + +v0.9.7.6 (2016-02-22) +- Refactored update, detects paths with spaces + +v0.9.7.5 (2016-02-16) +- Fix schedule import + +v0.9.7.4 (2016-02-16) +- Added Scheduler publisher to schedule tasks to run daily or at a given interval + +v0.9.7.3 (2016-02-15) +- Non-repo version added ability to update from Github + +v0.9.7.2 (2016-02-14) +- Added ability to load from zip in settings and backup current + +v0.9.7.2 (2016-02-13) +- Implement kodipathtools. Publisher tests passing on Linux, OSX, Windows. + +v0.9.7.1 (2016-02-04) +- Android path fix. Added %mt for pause and resume. Changed settings to allow for special// paths. + +v0.9.7 (2016-02-03) +- Watchdog startup file change detection added. Several bugs corrected. + +v0.9.6 (2016-01-31) +- Publisher and Subscriber Factories. Readied for offical repo submission. + +v0.9.5 (2016-01-29) +- Minor changes to onIdle and afterIdle + +v0.9.4 (2016-01-27) +- Added after Idle event. Bug fix with string handling from settings.xml + +v0.9.3 (2016-01-25) +- Provided string localization and po handling + +v0.9.2 (2016-01-22) +- Added basic auth for http, added task testing to settings, bug fixes, removed dependency on service.watchdog + +v0.9.1 (2016-01-16) +- Bug fixes, file watchdog added + +v0.9.0 (2016-01-12) +- Initial version \ No newline at end of file diff --git a/script.service.kodi.callbacks/default.py b/script.service.kodi.callbacks/default.py new file mode 100644 index 0000000000..9c310af3ca --- /dev/null +++ b/script.service.kodi.callbacks/default.py @@ -0,0 +1,167 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 KenV99 +# +# 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 . +# + + +debug = False # TODO: check +testdebug = False # TODO: check +testTasks = False # TODO: check +branch = 'master' +build = '1006' + +from resources.lib.utils.debugger import startdebugger + +if debug: + startdebugger() + +import os +import sys +import threading +import xbmc +import xbmcaddon +import resources.lib.pubsub as PubSub_Threaded +from resources.lib.kodilogging import KodiLogger +from resources.lib.publisherfactory import PublisherFactory +from resources.lib.subscriberfactory import SubscriberFactory +from resources.lib.settings import Settings +from resources.lib.utils.poutil import KodiPo + +kodipo = KodiPo() +_ = kodipo.getLocalizedString +log = KodiLogger.log + +try: + _addonversion_ = xbmcaddon.Addon().getAddonInfo('version') +except RuntimeError: + try: + _addonversion_ = xbmcaddon.Addon('script.service.kodi.callbacks').getAddonInfo('version') + except RuntimeError: + _addonversion_ = 'ERROR getting version' + + +class Cache(object): + publishers = None + dispatcher = None + + +class MainMonitor(xbmc.Monitor): + def __init__(self): + super(MainMonitor, self).__init__() + + def onSettingsChanged(self): + log(msg=_('Settings change detected - attempting to restart')) + abortall() + start() + + +def abortall(): + for p in Cache.publishers: + try: + p.abort() + except threading.ThreadError as e: + log(msg=_('Error aborting: %s - Error: %s') % (str(p), str(e))) + Cache.dispatcher.abort() + for p in Cache.publishers: + p.join(0.5) + Cache.dispatcher.join(0.5) + if len(threading.enumerate()) > 1: + main_thread = threading.current_thread() + log(msg=_('Enumerating threads to kill others than main (%i)') % main_thread.ident) + for t in threading.enumerate(): + if t is not main_thread and t.is_alive(): + log(msg=_('Attempting to kill thread: %i: %s') % (t.ident, t.name)) + try: + t.abort(0.5) + except (threading.ThreadError, AttributeError): + log(msg=_('Error killing thread')) + else: + if not t.is_alive(): + log(msg=_('Thread killed succesfully')) + else: + log(msg=_('Error killing thread')) + + +def start(): + global log + settings = Settings() + settings.getSettings() + kl = KodiLogger() + if settings.general['elevate_loglevel'] is True: + kl.setLogLevel(xbmc.LOGNOTICE) + else: + kl.setLogLevel(xbmc.LOGDEBUG) + log = kl.log + log(msg=_('Settings read')) + Cache.dispatcher = PubSub_Threaded.Dispatcher(interval=settings.general['TaskFreq'], sleepfxn=xbmc.sleep) + log(msg=_('Dispatcher initialized')) + + subscriberfactory = SubscriberFactory(settings, kl) + subscribers = subscriberfactory.createSubscribers() + for subscriber in subscribers: + Cache.dispatcher.addSubscriber(subscriber) + publisherfactory = PublisherFactory(settings, subscriberfactory.topics, Cache.dispatcher, kl, debug) + publisherfactory.createPublishers() + Cache.publishers = publisherfactory.ipublishers + + Cache.dispatcher.start() + log(msg=_('Dispatcher started')) + + for p in Cache.publishers: + try: + p.start() + except threading.ThreadError: + raise + log(msg=_('Publisher(s) started')) + + +def main(): + xbmc.log(msg=_('$$$ [kodi.callbacks] - Staring kodi.callbacks ver: %s (build %s)') % (str(_addonversion_), build), + level=xbmc.LOGNOTICE) + if branch != 'master': + xbmcaddon.Addon().setSetting('installed branch', branch) + start() + Cache.dispatcher.q_message(PubSub_Threaded.Message(PubSub_Threaded.Topic('onStartup'))) + monitor = MainMonitor() + log(msg=_('Entering wait loop')) + monitor.waitForAbort() + + # Shutdown tasks + Cache.dispatcher.q_message(PubSub_Threaded.Message(PubSub_Threaded.Topic('onShutdown'), pid=os.getpid())) + log(msg=_('Shutdown started')) + abortall() + log(msg='Shutdown complete') + + +if __name__ == '__main__': + + if testTasks: + KodiLogger.setLogLevel(KodiLogger.LOGNOTICE) + startdebugger() + from resources.lib.tests.testTasks import testTasks + + tt = testTasks() + tt.runTests() + else: + if branch != 'master': + try: + from resources.lib.utils.githubtools import processargs + except ImportError: + pass + else: + processargs(sys.argv) + main() diff --git a/script.service.kodi.callbacks/fanart.jpg b/script.service.kodi.callbacks/fanart.jpg new file mode 100644 index 0000000000..9652460b66 Binary files /dev/null and b/script.service.kodi.callbacks/fanart.jpg differ diff --git a/script.service.kodi.callbacks/icon.png b/script.service.kodi.callbacks/icon.png new file mode 100644 index 0000000000..5de843e3a0 Binary files /dev/null and b/script.service.kodi.callbacks/icon.png differ diff --git a/script.service.kodi.callbacks/resources/__init__.py b/script.service.kodi.callbacks/resources/__init__.py new file mode 100644 index 0000000000..6483cfc09b --- /dev/null +++ b/script.service.kodi.callbacks/resources/__init__.py @@ -0,0 +1,18 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 KenV99 +# +# 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 . +# diff --git a/script.service.kodi.callbacks/resources/language/English/strings.po b/script.service.kodi.callbacks/resources/language/English/strings.po new file mode 100644 index 0000000000..d91c5eedfa --- /dev/null +++ b/script.service.kodi.callbacks/resources/language/English/strings.po @@ -0,0 +1,1220 @@ +# Kodi Media Center language file +# Addon Name: Kodi Callbacks +# Addon id: script.service.kodi.callbacks +# Addon Provider: KenV99 +msgid "" +msgstr "" +"Project-Id-Version: XBMC Addons\n" +"POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: en\n""Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgctxt "Addon Summary" +msgid "Callbacks for Kodi" +msgstr "" + +msgctxt "Addon Description" +msgid "Provides user definable actions for specific events within Kodi. Credit to Yesudeep Mangalapilly (gorakhargosh on github) and contributors for watchdog and pathtools modules." +msgstr "" + +msgctxt "Addon Disclaimer" +msgid "For bugs, requests or general questions visit the Kodi forums." +msgstr "" + +#Add-on messages id=32001 to 32298 + +msgctxt "#32001" +msgid "Task 1" +msgstr "" + +msgctxt "#32002" +msgid "Task 2" +msgstr "" + +msgctxt "#32003" +msgid "Task 3" +msgstr "" + +msgctxt "#32004" +msgid "Task 4" +msgstr "" + +msgctxt "#32005" +msgid "Task 5" +msgstr "" + +msgctxt "#32006" +msgid "Task 6" +msgstr "" + +msgctxt "#32007" +msgid "Task 7" +msgstr "" + +msgctxt "#32008" +msgid "Task 8" +msgstr "" + +msgctxt "#32009" +msgid "Task 9" +msgstr "" + +msgctxt "#32010" +msgid "Task 10" +msgstr "" + +msgctxt "#32011" +msgid "Event 1" +msgstr "" + +msgctxt "#32012" +msgid "Event 2" +msgstr "" + +msgctxt "#32013" +msgid "Event 3" +msgstr "" + +msgctxt "#32014" +msgid "Event 4" +msgstr "" + +msgctxt "#32015" +msgid "Event 5" +msgstr "" + +msgctxt "#32016" +msgid "Event 6" +msgstr "" + +msgctxt "#32017" +msgid "Event 7" +msgstr "" + +msgctxt "#32018" +msgid "Event 8" +msgstr "" + +msgctxt "#32019" +msgid "Event 9" +msgstr "" + +msgctxt "#32020" +msgid "Event 10" +msgstr "" + +msgctxt "#32021" +msgid "Task received: %s: %s" +msgstr "" + +msgctxt "#32022" +msgid "Kodi Callbacks" +msgstr "" + +msgctxt "#32023" +msgid "Settings change detected - attempting to restart" +msgstr "" + +msgctxt "#32024" +msgid "Command for Task %s, Event %s completed succesfully!" +msgstr "" + +msgctxt "#32025" +msgid "\nThe following message was returned: %s" +msgstr "" + +msgctxt "#32026" +msgid "ERROR encountered for Task %s, Event %s\nERROR mesage: %s" +msgstr "" + +msgctxt "#32027" +msgid "Settings read" +msgstr "" + +msgctxt "#32028" +msgid "Dispatcher initialized" +msgstr "" + +msgctxt "#32029" +msgid "Subscriber for event: %s, task: %s created" +msgstr "" + +msgctxt "#32030" +msgid "Subscriber for event: %s, task: %s NOT created due to errors" +msgstr "" + +msgctxt "#32031" +msgid "Loop Publisher initialized" +msgstr "" + +msgctxt "#32032" +msgid "Player Publisher initialized" +msgstr "" + +msgctxt "#32033" +msgid "Monitor Publisher initialized" +msgstr "" + +msgctxt "#32034" +msgid "Log Publisher initialized" +msgstr "" + +msgctxt "#32035" +msgid "Watchdog Publisher initialized" +msgstr "" + +msgctxt "#32036" +msgid "Dispatcher started" +msgstr "" + +msgctxt "#32037" +msgid "Publisher(s) started" +msgstr "" + +msgctxt "#32038" +msgid "$$$ [kodi.callbacks] - Staring kodi.callbacks ver: %s (build %s)" +msgstr "" + +msgctxt "#32039" +msgid "Entering wait loop" +msgstr "" + +msgctxt "#32040" +msgid "Shutdown started" +msgstr "" + +msgctxt "#32041" +msgid "Error aborting: %s - Error: %s" +msgstr "" + +msgctxt "#32042" +msgid "Enumerating threads to kill others than main (%i)" +msgstr "" + +msgctxt "#32043" +msgid "Attempting to kill thread: %i: %s" +msgstr "" + +msgctxt "#32044" +msgid "Error killing thread" +msgstr "" + +msgctxt "#32045" +msgid "Thread killed succesfully" +msgstr "" + +msgctxt "#32046" +msgid "Running Test for Event: %s" +msgstr "" + +msgctxt "#32047" +msgid "Settings for test read" +msgstr "" + +msgctxt "#32048" +msgid "Creating subscriber for test" +msgstr "" + +msgctxt "#32049" +msgid "Test subscriber created successfully" +msgstr "" + +msgctxt "#32050" +msgid "Running test" +msgstr "" + +msgctxt "#32051" +msgid "Unspecified error during testing" +msgstr "" + +msgctxt "#32052" +msgid "Test subscriber creation failed due to errors" +msgstr "" + +msgctxt "#32053" +msgid "Native Task Testing Complete - see log for results" +msgstr "" + +msgctxt "#32054" +msgid "Task not run because task count exceeded" +msgstr "" + +msgctxt "#32055" +msgid "Task not run because task already running" +msgstr "" + +msgctxt "#32056" +msgid "Tasks" +msgstr "" + +msgctxt "#32057" +msgid "Task" +msgstr "" + +msgctxt "#32058" +msgid "Max num of this task running simultaneously (-1=no limit)" +msgstr "" + +msgctxt "#32059" +msgid "Max num of times this task runs (-1=no limit)" +msgstr "" + +msgctxt "#32060" +msgid "Refractory period in secs (-1=none)" +msgstr "" + +msgctxt "#32061" +msgid "Events" +msgstr "" + +msgctxt "#32062" +msgid "Type" +msgstr "" + +msgctxt "#32063" +msgid "Hint - variables can be subbed (%%=%, _%=space, _%%=,): " +msgstr "" + +msgctxt "#32064" +msgid "Var subbed arg string" +msgstr "" + +msgctxt "#32065" +msgid "Test Command (click OK to save changes first)" +msgstr "" + +msgctxt "#32066" +msgid "General" +msgstr "" + +msgctxt "#32067" +msgid "Display Notifications when Tasks Run?" +msgstr "" + +msgctxt "#32068" +msgid "Loop Pooling Frequency (ms)" +msgstr "" + +msgctxt "#32069" +msgid "Log Polling Frequency (ms)" +msgstr "" + +msgctxt "#32070" +msgid "Task Polling Frequency (ms)" +msgstr "" + +msgctxt "#32071" +msgid "Show debugging info in normal log?" +msgstr "" + +msgctxt "#32072" +msgid "Regenerate settings.xml (Developers Only)" +msgstr "" + +msgctxt "#32073" +msgid "Test addon native tasks (see log for output)" +msgstr "" + +msgctxt "#32074" +msgid "Settings.xml rewritten" +msgstr "" + +msgctxt "#32075" +msgid "Kodi Builtin Function" +msgstr "" + +msgctxt "#32076" +msgid "Task %s launching for event: %s" +msgstr "" + +msgctxt "#32077" +msgid "HTTP string (without parameters)" +msgstr "" + +msgctxt "#32078" +msgid "user for Basic Auth (optional)" +msgstr "" + +msgctxt "#32079" +msgid "password for Basic Auth (optional)" +msgstr "" + +msgctxt "#32080" +msgid "Invalid url: %s" +msgstr "" + +msgctxt "#32081" +msgid "Error on url read" +msgstr "" + +msgctxt "#32082" +msgid "Requests Connection Error" +msgstr "" + +msgctxt "#32083" +msgid "Requests HTTPError" +msgstr "" + +msgctxt "#32084" +msgid "Requests URLRequired Error" +msgstr "" + +msgctxt "#32085" +msgid "Requests Timeout Error" +msgstr "" + +msgctxt "#32086" +msgid "Generic Requests Error" +msgstr "" + +msgctxt "#32087" +msgid "HTTPError = " +msgstr "" + +msgctxt "#32088" +msgid "URLError\n" +msgstr "" + +msgctxt "#32089" +msgid "Http Bad Status Line caught and passed" +msgstr "" + +msgctxt "#32090" +msgid "HTTPException" +msgstr "" + +msgctxt "#32091" +msgid "The request timed out, host unreachable" +msgstr "" + +msgctxt "#32092" +msgid "Python file" +msgstr "" + +msgctxt "#32093" +msgid "Import and call run() (default=no)?" +msgstr "" + +msgctxt "#32094" +msgid "Error - not a python script: %s" +msgstr "" + +msgctxt "#32095" +msgid "Error - File not found: %s" +msgstr "" + +msgctxt "#32096" +msgid "Script executable file" +msgstr "" + +msgctxt "#32097" +msgid "Requires shell?" +msgstr "" + +msgctxt "#32098" +msgid "Wait for script to complete?" +msgstr "" + +msgctxt "#32099" +msgid "Failed to set execute bit on script: %s" +msgstr "" + +msgctxt "#32100" +msgid "Process returned data: %s\n" +msgstr "" + +msgctxt "#32101" +msgid "Process returned error: %s" +msgstr "" + +msgctxt "#32102" +msgid "sender" +msgstr "" + +msgctxt "#32103" +msgid "method" +msgstr "" + +msgctxt "#32104" +msgid "data" +msgstr "" + +msgctxt "#32105" +msgid "windowIdO" +msgstr "" + +msgctxt "#32106" +msgid "windowIdC" +msgstr "" + +msgctxt "#32107" +msgid "idleTime" +msgstr "" + +msgctxt "#32108" +msgid "matchIf" +msgstr "" + +msgctxt "#32109" +msgid "rejectIf" +msgstr "" + +msgctxt "#32110" +msgid "folder" +msgstr "" + +msgctxt "#32111" +msgid "patterns" +msgstr "" + +msgctxt "#32112" +msgid "ignore_patterns" +msgstr "" + +msgctxt "#32113" +msgid "ignore_directories" +msgstr "" + +msgctxt "#32114" +msgid "recursive" +msgstr "" + +msgctxt "#32115" +msgid "%sp=speed" +msgstr "" + +msgctxt "#32116" +msgid "%ar=aspectRatio,%mt=mediaType,%ti=title,%fn=fileName" +msgstr "" + +msgctxt "#32117" +msgid "%rs=resolution" +msgstr "" + +msgctxt "#32118" +msgid "%ti=time" +msgstr "" + +msgctxt "#32119" +msgid "%pi=pid" +msgstr "" + +msgctxt "#32120" +msgid "%ch=chapter" +msgstr "" + +msgctxt "#32121" +msgid "%li=library" +msgstr "" + +msgctxt "#32122" +msgid "%wi=windowId" +msgstr "" + +msgctxt "#32123" +msgid "%pp=profilePath" +msgstr "" + +msgctxt "#32124" +msgid "%ev=event,%pa=path" +msgstr "" + +msgctxt "#32125" +msgid "%mt=mediaType,%ti=title,%pp=percentPlayed,%fn=fileName" +msgstr "" + +msgctxt "#32126" +msgid "%me=method,%se=sender,%da=data" +msgstr "" + +msgctxt "#32127" +msgid "%sm=stereoMode" +msgstr "" + +msgctxt "#32128" +msgid "%ll=logLine" +msgstr "" + +msgctxt "#32129" +msgid "none" +msgstr "" + +msgctxt "#32130" +msgid "builtin" +msgstr "" + +msgctxt "#32131" +msgid "http" +msgstr "" + +msgctxt "#32132" +msgid "python" +msgstr "" + +msgctxt "#32133" +msgid "script" +msgstr "" + +msgctxt "#32134" +msgid "Task not run because task is in refractory period" +msgstr "" + +msgctxt "#32135" +msgid "Task starting for %s" +msgstr "" + +msgctxt "#32136" +msgid "Task finalized for %s" +msgstr "" + +msgctxt "#32137" +msgid "Could not stop LogMonitor T:%i" +msgstr "" + +msgctxt "#32138" +msgid "Could not stop LogPublisher T:%i" +msgstr "" + +msgctxt "#32139" +msgid "Could not stop LogCheckSimple T:%i" +msgstr "" + +msgctxt "#32140" +msgid "Could not stop LogCheckRegex T:%i" +msgstr "" + +msgctxt "#32141" +msgid "Could not stop LoopPublisher T:%i" +msgstr "" + +msgctxt "#32142" +msgid "Could not stop MonitorPublisher T:%i" +msgstr "" + +msgctxt "#32143" +msgid "Could not stop PlayerPublisher T:%i" +msgstr "" + +msgctxt "#32144" +msgid "Testing for task type: %s" +msgstr "" + +msgctxt "#32145" +msgid "Settings: %s" +msgstr "" + +msgctxt "#32146" +msgid "Runtime kwargs: %s" +msgstr "" + +msgctxt "#32147" +msgid "The following message was returned: %s" +msgstr "" + +msgctxt "#32148" +msgid "testHttp returned with an error: %s" +msgstr "" + +msgctxt "#32149" +msgid "Http test failed" +msgstr "" + +msgctxt "#32150" +msgid "Builtin test failed" +msgstr "" + +msgctxt "#32151" +msgid "testScriptNoShell returned with an error: %s" +msgstr "" + +msgctxt "#32152" +msgid "Script without shell test failed" +msgstr "" + +msgctxt "#32153" +msgid "testScriptShell returned with an error: %s" +msgstr "" + +msgctxt "#32154" +msgid "Script with shell test failed" +msgstr "" + +msgctxt "#32155" +msgid "testPythonImport returned with an error: %s" +msgstr "" + +msgctxt "#32156" +msgid "Python import test failed" +msgstr "" + +msgctxt "#32157" +msgid "testPythonExternal returned with an error: %s" +msgstr "" + +msgctxt "#32158" +msgid "Python external test failed" +msgstr "" + +msgctxt "#32159" +msgid "Error: %s" +msgstr "" + +msgctxt "#32160" +msgid "Error testing %s\n" +msgstr "" + +msgctxt "#32161" +msgid "Test passed for task %s" +msgstr "" + +msgctxt "#32162" +msgid "afterIdleTime" +msgstr "" + +msgctxt "#32163" +msgid "Process returned no data\n" +msgstr "" + +msgctxt "#32164" +msgid "Localized string added to po for: [%s]" +msgstr "" + +msgctxt "#32165" +msgid "Localized string not found for: [%s]" +msgstr "" + +msgctxt "#32166" +msgid "%li=listOfChanges" +msgstr "" + +msgctxt "#32167" +msgid "Watchdog Startup Publisher initialized" +msgstr "" + +msgctxt "#32168" +msgid "String not found" +msgstr "" + +msgctxt "#32169" +msgid "ws_folder" +msgstr "" + +msgctxt "#32170" +msgid "ws_patterns" +msgstr "" + +msgctxt "#32171" +msgid "ws_ignore_patterns" +msgstr "" + +msgctxt "#32172" +msgid "ws_ignore_directories" +msgstr "" + +msgctxt "#32173" +msgid "ws_recursive" +msgstr "" + +msgctxt "#32174" +msgid "Watchdog Startup ould not load pickle" +msgstr "" + +msgctxt "#32175" +msgid "Watchdog Startup unpickling error" +msgstr "" + +msgctxt "#32176" +msgid "Watchdog Startup folder not found: %s" +msgstr "" + +msgctxt "#32177" +msgid "Watchdog startup pickling error on exit" +msgstr "" + +msgctxt "#32178" +msgid "Watchdog startup OSError on pickle attempt" +msgstr "" + +msgctxt "#32179" +msgid "Watchdog startup pickle saved" +msgstr "" + +msgctxt "#32180" +msgid "Watchdog startup could not clear pickle" +msgstr "" + +msgctxt "#32181" +msgid "Localized string id not found for: [%s]" +msgstr "" + +msgctxt "#32182" +msgid "%mt=mediaType" +msgstr "" + +msgctxt "#32183" +msgid "%mt=mediaType,%ti=time" +msgstr "" + +msgctxt "#32184" +msgid "Error importing Watchdog: %s" +msgstr "" + +msgctxt "#32185" +msgid "Non existent string" +msgstr "" + +msgctxt "#32186" +msgid "testHttp never returned" +msgstr "" + +msgctxt "#32187" +msgid "Timed out waiting for return" +msgstr "" + +msgctxt "#32188" +msgid "Cannot fully test pythonExternal on Android" +msgstr "" + +msgctxt "#32189" +msgid "Error testing %s: %s" +msgstr "" + +msgctxt "#32190" +msgid "Update" +msgstr "" + +msgctxt "#32191" +msgid "Update from downloaded zip" +msgstr "" + +msgctxt "#32192" +msgid "Restore from previous back up" +msgstr "" + +msgctxt "#32193" +msgid "Locate zip file" +msgstr "" + +msgctxt "#32194" +msgid "Locate backup zip file" +msgstr "" + +msgctxt "#32195" +msgid "Watchdog Startup could not load pickle" +msgstr "" + +msgctxt "#32196" +msgid "A new version of %s is available\nDownload and install?" +msgstr "" + +msgctxt "#32197" +msgid "Downloaded file could not be extracted" +msgstr "" + +msgctxt "#32198" +msgid "Backup failed, update aborted" +msgstr "" + +msgctxt "#32199" +msgid "Error encountered copying to addon directory: %s" +msgstr "" + +msgctxt "#32200" +msgid "New version installed" +msgstr "" + +msgctxt "#32201" +msgid "\nPrevious installation backed up" +msgstr "" + +msgctxt "#32202" +msgid "Attempt to restart addon now?" +msgstr "" + +msgctxt "#32203" +msgid "All files are current" +msgstr "" + +msgctxt "#32204" +msgid "Could not find addon.xml\nInstallation aborted" +msgstr "" + +msgctxt "#32205" +msgid "Before any installation, the current is backed up to userdata/addon_data" +msgstr "" + +msgctxt "#32206" +msgid "Automatically download/install latest from GitHub on startup?" +msgstr "" + +msgctxt "#32207" +msgid "Install without prompts?" +msgstr "" + +msgctxt "#32208" +msgid "Check for update on GitHub" +msgstr "" + +msgctxt "#32209" +msgid "Download/install latest from Github" +msgstr "" + +msgctxt "#32210" +msgid "GitHub Download Error - Error reading file" +msgstr "" + +msgctxt "#32211" +msgid "Downloading %s bytes %s" +msgstr "" + +msgctxt "#32212" +msgid "Download Cancelled" +msgstr "" + +msgctxt "#32213" +msgid "GitHub Download Error: %s" +msgstr "" + +msgctxt "#32214" +msgid "Unknown GitHub Download Error" +msgstr "" + +msgctxt "#32215" +msgid "hour" +msgstr "" + +msgctxt "#32216" +msgid "minute" +msgstr "" + +msgctxt "#32217" +msgid "hours" +msgstr "" + +msgctxt "#32218" +msgid "minutes" +msgstr "" + +msgctxt "#32219" +msgid "seconds" +msgstr "" + +msgctxt "#32220" +msgid "onIntervalAlarm interval cannot be zero" +msgstr "" + +msgctxt "#32221" +msgid "Could not stop SchedulePublisher T:%i" +msgstr "" + +msgctxt "#32222" +msgid "Schedule Publisher initialized" +msgstr "" + +msgctxt "#32223" +msgid "Repository branch name" +msgstr "" + +msgctxt "#32224" +msgid "Currently installed branch" +msgstr "" + +msgctxt "#32225" +msgid "Repository branch name for downloads" +msgstr "" + +msgctxt "#32226" +msgid "Backup restored" +msgstr "" + +msgctxt "#32227" +msgid "The following files were updated: %s" +msgstr "" + +msgctxt "#32228" +msgid "$$$ [kodi.callbacks] - Staring kodi.callbacks ver: %s" +msgstr "" + +msgctxt "#32229" +msgid "None" +msgstr "" + +msgctxt "#32230" +msgid "on Clean Finished" +msgstr "" + +msgctxt "#32231" +msgid "on Clean Started" +msgstr "" + +msgctxt "#32232" +msgid "on DPMS Activated" +msgstr "" + +msgctxt "#32233" +msgid "on DPMS Deactivated" +msgstr "" + +msgctxt "#32234" +msgid "on Daily Alarm" +msgstr "" + +msgctxt "#32235" +msgid "on File System Change" +msgstr "" + +msgctxt "#32236" +msgid "on File System Change at Startup" +msgstr "" + +msgctxt "#32237" +msgid "on Idle [secs]" +msgstr "" + +msgctxt "#32238" +msgid "on Interval Alarm" +msgstr "" + +msgctxt "#32239" +msgid "on JSON Notification" +msgstr "" + +msgctxt "#32240" +msgid "on Log Regex" +msgstr "" + +msgctxt "#32241" +msgid "on Log Simple" +msgstr "" + +msgctxt "#32242" +msgid "on Playback Ended" +msgstr "" + +msgctxt "#32243" +msgid "on Playback Paused" +msgstr "" + +msgctxt "#32244" +msgid "on Playback Resumed" +msgstr "" + +msgctxt "#32245" +msgid "on Playback Seek" +msgstr "" + +msgctxt "#32246" +msgid "on Playback Seek Chapter" +msgstr "" + +msgctxt "#32247" +msgid "on Playback Speed Changed" +msgstr "" + +msgctxt "#32248" +msgid "on Playback Started" +msgstr "" + +msgctxt "#32249" +msgid "on Profile Change" +msgstr "" + +msgctxt "#32250" +msgid "on Queue Next Item" +msgstr "" + +msgctxt "#32251" +msgid "on Resume After Idle [secs]" +msgstr "" + +msgctxt "#32252" +msgid "on Scan Finished" +msgstr "" + +msgctxt "#32253" +msgid "on Scan Started" +msgstr "" + +msgctxt "#32254" +msgid "on Screensaver Activated" +msgstr "" + +msgctxt "#32255" +msgid "on Screensaver Deactivated" +msgstr "" + +msgctxt "#32256" +msgid "on Shutdown" +msgstr "" + +msgctxt "#32257" +msgid "on Startup" +msgstr "" + +msgctxt "#32258" +msgid "on Stereoscopic Mode Change " +msgstr "" + +msgctxt "#32259" +msgid "on Window Closed" +msgstr "" + +msgctxt "#32260" +msgid "on Window Opened" +msgstr "" + +msgctxt "#32261" +msgid "Choose event type (click here)" +msgstr "" + +msgctxt "#32262" +msgid "Event:" +msgstr "" + +msgctxt "#32263" +msgid "Write Settings into Kodi log" +msgstr "" + +msgctxt "#32264" +msgid "Settings Regenerated" +msgstr "" + +msgctxt "#32265" +msgid "Incorrect path" +msgstr "" + +msgctxt "#32266" +msgid "New version available for branch: %s" +msgstr "" + +msgctxt "#32267" +msgid "Current version: %s" +msgstr "" + +msgctxt "#32268" +msgid "Available version: %s" +msgstr "" + +msgctxt "#32269" +msgid "Download and install?" +msgstr "" + +msgctxt "#32270" +msgid "A new version is not available for branch: %s" +msgstr "" + +msgctxt "#32271" +msgid "Download and install anyway?" +msgstr "" + +msgctxt "#32272" +msgid "Settings written to log" +msgstr "" + +msgctxt "#32273" +msgid "Process returned data: [%s]\n" +msgstr "" + +msgctxt "#32274" +msgid "Script executable file - browse" +msgstr "" + +msgctxt "#32275" +msgid "Script executable file - edit" +msgstr "" + +msgctxt "#32276" +msgid "" +msgstr "" + +msgctxt "#32277" +msgid "Choose event type (click here)" +msgstr "" + +msgctxt "#32278" +msgid "Note: variables can be subbed (%%=%, _%=space, __%=,):" +msgstr "" + +msgctxt "#32279" +msgid "ws_folder - browse" +msgstr "" + +msgctxt "#32280" +msgid "ws_folder - edit" +msgstr "" + +msgctxt "#32281" +msgid "folder - browse" +msgstr "" + +msgctxt "#32282" +msgid "folder - edit" +msgstr "" + +msgctxt "#32283" +msgid "Upload Log" +msgstr "" + +msgctxt "#32284" +msgid "Choose event type" +msgstr "" + +msgctxt "#32285" +msgid "JsonNotify" +msgstr "" + +msgctxt "#32286" +msgid "Sender string" +msgstr "" + +msgctxt "#32287" +msgid "Request Type" +msgstr "" + +msgctxt "#32288" +msgid "json_rpc_notify" +msgstr "" + +msgctxt "#32289" +msgid "%al=album,%wi=width,%ar=aspectRatio,%at=artist,%ht=height" +msgstr "" + +msgctxt "#32290" +msgid "%st=showtitle,%ep=episode,%mt=mediaType,%se=season" +msgstr "" + +msgctxt "#32291" +msgid "%fn=fileName,%sm=stereomode,%ti=title" +msgstr "" + +msgctxt "#32292" +msgid "%st=showtitle,%sp=speed,%ep=episode,%mt=mediaType" +msgstr "" + +msgctxt "#32293" +msgid "%se=season,%fn=fileName,%sm=stereomode,%ti=title" +msgstr "" + +msgctxt "#32294" +msgid "%fn=fileName,%sm=stereomode,%ti=title,%tm=time" +msgstr "" + +msgctxt "#32295" +msgid "%al=album,%wi=width,%ch=chapter,%ar=aspectRatio,%at=artist" +msgstr "" + +msgctxt "#32296" +msgid "%ht=height,%st=showtitle,%ep=episode,%mt=mediaType" +msgstr "" + +msgctxt "#32297" +msgid "Content-Type (for POST or PUT only)" +msgstr "" + +msgctxt "#32298" +msgid "No actions run for this addon from Programs" +msgstr "" + diff --git a/script.service.kodi.callbacks/resources/lib/__init__.py b/script.service.kodi.callbacks/resources/lib/__init__.py new file mode 100644 index 0000000000..c23aacea2f --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/__init__.py @@ -0,0 +1,88 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import os +import pkgutil +import sys + +import tasks +from resources.lib.kodilogging import KodiLogger +from resources.lib.taskABC import AbstractTask +from resources.lib.utils.kodipathtools import translatepath, setPathExecuteRW, setPathRW + +KodiLogger.setLogLevel(KodiLogger.LOGNOTICE) +log = KodiLogger.log + + +def createUserTasks(): + paths = [translatepath('special://addondata')] + try: + setPathRW(paths[0]) + except OSError: + pass + paths.append(os.path.join(paths[0], 'lib')) + paths.append(os.path.join(paths[1], 'usertasks')) + for path in paths: + if not os.path.isdir(path): + try: + os.mkdir(path) + setPathExecuteRW(path) + except OSError: + pass + for path in paths[1:]: + fn = os.path.join(path, '__init__.py') + if not os.path.isfile(fn): + try: + with open(fn, mode='w') as f: + f.writelines('') + setPathExecuteRW(fn) + except (OSError, IOError): + pass + + +dirn = translatepath(r'special://addondata/lib') +usertasks = None +createUserTasks() +sys.path.insert(0, dirn) +try: + import usertasks +except ImportError: + usertasks = None + log(msg='Failed importing usertasks from addondata') +if usertasks is None: + packages = [tasks] +else: + packages = [tasks, usertasks] +taskdict = {} +tasktypes = [] +for package in packages: + prefix = package.__name__ + "." + for importer, modname, ispkg in pkgutil.iter_modules(package.__path__, prefix): + module = __import__(modname, fromlist="dummy") + for name, cls in module.__dict__.items(): + try: + if issubclass(cls, AbstractTask): + if cls.tasktype != 'abstract': + if cls.tasktype not in tasktypes: + try: + taskdict[cls.tasktype] = {'class': cls, 'variables': cls.variables} + tasktypes.append(cls.tasktype) + except: + raise Exception('Error loading class for %s' % cls.tasktype) + except TypeError: + pass diff --git a/script.service.kodi.callbacks/resources/lib/dialogtb.py b/script.service.kodi.callbacks/resources/lib/dialogtb.py new file mode 100644 index 0000000000..d1303f467d --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/dialogtb.py @@ -0,0 +1,83 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import xbmc +import xbmcgui +import xbmcaddon +import textwrap + +class MessageDialog(xbmcgui.WindowXMLDialog): + + MESSAGE_ACTION_OK = 110 + MESSAGE_EXIT = 111 + MESSAGE_TITLE = 101 + MESSAGE_TEXT = 105 + + def __init__(self, *args, **kwargs): + super(MessageDialog, self).__init__(*args, **kwargs) + self.msg = '' + self.title = '' + + def set_text(self, title, msg): + self.msg = msg + self.title = title + + def onInit(self): + self.getControl(self.MESSAGE_TITLE).setLabel(self.title) + # noinspection PyBroadException + try: + self.getControl(self.MESSAGE_TEXT).setText(self.msg) + except Exception: + pass + + def onAction(self, action): + if action == 1010: + self.close() + + def onClick(self, controlID): + if controlID == self.MESSAGE_ACTION_OK or controlID == self.MESSAGE_EXIT: + self.onAction(1010) + + def onFocus(self, controlID): + pass + + +def show_textbox(title, msg): + _addon_ = xbmcaddon.Addon('script.service.kodi.callbacks') + _cwd_ = xbmc.translatePath(_addon_.getAddonInfo('path')) + msgbox = MessageDialog("DialogTextBox.xml", _cwd_, "Default") + xt = type(msg) + if xt is str or xt is unicode: + wmsg = '\n'.join(textwrap.wrap(msg, 62)) + elif xt is list: + tmsg = [] + for i in msg: + omsg = textwrap.wrap(i, width=62, break_long_words=True) + l1 = [] + for i1 in omsg: + l1.append('i=%s, len=%s' % (i1, len(i1))) + nmsg = '\n'.join(omsg) + '\n' + tmsg.append(nmsg) + wmsg = ''.join(tmsg) + else: + wmsg = '' + msgbox.set_text(title, wmsg) + msgbox.doModal() + del msg + diff --git a/script.service.kodi.callbacks/resources/lib/events.py b/script.service.kodi.callbacks/resources/lib/events.py new file mode 100644 index 0000000000..b6ad67398a --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/events.py @@ -0,0 +1,286 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +def requires_subtopic(): + return ['onFileSystemChange', 'onLogSimple', 'onLogRegex', 'onIdle', 'afterIdle', 'onWindowOpen', 'onWindowClose', + 'onNotification', 'onFileSystemChange', 'onStartupFileChanges', 'onDailyAlarm', 'onIntervalAlarm'] + + +class Events(object): + Player = { + 'onPlayBackStarted': { + 'text': 'on Playback Started', + 'reqInfo': [], + 'optArgs': ['mediaType', 'fileName', 'title', 'aspectRatio', 'width', 'height', 'stereomode', 'season', + 'episode', 'showtitle'], + 'varArgs': {'%mt': 'mediaType', '%fn': 'fileName', '%ti': 'title', '%ar': 'aspectRatio', '%ht': 'height', + '%wi': 'width', '%sm': 'stereomode', '%se': 'season', '%ep': 'episode', '%st': 'showtitle', + '%at': 'artist', '%al': 'album'}, + 'expArgs': {'mediaType': 'movie', 'fileName': 'G:\\movies\\Star Wars - Episode IV\\movie.mkv', + 'title': 'Star Wars Episode IV - A New Hope', 'aspectRatio': '2.35', 'width': '1080'} + }, + 'onPlayBackEnded': { + 'text': 'on Playback Ended', + 'reqInfo': [], + 'optArgs': ['mediaType', 'fileName', 'title', 'aspectRatio', 'width', 'height', 'stereomode', 'season', + 'episode', 'showtitle', 'percentPlayed'], + 'varArgs': {'%mt': 'mediaType', '%fn': 'fileName', '%ti': 'title', '%ar': 'aspectRatio', '%ht': 'height', + '%wi': 'width', '%sm': 'stereomode', '%se': 'season', '%ep': 'episode', '%st': 'showtitle', + '%at': 'artist', '%al': 'album'}, + 'expArgs': {'mediaType': 'movie', 'fileName': 'G:\\movies\\Star Wars - Episode IV\\movie.mkv', + 'title': 'Star Wars Episode IV - A New Hope', 'percentPlayed': '26'} + }, + 'onPlayBackPaused': { + 'text': 'on Playback Paused', + 'reqInfo': [], + 'optArgs': ['time', 'mediaType', 'fileName', 'title', 'aspectRatio', 'width', 'height', 'stereomode', + 'season', 'episode', 'showtitle'], + 'varArgs': {'%tm': 'time', '%mt': 'mediaType', '%fn': 'fileName', '%ti': 'title', '%ar': 'aspectRatio', + '%ht': 'height', '%wi': 'width', '%sm': 'stereomode', '%se': 'season', '%ep': 'episode', + '%st': 'showtitle', '%at': 'artist', '%al': 'album'}, + 'expArgs': {'time': '235.026016235', 'mediaType': 'movie'} + }, + 'onPlayBackResumed': { + 'text': 'on Playback Resumed', + 'reqInfo': [], + 'optArgs': ['time', 'mediaType', 'fileName', 'title', 'aspectRatio', 'width', 'height', 'stereomode', + 'season', 'episode', 'showtitle'], + 'varArgs': {'%tm': 'time', '%mt': 'mediaType', '%fn': 'fileName', '%ti': 'title', '%ar': 'aspectRatio', + '%ht': 'height', '%wi': 'width', '%sm': 'stereomode', '%se': 'season', '%ep': 'episode', + '%st': 'showtitle', '%at': 'artist', '%al': 'album'}, + 'expArgs': {'mediaType': 'movie'} + }, + 'onPlayBackSeek': { + 'text': 'on Playback Seek', + 'reqInfo': [], + 'optArgs': ['time', 'mediaType', 'fileName', 'title', 'aspectRatio', 'width', 'height', 'stereomode', + 'season', 'episode', 'showtitle'], + 'varArgs': {'%tm': 'time', '%mt': 'mediaType', '%fn': 'fileName', '%ti': 'title', '%ar': 'aspectRatio', + '%ht': 'height', '%wi': 'width', '%sm': 'stereomode', '%se': 'season', '%ep': 'episode', + '%st': 'showtitle', '%at': 'artist', '%al': 'album'}, + 'expArgs': {'time': '252615'} + }, + 'onPlayBackSeekChapter': { + 'text': 'on Playback Seek Chapter', + 'reqInfo': [], + 'optArgs': ['chapter', 'mediaType', 'fileName', 'title', 'aspectRatio', 'width', 'height', 'stereomode', + 'season', 'episode', 'showtitle'], + 'varArgs': {'%ch': 'chapter', '%mt': 'mediaType', '%fn': 'fileName', '%ti': 'title', '%ar': 'aspectRatio', + '%ht': 'height', '%wi': 'width', '%sm': 'stereomode', '%se': 'season', '%ep': 'episode', + '%st': 'showtitle', '%at': 'artist', '%al': 'album'}, + 'expArgs': {'chapter': '2'} + }, + 'onPlayBackSpeedChanged': { + 'text': 'on Playback Speed Changed', + 'reqInfo': [], + 'optArgs': ['speed', 'mediaType', 'fileName', 'title', 'aspectRatio', 'width', 'height', 'stereomode', + 'season', 'episode', 'showtitle'], + 'varArgs': {'%sp': 'speed', '%mt': 'mediaType', '%fn': 'fileName', '%ti': 'title', '%ar': 'aspectRatio', + '%ht': 'height', '%wi': 'width', '%sm': 'stereomode', '%se': 'season', '%ep': 'episode', + '%st': 'showtitle', '%at': 'artist', '%al': 'album'}, + 'expArgs': {'speed': '2'} + }, + 'onQueueNextItem': { + 'text': 'on Queue Next Item', + 'reqInfo': [], + 'optArgs': [] + } + } + Monitor = { + 'onCleanFinished': { + 'text': 'on Clean Finished', + 'reqInfo': [], + 'optArgs': ['library'], + 'varArgs': {'%li': 'library'}, + 'expArgs': {'library': 'movies'} + }, + 'onCleanStarted': { + 'text': 'on Clean Started', + 'reqInfo': [], + 'optArgs': ['library'], + 'varArgs': {'%li': 'library'}, + 'expArgs': {'library': 'movies'} + }, + 'onDPMSActivated': { + 'text': 'on DPMS Activated', + 'reqInfo': [], + 'optArgs': [] + }, + 'onDMPSDeactivated': { + 'text': 'on DPMS Deactivated', + 'reqInfo': [], + 'optArgs': [] + }, + 'onNotification': { + 'text': 'on JSON Notification', + 'reqInfo': [('sender', 'text', ''), ('method', 'text', ''), ('data', 'text', '')], + 'optArgs': ['sender', 'method', 'data'], + 'varArgs': {'%se': 'sender', '%me': 'method', '%da': 'data'}, + 'expArgs': {'sender': 'xbmc', 'method': 'VideoLibrary.OnScanStarted', 'data': 'null'} + }, + 'onScanFinished': { + 'text': 'on Scan Finished', + 'reqInfo': [], + 'optArgs': ['library'], + 'varArgs': {'%li': 'library'}, + 'expArgs': {'library': 'movies'} + }, + 'onScanStarted': { + 'text': 'on Scan Started', + 'reqInfo': [], + 'optArgs': ['library'], + 'varArgs': {'%li': 'library'}, + 'expArgs': {'library': 'movies'} + }, + 'onScreensaverActivated': { + 'text': 'on Screensaver Activated', + 'reqInfo': [], + 'optArgs': [] + }, + 'onScreensaverDeactivated': { + 'text': 'on Screensaver Deactivated', + 'reqInfo': [], + 'optArgs': [] + }, + } + CustomLoop = { + 'onStereoModeChange': { + 'text': 'on Stereoscopic Mode Change ', + 'reqInfo': [], + 'optArgs': ['stereoMode'], + 'varArgs': {'%sm': 'stereoMode'}, + 'expArgs': {'stereoMode': 'split_vertical'} + }, + 'onProfileChange': { + 'text': 'on Profile Change', + 'reqInfo': [], + 'optArgs': ['profilePath'], + 'varArgs': {'%pp': 'profilePath'}, + 'expArgs': {'profilePath': 'C:\\Users\\Ken User\\AppData\\Roaming\\Kodi\\userdata\\'} + }, + 'onWindowOpen': { + 'text': 'on Window Opened', + 'reqInfo': [('windowIdO', 'int', 0)], + 'optArgs': ['windowId'], + 'varArgs': {'%wi': 'windowId'}, + 'expArgs': {'windowId': '10000'} + }, + 'onWindowClose': { + 'text': 'on Window Closed', + 'reqInfo': [('windowIdC', 'int', 0)], + 'optArgs': ['windowId'], + 'varArgs': {'%wi': 'windowId'}, + 'expArgs': {'windowId': '10000'} + }, + 'onIdle': { + 'text': 'on Idle [secs]', + 'reqInfo': [('idleTime', 'int', '60')], + 'optArgs': [] + }, + 'afterIdle': { + 'text': 'on Resume After Idle [secs]', + 'reqInfo': [('afterIdleTime', 'int', '60')], + 'optArgs': [] + } + } + Basic = { + 'onStartup': { + 'text': 'on Startup', + 'reqInfo': [], + 'optArgs': [] + }, + 'onShutdown': { + 'text': 'on Shutdown', + 'reqInfo': [], + 'optArgs': ['pid'], + 'varArgs': {'%pi': 'pid'}, + 'expArgs': {'pid': '10000'} + } + } + Log = { + 'onLogSimple': { + 'text': 'on Log Simple', + 'reqInfo': [('matchIf', 'text', ''), ('rejectIf', 'text', '')], + 'optArgs': ['logLine'], + 'varArgs': {'%ll': 'logLine'}, + 'expArgs': { + 'logLine': '16:10:31 T:13092 NOTICE: fps: 23.976024, pwidth: 1916, pheight: 796, dwidth: 1916, dheight: 796'} + }, + 'onLogRegex': { + 'text': 'on Log Regex', + 'reqInfo': [('matchIf', 'text', ''), ('rejectIf', 'text', '')], + 'optArgs': ['logLine'], + 'varArgs': {'%ll': 'logLine'}, + 'expArgs': { + 'logLine': '16:10:31 T:13092 NOTICE: fps: 23.976024, pwidth: 1916, pheight: 796, dwidth: 1916, dheight: 796'} + } + } + Watchdog = { + 'onFileSystemChange': { + 'text': 'on File System Change', + 'reqInfo': [('folder', 'sfolder', ''), ('patterns', 'text', ''), ('ignore_patterns', 'text', ''), + ('ignore_directories', 'bool', 'false'), ('recursive', 'bool', 'false')], + 'optArgs': ['path', 'event'], + 'varArgs': {'%pa': 'path', '%ev': 'event'}, + 'expArgs': {'path': 'C:\\Users\\User\\text.txt', 'event': 'deleted'} + } + } + WatchdogStartup = { + 'onStartupFileChanges': { + 'text': 'on File System Change at Startup', + 'reqInfo': [('ws_folder', 'sfolder', ''), ('ws_patterns', 'text', ''), ('ws_ignore_patterns', 'text', ''), + ('ws_ignore_directories', 'bool', 'false'), ('ws_recursive', 'bool', 'false')], + 'optArgs': ['listOfChanges'], + 'varArgs': {'%li': 'listOfChanges'}, + 'expArgs': {'listOfChanges': str( + {'FilesDeleted': ['C:\\Users\\User\\text.txt'], 'FilesCreated': ['C:\\movies\\Fargo.mp4']})} + } + } + Schedule = { + 'onDailyAlarm': { + 'text': 'on Daily Alarm', + 'reqInfo': [('hour', 'int', '13'), ('minute', 'int', '0')], + 'optArgs': [] + }, + 'onIntervalAlarm': { + 'text': 'on Interval Alarm', + 'reqInfo': [('hours', 'int', '0'), ('minutes', 'int', '30'), ('seconds', 'int', '0')], + 'optArgs': [] + }, + } + + def __init__(self): + self.AllEvents = self._AllEvents() + self.AllEventsSimple = self._AllEventsSimple() + + @staticmethod + def mergedicts(*dicts): + result = {} + for d in dicts: + result.update(d) + return result + + @staticmethod + def _AllEvents(): + return Events.mergedicts(Events.Player, Events.Monitor, Events.CustomLoop, Events.Basic, Events.Log, + Events.Watchdog, Events.WatchdogStartup, Events.Schedule) + + @staticmethod + def _AllEventsSimple(): + return Events._AllEvents().keys() diff --git a/script.service.kodi.callbacks/resources/lib/helpers/onQuit.bat b/script.service.kodi.callbacks/resources/lib/helpers/onQuit.bat new file mode 100644 index 0000000000..4d99059acd --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/helpers/onQuit.bat @@ -0,0 +1,9 @@ +@echo off +:loop +tasklist | find " %1 " >nul +if not errorlevel 1 ( + timeout /t 2 >nul + goto :loop +) +REM Put the code you want to run here +notepad.exe \ No newline at end of file diff --git a/script.service.kodi.callbacks/resources/lib/helpers/onQuit.sh b/script.service.kodi.callbacks/resources/lib/helpers/onQuit.sh new file mode 100644 index 0000000000..feb387695d --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/helpers/onQuit.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +while kill -0 $1 > /dev/null 2>&1 +do + sleep 0.2 +done +# insert your code here \ No newline at end of file diff --git a/script.service.kodi.callbacks/resources/lib/helpers/sudohelper.sh b/script.service.kodi.callbacks/resources/lib/helpers/sudohelper.sh new file mode 100644 index 0000000000..4f69919984 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/helpers/sudohelper.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$*" \ No newline at end of file diff --git a/script.service.kodi.callbacks/resources/lib/kodilogging.py b/script.service.kodi.callbacks/resources/lib/kodilogging.py new file mode 100644 index 0000000000..954a656d88 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/kodilogging.py @@ -0,0 +1,68 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import xbmc +import threading + +def log(loglevel=xbmc.LOGNOTICE, msg=''): + if isinstance(msg, str): + msg = msg.decode("utf-8") + message = u"$$$ [%s] - %s" % ('kodi.callbacks', msg) + xbmc.log(msg=message.encode("utf-8"), level=loglevel) + +class KodiLogger(object): + LOGDEBUG = 0 + LOGERROR = 4 + LOGFATAL = 6 + LOGINFO = 1 + LOGNONE = 7 + LOGNOTICE = 2 + LOGSEVERE = 5 + LOGWARNING = 3 + _instance = None + _lock = threading.Lock() + selfloglevel = xbmc.LOGDEBUG + kodirunning = True + + def __new__(cls): + if xbmc.getFreeMem() == long(): + KodiLogger.kodirunning = False + if KodiLogger._instance is None: + with KodiLogger._lock: + if KodiLogger._instance is None: + KodiLogger._instance = super(KodiLogger, cls).__new__(cls) + return KodiLogger._instance + + def __init__(self): + KodiLogger._instance = self + + @staticmethod + def setLogLevel(arg): + KodiLogger.selfloglevel = arg + + @staticmethod + def log(loglevel=None, msg=''): + if loglevel is None: + loglevel = KodiLogger.selfloglevel + if isinstance(msg, str): + msg = msg.decode("utf-8") + if KodiLogger.kodirunning: + message = u"$$$ [%s] - %s" % ('kodi.callbacks', msg) + xbmc.log(msg=message.encode("utf-8"), level=loglevel) + else: + print msg \ No newline at end of file diff --git a/script.service.kodi.callbacks/resources/lib/kodisettings/__init__.py b/script.service.kodi.callbacks/resources/lib/kodisettings/__init__.py new file mode 100644 index 0000000000..0463614978 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/kodisettings/__init__.py @@ -0,0 +1,18 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# 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 . +# diff --git a/script.service.kodi.callbacks/resources/lib/kodisettings/generate_xml.py b/script.service.kodi.callbacks/resources/lib/kodisettings/generate_xml.py new file mode 100644 index 0000000000..a40761e033 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/kodisettings/generate_xml.py @@ -0,0 +1,317 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import codecs +import os + +import xbmcaddon +from resources.lib import taskdict +from resources.lib.events import Events +from resources.lib.kodilogging import KodiLogger +from resources.lib.kodisettings import struct +from resources.lib.utils.poutil import KodiPo, PoDict + +kodipo = KodiPo() +kodipo.updateAlways = True +glsid = kodipo.getLocalizedStringId +__ = kodipo.podict.has_msgctxt +kl = KodiLogger() +log = kl.log + +podict = PoDict() +from default import branch as branch + +pofile = os.path.join(xbmcaddon.Addon('script.service.kodi.callbacks').getAddonInfo('path').decode("utf-8"), + 'resources', 'language', 'English', 'strings.po') +if pofile.startswith('resources'): + pofile = r'C:\Users\Ken User\AppData\Roaming\Kodi\addons\script.service.kodi.callbacks\resources\language\English\strings.po' + +podict.read_from_file(pofile) + + +def generate_settingsxml(fn=None): + taskcontrols, tasks = createTasks() + eventcontrols, podirty = createEvents(tasks) + generalcontrols = createGeneral() + updatecontrols = createUpdate() + settings = struct.Settings() + mysettings = [('Tasks', taskcontrols), ('Events', eventcontrols), ('General', generalcontrols), + ('Update', updatecontrols)] + for category, controls in mysettings: + settings.addCategory(struct.Category(category)) + for control in controls: + settings.addControl(category, control) + + output = settings.render() + writetofile(fn, output) + if podirty is True: + podict.write_to_file(pofile) + + +def createTasks(): + taskchoices = ['none'] + for key in sorted(taskdict.keys()): + taskchoices.append(key) + tasks = [] + last_id = None + taskcontrols = [] + for i in xrange(1, 11): + tasks.append('Task %i' % i) + prefix = "T%s" % str(i) + curTaskType = '%s.type' % prefix + if i == 1: + + taskcontrols.append(struct.Lsep('%s.div' % prefix, 'Task %i' % i)) + taskcontrols.append(struct.LabelEnum('%s.type' % prefix, 'Task', default='none', lvalues=taskchoices)) + else: + conditional = struct.Conditional(struct.Conditional.OP_NOT_EQUAL, 'none', last_id) + taskcontrols.append(struct.Lsep('%s.div' % prefix, 'Task %i' % i, visible=conditional)) + taskcontrols.append( + struct.LabelEnum('%s.type' % prefix, 'Task', default='none', lvalues=taskchoices, visible=conditional)) + conditional = struct.Conditional(struct.Conditional.OP_NOT_EQUAL, 'none', curTaskType) + taskcontrols.append( + struct.Number('%s.maxrunning' % prefix, 'Max num of this task running simultaneously (-1=no limit)', + default=-1, visible=conditional)) + taskcontrols.append( + struct.Number('%s.maxruns' % prefix, 'Max num of times this task runs (-1=no limit)', default=-1, + visible=conditional)) + taskcontrols.append(struct.Number('%s.refractory' % prefix, 'Refractory period in secs (-1=none)', default=-1, + visible=conditional)) + + for key in sorted(taskdict.keys()): + for var in taskdict[key]['variables']: + varset = var['settings'] + if varset['type'] == 'sfile': + mytype = 'browser' + elif varset['type'] == 'file': + mytype = 'browser' + else: + mytype = varset['type'] + try: + option = varset['option'] + except KeyError: + conditionals = struct.Conditional(struct.Conditional.OP_EQUAL, unicode(key), curTaskType) + if varset['type'] == 'sfile': + labelbrowse = u'%s - browse' % varset['label'] + labeledit = u'%s - edit' % varset['label'] + taskcontrols.append( + struct.FileBrowser(u'%s.%s' % (prefix, var['id']), labelbrowse, default=varset['default'], + fbtype=struct.FileBrowser.TYPE_FILE, visible=conditionals)) + taskcontrols.append( + struct.Text(u'%s.%s' % (prefix, var['id']), labeledit, default=varset['default'], + visible=conditionals)) + elif varset['type'] == 'labelenum': + Control = struct.getControlClass[mytype] + taskcontrols.append( + Control('%s.%s' % (prefix, var['id']), label=varset['label'], values=varset['values'], + default=varset['default'], + visible=conditionals)) + else: + Control = struct.getControlClass[mytype] + taskcontrols.append( + Control('%s.%s' % (prefix, var['id']), label=varset['label'], + default=varset['default'], + visible=conditionals)) + else: + conditionals = struct.Conditional(struct.Conditional.OP_EQUAL, unicode(key), curTaskType) + if varset['type'] == 'sfile': + labelbrowse = '%s - browse' % varset['label'] + labeledit = '%s - edit' % varset['label'] + taskcontrols.append( + struct.FileBrowser('%s.%s' % (prefix, var['id']), labelbrowse, default=varset['default'], + option=option, fbtype=struct.FileBrowser.TYPE_FILE, + visible=conditionals)) + taskcontrols.append( + struct.Text('%s.%s' % (prefix, var['id']), labeledit, default=varset['default'], + visible=conditionals)) + else: + Control = struct.getControlClass[mytype] + taskcontrols.append( + Control('%s.%s' % (prefix, var['id']), varset['label'], default=varset['default'], + option=option, visible=conditionals)) + last_id = curTaskType + return taskcontrols, tasks + + +def createEvents(tasks): + podirty = False + allevts = Events().AllEvents + evts = [] + for evtkey in allevts.keys(): + evts.append(allevts[evtkey]['text']) + evts.sort() + evts.insert(0, 'None') + levts = [] + for evt in evts: + levts.append(glsid(evt)) + levts = "|".join(levts) + eventcontrols = [] + + last_id = None + for i in xrange(1, 11): + prefix = 'E%s' % str(i) + curEvtType = '%s.type' % prefix + action_evt = 'RunScript(script.service.kodi.callbacks, lselector, id=%s.type, heading=%s, lvalues=%s)' % ( + prefix, glsid('Choose event type'), levts) + if i == 1: + eventcontrols.append(struct.Lsep('%s.lsep' % prefix, 'Event %i' % i)) + eventcontrols.append( + struct.Action('%s.action' % prefix, 'Choose event type (click here)', action=action_evt)) + eventcontrols.append( + struct.Select('%s.type-v' % prefix, 'Event:', default='None', enable=False, lvalues=evts)) + else: + conditionals = struct.Conditional(struct.Conditional.OP_NOT_EQUAL, glsid('None'), last_id) + eventcontrols.append(struct.Lsep('%s.lsep' % prefix, 'Event %i' % i, visible=conditionals)) + eventcontrols.append( + struct.Action('%s.action' % prefix, 'Choose event type (click here)', action=action_evt, + visible=conditionals)) + eventcontrols.append( + struct.Select('%s.type-v' % prefix, 'Event:', default=glsid('None'), enable=False, lvalues=evts, + visible=conditionals)) + conditionals = struct.Conditional(struct.Conditional.OP_NOT_EQUAL, glsid('None'), curEvtType) + eventcontrols.append(struct.Text(curEvtType, '', default=glsid('None'), visible=False)) + + eventcontrols.append( + struct.LabelEnum('%s.task' % prefix, 'Task', default='Task 1', lvalues=tasks, visible=conditionals)) + + for evtkey in allevts.keys(): + evt = allevts[evtkey] + conditionals = struct.Conditional(struct.Conditional.OP_EQUAL, glsid(evt['text']), curEvtType) + for req in evt['reqInfo']: + r1 = req[1] + if r1 in ['float', 'int']: + r1 = 'number' + if r1 == 'sfolder': + mytype = 'browse' + else: + mytype = r1 + + if r1 == 'sfolder': + labelbrowse = '%s - browse' % req[0] + labeledit = '%s - edit' % req[0] + eventcontrols.append( + struct.FileBrowser('%s.%s' % (prefix, req[0]), labelbrowse, struct.FileBrowser.TYPE_FOLDER, + default=req[2], visible=conditionals)) + eventcontrols.append( + struct.Text('%s.%s' % (prefix, req[0]), labeledit, default=req[2], visible=conditionals)) + else: + Control = struct.getControlClass[mytype] + eventcontrols.append( + Control(sid='%s.%s' % (prefix, req[0]), label=req[0], default=req[2], visible=conditionals)) + eventcontrols.append( + struct.Lsep(label='Note: variables can be subbed (%%=%, _%=space, __%=,):', visible=conditionals)) + try: + vargs = evt['varArgs'] + except KeyError: + vargs = {} + vs = [] + for key in vargs.keys(): + vs.append('%s=%s' % (key, vargs[key])) + vs = ','.join(vs) + brk = 60 + if len(vs) > 0: + if len(vs) < brk: + found, strid = podict.has_msgid(vs) + if found is False: + podict.addentry(strid, vs) + podirty = True + eventcontrols.append(struct.Lsep(label=vs, visible=conditionals)) + else: + startindex = 0 + x = 0 + lines = [] + while startindex + brk < len(vs): + x = vs.rfind(',', startindex, startindex+brk) + found, strid = podict.has_msgid(vs[startindex:x]) + if found is False: + podict.addentry(strid, vs[startindex:x]) + podirty = True + lines.append(vs[startindex:x]) + startindex = x + 1 + found, strid = podict.has_msgid(vs[x + 1:]) + if found is False: + podict.addentry(strid, vs[x + 1:]) + podirty = True + lines.append(vs[x+1:]) + for line in lines: + eventcontrols.append(struct.Lsep(label=line, visible=conditionals)) + conditionals = struct.Conditional(struct.Conditional.OP_NOT_EQUAL, unicode(glsid('None')), curEvtType) + eventcontrols.append( + struct.Text('%s.userargs' % prefix, 'Var subbed arg string', default='', visible=conditionals)) + eventcontrols.append(struct.Action('%s.test' % prefix, 'Test Command (click OK to save changes first)', + action='RunScript(script.service.kodi.callbacks, %s)' % prefix, + visible=conditionals)) + last_id = curEvtType + return eventcontrols, podirty + + +def createGeneral(): + generalcontrols = [] + generalcontrols.append(struct.Bool('Notify', 'Display Notifications when Tasks Run?', default=False)) + generalcontrols.append(struct.Number('LoopFreq', 'Loop Pooling Frequency (ms)', default=500)) + generalcontrols.append(struct.Number('LogFreq', 'Log Polling Frequency (ms)', default=500)) + generalcontrols.append(struct.Number('TaskFreq', 'Task Polling Frequency (ms)', default=100)) + generalcontrols.append(struct.Bool('loglevel', 'Show debugging info in normal log?', default=False)) + generalcontrols.append(struct.Action('logsettings', 'Write Settings into Kodi log', + action='RunScript(script.service.kodi.callbacks, logsettings)')) + generalcontrols.append(struct.Action('uploadlog', 'Upload Log', action='RunScript(script.xbmc.debug.log)', + visible=struct.Conditionals(struct.Conditional(struct.Conditional.OP_HAS_ADDON, + 'script.xbmc.debug.log')))) + generalcontrols.append( + struct.Action('regen', 'Regenerate settings.xml (Developers Only)', + action='RunScript(script.service.kodi.callbacks, regen)')) + generalcontrols.append(struct.Action('test', 'Test addon native tasks (see log for output)', + action='RunScript(script.service.kodi.callbacks, test)')) + return generalcontrols + + +def createUpdate(): + updatecontrols = [] + updatecontrols.append(struct.Lsep(label='Before any installation, the current is backed up to userdata/addon_data')) + if branch != 'master': + updatecontrols.append( + struct.Text('installed branch', 'Currently installed branch', default='nonrepo', enable=False)) + updatecontrols.append(struct.Select('repobranchname', 'Repository branch name for downloads', default='nonrepo', + values=['master', 'nonrepo'])) + updatecontrols.append( + struct.Bool('autodownload', 'Automatically download/install latest from GitHub on startup?', default=False)) + updatecontrols.append(struct.Action('checkupdate', 'Check for update on GitHub', + action='RunScript(script.service.kodi.callbacks, checkupdate)')) + updatecontrols.append(struct.Bool('silent_install', 'Install without prompts?', visible=False, default=False)) + updatecontrols.append(struct.Action('updatefromzip', 'Update from downloaded zip', + action='RunScript(script.service.kodi.callbacks, updatefromzip)')) + updatecontrols.append(struct.Action('restorebackup', 'Restore from previous back up', + action='RunScript(script.service.kodi.callbacks, restorebackup)')) + return updatecontrols + + +def writetofile(fn, output): + if fn is None: + fn = os.path.join(xbmcaddon.Addon('script.service.kodi.callbacks').getAddonInfo('path').decode("utf-8"), + 'resources', 'settings.xml') + with codecs.open(fn, 'wb', 'UTF-8') as f: + f.writelines(output) + try: + log(msg='Settings.xml rewritten') + except TypeError: + pass + + +if __name__ == '__main__': + generate_settingsxml( + r'C:\Users\Ken User\AppData\Roaming\Kodi\addons\script.service.kodi.callbacks\resources\settings.xml') diff --git a/script.service.kodi.callbacks/resources/lib/kodisettings/struct.py b/script.service.kodi.callbacks/resources/lib/kodisettings/struct.py new file mode 100644 index 0000000000..043e5ae58f --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/kodisettings/struct.py @@ -0,0 +1,1210 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import abc +from resources.lib.utils.poutil import KodiPo +from resources.lib.kodilogging import KodiLogger +log = KodiLogger.log +kodipo = KodiPo() +kodipo.updateAlways = True +_ = kodipo.getLocalizedStringId + +def getSettingMock(sid): + assert isinstance(sid, str) or isinstance(sid, unicode) + return 'none' + +try: + import xbmcaddon + getSetting = xbmcaddon.Addon().getSetting +except ImportError: + getSetting = getSettingMock + +class Settings(object): + """ + Class representing the top level portion of the settings.xml + Contains categories + """ + + def __init__(self): + self._categories = [] + self._controldict = {} + self._controldictbyrefid = {} + self.id_position = {} + self.duplicateids = [] + self.values = {} + + def category(self, label): + """ + Retrieves a category by label + :param label: English non-localized unicode or string for label + :type label: unicode or str + :return: + :rtype: Category + """ + assert (isinstance(label, unicode) or isinstance(label, str)) + label = unicode(label) + ret = None + for c in self._categories: + if c.label == label: + ret = c + break + if ret is None: + raise KeyError('Category not found for: %s' % label) + else: + return ret + + def addCategory(self, category): + """ + Add a category. Categories are listed as tabbed pages in settings. + :param category: + :type category: Category or str or unicode + :return: + :rtype: Category + """ + if isinstance(category, str) or isinstance(category, unicode): + category = Category(unicode(category)) + self._categories.append(category) + elif isinstance(category, Category): + self._categories.append(category) + else: + raise TypeError('Add Category must be string or Category') + return category + + def addControl(self, catgorylabel, control): + """ + Add a control to a specific category. + :param catgorylabel: + :type catgorylabel: unicode or str + :param control: + :type control: Control + """ + assert isinstance(catgorylabel, unicode) or isinstance(catgorylabel, str) + assert isinstance(control, Control) + try: + c = self.category(catgorylabel) + except KeyError: + raise KeyError('No category created with that label: %s' % catgorylabel) + else: + c.addControl(control) + if control.internal_ref != u'': + if self._controldict.has_key(control.internal_ref): + log(msg='Warning - control with duplicate internal reference: %s' % control.internal_ref) + else: + self._controldict[control.internal_ref] = control + + + def control(self, internal_ref): + """ + Returns the instance of Control associated with the internal reference id, if the control has a unique id. + Unless a specific internal reference id was provided during instantiation, the sid is the internal + reference id. + :param internal_ref: + :type internal_ref: unicode or str + :return: + :rtype: Control + """ + assert isinstance(internal_ref, unicode) or isinstance(internal_ref, str) + internal_ref = unicode(internal_ref) + try: + return self._controldict[internal_ref] + except KeyError: + raise KeyError("No control found for sid: %s" % internal_ref) + + @staticmethod + def renderHead(): + line1 = u'' + line2 = u'' + return [line1, line2] + + @staticmethod + def renderTail(): + return [u''] + + def render(self): + """ + Renders the xml version of the contained Categories and their Controls. + :return: Unicode string representing the xml + :rtype: unicode + """ + self.buildIndices() + output = self.renderHead() + for category in self._categories: + output += category.render(self) + output += self.renderTail() + output = u'\n'.join(output) + return output + + def buildIndices(self): + """ + Builds the index of positions of the currently contained categories and controls to be used for referenced + conditionals. + """ + curindex = 3 + for category in self._categories: + curindex += 1 + for control in category._controls: + curindex += control.weight + if control.internal_ref != u'': + if not self.id_position.has_key(control.internal_ref): + self.id_position[control.internal_ref] = curindex + else: + log(msg='Warning duplicate key found: %s [%s, %s]' % (control.internal_ref, control.label, self.control(control.internal_ref).label)) + self.duplicateids.append(control.internal_ref) + control._outputindex = curindex + curindex += 1 + + def read(self): + for category in self._categories: + self.values.update(category.read()) + + +class Category(object): + """ + Class representing a category. + """ + def __init__(self, label): + """ + :param label: + :type label: unicode or str + """ + assert (isinstance(label, unicode) or isinstance(label, str)) + self.label = unicode(label) + self._controls = [] + self.indent = u' ' + + def addControl(self, control): + """ + Adds a control to the category + :param control: + :type control: Control + """ + assert isinstance(control,Control) + self._controls.append(control) + + def control(self, sid): + """ + Retrieves control by sid + :param sid: + :type sid: unicode + :return: + :rtype: Control + """ + assert isinstance(sid, unicode) + ret = None + for c in self._controls: + if c.id == sid: + ret = c + break + if ret is None: + raise KeyError("Control not found for id: %s" % sid) + else: + return ret + + def renderHead(self): + return [u'%s' % (self.indent, _(self.label))] + + def renderTail(self): + return [u'%s' % self.indent] + + def render(self, parent): + """ + Renders the category and its controls to xml + :param parent: + :type parent: resources.lib.kodisettings.struct.Settings + :return: + :rtype: unicode + """ + output = self.renderHead() + for control in self._controls: + assert isinstance(control, Control) + tmp = control.render(parent) + output += tmp + output += self.renderTail() + return output + + def read(self): + ret = {} + for control in self._controls: + ret.update(control.read()) + +class Control(object): + """ + Base class for controls + """ + __metaclass__ = abc.ABCMeta + def __init__(self, stype, sid=u'', label=u'', enable=None, visible=None, subsetting=False, weight=1, internal_ref=None): + """ + + :param stype: The internally used type of control. + :type stype: str + :param sid: The settings id for the control. + :type sid: unicode or str + :param label: The label to be displayed. If it is a five digit integer, it will look in the po file for a localized string. + :type label: unicode or str + :param enable: If evaluates to false, the control is shown as greyed out and disabled. + :type enable: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param visible: If evaluates to false, the control is not visible (hidden). + :type visible: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param subsetting: If true, a dash is shown before the label. + :type subsetting: bool + :param weight: Internally used; the number of lines the control consumes. + :type weight: int + :param internal_ref: If needed, a separate internal reference id for the control. Needed if more than one control shares the same sid (and values). + :type internal_ref: unicode or string + """ + if isinstance(sid, str): + sid = unicode(sid) + if isinstance(label, str): + label = unicode(label) + assert isinstance(sid, unicode) + assert isinstance(label, unicode) + if enable is not None: + if isinstance(enable, bool): + enable = Conditionals(Conditional(Conditional.OP_BOOLEAN, enable)) + elif isinstance(enable, Conditional): + enable = Conditionals(enable) + else: + assert isinstance(enable, Conditionals) + if visible is not None: + if isinstance(visible, bool): + visible = Conditionals(Conditional(Conditional.OP_BOOLEAN, visible)) + elif isinstance(visible, Conditional): + visible = Conditionals(visible) + else: + assert isinstance(visible, Conditionals) + if internal_ref is not None: + assert isinstance(internal_ref, str) or isinstance(internal_ref, unicode) + self.internal_ref = unicode(internal_ref) + else: + self.internal_ref = sid + self.sid = sid + self.stype = stype + self.label = label + self.enable = enable + self.visible = visible + self.subsetting = subsetting + self._outputindex = 0 + self.indent = u' ' + self.weight = weight + + @abc.abstractmethod + def render(self, parent): + pass + + def requiredrenderlist(self, parent): + """ + + :param parent: A reference to the calling instantiation of Setttings. + :type parent: resources.lib.kodisettings.struct.Settings + :return: A list of rendered unicode strings. + :rtype:list + """ + if self.sid != u'': + elements = [u'id="%s"' % self.sid] + else: + elements = [] + elements += [u'label="%s"' % _(self.label)] + elements += [u'type="%s"' % self.stype] + if self.subsetting is True: + elements += [u'subsetting="true"'] + if self.enable is not None: + elements += [u'enable="%s"' % self.enable.render(self, parent)] + if self.visible is not None: + elements += [u'visible="%s"' % self.visible.render(self, parent)] + return elements + + def read(self): + if self.sid != u'': + return {self.sid: getSetting(self.sid)} + else: + return None + +class Sep(Control): + """ + Adds a horizontal separating line between other elements. + """ + def __init__(self): + super(Sep, self).__init__('sep') + + def render(self, parent): + assert isinstance(parent, Settings) + return [u'%s' % self.indent] + +class Lsep(Control): + """ + Shows a horizontal line with a text. + """ + def __init__(self, sid=u'', label=u'', visible=None): + """ + + :param sid: The settings id for the control. + :type sid: unicode or str + :param label: The label to be displayed. If it is a five digit integer, it will look in the po file for a localized string. + :type label: unicode or str + :param visible: If evaluates to false, the control is not visible (hidden). + :type visible: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + """ + super(Lsep, self).__init__('lsep', sid, label, visible=visible) + + + def render(self, parent): + elements = [u'%s'] + return [u' '.join(elements)] + + +class Text(Control): + """ + Text input elements allow a user to input text in various formats. + """ + def __init__(self, sid=u'', label=u'', enable=None, visible=None, subsetting=False, option=None, default=None, internal_ref=None): + """ + + :param sid: + :type sid: unicode or str + :param label: + :type label: unicode or str + :param enable: + :type enable: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param visible: + :type visible: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param subsetting: + :type subsetting: bool + :param option: "hidden"|"urlencoded" (optional) + :type option: unicode or str + :param default: (optional) - the default value. + :type default: unicode or str + :return: + :rtype: + """ + super(Text, self).__init__('text', sid, label, enable, visible, subsetting, internal_ref=internal_ref) + self.option = option + if option is not None: + if option == 'hidden': + self.option = unicode(option) + elif option == 'urlencoded': + self.option = unicode(option) + else: + raise SyntaxError('Text "option" not defined: %s' % option) + if default is not None: + assert (isinstance(default, unicode) or isinstance(default, str)) + self.default = unicode(default) + else: + self.default = None + + def render(self, parent): + elements = [u'%s'] + return [u' '.join(elements)] + +class Ipaddress(Control): + """ + + """ + def __init__(self, sid=u'', label=u'', enable=None, visible=None, subsetting=False, default=None, internal_ref=None): + """ + + :param sid: + :type sid: unicode or str + :param label: + :type label: unicode or str + :param enable: + :type enable: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param visible: + :type visible: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param subsetting: + :type subsetting: bool + :param default: + :type default: unicode or str + :return: + :rtype: + """ + super(Ipaddress, self).__init__('ipaddress', sid, label, enable, visible, subsetting, internal_ref=internal_ref) + if default is not None: + assert (isinstance(default, unicode) or isinstance(default, str)) + self.default = unicode(default) + else: + self.default = None + + def render(self, parent): + elements = [u'%s'] + return [u' '.join(elements)] + +class Number(Control): + """ + Allows the user to enter an integer using up/down buttons. + """ + def __init__(self, sid=u'', label=u'', enable=None, visible=None, subsetting=False, default=None, internal_ref=None): + """ + + :param sid: + :type sid: unicode or str + :param label: + :type label: unicode or str + :param enable: + :type enable: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param visible: + :type visible: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param subsetting: + :type subsetting: bool + :param default: + :type default: int + :return: + :rtype: + """ + super(Number, self).__init__('number', sid, label, enable, visible, subsetting, internal_ref=internal_ref) + if default is not None: + if isinstance(default, int): + self.default = unicode(str(default)) + else: + self.default = unicode(default) + + def render(self, parent): + elements = [u'%s'] + return [u' '.join(elements)] + + def read(self): + try: + value = int(getSetting(self.sid)) + except ValueError: + value = 0 + return {self.sid: value} + + +class Slider(Control): + """ + Allows the user to enter a number using a horizontal sliding bar. + """ + def __init__(self, sid=u'', label=u'', srangemin=0, srangemax=100, srangestep=None, option=u'int', enable=None, visible=None, subsetting=False, default=None, internal_ref=None): + """ + + :param sid: + :type sid: unicode or str + :param label: + :type label: unicode or str + :param srangemin: + :type srangemin: int or float or str or unicode + :param srangemax: + :type srangemax: int or float or str or unicode + :param srangestep: + :type srangestep: int or float or str or unicode + :param option: + :type option: unicode or str + :param enable: + :type enable: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param visible: + :type visible: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param subsetting: + :type subsetting:bool + :param default: + :type default: unicode or str + :return: + :rtype: + """ + + super(Slider, self).__init__('slider', sid, label, enable, visible, subsetting, internal_ref=internal_ref) + self.srangemin = unicode(srangemin) + self.srangemax = unicode(srangemax) + if srangestep is not None: + self.srangestep = unicode(srangestep) + else: + self.srangestep = u'' + assert isinstance(option, unicode) or isinstance(option, str) + option = unicode(option) + if option == u'int' or option == u'float' or option == u'percent': + self.option = option + else: + raise SyntaxError('Slider option must be "int|float|percent" got: %s' % option) + if default is not None: + assert (isinstance(default, unicode) or isinstance(default, str)) + self.default = unicode(default) + else: + self.default = None + + def render(self, parent): + elements = [u'%s'] + return [u' '.join(elements)] + + def read(self): + rawvalue = getSetting(self.sid) + if self.option == u'int': + try: + value = int(rawvalue) + except ValueError: + value = 0 + else: + try: + value = float(rawvalue) + except ValueError: + value = 0.00 + else: + if self.option == u'percent': + value *= 100.0 + return {self.sid:value} + +class Date(Control): + """ + Displays a date picker dialog box. + """ + def __init__(self, sid=u'', label=u'', enable=None, visible=None, subsetting=False, default=None, internal_ref=None): + """ + + :param sid: + :type sid: unicode or str + :param label: + :type label: unicode or str + :param enable: + :type enable: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param visible: + :type visible: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param subsetting: + :type subsetting: bool + :param default: + :type default: unicode or str + :return: + :rtype: + """ + super(Date, self).__init__('date', sid, label, enable, visible, subsetting, internal_ref=internal_ref) + if default is not None: + assert (isinstance(default, unicode) or isinstance(default, str)) + self.default = unicode(default) + else: + self.default = None + + def render(self, parent): + elements = [u'%s'] + return [u' '.join(elements)] + +class Time(Control): + """ + Displays a time picker dialog box. + """ + def __init__(self, sid=u'', label=u'', enable=None, visible=None, subsetting=False, default=None, internal_ref=None): + """ + + :param sid: + :type sid: unicode or str + :param label: + :type label: unicode or str + :param enable: + :type enable: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param visible: + :type visible: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param subsetting: + :type subsetting: bool + :param default: + :type default: unicode or str + :return: + :rtype: + """ + super(Time, self).__init__('time', sid, label, enable, visible, subsetting, internal_ref=internal_ref) + if default is not None: + assert (isinstance(default, unicode) or isinstance(default, str)) + self.default = unicode(default) + else: + self.default = None + + def render(self, parent): + elements = [u'%s'] + return [u' '.join(elements)] + +class Bool(Control): + """ + Boolean input elements allow a user to switch a setting on or off. + """ + def __init__(self, sid, label, enable=None, visible=None, subsetting=False, default=None, internal_ref=None): + """ + + :param sid: + :type sid: unicode or str + :param label: + :type label: unicode or str + :param enable: + :type enable: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param visible: + :type visible: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param subsetting: + :type subsetting: bool + :param default: + :type default: unicode or str or bool + :return: + :rtype: + """ + super(Bool, self).__init__('bool', sid, label, enable, visible, subsetting, internal_ref=internal_ref) + if default is not None: + if isinstance(default, unicode) or isinstance(default, str): + if default.lower() == 'true': + self.default = u'true' + else: + self.default = u'false' + elif isinstance(default, bool): + if default is True: + self.default = u'true' + else: + self.default = u'false' + elif isinstance(default, int): + if default == 0: + self.default = u'false' + else: + self.default = u'true' + else: + raise TypeError ('Undefined type for default in Bool control') + else: + self.default = None + + def render(self, parent): + elements = [u'%s'] + return [u' '.join(elements)] + + def read(self): + return {self.sid:getSetting(self.sid)=='true'} + +class Select(Control): + """ + Will open separate selection window + """ + def __init__(self, sid=u'', label=u'', values=None, lvalues=None, enable=None, visible=None, subsetting=False, default=None, internal_ref=None): + """ + + :param sid: + :type sid: unicode or str + :param label: + :type label: unicode or str + :param values: + :type values: list + :param lvalues: + :type lvalues: list + :param enable: + :type enable: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param visible: + :type visible: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param subsetting: + :type subsetting: bool + :param default: + :type default: unicode or str + :return: + :rtype: + """ + super(Select, self).__init__('select', sid, label, enable, visible, subsetting, internal_ref=internal_ref) + assert ((values is not None and lvalues is None) or (lvalues is not None and values is None)) + if lvalues is not None: + self.usinglvalues = True + else: + self.usinglvalues = False + if self.usinglvalues: + assert isinstance(lvalues, list) + self.values = lvalues + else: + assert isinstance(values, list) + self.values = values + if default is not None: + assert (isinstance(default, unicode) or isinstance(default, str)) + self.default = unicode(default) + else: + self.default = None + + def render(self, parent): + elements = [u'%s'] + return [u' '.join(elements)] + +class Addon(Control): + """ + Displays a selection window with a list of addons. + """ + def __init__(self, sid=u'', label=u'', addontype=u'xbmc.metadata.scraper.movies', multiselect=None, enable=None, visible=None, subsetting=False, default=None, internal_ref=None): + """ + + :param sid: + :type sid: unicode or str + :param label: + :type label: unicode or str + :param addontype: + :type addontype: unicode or str + :param multiselect: + :type multiselect: bool + :param enable: + :type enable: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param visible: + :type visible: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param subsetting: + :type subsetting: bool + :param default: + :type default: unicode or str + :return: + :rtype: + """ + super(Addon, self).__init__('addon', sid, label, enable, visible, subsetting, internal_ref=internal_ref) + assert (isinstance(addontype, unicode) or isinstance(addontype, str)) + self.addontype = unicode(addontype) + if multiselect is not None: + assert isinstance(multiselect, bool) + if multiselect is True: + self.multiselect = u'true' + else: + self.multiselect = u'false' + else: + self.multiselect = None + if default is not None: + assert (isinstance(default, unicode) or isinstance(default, str)) + self.default = unicode(default) + else: + self.default = None + + def render(self, parent): + elements = [u'%s'] + return [u' '.join(elements)] + +class Enum(Control): + """ + A rotary selector allows the user to selected from a list of predefined values using the index of the chosen value. + """ + def __init__(self, sid=u'', label=u'', values=None, lvalues=None, enable=None, visible=None, subsetting=False, default=None, internal_ref=None): + """ + + :param sid: + :type sid: unicode or str + :param label: + :type label: unicode or str + :param values: + :type values: list + :param lvalues: + :type lvalues: list + :param enable: + :type enable: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param visible: + :type visible: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param subsetting: + :type subsetting: bool + :param default: + :type default: unicode or str + :return: + :rtype: + """ + super(Enum, self).__init__('enum', sid, label, enable, visible, subsetting, internal_ref=internal_ref) + assert ((values is not None and lvalues is None) or (lvalues is not None and values is None)) + if lvalues is not None: + self.usinglvalues = True + else: + self.usinglvalues = False + if self.usinglvalues: + assert isinstance(lvalues, list) + self.values = lvalues + else: + assert (isinstance(values, list) or values == u'$HOURS') + self.values = values + if default is not None: + assert (isinstance(default, unicode) or isinstance(default, str)) + self.default = unicode(default) + else: + self.default = None + + def render(self, parent): + elements = [u'%s'] + return [u' '.join(elements)] + + def read(self): + index = int(getSetting(self.sid)) + if self.values == u'$HOURS': + return {self.sid:index} + else: + return {self.sid:self.values[index]} + +class LabelEnum(Control): + """ + A rotary selector allows the user to selected from a list of predefined values using the actual value of the chosen value. + """ + def __init__(self, sid=u'', label=u'', values=None, lvalues=None, sort=u'no', enable=None, visible=None, subsetting=False, default=None, internal_ref=None): + """ + + :param sid: + :type sid: unicode or str + :param label: + :type label: unicode or str + :param values: + :type values: list + :param lvalues: + :type lvalues: list + :param sort: + :type sort: unicode or str or bool + :param enable: + :type enable: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param visible: + :type visible: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param subsetting: + :type subsetting: + :param default: bool + :type default: unicode or str + :return: + :rtype: + """ + super(LabelEnum, self).__init__('labelenum', sid, label, enable, visible, subsetting, internal_ref=internal_ref) + assert ((values is not None and lvalues is None) or (lvalues is not None and values is None)) + if lvalues is not None: + self.usinglvalues = True + else: + self.usinglvalues = False + if self.usinglvalues: + assert isinstance(lvalues, list) + self.values = lvalues + else: + assert isinstance(values, list) + self.values = values + assert (sort == u'yes' or sort == u'no' or isinstance(sort, bool)) + if isinstance(sort, bool): + if sort is True: + self.sort = u'yes' + else: + self.sort = u'no' + else: + self.sort = sort + if default is not None: + assert (isinstance(default, unicode) or isinstance(default, str)) + self.default = unicode(default) + else: + self.default = None + + def render(self, parent): + elements = [u'%s'] + return [u' '.join(elements)] + +class FileBrowser(Control): + """ + + """ + TYPE_FILE = 0 + TYPE_AUDIO = 1 + TYPE_VIDEO = 2 + TYPE_IMAGE = 3 + TYPE_EXECUTABLE = 4 + TYPE_FOLDER = 5 + TYPE_FILE_ENUM = 6 + TYPE_DICT = {TYPE_FILE:'file', TYPE_AUDIO:'audio', TYPE_VIDEO:'video', TYPE_IMAGE:'image', TYPE_EXECUTABLE:'executable', TYPE_FOLDER:'folder', TYPE_FILE_ENUM:'fileenum'} + + def __init__(self, sid=u'', label=u'', fbtype=TYPE_FILE, source=u'auto', option=u'', mask=u'', enable=None, visible=None, subsetting=False, default=u'', internal_ref=None): + """ + + :param sid: + :type sid: unicode or str + :param label: + :type label: unicode or str + :param fbtype: + :type fbtype: + :param source: + :type source: unicode or str + :param option: + :type option: unicode or str + :param mask: + :type mask: unicode or str + :param enable: + :type enable: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param visible: + :type visible: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param subsetting: + :type subsetting: bool + :param default: + :type default: unicode or str + :return: + :rtype: + """ + super(FileBrowser, self).__init__(self.TYPE_DICT[fbtype], sid, label, enable, visible, subsetting, internal_ref=internal_ref) + assert (self.TYPE_FILE <= fbtype <= self.TYPE_FILE_ENUM) + self.fbtype = fbtype + if fbtype == self.TYPE_FOLDER: + self.source = source + assert (option == u'' or option == 'writeable') + self.option = option + else: + assert (option == u'' or option == u'hideext') + self.option = option + assert isinstance(mask, unicode) or isinstance(mask, str) + self.mask = mask + if default is not None: + assert (isinstance(default, unicode) or isinstance(default, str)) + self.default = unicode(default) + else: + self.default = None + + def render(self, parent): + elements = [u'%s'] + return [u' '.join(elements)] + +class Action(Control): + """ + + """ + def __init__(self, sid=u'', label=u'', action=u'', enable=None, visible=None, subsetting=False, internal_ref=None): + """ + + :param sid: + :type sid: unicode or str + :param label: + :type label: unicode or str + :param action: + :type action: unicode or str + :param enable: + :type enable: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param visible: + :type visible: NoneType or resources.lib.kodisettings.struct.Conditionals or bool or resources.lib.kodisettings.struct.Conditional + :param subsetting: + :type subsetting: bool + :return: + :rtype: + """ + super(Action, self).__init__('action', sid, label, enable, visible, subsetting, internal_ref=internal_ref) + assert (isinstance(action, unicode) or isinstance(action, str)) + self.action = unicode(action) + + def render(self, parent): + elements = [u'%s'] + return [u' '.join(elements)] + + +class Conditionals(object): + """ + + """ + COMBINE_AND = 0 + COMBINE_OR = 1 + def __init__(self, args, combine_type=COMBINE_AND): + """ + + :param args: + :type args: resources.lib.kodisettings.struct.Conditional or list + :param combine_type: + :type combine_type: int + :return: + :rtype: + """ + if isinstance(args, list): + for item in args: + assert isinstance(item, Conditional) + self.conditionals = args + else: + assert isinstance(args, Conditional) + self.conditionals = [args] + assert (combine_type==self.COMBINE_AND or combine_type==self.COMBINE_OR) + self.combine_type = combine_type + + def addConditional(self, conditional): + assert isinstance(conditional, Conditional) + self.conditionals.append(conditional) + + def render(self, control, parent): + """ + + :param control: + :type control: resources.lib.kodisettings.struct.Number or resources.lib.kodisettings.struct.Text or resources.lib.kodisettings.struct.FileBrowser or resources.lib.kodisettings.struct.Bool or resources.lib.kodisettings.struct.Lsep or resources.lib.kodisettings.struct.LabelEnum or resources.lib.kodisettings.struct.Select or resources.lib.kodisettings.struct.Action + :param parent: + :type parent: resources.lib.kodisettings.struct.Settings + :return: + :rtype: + """ + cond = [] + for conditional in self.conditionals: + cond += conditional.render(control, parent) + if self.combine_type == self.COMBINE_AND: + cond = u' + '.join(cond) + else: + cond = u' | '.join(cond) + return cond + + +class Conditional(object): + """ + + """ + OP_EQUAL = 1 + OP_NOT_EQUAL = 2 + OP_GREATER_THAN = 3 + OP_LESSER_THAN = 5 + OP_HAS_ADDON = 7 + OP_BOOLEAN = 8 + OP_DICT = {OP_EQUAL:u'eq', OP_NOT_EQUAL:u'!eq', OP_GREATER_THAN:u'gt', OP_LESSER_THAN:u'lt'} + def __init__(self, operator, value, reference=None): + """ + + :param operator: + :type operator: int + :param value: + :type value: unicode or str or bool + :param reference: + :type reference: unicode or str + :return: + :rtype: + """ + self.operator = operator + if operator == Conditional.OP_BOOLEAN: + assert isinstance(value, bool) + if value is True: + self.value = u'true' + else: + self.value = u'false' + elif operator != Conditional.OP_HAS_ADDON: + assert isinstance(reference, unicode) or isinstance(reference, str) + assert isinstance(value, unicode) or isinstance(value, str) + assert reference != u'' + self.reference = unicode(reference) + self.value = unicode(value) + else: + assert isinstance(value, unicode) or isinstance(value, str) + self.addonid = unicode(value) + + def render(self, control, parent): + """ + + :param control: + :type control: resources.lib.kodisettings.struct.Number or resources.lib.kodisettings.struct.Text or resources.lib.kodisettings.struct.FileBrowser or resources.lib.kodisettings.struct.Bool or resources.lib.kodisettings.struct.Lsep or resources.lib.kodisettings.struct.LabelEnum or resources.lib.kodisettings.struct.Select or resources.lib.kodisettings.struct.Action + :param parent: + :type parent: resources.lib.kodisettings.struct.Settings + :return: + :rtype: + """ + if self.operator == self.OP_BOOLEAN: + return [self.value] + elif self.operator == self.OP_HAS_ADDON: + return [u'System.HasAddon(%s)' % self.addonid] + else: + assert isinstance(parent, Settings) + if self.reference in parent.duplicateids: + raise KeyError('Cannot use control with duplicated id as reference: %s' % self.reference) + relindex = parent.control(self.reference)._outputindex - control._outputindex + refcontrol = parent.control(self.reference) + assert isinstance(refcontrol, Control) + if refcontrol.stype == u'enum' or refcontrol.stype == u'labelenum': + assert (isinstance(refcontrol, Enum) or isinstance(refcontrol, LabelEnum)) + index = None + for i, item in enumerate(refcontrol.values): + if item == self.value: + index = i + break + if index is None: + raise KeyError('Conditional Error: match value (%s) not in list of values: %s' % (self.value, refcontrol.values)) + else: + return [u'%s(%s,%s)' % (self.OP_DICT[self.operator], relindex, index)] + elif refcontrol.stype == u'labelenum': + assert isinstance(refcontrol, LabelEnum) + if self.value in refcontrol.values: + return [u'%s(%s,%s)' % (self.OP_DICT[self.operator], relindex, self.value)] + else: + raise KeyError('Conditional Error: match value (%s) not in list of values: %s' % (self.value, refcontrol.values)) + else: + return [u'%s(%s,%s)' % (self.OP_DICT[self.operator], relindex, self.value)] + +getControlClass = {'sep': Sep, 'lsep':Lsep, 'text':Text, 'ipaddress':Ipaddress, 'number':Number, 'slider':Slider, + 'date':Date, 'time':Time, 'bool':Bool, 'select':Select, 'addon':Addon, 'enum':Enum, + 'labelenum':LabelEnum, 'browser':FileBrowser, 'action':Action} + + + diff --git a/script.service.kodi.callbacks/resources/lib/pathtools/AUTHORS b/script.service.kodi.callbacks/resources/lib/pathtools/AUTHORS new file mode 100644 index 0000000000..e14de1185f --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/pathtools/AUTHORS @@ -0,0 +1,2 @@ +Yesudeep Mangalapilly +Martin Kreichgauer diff --git a/script.service.kodi.callbacks/resources/lib/pathtools/LICENSE.txt b/script.service.kodi.callbacks/resources/lib/pathtools/LICENSE.txt new file mode 100644 index 0000000000..5e6adc9dad --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/pathtools/LICENSE.txt @@ -0,0 +1,21 @@ +Copyright (C) 2010 by Yesudeep Mangalapilly + +MIT License +----------- +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/script.service.kodi.callbacks/resources/lib/pathtools/README b/script.service.kodi.callbacks/resources/lib/pathtools/README new file mode 100644 index 0000000000..5b5110fef8 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/pathtools/README @@ -0,0 +1,6 @@ +pathtools +========= + +Pattern matching and various utilities for file systems paths. + + diff --git a/script.service.kodi.callbacks/resources/lib/pathtools/README.rst b/script.service.kodi.callbacks/resources/lib/pathtools/README.rst new file mode 100644 index 0000000000..100b93820a --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/pathtools/README.rst @@ -0,0 +1 @@ +README \ No newline at end of file diff --git a/script.service.kodi.callbacks/resources/lib/pathtools/__init__.py b/script.service.kodi.callbacks/resources/lib/pathtools/__init__.py new file mode 100644 index 0000000000..c9c373fc07 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/pathtools/__init__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# pathtools: File system path tools. +# Copyright (C) 2010 Yesudeep Mangalapilly +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. diff --git a/script.service.kodi.callbacks/resources/lib/pathtools/path.py b/script.service.kodi.callbacks/resources/lib/pathtools/path.py new file mode 100644 index 0000000000..20013599aa --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/pathtools/path.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# path.py: Path functions. +# +# Copyright (C) 2010 Yesudeep Mangalapilly +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +""" +:module: pathtools.path +:synopsis: Directory walking, listing, and path sanitizing functions. +:author: Yesudeep Mangalapilly + +Functions +--------- +.. autofunction:: get_dir_walker +.. autofunction:: walk +.. autofunction:: listdir +.. autofunction:: list_directories +.. autofunction:: list_files +.. autofunction:: absolute_path +.. autofunction:: real_absolute_path +.. autofunction:: parent_dir_path +""" + +import os.path +import os.path +from functools import partial + + +__all__ = [ + 'get_dir_walker', + 'walk', + 'listdir', + 'list_directories', + 'list_files', + 'absolute_path', + 'real_absolute_path', + 'parent_dir_path', +] + + +def get_dir_walker(recursive, topdown=True, followlinks=False): + """ + Returns a recursive or a non-recursive directory walker. + + :param recursive: + ``True`` produces a recursive walker; ``False`` produces a non-recursive + walker. + :returns: + A walker function. + """ + if recursive: + walk = partial(os.walk, topdown=topdown, followlinks=followlinks) + else: + def walk(path, topdown=topdown, followlinks=followlinks): + try: + yield next(os.walk(path, topdown=topdown, followlinks=followlinks)) + except NameError: + yield os.walk(path, topdown=topdown, followlinks=followlinks).next() #IGNORE:E1101 + return walk + + +def walk(dir_pathname, recursive=True, topdown=True, followlinks=False): + """ + Walks a directory tree optionally recursively. Works exactly like + :func:`os.walk` only adding the `recursive` argument. + + :param dir_pathname: + The directory to traverse. + :param recursive: + ``True`` for walking recursively through the directory tree; + ``False`` otherwise. + :param topdown: + Please see the documentation for :func:`os.walk` + :param followlinks: + Please see the documentation for :func:`os.walk` + """ + walk_func = get_dir_walker(recursive, topdown, followlinks) + for root, dirnames, filenames in walk_func(dir_pathname): + yield (root, dirnames, filenames) + + +def listdir(dir_pathname, + recursive=True, + topdown=True, + followlinks=False): + """ + Enlists all items using their absolute paths in a directory, optionally + recursively. + + :param dir_pathname: + The directory to traverse. + :param recursive: + ``True`` for walking recursively through the directory tree; + ``False`` otherwise. + :param topdown: + Please see the documentation for :func:`os.walk` + :param followlinks: + Please see the documentation for :func:`os.walk` + """ + for root, dirnames, filenames\ + in walk(dir_pathname, recursive, topdown, followlinks): + for dirname in dirnames: + yield absolute_path(os.path.join(root, dirname)) + for filename in filenames: + yield absolute_path(os.path.join(root, filename)) + + +def list_directories(dir_pathname, + recursive=True, + topdown=True, + followlinks=False): + """ + Enlists all the directories using their absolute paths within the specified + directory, optionally recursively. + + :param dir_pathname: + The directory to traverse. + :param recursive: + ``True`` for walking recursively through the directory tree; + ``False`` otherwise. + :param topdown: + Please see the documentation for :func:`os.walk` + :param followlinks: + Please see the documentation for :func:`os.walk` + """ + for root, dirnames, filenames\ + in walk(dir_pathname, recursive, topdown, followlinks): + for dirname in dirnames: + yield absolute_path(os.path.join(root, dirname)) + + +def list_files(dir_pathname, + recursive=True, + topdown=True, + followlinks=False): + """ + Enlists all the files using their absolute paths within the specified + directory, optionally recursively. + + :param dir_pathname: + The directory to traverse. + :param recursive: + ``True`` for walking recursively through the directory tree; + ``False`` otherwise. + :param topdown: + Please see the documentation for :func:`os.walk` + :param followlinks: + Please see the documentation for :func:`os.walk` + """ + for root, dirnames, filenames\ + in walk(dir_pathname, recursive, topdown, followlinks): + for filename in filenames: + yield absolute_path(os.path.join(root, filename)) + + +def absolute_path(path): + """ + Returns the absolute path for the given path and normalizes the path. + + :param path: + Path for which the absolute normalized path will be found. + :returns: + Absolute normalized path. + """ + return os.path.abspath(os.path.normpath(path)) + + +def real_absolute_path(path): + """ + Returns the real absolute normalized path for the given path. + + :param path: + Path for which the real absolute normalized path will be found. + :returns: + Real absolute normalized path. + """ + return os.path.realpath(absolute_path(path)) + + +def parent_dir_path(path): + """ + Returns the parent directory path. + + :param path: + Path for which the parent directory will be obtained. + :returns: + Parent directory path. + """ + return absolute_path(os.path.dirname(path)) diff --git a/script.service.kodi.callbacks/resources/lib/pathtools/patterns.py b/script.service.kodi.callbacks/resources/lib/pathtools/patterns.py new file mode 100644 index 0000000000..4ecd853745 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/pathtools/patterns.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# patterns.py: Common wildcard searching/filtering functionality for files. +# +# Copyright (C) 2010 Yesudeep Mangalapilly +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +""" +:module: pathtools.patterns +:synopsis: Wildcard pattern matching and filtering functions for paths. +:author: Yesudeep Mangalapilly + +Functions +--------- +.. autofunction:: match_path +.. autofunction:: match_path_against +.. autofunction:: filter_paths +""" + +from fnmatch import fnmatch, fnmatchcase + +__all__ = ['match_path', + 'match_path_against', + 'match_any_paths', + 'filter_paths'] + + +def _string_lower(s): + """ + Convenience function to lowercase a string (the :mod:`string` module is + deprecated/removed in Python 3.0). + + :param s: + The string which will be lowercased. + :returns: + Lowercased copy of string s. + """ + return s.lower() + + +def match_path_against(pathname, patterns, case_sensitive=True): + """ + Determines whether the pathname matches any of the given wildcard patterns, + optionally ignoring the case of the pathname and patterns. + + :param pathname: + A path name that will be matched against a wildcard pattern. + :param patterns: + A list of wildcard patterns to match_path the filename against. + :param case_sensitive: + ``True`` if the matching should be case-sensitive; ``False`` otherwise. + :returns: + ``True`` if the pattern matches; ``False`` otherwise. + + Doctests:: + >>> match_path_against("/home/username/foobar/blah.py", ["*.py", "*.txt"], False) + True + >>> match_path_against("/home/username/foobar/blah.py", ["*.PY", "*.txt"], True) + False + >>> match_path_against("/home/username/foobar/blah.py", ["*.PY", "*.txt"], False) + True + >>> match_path_against("C:\\windows\\blah\\BLAH.PY", ["*.py", "*.txt"], True) + False + >>> match_path_against("C:\\windows\\blah\\BLAH.PY", ["*.py", "*.txt"], False) + True + """ + if case_sensitive: + match_func = fnmatchcase + pattern_transform_func = (lambda w: w) + else: + match_func = fnmatch + pathname = pathname.lower() + pattern_transform_func = _string_lower + for pattern in set(patterns): + pattern = pattern_transform_func(pattern) + if match_func(pathname, pattern): + return True + return False + + +def _match_path(pathname, + included_patterns, + excluded_patterns, + case_sensitive=True): + """Internal function same as :func:`match_path` but does not check arguments. + + Doctests:: + >>> _match_path("/users/gorakhargosh/foobar.py", ["*.py"], ["*.PY"], True) + True + >>> _match_path("/users/gorakhargosh/FOOBAR.PY", ["*.py"], ["*.PY"], True) + False + >>> _match_path("/users/gorakhargosh/foobar/", ["*.py"], ["*.txt"], False) + False + >>> _match_path("/users/gorakhargosh/FOOBAR.PY", ["*.py"], ["*.PY"], False) + Traceback (most recent call last): + ... + ValueError: conflicting patterns `set(['*.py'])` included and excluded + """ + if not case_sensitive: + included_patterns = set(map(_string_lower, included_patterns)) + excluded_patterns = set(map(_string_lower, excluded_patterns)) + else: + included_patterns = set(included_patterns) + excluded_patterns = set(excluded_patterns) + common_patterns = included_patterns & excluded_patterns + if common_patterns: + raise ValueError('conflicting patterns `%s` included and excluded'\ + % common_patterns) + return (match_path_against(pathname, included_patterns, case_sensitive)\ + and not match_path_against(pathname, excluded_patterns, + case_sensitive)) + + +def match_path(pathname, + included_patterns=None, + excluded_patterns=None, + case_sensitive=True): + """ + Matches a pathname against a set of acceptable and ignored patterns. + + :param pathname: + A pathname which will be matched against a pattern. + :param included_patterns: + Allow filenames matching wildcard patterns specified in this list. + If no pattern is specified, the function treats the pathname as + a match_path. + :param excluded_patterns: + Ignores filenames matching wildcard patterns specified in this list. + If no pattern is specified, the function treats the pathname as + a match_path. + :param case_sensitive: + ``True`` if matching should be case-sensitive; ``False`` otherwise. + :returns: + ``True`` if the pathname matches; ``False`` otherwise. + :raises: + ValueError if included patterns and excluded patterns contain the + same pattern. + + Doctests:: + >>> match_path("/Users/gorakhargosh/foobar.py") + True + >>> match_path("/Users/gorakhargosh/foobar.py", case_sensitive=False) + True + >>> match_path("/users/gorakhargosh/foobar.py", ["*.py"], ["*.PY"], True) + True + >>> match_path("/users/gorakhargosh/FOOBAR.PY", ["*.py"], ["*.PY"], True) + False + >>> match_path("/users/gorakhargosh/foobar/", ["*.py"], ["*.txt"], False) + False + >>> match_path("/users/gorakhargosh/FOOBAR.PY", ["*.py"], ["*.PY"], False) + Traceback (most recent call last): + ... + ValueError: conflicting patterns `set(['*.py'])` included and excluded + """ + included = ["*"] if included_patterns is None else included_patterns + excluded = [] if excluded_patterns is None else excluded_patterns + return _match_path(pathname, included, excluded, case_sensitive) + + +def filter_paths(pathnames, + included_patterns=None, + excluded_patterns=None, + case_sensitive=True): + """ + Filters from a set of paths based on acceptable patterns and + ignorable patterns. + + :param pathnames: + A list of path names that will be filtered based on matching and + ignored patterns. + :param included_patterns: + Allow filenames matching wildcard patterns specified in this list. + If no pattern list is specified, ["*"] is used as the default pattern, + which matches all files. + :param excluded_patterns: + Ignores filenames matching wildcard patterns specified in this list. + If no pattern list is specified, no files are ignored. + :param case_sensitive: + ``True`` if matching should be case-sensitive; ``False`` otherwise. + :returns: + A list of pathnames that matched the allowable patterns and passed + through the ignored patterns. + + Doctests:: + >>> pathnames = set(["/users/gorakhargosh/foobar.py", "/var/cache/pdnsd.status", "/etc/pdnsd.conf", "/usr/local/bin/python"]) + >>> set(filter_paths(pathnames)) == pathnames + True + >>> set(filter_paths(pathnames, case_sensitive=False)) == pathnames + True + >>> set(filter_paths(pathnames, ["*.py", "*.conf"], ["*.status"], case_sensitive=True)) == set(["/users/gorakhargosh/foobar.py", "/etc/pdnsd.conf"]) + True + """ + included = ["*"] if included_patterns is None else included_patterns + excluded = [] if excluded_patterns is None else excluded_patterns + + for pathname in pathnames: + # We don't call the public match_path because it checks arguments + # and sets default values if none are found. We're already doing that + # above. + if _match_path(pathname, included, excluded, case_sensitive): + yield pathname + +def match_any_paths(pathnames, + included_patterns=None, + excluded_patterns=None, + case_sensitive=True): + """ + Matches from a set of paths based on acceptable patterns and + ignorable patterns. + + :param pathnames: + A list of path names that will be filtered based on matching and + ignored patterns. + :param included_patterns: + Allow filenames matching wildcard patterns specified in this list. + If no pattern list is specified, ["*"] is used as the default pattern, + which matches all files. + :param excluded_patterns: + Ignores filenames matching wildcard patterns specified in this list. + If no pattern list is specified, no files are ignored. + :param case_sensitive: + ``True`` if matching should be case-sensitive; ``False`` otherwise. + :returns: + ``True`` if any of the paths matches; ``False`` otherwise. + + Doctests:: + >>> pathnames = set(["/users/gorakhargosh/foobar.py", "/var/cache/pdnsd.status", "/etc/pdnsd.conf", "/usr/local/bin/python"]) + >>> match_any_paths(pathnames) + True + >>> match_any_paths(pathnames, case_sensitive=False) + True + >>> match_any_paths(pathnames, ["*.py", "*.conf"], ["*.status"], case_sensitive=True) + True + >>> match_any_paths(pathnames, ["*.txt"], case_sensitive=False) + False + >>> match_any_paths(pathnames, ["*.txt"], case_sensitive=True) + False + """ + included = ["*"] if included_patterns is None else included_patterns + excluded = [] if excluded_patterns is None else excluded_patterns + + for pathname in pathnames: + # We don't call the public match_path because it checks arguments + # and sets default values if none are found. We're already doing that + # above. + if _match_path(pathname, included, excluded, case_sensitive): + return True + return False diff --git a/script.service.kodi.callbacks/resources/lib/pathtools/version.py b/script.service.kodi.callbacks/resources/lib/pathtools/version.py new file mode 100644 index 0000000000..2a94d01277 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/pathtools/version.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# version.py: Version information. +# Copyright (C) 2010 Yesudeep Mangalapilly +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# When updating this version number, please update the +# ``docs/source/global.rst.inc`` file as well. +VERSION_MAJOR = 0 +VERSION_MINOR = 1 +VERSION_BUILD = 1 +VERSION_INFO = (VERSION_MAJOR, VERSION_MINOR, VERSION_BUILD) +VERSION_STRING = "%d.%d.%d" % VERSION_INFO + +__version__ = VERSION_INFO diff --git a/script.service.kodi.callbacks/resources/lib/publisherfactory.py b/script.service.kodi.callbacks/resources/lib/publisherfactory.py new file mode 100644 index 0000000000..5a977b6875 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/publisherfactory.py @@ -0,0 +1,69 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# 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 . +# + +from resources.lib.publishers.log import LogPublisher +from resources.lib.publishers.loop import LoopPublisher +from resources.lib.publishers.monitor import MonitorPublisher +from resources.lib.publishers.player import PlayerPublisher +from resources.lib.publishers.schedule import SchedulePublisher +from resources.lib.kodilogging import KodiLogger +kl = KodiLogger() +log = kl.log +from resources.lib.utils.poutil import KodiPo +kodipo = KodiPo() +_ = kodipo.getLocalizedString +try: + from resources.lib.publishers.watchdog import WatchdogPublisher +except ImportError as e: + from resources.lib.publishers.dummy import WatchdogPublisherDummy as WatchdogPublisher + log(msg=_('Error importing Watchdog: %s') % str(e), loglevel=KodiLogger.LOGERROR) +try: + from resources.lib.publishers.watchdogStartup import WatchdogStartup +except ImportError as e: + from resources.lib.publishers.dummy import WatchdogPublisherDummy as WatchdogStartup + log(msg=_('Error importing Watchdog: %s') % str(e), loglevel=KodiLogger.LOGERROR) + + + +class PublisherFactory(object): + + def __init__(self, settings, topics, dispatcher, logger, debug=False): + self.settings = settings + self.logger = logger + self.topics = topics + self.debug = debug + self.dispatcher = dispatcher + self.publishers = {LogPublisher:_('Log Publisher initialized'), + LoopPublisher:_('Loop Publisher initialized'), + MonitorPublisher:_('Monitor Publisher initialized'), + PlayerPublisher:_('Player Publisher initialized'), + WatchdogPublisher:_('Watchdog Publisher initialized'), + WatchdogStartup:_('Watchdog Startup Publisher initialized'), + SchedulePublisher:_('Schedule Publisher initialized') + } + self.ipublishers = [] + + def createPublishers(self): + for publisher in self.publishers.keys(): + if not set(self.topics).isdisjoint(publisher.publishes) or self.debug is True: + ipublisher = publisher(self.dispatcher, self.settings) + self.ipublishers.append(ipublisher) + self.logger.log(msg=_(self.publishers[publisher])) + + diff --git a/script.service.kodi.callbacks/resources/lib/publishers/__init__.py b/script.service.kodi.callbacks/resources/lib/publishers/__init__.py new file mode 100644 index 0000000000..25e9a531ca --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/publishers/__init__.py @@ -0,0 +1,18 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 KenV99 +# +# 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 . +# diff --git a/script.service.kodi.callbacks/resources/lib/publishers/dummy.py b/script.service.kodi.callbacks/resources/lib/publishers/dummy.py new file mode 100644 index 0000000000..a15a64364e --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/publishers/dummy.py @@ -0,0 +1,36 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 KenV99 +# +# 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 . +# +from resources.lib.events import Events +from resources.lib.pubsub import Publisher + + +class WatchdogPublisherDummy(Publisher): + publishes = Events().Watchdog.keys() + + def __init__(self, dispatcher, _): + super(WatchdogPublisherDummy, self).__init__(dispatcher) + + def start(self): + pass + + def abort(self, timeout=None): + pass + + def join(self, timeout=None): + pass \ No newline at end of file diff --git a/script.service.kodi.callbacks/resources/lib/publishers/log.py b/script.service.kodi.callbacks/resources/lib/publishers/log.py new file mode 100644 index 0000000000..966bd7e9f9 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/publishers/log.py @@ -0,0 +1,210 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import xbmc +import threading +from Queue import Queue, Empty +import re +from resources.lib.pubsub import Publisher, Topic, Message +from resources.lib.events import Events +from resources.lib.utils.poutil import KodiPo +kodipo = KodiPo() +_ = kodipo.getLocalizedString + +logfn = xbmc.translatePath(r'special://logpath/kodi.log') + +class LogMonitor(threading.Thread): + def __init__(self, interval=100): + super(LogMonitor, self).__init__(name='LogMonitor') + self.logfn = logfn + self.__abort_evt = threading.Event() + self.__abort_evt.clear() + self.ouputq = Queue() + self.interval = interval + + def run(self): + f = open(self.logfn, 'r') + f.seek(0, 2) # Seek @ EOF + fsize_old = f.tell() + while not self.__abort_evt.is_set(): + f.seek(0, 2) # Seek @ EOF + fsize = f.tell() # Get Size + if fsize > fsize_old: + f.seek(fsize_old, 0) + lines = f.readlines() # Read to end + for line in lines: + self.ouputq.put(line, False) + fsize_old = f.tell() + xbmc.sleep(self.interval) + + def abort(self, timeout=0): + self.__abort_evt.set() + if timeout > 0: + self.join(timeout) + if self.is_alive(): + xbmc.log(msg=_('Could not stop LogMonitor T:%i') % self.ident) + +class LogPublisher(threading.Thread, Publisher): + + publishes = Events.Log.keys() + + def __init__(self, dispatcher, settings): + Publisher.__init__(self, dispatcher) + threading.Thread.__init__(self, name='LogPublisher') + self._checks_simple = [] + self._checks_regex = [] + self.abort_evt = threading.Event() + self.abort_evt.clear() + self.interval_checker = settings.general['LogFreq'] + self.interval_monitor = settings.general['LogFreq'] + self.add_simple_checks(settings.getLogSimples()) + self.add_regex_checks(settings.getLogRegexes()) + + def add_simple_checks(self, simpleList): + for chk in simpleList: + self.add_simple_check(chk['matchIf'], chk['rejectIf'], chk['eventId']) + + def add_regex_checks(self, regexList): + for chk in regexList: + self.add_re_check(chk['matchIf'], chk['rejectIf'], chk['eventId']) + + def add_simple_check(self, match, nomatch, subtopic): + self._checks_simple.append(LogCheckSimple(match, nomatch, subtopic, self.publish)) + + def add_re_check(self, match, nomatch, subtopic): + self._checks_regex.append(LogCheckRegex(match, nomatch, subtopic, self.publish)) + + def run(self): + lm = LogMonitor(interval=self.interval_monitor) + lm.start() + for chk in self._checks_simple: + chk.start() + for chk in self._checks_regex: + chk.start() + while not self.abort_evt.is_set(): + while not lm.ouputq.empty(): + try: + line = lm.ouputq.get_nowait() + except Empty: + continue + for chk in self._checks_simple: + chk.queue.put(line, False) + for chk in self._checks_regex: + chk.queue.put(line, False) + if self.abort_evt.is_set(): + continue + xbmc.sleep(self.interval_checker) + for chk in self._checks_simple: + chk.abort() + for chk in self._checks_regex: + chk.abort() + lm.abort() + + def abort(self, timeout=0): + self.abort_evt.set() + if timeout > 0: + self.join(timeout) + if self.is_alive(): + xbmc.log(msg=_('Could not stop LogPublisher T:%i') % self.ident) + +class LogCheck(object): + def __init__(self, match, nomatch, callback, param): + self.match = match + self.nomatch = nomatch + self.callback = callback + self.param = param + +class LogCheckSimple(threading.Thread): + def __init__(self, match, nomatch, subtopic, publish): + super(LogCheckSimple, self).__init__(name='LogCheckSimple') + self.match = match + self.nomatch = nomatch + self.publish = publish + self.queue = Queue() + self._abort_evt = threading.Event() + self._abort_evt.clear() + self.topic = Topic('onLogSimple', subtopic) + + def run(self): + while not self._abort_evt.is_set(): + while not self.queue.empty(): + try: + line = self.queue.get_nowait() + except Empty: + continue + if self.match in line: + if self.nomatch != '': + if (self.nomatch in line) is not True: + msg = Message(self.topic, line=line) + self.publish(msg) + else: + msg = Message(self.topic, line=line) + self.publish(msg) + + def abort(self, timeout=0): + self._abort_evt.set() + if timeout > 0: + self.join(timeout) + if self.is_alive(): + xbmc.log(msg=_('Could not stop LogCheckSimple T:%i') % self.ident) + +class LogCheckRegex(threading.Thread): + def __init__(self, match, nomatch, subtopic, publish): + super(LogCheckRegex, self).__init__(name='LogCheckRegex') + try: + re_match = re.compile(match, flags=re.IGNORECASE) + except re.error: + raise + if nomatch != '': + try: + re_nomatch = re.compile(nomatch, flags=re.IGNORECASE) + except re.error: + raise + else: + re_nomatch = None + self.match = re_match + self.nomatch = re_nomatch + self.publish = publish + self.queue = Queue() + self._abort_evt = threading.Event() + self._abort_evt.clear() + self.topic = Topic('onLogRegex', subtopic) + + def run(self): + while not self._abort_evt.is_set(): + while not self.queue.empty(): + try: + line = self.queue.get_nowait() + except Empty: + continue + if self.match.search(line): + if self.nomatch is not None: + if (self.nomatch.search(line)) is None: + msg = Message(self.topic, line=line) + self.publish(msg) + else: + msg = Message(self.topic, line=line) + self.publish(msg) + + def abort(self, timeout=0): + self._abort_evt.set() + if timeout > 0: + self.join(timeout) + if self.is_alive(): + xbmc.log(msg=_('Could not stop LogCheckRegex T:%i') % self.ident) \ No newline at end of file diff --git a/script.service.kodi.callbacks/resources/lib/publishers/loop.py b/script.service.kodi.callbacks/resources/lib/publishers/loop.py new file mode 100644 index 0000000000..17dcde937a --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/publishers/loop.py @@ -0,0 +1,176 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + + +import threading +import time +from json import loads as jloads + +import xbmc +import xbmcgui +from resources.lib.events import Events +from resources.lib.pubsub import Publisher, Message, Topic +from resources.lib.utils.poutil import KodiPo + +kodipo = KodiPo() +_ = kodipo.getLocalizedString + + +def getStereoscopicMode(): + """ + Retrieves stereoscopic mode from json-rpc + @return: "off", "split_vertical", "split_horizontal", "row_interleaved", "hardware_based", "anaglyph_cyan_red", + "anaglyph_green_magenta", "monoscopic" + @rtype: str + """ + query = '{"jsonrpc": "2.0", "method": "GUI.GetProperties", "params": {"properties": ["stereoscopicmode"]}, "id": 1}' + result = xbmc.executeJSONRPC(query) + jsonr = jloads(result) + ret = '' + if 'result' in jsonr: + if 'stereoscopicmode' in jsonr['result']: + if 'mode' in jsonr['result']['stereoscopicmode']: + ret = jsonr['result']['stereoscopicmode']['mode'].encode('utf-8') + return ret + + +def getProfileString(): + """ + Retrieves the current profile as a path + @rtype: str + """ + ps = xbmc.translatePath('special://profile') + return ps + + +class LoopPublisher(threading.Thread, Publisher): + publishes = Events().CustomLoop.keys() + + def __init__(self, dispatcher, settings): + Publisher.__init__(self, dispatcher) + threading.Thread.__init__(self, name='LoopPublisher') + self.interval = settings.general['LoopFreq'] + self.abort_evt = threading.Event() + self.abort_evt.clear() + self.openwindowids = settings.getOpenwindowids() + self.closewindowsids = settings.getClosewindowids() + idleT = settings.getIdleTimes() + afterIdle = settings.getAfterIdleTimes() + self.player = xbmc.Player() + if idleT is not None: + if len(idleT) > 0: + self.idleTs = [] + self._startidle = 0 + self._playeridle = False + for key in idleT.keys(): + # time, key, executed + self.idleTs.append([idleT[key], key, False]) + else: + self.idleTs = [] + else: + self.idleTs = [] + if afterIdle is not None: + if len(afterIdle) > 0: + self.afterIdles = [] + self._startidle = 0 + self._playeridle = False + for key in afterIdle.keys(): + # time, key, triggered + self.afterIdles.append([afterIdle[key], key, False]) + else: + self.afterIdles = [] + else: + self.afterIdles = [] + if len(self.idleTs) > 0 or len(self.afterIdles) > 0: + self.doidle = True + else: + self.doidle = False + + def run(self): + lastwindowid = xbmcgui.getCurrentWindowId() + lastprofile = getProfileString() + laststereomode = getStereoscopicMode() + interval = self.interval + firstloop = True + starttime = time.time() + while not self.abort_evt.is_set(): + + self._checkIdle() + + newprofile = getProfileString() + if newprofile != lastprofile: + self.publish(Message(Topic('onProfileChange'), profilePath=newprofile)) + lastprofile = newprofile + + newstereomode = getStereoscopicMode() + if newstereomode != laststereomode: + self.publish(Message(Topic('onStereoModeChange'), stereoMode=newstereomode)) + laststereomode = newstereomode + + newwindowid = xbmcgui.getCurrentWindowId() + if newwindowid != lastwindowid: + if lastwindowid in self.closewindowsids.keys(): + self.publish(Message(Topic('onWindowClose', self.closewindowsids[lastwindowid]))) + if newwindowid in self.openwindowids: + self.publish(Message(Topic('onWindowOpen', self.openwindowids[newwindowid]))) + lastwindowid = newwindowid + + if firstloop: + endtime = time.time() + interval = int(interval - (endtime - starttime) * 1000) + interval = max(5, interval) + firstloop = False + xbmc.sleep(interval) + del self.player + + def _checkIdle(self): + if self.doidle is False: + return + XBMCit = xbmc.getGlobalIdleTime() + if self.player.isPlaying(): + self._playeridle = False + self._startidle = XBMCit + else: # player is not playing + if self._playeridle is False: # if the first pass with player idle, start timing here + self._playeridle = True + self._startidle = XBMCit + myit = XBMCit - self._startidle # amount of time idle and not playing + for it in self.idleTs: + if myit > it[0]: # if time exceeded idle timer + if it[2] is False: # idle task has NOT been executed + msg = Message(Topic('onIdle', it[1])) + self.publish(msg) + it[2] = True + else: # if time has not exceeded timer + it[2] = False # reset task executed flag + for it in self.afterIdles: + if myit > it[0]: # time has exceeded timer + it[2] = True # set flag that task needs to be executed when exiting idle + else: # time has not exceeded idle timer + if it[2] is True: # if flag to execute has been set + msg = Message(Topic('afterIdle', it[1])) + self.publish(msg) + it[2] = False # reset flag to execute + + def abort(self, timeout=0): + self.abort_evt.set() + if timeout > 0: + self.join(timeout) + if self.is_alive(): + xbmc.log(msg=_('Could not stop LoopPublisher T:%i') % self.ident) diff --git a/script.service.kodi.callbacks/resources/lib/publishers/monitor.py b/script.service.kodi.callbacks/resources/lib/publishers/monitor.py new file mode 100644 index 0000000000..29e99e94b8 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/publishers/monitor.py @@ -0,0 +1,106 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import threading +import xbmc +from resources.lib.pubsub import Publisher, Topic, Message +from resources.lib.events import Events +from resources.lib.utils.poutil import KodiPo +kodipo = KodiPo() +_ = kodipo.getLocalizedString + +class MonitorPublisher(threading.Thread, Publisher): + publishes = Events().Monitor.keys() + + def __init__(self, dispatcher, settings): + Publisher.__init__(self, dispatcher) + threading.Thread.__init__(self, name='MonitorPublisher') + self.dispatcher = dispatcher + self._abortevt = threading.Event() + self._abortevt.clear() + self.jsoncriteria = settings.getJsonNotifications() + + def run(self): + publish = super(MonitorPublisher, self).publish + monitor = _Monitor() + monitor.jsoncriteria = self.jsoncriteria + monitor.publish = publish + while not self._abortevt.is_set(): + xbmc.sleep(500) + del monitor + + def abort(self, timeout=0): + self._abortevt.set() + if timeout > 0: + self.join(timeout) + if self.is_alive(): + xbmc.log(msg=_('Could not stop MonitorPublisher T:%i') % self.ident) + +class _Monitor(xbmc.Monitor): + def __init__(self): + super(_Monitor, self).__init__() + self.publish = None + self.jsoncriteria = None + + def onCleanFinished(self, library): + topic = Topic('onCleanFinished') + kwargs = {'library':library} + self.publish(Message(topic, **kwargs)) + + def onCleanStarted(self, library): + topic = Topic('onCleanStarted') + kwargs = {'library':library} + self.publish(Message(topic, **kwargs)) + + def onDPMSActivated(self): + topic = Topic('onDPMSActivated') + kwargs = {} + self.publish(Message(topic, **kwargs)) + + def onDPMSDeactivated(self): + topic = Topic('onDPMSDeactivated') + kwargs = {} + self.publish(Message(topic, **kwargs)) + + def onNotification(self, sender, method, data): + for criterion in self.jsoncriteria: + if criterion['sender'] == sender and criterion['method'] == method and criterion['data'] == data: + topic = Topic('onNotification', criterion['eventId']) + kwargs = {'sender':sender, 'method':method, 'data':data} + self.publish(Message(topic, **kwargs)) + + def onScanStarted(self, library): + topic = Topic('onScanStarted') + kwargs = {'library':library} + self.publish(Message(topic, **kwargs)) + + def onScanFinished(self, library): + topic = Topic('onScanFinished') + kwargs = {'library':library} + self.publish(Message(topic, **kwargs)) + + def onScreensaverActivated(self): + topic = Topic('onScreensaverActivated') + kwargs = {} + self.publish(Message(topic, **kwargs)) + + def onScreensaverDeactivated(self): + topic = Topic('onScreensaverDeactivated') + kwargs = {} + self.publish(Message(topic, **kwargs)) + diff --git a/script.service.kodi.callbacks/resources/lib/publishers/player.py b/script.service.kodi.callbacks/resources/lib/publishers/player.py new file mode 100644 index 0000000000..7c5791183c --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/publishers/player.py @@ -0,0 +1,364 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import json +import threading + +import xbmc +from resources.lib.events import Events +from resources.lib.pubsub import Publisher, Topic, Message +from resources.lib.utils.poutil import KodiPo + +kodipo = KodiPo() +_ = kodipo.getLocalizedString + + +class PlayerPublisher(threading.Thread, Publisher): + publishes = Events.Player.keys() + + def __init__(self, dispatcher, settings): + assert settings is not None + Publisher.__init__(self, dispatcher) + threading.Thread.__init__(self, name='PlayerPublisher') + self.dispatcher = dispatcher + self.publishes = Events.Player.keys() + self._abortevt = threading.Event() + self._abortevt.clear() + + def run(self): + publish = super(PlayerPublisher, self).publish + player = Player() + player.publish = publish + while not self._abortevt.is_set(): + if player.isPlaying(): + player.playingTime = player.getTime() + xbmc.sleep(500) + del player + + def abort(self, timeout=0): + self._abortevt.set() + if timeout > 0: + self.join(timeout) + if self.is_alive(): + xbmc.log(msg=_('Could not stop PlayerPublisher T:%i') % self.ident) + + +class Player(xbmc.Player): + def __init__(self): + super(Player, self).__init__() + self.publish = None + self.totalTime = -1 + self.playingTime = 0 + self.info = {} + + def playing_type(self): + """ + @return: [music|movie|episode|stream|liveTV|recordedTV|PVRradio|unknown] + """ + substrings = ['-trailer', 'http://'] + isMovie = False + if self.isPlayingAudio(): + return "music" + else: + if xbmc.getCondVisibility('VideoPlayer.Content(movies)'): + isMovie = True + try: + filename = self.getPlayingFile() + except RuntimeError: + filename = '' + if filename != '': + if filename[0:3] == 'pvr': + if xbmc.getCondVisibility('Pvr.IsPlayingTv'): + return 'liveTV' + elif xbmc.getCondVisibility('Pvr.IsPlayingRecording'): + return 'recordedTV' + elif xbmc.getCondVisibility('Pvr.IsPlayingRadio'): + return 'PVRradio' + else: + for string in substrings: + if string in filename: + isMovie = False + break + if isMovie: + return "movie" + elif xbmc.getCondVisibility('VideoPlayer.Content(episodes)'): + # Check for tv show title and season to make sure it's really an episode + if xbmc.getInfoLabel('VideoPlayer.Season') != "" and xbmc.getInfoLabel('VideoPlayer.TVShowTitle') != "": + return "episode" + elif xbmc.getCondVisibility('Player.IsInternetStream'): + return 'stream' + else: + return 'unknown' + + def getTitle(self): + if self.isPlayingAudio(): + tries = 0 + while xbmc.getInfoLabel('MusicPlayer.Title') is None and tries < 8: + xbmc.sleep(250) + tries += 1 + title = xbmc.getInfoLabel('MusicPlayer.Title') + if title is None or title == '': + return 'Kodi cannot detect title' + else: + return title + elif self.isPlayingVideo(): + tries = 0 + while xbmc.getInfoLabel('VideoPlayer.Title') is None and tries < 8: + xbmc.sleep(250) + tries += 1 + if xbmc.getCondVisibility('VideoPlayer.Content(episodes)'): + if xbmc.getInfoLabel('VideoPlayer.Season') != "" and xbmc.getInfoLabel('VideoPlayer.TVShowTitle') != "": + return '%s-S%sE%s-%s' % (xbmc.getInfoLabel('VideoPlayer.TVShowTitle'), + xbmc.getInfoLabel('VideoPlayer.Season'), + xbmc.getInfoLabel('VideoPlayer.Episode'), + xbmc.getInfoLabel('VideoPlayer.Title')) + else: + title = xbmc.getInfoLabel('VideoPlayer.Title') + if title is None or title == '': + try: + ret = json.loads(xbmc.executeJSONRPC( + '{"jsonrpc": "2.0", "method": "Player.GetItem", "params": { "properties": ["title"], "playerid": 1 }, "id": "1"}')) + except RuntimeError: + title = '' + else: + try: + title = ret['result']['item']['title'] + except KeyError: + title = 'Kodi cannot detect title' + else: + if title == '': + title = ret['result']['item']['label'] + if title == '': + title = 'Kodi cannot detect title' + return title + else: + return title + else: + return 'Kodi cannot detect title - none playing' + + def getAudioInfo(self, playerid): + try: + info = json.loads(xbmc.executeJSONRPC( + '{"jsonrpc": "2.0", "method": "Player.GetItem", "params": { "properties": ["title", "album",' + ' "artist", "duration", "file", "streamdetails"], "playerid": %s }, "id": "AudioGetItem"}' + % playerid))['result']['item'] + if 'artist' in info.keys(): + t = info['artist'] + if isinstance(t, list): + info['artist'] = t[0] + elif isinstance(t, unicode): + if t != u'': + info['artist'] = t + else: + info['artist'] = u'unknown' + else: + info['artist'] = u'unknown' + else: + info['artist'] = u'unknown' + items = ['duration', 'id', 'label', 'type'] + for item in items: + try: + del info[item] + except KeyError: + pass + info['mediaType'] = 'audio' + except RuntimeError: + self.info = {} + else: + self.info = info + + def getVideoInfo(self, playerid): + try: + info = json.loads(xbmc.executeJSONRPC( + '{"jsonrpc": "2.0", "method": "Player.GetItem", "params": { "properties": ["title", "album",' + ' "artist", "season", "episode", "duration", "showtitle", "tvshowid", "file", "streamdetails"],' + ' "playerid": %s }, "id": "VideoGetItem"}' % playerid))['result']['item'] + except RuntimeError: + self.info = {} + else: + items = ['label', 'id', 'tvshowid'] + for item in items: + try: + del info[item] + except KeyError: + pass + items = {'mediaType': 'type', 'fileName': 'file'} + for item in items.keys(): + try: + t = items[item] + info[item] = info.pop(t, 'unknown') + except KeyError: + info[item] = 'unknown' + if info['mediaType'] != 'musicvideo': + items = ['artist', 'album'] + for item in items: + try: + del info[item] + except KeyError: + pass + else: + info['artist'] = info['artist'][0] + if 'streamdetails' in info.keys(): + sd = info.pop('streamdetails', {}) + info['stereomode'] = sd['video'][0]['stereomode'] + info['width'] = str(sd['video'][0]['width']) + info['height'] = str(sd['video'][0]['height']) + info['aspectRatio'] = str(int((sd['video'][0]['aspect'] * 100.0) + 0.5) / 100.0) + if info['mediaType'] == u'episode': + items = ['episode', 'season'] + for item in items: + try: + info[item] = str(info[item]).zfill(2) + except KeyError: + info[item] = 'unknown' + else: + items = ['episode', 'season', 'showtitle'] + for item in items: + try: + del info[item] + except KeyError: + pass + self.info = info + + def getInfo(self): + tries = 0 + while tries < 8 and self.isPlaying() is False: + xbmc.sleep(250) + try: + player = json.loads(xbmc.executeJSONRPC('{"jsonrpc": "2.0", "method": "Player.GetActivePlayers", "id": 1}')) + except RuntimeError: + playerid = -1 + playertype = 'none' + else: + try: + playerid = player['result'][0]['playerid'] + playertype = player['result'][0]['type'] + except KeyError: + playerid = -1 + playertype = 'none' + if playertype == 'audio': + self.getAudioInfo(playerid) + self.rectifyUnknowns() + elif playertype == 'video': + self.getVideoInfo(playerid) + self.rectifyUnknowns() + else: + self.info = {} + + def rectifyUnknowns(self): + items = {'filename': self.getPlayingFileX, 'aspectRatio': self.getAspectRatio, 'height': self.getResoluion, + 'title': self.getTitle} + for item in items.keys(): + if item not in self.info.keys(): + self.info[item] = items[item]() + else: + try: + if self.info[item] == '' or self.info[item] == 'unknown': + self.info[item] = items[item]() + except KeyError: + pass + pt = self.playing_type() + if 'mediaType' not in self.info.keys(): + self.info['mediaType'] = pt + else: + try: + if pt != 'unknown' and self.info['mediaType'] != pt: + self.info['mediaType'] = pt + except KeyError: + pass + + def getPlayingFileX(self): + try: + fn = self.getPlayingFile() + except RuntimeError: + fn = 'unknown' + if fn is None or fn == '': + fn = 'unknown' + return xbmc.translatePath(fn) + + @staticmethod + def getAspectRatio(): + ar = xbmc.getInfoLabel("VideoPlayer.VideoAspect") + if ar is None: + ar = 'unknown' + elif ar == '': + ar = 'unknown' + return str(ar) + + @staticmethod + def getResoluion(): + vr = xbmc.getInfoLabel("VideoPlayer.VideoResolution") + if vr is None: + vr = 'unknown' + elif vr == '': + vr = 'unknown' + return str(vr) + + def onPlayBackStarted(self): + self.getInfo() + try: + self.totalTime = self.getTotalTime() + except RuntimeError: + self.totalTime = -1 + finally: + if self.totalTime == 0: + self.totalTime = -1 + topic = Topic('onPlayBackStarted') + self.publish(Message(topic, **self.info)) + + def onPlayBackEnded(self): + topic = Topic('onPlayBackEnded') + try: + tt = self.totalTime + tp = self.playingTime + pp = int(100 * tp / tt) + except RuntimeError: + pp = -1 + except OverflowError: + pp = -1 + self.publish(Message(topic, percentPlayed=str(pp), **self.info)) + self.totalTime = -1.0 + self.playingTime = 0.0 + self.info = {} + + def onPlayBackStopped(self): + self.onPlayBackEnded() + + def onPlayBackPaused(self): + topic = Topic('onPlayBackPaused') + self.publish(Message(topic, time=str(self.getTime()), **self.info)) + + def onPlayBackResumed(self): + topic = Topic('onPlayBackResumed') + self.publish(Message(topic, time=str(self.getTime()), **self.info)) + + def onPlayBackSeek(self, time, seekOffset): + topic = Topic('onPlayBackSeek') + self.publish(Message(topic, time=str(time), **self.info)) + + def onPlayBackSeekChapter(self, chapter): + topic = Topic('onPlayBackSeekChapter') + self.publish(Message(topic, chapter=str(chapter), **self.info)) + + def onPlayBackSpeedChanged(self, speed): + topic = Topic('onPlayBackSpeedChanged') + self.publish(Message(topic, speed=str(speed), **self.info)) + + def onQueueNextItem(self): + topic = Topic('onQueueNextItem') + self.publish(Message(topic, **self.info)) diff --git a/script.service.kodi.callbacks/resources/lib/publishers/schedule.py b/script.service.kodi.callbacks/resources/lib/publishers/schedule.py new file mode 100644 index 0000000000..2e617d0717 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/publishers/schedule.py @@ -0,0 +1,73 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# 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 . +# +from resources.lib import schedule +import xbmc +import threading +from resources.lib.pubsub import Publisher, Message, Topic +from resources.lib.events import Events +from resources.lib.utils.poutil import KodiPo +kodipo = KodiPo() +_ = kodipo.getLocalizedString + +class SchedulePublisher(threading.Thread, Publisher): + publishes = Events().Schedule.keys() + + def __init__(self, dispatcher, settings): + Publisher.__init__(self, dispatcher) + threading.Thread.__init__(self, name='SchedulePublisher') + self.dailyAlarms = settings.getEventsByType('onDailyAlarm') + self.intervalAlarms = settings.getEventsByType('onIntervalAlarm') + self.abortEvt = threading.Event() + self.abortEvt.clear() + self.sleep = xbmc.sleep + self.sleepinterval = 1000 + self.schedules = [] + + def run(self): + for alarm in self.dailyAlarms: + hour = str(alarm['hour']).zfill(2) + minute = str(alarm['minute']).zfill(2) + stime = ':'.join([hour, minute]) + schedule.every().day.at(stime).do(self.prePublishDailyAlarm, key=alarm['key']) + for alarm in self.intervalAlarms: + interval = alarm['hours'] * 3600 + alarm['minutes'] * 60 + alarm['seconds'] + if interval > 0: + schedule.every(interval).seconds.do(self.prePublishIntervalAlarm, key=alarm['key']) + else: + xbmc.log(msg=_('onIntervalAlarm interval cannot be zero')) + + while not self.abortEvt.is_set(): + schedule.run_pending() + self.sleep(self.sleepinterval) + schedule.clear() + + def prePublishDailyAlarm(self, key): + meseage = Message(Topic('onDailyAlarm', key)) + self.publish(meseage) + + def prePublishIntervalAlarm(self, key): + meseage = Message(Topic('onIntervalAlarm', key)) + self.publish(meseage) + + def abort(self, timeout=0): + self.abortEvt.set() + if timeout > 0: + self.join(timeout) + if self.is_alive(): + xbmc.log(msg=_('Could not stop SchedulePublisher T:%i') % self.ident) diff --git a/script.service.kodi.callbacks/resources/lib/publishers/watchdog.py b/script.service.kodi.callbacks/resources/lib/publishers/watchdog.py new file mode 100644 index 0000000000..26b058a968 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/publishers/watchdog.py @@ -0,0 +1,93 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import sys + +from resources.lib.pubsub import Publisher, Message, Topic +from resources.lib.events import Events +from resources.lib.utils.kodipathtools import translatepath +try: + from watchdog.observers import Observer + from watchdog.events import PatternMatchingEventHandler +except ImportError: + libs = translatepath('special://addon/resources/lib') + sys.path.append(libs) + libs = translatepath('special://addon/resources/lib/watchdog') + sys.path.append(libs) + try: + from resources.lib.watchdog.observers import Observer + from resources.lib.watchdog.events import PatternMatchingEventHandler + except ImportError: + raise + +class EventHandler(PatternMatchingEventHandler): + def __init__(self, patterns, ignore_patterns, ignore_directories, topic, publish): + super(EventHandler, self).__init__(patterns=patterns, ignore_patterns=ignore_patterns, ignore_directories=ignore_directories) + self.topic = topic + self.publish = publish + + def on_any_event(self, event): + msg = Message(self.topic, path=event.src_path, event=event.event_type) + self.publish(msg) + +class WatchdogPublisher(Publisher): + publishes = Events().Watchdog.keys() + + def __init__(self, dispatcher, settings): + super(WatchdogPublisher, self).__init__(dispatcher) + self.watchdogSettings = settings.getWatchdogSettings() + self.event_handlers = [] + self.observersettings = [] + self.observers = [] + self.initialize() + + def initialize(self): + for setting in self.watchdogSettings: + patterns = setting['patterns'].split(',') + ignore_patterns = setting['ignore_patterns'].split(',') + eh = EventHandler(patterns=patterns, ignore_patterns=ignore_patterns, + ignore_directories=setting['ignore_directories'], + topic=Topic('onFileSystemChange', setting['key']), publish=self.publish) + self.event_handlers.append(eh) + folder = translatepath(setting['folder']) + mysetting = [eh, folder, setting['recursive']] + self.observersettings.append(mysetting) + + def start(self): + for item in self.observersettings: + observer = Observer() + observer.schedule(item[0], item[1], recursive=item[2]) + observer.start() + self.observers.append(observer) + + def abort(self, timeout=0): + for item in self.observers: + assert isinstance(item, Observer) + item.stop() + if timeout > 0: + self.join(timeout) + + def join(self, timeout=0): + for item in self.observers: + if timeout > 0: + item.join(timeout) + else: + item.join() + + diff --git a/script.service.kodi.callbacks/resources/lib/publishers/watchdogStartup.py b/script.service.kodi.callbacks/resources/lib/publishers/watchdogStartup.py new file mode 100644 index 0000000000..e27236c88b --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/publishers/watchdogStartup.py @@ -0,0 +1,179 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import sys +import pickle +import os +import time +from resources.lib.utils.kodipathtools import translatepath, setPathRW +libs = translatepath('special://addon/resources/lib') +sys.path.append(libs) +libs = translatepath('special://addon/resources/lib/watchdog') +sys.path.append(libs) + +import xbmc +from resources.lib.events import Events +from resources.lib.watchdog.utils.dirsnapshot import DirectorySnapshot, DirectorySnapshotDiff +from resources.lib.watchdog.observers import Observer +from resources.lib.watchdog.events import PatternMatchingEventHandler, FileSystemEvent, EVENT_TYPE_CREATED, EVENT_TYPE_DELETED, EVENT_TYPE_MODIFIED, EVENT_TYPE_MOVED +from resources.lib.pubsub import Publisher, Message, Topic +from resources.lib.utils.poutil import KodiPo +kodipo = KodiPo() +_ = kodipo.getLocalizedString +from resources.lib.kodilogging import KodiLogger +klogger = KodiLogger() +log = klogger.log + + +class EventHandler(PatternMatchingEventHandler): + def __init__(self, patterns, ignore_patterns, ignore_directories): + super(EventHandler, self).__init__(patterns=patterns, ignore_patterns=ignore_patterns, + ignore_directories=ignore_directories) + self.data = {} + + def on_any_event(self, event): + if event.is_directory: + et = 'Dirs%s' % event.event_type.capitalize() + else: + et = 'Files%s' % event.event_type.capitalize() + if et in self.data.keys(): + self.data[et].append(event.src_path) + else: + self.data[et] = [event.src_path] + +class WatchdogStartup(Publisher): + publishes = Events().WatchdogStartup.keys() + + def __init__(self, dispatcher, settings): + super(WatchdogStartup, self).__init__(dispatcher) + self.settings = settings.getWatchdogStartupSettings() + + def start(self): + oldsnapshots = WatchdogStartup.getPickle() + newsnapshots = {} + for setting in self.settings: + folder = translatepath(setting['ws_folder']) + if os.path.exists(folder): + newsnapshot = DirectorySnapshot(folder, recursive=setting['ws_recursive']) + newsnapshots[folder] = newsnapshot + if oldsnapshots is not None: + if folder in oldsnapshots.keys(): + oldsnapshot = oldsnapshots[folder] + diff = DirectorySnapshotDiff(oldsnapshot, newsnapshot) + changes = self.getChangesFromDiff(diff) + if len(changes) > 0: + eh = EventHandler(patterns=setting['ws_patterns'].split(','), ignore_patterns=setting['ws_ignore_patterns'].split(','), + ignore_directories=setting['ws_ignore_directories']) + observer = Observer() + try: + observer.schedule(eh, folder, recursive=setting['ws_recursive']) + time.sleep(0.5) + for change in changes: + eh.dispatch(change) + time.sleep(0.25) + try: + observer.unschedule_all() + except Exception: + pass + except Exception: + raise + if len(eh.data) > 0: + message = Message(Topic('onStartupFileChanges', setting['key']), listOfChanges=eh.data) + self.publish(message) + else: + message = Message(Topic('onStartupFileChanges', setting['key']), listOfChanges=[{'DirsDeleted':folder}]) + log(msg=_('Watchdog Startup folder not found: %s') % folder) + self.publish(message) + + @staticmethod + def getChangesFromDiff(diff): + ret = [] + events = {'dirs_created':(EVENT_TYPE_CREATED, True), 'dirs_deleted':(EVENT_TYPE_DELETED, True), 'dirs_modified':(EVENT_TYPE_MODIFIED, True), 'dirs_moved':(EVENT_TYPE_MOVED, True), + 'files_created':(EVENT_TYPE_CREATED, False), 'files_deleted':(EVENT_TYPE_DELETED, False), 'files_modified':(EVENT_TYPE_MODIFIED, False), 'files_moved':(EVENT_TYPE_MOVED, False)} + for event in events.keys(): + try: + mylist = diff.__getattribute__(event) + except AttributeError: + mylist = [] + if len(mylist) > 0: + for item in mylist: + evt = FileSystemEvent(item) + evt.event_type = events[event][0] + evt.is_directory = events[event][1] + ret.append(evt) + return ret + + def abort(self, *args): + snapshots = {} + for setting in self.settings: + folder = xbmc.translatePath(setting['ws_folder']) + if folder == u'': + folder = setting['ws_folder'] + folder = translatepath(folder) + if os.path.exists(folder): + snapshot = DirectorySnapshot(folder, recursive=setting['ws_recursive']) + snapshots[folder] = snapshot + WatchdogStartup.savePickle(snapshots) + + def join(self, timeout=None): + pass + + @staticmethod + def savePickle(snapshots): + picklepath = WatchdogStartup.getPicklePath() + try: + with open(picklepath, 'w') as f: + pickle.dump(snapshots, f) + except pickle.PicklingError: + log(msg=_('Watchdog startup pickling error on exit')) + except OSError: + log(msg=_('Watchdog startup OSError on pickle attempt')) + else: + log(msg=_('Watchdog startup pickle saved')) + + @staticmethod + def clearPickle(): + path = WatchdogStartup.getPicklePath() + if os.path.exists(path): + try: + os.remove(path) + except OSError: + log(msg=_('Watchdog startup could not clear pickle')) + + @staticmethod + def getPicklePath(): + path = translatepath('special://addondata/watchdog.pkl') + setPathRW(os.path.split(path)[0]) + return path + + @staticmethod + def getPickle(): + picklepath = WatchdogStartup.getPicklePath() + if not os.path.exists(picklepath): + return + try: + with open(picklepath, 'r') as f: + oldsnapshots = pickle.load(f) + except OSError: + log (msg=_('Watchdog Startup could not load pickle')) + except pickle.UnpicklingError: + log (msg=_('Watchdog Startup unpickling error')) + else: + return oldsnapshots + diff --git a/script.service.kodi.callbacks/resources/lib/pubsub.py b/script.service.kodi.callbacks/resources/lib/pubsub.py new file mode 100644 index 0000000000..85ade45df9 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/pubsub.py @@ -0,0 +1,316 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import Queue +import abc +import copy +import threading +import time +from resources.lib.utils.poutil import KodiPo +kodipo = KodiPo() +_ = kodipo.getLocalizedString + +LOGLEVEL_CRITICAL = 50 +LOGLEVEL_ERROR = 40 +LOGLEVEL_WARNING = 30 +LOGLEVEL_INFO = 20 +LOGLEVEL_DEBUG = 10 + + +class BaseLogger(object): + __metaclass__ = abc.ABCMeta + selfloglevel = LOGLEVEL_INFO + + @staticmethod + def setLogLevel(loglevel): + BaseLogger.selfloglevel = loglevel + + @staticmethod + @abc.abstractmethod + def log(loglevel=None, msg=None): + pass + + +class PrintLogger(BaseLogger): + @staticmethod + def log(loglevel=LOGLEVEL_INFO, msg='Log Event'): + if loglevel >= BaseLogger.selfloglevel: + print msg + +class TaskReturn(object): + def __init__(self, iserror=False, msg=''): + self.iserror = iserror + self.msg = msg + self.taskId = None + self.eventId = None + + +def DummyReturnHandler(*args, **kwargs): + pass + + +class DummyLogger(BaseLogger): + @staticmethod + def log(*args): + pass + + +class Topic(object): + def __init__(self, topic, subtopic=None): + self.topic = topic + self.subtopic = subtopic + + def has_subtopic(self): + if self.subtopic is None: + return False + else: + return True + + def __eq__(self, other): + assert isinstance(other, Topic) + if other.has_subtopic(): + if self.has_subtopic(): + if other.topic == self.topic and other.subtopic == self.subtopic: + return True + else: + return False + else: ## self has no subtopic + return False + else: + if self.has_subtopic(): + if self.topic == other.topic: + return True + else: + return False + else: + if self.topic == other.topic: + return True + else: + return False + + def __repr__(self): + if self.has_subtopic(): + return '%s:%s' % (self.topic, self.subtopic) + else: + return self.topic + + +class Message(object): + def __init__(self, topic, **kwargs): + self.topic = topic + self.kwargs = kwargs + + +class Dispatcher(threading.Thread): + def __init__(self, interval=0.1, sleepfxn=time.sleep): + super(Dispatcher, self).__init__(name='Dispatcher') + self._message_q = Queue.Queue() + self._abort_evt = threading.Event() + self._abort_evt.clear() + self.subscribers = [] + self.running = False + self.interval = interval + self.sleepfxn = sleepfxn + + def addSubscriber(self, subscriber): + assert isinstance(subscriber, Subscriber) + wasrunning = False + if self.running: + self.abort() + self.join() + wasrunning = True + self.subscribers.append(subscriber) + if wasrunning: + self.start() + + def q_message(self, message): + self._message_q.put(copy.copy(message), block=False) + + def run(self): + self.running = True + while not self._abort_evt.is_set(): + while not self._message_q.empty(): + try: + msg = self._message_q.get_nowait() + assert isinstance(msg, Message) + except Queue.Empty: + continue + except AssertionError: + raise + for s in self.subscribers: + if msg.topic in s.topics: + s.notify(msg) + self.sleepfxn(self.interval) + self.running = False + + def abort(self, timeout=0): + if self.running: + self._abort_evt.set() + if timeout > 0: + self.join(timeout) + + +class Publisher(object): + __metaclass__ = abc.ABCMeta + def __init__(self, dispatcher): + self.dispatcher = dispatcher + + def publish(self, message): + self.dispatcher.q_message(message) + + @abc.abstractmethod + def start(self): + pass + + @abc.abstractmethod + def abort(self, timeout=0): + pass + + @abc.abstractmethod + def join(self, timeout=0): + pass + + + +class Task(threading.Thread): + __metaclass__ = abc.ABCMeta + + def __init__(self): + super(Task, self).__init__(name='Task') + self.kwargs = {} + self.userargs = [] + self.topic = None + self.returnQ = Queue.Queue() + + def t_start(self, topic, *args, **kwargs): + + self.userargs = args + self.kwargs = kwargs + self.topic = topic + self.start() + + @abc.abstractmethod + def run(self): + ret = TaskReturn() + self.returnQ.put(ret) + + +class TaskManager(object): + def __init__(self, task, maxrunning=1, refractory=None, maxruns=-1, **taskKwargs): + self.task = task + self.maxrunning = maxrunning + self.refractory_period = refractory + self.run_tasks = [] + self.most_recent_task_time = time.time() + self.max_runs = maxruns + self.run_count = 0 + self.taskKwargs = taskKwargs + self.returnHandler = DummyReturnHandler + + def start(self, topic, **kwargs): + taskReturn = TaskReturn(iserror=True) + taskReturn.eventId = str(topic) + taskReturn.taskId = '' + if self.max_runs > 0: + if self.run_count >= self.max_runs: + taskReturn.msg = TaskManagerException_TaskCountExceeded.message + self.returnHandler(taskReturn) + return + if self.maxrunning > 0: + count = 0 + for i, task in enumerate(self.run_tasks): + assert isinstance(task, threading.Thread) + if task.is_alive(): + count += 1 + else: + del task + del self.run_tasks[i] + if count >= self.maxrunning: + taskReturn.msg = TaskManagerException_TaskAlreadyRunning.message + self.returnHandler(taskReturn) + return + if self.refractory_period > 0: + tnow = time.time() + if tnow - self.most_recent_task_time < self.refractory_period: + taskReturn.msg = TaskManagerException_TaskInRefractoryPeriod.message + self.returnHandler(taskReturn) + return + else: + self.most_recent_task_time = tnow + + # Launch the task + self.run_count += 1 + + task = self.task() + task.t_start(topic, self.taskKwargs, **kwargs) + if self.maxrunning > 0: + self.run_tasks.append(task) + while task.returnQ.empty(): + pass + taskReturn = task.returnQ.get_nowait() + assert isinstance(taskReturn, TaskReturn) + self.returnHandler(taskReturn) + + +class TaskManagerException_TaskCountExceeded(Exception): + def __init__(self): + super(TaskManagerException_TaskCountExceeded, self).__init__(_('Task not run because task count exceeded')) + + +class TaskManagerException_TaskAlreadyRunning(Exception): + def __init__(self): + super(TaskManagerException_TaskAlreadyRunning, self).__init__(_('Task not run because task already running')) + + +class TaskManagerException_TaskInRefractoryPeriod(Exception): + def __init__(self): + super(TaskManagerException_TaskInRefractoryPeriod, self).__init__( + _('Task not run because task is in refractory period')) + + +class Subscriber(object): + def __init__(self, logger=DummyLogger, loglevel=LOGLEVEL_INFO): + self.topics = [] + self.taskmanagers = [] + self.logger = logger + self.loglevel = loglevel + + def addTopic(self, topic): + assert isinstance(topic, Topic) + self.topics.append(topic) + + def addTaskManager(self, tm): + assert isinstance(tm, TaskManager) + self.taskmanagers.append(tm) + + def notify(self, message): + for taskmanager in self.taskmanagers: + try: + self.logger.log(self.loglevel, _('Task starting for %s') % message.topic) + taskmanager.start(message.topic, **message.kwargs) + except TaskManagerException_TaskAlreadyRunning as e: + self.logger.log(self.loglevel, '%s - %s' % (message.topic, e.message)) + except TaskManagerException_TaskInRefractoryPeriod as e: + self.logger.log(self.loglevel, '%s - %s' % (message.topic, e.message)) + except TaskManagerException_TaskCountExceeded as e: + self.logger.log(self.loglevel, '%s - %s' % (message.topic, e.message)) + except Exception as e: + raise e + else: + self.logger.log(self.loglevel, _('Task finalized for %s') % message.topic) diff --git a/script.service.kodi.callbacks/resources/lib/schedule/__init__.py b/script.service.kodi.callbacks/resources/lib/schedule/__init__.py new file mode 100644 index 0000000000..ed13587b43 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/schedule/__init__.py @@ -0,0 +1,454 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +""" +Python job scheduling for humans. + +An in-process scheduler for periodic jobs that uses the builder pattern +for configuration. Schedule lets you run Python functions (or any other +callable) periodically at pre-determined intervals using a simple, +human-friendly syntax. + +Inspired by Addam Wiggins' article "Rethinking Cron" [1] and the +"clockwork" Ruby module [2][3]. + +Features: + - A simple to use API for scheduling jobs. + - Very lightweight and no external dependencies. + - Excellent test coverage. + - Works with Python 2.7 and 3.3 + +Usage: + >>> from resources.lib schedule + >>> import time + + >>> def job(message='stuff'): + >>> print("I'm working on:", message) + + >>> schedule.every(10).minutes.do(job) + >>> schedule.every().hour.do(job, message='things') + >>> schedule.every().day.at("10:30").do(job) + + >>> while True: + >>> schedule.run_pending() + >>> time.sleep(1) + +[1] http://adam.heroku.com/past/2010/4/13/rethinking_cron/ +[2] https://github.com/tomykaira/clockwork +[3] http://adam.heroku.com/past/2010/6/30/replace_cron_with_clockwork/ +""" +import datetime +import functools +import logging +import time + +logger = logging.getLogger('schedule') + + +class CancelJob(object): + pass + + +class Scheduler(object): + def __init__(self): + self.jobs = [] + + def run_pending(self): + """Run all jobs that are scheduled to run. + + Please note that it is *intended behavior that tick() does not + run missed jobs*. For example, if you've registered a job that + should run every minute and you only call tick() in one hour + increments then your job won't be run 60 times in between but + only once. + """ + runnable_jobs = (job for job in self.jobs if job.should_run) + for job in sorted(runnable_jobs): + self._run_job(job) + + def run_all(self, delay_seconds=0): + """ + Run all jobs regardless if they are scheduled to run or not. + + A delay of `delay` seconds is added between each job. This helps + distribute system load generated by the jobs more evenly + over time. + :param delay_seconds: + :type delay_seconds: float + :return: + :rtype: + """ + logger.info('Running *all* %i jobs with %is delay inbetween', + len(self.jobs), delay_seconds) + for job in self.jobs: + self._run_job(job) + time.sleep(delay_seconds) + + def clear(self): + """Deletes all scheduled jobs.""" + del self.jobs[:] + + def cancel_job(self, job): + """ + Delete a scheduled job. + :param job: + :type job: Job + :return: + :rtype: + """ + try: + self.jobs.remove(job) + except ValueError: + pass + + def every(self, interval=1): + """ + Schedule a new periodic job. + :param interval: + :type interval: int + :return: + :rtype: + """ + job = Job(interval) + self.jobs.append(job) + return job + + def _run_job(self, job): + ret = job.run() + if isinstance(ret, CancelJob) or ret is CancelJob: + self.cancel_job(job) + + @property + def next_run(self): + """Datetime when the next job should run.""" + if not self.jobs: + return None + return min(self.jobs).next_run + + @property + def idle_seconds(self): + """Number of seconds until `next_run`.""" + return (self.next_run - datetime.datetime.now()).total_seconds() + + +class Job(object): + """A periodic job as used by `Scheduler`.""" + def __init__(self, interval): + self.interval = interval # pause interval * unit between runs + self.job_func = None # the job job_func to run + self.unit = None # time units, e.g. 'minutes', 'hours', ... + self.at_time = None # optional time at which this job runs + self.last_run = None # datetime of the last run + self.next_run = None # datetime of the next run + self.period = None # timedelta between runs, only valid for + self.start_day = None # Specific day of the week to start on + + def __lt__(self, other): + """ + PeriodicJobs are sortable based on the scheduled time + they run next. + :param other: + :type other: Job + :return: + :rtype: + """ + return self.next_run < other.next_run + + def __repr__(self): + def format_time(t): + return t.strftime('%Y-%m-%d %H:%M:%S') if t else '[never]' + + timestats = '(last run: %s, next run: %s)' % ( + format_time(self.last_run), format_time(self.next_run)) + + if hasattr(self.job_func, '__name__'): + job_func_name = self.job_func.__name__ + else: + job_func_name = repr(self.job_func) + args = [repr(x) for x in self.job_func.args] + kwargs = ['%s=%s' % (k, repr(v)) + for k, v in self.job_func.keywords.items()] + call_repr = job_func_name + '(' + ', '.join(args + kwargs) + ')' + + if self.at_time is not None: + return 'Every %s %s at %s do %s %s' % ( + self.interval, + self.unit[:-1] if self.interval == 1 else self.unit, + self.at_time, call_repr, timestats) + else: + return 'Every %s %s do %s %s' % ( + self.interval, + self.unit[:-1] if self.interval == 1 else self.unit, + call_repr, timestats) + + @property + def second(self): + assert self.interval == 1, 'Use seconds instead of second' + return self.seconds + + @property + def seconds(self): + self.unit = 'seconds' + return self + + @property + def minute(self): + assert self.interval == 1, 'Use minutes instead of minute' + return self.minutes + + @property + def minutes(self): + self.unit = 'minutes' + return self + + @property + def hour(self): + assert self.interval == 1, 'Use hours instead of hour' + return self.hours + + @property + def hours(self): + self.unit = 'hours' + return self + + @property + def day(self): + assert self.interval == 1, 'Use days instead of day' + return self.days + + @property + def days(self): + self.unit = 'days' + return self + + @property + def week(self): + assert self.interval == 1, 'Use weeks instead of week' + return self.weeks + + @property + def weeks(self): + self.unit = 'weeks' + return self + + @property + def monday(self): + assert self.interval == 1, 'Use mondays instead of monday' + self.start_day = 'monday' + return self.weeks + + @property + def tuesday(self): + assert self.interval == 1, 'Use tuesdays instead of tuesday' + self.start_day = 'tuesday' + return self.weeks + + @property + def wednesday(self): + assert self.interval == 1, 'Use wedesdays instead of wednesday' + self.start_day = 'wednesday' + return self.weeks + + @property + def thursday(self): + assert self.interval == 1, 'Use thursday instead of thursday' + self.start_day = 'thursday' + return self.weeks + + @property + def friday(self): + assert self.interval == 1, 'Use fridays instead of friday' + self.start_day = 'friday' + return self.weeks + + @property + def saturday(self): + assert self.interval == 1, 'Use saturdays instead of saturday' + self.start_day = 'saturday' + return self.weeks + + @property + def sunday(self): + assert self.interval == 1, 'Use sundays instead of sunday' + self.start_day = 'sunday' + return self.weeks + + def at(self, time_str): + """ + Schedule the job every day at a specific time. + + Calling this is only valid for jobs scheduled to run every + N day(s). + :param time_str: + :type time_str: str + :return: + :rtype: + """ + assert self.unit in ('days', 'hours') or self.start_day + hour, minute = time_str.split(':') + minute = int(minute) + if self.unit == 'days' or self.start_day: + hour = int(hour) + assert 0 <= hour <= 23 + elif self.unit == 'hours': + hour = 0 + assert 0 <= minute <= 59 + self.at_time = datetime.time(hour, minute) + return self + + def do(self, job_func, *args, **kwargs): + """ + Specifies the job_func that should be called every time the + job runs. + + Any additional arguments are passed on to job_func when + the job runs. + :param job_func: + :type job_func: instancemethod + :param args: + :type args: + :param kwargs: + :type kwargs: + :return: + :rtype: + """ + self.job_func = functools.partial(job_func, *args, **kwargs) + try: + functools.update_wrapper(self.job_func, job_func) + except AttributeError: + # job_funcs already wrapped by functools.partial won't have + # __name__, __module__ or __doc__ and the update_wrapper() + # call will fail. + pass + self._schedule_next_run() + return self + + @property + def should_run(self): + """True if the job should be run now.""" + return datetime.datetime.now() >= self.next_run + + def run(self): + """Run the job and immediately reschedule it.""" + logger.info('Running job %s', self) + ret = self.job_func() + self.last_run = datetime.datetime.now() + self._schedule_next_run() + return ret + + def _schedule_next_run(self): + """Compute the instant when this job should run next.""" + # Allow *, ** magic temporarily: + # pylint: disable=W0142 + assert self.unit in ('seconds', 'minutes', 'hours', 'days', 'weeks') + self.period = datetime.timedelta(**{self.unit: self.interval}) + self.next_run = datetime.datetime.now() + self.period + if self.start_day is not None: + assert self.unit == 'weeks' + weekdays = ( + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday' + ) + assert self.start_day in weekdays + weekday = weekdays.index(self.start_day) + days_ahead = weekday - self.next_run.weekday() + if days_ahead <= 0: # Target day already happened this week + days_ahead += 7 + self.next_run += datetime.timedelta(days_ahead) - self.period + if self.at_time is not None: + assert self.unit in ('days', 'hours') or self.start_day is not None + kwargs = { + 'minute': self.at_time.minute, + 'second': self.at_time.second, + 'microsecond': 0 + } + if self.unit == 'days' or self.start_day is not None: + kwargs['hour'] = self.at_time.hour + self.next_run = self.next_run.replace(**kwargs) + # If we are running for the first time, make sure we run + # at the specified time *today* (or *this hour*) as well + if not self.last_run: + now = datetime.datetime.now() + if (self.unit == 'days' and self.at_time > now.time() and + self.interval == 1): + self.next_run = self.next_run - datetime.timedelta(days=1) + elif self.unit == 'hours' and self.at_time.minute > now.minute: + self.next_run = self.next_run - datetime.timedelta(hours=1) + if self.start_day is not None and self.at_time is not None: + # Let's see if we will still make that time we specified today + if (self.next_run - datetime.datetime.now()).days >= 7: + self.next_run -= self.period + +# The following methods are shortcuts for not having to +# create a Scheduler instance: + +default_scheduler = Scheduler() +jobs = default_scheduler.jobs # todo: should this be a copy, e.g. jobs()? + + +def every(interval=1): + """ + Schedule a new periodic job. + :param interval: + :type interval: int + :return: + :rtype: + """ + return default_scheduler.every(interval) + + +def run_pending(): + """Run all jobs that are scheduled to run. + + Please note that it is *intended behavior that run_pending() + does not run missed jobs*. For example, if you've registered a job + that should run every minute and you only call run_pending() + in one hour increments then your job won't be run 60 times in + between but only once. + """ + default_scheduler.run_pending() + + +def run_all(delay_seconds=0): + """ + Run all jobs regardless if they are scheduled to run or not. + + A delay of `delay` seconds is added between each job. This can help + to distribute the system load generated by the jobs more evenly over + time. + :param delay_seconds: + :type delay_seconds: float + :return: + :rtype: + """ + default_scheduler.run_all(delay_seconds=delay_seconds) + + +def clear(): + """Deletes all scheduled jobs.""" + default_scheduler.clear() + + +def cancel_job(job): + """ + Delete a scheduled job. + :param job: + :type job: Job + :return: + :rtype: + """ + default_scheduler.cancel_job(job) + + +def next_run(): + """Datetime when the next job should run.""" + return default_scheduler.next_run + + +def idle_seconds(): + """Number of seconds until `next_run`.""" + return default_scheduler.idle_seconds diff --git a/script.service.kodi.callbacks/resources/lib/settings.py b/script.service.kodi.callbacks/resources/lib/settings.py new file mode 100644 index 0000000000..6ef721debf --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/settings.py @@ -0,0 +1,245 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import xbmcaddon +from resources.lib import taskdict +from resources.lib.events import Events +from resources.lib.events import requires_subtopic +from resources.lib.kodilogging import KodiLogger +from resources.lib.pubsub import Topic +from resources.lib.utils.kodipathtools import translatepath +from resources.lib.utils.poutil import PoDict + +podict = PoDict() +podict.read_from_file(translatepath('special://addon/resources/language/English/strings.po')) + + +def getEnglishStringFromId(msgctxt): + status, ret = podict.has_msgctxt(msgctxt) + if status is True: + return ret + else: + return '' + + +_ = getEnglishStringFromId + +try: + addonid = xbmcaddon.Addon('script.service.kodi.callbacks').getAddonInfo('id') +except RuntimeError: + addonid = 'script.service.kodi.callbacks' +else: + if addonid == '': + addonid = 'script.service.kodi.callbacks' + +kl = KodiLogger() +log = kl.log + + +def get(settingid, var_type): + t = xbmcaddon.Addon(addonid).getSetting(settingid) + if var_type == 'text' or var_type == 'file' or var_type == 'folder' or var_type == 'sfile' or var_type == 'sfolder' or var_type == 'labelenum': + return t + elif var_type == 'int': + try: + return int(t) + except TypeError: + log(msg='TYPE ERROR for variable %s. Expected int got "%s"' % (settingid, t)) + return 0 + elif var_type == 'bool': + if t == 'false': + return False + else: + return True + else: + log(msg='ERROR Could not process variable %s = "%s"' % (settingid, t)) + return None + + +class Settings(object): + allevents = Events().AllEvents + taskSuffixes = {'general': [['maxrunning', 'int'], ['maxruns', 'int'], ['refractory', 'int']], + } + eventsReverseLookup = None + + def __init__(self): + self.tasks = {} + self.events = {} + self.general = {} + rl = {} + for key in Settings.allevents.keys(): + evt = Settings.allevents[key] + rl[evt['text']] = key + Settings.eventsReverseLookup = rl + + def logSettings(self): + import pprint + settingspp = {'Tasks': self.tasks, 'Events': self.events, 'General': self.general} + pp = pprint.PrettyPrinter(indent=2) + msg = pp.pformat(settingspp) + kl = KodiLogger() + kl.log(msg=msg) + + def getSettings(self): + self.getTaskSettings() + self.getEventSettings() + self.getGeneralSettings() + + def getTaskSettings(self): + for i in xrange(1, 11): + pid = 'T%s' % str(i) + tsk = self.getTaskSetting(pid) + if tsk is not None: + self.tasks[pid] = tsk + + @staticmethod + def getTaskSetting(pid): + tsk = {} + tasktype = get('%s.type' % pid, 'text') + if tasktype == 'none': + return None + else: + tsk['type'] = tasktype + for suff in Settings.taskSuffixes['general']: + tsk[suff[0]] = get('%s.%s' % (pid, suff[0]), suff[1]) + for var in taskdict[tasktype]['variables']: + tsk[var['id']] = get('%s.%s' % (pid, var['id']), var['settings']['type']) + return tsk + + def getEventSettings(self): + for i in xrange(1, 11): + pid = "E%s" % str(i) + evt = self.getEventSetting(pid) + if evt is not None: + self.events[pid] = evt + + @staticmethod + def getEventSetting(pid): + evt = {} + et = get('%s.type' % pid, 'text') + if et == podict.has_msgid('None')[1]: + return + else: + et = _(et) + et = Settings.eventsReverseLookup[et] + evt['type'] = et + tsk = get('%s.task' % pid, 'text') + if tsk == '' or tsk.lower() == 'none': + return None + evt['task'] = 'T%s' % int(tsk[5:]) + for ri in Settings.allevents[et]['reqInfo']: + evt[ri[0]] = get('%s.%s' % (pid, ri[0]), ri[1]) + evt['userargs'] = get('%s.userargs' % pid, 'text') + return evt + + @staticmethod + def getTestEventSettings(taskId): + evt = {'type': 'onTest', 'task': taskId} + for oa in Settings.allevents['onTest']['optArgs']: + evt[oa] = True + evt['eventId'] = True + evt['taskId'] = True + return evt + + def getGeneralSettings(self): + polls = ['LoopFreq', 'LogFreq', 'TaskFreq'] + self.general['Notify'] = get('Notify', 'bool') + for p in polls: + self.general[p] = get(p, 'int') + self.general['elevate_loglevel'] = get('loglevel', 'bool') + + def getOpenwindowids(self): + ret = {} + for evtkey in self.events.keys(): + evt = self.events[evtkey] + if evt['type'] == 'onWindowOpen': + ret[evt['windowIdO']] = evt['key'] + return ret + + def getClosewindowids(self): + ret = {} + for evtkey in self.events.keys(): + evt = self.events[evtkey] + if evt['type'] == 'onWindowClose': + ret[evt['windowIdC']] = evt['key'] + return ret + + def getEventsByType(self, eventType): + ret = [] + for key in self.events.keys(): + evt = self.events[key] + if evt['type'] == eventType: + evt['key'] = key + ret.append(evt) + return ret + + def getIdleTimes(self): + idleEvts = self.getEventsByType('onIdle') + ret = {} + for evt in idleEvts: + ret[evt['key']] = int(evt['idleTime']) + return ret + + def getAfterIdleTimes(self): + idleEvts = self.getEventsByType('afterIdle') + ret = {} + for evt in idleEvts: + ret[evt['key']] = int(evt['afterIdleTime']) + return ret + + def getJsonNotifications(self): + jsonEvts = self.getEventsByType('onNotification') + ret = [] + dic = {} + for evt in jsonEvts: + dic['eventId'] = evt['key'] + dic['sender'] = evt['reqInfo']['sender'] + dic['method'] = evt['regInfo']['method'] + dic['data'] = evt['reqInfo']['data'] + ret.append(dic) + return ret + + def getLogSimples(self): + evts = self.getEventsByType('onLogSimple') + ret = [] + for evt in evts: + ret.append({'matchIf': evt['matchIf'], 'rejectIf': evt['rejectIf'], 'eventId': evt['key']}) + return ret + + def getLogRegexes(self): + evts = self.getEventsByType('onLogRegex') + ret = [] + for evt in evts: + ret.append({'matchIf': evt['matchIf'], 'rejectIf': evt['rejectIf'], 'eventId': evt['key']}) + return ret + + def getWatchdogSettings(self): + evts = self.getEventsByType('onFileSystemChange') + return evts + + def getWatchdogStartupSettings(self): + evts = self.getEventsByType('onStartupFileChanges') + return evts + + def topicFromSettingsEvent(self, key): + top = self.events[key]['type'] + if top in requires_subtopic(): + return Topic(top, key) + else: + return Topic(top) diff --git a/script.service.kodi.callbacks/resources/lib/subscriberfactory.py b/script.service.kodi.callbacks/resources/lib/subscriberfactory.py new file mode 100644 index 0000000000..9bdf2753d3 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/subscriberfactory.py @@ -0,0 +1,85 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# 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 . +# + +from resources.lib import taskdict +from resources.lib.pubsub import TaskManager, Subscriber, TaskReturn +from resources.lib.kodilogging import KodiLogger +from resources.lib.utils.poutil import KodiPo +kl = KodiLogger() +log = kl.log +kodipo = KodiPo() +_ = kodipo.getLocalizedString + +def returnHandler(taskReturn): + assert isinstance(taskReturn, TaskReturn) + if taskReturn.iserror is False: + msg = _('Command for Task %s, Event %s completed succesfully!') % (taskReturn.taskId, taskReturn.eventId) + if taskReturn.msg.strip() != '': + msg += _('\nThe following message was returned: %s') % taskReturn.msg + log(msg=msg) + else: + msg = _('ERROR encountered for Task %s, Event %s\nERROR mesage: %s') % ( + taskReturn.taskId, taskReturn.eventId, taskReturn.msg) + log(loglevel=kl.LOGERROR, msg=msg) + + +class SubscriberFactory(object): + + def __init__(self, settings, logger): + self.settings = settings + self.topics = [] + self.logger = logger + + def createSubscribers(self, retHandler=returnHandler): + subscribers = [] + for key in self.settings.events.keys(): + subscriber = self.createSubscriber(key, retHandler) + if subscriber is not None: + subscribers.append(subscriber) + return subscribers + + def createSubscriber(self, eventkey, retHandler=returnHandler): + task_key = self.settings.events[eventkey]['task'] + evtsettings = self.settings.events[eventkey] + topic = self.settings.topicFromSettingsEvent(eventkey) + self.topics.append(topic.topic) + task = self.createTask(task_key) + if task is not None: + tm = TaskManager(task, taskid=evtsettings['task'], userargs=evtsettings['userargs'], + **self.settings.tasks[task_key]) + tm.returnHandler = retHandler + tm.taskKwargs['notify'] = self.settings.general['Notify'] + subscriber = Subscriber(logger=self.logger) + subscriber.addTaskManager(tm) + subscriber.addTopic(topic) + self.logger.log(msg=_('Subscriber for event: %s, task: %s created') % (str(topic), task_key)) + return subscriber + else: + self.logger.log(loglevel=self.logger.LOGERROR, + msg=_('Subscriber for event: %s, task: %s NOT created due to errors') % (str(topic), task_key)) + return None + + def createTask(self, taskkey): + tasksettings = self.settings.tasks[taskkey] + mytask = taskdict[tasksettings['type']]['class'] + if mytask.validate(tasksettings, xlog=self.logger.log) is True: + return mytask + else: + return None + diff --git a/script.service.kodi.callbacks/resources/lib/taskABC.py b/script.service.kodi.callbacks/resources/lib/taskABC.py new file mode 100644 index 0000000000..5e2ea0d4c2 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/taskABC.py @@ -0,0 +1,114 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import threading +import Queue +import abc +import re +import copy +from resources.lib.kodilogging import KodiLogger +from resources.lib.pubsub import TaskReturn +from resources.lib.events import Events +import xbmcgui + +events = Events() + +def notify(msg): + dialog = xbmcgui.Dialog() + dialog.notification('Kodi Callbacks', msg, xbmcgui.NOTIFICATION_INFO, 5000) + + +class AbstractTask(threading.Thread): + """ + Abstract class for command specific workers to follow + """ + __metaclass__ = abc.ABCMeta + tasktype = 'abstract' + lock = threading.RLock() + + def __init__(self, logger=KodiLogger.log): + super(AbstractTask, self).__init__(name='Worker') + self.cmd_str = '' + self.userargs = '' + self.log = logger + self.runtimeargs = [] + self.taskKwargs = {} + self.publisherKwargs = {} + self.topic = None + self.type = '' + self.taskId = '' + self.returnQ = Queue.Queue() + self.delimitregex = re.compile(r'\s+,\s+|,\s+|\s+') + + + def processUserargs(self, kwargs): + if self.userargs == '': + return [] + ret = copy.copy(self.userargs) + ret = ret.replace(r'%%', '{@literal%@}') + if self.tasktype == 'script' or self.tasktype == 'python': + tmp = self.delimitregex.sub(r'{@originaldelim@}', ret) + ret = tmp + try: + varArgs = events.AllEvents[self.topic.topic]['varArgs'] + except KeyError: + pass + else: + for key in varArgs.keys(): + try: + kw = str(kwargs[varArgs[key]]) + kw = kw.replace(" ", '%__') + ret = ret.replace(key, kw) + except KeyError: + pass + ret = ret.replace('%__', " ") + ret = ret.replace('%_', ",") + ret = ret.replace('{@literal%@}', r'%') + if self.tasktype == 'script' or self.tasktype == 'python': + ret = ret.split('{@originaldelim@}') + return ret + else: + return ret + + @staticmethod + @abc.abstractmethod + def validate(taskKwargs, xlog=KodiLogger.log): + pass + + def t_start(self, topic, taskKwargs, **kwargs): + with AbstractTask.lock: + self.topic = topic + self.taskKwargs = taskKwargs + self.userargs = taskKwargs['userargs'] + self.taskId = taskKwargs['taskid'] + self.publisherKwargs = kwargs + self.runtimeargs = self.processUserargs(kwargs) + self.start() + + @abc.abstractmethod + def run(self): + err = None # True if error occured + msg = '' # string containing error message or return message + self.threadReturn(err, msg) + + def threadReturn(self, err, msg): + taskReturn = TaskReturn(err, msg) + taskReturn.eventId = str(self.topic) + taskReturn.taskId = self.taskId + self.returnQ.put(taskReturn) diff --git a/script.service.kodi.callbacks/resources/lib/taskExample.py b/script.service.kodi.callbacks/resources/lib/taskExample.py new file mode 100644 index 0000000000..4d91396e1c --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/taskExample.py @@ -0,0 +1,124 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import sys +import traceback +from resources.lib.taskABC import AbstractTask, KodiLogger, notify +from resources.lib.utils.poutil import KodiPo +kodipo = KodiPo() +_ = kodipo.getLocalizedString +__ = kodipo.getLocalizedStringId + +class TaskCustom(AbstractTask): + """ + Your task MUST subclass AbstractTask. If it doesn't, it will not be accessible. + The following two parameters are REQUIRED with the same structure as shown. + 'Varaibles' will be prompted for from the Settings screen. + If you have no variables, then you probably do not need to be writing a custom task. + Shorten or extend the list as needed for more or less variables. + This allows for the task to be properly 'discovered' and also will allow users to imput required information. + See http://kodi.wiki/view/Add-on_settings for details about the settings variables. + """ + tasktype = 'mycustomtasktype' + variables = [ + { + 'id':'mycustomvariable1', + 'settings':{ + 'default':'', + 'label':__('My Custom Task Variable 1', update=True), # Surround label for settings with localization function + 'type':'text' + } + }, + { + 'id':'mycustomvariable2', + 'settings':{ + 'default':'false', + 'label':__('My Custom Task Variable 2', update=True), # Surround label for settings with localization function + 'type':'bool' + } + }, + ] + + def __init__(self): + """Do not request any other variables via __init__. Nothing else will be provided and an exception will be raised. + The following super call is REQUIRED.""" + super(TaskCustom, self).__init__() + # Put anything else here you may need, but note that validate is a staticmethod and cannot access 'self'. + + @staticmethod + def validate(taskKwargs, xlog=KodiLogger.log): + """ + :param taskKwargs: + :param xlog: class or method that accepts msg=str and loglevel=int see below + :type: + :return: whether validate + :rtype: bool + Place any code here to validate the users input from the settings page. + Referring to the above taskKwargs will be a dictionary objected keyed for your variable ids. + For example: {'mycustomvariable1':'userinput', 'mycustomvariable2':False} + The appropraite logger will be passed in. During testing, log output is captured and then displayed + on the screen. Usage: + xlog(msg='My message') + Return True if validating passed. False if it didn't. If there is nothing to validate, just return True. + + ** If you generate any log messages here, surround them with the _( , update=True) localization function. + This will cause the po file to be updated with your strings. + See below. During direct testing from the settings page, these log messages will be displayed on the screen + """ + xlog(msg=_('My Custom Task passed validation', update=True)) + + return True + + def run(self): + """ + The following templated code is recommended. As above, self.taskKwargs contains your user input variables. + There are a few other things added to taskKwargs (such as 'notify' seen below). If you have access to a debugger, + stop the code here and look at that variable. Or try logging it as a string, if interested. + self.runtimeargs contains the variable substituted event string. + Information is passed out via the err and msg variables. + + ** If you generate any log messages here, surround them with the _( , update=True) localization function. + This will cause the po file to be updated with your strings. + See below. During direct testing from the settings page, these log messages will be displayed on the screen + """ + if self.taskKwargs['notify'] is True: + notify(_('Task %s launching for event: %s') % (self.taskId, str(self.topic))) + err = False # Change this to True if an error is encountered + msg = '' # Accumulate error or non-error information that needs to be returned in this string + args = self.runtimeargs # This contains a list derived from the variable subsituted event string + # This list format is needed for using python's subprocess and Popen calls so that's why it is formatted + # in this fashion. + # If you need the args in a different format consider rejoining it into a string such as ' '.join(args) or + # ', '.join(args). If you need something very different, you will need to override self.processUserargs() + # See taskABC for the default processing and taskHttp for an example of overriding. + assert isinstance(args, list) + try: + pass + # Put your task implementation code here. Consider an inner try/except block accumulating specific error info + # by setting err=True and appending to the message. + except Exception: + # Non-specific error catching and processing. + e = sys.exc_info()[0] + err = True + if hasattr(e, 'message'): + msg = str(e.message) + msg = msg + '\n' + traceback.format_exc() + # The following needs to be the last call. Since this code is running in its own thread, to pass information + # backout, the following is formatted and placed in an output Queue where the parent thread is waiting. + # If you do not pass anything out, a memory leak will accumulate with 'TaskManagers' accumulating over time. + self.threadReturn(err, msg) diff --git a/script.service.kodi.callbacks/resources/lib/tasks/__init__.py b/script.service.kodi.callbacks/resources/lib/tasks/__init__.py new file mode 100644 index 0000000000..9789d2f536 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/tasks/__init__.py @@ -0,0 +1,19 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 KenV99 +# +# 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 . +# + diff --git a/script.service.kodi.callbacks/resources/lib/tasks/taskBuiltin.py b/script.service.kodi.callbacks/resources/lib/tasks/taskBuiltin.py new file mode 100644 index 0000000000..5fd622cb34 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/tasks/taskBuiltin.py @@ -0,0 +1,71 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import sys +import traceback +import xbmc +from resources.lib.taskABC import AbstractTask, KodiLogger, notify +from resources.lib.utils.poutil import KodiPo +kodipo = KodiPo() +_ = kodipo.getLocalizedString +__ = kodipo.getLocalizedStringId + +class TaskBuiltin(AbstractTask): + tasktype = 'builtin' + variables = [ + { + 'id':u'builtin', + 'settings':{ + 'default':u'', + 'label':u'Kodi Builtin Function', + 'type':'text' + } + }, + ] + + def __init__(self): + super(TaskBuiltin, self).__init__() + + @staticmethod + def validate(taskKwargs, xlog=KodiLogger.log): + return True + + def run(self): + if self.taskKwargs['notify'] is True: + notify(_('Task %s launching for event: %s') % (self.taskId, str(self.topic))) + err = False + msg = '' + args = ' %s' % ' '.join(self.runtimeargs) + # noinspection PyBroadException,PyBroadException,PyBroadException + try: + if len(self.runtimeargs) > 0: + result = xbmc.executebuiltin('%s, %s' % (self.taskKwargs['builtin'], args)) + else: + result = xbmc.executebuiltin('%s' % self.taskKwargs['builtin']) + if result is not None: + msg = result + if result != '': + err = True + except Exception: + e = sys.exc_info()[0] + err = True + if hasattr(e, 'message'): + msg = str(e.message) + msg = msg + '\n' + traceback.format_exc() + self.threadReturn(err, msg) diff --git a/script.service.kodi.callbacks/resources/lib/tasks/taskHttp.py b/script.service.kodi.callbacks/resources/lib/tasks/taskHttp.py new file mode 100644 index 0000000000..bf42ca1bf0 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/tasks/taskHttp.py @@ -0,0 +1,184 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import sys +import traceback +import requests +import urllib2 +import httplib +from urlparse import urlparse +import socket +from resources.lib.taskABC import AbstractTask, KodiLogger, notify +from resources.lib.utils.poutil import KodiPo +kodipo = KodiPo() +_ = kodipo.getLocalizedString +__ = kodipo.getLocalizedStringId + +class TaskHttp(AbstractTask): + tasktype = 'http' + variables = [ + { + 'id':u'http', + 'settings':{ + 'default':u'', + 'label':u'HTTP string (without parameters)', + 'type':'text' + } + }, + { + 'id':u'user', + 'settings':{ + 'default':u'', + 'label':u'user for Basic Auth (optional)', + 'type':'text' + } + }, + { + 'id':u'pass', + 'settings':{ + 'default':u'', + 'label':u'password for Basic Auth (optional)', + 'type':'text', + 'option':u'hidden' + } + }, + { + 'id': u'request-type', + 'settings': { + 'default': u'GET', + 'label': u'Request Type', + 'type': u'labelenum', + 'values': [u'GET', u'POST', u'POST-GET', u'PUT', u'DELETE', u'HEAD', u'OPTIONS'] + } + }, + { + 'id': u'content-type', + 'settings': { + 'default': u'application/json', + 'label': u'Content-Type (for POST or PUT only)', + 'type': u'labelenum', + 'values': [u'application/json', u'application/x-www-form-urlencoded', u'text/html', u'text/plain'] + } + } + ] + + def __init__(self): + super(TaskHttp, self).__init__() + self.runtimeargs = '' + + @staticmethod + def validate(taskKwargs, xlog=KodiLogger.log): + o = urlparse(taskKwargs['http']) + if o.scheme != '' and o.netloc != '': + return True + else: + xlog(msg=_('Invalid url: %s') % taskKwargs['http']) + return False + + def sendRequest(self, session, verb, url, postget=False): + if postget or verb == 'POST' or verb == 'PUT': + url, data = url.split('??', 1) + if postget: + data = None + else: + data = None + req = requests.Request(verb, url, data=data) + prepped = session.prepare_request(req) + if verb == 'POST' or verb == 'PUT': + prepped.headers['Content-Type'] = self.taskKwargs['content-type'] + msg = 'Prepped URL: %s\nBody: %s' % (prepped.url, prepped.body) + try: + resp = session.send(prepped, timeout=20) + msg += '\nStatus: %s' % str(resp.status_code) + resp.raise_for_status() + err = False + if resp.text == '': + respmsg = 'No response received' + else: + respmsg = resp.text + msg += '\nResponse for %s: %s' %(verb, respmsg) + resp.close() + except requests.ConnectionError as e: + err = True + msg = _('Requests Connection Error') + except requests.HTTPError as e: + err = True + msg = '%s: %s' %(_('Requests HTTPError'), str(e)) + except requests.URLRequired as e: + err = True + msg = '%s: %s' %(_('Requests URLRequired Error'), str(e)) + except requests.Timeout as e: + err = True + msg = '%s: %s' %(_('Requests Timeout Error'), str(e)) + except requests.RequestException as e: + err = True + msg = '%s: %s' %(_('Generic Requests Error'), str(e)) + except urllib2.HTTPError, e: + err = True + msg = _('HTTPError = ') + str(e.code) + except urllib2.URLError, e: + err = True + msg = _('URLError\n') + e.reason + except httplib.BadStatusLine: + err = False + self.log(msg=_('Http Bad Status Line caught and passed')) + except httplib.HTTPException, e: + err = True + msg = _('HTTPException') + if hasattr(e, 'message'): + msg = msg + '\n' + e.message + except socket.timeout: + err = True + msg = _('The request timed out, host unreachable') + except Exception: + err = True + e = sys.exc_info()[0] + if hasattr(e, 'message'): + msg = str(e.message) + msg = msg + '\n' + traceback.format_exc() + return err, msg + + + def run(self): + if self.taskKwargs['notify'] is True: + notify(_('Task %s launching for event: %s') % (self.taskId, str(self.topic))) + if isinstance(self.runtimeargs, list): + if len(self.runtimeargs) > 0: + self.runtimeargs = ''.join(self.runtimeargs) + else: + self.runtimeargs = '' + s = requests.Session() + url = self.taskKwargs['http']+self.runtimeargs + if self.taskKwargs['user'] != '' and self.taskKwargs['pass'] != '': + s.auth = (self.taskKwargs['user'], self.taskKwargs['pass']) + if self.taskKwargs['request-type'] == 'POST-GET': + verb = 'POST' + else: + verb = self.taskKwargs['request-type'] + + err, msg = self.sendRequest(s, verb, url) + + if self.taskKwargs['request-type'] == 'POST-GET': + err2, msg2 = self.sendRequest(s, 'GET', url, postget=True) + err = err or err2 + msg = '\n'.join([msg, msg2]) + + s.close() + self.threadReturn(err, msg) + diff --git a/script.service.kodi.callbacks/resources/lib/tasks/taskJson.py b/script.service.kodi.callbacks/resources/lib/tasks/taskJson.py new file mode 100644 index 0000000000..c5223d1493 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/tasks/taskJson.py @@ -0,0 +1,76 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import sys +import traceback +import xbmc +import json +from resources.lib.taskABC import AbstractTask, KodiLogger, notify +from resources.lib.utils.poutil import KodiPo +kodipo = KodiPo() +_ = kodipo.getLocalizedString +__ = kodipo.getLocalizedStringId + +class TaskJsonNotify(AbstractTask): + tasktype = 'json_rpc_notify' + variables = [ + { + 'id':u'jsonnotify', + 'settings':{ + 'default':u'kodi.callbacks', + 'label':u'Sender string', + 'type':'text' + } + }, + ] + + def __init__(self): + super(TaskJsonNotify, self).__init__() + + @staticmethod + def validate(taskKwargs, xlog=KodiLogger.log): + return True + + def run(self): + if self.taskKwargs['notify'] is True: + notify(_('Task %s launching for event: %s') % (self.taskId, str(self.topic))) + err = False + msg = '' + message = str(self.topic) + data = str(self.publisherKwargs).replace('\'', '"') + try: + json_query = xbmc.executeJSONRPC('{ "jsonrpc": "2.0", "id": 0, "method": "JSONRPC.NotifyAll", "params": {"sender":"%s", "message":"%s", "data":%s} }' %(self.taskKwargs['jsonnotify'], message, data)) + json_query = unicode(json_query, 'utf-8', errors='ignore') + json_response = json.loads(json_query) + except Exception: + e = sys.exc_info()[0] + err = True + if hasattr(e, 'message'): + msg = str(e.message) + msg = msg + '\n' + traceback.format_exc() + else: + if json_response.has_key('result'): + if json_response['result'] != u'OK': + err = True + msg = 'JSON Notify Error %s' % json_response['result'] + else: + err = True + msg = 'JSON Notify Error: %s' % str(json_response) + + self.threadReturn(err, msg) diff --git a/script.service.kodi.callbacks/resources/lib/tasks/taskPython.py b/script.service.kodi.callbacks/resources/lib/tasks/taskPython.py new file mode 100644 index 0000000000..56fec1be95 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/tasks/taskPython.py @@ -0,0 +1,123 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import sys +import os +import traceback +import xbmc +import xbmcvfs +from resources.lib.taskABC import AbstractTask, KodiLogger, notify +from resources.lib.utils.poutil import KodiPo +from resources.lib.utils.kodipathtools import translatepath +kodipo = KodiPo() +_ = kodipo.getLocalizedString +__ = kodipo.getLocalizedStringId + + +class TaskPython(AbstractTask): + tasktype = 'python' + variables = [ + { + 'id':u'pythonfile', + 'settings':{ + 'default':u'', + 'label':u'Python file', + 'type':'file' + } + }, + { + 'id':u'import', + 'settings':{ + 'default':u'false', + 'label':u'Import and call run() (default=no)?', + 'type':'bool' + } + } + ] + + def __init__(self): + super(TaskPython, self).__init__() + + @staticmethod + def validate(taskKwargs, xlog=KodiLogger.log): + tmp = xbmc.translatePath(taskKwargs['pythonfile']) + if xbmcvfs.exists(tmp): + ext = os.path.splitext(tmp)[1] + if ext == '.py': + return True + else: + xlog(msg=_('Error - not a python script: %s') % tmp) + return False + else: + xlog(msg=_('Error - File not found: %s') % tmp) + return False + + def run(self): + if self.taskKwargs['notify'] is True: + notify(_('Task %s launching for event: %s') % (self.taskId, str(self.topic))) + err = False + msg = '' + args = self.runtimeargs + try: + useImport = self.taskKwargs['import'] + except KeyError: + useImport = False + fn = translatepath(self.taskKwargs['pythonfile']) + try: + if len(self.runtimeargs) > 0: + if useImport is False: + args = ' %s' % ' '.join(args) + result = xbmc.executebuiltin('XBMC.RunScript(%s, %s)' % (fn, args)) + else: + directory, module_name = os.path.split(fn) + module_name = os.path.splitext(module_name)[0] + + path = list(sys.path) + sys.path.insert(0, directory) + try: + module = __import__(module_name) + result = module.run(args) + finally: + sys.path[:] = path + else: + if useImport is False: + result = xbmc.executebuiltin('XBMC.RunScript(%s)' % fn) + else: + directory, module_name = os.path.split(fn) + module_name = os.path.splitext(module_name)[0] + + path = list(sys.path) + sys.path.insert(0, directory) + try: + module = __import__(module_name) + result = module.run(args) + finally: + sys.path[:] = path + if result is not None: + msg = result + if result != '': + err = True + except Exception: + e = sys.exc_info()[0] + err = True + if hasattr(e, 'message'): + msg = str(e.message) + msg = msg + '\n' + traceback.format_exc() + + self.threadReturn(err, msg) diff --git a/script.service.kodi.callbacks/resources/lib/tasks/taskScript.py b/script.service.kodi.callbacks/resources/lib/tasks/taskScript.py new file mode 100644 index 0000000000..f1c4cc036d --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/tasks/taskScript.py @@ -0,0 +1,174 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import sys +import os +import stat +import subprocess +import traceback +from resources.lib.taskABC import AbstractTask, notify, KodiLogger +from resources.lib.utils.detectPath import process_cmdline +import xbmc +import xbmcvfs +from resources.lib.utils.poutil import KodiPo +kodipo = KodiPo() +_ = kodipo.getLocalizedString +__ = kodipo.getLocalizedStringId + +sysplat = sys.platform +isAndroid = 'XBMC_ANDROID_SYSTEM_LIBS' in os.environ.keys() + +class TaskScript(AbstractTask): + tasktype = 'script' + variables = [ + { + 'id':u'scriptfile', + 'settings':{ + 'default':u'', + 'label':u'Script executable file', + 'type':'sfile' + } + }, + { + 'id':u'use_shell', + 'settings':{ + 'default':u'false', + 'label':u'Requires shell?', + 'type':'bool' + } + }, + { + 'id':u'waitForCompletion', + 'settings':{ + 'default':u'true', + 'label':u'Wait for script to complete?', + 'type':'bool' + } + } + ] + + + def __init__(self): + super(TaskScript, self).__init__() + + @staticmethod + def validate(taskKwargs, xlog=KodiLogger.log): + + tmpl = process_cmdline(taskKwargs['scriptfile']) + found = False + for tmp in tmpl: + tmp = xbmc.translatePath(tmp) + if xbmcvfs.exists(tmp) or os.path.exists(tmp) and found is False: + try: + mode = os.stat(tmp).st_mode + mode |= stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + os.chmod(tmp, mode) + except OSError: + if sysplat.startswith('win') is False: + xlog(msg=_('Failed to set execute bit on script: %s') % tmp) + finally: + found = True + return True + + def run(self): + msg = '' + if self.taskKwargs['notify'] is True: + notify(_('Task %s launching for event: %s') % (self.taskId, str(self.topic))) + try: + needs_shell = self.taskKwargs['use_shell'] + except KeyError: + needs_shell = False + try: + wait = self.taskKwargs['waitForCompletion'] + except KeyError: + wait = True + tmpl = process_cmdline(self.taskKwargs['scriptfile']) + filefound = False + basedir = None + sysexecutable = None + for i, tmp in enumerate(tmpl): + tmp = xbmc.translatePath(tmp) + if os.path.exists(tmp) and filefound is False: + basedir, fn = os.path.split(tmp) + basedir = os.path.realpath(basedir) + tmpl[i] = fn + filefound = True + if i == 0: + if os.path.splitext(fn)[1] == u'.sh': + if isAndroid: + sysexecutable = '/system/bin/sh' + elif not sysplat.startswith('win'): + sysexecutable = '/bin/bash' + else: + tmpl[i] = tmp + if sysexecutable == '/system/bin/sh': + tmpl.insert(0, 'sh') + elif sysexecutable == '/bin/bash': + tmpl.insert(0, 'bash') + + cwd = os.getcwd() + args = tmpl + self.runtimeargs + if needs_shell: + args = ' '.join(args) + err = False + msg += 'taskScript ARGS = %s\n SYSEXEC = %s\n BASEDIR = %s\n' % (args, sysexecutable, basedir) + sys.exc_clear() + try: + if basedir is not None: + os.chdir(basedir) + if sysexecutable is not None: + if isAndroid or sysplat.startswith('darwin'): + p = subprocess.Popen(args, stdout=subprocess.PIPE, shell=needs_shell, stderr=subprocess.STDOUT, executable=sysexecutable) + else: + p = subprocess.Popen(args, stdout=subprocess.PIPE, shell=needs_shell, stderr=subprocess.STDOUT, executable=sysexecutable, cwd=basedir) + else: + if isAndroid or sysplat.startswith('darwin'): + p = subprocess.Popen(args, stdout=subprocess.PIPE, shell=needs_shell, stderr=subprocess.STDOUT) + else: + p = subprocess.Popen(args, stdout=subprocess.PIPE, shell=needs_shell, stderr=subprocess.STDOUT, cwd=basedir) + if wait: + stdoutdata, stderrdata = p.communicate() + if stdoutdata is not None: + stdoutdata = str(stdoutdata).strip() + if stdoutdata != '': + msg += _('Process returned data: [%s]\n') % stdoutdata + else: + msg += _('Process returned no data\n') + else: + msg += _('Process returned no data\n') + if stderrdata is not None: + stderrdata = str(stderrdata).strip() + if stderrdata != '': + msg += _('Process returned error: %s') % stdoutdata + except ValueError, e: + err = True + msg = str(e) + except subprocess.CalledProcessError, e: + err = True + msg = e.output + except Exception: + e = sys.exc_info()[0] + err = True + if hasattr(e, 'message'): + msg = str(e.message) + msg = msg + '\n' + traceback.format_exc() + finally: + os.chdir(cwd) + self.threadReturn(err, msg) + diff --git a/script.service.kodi.callbacks/resources/lib/tests/__init__.py b/script.service.kodi.callbacks/resources/lib/tests/__init__.py new file mode 100644 index 0000000000..25e9a531ca --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/tests/__init__.py @@ -0,0 +1,18 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 KenV99 +# +# 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 . +# diff --git a/script.service.kodi.callbacks/resources/lib/tests/direct_test.py b/script.service.kodi.callbacks/resources/lib/tests/direct_test.py new file mode 100644 index 0000000000..9db35c340d --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/tests/direct_test.py @@ -0,0 +1,65 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 KenV99 +# +# 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 . +# + +from resources.lib.pubsub import TaskReturn +from resources.lib.utils.poutil import KodiPo +from resources.lib.kodilogging import KodiLogger +kl = KodiLogger() +log = kl.log +kodipo = KodiPo() +_ = kodipo.getLocalizedString + +def testMsg(taskManager, taskSettings, kwargs): + msg = [_('Testing for task type: %s') % taskSettings['type'], _('Settings: %s') % str(taskManager.taskKwargs), + _('Runtime kwargs: %s') % str(kwargs)] + return msg + +class TestHandler(object): + testMessage = [] + + def __init__(self, testMessage): + TestHandler.testMessage = testMessage + + @staticmethod + def testReturnHandler(taskReturn): + assert isinstance(taskReturn, TaskReturn) + from resources.lib.dialogtb import show_textbox + if taskReturn.iserror is False: + TestHandler.testMessage.append(_('Command for Task %s, Event %s completed succesfully!') % (taskReturn.taskId, taskReturn.eventId)) + if taskReturn.msg != '': + TestHandler.testMessage.append(_('The following message was returned: %s') % taskReturn.msg) + log(msg='\n'.join(TestHandler.testMessage), loglevel=kl.LOGNOTICE) + else: + TestHandler.testMessage.append(_('ERROR encountered for Task %s, Event %s\nERROR mesage: %s') % (taskReturn.taskId, taskReturn.eventId, taskReturn.msg)) + log(msg='\n'.join(TestHandler.testMessage), loglevel=kl.LOGERROR) + show_textbox('Test Results', TestHandler.testMessage) + +class TestLogger(object): + def __init__(self): + self._log = [] + + def log(self, level=1, msg=''): + assert isinstance(level, int) + msg_list = msg.split('\n') + if not isinstance(msg_list, list): + msg_list = [msg] + self._log.extend(msg_list) + + def retrieveLogAsList(self): + return self._log diff --git a/script.service.kodi.callbacks/resources/lib/tests/testPublishers.py b/script.service.kodi.callbacks/resources/lib/tests/testPublishers.py new file mode 100644 index 0000000000..80ce1a2876 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/tests/testPublishers.py @@ -0,0 +1,602 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 KenV99 +# +# 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 . +# +# MEANT TO BE RUN USING NOSE + +import xbmc +import os +import sys +from resources.lib.publishers.log import LogPublisher +import resources.lib.publishers.loop as loop +import resources.lib.publishers.log as log +from resources.lib.publishers.loop import LoopPublisher +from resources.lib.publishers.watchdog import WatchdogPublisher +from resources.lib.publishers.watchdogStartup import WatchdogStartup +from resources.lib.publishers.schedule import SchedulePublisher +from resources.lib.pubsub import Dispatcher, Subscriber, Message, Topic +from resources.lib.settings import Settings +from resources.lib.utils.kodipathtools import translatepath, setPathRW +from flexmock import flexmock +import Queue +import threading +import time +import nose + + +# from nose.plugins.skip import SkipTest + +def printlog(msg,level=0): + print msg, level + + +flexmock(xbmc, log=printlog) + + +def sleep(xtime): + time.sleep(xtime / 1000.0) + + +class MockSubscriber(Subscriber): + def __init__(self): + super(MockSubscriber, self).__init__() + self.testq = Queue.Queue() + + def notify(self, message): + self.testq.put(message) + + def retrieveMessages(self): + messages = [] + while not self.testq.empty(): + try: + message = self.testq.get(timeout=1) + except Queue.Empty: + pass + else: + messages.append(message) + return messages + + def waitForMessage(self, count=1, timeout=30): + loopcount = 0 + while loopcount < timeout: + msgcount = self.testq.qsize() + if msgcount >= count: + return + else: + time.sleep(1) + loopcount += 1 + + +# @SkipTest +class testWatchdogStartup(object): + def __init__(self): + self.publisher = None + self.dispatcher = None + self.subscriber = None + self.topic = None + self.folder = None + self.saveduserpickle = None + + def setup(self): + self.folder = translatepath('special://addon/resources/lib/tests') + watchdogStartupSettings = [ + {'ws_folder': self.folder, 'ws_patterns': '*', 'ws_ignore_patterns': '', 'ws_ignore_directories': True, + 'ws_recursive': False, 'key': 'E1'}] + self.saveduserpickle = WatchdogStartup.getPickle() + self.dispatcher = Dispatcher() + self.subscriber = MockSubscriber() + self.topic = Topic('onStartupFileChanges', 'E1') + self.subscriber.addTopic(self.topic) + self.dispatcher.addSubscriber(self.subscriber) + settings = Settings() + flexmock(settings, getWatchdogStartupSettings=watchdogStartupSettings) + self.publisher = WatchdogStartup(self.dispatcher, settings) + self.dispatcher.start() + + def teardown(self): + WatchdogStartup.savePickle(self.saveduserpickle) + self.publisher.abort() + self.dispatcher.abort() + del self.publisher + del self.dispatcher + + def testWatchdogPublisherCreate(self): + fn = os.path.join(self.folder, 'test.txt') + if os.path.exists(fn): + os.remove(fn) + self.publisher.start() + self.publisher.abort() + time.sleep(1) + self.subscriber.testq = Queue.Queue() + with open(fn, 'w') as f: + f.writelines('test') + time.sleep(1) + self.publisher.start() + self.subscriber.waitForMessage(count=2, timeout=2) + self.publisher.abort() + self.dispatcher.abort() + os.remove(fn) + messages = self.subscriber.retrieveMessages() + found = False + for message in messages: + assert isinstance(message, Message) + assert 'listOfChanges' in message.kwargs.keys() + tmp = message.kwargs['listOfChanges'] + if 'FilesCreated' in tmp.keys() and [fn] in tmp.values(): + found = True + assert message.topic == self.topic + assert found == True + if len(messages) > 1: + raise AssertionError('Warning: Too many messages found for Watchdog Startup Create') + + +# @SkipTest +class testWatchdog(object): + def __init__(self): + self.publisher = None + self.dispatcher = None + self.subscriber = None + self.topic = None + self.folder = None + + def setup(self): + self.folder = translatepath('special://addon/resources/lib/tests') + setPathRW(self.folder) + watchdogSettings = [{'folder': self.folder, 'patterns': '*', 'ignore_patterns': '', 'ignore_directories': True, + 'recursive': False, 'key': 'E1'}] + self.dispatcher = Dispatcher() + self.subscriber = MockSubscriber() + self.topic = Topic('onFileSystemChange', 'E1') + self.subscriber.addTopic(self.topic) + self.dispatcher.addSubscriber(self.subscriber) + settings = Settings() + flexmock(settings, getWatchdogSettings=watchdogSettings) + self.publisher = WatchdogPublisher(self.dispatcher, settings) + self.dispatcher.start() + + def teardown(self): + self.publisher.abort() + self.dispatcher.abort() + del self.publisher + del self.dispatcher + + def testWatchdogPublisherCreate(self): + fn = os.path.join(self.folder, 'test.txt') + if os.path.exists(fn): + os.remove(fn) + time.sleep(1) + self.publisher.start() + time.sleep(1) + with open(fn, 'w') as f: + f.writelines('test') + self.subscriber.waitForMessage(count=2, timeout=2) + self.publisher.abort() + self.dispatcher.abort() + time.sleep(1) + os.remove(fn) + messages = self.subscriber.retrieveMessages() + foundc = False + foundm = False + fmesgc = None + fmesgm = None + for message in messages: + assert isinstance(message, Message) + if message.kwargs['event'] == 'created': + foundc = True + fmesgc = message + elif message.kwargs['event'] == 'modified': + foundm = True + fmesgm = message + assert foundc is True + assert fmesgc.topic == self.topic + assert fmesgc.kwargs['path'] == fn + assert foundm is True + assert fmesgm.topic == self.topic, 'Warning Only' + assert fmesgm.kwargs['path'] == fn, 'Warning Only' + if len(messages) > 2: + raise AssertionError('Warning: Too many messages found for Watchdog Create') + + def testWatchdogPublisherDelete(self): + fn = os.path.join(self.folder, 'test.txt') + if os.path.exists(fn) is False: + with open(fn, 'w') as f: + f.writelines('test') + time.sleep(1) + self.publisher.start() + time.sleep(2) + os.remove(fn) + self.subscriber.waitForMessage(count=1, timeout=2) + self.publisher.abort() + self.dispatcher.abort() + messages = self.subscriber.retrieveMessages() + found = False + fmesg = None + for message in messages: + assert isinstance(message, Message) + if message.kwargs['event'] == 'deleted': + found = True + fmesg = message + assert found is True + assert fmesg.topic == self.topic + assert fmesg.kwargs['path'] == fn + if len(messages) > 1: + raise AssertionError('Warning: Too many messages found for Watchdog Delete') + + def testWatchdogPublisherModify(self): + fn = os.path.join(self.folder, 'test.txt') + if os.path.exists(fn) is False: + with open(fn, 'w') as f: + f.writelines('test') + time.sleep(1) + self.publisher.start() + time.sleep(1) + with open(fn, 'a') as f: + f.writelines('test2') + self.subscriber.waitForMessage(count=1, timeout=2) + self.publisher.abort() + self.dispatcher.abort() + os.remove(fn) + messages = self.subscriber.retrieveMessages() + found = False + fmesg = None + for message in messages: + assert isinstance(message, Message) + if message.kwargs['event'] == 'modified': + found = True + fmesg = message + assert found is True + assert fmesg.topic == self.topic + assert fmesg.kwargs['path'] == fn + if len(messages) > 1: + raise AssertionError('Warning: Too many messages found for Watchdog Modify') + + +# @SkipTest +class testLoop(object): + def __init__(self): + self.publisher = None + self.dispatcher = None + self.subscriber = None + self.globalidletime = None + self.starttime = None + self.topics = None + + def getGlobalIdleTime(self): + if self.globalidletime is None: + self.starttime = time.time() + self.globalidletime = 0 + return 0 + else: + self.globalidletime = int(time.time() - self.starttime) + return self.globalidletime + + def getStereoMode(self): + if self.getGlobalIdleTime() < 2: + return 'off' + else: + return 'split_vertical' + + def getCurrentWindowId(self): + git = self.getGlobalIdleTime() + if git < 2: + return 10000 + elif 2 <= git < 4: + return 10001 + else: + return 10002 + + def getProfileString(self): + if self.getGlobalIdleTime() < 2: + return 'Bob' + else: + return 'Mary' + + def setup(self): + flexmock(loop.xbmc, getGlobalIdleTime=self.getGlobalIdleTime) + flexmock(loop.xbmc, sleep=sleep) + flexmock(loop, getStereoscopicMode=self.getStereoMode) + flexmock(loop, getProfileString=self.getProfileString) + flexmock(loop.xbmc.Player, isPlaying=False) + flexmock(loop.xbmcgui, getCurrentWindowId=self.getCurrentWindowId) + self.dispatcher = Dispatcher() + self.subscriber = MockSubscriber() + + def teardown(self): + self.publisher.abort() + self.dispatcher.abort() + del self.publisher + del self.dispatcher + + def testLoopIdle(self): + self.topics = [Topic('onIdle', 'E1'), Topic('onIdle', 'E2')] + for topic in self.topics: + self.subscriber.addTopic(topic) + self.dispatcher.addSubscriber(self.subscriber) + idleSettings = {'E1': 3, 'E2': 5} + settings = Settings() + flexmock(settings, getIdleTimes=idleSettings) + flexmock(settings, general={'LoopFreq': 100}) + self.publisher = LoopPublisher(self.dispatcher, settings) + self.dispatcher.start() + self.publisher.start() + self.subscriber.waitForMessage(count=2, timeout=7) + self.publisher.abort() + self.dispatcher.abort() + messages = self.subscriber.retrieveMessages() + msgtopics = [msg.topic for msg in messages] + for topic in self.topics: + assert topic in msgtopics + + def testStereoModeChange(self): + self.topics = [Topic('onStereoModeChange')] + self.subscriber.addTopic(self.topics[0]) + self.dispatcher.addSubscriber(self.subscriber) + settings = Settings() + flexmock(settings, general={'LoopFreq': 100}) + self.publisher = LoopPublisher(self.dispatcher, settings) + self.dispatcher.start() + self.publisher.start() + self.subscriber.waitForMessage(count=1, timeout=5) + self.publisher.abort() + self.dispatcher.abort() + messages = self.subscriber.retrieveMessages() + msgtopics = [msg.topic for msg in messages] + for topic in self.topics: + assert topic in msgtopics + + def testOnWindowOpen(self): + self.topics = [Topic('onWindowOpen', 'E1')] + self.subscriber.addTopic(self.topics[0]) + self.dispatcher.addSubscriber(self.subscriber) + settings = Settings() + flexmock(settings, general={'LoopFreq': 100}) + flexmock(settings, getOpenwindowids={10001: 'E1'}) + self.publisher = LoopPublisher(self.dispatcher, settings) + self.dispatcher.start() + self.publisher.start() + self.subscriber.waitForMessage(count=1, timeout=5) + self.publisher.abort() + self.dispatcher.abort() + messages = self.subscriber.retrieveMessages() + msgtopics = [msg.topic for msg in messages] + for topic in self.topics: + assert topic in msgtopics + + def testOnWindowClose(self): + self.topics = [Topic('onWindowClose', 'E1')] + self.subscriber.addTopic(self.topics[0]) + self.dispatcher.addSubscriber(self.subscriber) + settings = Settings() + flexmock(settings, general={'LoopFreq': 100}) + flexmock(settings, getClosewindowids={10001: 'E1'}) + self.publisher = LoopPublisher(self.dispatcher, settings) + self.dispatcher.start() + self.publisher.start() + self.subscriber.waitForMessage(count=1, timeout=5) + self.publisher.abort() + self.dispatcher.abort() + messages = self.subscriber.retrieveMessages() + msgtopics = [msg.topic for msg in messages] + for topic in self.topics: + assert topic in msgtopics + + def testProfileChange(self): + self.topics = [Topic('onProfileChange')] + self.subscriber.addTopic(self.topics[0]) + self.dispatcher.addSubscriber(self.subscriber) + settings = Settings() + flexmock(settings, general={'LoopFreq': 100}) + self.publisher = LoopPublisher(self.dispatcher, settings) + self.dispatcher.start() + self.publisher.start() + self.subscriber.waitForMessage(count=1, timeout=5) + self.publisher.abort() + self.dispatcher.abort() + messages = self.subscriber.retrieveMessages() + msgtopics = [msg.topic for msg in messages] + for topic in self.topics: + assert topic in msgtopics + + +# @SkipTest +class testLog(object): + path = translatepath('special://addondata') + if not os.path.exists(path): + os.mkdir(path) + setPathRW(path) + fn = translatepath('special://addondata/kodi.log') + + def __init__(self): + self.publisher = None + self.dispatcher = None + self.subscriber = None + self.globalidletime = None + self.starttime = None + self.topics = None + + @staticmethod + def logSimulate(): + import random, string + randomstring = ''.join(random.choice(string.lowercase) for _ in range(30)) + '\n' + targetstring = '%s%s%s' % (randomstring[:12], 'kodi_callbacks', randomstring[20:]) + for i in xrange(0, 10): + with open(testLog.fn, 'a') as f: + if i == 5: + f.writelines(targetstring) + else: + f.writelines(randomstring) + time.sleep(0.25) + + def setup(self): + flexmock(log, logfn=testLog.fn) + flexmock(log.xbmc, log=printlog) + flexmock(log.xbmc, sleep=sleep) + self.dispatcher = Dispatcher() + self.subscriber = MockSubscriber() + + def teardown(self): + self.publisher.abort() + self.dispatcher.abort() + del self.publisher + del self.dispatcher + + def testLogSimple(self): + self.topics = [Topic('onLogSimple', 'E1')] + xsettings = [{'matchIf': 'kodi_callbacks', 'rejectIf': '', 'eventId': 'E1'}] + settings = Settings() + flexmock(settings, getLogSimples=xsettings) + flexmock(settings, general={'LogFreq': 100}) + self.publisher = LogPublisher(self.dispatcher, settings) + try: + os.remove(testLog.fn) + except OSError: + pass + finally: + with open(testLog.fn, 'w') as f: + f.writelines('') + self.subscriber.addTopic(self.topics[0]) + self.dispatcher.addSubscriber(self.subscriber) + self.dispatcher.start() + self.publisher.start() + xthread = threading.Thread(target=testLog.logSimulate) + xthread.start() + xthread.join() + self.publisher.abort() + self.dispatcher.abort() + self.subscriber.waitForMessage(count=1, timeout=2) + try: + os.remove(testLog.fn) + except OSError: + pass + messages = self.subscriber.retrieveMessages() + msgtopics = [msg.topic for msg in messages] + for topic in self.topics: + assert topic in msgtopics + + def testLogRegex(self): + self.topics = [Topic('onLogRegex', 'E1')] + xsettings = [{'matchIf': 'kodi_callbacks', 'rejectIf': '', 'eventId': 'E1'}] + settings = Settings() + flexmock(settings, getLogRegexes=xsettings) + flexmock(settings, general={'LogFreq': 100}) + self.publisher = LogPublisher(self.dispatcher, settings) + try: + os.remove(testLog.fn) + except OSError: + pass + finally: + with open(testLog.fn, 'w') as f: + f.writelines('') + self.subscriber.addTopic(self.topics[0]) + self.dispatcher.addSubscriber(self.subscriber) + self.dispatcher.start() + self.publisher.start() + t = threading.Thread(target=testLog.logSimulate) + t.start() + t.join() + self.publisher.abort() + self.dispatcher.abort() + self.subscriber.waitForMessage(count=1, timeout=2) + try: + os.remove(testLog.fn) + except OSError: + pass + messages = self.subscriber.retrieveMessages() + msgtopics = [msg.topic for msg in messages] + for topic in self.topics: + assert topic in msgtopics + +# @SkipTest +class TestSchedule(object): + def __init__(self): + self.publisher = None + self.dispatcher = None + self.subscriber = None + self.topics = None + + def setup(self): + flexmock(log.xbmc, log=printlog) + flexmock(log.xbmc, sleep=sleep) + self.dispatcher = Dispatcher() + self.subscriber = MockSubscriber() + + def teardown(self): + self.publisher.abort() + self.dispatcher.abort() + del self.publisher + del self.dispatcher + + def testDailyAlarm(self): + from time import strftime + self.topics = [Topic('onDailyAlarm', 'E1')] + hour, minute = strftime('%H:%M').split(':') + xsettings = [{'hour': int(hour), 'minute': int(minute) + 1, 'key': 'E1'}] + settings = Settings() + flexmock(settings, getEventsByType=xsettings) + self.publisher = SchedulePublisher(self.dispatcher, settings) + self.publisher.intervalAlarms = [] + self.publisher.sleep = time.sleep + self.publisher.sleepinterval = 1 + self.subscriber.addTopic(self.topics[0]) + self.dispatcher.addSubscriber(self.subscriber) + self.dispatcher.start() + self.publisher.start() + self.subscriber.waitForMessage(count=1, timeout=65) + self.publisher.abort() + self.dispatcher.abort() + messages = self.subscriber.retrieveMessages() + msgtopics = [msg.topic for msg in messages] + time.sleep(1) + for topic in self.topics: + assert topic in msgtopics + + def testIntervalAlarm(self): + self.topics = [Topic('onIntervalAlarm', 'E1')] + xsettings = [{'hours': 0, 'minutes': 0, 'seconds': 10, 'key': 'E1'}] + settings = Settings() + self.dispatcher = Dispatcher() + self.subscriber = MockSubscriber() + flexmock(settings, getEventsByType=xsettings) + self.publisher = SchedulePublisher(self.dispatcher, settings) + self.publisher.dailyAlarms = [] + self.publisher.sleep = time.sleep + self.publisher.sleepinterval = 1 + self.subscriber.testq = Queue.Queue() + self.subscriber.addTopic(self.topics[0]) + self.dispatcher.addSubscriber(self.subscriber) + self.dispatcher.start() + self.publisher.start() + self.subscriber.waitForMessage(count=1, timeout=20) + self.publisher.abort() + self.dispatcher.abort() + messages = self.subscriber.retrieveMessages() + msgtopics = [msg.topic for msg in messages] + for topic in self.topics: + assert topic in msgtopics + + +def main(): + module_name = sys.modules[__name__].__file__ + result = nose.run( + argv=[sys.argv[0], + module_name] + ) + return result + + +if __name__ == '__main__': + main() diff --git a/script.service.kodi.callbacks/resources/lib/tests/testTasks.py b/script.service.kodi.callbacks/resources/lib/tests/testTasks.py new file mode 100644 index 0000000000..707266f50a --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/tests/testTasks.py @@ -0,0 +1,272 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import os +import sys +import Queue +import time +import re +import traceback +import json +from resources.lib import taskdict +from resources.lib.pubsub import Topic, TaskManager +from resources.lib.events import Events +from resources.lib.kodilogging import KodiLogger +from resources.lib.utils.kodipathtools import translatepath, setPathExecuteRW +import xbmc +from resources.lib.utils.poutil import KodiPo +kodipo = KodiPo() +_ = kodipo.getLocalizedString +kl = KodiLogger() +log = kl.log +events = Events().AllEvents +isAndroid = 'XBMC_ANDROID_SYSTEM_LIBS' in os.environ.keys() +testdir = translatepath('special://addon/resources/lib/tests') +setPathExecuteRW(testdir) + + +def is_xbmc_debug(): + json_query = xbmc.executeJSONRPC('{ "jsonrpc": "2.0", "id": 0, "method": "Settings.GetSettingValue", "params": {"setting":"debug.showloginfo"} }') + json_query = unicode(json_query, 'utf-8', errors='ignore') + json_response = json.loads(json_query) + if json_response.has_key('result'): + if json_response['result'].has_key('value'): + return json_response['result']['value'] + else: + raise SyntaxError('Bad JSON') + else: + raise SyntaxError('Bad JSON') + + +def getWebserverInfo(): + json_query = xbmc.executeJSONRPC('{ "jsonrpc": "2.0", "id": 0, "method": "Settings.getSettings", "params":' + ' {"filter":{"section":"services", "category":"webserver"}}}') + json_query = unicode(json_query, 'utf-8', errors='ignore') + json_response = json.loads(json_query) + + if json_response.has_key('result') and json_response['result'].has_key('settings') and json_response['result']['settings'] is not None: + serverEnabled = False + serverPort = 8080 + serverUser = '' + serverPassword = '' + for item in json_response['result']['settings']: + if item["id"] == u"services.webserver": + if item["value"] is True: + serverEnabled = True + elif item["id"] == u'services.webserverport': + serverPort = item['value'] + elif item['id'] == u'services.webserverusername': + serverUser = item['value'] + elif item['id'] == u'services.webserverpassword': + serverPassword = item['value'] + return serverEnabled, serverPort, serverUser, serverPassword + + +class testTasks(object): + def __init__(self): + self.task = None + self.q = Queue.Queue() + + def clear_q(self): + self.q = Queue.Queue() + + def testHttp(self): + serverEnabled, serverPort, serverUser, serverPassword = getWebserverInfo() + if serverEnabled: + self.task = taskdict['http']['class'] + taskKwargs = {'http':'http://localhost:%s/jsonrpc' % str(serverPort), 'user':serverUser, 'pass':serverPassword, 'type':'http', 'request-type':'GET', 'notify':False} + userargs = '?request={"jsonrpc":%__"2.0"%_%__"id": 1%_%__"method":"Application.Setmute"%_%__"params":{"mute":"toggle"}}' + tm = TaskManager(self.task, 1, None, -1, taskid='T1', userargs=userargs, **taskKwargs) + tm.returnHandler = self.returnHandler + topic = Topic('onPlaybackStarted') + runKwargs = events['onPlayBackStarted']['expArgs'] + self.clear_q() + tm.start(topic, **runKwargs) + try: + tr = self.q.get(timeout=1) + except Queue.Empty: + raise Queue.Empty(_('testHttp never returned')) + else: + tm.start(topic, **runKwargs) # Toggle Mute again + if tr.iserror is True: + log(loglevel=xbmc.LOGERROR, msg=_('testHttp returned with an error: %s') % tr.msg) + if '{"id":1,"jsonrpc":"2.0","result":' not in tr.msg: + raise AssertionError(_('Http test failed')) + else: + raise AssertionError('Http test cannot be run because webserver not enabled') + + def returnHandler(self, taskReturn): + self.q.put_nowait(taskReturn) + + def testBuiltin(self): + start_debug = is_xbmc_debug() + self.task = taskdict['builtin']['class'] + taskKwargs = {'builtin':'ToggleDebug', 'type':'builtin', 'notify':False} + userargs = '' + tm = TaskManager(self.task,1, None, -1, taskid='T1', userargs=userargs, **taskKwargs) + topic = Topic('onPlayBackStarted') + runKwargs = events['onPlayBackStarted']['expArgs'] + self.clear_q() + tm.start(topic, **runKwargs) + time.sleep(3) + debug = is_xbmc_debug() + tm.start(topic, **runKwargs) + if debug == start_debug: + raise AssertionError(_('Builtin test failed')) + + + def testScriptNoShell(self): + self.task = taskdict['script']['class'] + if sys.platform.startswith('win'): + testfile = 'tstScript.bat' + else: + testfile = 'tstScript.sh' + taskKwargs = {'scriptfile':'"%s"' % os.path.join(testdir, testfile), + 'use_shell':False, 'type':'script', 'waitForCompletion': True, 'notify':False} + self.task.validate(taskKwargs) + userargs = 'abc def:ghi' + tm = TaskManager(self.task, 1, None, -1, taskid='T1', userargs=userargs, **taskKwargs) + tm.returnHandler = self.returnHandler + topic = Topic('onPlaybackStarted') + runKwargs = events['onPlayBackStarted']['expArgs'] + self.clear_q() + tm.start(topic, **runKwargs) + try: + tr = self.q.get(timeout=2) + except Queue.Empty: + raise AssertionError(_('Timed out waiting for return')) + else: + if tr.iserror is True: + log(loglevel=xbmc.LOGERROR, msg=_('testScriptNoShell returned with an error: %s') % tr.msg) + raise AssertionError (_('Script without shell test failed')) + else: + retArgs = re.findall(r'Process returned data: \[(.+)\]', tr.msg)[0] + if retArgs != userargs: + raise AssertionError(_('Script without shell test failed')) + + def testScriptShell(self): + self.task = taskdict['script']['class'] + if sys.platform.startswith('win'): + testfile = 'tstScript.bat' + else: + testfile = 'tstScript.sh' + taskKwargs = {'scriptfile':'"%s"' % os.path.join(testdir, testfile), + 'use_shell':True, 'type':'script', 'waitForCompletion': True, 'notify':False} + userargs = 'abc def:ghi' + self.task.validate(taskKwargs) + tm = TaskManager(self.task, 1, None, -1, taskid='T1', userargs=userargs, **taskKwargs) + tm.returnHandler = self.returnHandler + topic = Topic('onPlaybackStarted') + runKwargs = events['onPlayBackStarted']['expArgs'] + self.clear_q() + tm.start(topic, **runKwargs) + try: + tr = self.q.get(timeout=2) + except Queue.Empty: + raise AssertionError(_('Timed out waiting for return')) + else: + if tr.iserror is True: + log(loglevel=xbmc.LOGERROR, msg=_('testScriptShell returned with an error: %s') % tr.msg) + raise AssertionError(_('Script with shell test failed')) + else: + retArgs = re.findall(r'Process returned data: \[(.+)\]', tr.msg)[0] + if retArgs != userargs: + raise AssertionError(_('Script with shell test failed')) + + + def testPythonImport(self): + self.task = taskdict['python']['class'] + taskKwargs = {'pythonfile':os.path.join(testdir,'tstPythonGlobal.py'), + 'import':True, 'type':'python', 'notify':False} + userargs = 'abc def:ghi' + tm = TaskManager(self.task, 1, None, -1, taskid='T1', userargs=userargs, **taskKwargs) + tm.returnHandler = self.returnHandler + topic = Topic('onPlaybackStarted') + runKwargs = events['onPlayBackStarted']['expArgs'] + self.clear_q() + tm.start(topic, **runKwargs) + try: + tr = self.q.get(timeout=1) + except Queue.Empty: + raise AssertionError(_('Timed out waiting for return')) + if tr.iserror is True: + log(loglevel=xbmc.LOGERROR, msg=_('testPythonImport returned with an error: %s') % tr.msg) + try: + retArgs = sys.modules['__builtin__'].__dict__['testReturn'] + except KeyError: + retArgs = sys.modules['builtins'].__dict__['testReturn'] + finally: + try: + sys.modules['__builtin__'].__dict__.pop('testReturn', None) + except KeyError: + sys.modules['builtins'].__dict__.pop('testReturn', None) + if ' '.join(retArgs[0]) != userargs: + raise AssertionError(_('Python import test failed')) + + def testPythonExternal(self): + self.task = taskdict['python']['class'] + taskKwargs = {'pythonfile':os.path.join(testdir, 'tstPythonGlobal.py'), + 'import':True, 'type':'python', 'notify':False} + userargs = 'jkl mno:pqr' + tm = TaskManager(self.task, 1, None, -1, taskid='T1', userargs=userargs, **taskKwargs) + tm.returnHandler = self.returnHandler + topic = Topic('onPlaybackStarted') + runKwargs = events['onPlayBackStarted']['expArgs'] + self.clear_q() + tm.start(topic, **runKwargs) + try: + tr = self.q.get(timeout=1) + except Queue.Empty: + raise AssertionError(_('Timed out waiting for return')) + if tr.iserror is True: + log(loglevel=xbmc.LOGERROR, msg=_('testPythonExternal returned with an error: %s') % tr.msg) + if isAndroid: + raise AssertionError(_('Cannot fully test pythonExternal on Android')) + try: + retArgs = sys.modules['__builtin__'].__dict__['testReturn'] + except KeyError: + retArgs = sys.modules['builtins'].__dict__['testReturn'] + finally: + try: + sys.modules['__builtin__'].__dict__.pop('testReturn', None) + except KeyError: + sys.modules['builtins'].__dict__.pop('testReturn', None) + if ' '.join(retArgs[0]) != userargs: + raise AssertionError(_('Python external test failed')) + + def runTests(self): + tests = [self.testHttp, self.testBuiltin, self.testScriptNoShell, self.testScriptShell, self.testPythonExternal, + self.testPythonImport] + for test in tests: + testname = test.__name__ + sys.exc_clear() + try: + test() + except AssertionError as e: + log(msg=_('Error testing %s: %s') % (testname, str(e))) + except Exception: + msg = _('Error testing %s\n') % testname + e = sys.exc_info()[0] + if hasattr(e, 'message'): + msg += str(e.message) + else: + msg += str(e) + msg = msg + '\n' + traceback.format_exc() + log(loglevel=xbmc.LOGERROR, msg=msg) + else: + log(msg=_('Test passed for task %s') % str(testname)) diff --git a/script.service.kodi.callbacks/resources/lib/tests/tstPythonGlobal.py b/script.service.kodi.callbacks/resources/lib/tests/tstPythonGlobal.py new file mode 100644 index 0000000000..e8675cdc15 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/tests/tstPythonGlobal.py @@ -0,0 +1,29 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import sys + +def run(*args): + try: + sys.modules['__builtin__'].__dict__['testReturn'] = args + except KeyError: + sys.modules['builtins'].__dict__['testReturn'] = args + +if __name__ == '__main__': + run(list(sys.argv[1:])) \ No newline at end of file diff --git a/script.service.kodi.callbacks/resources/lib/tests/tstScript.bat b/script.service.kodi.callbacks/resources/lib/tests/tstScript.bat new file mode 100644 index 0000000000..828a2127b3 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/tests/tstScript.bat @@ -0,0 +1 @@ +@echo %1 %2 \ No newline at end of file diff --git a/script.service.kodi.callbacks/resources/lib/tests/tstScript.sh b/script.service.kodi.callbacks/resources/lib/tests/tstScript.sh new file mode 100644 index 0000000000..b13cae6ff9 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/tests/tstScript.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +echo $1 $2 \ No newline at end of file diff --git a/script.service.kodi.callbacks/resources/lib/utils/__init__.py b/script.service.kodi.callbacks/resources/lib/utils/__init__.py new file mode 100644 index 0000000000..0463614978 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/utils/__init__.py @@ -0,0 +1,18 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# 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 . +# diff --git a/script.service.kodi.callbacks/resources/lib/utils/copyToDir.py b/script.service.kodi.callbacks/resources/lib/utils/copyToDir.py new file mode 100644 index 0000000000..1b3896c635 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/utils/copyToDir.py @@ -0,0 +1,95 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import os +import shutil +import fnmatch +import stat +import itertools + +def copyToDir(src, dst, updateonly=True, symlinks=True, ignore=None, forceupdate=None, dryrun=False): + + def copySymLink(srclink, destlink): + if os.path.lexists(destlink): + os.remove(destlink) + os.symlink(os.readlink(srclink), destlink) + try: + st = os.lstat(srclink) + mode = stat.S_IMODE(st.st_mode) + os.lchmod(destlink, mode) + except OSError: + pass # lchmod not available + fc = [] + if not os.path.exists(dst) and not dryrun: + os.makedirs(dst) + shutil.copystat(src, dst) + if ignore is not None: + ignorepatterns = [os.path.join(src, *x.split('/')) for x in ignore] + else: + ignorepatterns = [] + if forceupdate is not None: + forceupdatepatterns = [os.path.join(src, *x.split('/')) for x in forceupdate] + else: + forceupdatepatterns = [] + srclen = len(src) + for root, dirs, files in os.walk(src): + fullsrcfiles = [os.path.join(root, x) for x in files] + t = root[srclen+1:] + dstroot = os.path.join(dst, t) + fulldstfiles = [os.path.join(dstroot, x) for x in files] + excludefiles = list(itertools.chain.from_iterable([fnmatch.filter(fullsrcfiles, pattern) for pattern in ignorepatterns])) + forceupdatefiles = list(itertools.chain.from_iterable([fnmatch.filter(fullsrcfiles, pattern) for pattern in forceupdatepatterns])) + for directory in dirs: + fullsrcdir = os.path.join(src, directory) + fulldstdir = os.path.join(dstroot, directory) + if os.path.islink(fullsrcdir): + if symlinks and dryrun is False: + copySymLink(fullsrcdir, fulldstdir) + else: + if not os.path.exists(fulldstdir) and dryrun is False: + os.makedirs(fulldstdir) + shutil.copystat(src, dst) + for s,d in zip(fullsrcfiles, fulldstfiles): + if s not in excludefiles: + if updateonly: + go = False + if os.path.isfile(d): + srcdate = os.stat(s).st_mtime + dstdate = os.stat(d).st_mtime + if srcdate > dstdate: + go = True + else: + go = True + if s in forceupdatefiles: + go = True + if go is True: + fc.append(d) + if not dryrun: + if os.path.islink(s) and symlinks is True: + copySymLink(s, d) + else: + shutil.copy2(s, d) + else: + fc.append(d) + if not dryrun: + if os.path.islink(s) and symlinks is True: + copySymLink(s, d) + else: + shutil.copy2(s, d) + return fc \ No newline at end of file diff --git a/script.service.kodi.callbacks/resources/lib/utils/debugger.py b/script.service.kodi.callbacks/resources/lib/utils/debugger.py new file mode 100644 index 0000000000..c8e000d613 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/utils/debugger.py @@ -0,0 +1,41 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import os +import sys + +class pydevd_dummy(object): + @staticmethod + def settrace(*args, **kwargs): + pass + +def startdebugger(): + debugegg = '' + if sys.platform.lower().startswith('win'): + debugegg = os.path.expandvars('%programfiles(x86)%\\JetBrains\\PyCharm 2016.1\\debug-eggs\\pycharm-debug.egg') + elif sys.platform.lower().startswith('darwin'): + debugegg = '/Applications/PyCharm.app/Contents/debug-eggs/pycharm-debug.egg' + elif sys.platform.lower().startswith('linux'): + debugegg = os.path.expandvars(os.path.expanduser('~/PyCharm 2016.1/debug-eggs/pycharm-debug.egg')) + if os.path.exists(debugegg): + sys.path.append(debugegg) + try: + import pydevd + except ImportError: + pydevd = pydevd_dummy + pydevd.settrace('localhost', port=51234, stdoutToServer=True, stderrToServer=True, suspend=False) \ No newline at end of file diff --git a/script.service.kodi.callbacks/resources/lib/utils/detectPath.py b/script.service.kodi.callbacks/resources/lib/utils/detectPath.py new file mode 100644 index 0000000000..a974358adc --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/utils/detectPath.py @@ -0,0 +1,64 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import os +import shlex +import sys +from resources.lib.utils.kodipathtools import translatepath + +def process_cmdline(cmd): + posspaths = [] + parts = shlex.split(cmd, posix= not sys.platform.startswith('win')) + for i in xrange(0, len(parts)): + found=-1 + for j in xrange(i+1, len(parts)+1): + t = ' '.join(parts[i:j]) + t = translatepath(t) + t = t.strip('"') + if os.path.exists(t): + if j > found: + found = j + if found != -1: + posspaths.append([i, found]) + paths = [] + args = [] + if len(posspaths) > 0: + for i, path in enumerate(posspaths): # Check for overlaps + if i > 0: + if path[0] < posspaths[i-1][1]: + pass # If possible paths overlap, treat the first as a path and treat the rest of the overlap as non-path + else: + paths.append(path) + else: + paths.append(path) + for i in xrange(0, len(parts)): + for j in xrange(0, len(paths)): + if i == paths[j][0]: + t = ' '.join(parts[i:paths[j][1]]) + t = translatepath(t) + t = t.strip('"') + parts[i] = t + for k in xrange(i+1, paths[j][1]): + parts[k]='' + for i in xrange(0, len(parts)): + if parts[i] != '': + args.append(parts[i]) + else: + args = parts + return args diff --git a/script.service.kodi.callbacks/resources/lib/utils/kodipathtools.py b/script.service.kodi.callbacks/resources/lib/utils/kodipathtools.py new file mode 100644 index 0000000000..9e34b36a4c --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/utils/kodipathtools.py @@ -0,0 +1,182 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import os +import platform +import re +import stat +import sys + +import xbmc +import xbmcaddon + + +def _translatePathMock(path): + return kodiTranslatePathMock(path) + + +try: + from xbmc import translatePath as kodiTP +except ImportError: + kodiTP = _translatePathMock + isStub = True +else: + if kodiTP('special://home') == u'': + isStub = True + kodiTP = _translatePathMock + else: + isStub = False + +_split = re.compile(r'\0') + + +def getPlatform(): + if sys.platform.startswith('win'): + ret = 'win' + elif platform.system().lower().startswith('darwin'): + if platform.machine().startswith('iP'): + ret = 'ios' + else: + ret = 'osx' + elif 'XBMC_ANDROID_SYSTEM_LIBS' in os.environ.keys(): + ret = 'and' + else: # Big assumption here + ret = 'nix' + return ret + + +def secure_filename(path): + return _split.sub('', path) + + +def translatepath(path): + ret = [] + if path.lower().startswith('special://'): + special = re.split(r'\\|/', path[10:])[0] + if special.startswith('addondata'): + myid = re.findall(r'addondata\((.+?)\)', special) + if len(myid) > 0: + ret.append(addondatapath(myid[0])) + else: + ret.append(addondatapath()) + elif special.startswith('addon'): + myid = re.findall(r'addon\((.+?)\)', special) + if len(myid) > 0: + ret.append(addonpath(myid[0])) + else: + ret.append(addonpath()) + else: + ret.append(kodiTP('special://%s' % special)) + path = path[10:] + ret = ret + re.split(r'\\|/', path)[1:] + else: + ret = re.split(r'\\|/', path) + if ret[0].endswith(':'): + ret[0] = '%s\\' % ret[0] + for i, r in enumerate(ret): + ret[i] = secure_filename(r) + ret = os.path.join(*ret) + ret = os.path.expandvars(ret) + ret = os.path.expanduser(ret) + ret = os.path.normpath(ret) + if path.startswith('/'): + ret = '/%s' % ret + + if not os.path.supports_unicode_filenames: + ret = ret.decode('utf-8') + + ret = secure_filename(ret) + return ret + + +def kodiTranslatePathMock(path): + ret = [] + special = re.split(r'\\|/', path[10:])[0] + if special == 'home': + ret.append(homepath()) + elif special == 'logpath': + ret.append(logpath()) + elif special == 'masterprofile' or special == 'userdata': + ret = ret + [homepath(), 'userdata'] + return os.path.join(*ret) + + +def addonpath(addon_id='script.service.kodi.callbacks'): + if isStub: + path = os.path.join(*[homepath(), 'addons', addon_id]) + else: + try: + path = xbmcaddon.Addon(addon_id).getAddonInfo('path') + except RuntimeError: + path = '' + if path == '': + path = os.path.join(*[homepath(), 'addons', addon_id]) + return path + + +def addondatapath(addon_id='script.service.kodi.callbacks'): + if isStub: + path = os.path.join(*[homepath(), 'userdata', 'addon_data', addon_id]) + else: + path = os.path.join(*[xbmc.translatePath('special://userdata'), 'addon_data', addon_id]) + return path + + +def homepath(): + paths = {'win': r'%APPDATA%\Kodi', 'nix': r'$HOME/.kodi', 'osx': r'~/Library/Application Support/Kodi', + 'ios': r'/private/var/mobile/Library/Preferences/Kodi', + 'and': r' /sdcard/Android/data/org.xbmc.kodi/files/.kodi/'} + if isStub: + return translatepath(paths[getPlatform()]) + else: + return xbmc.translatePath('special://home') + + +def logpath(): + paths = {'win': r'%APPDATA%\Kodi\kodi.log', 'nix': r'$HOME/.kodi/temp/kodi.log', 'osx': r'~/Library/Logs/kodi.log', + 'ios': r'/private/var/mobile/Library/Preferences/kodi.log', + 'and': r'/sdcard/Android/data/org.xbmc.kodi/files/.kodi/temp/kodi.log'} + if isStub: + return translatepath(paths[getPlatform()]) + else: + return xbmc.translatePath('special://logpath') + + +def setPathExecuteRW(path): + path = translatepath(path) + try: + os.chmod(path, os.stat( + path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH | stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) + except OSError: + pass + + +def setPathExecute(path): + path = translatepath(path) + try: + os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + except OSError: + pass + + +def setPathRW(path): + path = translatepath(path) + try: + os.chmod(path, os.stat(path).st_mode | stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) + except OSError: + pass diff --git a/script.service.kodi.callbacks/resources/lib/utils/poutil.py b/script.service.kodi.callbacks/resources/lib/utils/poutil.py new file mode 100644 index 0000000000..13800dff47 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/utils/poutil.py @@ -0,0 +1,341 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import os +import codecs +import fnmatch +import re +import operator +import xbmcaddon +import threading +import copy +from resources.lib.utils.kodipathtools import translatepath +from resources.lib.kodilogging import KodiLogger +klogger = KodiLogger() +log = klogger.log + +try: + addonid = xbmcaddon.Addon().getAddonInfo('id') +except RuntimeError: + addonid = 'script.service.kodi.callbacks' +if addonid == '': + addonid = 'script.service.kodi.callbacks' + +class KodiPo(object): + + _instance = None + _lock = threading.Lock() + + def __new__(cls): + if KodiPo._instance is None: + with KodiPo._lock: + if KodiPo._instance is None: + KodiPo._instance = super(KodiPo, cls).__new__(cls) + KodiPo.cls_init() + return KodiPo._instance + + @classmethod + def cls_init(cls): + cls.pofn = translatepath('special://addon/resources/language/English/strings.po') + cls.isStub = xbmcaddon.Addon().getAddonInfo('id') == '' + cls.podict = PoDict() + cls.podict.read_from_file(cls.pofn) + cls.updateAlways = False + + + def __init__(self): + KodiPo._instance = self + + def _(self, strToId, update=False): + self.getLocalizedString(strToId, update) + + def getLocalizedString(self, strToId, update=False): + strToId = strToId.replace('\n', '\\n') + idFound, strid = self.podict.has_msgid(strToId) + if idFound: + if self.podict.savethread.is_alive(): + self.podict.savethread.join() + ret = xbmcaddon.Addon(addonid).getLocalizedString(int(strid)) + if ret == u'': # Occurs with stub or undefined number + if not self.isStub: + log(_('Localized string not found for: [%s]') % str(strToId)) + ret = strToId + return ret + else: + if update is True or self.updateAlways is True: + + log(msg=_('Localized string added to po for: [%s]') % strToId) + self.updatePo(strid, strToId) + else: + log(msg=_('Localized string id not found for: [%s]') % strToId) + return strToId + + def getLocalizedStringId(self, strToId, update=False): + idFound, strid = self.podict.has_msgid(strToId) + if idFound: + return strid + else: + if update is True or self.updateAlways is True: + self.updatePo(strid, strToId) + log(msg=_('Localized string added to po for: [%s]') % strToId) + return strid + else: + log(msg=_('Localized string not found for: [%s]') % strToId) + return 32168 + + def updatePo(self, strid, txt): + self.podict.addentry(strid, txt) + self.podict.write_to_file(self.pofn) + +class PoDict(object): + + _instance = None + _lock = threading.Lock() + _rlock = threading.RLock() + + def __new__(cls): + if PoDict._instance is None: + with PoDict._lock: + if PoDict._instance is None: + PoDict._instance = super(PoDict, cls).__new__(cls) + return PoDict._instance + + def __init__(self): + PoDict._instance = self + self.dict_msgctxt = dict() + self.dict_msgid = dict() + self.chkdict = dict() + self.remsgid = re.compile(r'"([^"\\]*(?:\\.[^"\\]*)*)"') + self.savethread = threading.Thread() + + def get_new_key(self): + if len(self.dict_msgctxt) > 0: + with PoDict._rlock: + mmax = max(self.dict_msgctxt.iteritems(), key=operator.itemgetter(0))[0] + else: + mmax = '32000' + try: + int_key = int(mmax) + except ValueError: + int_key = -1 + return int_key + 1 + + def addentry(self, str_msgctxt, str_msgid): + with PoDict._lock: + self.dict_msgctxt[str_msgctxt] = str_msgid + self.dict_msgid[str_msgid] = str_msgctxt + + def has_msgctxt(self, str_msgctxt): # Returns the English string associated with the id provided + with PoDict._lock: + if str_msgctxt in self.dict_msgctxt.keys(): + return [True, self.dict_msgctxt[str_msgctxt]] + else: + return [False, None] + + def has_msgid(self, str_msgid): # Returns the id in .po as a string i.e. "32000" + with PoDict._lock: + if str_msgid in self.dict_msgid.keys(): + return [True, self.dict_msgid[str_msgid]] + else: + return [False, str(self.get_new_key())] + + def read_from_file(self, url): + if url is None: + log(loglevel=klogger.LOGERROR, msg='No URL to Read PoDict From') + return + if os.path.exists(url): + try: + with codecs.open(url, 'r', 'UTF-8') as f: + poin = f.readlines() + i = 0 + while i < len(poin): + line = poin[i] + if line[0:7] == 'msgctxt': + t = re.findall(r'".+"', line) + if not t[0].startswith('"Addon'): + str_msgctxt = t[0][2:7] + i += 1 + line2 = poin[i] + str_msgid = self.remsgid.findall(line2)[0] + self.dict_msgctxt[str_msgctxt] = str_msgid + self.dict_msgid[str_msgid] = str_msgctxt + self.chkdict[str_msgctxt] = False + else: + i += 1 + i += 1 + except Exception as e: + log(loglevel=klogger.LOGERROR, msg='Error reading po: %s' % e.message) + else: + log(loglevel=klogger.LOGERROR, msg='Could not locate po at %s' % url) + + def write_to_file(self, url): + if self.savethread is not None: + assert isinstance(self.savethread, threading.Thread) + if self.savethread.is_alive(): + self.savethread.join() + with PoDict._lock: + tmp = copy.copy(self.dict_msgctxt) + self.savethread = threading.Thread(target=PoDict._write_to_file, args=[tmp, url]) + self.savethread.start() + + @staticmethod + def _write_to_file(dict_msgctxt, url): + fo = codecs.open(url, 'wb', 'UTF-8') + PoDict.write_po_header(fo) + str_max = max(dict_msgctxt.iteritems(), key=operator.itemgetter(0))[0] + str_min = min(dict_msgctxt.iteritems(), key=operator.itemgetter(0))[0] + + fo.write('msgctxt "Addon Summary"\n') + fo.write('msgid "Callbacks for Kodi"\n') + fo.write('msgstr ""\n\n') + fo.write('msgctxt "Addon Description"\n') + fo.write('msgid "Provides user definable actions for specific events within Kodi. Credit to Yesudeep Mangalapilly (gorakhargosh on github) and contributors for watchdog and pathtools modules."\n') + fo.write('msgstr ""\n\n') + fo.write('msgctxt "Addon Disclaimer"\n') + fo.write('msgid "For bugs, requests or general questions visit the Kodi forums."\n') + fo.write('msgstr ""\n\n') + fo.write('#Add-on messages id=%s to %s\n\n' % (str_min, str_max)) + last = int(str_min) - 1 + for str_msgctxt in sorted(dict_msgctxt): + if not str_msgctxt.startswith('Addon'): + if int(str_msgctxt) != last + 1: + fo.write('#empty strings from id %s to %s\n\n' % (str(last + 1), str(int(str_msgctxt) - 1))) + PoDict.write_to_po(fo, str_msgctxt, PoDict.format_string_forpo(dict_msgctxt[str_msgctxt])) + last = int(str_msgctxt) + fo.close() + + @staticmethod + def format_string_forpo(mstr): + out = '' + for (i, x) in enumerate(mstr): + if i == 1 and x == r'"': + out += "\\" + x + elif x == r'"' and mstr[i-1] != "\\": + out += "\\" + x + else: + out += x + return out + + @staticmethod + def write_po_header(fo): + fo.write('# Kodi Media Center language file\n') + fo.write('# Addon Name: Kodi Callbacks\n') + fo.write('# Addon id: script.service.kodi.callbacks\n') + fo.write('# Addon Provider: KenV99\n') + fo.write('msgid ""\n') + fo.write('msgstr ""\n') + fo.write('"Project-Id-Version: XBMC Addons\\n"\n') + fo.write('"POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\\n"\n') + fo.write('"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"\n') + fo.write('"MIME-Version: 1.0\\n"\n') + fo.write('"Content-Type: text/plain; charset=UTF-8\\n"\n') + fo.write('"Content-Transfer-Encoding: 8bit\\n"\n') + fo.write('"Language: en\\n"') + fo.write('"Plural-Forms: nplurals=2; plural=(n != 1);\\n"\n\n') + + @staticmethod + def write_to_po(fileobject, int_num, str_msg): + w = r'"#' + str(int_num) + r'"' + fileobject.write('msgctxt ' + w + '\n') + fileobject.write('msgid ' + r'"' + str_msg + r'"' + '\n') + fileobject.write('msgstr ' + r'""' + '\n') + fileobject.write('\n') + + def createreport(self): + cnt = 0 + reportpo = [] + for x in self.chkdict: + if not self.chkdict[x]: + if cnt == 0: + reportpo = ['No usage found for the following pairs:'] + msgid = self.dict_msgctxt[x] + reportpo.append(' %s:%s' % (x, msgid)) + cnt += 1 + ret = '\n '.join(reportpo) + return ret + + +class UpdatePo(object): + + def __init__(self, root_directory_to_scan, current_working_English_strings_po, exclude_directories=None, exclude_files=None): + if exclude_directories is None: + exclude_directories = [] + if exclude_files is None: + exclude_files = [] + self.root_directory_to_scan = root_directory_to_scan + self.current_working_English_strings_po = current_working_English_strings_po + self.podict = PoDict() + self.podict.read_from_file(self.current_working_English_strings_po) + self.exclude_directories = exclude_directories + self.exclude_files = exclude_files + self.find_localizer = re.compile(r'^(\S+?)\s*=\s*kodipo.getLocalizedString\s*$', flags=re.MULTILINE) + + def getFileList(self): + files_to_scan = [] + exclusions = [] + for direct in self.exclude_directories: + for root, ___, filenames in os.walk(os.path.join(self.root_directory_to_scan, direct)): + for filename in filenames: + exclusions.append(os.path.join(root, filename)) + for root, ___, filenames in os.walk(self.root_directory_to_scan): + for filename in fnmatch.filter(filenames, '*.py'): + if os.path.split(filename)[1] in self.exclude_files: + continue + elif os.path.join(root, filename) in exclusions: + continue + else: + files_to_scan.append(os.path.join(root, filename)) + return files_to_scan + + def scanPyFilesForStrings(self): + files = self.getFileList() + lstrings = [] + for myfile in files: + finds = [] + with open(myfile, 'r') as f: + lines = ''.join(f.readlines()) + try: + finds = self.find_localizer.findall(lines) + except re.error: + pass + finally: + if len(finds) != 1: + log(msg='Skipping file: %s, localizer not found' % myfile) + else: + findstr = r"%s\('(.+?)'\)" % finds[0] + find = re.compile(findstr) + finds = [] + try: + finds = find.findall(lines) + except re.error: + pass + lstrings += finds + return lstrings + + def updateStringsPo(self): + lstrings = self.scanPyFilesForStrings() + for s in lstrings: + found, strid = self.podict.has_msgid(s) + if found is False: + self.podict.addentry(strid, s) + self.podict.write_to_file(self.current_working_English_strings_po) + +kodipo = KodiPo() +_ = kodipo.getLocalizedString +__ = kodipo.getLocalizedStringId diff --git a/script.service.kodi.callbacks/resources/lib/utils/selector.py b/script.service.kodi.callbacks/resources/lib/utils/selector.py new file mode 100644 index 0000000000..9c4af6e553 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/utils/selector.py @@ -0,0 +1,107 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import xbmcaddon +import xbmcgui + + +def selectordialog(args): + """ + Emulates a selector control that is lvalues compatible for subsequent conditionals in Kodi settings + args is a list of strings that is kwarg 'like'. + 'id=myid': (Required) where myid is the settings.xml id that will be updated + The plain id will contain the actual index value and is expected to be a hidden text element. + This is the value to be tested against in subsequent conitionals. + The string passed will be appended with '-v' i.e. myid-v. It is expected that this refers to a + disabled selector element with the same lvalues as passed to the script. + NOTE: There is an undocumented feature of type="select" controls to set the default based on an lvalue: + ldefault="lvalue" where lvalue is a po string id. + 'useindex=bool': (Optional)If True, the zero based index of the subsequent lvalues will be stored in the hidden test + element. + If False or not provided, will store the actual lvalue in the hidden field. + 'heading=lvalue': (Optional) String id for heading of dialog box. + 'lvalues=int|int|int|...': (Required) The list of lvalues to display as choices. + + Usage example for settings.xml: + + + + + + + + + + + + + + + + + :param args: List of string args + :type args: list + :return: True is selected, False if cancelled + :rtype: bool + """ + + settingid = None + useindex = False + lvalues_str = None + heading = '' + for arg in args: + splitarg = arg.split('=') + kw = splitarg[0].strip() + value = splitarg[1].strip() + if kw == 'id': + settingid = value + elif kw == 'useindex': + useindex = value.lower() == 'true' + elif kw == 'lvalues': + lvalues_str = value.split('|') + elif kw == 'heading': + heading = value + if lvalues_str is None or settingid is None: + raise SyntaxError('Selector Dialog: Missing elements from args') + lvalues = [] + choices = [] + for lvalue in lvalues_str: + try: + lvalues.append(int(lvalue)) + except TypeError: + raise TypeError('Selector Dialog: lvalue not int') + else: + choices.append(xbmcaddon.Addon().getLocalizedString(int(lvalue))) + if heading != '': + try: + lheading = int(heading) + except TypeError: + raise TypeError('Selector Dialog: heading lvalue not int') + else: + lheading = '' + result = xbmcgui.Dialog().select(heading=xbmcaddon.Addon().getLocalizedString(lheading), list=choices) + if result != -1: + if useindex: + xbmcaddon.Addon().setSetting(settingid, str(result)) + else: + xbmcaddon.Addon().setSetting(settingid, str(lvalues[result])) + xbmcaddon.Addon().setSetting('%s-v' % settingid, str(result)) + return True + else: + return False diff --git a/script.service.kodi.callbacks/resources/lib/utils/updateaddon.py b/script.service.kodi.callbacks/resources/lib/utils/updateaddon.py new file mode 100644 index 0000000000..a22b497b29 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/utils/updateaddon.py @@ -0,0 +1,441 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import contextlib +import datetime +import fnmatch +import json +import os +import re +import shutil +import time +import zipfile +from stat import S_ISREG, ST_MTIME, ST_MODE +from time import strftime + +import xbmc +import xbmcaddon +import xbmcgui +from resources.lib.kodilogging import KodiLogger +from resources.lib.utils.copyToDir import copyToDir +from resources.lib.utils.kodipathtools import translatepath +from resources.lib.utils.poutil import KodiPo + +kodipo = KodiPo() +_ = kodipo.getLocalizedString + +kl = KodiLogger() +log = kl.log + + +class UpdateAddon(object): + def __init__(self, addonid=None, silent=False, numbackups=5): + + self.addonid = addonid + self.addondir = translatepath('special://addon(%s)' % self.addonid) + self.addondatadir = translatepath('special://addondata(%s)' % self.addonid) + self.tmpdir = translatepath('%s/temp' % self.addondatadir) + self.backupdir = translatepath('%s/backup' % self.addondatadir) + self.silent = silent + self.numbackups = numbackups + + @staticmethod + def currentversion(addonid): + currentversion = xbmcaddon.Addon(addonid).getAddonInfo('version') + if currentversion == u'': # Running stub + currentversion = '0.9.9' + return currentversion + + @staticmethod + def prompt(strprompt, silent=False, force=False): + if not silent or force: + ddialog = xbmcgui.Dialog() + if ddialog.yesno('', strprompt): + return True + else: + return False + + @staticmethod + def notify(message, silent=False, force=False): + log(msg=message) + if not silent or force: + ddialog = xbmcgui.Dialog() + ddialog.ok('', message) + + def cleartemp(self, recreate=True): + if os.path.exists(os.path.join(self.tmpdir, '.git')): + shutil.rmtree(os.path.join(self.tmpdir, '.git')) + if os.path.exists(self.tmpdir): + try: + shutil.rmtree(self.tmpdir, ignore_errors=True) + except OSError: + return False + else: + if recreate is True: + os.mkdir(self.tmpdir) + return True + else: + if recreate is True: + os.mkdir(self.tmpdir) + return True + + @staticmethod + def unzip(source_filename, dest_dir): + try: + with contextlib.closing(zipfile.ZipFile(source_filename, "r")) as zf: + zf.extractall(dest_dir) + except zipfile.BadZipfile: + log(msg='Zip File Error') + return False + return True + + @staticmethod + def zipdir(dest, srcdir): + dest = '%s.zip' % dest + zipf = ZipArchive(dest, 'w', zipfile.ZIP_DEFLATED) + zipf.addDir(srcdir, srcdir) + zipf.close() + + def backup(self, src=None, destdir=None, numbackups=5): + if src is None: + src = self.addondir + if destdir is None: + destdir = self.backupdir + ts = strftime("%Y-%m-%d-%H-%M-%S") + destname = os.path.join(destdir, '%s-%s' % (ts, self.addonid)) + if not os.path.exists(destdir): + os.mkdir(destdir) + else: + if os.path.exists(destname): + os.remove(destname) + self.cleartemp(recreate=True) + archivedir = os.path.join(self.tmpdir, + '%s-%s' % (os.path.split(src)[1], xbmcaddon.Addon().getSetting('installedbranch'))) + shutil.copytree(src, archivedir, ignore=shutil.ignore_patterns('*.pyc', '*.pyo', '.git', '.idea')) + UpdateAddon.zipdir(destname, self.tmpdir) + self.cleartemp(recreate=False) + sorteddir = UpdateAddon.datesorteddir(destdir) + num = len(sorteddir) + if num > numbackups: + for i in xrange(0, num - numbackups): + try: + os.remove(sorted(sorteddir)[i][2]) + except OSError: + raise + return True + + @staticmethod + def datesorteddir(sortdir): # oldest first + # returns list of tuples: (index, date, path) + + # get all entries in the directory w/ stats + entries = (os.path.join(sortdir, fn) for fn in os.listdir(sortdir)) + entries = ((os.stat(path), path) for path in entries) + + # leave only regular files, insert creation date + entries = ((stat[ST_MTIME], path) + for stat, path in entries if S_ISREG(stat[ST_MODE])) + entrylist = [] + i = 0 + for cdate, path in sorted(entries): + entrylist.append((i, cdate, path)) + i += 1 + return entrylist + + @staticmethod + def is_v1_gt_v2(version1, version2): + def normalize(v): + return [int(x) for x in re.sub(r'(\.0+)*$', '', v).split(".")] + + result = cmp(normalize(version1), normalize(version2)) + if result == 1: + return True + else: + return False + + @staticmethod + def checkfilematch(fn, lst): + ret = False + for item in lst: + if fn == item: + ret = True + elif fnmatch.fnmatchcase(fn, item): + ret = True + return ret + + @staticmethod + def getBranchFromFile(path): + root = UpdateAddon.getAddonxmlPath(path) + if root != '': + ps = root.split('-') + if len(ps) == 1: + return '' + else: + return ps[len(ps) - 1] + else: + return '' + + def installFromZip(self, zipfn, dryrun=False, updateonly=None, deletezip=False, silent=False): + if os.path.split(os.path.split(zipfn)[0])[1] == 'backup': + log(msg='Installing from backup') + isBackup = True + else: + isBackup = False + unzipdir = os.path.join(self.addondatadir, 'tmpunzip') + if UpdateAddon.unzip(zipfn, unzipdir) is False: + UpdateAddon.notify(_('Downloaded file could not be extracted')) + try: + os.remove(zipfn) + except OSError: + pass + try: + shutil.rmtree(unzipdir) + except OSError: + pass + return + else: + if deletezip: + os.remove(zipfn) + branch = UpdateAddon.getBranchFromFile(unzipdir) + installedbranch = xbmcaddon.Addon().getSetting('installedbranch') + if branch == '': + branch = installedbranch + if not isBackup: + if self.backup(self.addondir, self.backupdir, self.numbackups) is False: + UpdateAddon.notify(_('Backup failed, update aborted'), silent=silent) + return + else: + log(msg='Backup succeeded.') + archivedir = UpdateAddon.getAddonxmlPath(unzipdir) + addonisGHA = UpdateAddon.isGitHubArchive(self.addondir) + if os.path.isfile(os.path.join(archivedir, 'timestamp.json')) and not isBackup: + fd = UpdateAddon.loadfiledates(os.path.join(archivedir, 'timestamp.json')) + UpdateAddon.setfiledates(archivedir, fd) + log(msg='File timestamps updated') + if updateonly is None: + updateonly = True + ziptimestamped = True + else: + ziptimestamped = False + if updateonly is None: + updateonly = False + if updateonly is True and addonisGHA: + updateonly = False + if updateonly is True and ziptimestamped is False and isBackup is False: + updateonly = False + if installedbranch != branch: + updateonly = False + if archivedir != '': + try: + fc = copyToDir(archivedir, self.addondir, updateonly=updateonly, dryrun=dryrun) + except OSError as e: + UpdateAddon.notify(_('Error encountered copying to addon directory: %s') % str(e), silent=silent) + shutil.rmtree(unzipdir) + self.cleartemp(recreate=False) + else: + if installedbranch != branch: + xbmcaddon.Addon().setSetting('installedbranch', branch) + if len(fc) > 0: + self.cleartemp(recreate=False) + shutil.rmtree(unzipdir) + if silent is False: + if not isBackup: + msg = _('New version installed') + msg += _('\nPrevious installation backed up') + else: + msg = _('Backup restored') + UpdateAddon.notify(msg) + log(msg=_('The following files were updated: %s') % str(fc)) + if not silent: + answer = UpdateAddon.prompt(_('Attempt to restart addon now?')) == True + else: + answer = True + if answer is True: + restartpath = translatepath('special://addon{%s)/restartaddon.py' % self.addonid) + if not os.path.isfile(restartpath): + self.createRestartPy(restartpath) + xbmc.executebuiltin('RunScript(%s, %s)' % (restartpath, self.addonid)) + else: + UpdateAddon.notify(_('All files are current'), silent=silent) + else: + self.cleartemp(recreate=False) + shutil.rmtree(unzipdir) + UpdateAddon.notify(_('Could not find addon.xml\nInstallation aborted'), silent=silent) + + @staticmethod + def getAddonxmlPath(path): + ret = '' + for root, __, files in os.walk(path): + if 'addon.xml' in files: + ret = root + break + return ret + + @staticmethod + def getTS(strtime): + t_struct = time.strptime(strtime, '%Y-%m-%dT%H:%M:%SZ') + ret = time.mktime(t_struct) + return ret + + @staticmethod + def setTime(path, strtime): + ts = UpdateAddon.getTS(strtime) + os.utime(path, (ts, ts)) + + @staticmethod + def loadfiledates(path): + if os.path.isfile(path): + with open(path, 'r') as f: + try: + ret = json.load(f) + except: + raise + else: + return ret + else: + return {} + + @staticmethod + def setfiledates(rootpath, filedict): + for key in filedict.keys(): + fl = key.split(r'/') + path = os.path.join(rootpath, *fl) + if os.path.isfile(path): + UpdateAddon.setTime(path, filedict[key]) + + @staticmethod + def createRestartPy(path): + output = [] + output.append('import xbmc') + output.append('import sys') + output.append('addonid = sys.argv[1]') + output.append( + 'xbmc.executeJSONRPC(\'{"jsonrpc":"2.0","method":"Addons.SetAddonEnabled", "params":{"addonid":"%s","enabled":"toggle"},"id":1}\' % addonid)') + output.append('xbmc.log(msg=\'***** Toggling addon enabled 1: %s\' % addonid)') + output.append('xbmc.sleep(1000)') + output.append( + 'xbmc.executeJSONRPC(\'{"jsonrpc":"2.0","method":"Addons.SetAddonEnabled", "params":{"addonid":"%s","enabled":"toggle"},"id":1}\' % addonid)') + output.append('xbmc.log(msg=\'***** Toggling addon enabled 2: %s\' % addonid)') + output = '\n'.join(output) + with open(path, 'w') as f: + f.writelines(output) + + @staticmethod + def getFileModTime(path): + return datetime.datetime.fromtimestamp(os.path.getmtime(path)).strftime('%Y-%m-%dT%H:%M:%SZ') + + @staticmethod + def createTimeStampJson(src, dst=None, ignore=None): + if ignore is None: + ignore = [] + fd = {} + if dst is None: + dst = os.path.join(src, 'timestamp.json') + for root, __, files in os.walk(src): + for fn in files: + ffn = os.path.join(root, fn) + relpath = os.path.relpath(ffn, src).replace('\\', '/') + if not UpdateAddon.checkfilematch(relpath, ignore): + fd[relpath] = UpdateAddon.getFileModTime(ffn) + if os.path.dirname(dst) == src: + fd[os.path.relpath(dst, src)] = strftime('%Y-%m-%dT%H:%M:%SZ') + with open(dst, 'w') as f: + json.dump(fd, f, ensure_ascii=False) + + @staticmethod + def isGitHubArchive(path): + filelist = [] + vals = [] + ignoreDirs = ['.git', '.idea'] + ignoreExts = ['.pyo', '.pyc'] + ignoredRoots = [] + for root, dirs, files in os.walk(path): + dirName = os.path.basename(root) + if ignoreDirs.count(dirName) > 0: + ignoredRoots += [root] + continue + ignore = False + for ignoredRoot in ignoredRoots: + if root.startswith(ignoredRoot): + ignore = True + break + if ignore: + continue + # add files + for fn in files: + if os.path.splitext(fn)[1] not in ignoreExts: + vals.append(os.path.getmtime(os.path.join(root, fn))) + filelist.append(os.path.join(root, fn)) + vals.sort() + vals = vals[5:-5] + n = len(vals) + mean = sum(vals) / n + stdev = ((sum((x - mean) ** 2 for x in vals)) / n) ** 0.5 + if stdev / 60.0 < 1.0: + return True + else: + return False + + +class ZipArchive(zipfile.ZipFile): + def __init__(self, *args, **kwargs): + zipfile.ZipFile.__init__(self, *args, **kwargs) + + def addEmptyDir(self, path, baseToRemove="", inZipRoot=None): + inZipPath = os.path.relpath(path, baseToRemove) + if inZipPath == ".": # path == baseToRemove (but still root might be added + inZipPath = "" + if inZipRoot is not None: + inZipPath = os.path.join(inZipRoot, inZipPath) + if inZipPath == "": # nothing to add + return + zipInfo = zipfile.ZipInfo(os.path.join(inZipPath, '')) + self.writestr(zipInfo, '') + + def addFile(self, filePath, baseToRemove="", inZipRoot=None): + inZipPath = os.path.relpath(filePath, baseToRemove) + if inZipRoot is not None: + inZipPath = os.path.join(inZipRoot, inZipPath) + self.write(filePath, inZipPath) + + def addDir(self, path, baseToRemove="", ignoreDirs=None, inZipRoot=None): + if ignoreDirs is None: + ignoreDirs = [] + ignoredRoots = [] + for root, dirs, files in os.walk(path): + # ignore e.g. special folders + dirName = os.path.basename(root) + if ignoreDirs.count(dirName) > 0: + ignoredRoots += [root] + continue + # ignore descendants of folders ignored above + ignore = False + for ignoredRoot in ignoredRoots: + if root.startswith(ignoredRoot): + ignore = True + break + if ignore: + continue + + # add dir itself (needed for empty dirs) + if len(files) <= 0: + self.addEmptyDir(root, baseToRemove, inZipRoot) + + # add files + for fn in files: + self.addFile(os.path.join(root, fn), baseToRemove, inZipRoot) diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/AUTHORS b/script.service.kodi.callbacks/resources/lib/watchdog/AUTHORS new file mode 100644 index 0000000000..a4f4a49001 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/AUTHORS @@ -0,0 +1,66 @@ +Project Lead: +------------- +Yesudeep Mangalapilly + +Contributors in alphabetical order: +----------------------------------- +Adrian Tejn Kern +Andrew Schaaf +David LaPalomento +Filip Noetzel +Gary van der Merwe +Gora Khargosh +Hannu Valtonen +Jesse Printz +Luke McCarthy +Lukáš Lalinský +Malthe Borch +Martin Kreichgauer +Martin Kreichgauer +Mike Lundy +Raymond Hettinger +Roman Ovchinnikov +Rotem Yaari +Ryan Kelly +Senko Rasic +Senko Rašić +Shane Hathaway +Simon Pantzare +Simon Pantzare +Steven Samuel Cole +Stéphane Klein +Thomas Guest +Thomas Heller +Tim Cuthbertson +Todd Whiteman +Will McGugan +Yesudeep Mangalapilly +Yesudeep Mangalapilly +dvogel +gfxmonk + + +We would like to thank these individuals for ideas: +--------------------------------------------------- +Tim Golden +Sebastien Martini + +Initially we used the flask theme for the documentation which was written by +---------------------------------------------------------------------------- +Armin Ronacher + + +Watchdog also includes open source libraries or adapted code +from the following projects: + +- MacFSEvents - http://github.com/malthe/macfsevents +- watch_directory.py - http://timgolden.me.uk/python/downloads/watch_directory.py +- pyinotify - http://github.com/seb-m/pyinotify +- fsmonitor - http://github.com/shaurz/fsmonitor +- echo - http://wordaligned.org/articles/echo +- Lukáš Lalinský's ordered set queue implementation: + http://stackoverflow.com/questions/1581895/how-check-if-a-task-is-already-in-python-queue +- Armin Ronacher's flask-sphinx-themes for the documentation: + https://github.com/mitsuhiko/flask-sphinx-themes +- pyfilesystem - http://code.google.com/p/pyfilesystem +- get_FILE_NOTIFY_INFORMATION - http://blog.gmane.org/gmane.comp.python.ctypes/month=20070901 diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/COPYING b/script.service.kodi.callbacks/resources/lib/watchdog/COPYING new file mode 100644 index 0000000000..e6f091e8c5 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/COPYING @@ -0,0 +1,14 @@ +Copyright 2011 Yesudeep Mangalapilly +Copyright 2012 Google, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/LICENSE.txt b/script.service.kodi.callbacks/resources/lib/watchdog/LICENSE.txt new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/README.rst b/script.service.kodi.callbacks/resources/lib/watchdog/README.rst new file mode 100644 index 0000000000..de3b726c03 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/README.rst @@ -0,0 +1,276 @@ +Watchdog +======== +Python API and shell utilities to monitor file system events. + + +Example API Usage +----------------- +A simple program that uses watchdog to monitor directories specified +as command-line arguments and logs events generated: + +.. code-block:: python + + import sys + import time + import logging + from watchdog.observers import Observer + from watchdog.events import LoggingEventHandler + + if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, + format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S') + path = sys.argv[1] if len(sys.argv) > 1 else '.' + event_handler = LoggingEventHandler() + observer = Observer() + observer.schedule(event_handler, path, recursive=True) + observer.start() + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + observer.stop() + observer.join() + + +Shell Utilities +--------------- +Watchdog comes with a utility script called ``watchmedo``. +Please type ``watchmedo --help`` at the shell prompt to +know more about this tool. + +Here is how you can log the current directory recursively +for events related only to ``*.py`` and ``*.txt`` files while +ignoring all directory events: + +.. code-block:: bash + + watchmedo log \ + --patterns="*.py;*.txt" \ + --ignore-directories \ + --recursive \ + . + +You can use the ``shell-command`` subcommand to execute shell commands in +response to events: + +.. code-block:: bash + + watchmedo shell-command \ + --patterns="*.py;*.txt" \ + --recursive \ + --command='echo "${watch_src_path}"' \ + . + +Please see the help information for these commands by typing: + +.. code-block:: bash + + watchmedo [command] --help + + +About ``watchmedo`` Tricks +~~~~~~~~~~~~~~~~~~~~~~~~~~ +``watchmedo`` can read ``tricks.yaml`` files and execute tricks within them in +response to file system events. Tricks are actually event handlers that +subclass ``watchdog.tricks.Trick`` and are written by plugin authors. Trick +classes are augmented with a few additional features that regular event handlers +don't need. + +An example ``tricks.yaml`` file: + +.. code-block:: yaml + + tricks: + - watchdog.tricks.LoggerTrick: + patterns: ["*.py", "*.js"] + - watchmedo_webtricks.GoogleClosureTrick: + patterns: ['*.js'] + hash_names: true + mappings_format: json # json|yaml|python + mappings_module: app/javascript_mappings + suffix: .min.js + compilation_level: advanced # simple|advanced + source_directory: app/static/js/ + destination_directory: app/public/js/ + files: + index-page: + - app/static/js/vendor/jquery*.js + - app/static/js/base.js + - app/static/js/index-page.js + about-page: + - app/static/js/vendor/jquery*.js + - app/static/js/base.js + - app/static/js/about-page/**/*.js + +The directory containing the ``tricks.yaml`` file will be monitored. Each trick +class is initialized with its corresponding keys in the ``tricks.yaml`` file as +arguments and events are fed to an instance of this class as they arrive. + +Tricks will be included in the 0.5.0 release. I need community input about them. +Please file enhancement requests at the `issue tracker`_. + + +Installation +------------ +Installing from PyPI using ``pip``: + +.. code-block:: bash + + $ pip install watchdog + +Installing from PyPI using ``easy_install``: + +.. code-block:: bash + + $ easy_install watchdog + +Installing from source: + +.. code-block:: bash + + $ python setup.py install + + +Installation Caveats +~~~~~~~~~~~~~~~~~~~~ +The ``watchmedo`` script depends on PyYAML_ which links with LibYAML_, +which brings a performance boost to the PyYAML parser. However, installing +LibYAML_ is optional but recommended. On Mac OS X, you can use homebrew_ +to install LibYAML: + +.. code-block:: bash + + $ brew install libyaml + +On Linux, use your favorite package manager to install LibYAML. Here's how you +do it on Ubuntu: + +.. code-block:: bash + + $ sudo aptitude install libyaml-dev + +On Windows, please install PyYAML_ using the binaries they provide. + +Documentation +------------- +You can browse the latest release documentation_ online. + +Contribute +---------- +Fork the `repository`_ on GitHub and send a pull request, or file an issue +ticket at the `issue tracker`_. For general help and questions use the official +`mailing list`_ or ask on `stackoverflow`_ with tag `python-watchdog`. + +Create and activate your virtual environment, then:: + + pip install pytest + pip install -e . + py.test tests + + +Supported Platforms +------------------- +* Linux 2.6 (inotify) +* Mac OS X (FSEvents, kqueue) +* FreeBSD/BSD (kqueue) +* Windows (ReadDirectoryChangesW with I/O completion ports; + ReadDirectoryChangesW worker threads) +* OS-independent (polling the disk for directory snapshots and comparing them + periodically; slow and not recommended) + +Note that when using watchdog with kqueue, you need the +number of file descriptors allowed to be opened by programs +running on your system to be increased to more than the +number of files that you will be monitoring. The easiest way +to do that is to edit your ``~/.profile`` file and add +a line similar to:: + + ulimit -n 1024 + +This is an inherent problem with kqueue because it uses +file descriptors to monitor files. That plus the enormous +amount of bookkeeping that watchdog needs to do in order +to monitor file descriptors just makes this a painful way +to monitor files and directories. In essence, kqueue is +not a very scalable way to monitor a deeply nested +directory of files and directories with a large number of +files. + +About using watchdog with editors like Vim +------------------------------------------ +Vim does not modify files unless directed to do so. +It creates backup files and then swaps them in to replace +the files you are editing on the disk. This means that +if you use Vim to edit your files, the on-modified events +for those files will not be triggered by watchdog. +You may need to configure Vim to appropriately to disable +this feature. + + +Dependencies +------------ +1. Python 2.6 or above. +2. pathtools_ +3. select_backport_ (select.kqueue replacement for 2.6 on BSD/Mac OS X) +4. XCode_ (only on Mac OS X) +5. PyYAML_ (only for ``watchmedo`` script) +6. argh_ (only for ``watchmedo`` script) + + +Licensing +--------- +Watchdog is licensed under the terms of the `Apache License, version 2.0`_. + +Copyright 2011 `Yesudeep Mangalapilly`_. + +Copyright 2012 Google, Inc. + +Project `source code`_ is available at Github. Please report bugs and file +enhancement requests at the `issue tracker`_. + +Why Watchdog? +------------- +Too many people tried to do the same thing and none did what I needed Python +to do: + +* pnotify_ +* `unison fsmonitor`_ +* fsmonitor_ +* guard_ +* pyinotify_ +* `inotify-tools`_ +* jnotify_ +* treewalker_ +* `file.monitor`_ +* pyfilesystem_ + +.. links: +.. _Yesudeep Mangalapilly: yesudeep@gmail.com +.. _source code: http://github.com/gorakhargosh/watchdog +.. _issue tracker: http://github.com/gorakhargosh/watchdog/issues +.. _Apache License, version 2.0: http://www.apache.org/licenses/LICENSE-2.0 +.. _documentation: http://packages.python.org/watchdog/ +.. _stackoverflow: http://stackoverflow.com/questions/tagged/python-watchdog +.. _mailing list: http://groups.google.com/group/watchdog-python +.. _repository: http://github.com/gorakhargosh/watchdog +.. _issue tracker: http://github.com/gorakhargosh/watchdog/issues + +.. _homebrew: http://mxcl.github.com/homebrew/ +.. _select_backport: http://pypi.python.org/pypi/select_backport +.. _argh: http://pypi.python.org/pypi/argh +.. _PyYAML: http://www.pyyaml.org/ +.. _XCode: http://developer.apple.com/technologies/tools/xcode.html +.. _LibYAML: http://pyyaml.org/wiki/LibYAML +.. _pathtools: http://github.com/gorakhargosh/pathtools + +.. _pnotify: http://mark.heily.com/pnotify +.. _unison fsmonitor: https://webdav.seas.upenn.edu/viewvc/unison/trunk/src/fsmonitor.py?view=markup&pathrev=471 +.. _fsmonitor: http://github.com/shaurz/fsmonitor +.. _guard: http://github.com/guard/guard +.. _pyinotify: http://github.com/seb-m/pyinotify +.. _inotify-tools: http://github.com/rvoicilas/inotify-tools +.. _jnotify: http://jnotify.sourceforge.net/ +.. _treewalker: http://github.com/jbd/treewatcher +.. _file.monitor: http://github.com/pke/file.monitor +.. _pyfilesystem: http://code.google.com/p/pyfilesystem diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/__init__.py b/script.service.kodi.callbacks/resources/lib/watchdog/__init__.py new file mode 100644 index 0000000000..1a641ff98f --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/events.py b/script.service.kodi.callbacks/resources/lib/watchdog/events.py new file mode 100644 index 0000000000..7c1f075096 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/events.py @@ -0,0 +1,615 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +:module: watchdog.events +:synopsis: File system events and event handlers. +:author: yesudeep@google.com (Yesudeep Mangalapilly) + +Event Classes +------------- +.. autoclass:: FileSystemEvent + :members: + :show-inheritance: + :inherited-members: + +.. autoclass:: FileSystemMovedEvent + :members: + :show-inheritance: + +.. autoclass:: FileMovedEvent + :members: + :show-inheritance: + +.. autoclass:: DirMovedEvent + :members: + :show-inheritance: + +.. autoclass:: FileModifiedEvent + :members: + :show-inheritance: + +.. autoclass:: DirModifiedEvent + :members: + :show-inheritance: + +.. autoclass:: FileCreatedEvent + :members: + :show-inheritance: + +.. autoclass:: DirCreatedEvent + :members: + :show-inheritance: + +.. autoclass:: FileDeletedEvent + :members: + :show-inheritance: + +.. autoclass:: DirDeletedEvent + :members: + :show-inheritance: + + +Event Handler Classes +--------------------- +.. autoclass:: FileSystemEventHandler + :members: + :show-inheritance: + +.. autoclass:: PatternMatchingEventHandler + :members: + :show-inheritance: + +.. autoclass:: RegexMatchingEventHandler + :members: + :show-inheritance: + +.. autoclass:: LoggingEventHandler + :members: + :show-inheritance: + +""" + +import os.path +import logging +import re +from pathtools.patterns import match_any_paths +from watchdog.utils import has_attribute +from watchdog.utils import unicode_paths + + +EVENT_TYPE_MOVED = 'moved' +EVENT_TYPE_DELETED = 'deleted' +EVENT_TYPE_CREATED = 'created' +EVENT_TYPE_MODIFIED = 'modified' + + +class FileSystemEvent(object): + """ + Immutable type that represents a file system event that is triggered + when a change occurs on the monitored file system. + + All FileSystemEvent objects are required to be immutable and hence + can be used as keys in dictionaries or be added to sets. + """ + + event_type = None + """The type of the event as a string.""" + + is_directory = False + """True if event was emitted for a directory; False otherwise.""" + + def __init__(self, src_path): + self._src_path = src_path + + @property + def src_path(self): + """Source path of the file system object that triggered this event.""" + return self._src_path + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return ("<%(class_name)s: event_type=%(event_type)s, " + "src_path=%(src_path)r, " + "is_directory=%(is_directory)s>" + ) % (dict( + class_name=self.__class__.__name__, + event_type=self.event_type, + src_path=self.src_path, + is_directory=self.is_directory)) + + # Used for comparison of events. + @property + def key(self): + return (self.event_type, self.src_path, self.is_directory) + + def __eq__(self, event): + return self.key == event.key + + def __ne__(self, event): + return self.key != event.key + + def __hash__(self): + return hash(self.key) + + +class FileSystemMovedEvent(FileSystemEvent): + """ + File system event representing any kind of file system movement. + """ + + event_type = EVENT_TYPE_MOVED + + def __init__(self, src_path, dest_path): + super(FileSystemMovedEvent, self).__init__(src_path) + self._dest_path = dest_path + + @property + def dest_path(self): + """The destination path of the move event.""" + return self._dest_path + + # Used for hashing this as an immutable object. + @property + def key(self): + return (self.event_type, self.src_path, self.dest_path, self.is_directory) + + def __repr__(self): + return ("<%(class_name)s: src_path=%(src_path)r, " + "dest_path=%(dest_path)r, " + "is_directory=%(is_directory)s>" + ) % (dict(class_name=self.__class__.__name__, + src_path=self.src_path, + dest_path=self.dest_path, + is_directory=self.is_directory)) + + +# File events. + + +class FileDeletedEvent(FileSystemEvent): + """File system event representing file deletion on the file system.""" + + event_type = EVENT_TYPE_DELETED + + def __init__(self, src_path): + super(FileDeletedEvent, self).__init__(src_path) + + def __repr__(self): + return "<%(class_name)s: src_path=%(src_path)r>" %\ + dict(class_name=self.__class__.__name__, + src_path=self.src_path) + + +class FileModifiedEvent(FileSystemEvent): + """File system event representing file modification on the file system.""" + + event_type = EVENT_TYPE_MODIFIED + + def __init__(self, src_path): + super(FileModifiedEvent, self).__init__(src_path) + + def __repr__(self): + return ("<%(class_name)s: src_path=%(src_path)r>" + ) % (dict(class_name=self.__class__.__name__, + src_path=self.src_path)) + + +class FileCreatedEvent(FileSystemEvent): + """File system event representing file creation on the file system.""" + + event_type = EVENT_TYPE_CREATED + + def __init__(self, src_path): + super(FileCreatedEvent, self).__init__(src_path) + + def __repr__(self): + return ("<%(class_name)s: src_path=%(src_path)r>" + ) % (dict(class_name=self.__class__.__name__, + src_path=self.src_path)) + + +class FileMovedEvent(FileSystemMovedEvent): + """File system event representing file movement on the file system.""" + + def __init__(self, src_path, dest_path): + super(FileMovedEvent, self).__init__(src_path, dest_path) + + def __repr__(self): + return ("<%(class_name)s: src_path=%(src_path)r, " + "dest_path=%(dest_path)r>" + ) % (dict(class_name=self.__class__.__name__, + src_path=self.src_path, + dest_path=self.dest_path)) + + +# Directory events. + + +class DirDeletedEvent(FileSystemEvent): + """File system event representing directory deletion on the file system.""" + + event_type = EVENT_TYPE_DELETED + is_directory = True + + def __init__(self, src_path): + super(DirDeletedEvent, self).__init__(src_path) + + def __repr__(self): + return ("<%(class_name)s: src_path=%(src_path)r>" + ) % (dict(class_name=self.__class__.__name__, + src_path=self.src_path)) + + +class DirModifiedEvent(FileSystemEvent): + """ + File system event representing directory modification on the file system. + """ + + event_type = EVENT_TYPE_MODIFIED + is_directory = True + + def __init__(self, src_path): + super(DirModifiedEvent, self).__init__(src_path) + + def __repr__(self): + return ("<%(class_name)s: src_path=%(src_path)r>" + ) % (dict(class_name=self.__class__.__name__, + src_path=self.src_path)) + + +class DirCreatedEvent(FileSystemEvent): + """File system event representing directory creation on the file system.""" + + event_type = EVENT_TYPE_CREATED + is_directory = True + + def __init__(self, src_path): + super(DirCreatedEvent, self).__init__(src_path) + + def __repr__(self): + return ("<%(class_name)s: src_path=%(src_path)r>" + ) % (dict(class_name=self.__class__.__name__, + src_path=self.src_path)) + + +class DirMovedEvent(FileSystemMovedEvent): + """File system event representing directory movement on the file system.""" + + is_directory = True + + def __init__(self, src_path, dest_path): + super(DirMovedEvent, self).__init__(src_path, dest_path) + + def __repr__(self): + return ("<%(class_name)s: src_path=%(src_path)r, " + "dest_path=%(dest_path)r>" + ) % (dict(class_name=self.__class__.__name__, + src_path=self.src_path, + dest_path=self.dest_path)) + + +class FileSystemEventHandler(object): + """ + Base file system event handler that you can override methods from. + """ + + def dispatch(self, event): + """Dispatches events to the appropriate methods. + + :param event: + The event object representing the file system event. + :type event: + :class:`FileSystemEvent` + """ + self.on_any_event(event) + _method_map = { + EVENT_TYPE_MODIFIED: self.on_modified, + EVENT_TYPE_MOVED: self.on_moved, + EVENT_TYPE_CREATED: self.on_created, + EVENT_TYPE_DELETED: self.on_deleted, + } + event_type = event.event_type + _method_map[event_type](event) + + def on_any_event(self, event): + """Catch-all event handler. + + :param event: + The event object representing the file system event. + :type event: + :class:`FileSystemEvent` + """ + + def on_moved(self, event): + """Called when a file or a directory is moved or renamed. + + :param event: + Event representing file/directory movement. + :type event: + :class:`DirMovedEvent` or :class:`FileMovedEvent` + """ + + def on_created(self, event): + """Called when a file or directory is created. + + :param event: + Event representing file/directory creation. + :type event: + :class:`DirCreatedEvent` or :class:`FileCreatedEvent` + """ + + def on_deleted(self, event): + """Called when a file or directory is deleted. + + :param event: + Event representing file/directory deletion. + :type event: + :class:`DirDeletedEvent` or :class:`FileDeletedEvent` + """ + + def on_modified(self, event): + """Called when a file or directory is modified. + + :param event: + Event representing file/directory modification. + :type event: + :class:`DirModifiedEvent` or :class:`FileModifiedEvent` + """ + + +class PatternMatchingEventHandler(FileSystemEventHandler): + """ + Matches given patterns with file paths associated with occurring events. + """ + + def __init__(self, patterns=None, ignore_patterns=None, + ignore_directories=False, case_sensitive=False): + super(PatternMatchingEventHandler, self).__init__() + + self._patterns = patterns + self._ignore_patterns = ignore_patterns + self._ignore_directories = ignore_directories + self._case_sensitive = case_sensitive + + @property + def patterns(self): + """ + (Read-only) + Patterns to allow matching event paths. + """ + return self._patterns + + @property + def ignore_patterns(self): + """ + (Read-only) + Patterns to ignore matching event paths. + """ + return self._ignore_patterns + + @property + def ignore_directories(self): + """ + (Read-only) + ``True`` if directories should be ignored; ``False`` otherwise. + """ + return self._ignore_directories + + @property + def case_sensitive(self): + """ + (Read-only) + ``True`` if path names should be matched sensitive to case; ``False`` + otherwise. + """ + return self._case_sensitive + + def dispatch(self, event): + """Dispatches events to the appropriate methods. + + :param event: + The event object representing the file system event. + :type event: + :class:`FileSystemEvent` + """ + if self.ignore_directories and event.is_directory: + return + + paths = [] + if has_attribute(event, 'dest_path'): + paths.append(unicode_paths.decode(event.dest_path)) + if event.src_path: + paths.append(unicode_paths.decode(event.src_path)) + + if match_any_paths(paths, + included_patterns=self.patterns, + excluded_patterns=self.ignore_patterns, + case_sensitive=self.case_sensitive): + self.on_any_event(event) + _method_map = { + EVENT_TYPE_MODIFIED: self.on_modified, + EVENT_TYPE_MOVED: self.on_moved, + EVENT_TYPE_CREATED: self.on_created, + EVENT_TYPE_DELETED: self.on_deleted, + } + event_type = event.event_type + _method_map[event_type](event) + + +class RegexMatchingEventHandler(FileSystemEventHandler): + """ + Matches given regexes with file paths associated with occurring events. + """ + + def __init__(self, regexes=[r".*"], ignore_regexes=[], + ignore_directories=False, case_sensitive=False): + super(RegexMatchingEventHandler, self).__init__() + + if case_sensitive: + self._regexes = [re.compile(r) for r in regexes] + self._ignore_regexes = [re.compile(r) for r in ignore_regexes] + else: + self._regexes = [re.compile(r, re.I) for r in regexes] + self._ignore_regexes = [re.compile(r, re.I) for r in ignore_regexes] + self._ignore_directories = ignore_directories + self._case_sensitive = case_sensitive + + @property + def regexes(self): + """ + (Read-only) + Regexes to allow matching event paths. + """ + return self._regexes + + @property + def ignore_regexes(self): + """ + (Read-only) + Regexes to ignore matching event paths. + """ + return self._ignore_regexes + + @property + def ignore_directories(self): + """ + (Read-only) + ``True`` if directories should be ignored; ``False`` otherwise. + """ + return self._ignore_directories + + @property + def case_sensitive(self): + """ + (Read-only) + ``True`` if path names should be matched sensitive to case; ``False`` + otherwise. + """ + return self._case_sensitive + + def dispatch(self, event): + """Dispatches events to the appropriate methods. + + :param event: + The event object representing the file system event. + :type event: + :class:`FileSystemEvent` + """ + if self.ignore_directories and event.is_directory: + return + + paths = [] + if has_attribute(event, 'dest_path'): + paths.append(unicode_paths.decode(event.dest_path)) + if event.src_path: + paths.append(unicode_paths.decode(event.src_path)) + + if any(r.match(p) for r in self.ignore_regexes for p in paths): + return + + if any(r.match(p) for r in self.regexes for p in paths): + self.on_any_event(event) + _method_map = { + EVENT_TYPE_MODIFIED: self.on_modified, + EVENT_TYPE_MOVED: self.on_moved, + EVENT_TYPE_CREATED: self.on_created, + EVENT_TYPE_DELETED: self.on_deleted, + } + event_type = event.event_type + _method_map[event_type](event) + + +class LoggingEventHandler(FileSystemEventHandler): + """Logs all the events captured.""" + + def on_moved(self, event): + super(LoggingEventHandler, self).on_moved(event) + + what = 'directory' if event.is_directory else 'file' + logging.info("Moved %s: from %s to %s", what, event.src_path, + event.dest_path) + + def on_created(self, event): + super(LoggingEventHandler, self).on_created(event) + + what = 'directory' if event.is_directory else 'file' + logging.info("Created %s: %s", what, event.src_path) + + def on_deleted(self, event): + super(LoggingEventHandler, self).on_deleted(event) + + what = 'directory' if event.is_directory else 'file' + logging.info("Deleted %s: %s", what, event.src_path) + + def on_modified(self, event): + super(LoggingEventHandler, self).on_modified(event) + + what = 'directory' if event.is_directory else 'file' + logging.info("Modified %s: %s", what, event.src_path) + + +class LoggingFileSystemEventHandler(LoggingEventHandler): + """ + For backwards-compatibility. Please use :class:`LoggingEventHandler` + instead. + """ + + +def generate_sub_moved_events(src_dir_path, dest_dir_path): + """Generates an event list of :class:`DirMovedEvent` and + :class:`FileMovedEvent` objects for all the files and directories within + the given moved directory that were moved along with the directory. + + :param src_dir_path: + The source path of the moved directory. + :param dest_dir_path: + The destination path of the moved directory. + :returns: + An iterable of file system events of type :class:`DirMovedEvent` and + :class:`FileMovedEvent`. + """ + for root, directories, filenames in os.walk(dest_dir_path): + for directory in directories: + full_path = os.path.join(root, directory) + renamed_path = full_path.replace(dest_dir_path, src_dir_path) if src_dir_path else None + yield DirMovedEvent(renamed_path, full_path) + for filename in filenames: + full_path = os.path.join(root, filename) + renamed_path = full_path.replace(dest_dir_path, src_dir_path) if src_dir_path else None + yield FileMovedEvent(renamed_path, full_path) + + +def generate_sub_created_events(src_dir_path): + """Generates an event list of :class:`DirCreatedEvent` and + :class:`FileCreatedEvent` objects for all the files and directories within + the given moved directory that were moved along with the directory. + + :param src_dir_path: + The source path of the created directory. + :returns: + An iterable of file system events of type :class:`DirCreatedEvent` and + :class:`FileCreatedEvent`. + """ + for root, directories, filenames in os.walk(src_dir_path): + for directory in directories: + yield DirCreatedEvent(os.path.join(root, directory)) + for filename in filenames: + yield FileCreatedEvent(os.path.join(root, filename)) \ No newline at end of file diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/observers/__init__.py b/script.service.kodi.callbacks/resources/lib/watchdog/observers/__init__.py new file mode 100644 index 0000000000..d38efc8a73 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/observers/__init__.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +:module: watchdog.observers +:synopsis: Observer that picks a native implementation if available. +:author: yesudeep@google.com (Yesudeep Mangalapilly) + + +Classes +======= +.. autoclass:: Observer + :members: + :show-inheritance: + :inherited-members: + +Observer thread that schedules watching directories and dispatches +calls to event handlers. + +You can also import platform specific classes directly and use it instead +of :class:`Observer`. Here is a list of implemented observer classes.: + +============== ================================ ============================== +Class Platforms Note +============== ================================ ============================== +|Inotify| Linux 2.6.13+ ``inotify(7)`` based observer +|FSEvents| Mac OS X FSEvents based observer +|Kqueue| Mac OS X and BSD with kqueue(2) ``kqueue(2)`` based observer +|WinApi| MS Windows Windows API-based observer +|Polling| Any fallback implementation +============== ================================ ============================== + +.. |Inotify| replace:: :class:`.inotify.InotifyObserver` +.. |FSEvents| replace:: :class:`.fsevents.FSEventsObserver` +.. |Kqueue| replace:: :class:`.kqueue.KqueueObserver` +.. |WinApi| replace:: :class:`.read_directory_changes.WindowsApiObserver` +.. |WinApiAsync| replace:: :class:`.read_directory_changes_async.WindowsApiAsyncObserver` +.. |Polling| replace:: :class:`.polling.PollingObserver` + +""" + +import warnings +try: + from watchdog.utils import platform +except: + from resources.lib.watchdog.utils import platform +try: + from watchdog.utils import UnsupportedLibc +except: + from resources.lib.watchdog.utils import UnsupportedLibc + +if platform.is_linux(): + try: + from .inotify import InotifyObserver as Observer + except UnsupportedLibc: + from .polling import PollingObserver as Observer + +elif platform.is_darwin(): + # FIXME: catching too broad. Error prone + try: + from .fsevents import FSEventsObserver as Observer + except: + try: + from .kqueue import KqueueObserver as Observer + warnings.warn("Failed to import fsevents. Fall back to kqueue") + except: + from .polling import PollingObserver as Observer + warnings.warn("Failed to import fsevents and kqueue. Fall back to polling.") + +elif platform.is_bsd(): + from .kqueue import KqueueObserver as Observer + +elif platform.is_windows(): + # TODO: find a reliable way of checking Windows version and import + # polling explicitly for Windows XP + try: + from .read_directory_changes import WindowsApiObserver as Observer + except: + from .polling import PollingObserver as Observer + warnings.warn("Failed to import read_directory_changes. Fall back to polling.") + +else: + from .polling import PollingObserver as Observer diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/observers/api.py b/script.service.kodi.callbacks/resources/lib/watchdog/observers/api.py new file mode 100644 index 0000000000..30f2e9a35d --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/observers/api.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import with_statement +import threading +from watchdog.utils import BaseThread +from watchdog.utils.compat import queue +from watchdog.utils.bricks import SkipRepeatsQueue + +DEFAULT_EMITTER_TIMEOUT = 1 # in seconds. +DEFAULT_OBSERVER_TIMEOUT = 1 # in seconds. + + +# Collection classes +class EventQueue(SkipRepeatsQueue): + """Thread-safe event queue based on a special queue that skips adding + the same event (:class:`FileSystemEvent`) multiple times consecutively. + Thus avoiding dispatching multiple event handling + calls when multiple identical events are produced quicker than an observer + can consume them. + """ + + +class ObservedWatch(object): + """An scheduled watch. + + :param path: + Path string. + :param recursive: + ``True`` if watch is recursive; ``False`` otherwise. + """ + + def __init__(self, path, recursive): + self._path = path + self._is_recursive = recursive + + @property + def path(self): + """The path that this watch monitors.""" + return self._path + + @property + def is_recursive(self): + """Determines whether subdirectories are watched for the path.""" + return self._is_recursive + + @property + def key(self): + return self.path, self.is_recursive + + def __eq__(self, watch): + return self.key == watch.key + + def __ne__(self, watch): + return self.key != watch.key + + def __hash__(self): + return hash(self.key) + + def __repr__(self): + return "" % ( + self.path, self.is_recursive) + + +# Observer classes +class EventEmitter(BaseThread): + """ + Producer thread base class subclassed by event emitters + that generate events and populate a queue with them. + + :param event_queue: + The event queue to populate with generated events. + :type event_queue: + :class:`watchdog.events.EventQueue` + :param watch: + The watch to observe and produce events for. + :type watch: + :class:`ObservedWatch` + :param timeout: + Timeout (in seconds) between successive attempts at reading events. + :type timeout: + ``float`` + """ + + def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT): + BaseThread.__init__(self) + self._event_queue = event_queue + self._watch = watch + self._timeout = timeout + + @property + def timeout(self): + """ + Blocking timeout for reading events. + """ + return self._timeout + + @property + def watch(self): + """ + The watch associated with this emitter. + """ + return self._watch + + def queue_event(self, event): + """ + Queues a single event. + + :param event: + Event to be queued. + :type event: + An instance of :class:`watchdog.events.FileSystemEvent` + or a subclass. + """ + self._event_queue.put((event, self.watch)) + + def queue_events(self, timeout): + """Override this method to populate the event queue with events + per interval period. + + :param timeout: + Timeout (in seconds) between successive attempts at + reading events. + :type timeout: + ``float`` + """ + + def run(self): + try: + while self.should_keep_running(): + self.queue_events(self.timeout) + finally: + pass + + +class EventDispatcher(BaseThread): + """ + Consumer thread base class subclassed by event observer threads + that dispatch events from an event queue to appropriate event handlers. + + :param timeout: + Event queue blocking timeout (in seconds). + :type timeout: + ``float`` + """ + + def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT): + BaseThread.__init__(self) + self._event_queue = EventQueue() + self._timeout = timeout + + @property + def timeout(self): + """Event queue block timeout.""" + return self._timeout + + @property + def event_queue(self): + """The event queue which is populated with file system events + by emitters and from which events are dispatched by a dispatcher + thread.""" + return self._event_queue + + def dispatch_events(self, event_queue, timeout): + """Override this method to consume events from an event queue, blocking + on the queue for the specified timeout before raising :class:`queue.Empty`. + + :param event_queue: + Event queue to populate with one set of events. + :type event_queue: + :class:`EventQueue` + :param timeout: + Interval period (in seconds) to wait before timing out on the + event queue. + :type timeout: + ``float`` + :raises: + :class:`queue.Empty` + """ + + def run(self): + while self.should_keep_running(): + try: + self.dispatch_events(self.event_queue, self.timeout) + except queue.Empty: + continue + + +class BaseObserver(EventDispatcher): + """Base observer.""" + + def __init__(self, emitter_class, timeout=DEFAULT_OBSERVER_TIMEOUT): + EventDispatcher.__init__(self, timeout) + self._emitter_class = emitter_class + self._lock = threading.RLock() + self._watches = set() + self._handlers = dict() + self._emitters = set() + self._emitter_for_watch = dict() + + def _add_emitter(self, emitter): + self._emitter_for_watch[emitter.watch] = emitter + self._emitters.add(emitter) + + def _remove_emitter(self, emitter): + del self._emitter_for_watch[emitter.watch] + self._emitters.remove(emitter) + emitter.stop() + try: + emitter.join() + except RuntimeError: + pass + + def _clear_emitters(self): + for emitter in self._emitters: + emitter.stop() + for emitter in self._emitters: + try: + emitter.join() + except RuntimeError: + pass + self._emitters.clear() + self._emitter_for_watch.clear() + + def _add_handler_for_watch(self, event_handler, watch): + if watch not in self._handlers: + self._handlers[watch] = set() + self._handlers[watch].add(event_handler) + + def _remove_handlers_for_watch(self, watch): + del self._handlers[watch] + + @property + def emitters(self): + """Returns event emitter created by this observer.""" + return self._emitters + + def start(self): + for emitter in self._emitters: + emitter.start() + super(BaseObserver, self).start() + + def schedule(self, event_handler, path, recursive=False): + """ + Schedules watching a path and calls appropriate methods specified + in the given event handler in response to file system events. + + :param event_handler: + An event handler instance that has appropriate event handling + methods which will be called by the observer in response to + file system events. + :type event_handler: + :class:`watchdog.events.FileSystemEventHandler` or a subclass + :param path: + Directory path that will be monitored. + :type path: + ``str`` + :param recursive: + ``True`` if events will be emitted for sub-directories + traversed recursively; ``False`` otherwise. + :type recursive: + ``bool`` + :return: + An :class:`ObservedWatch` object instance representing + a watch. + """ + with self._lock: + watch = ObservedWatch(path, recursive) + self._add_handler_for_watch(event_handler, watch) + + # If we don't have an emitter for this watch already, create it. + if self._emitter_for_watch.get(watch) is None: + emitter = self._emitter_class(event_queue=self.event_queue, + watch=watch, + timeout=self.timeout) + self._add_emitter(emitter) + if self.is_alive(): + emitter.start() + self._watches.add(watch) + return watch + + def add_handler_for_watch(self, event_handler, watch): + """Adds a handler for the given watch. + + :param event_handler: + An event handler instance that has appropriate event handling + methods which will be called by the observer in response to + file system events. + :type event_handler: + :class:`watchdog.events.FileSystemEventHandler` or a subclass + :param watch: + The watch to add a handler for. + :type watch: + An instance of :class:`ObservedWatch` or a subclass of + :class:`ObservedWatch` + """ + with self._lock: + self._add_handler_for_watch(event_handler, watch) + + def remove_handler_for_watch(self, event_handler, watch): + """Removes a handler for the given watch. + + :param event_handler: + An event handler instance that has appropriate event handling + methods which will be called by the observer in response to + file system events. + :type event_handler: + :class:`watchdog.events.FileSystemEventHandler` or a subclass + :param watch: + The watch to remove a handler for. + :type watch: + An instance of :class:`ObservedWatch` or a subclass of + :class:`ObservedWatch` + """ + with self._lock: + self._handlers[watch].remove(event_handler) + + def unschedule(self, watch): + """Unschedules a watch. + + :param watch: + The watch to unschedule. + :type watch: + An instance of :class:`ObservedWatch` or a subclass of + :class:`ObservedWatch` + """ + with self._lock: + emitter = self._emitter_for_watch[watch] + del self._handlers[watch] + self._remove_emitter(emitter) + self._watches.remove(watch) + + def unschedule_all(self): + """Unschedules all watches and detaches all associated event + handlers.""" + with self._lock: + self._handlers.clear() + self._clear_emitters() + self._watches.clear() + + def on_thread_stop(self): + self.unschedule_all() + + def dispatch_events(self, event_queue, timeout): + event, watch = event_queue.get(block=True, timeout=timeout) + + with self._lock: + # To allow unschedule/stop and safe removal of event handlers + # within event handlers itself, check if the handler is still + # registered after every dispatch. + for handler in list(self._handlers.get(watch, [])): + if handler in self._handlers.get(watch, []): + handler.dispatch(event) + event_queue.task_done() diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/observers/fsevents.py b/script.service.kodi.callbacks/resources/lib/watchdog/observers/fsevents.py new file mode 100644 index 0000000000..6748148007 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/observers/fsevents.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +:module: watchdog.observers.fsevents +:synopsis: FSEvents based emitter implementation. +:author: yesudeep@google.com (Yesudeep Mangalapilly) +:platforms: Mac OS X +""" + +from __future__ import with_statement + +import sys +import threading +import unicodedata +import _watchdog_fsevents as _fsevents + +from watchdog.events import ( + FileDeletedEvent, + FileModifiedEvent, + FileCreatedEvent, + FileMovedEvent, + DirDeletedEvent, + DirModifiedEvent, + DirCreatedEvent, + DirMovedEvent +) + +from watchdog.utils.dirsnapshot import DirectorySnapshot +from watchdog.observers.api import ( + BaseObserver, + EventEmitter, + DEFAULT_EMITTER_TIMEOUT, + DEFAULT_OBSERVER_TIMEOUT +) + + +class FSEventsEmitter(EventEmitter): + + """ + Mac OS X FSEvents Emitter class. + + :param event_queue: + The event queue to fill with events. + :param watch: + A watch object representing the directory to monitor. + :type watch: + :class:`watchdog.observers.api.ObservedWatch` + :param timeout: + Read events blocking timeout (in seconds). + :type timeout: + ``float`` + """ + + def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT): + EventEmitter.__init__(self, event_queue, watch, timeout) + self._lock = threading.Lock() + self.snapshot = DirectorySnapshot(watch.path, watch.is_recursive) + + def on_thread_stop(self): + _fsevents.remove_watch(self.watch) + _fsevents.stop(self) + + def queue_events(self, timeout): + with self._lock: + if not self.watch.is_recursive\ + and self.watch.path not in self.pathnames: + return + new_snapshot = DirectorySnapshot(self.watch.path, + self.watch.is_recursive) + events = new_snapshot - self.snapshot + self.snapshot = new_snapshot + + # Files. + for src_path in events.files_deleted: + self.queue_event(FileDeletedEvent(src_path)) + for src_path in events.files_modified: + self.queue_event(FileModifiedEvent(src_path)) + for src_path in events.files_created: + self.queue_event(FileCreatedEvent(src_path)) + for src_path, dest_path in events.files_moved: + self.queue_event(FileMovedEvent(src_path, dest_path)) + + # Directories. + for src_path in events.dirs_deleted: + self.queue_event(DirDeletedEvent(src_path)) + for src_path in events.dirs_modified: + self.queue_event(DirModifiedEvent(src_path)) + for src_path in events.dirs_created: + self.queue_event(DirCreatedEvent(src_path)) + for src_path, dest_path in events.dirs_moved: + self.queue_event(DirMovedEvent(src_path, dest_path)) + + def run(self): + try: + def callback(pathnames, flags, emitter=self): + emitter.queue_events(emitter.timeout) + + # for pathname, flag in zip(pathnames, flags): + # if emitter.watch.is_recursive: # and pathname != emitter.watch.path: + # new_sub_snapshot = DirectorySnapshot(pathname, True) + # old_sub_snapshot = self.snapshot.copy(pathname) + # diff = new_sub_snapshot - old_sub_snapshot + # self.snapshot += new_subsnapshot + # else: + # new_snapshot = DirectorySnapshot(emitter.watch.path, False) + # diff = new_snapshot - emitter.snapshot + # emitter.snapshot = new_snapshot + + # INFO: FSEvents reports directory notifications recursively + # by default, so we do not need to add subdirectory paths. + #pathnames = set([self.watch.path]) + # if self.watch.is_recursive: + # for root, directory_names, _ in os.walk(self.watch.path): + # for directory_name in directory_names: + # full_path = absolute_path( + # os.path.join(root, directory_name)) + # pathnames.add(full_path) + self.pathnames = [self.watch.path] + _fsevents.add_watch(self, + self.watch, + callback, + self.pathnames) + _fsevents.read_events(self) + except Exception as e: + pass + + +class FSEventsObserver(BaseObserver): + + def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT): + BaseObserver.__init__(self, emitter_class=FSEventsEmitter, + timeout=timeout) + + def schedule(self, event_handler, path, recursive=False): + # Python 2/3 compat + try: + str_class = unicode + except NameError: + str_class = str + + # Fix for issue #26: Trace/BPT error when given a unicode path + # string. https://github.com/gorakhargosh/watchdog/issues#issue/26 + if isinstance(path, str_class): + #path = unicode(path, 'utf-8') + path = unicodedata.normalize('NFC', path) + # We only encode the path in Python 2 for backwards compatibility. + # On Python 3 we want the path to stay as unicode if possible for + # the sake of path matching not having to be rewritten to use the + # bytes API instead of strings. The _watchdog_fsevent.so code for + # Python 3 can handle both str and bytes paths, which is why we + # do not HAVE to encode it with Python 3. The Python 2 code in + # _watchdog_fsevents.so was not changed for the sake of backwards + # compatibility. + if sys.version_info < (3,): + path = path.encode('utf-8') + return BaseObserver.schedule(self, event_handler, path, recursive) diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/observers/fsevents2.py b/script.service.kodi.callbacks/resources/lib/watchdog/observers/fsevents2.py new file mode 100644 index 0000000000..9b6ebcba71 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/observers/fsevents2.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2014 Thomas Amland +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +:module: watchdog.observers.fsevents2 +:synopsis: FSEvents based emitter implementation. +:platforms: Mac OS X +""" + +import os +import logging +import unicodedata +from threading import Thread +from watchdog.utils.compat import queue + +from watchdog.events import ( + FileDeletedEvent, + FileModifiedEvent, + FileCreatedEvent, + FileMovedEvent, + DirDeletedEvent, + DirModifiedEvent, + DirCreatedEvent, + DirMovedEvent +) +from watchdog.observers.api import ( + BaseObserver, + EventEmitter, + DEFAULT_EMITTER_TIMEOUT, + DEFAULT_OBSERVER_TIMEOUT, +) + +# pyobjc +import AppKit +from FSEvents import ( + FSEventStreamCreate, + CFRunLoopGetCurrent, + FSEventStreamScheduleWithRunLoop, + FSEventStreamStart, + CFRunLoopRun, + CFRunLoopStop, + FSEventStreamStop, + FSEventStreamInvalidate, + FSEventStreamRelease, +) + +from FSEvents import ( + kCFAllocatorDefault, + kCFRunLoopDefaultMode, + kFSEventStreamEventIdSinceNow, + kFSEventStreamCreateFlagNoDefer, + kFSEventStreamCreateFlagFileEvents, + kFSEventStreamEventFlagItemCreated, + kFSEventStreamEventFlagItemRemoved, + kFSEventStreamEventFlagItemInodeMetaMod, + kFSEventStreamEventFlagItemRenamed, + kFSEventStreamEventFlagItemModified, + kFSEventStreamEventFlagItemFinderInfoMod, + kFSEventStreamEventFlagItemChangeOwner, + kFSEventStreamEventFlagItemXattrMod, + kFSEventStreamEventFlagItemIsFile, + kFSEventStreamEventFlagItemIsDir, + kFSEventStreamEventFlagItemIsSymlink, +) + +logger = logging.getLogger(__name__) + + +class FSEventsQueue(Thread): + """ Low level FSEvents client. """ + + def __init__(self, path): + Thread.__init__(self) + self._queue = queue.Queue() + self._run_loop = None + + if isinstance(path, bytes): + path = path.decode('utf-8') + self._path = unicodedata.normalize('NFC', path) + + context = None + latency = 1.0 + self._stream_ref = FSEventStreamCreate( + kCFAllocatorDefault, self._callback, context, [self._path], + kFSEventStreamEventIdSinceNow, latency, + kFSEventStreamCreateFlagNoDefer | kFSEventStreamCreateFlagFileEvents) + if self._stream_ref is None: + raise IOError("FSEvents. Could not create stream.") + + def run(self): + pool = AppKit.NSAutoreleasePool.alloc().init() + self._run_loop = CFRunLoopGetCurrent() + FSEventStreamScheduleWithRunLoop( + self._stream_ref, self._run_loop, kCFRunLoopDefaultMode) + if not FSEventStreamStart(self._stream_ref): + FSEventStreamInvalidate(self._stream_ref) + FSEventStreamRelease(self._stream_ref) + raise IOError("FSEvents. Could not start stream.") + + CFRunLoopRun() + FSEventStreamStop(self._stream_ref) + FSEventStreamInvalidate(self._stream_ref) + FSEventStreamRelease(self._stream_ref) + del pool + # Make sure waiting thread is notified + self._queue.put(None) + + def stop(self): + if self._run_loop is not None: + CFRunLoopStop(self._run_loop) + + def _callback(self, streamRef, clientCallBackInfo, numEvents, eventPaths, eventFlags, eventIDs): + events = [NativeEvent(path, flags, _id) for path, flags, _id in + zip(eventPaths, eventFlags, eventIDs)] + logger.debug("FSEvents callback. Got %d events:" % numEvents) + for e in events: + logger.debug(e) + self._queue.put(events) + + def read_events(self): + """ + Returns a list or one or more events, or None if there are no more + events to be read. + """ + if not self.is_alive(): + return None + return self._queue.get() + + +class NativeEvent(object): + def __init__(self, path, flags, event_id): + self.path = path + self.flags = flags + self.event_id = event_id + self.is_created = bool(flags & kFSEventStreamEventFlagItemCreated) + self.is_removed = bool(flags & kFSEventStreamEventFlagItemRemoved) + self.is_renamed = bool(flags & kFSEventStreamEventFlagItemRenamed) + self.is_modified = bool(flags & kFSEventStreamEventFlagItemModified) + self.is_change_owner = bool(flags & kFSEventStreamEventFlagItemChangeOwner) + self.is_inode_meta_mod = bool(flags & kFSEventStreamEventFlagItemInodeMetaMod) + self.is_finder_info_mod = bool(flags & kFSEventStreamEventFlagItemFinderInfoMod) + self.is_xattr_mod = bool(flags & kFSEventStreamEventFlagItemXattrMod) + self.is_symlink = bool(flags & kFSEventStreamEventFlagItemIsSymlink) + self.is_directory = bool(flags & kFSEventStreamEventFlagItemIsDir) + + @property + def _event_type(self): + if self.is_created: return "Created" + if self.is_removed: return "Removed" + if self.is_renamed: return "Renamed" + if self.is_modified: return "Modified" + if self.is_inode_meta_mod: return "InodeMetaMod" + if self.is_xattr_mod: return "XattrMod" + return "Unknown" + + def __repr__(self): + s ="" + return s % (repr(self.path), self._event_type, self.is_directory, hex(self.flags), self.event_id) + + +class FSEventsEmitter(EventEmitter): + """ + FSEvents based event emitter. Handles conversion of native events. + """ + + def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT): + EventEmitter.__init__(self, event_queue, watch, timeout) + self._fsevents = FSEventsQueue(watch.path) + self._fsevents.start() + + def on_thread_stop(self): + self._fsevents.stop() + + def queue_events(self, timeout): + events = self._fsevents.read_events() + if events is None: + return + i = 0 + while i < len(events): + event = events[i] + + # For some reason the create and remove flags are sometimes also + # set for rename and modify type events, so let those take + # precedence. + if event.is_renamed: + # Internal moves appears to always be consecutive in the same + # buffer and have IDs differ by exactly one (while others + # don't) making it possible to pair up the two events coming + # from a singe move operation. (None of this is documented!) + # Otherwise, guess whether file was moved in or out. + #TODO: handle id wrapping + if (i+1 < len(events) and events[i+1].is_renamed and + events[i+1].event_id == event.event_id + 1): + cls = DirMovedEvent if event.is_directory else FileMovedEvent + self.queue_event(cls(event.path, events[i+1].path)) + self.queue_event(DirModifiedEvent(os.path.dirname(event.path))) + self.queue_event(DirModifiedEvent(os.path.dirname(events[i+1].path))) + i += 1 + elif os.path.exists(event.path): + cls = DirCreatedEvent if event.is_directory else FileCreatedEvent + self.queue_event(cls(event.path)) + self.queue_event(DirModifiedEvent(os.path.dirname(event.path))) + else: + cls = DirDeletedEvent if event.is_directory else FileDeletedEvent + self.queue_event(cls(event.path)) + self.queue_event(DirModifiedEvent(os.path.dirname(event.path))) + #TODO: generate events for tree + + elif event.is_modified or event.is_inode_meta_mod or event.is_xattr_mod : + cls = DirModifiedEvent if event.is_directory else FileModifiedEvent + self.queue_event(cls(event.path)) + + elif event.is_created: + cls = DirCreatedEvent if event.is_directory else FileCreatedEvent + self.queue_event(cls(event.path)) + self.queue_event(DirModifiedEvent(os.path.dirname(event.path))) + + elif event.is_removed: + cls = DirDeletedEvent if event.is_directory else FileDeletedEvent + self.queue_event(cls(event.path)) + self.queue_event(DirModifiedEvent(os.path.dirname(event.path))) + i += 1 + + +class FSEventsObserver2(BaseObserver): + def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT): + BaseObserver.__init__(self, emitter_class=FSEventsEmitter, timeout=timeout) diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/observers/inotify.py b/script.service.kodi.callbacks/resources/lib/watchdog/observers/inotify.py new file mode 100644 index 0000000000..65fa7d51cd --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/observers/inotify.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +:module: watchdog.observers.inotify +:synopsis: ``inotify(7)`` based emitter implementation. +:author: Sebastien Martini +:author: Luke McCarthy +:author: yesudeep@google.com (Yesudeep Mangalapilly) +:author: Tim Cuthbertson +:platforms: Linux 2.6.13+. + +.. ADMONITION:: About system requirements + + Recommended minimum kernel version: 2.6.25. + + Quote from the inotify(7) man page: + + "Inotify was merged into the 2.6.13 Linux kernel. The required library + interfaces were added to glibc in version 2.4. (IN_DONT_FOLLOW, + IN_MASK_ADD, and IN_ONLYDIR were only added in version 2.5.)" + + Therefore, you must ensure the system is running at least these versions + appropriate libraries and the kernel. + +.. ADMONITION:: About recursiveness, event order, and event coalescing + + Quote from the inotify(7) man page: + + If successive output inotify events produced on the inotify file + descriptor are identical (same wd, mask, cookie, and name) then they + are coalesced into a single event if the older event has not yet been + read (but see BUGS). + + The events returned by reading from an inotify file descriptor form + an ordered queue. Thus, for example, it is guaranteed that when + renaming from one directory to another, events will be produced in + the correct order on the inotify file descriptor. + + ... + + Inotify monitoring of directories is not recursive: to monitor + subdirectories under a directory, additional watches must be created. + + This emitter implementation therefore automatically adds watches for + sub-directories if running in recursive mode. + +Some extremely useful articles and documentation: + +.. _inotify FAQ: http://inotify.aiken.cz/?section=inotify&page=faq&lang=en +.. _intro to inotify: http://www.linuxjournal.com/article/8478 + +""" + +from __future__ import with_statement + +import os +import threading +from .inotify_buffer import InotifyBuffer + +from watchdog.observers.api import ( + EventEmitter, + BaseObserver, + DEFAULT_EMITTER_TIMEOUT, + DEFAULT_OBSERVER_TIMEOUT +) + +from watchdog.events import ( + DirDeletedEvent, + DirModifiedEvent, + DirMovedEvent, + DirCreatedEvent, + FileDeletedEvent, + FileModifiedEvent, + FileMovedEvent, + FileCreatedEvent, + generate_sub_moved_events, + generate_sub_created_events, +) +from watchdog.utils import unicode_paths + + +class InotifyEmitter(EventEmitter): + """ + inotify(7)-based event emitter. + + :param event_queue: + The event queue to fill with events. + :param watch: + A watch object representing the directory to monitor. + :type watch: + :class:`watchdog.observers.api.ObservedWatch` + :param timeout: + Read events blocking timeout (in seconds). + :type timeout: + ``float`` + """ + + def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT): + EventEmitter.__init__(self, event_queue, watch, timeout) + self._lock = threading.Lock() + self._inotify = None + + def on_thread_start(self): + path = unicode_paths.encode(self.watch.path) + self._inotify = InotifyBuffer(path, self.watch.is_recursive) + + def on_thread_stop(self): + if self._inotify: + self._inotify.close() + + def queue_events(self, timeout): + with self._lock: + event = self._inotify.read_event() + if event is None: + return + if isinstance(event, tuple): + move_from, move_to = event + src_path = self._decode_path(move_from.src_path) + dest_path = self._decode_path(move_to.src_path) + cls = DirMovedEvent if move_from.is_directory else FileMovedEvent + self.queue_event(cls(src_path, dest_path)) + self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) + self.queue_event(DirModifiedEvent(os.path.dirname(dest_path))) + if move_from.is_directory and self.watch.is_recursive: + for sub_event in generate_sub_moved_events(src_path, dest_path): + self.queue_event(sub_event) + return + + src_path = self._decode_path(event.src_path) + if event.is_moved_to: + cls = DirCreatedEvent if event.is_directory else FileCreatedEvent + self.queue_event(cls(src_path)) + self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) + if event.is_directory and self.watch.is_recursive: + for sub_event in generate_sub_created_events(src_path): + self.queue_event(sub_event) + elif event.is_attrib: + cls = DirModifiedEvent if event.is_directory else FileModifiedEvent + self.queue_event(cls(src_path)) + elif event.is_modify: + cls = DirModifiedEvent if event.is_directory else FileModifiedEvent + self.queue_event(cls(src_path)) + elif event.is_delete_self: + cls = DirDeletedEvent if event.is_directory else FileDeletedEvent + self.queue_event(cls(src_path)) + elif event.is_delete or event.is_moved_from: + cls = DirDeletedEvent if event.is_directory else FileDeletedEvent + self.queue_event(cls(src_path)) + self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) + elif event.is_create: + cls = DirCreatedEvent if event.is_directory else FileCreatedEvent + self.queue_event(cls(src_path)) + self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) + + def _decode_path(self, path): + """ Decode path only if unicode string was passed to this emitter. """ + if isinstance(self.watch.path, bytes): + return path + return unicode_paths.decode(path) + + +class InotifyObserver(BaseObserver): + """ + Observer thread that schedules watching directories and dispatches + calls to event handlers. + """ + + def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT): + BaseObserver.__init__(self, emitter_class=InotifyEmitter, + timeout=timeout) diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/observers/inotify_buffer.py b/script.service.kodi.callbacks/resources/lib/watchdog/observers/inotify_buffer.py new file mode 100644 index 0000000000..010bbb8ed9 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/observers/inotify_buffer.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2014 Thomas Amland +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from watchdog.utils import BaseThread +from watchdog.utils.delayed_queue import DelayedQueue +from watchdog.observers.inotify_c import Inotify + +logger = logging.getLogger(__name__) + + +class InotifyBuffer(BaseThread): + """A wrapper for `Inotify` that holds events for `delay` seconds. During + this time, IN_MOVED_FROM and IN_MOVED_TO events are paired. + """ + + delay = 0.5 + + def __init__(self, path, recursive=False): + BaseThread.__init__(self) + self._queue = DelayedQueue(self.delay) + self._inotify = Inotify(path, recursive) + self.start() + + def read_event(self): + """Returns a single event or a tuple of from/to events in case of a + paired move event. If this buffer has been closed, immediately return + None. + """ + return self._queue.get() + + def on_thread_stop(self): + self._inotify.close() + self._queue.close() + + def close(self): + self.stop() + self.join() + + def run(self): + """Read event from `inotify` and add them to `queue`. When reading a + IN_MOVE_TO event, remove the previous added matching IN_MOVE_FROM event + and add them back to the queue as a tuple. + """ + while self.should_keep_running(): + inotify_events = self._inotify.read_events() + for inotify_event in inotify_events: + logger.debug("in-event %s", inotify_event) + if inotify_event.is_moved_to: + + def matching_from_event(event): + return (not isinstance(event, tuple) and event.is_moved_from + and event.cookie == inotify_event.cookie) + + from_event = self._queue.remove(matching_from_event) + if from_event is not None: + self._queue.put((from_event, inotify_event)) + else: + logger.debug("could not find matching move_from event") + self._queue.put(inotify_event) + else: + self._queue.put(inotify_event) + diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/observers/inotify_c.py b/script.service.kodi.callbacks/resources/lib/watchdog/observers/inotify_c.py new file mode 100644 index 0000000000..ec795cd29b --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/observers/inotify_c.py @@ -0,0 +1,564 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import with_statement +import os +import errno +import struct +import threading +import ctypes +import ctypes.util +from functools import reduce +from ctypes import c_int, c_char_p, c_uint32 +from watchdog.utils import has_attribute +from watchdog.utils import UnsupportedLibc + + +def _load_libc(): + libc_path = None + try: + libc_path = ctypes.util.find_library('c') + except (OSError, IOError): + # Note: find_library will on some platforms raise these undocumented + # errors, e.g.on android IOError "No usable temporary directory found" + # will be raised. + pass + + if libc_path is not None: + return ctypes.CDLL(libc_path) + + # Fallbacks + try: + return ctypes.CDLL('libc.so') + except (OSError, IOError): + return ctypes.CDLL('libc.so.6') + +libc = _load_libc() + +if not has_attribute(libc, 'inotify_init') or \ + not has_attribute(libc, 'inotify_add_watch') or \ + not has_attribute(libc, 'inotify_rm_watch'): + raise UnsupportedLibc("Unsupported libc version found: %s" % libc._name) + +inotify_add_watch = ctypes.CFUNCTYPE(c_int, c_int, c_char_p, c_uint32, use_errno=True)( + ("inotify_add_watch", libc)) + +inotify_rm_watch = ctypes.CFUNCTYPE(c_int, c_int, c_uint32, use_errno=True)( + ("inotify_rm_watch", libc)) + +inotify_init = ctypes.CFUNCTYPE(c_int, use_errno=True)( + ("inotify_init", libc)) + + +class InotifyConstants(object): + # User-space events + IN_ACCESS = 0x00000001 # File was accessed. + IN_MODIFY = 0x00000002 # File was modified. + IN_ATTRIB = 0x00000004 # Meta-data changed. + IN_CLOSE_WRITE = 0x00000008 # Writable file was closed. + IN_CLOSE_NOWRITE = 0x00000010 # Unwritable file closed. + IN_OPEN = 0x00000020 # File was opened. + IN_MOVED_FROM = 0x00000040 # File was moved from X. + IN_MOVED_TO = 0x00000080 # File was moved to Y. + IN_CREATE = 0x00000100 # Subfile was created. + IN_DELETE = 0x00000200 # Subfile was deleted. + IN_DELETE_SELF = 0x00000400 # Self was deleted. + IN_MOVE_SELF = 0x00000800 # Self was moved. + + # Helper user-space events. + IN_CLOSE = IN_CLOSE_WRITE | IN_CLOSE_NOWRITE # Close. + IN_MOVE = IN_MOVED_FROM | IN_MOVED_TO # Moves. + + # Events sent by the kernel to a watch. + IN_UNMOUNT = 0x00002000 # Backing file system was unmounted. + IN_Q_OVERFLOW = 0x00004000 # Event queued overflowed. + IN_IGNORED = 0x00008000 # File was ignored. + + # Special flags. + IN_ONLYDIR = 0x01000000 # Only watch the path if it's a directory. + IN_DONT_FOLLOW = 0x02000000 # Do not follow a symbolic link. + IN_EXCL_UNLINK = 0x04000000 # Exclude events on unlinked objects + IN_MASK_ADD = 0x20000000 # Add to the mask of an existing watch. + IN_ISDIR = 0x40000000 # Event occurred against directory. + IN_ONESHOT = 0x80000000 # Only send event once. + + # All user-space events. + IN_ALL_EVENTS = reduce( + lambda x, y: x | y, [ + IN_ACCESS, + IN_MODIFY, + IN_ATTRIB, + IN_CLOSE_WRITE, + IN_CLOSE_NOWRITE, + IN_OPEN, + IN_MOVED_FROM, + IN_MOVED_TO, + IN_DELETE, + IN_CREATE, + IN_DELETE_SELF, + IN_MOVE_SELF, + ]) + + # Flags for ``inotify_init1`` + IN_CLOEXEC = 0x02000000 + IN_NONBLOCK = 0x00004000 + + +# Watchdog's API cares only about these events. +WATCHDOG_ALL_EVENTS = reduce( + lambda x, y: x | y, [ + InotifyConstants.IN_MODIFY, + InotifyConstants.IN_ATTRIB, + InotifyConstants.IN_MOVED_FROM, + InotifyConstants.IN_MOVED_TO, + InotifyConstants.IN_CREATE, + InotifyConstants.IN_DELETE, + InotifyConstants.IN_DELETE_SELF, + InotifyConstants.IN_DONT_FOLLOW, + ]) + + +class inotify_event_struct(ctypes.Structure): + """ + Structure representation of the inotify_event structure + (used in buffer size calculations):: + + struct inotify_event { + __s32 wd; /* watch descriptor */ + __u32 mask; /* watch mask */ + __u32 cookie; /* cookie to synchronize two events */ + __u32 len; /* length (including nulls) of name */ + char name[0]; /* stub for possible name */ + }; + """ + _fields_ = [('wd', c_int), + ('mask', c_uint32), + ('cookie', c_uint32), + ('len', c_uint32), + ('name', c_char_p)] + + +EVENT_SIZE = ctypes.sizeof(inotify_event_struct) +DEFAULT_NUM_EVENTS = 2048 +DEFAULT_EVENT_BUFFER_SIZE = DEFAULT_NUM_EVENTS * (EVENT_SIZE + 16) + + +class Inotify(object): + """ + Linux inotify(7) API wrapper class. + + :param path: + The directory path for which we want an inotify object. + :type path: + :class:`bytes` + :param recursive: + ``True`` if subdirectories should be monitored; ``False`` otherwise. + """ + + def __init__(self, path, recursive=False, event_mask=WATCHDOG_ALL_EVENTS): + # The file descriptor associated with the inotify instance. + inotify_fd = inotify_init() + if inotify_fd == -1: + Inotify._raise_error() + self._inotify_fd = inotify_fd + self._lock = threading.Lock() + + # Stores the watch descriptor for a given path. + self._wd_for_path = dict() + self._path_for_wd = dict() + + self._path = path + self._event_mask = event_mask + self._is_recursive = recursive + self._add_dir_watch(path, recursive, event_mask) + self._moved_from_events = dict() + + @property + def event_mask(self): + """The event mask for this inotify instance.""" + return self._event_mask + + @property + def path(self): + """The path associated with the inotify instance.""" + return self._path + + @property + def is_recursive(self): + """Whether we are watching directories recursively.""" + return self._is_recursive + + @property + def fd(self): + """The file descriptor associated with the inotify instance.""" + return self._inotify_fd + + def clear_move_records(self): + """Clear cached records of MOVED_FROM events""" + self._moved_from_events = dict() + + def source_for_move(self, destination_event): + """ + The source path corresponding to the given MOVED_TO event. + + If the source path is outside the monitored directories, None + is returned instead. + """ + if destination_event.cookie in self._moved_from_events: + return self._moved_from_events[destination_event.cookie].src_path + else: + return None + + def remember_move_from_event(self, event): + """ + Save this event as the source event for future MOVED_TO events to + reference. + """ + self._moved_from_events[event.cookie] = event + + def add_watch(self, path): + """ + Adds a watch for the given path. + + :param path: + Path to begin monitoring. + """ + with self._lock: + self._add_watch(path, self._event_mask) + + def remove_watch(self, path): + """ + Removes a watch for the given path. + + :param path: + Path string for which the watch will be removed. + """ + with self._lock: + wd = self._remove_watch_bookkeeping(path) + if inotify_rm_watch(self._inotify_fd, wd) == -1: + Inotify._raise_error() + + def close(self): + """ + Closes the inotify instance and removes all associated watches. + """ + with self._lock: + wd = self._wd_for_path[self._path] + inotify_rm_watch(self._inotify_fd, wd) + os.close(self._inotify_fd) + + def read_events(self, event_buffer_size=DEFAULT_EVENT_BUFFER_SIZE): + """ + Reads events from inotify and yields them. + """ + # HACK: We need to traverse the directory path + # recursively and simulate events for newly + # created subdirectories/files. This will handle + # mkdir -p foobar/blah/bar; touch foobar/afile + + def _recursive_simulate(src_path): + events = [] + for root, dirnames, filenames in os.walk(src_path): + for dirname in dirnames: + try: + full_path = os.path.join(root, dirname) + wd_dir = self._add_watch(full_path, self._event_mask) + e = InotifyEvent( + wd_dir, InotifyConstants.IN_CREATE | InotifyConstants.IN_ISDIR, 0, dirname, full_path) + events.append(e) + except OSError: + pass + for filename in filenames: + full_path = os.path.join(root, filename) + wd_parent_dir = self._wd_for_path[os.path.dirname(full_path)] + e = InotifyEvent( + wd_parent_dir, InotifyConstants.IN_CREATE, 0, filename, full_path) + events.append(e) + return events + + event_buffer = None + while True: + try: + event_buffer = os.read(self._inotify_fd, event_buffer_size) + except OSError as e: + if e.errno == errno.EINTR: + continue + break + + with self._lock: + event_list = [] + for wd, mask, cookie, name in Inotify._parse_event_buffer(event_buffer): + if wd == -1: + continue + wd_path = self._path_for_wd[wd] + src_path = os.path.join(wd_path, name) if name else wd_path #avoid trailing slash + inotify_event = InotifyEvent(wd, mask, cookie, name, src_path) + + if inotify_event.is_moved_from: + self.remember_move_from_event(inotify_event) + elif inotify_event.is_moved_to: + move_src_path = self.source_for_move(inotify_event) + if move_src_path in self._wd_for_path: + moved_wd = self._wd_for_path[move_src_path] + del self._wd_for_path[move_src_path] + self._wd_for_path[inotify_event.src_path] = moved_wd + self._path_for_wd[moved_wd] = inotify_event.src_path + src_path = os.path.join(wd_path, name) + inotify_event = InotifyEvent(wd, mask, cookie, name, src_path) + + if inotify_event.is_ignored: + # Clean up book-keeping for deleted watches. + self._remove_watch_bookkeeping(src_path) + continue + + event_list.append(inotify_event) + + if (self.is_recursive and inotify_event.is_directory and + inotify_event.is_create): + + # TODO: When a directory from another part of the + # filesystem is moved into a watched directory, this + # will not generate events for the directory tree. + # We need to coalesce IN_MOVED_TO events and those + # IN_MOVED_TO events which don't pair up with + # IN_MOVED_FROM events should be marked IN_CREATE + # instead relative to this directory. + try: + self._add_watch(src_path, self._event_mask) + except OSError: + continue + + event_list.extend(_recursive_simulate(src_path)) + + return event_list + + # Non-synchronized methods. + def _add_dir_watch(self, path, recursive, mask): + """ + Adds a watch (optionally recursively) for the given directory path + to monitor events specified by the mask. + + :param path: + Path to monitor + :param recursive: + ``True`` to monitor recursively. + :param mask: + Event bit mask. + """ + if not os.path.isdir(path): + raise OSError('Path is not a directory') + self._add_watch(path, mask) + if recursive: + for root, dirnames, _ in os.walk(path): + for dirname in dirnames: + full_path = os.path.join(root, dirname) + if os.path.islink(full_path): + continue + self._add_watch(full_path, mask) + + def _add_watch(self, path, mask): + """ + Adds a watch for the given path to monitor events specified by the + mask. + + :param path: + Path to monitor + :param mask: + Event bit mask. + """ + wd = inotify_add_watch(self._inotify_fd, path, mask) + if wd == -1: + Inotify._raise_error() + self._wd_for_path[path] = wd + self._path_for_wd[wd] = path + return wd + + def _remove_watch_bookkeeping(self, path): + wd = self._wd_for_path.pop(path) + del self._path_for_wd[wd] + return wd + + @staticmethod + def _raise_error(): + """ + Raises errors for inotify failures. + """ + err = ctypes.get_errno() + if err == errno.ENOSPC: + raise OSError("inotify watch limit reached") + elif err == errno.EMFILE: + raise OSError("inotify instance limit reached") + else: + raise OSError(os.strerror(err)) + + @staticmethod + def _parse_event_buffer(event_buffer): + """ + Parses an event buffer of ``inotify_event`` structs returned by + inotify:: + + struct inotify_event { + __s32 wd; /* watch descriptor */ + __u32 mask; /* watch mask */ + __u32 cookie; /* cookie to synchronize two events */ + __u32 len; /* length (including nulls) of name */ + char name[0]; /* stub for possible name */ + }; + + The ``cookie`` member of this struct is used to pair two related + events, for example, it pairs an IN_MOVED_FROM event with an + IN_MOVED_TO event. + """ + i = 0 + while i + 16 <= len(event_buffer): + wd, mask, cookie, length = struct.unpack_from('iIII', event_buffer, i) + name = event_buffer[i + 16:i + 16 + length].rstrip(b'\0') + i += 16 + length + yield wd, mask, cookie, name + + +class InotifyEvent(object): + """ + Inotify event struct wrapper. + + :param wd: + Watch descriptor + :param mask: + Event mask + :param cookie: + Event cookie + :param name: + Event name. + :param src_path: + Event source path + """ + + def __init__(self, wd, mask, cookie, name, src_path): + self._wd = wd + self._mask = mask + self._cookie = cookie + self._name = name + self._src_path = src_path + + @property + def src_path(self): + return self._src_path + + @property + def wd(self): + return self._wd + + @property + def mask(self): + return self._mask + + @property + def cookie(self): + return self._cookie + + @property + def name(self): + return self._name + + @property + def is_modify(self): + return self._mask & InotifyConstants.IN_MODIFY > 0 + + @property + def is_close_write(self): + return self._mask & InotifyConstants.IN_CLOSE_WRITE > 0 + + @property + def is_close_nowrite(self): + return self._mask & InotifyConstants.IN_CLOSE_NOWRITE > 0 + + @property + def is_access(self): + return self._mask & InotifyConstants.IN_ACCESS > 0 + + @property + def is_delete(self): + return self._mask & InotifyConstants.IN_DELETE > 0 + + @property + def is_delete_self(self): + return self._mask & InotifyConstants.IN_DELETE_SELF > 0 + + @property + def is_create(self): + return self._mask & InotifyConstants.IN_CREATE > 0 + + @property + def is_moved_from(self): + return self._mask & InotifyConstants.IN_MOVED_FROM > 0 + + @property + def is_moved_to(self): + return self._mask & InotifyConstants.IN_MOVED_TO > 0 + + @property + def is_move(self): + return self._mask & InotifyConstants.IN_MOVE > 0 + + @property + def is_move_self(self): + return self._mask & InotifyConstants.IN_MOVE_SELF > 0 + + @property + def is_attrib(self): + return self._mask & InotifyConstants.IN_ATTRIB > 0 + + @property + def is_ignored(self): + return self._mask & InotifyConstants.IN_IGNORED > 0 + + @property + def is_directory(self): + # It looks like the kernel does not provide this information for + # IN_DELETE_SELF and IN_MOVE_SELF. In this case, assume it's a dir. + # See also: https://github.com/seb-m/pyinotify/blob/2c7e8f8/python2/pyinotify.py#L897 + return (self.is_delete_self or self.is_move_self or + self._mask & InotifyConstants.IN_ISDIR > 0) + + @property + def key(self): + return self._src_path, self._wd, self._mask, self._cookie, self._name + + def __eq__(self, inotify_event): + return self.key == inotify_event.key + + def __ne__(self, inotify_event): + return self.key == inotify_event.key + + def __hash__(self): + return hash(self.key) + + @staticmethod + def _get_mask_string(mask): + masks = [] + for c in dir(InotifyConstants): + if c.startswith('IN_') and c not in ['IN_ALL_EVENTS', 'IN_CLOSE', 'IN_MOVE']: + c_val = getattr(InotifyConstants, c) + if mask & c_val: + masks.append(c) + mask_string = '|'.join(masks) + return mask_string + + def __repr__(self): + mask_string = self._get_mask_string(self.mask) + s = "" + return s % (self.src_path, self.wd, mask_string, self.cookie, self.name) diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/observers/kqueue.py b/script.service.kodi.callbacks/resources/lib/watchdog/observers/kqueue.py new file mode 100644 index 0000000000..9ace923213 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/observers/kqueue.py @@ -0,0 +1,726 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +:module: watchdog.observers.kqueue +:synopsis: ``kqueue(2)`` based emitter implementation. +:author: yesudeep@google.com (Yesudeep Mangalapilly) +:platforms: Mac OS X and BSD with kqueue(2). + +.. WARNING:: kqueue is a very heavyweight way to monitor file systems. + Each kqueue-detected directory modification triggers + a full directory scan. Traversing the entire directory tree + and opening file descriptors for all files will create + performance problems. We need to find a way to re-scan + only those directories which report changes and do a diff + between two sub-DirectorySnapshots perhaps. + +.. ADMONITION:: About ``select.kqueue`` and Python versions + + * Python 2.5 does not ship with ``select.kqueue`` + * Python 2.6 ships with a broken ``select.kqueue`` that cannot take + multiple events in the event list passed to ``kqueue.control``. + * Python 2.7 ships with a working ``select.kqueue`` + implementation. + + I have backported the Python 2.7 implementation to Python 2.5 and 2.6 + in the ``select_backport`` package available on PyPI. + +.. ADMONITION:: About OS X performance guidelines + + Quote from the `Mac OS X File System Performance Guidelines`_: + + "When you only want to track changes on a file or directory, be sure to + open it using the ``O_EVTONLY`` flag. This flag prevents the file or + directory from being marked as open or in use. This is important + if you are tracking files on a removable volume and the user tries to + unmount the volume. With this flag in place, the system knows it can + dismiss the volume. If you had opened the files or directories without + this flag, the volume would be marked as busy and would not be + unmounted." + + ``O_EVTONLY`` is defined as ``0x8000`` in the OS X header files. + More information here: http://www.mlsite.net/blog/?p=2312 + +Classes +------- +.. autoclass:: KqueueEmitter + :members: + :show-inheritance: + +Collections and Utility Classes +------------------------------- +.. autoclass:: KeventDescriptor + :members: + :show-inheritance: + +.. autoclass:: KeventDescriptorSet + :members: + :show-inheritance: + +.. _Mac OS X File System Performance Guidelines: http://developer.apple.com/library/ios/#documentation/Performance/Conceptual/FileSystem/Articles/TrackingChanges.html#//apple_ref/doc/uid/20001993-CJBJFIDD + +""" + +from __future__ import with_statement +from watchdog.utils import platform + +import threading +import errno +import sys +import stat +import os + +# See the notes for this module in the documentation above ^. +#import select +# if not has_attribute(select, 'kqueue') or sys.version_info < (2, 7, 0): +if sys.version_info < (2, 7, 0): + import select_backport as select +else: + import select + +from pathtools.path import absolute_path + +from watchdog.observers.api import ( + BaseObserver, + EventEmitter, + DEFAULT_OBSERVER_TIMEOUT, + DEFAULT_EMITTER_TIMEOUT +) + +from watchdog.utils.dirsnapshot import DirectorySnapshot + +from watchdog.events import ( + DirMovedEvent, + DirDeletedEvent, + DirCreatedEvent, + DirModifiedEvent, + FileMovedEvent, + FileDeletedEvent, + FileCreatedEvent, + FileModifiedEvent, + EVENT_TYPE_MOVED, + EVENT_TYPE_DELETED, + EVENT_TYPE_CREATED +) + +# Maximum number of events to process. +MAX_EVENTS = 4096 + +# O_EVTONLY value from the header files for OS X only. +O_EVTONLY = 0x8000 + +# Pre-calculated values for the kevent filter, flags, and fflags attributes. +if platform.is_darwin(): + WATCHDOG_OS_OPEN_FLAGS = O_EVTONLY +else: + WATCHDOG_OS_OPEN_FLAGS = os.O_RDONLY | os.O_NONBLOCK +WATCHDOG_KQ_FILTER = select.KQ_FILTER_VNODE +WATCHDOG_KQ_EV_FLAGS = select.KQ_EV_ADD | select.KQ_EV_ENABLE | select.KQ_EV_CLEAR +WATCHDOG_KQ_FFLAGS = ( + select.KQ_NOTE_DELETE | + select.KQ_NOTE_WRITE | + select.KQ_NOTE_EXTEND | + select.KQ_NOTE_ATTRIB | + select.KQ_NOTE_LINK | + select.KQ_NOTE_RENAME | + select.KQ_NOTE_REVOKE +) + +# Flag tests. + + +def is_deleted(kev): + """Determines whether the given kevent represents deletion.""" + return kev.fflags & select.KQ_NOTE_DELETE + + +def is_modified(kev): + """Determines whether the given kevent represents modification.""" + fflags = kev.fflags + return (fflags & select.KQ_NOTE_EXTEND) or (fflags & select.KQ_NOTE_WRITE) + + +def is_attrib_modified(kev): + """Determines whether the given kevent represents attribute modification.""" + return kev.fflags & select.KQ_NOTE_ATTRIB + + +def is_renamed(kev): + """Determines whether the given kevent represents movement.""" + return kev.fflags & select.KQ_NOTE_RENAME + + +class KeventDescriptorSet(object): + + """ + Thread-safe kevent descriptor collection. + """ + + def __init__(self): + # Set of KeventDescriptor + self._descriptors = set() + + # Descriptor for a given path. + self._descriptor_for_path = dict() + + # Descriptor for a given fd. + self._descriptor_for_fd = dict() + + # List of kevent objects. + self._kevents = list() + + self._lock = threading.Lock() + + @property + def kevents(self): + """ + List of kevents monitored. + """ + with self._lock: + return self._kevents + + @property + def paths(self): + """ + List of paths for which kevents have been created. + """ + with self._lock: + return list(self._descriptor_for_path.keys()) + + def get_for_fd(self, fd): + """ + Given a file descriptor, returns the kevent descriptor object + for it. + + :param fd: + OS file descriptor. + :type fd: + ``int`` + :returns: + A :class:`KeventDescriptor` object. + """ + with self._lock: + return self._descriptor_for_fd[fd] + + def get(self, path): + """ + Obtains a :class:`KeventDescriptor` object for the specified path. + + :param path: + Path for which the descriptor will be obtained. + """ + with self._lock: + path = absolute_path(path) + return self._get(path) + + def __contains__(self, path): + """ + Determines whether a :class:`KeventDescriptor has been registered + for the specified path. + + :param path: + Path for which the descriptor will be obtained. + """ + with self._lock: + path = absolute_path(path) + return self._has_path(path) + + def add(self, path, is_directory): + """ + Adds a :class:`KeventDescriptor` to the collection for the given + path. + + :param path: + The path for which a :class:`KeventDescriptor` object will be + added. + :param is_directory: + ``True`` if the path refers to a directory; ``False`` otherwise. + :type is_directory: + ``bool`` + """ + with self._lock: + path = absolute_path(path) + if not self._has_path(path): + self._add_descriptor(KeventDescriptor(path, is_directory)) + + def remove(self, path): + """ + Removes the :class:`KeventDescriptor` object for the given path + if it already exists. + + :param path: + Path for which the :class:`KeventDescriptor` object will be + removed. + """ + with self._lock: + path = absolute_path(path) + if self._has_path(path): + self._remove_descriptor(self._get(path)) + + def clear(self): + """ + Clears the collection and closes all open descriptors. + """ + with self._lock: + for descriptor in self._descriptors: + descriptor.close() + self._descriptors.clear() + self._descriptor_for_fd.clear() + self._descriptor_for_path.clear() + self._kevents = [] + + # Thread-unsafe methods. Locking is provided at a higher level. + def _get(self, path): + """Returns a kevent descriptor for a given path.""" + return self._descriptor_for_path[path] + + def _has_path(self, path): + """Determines whether a :class:`KeventDescriptor` for the specified + path exists already in the collection.""" + return path in self._descriptor_for_path + + def _add_descriptor(self, descriptor): + """ + Adds a descriptor to the collection. + + :param descriptor: + An instance of :class:`KeventDescriptor` to be added. + """ + self._descriptors.add(descriptor) + self._kevents.append(descriptor.kevent) + self._descriptor_for_path[descriptor.path] = descriptor + self._descriptor_for_fd[descriptor.fd] = descriptor + + def _remove_descriptor(self, descriptor): + """ + Removes a descriptor from the collection. + + :param descriptor: + An instance of :class:`KeventDescriptor` to be removed. + """ + self._descriptors.remove(descriptor) + del self._descriptor_for_fd[descriptor.fd] + del self._descriptor_for_path[descriptor.path] + self._kevents.remove(descriptor.kevent) + descriptor.close() + + +class KeventDescriptor(object): + + """ + A kevent descriptor convenience data structure to keep together: + + * kevent + * directory status + * path + * file descriptor + + :param path: + Path string for which a kevent descriptor will be created. + :param is_directory: + ``True`` if the path refers to a directory; ``False`` otherwise. + :type is_directory: + ``bool`` + """ + + def __init__(self, path, is_directory): + self._path = absolute_path(path) + self._is_directory = is_directory + self._fd = os.open(path, WATCHDOG_OS_OPEN_FLAGS) + self._kev = select.kevent(self._fd, + filter=WATCHDOG_KQ_FILTER, + flags=WATCHDOG_KQ_EV_FLAGS, + fflags=WATCHDOG_KQ_FFLAGS) + + @property + def fd(self): + """OS file descriptor for the kevent descriptor.""" + return self._fd + + @property + def path(self): + """The path associated with the kevent descriptor.""" + return self._path + + @property + def kevent(self): + """The kevent object associated with the kevent descriptor.""" + return self._kev + + @property + def is_directory(self): + """Determines whether the kevent descriptor refers to a directory. + + :returns: + ``True`` or ``False`` + """ + return self._is_directory + + def close(self): + """ + Closes the file descriptor associated with a kevent descriptor. + """ + try: + os.close(self.fd) + except OSError: + pass + + @property + def key(self): + return (self.path, self.is_directory) + + def __eq__(self, descriptor): + return self.key == descriptor.key + + def __ne__(self, descriptor): + return self.key != descriptor.key + + def __hash__(self): + return hash(self.key) + + def __repr__(self): + return ""\ + % (self.path, self.is_directory) + + +class KqueueEmitter(EventEmitter): + + """ + kqueue(2)-based event emitter. + + .. ADMONITION:: About ``kqueue(2)`` behavior and this implementation + + ``kqueue(2)`` monitors file system events only for + open descriptors, which means, this emitter does a lot of + book-keeping behind the scenes to keep track of open + descriptors for every entry in the monitored directory tree. + + This also means the number of maximum open file descriptors + on your system must be increased **manually**. + Usually, issuing a call to ``ulimit`` should suffice:: + + ulimit -n 1024 + + Ensure that you pick a number that is larger than the + number of files you expect to be monitored. + + ``kqueue(2)`` does not provide enough information about the + following things: + + * The destination path of a file or directory that is renamed. + * Creation of a file or directory within a directory; in this + case, ``kqueue(2)`` only indicates a modified event on the + parent directory. + + Therefore, this emitter takes a snapshot of the directory + tree when ``kqueue(2)`` detects a change on the file system + to be able to determine the above information. + + :param event_queue: + The event queue to fill with events. + :param watch: + A watch object representing the directory to monitor. + :type watch: + :class:`watchdog.observers.api.ObservedWatch` + :param timeout: + Read events blocking timeout (in seconds). + :type timeout: + ``float`` + """ + + def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT): + EventEmitter.__init__(self, event_queue, watch, timeout) + + self._kq = select.kqueue() + self._lock = threading.RLock() + + # A collection of KeventDescriptor. + self._descriptors = KeventDescriptorSet() + + def walker_callback(path, stat_info, self=self): + self._register_kevent(path, stat.S_ISDIR(stat_info.st_mode)) + + self._snapshot = DirectorySnapshot(watch.path, + watch.is_recursive, + walker_callback) + + def _register_kevent(self, path, is_directory): + """ + Registers a kevent descriptor for the given path. + + :param path: + Path for which a kevent descriptor will be created. + :param is_directory: + ``True`` if the path refers to a directory; ``False`` otherwise. + :type is_directory: + ``bool`` + """ + try: + self._descriptors.add(path, is_directory) + except OSError as e: + if e.errno == errno.ENOENT: + # Probably dealing with a temporary file that was created + # and then quickly deleted before we could open + # a descriptor for it. Therefore, simply queue a sequence + # of created and deleted events for the path. + #path = absolute_path(path) + # if is_directory: + # self.queue_event(DirCreatedEvent(path)) + # self.queue_event(DirDeletedEvent(path)) + # else: + # self.queue_event(FileCreatedEvent(path)) + # self.queue_event(FileDeletedEvent(path)) + + # TODO: We could simply ignore these files. + # Locked files cause the python process to die with + # a bus error when we handle temporary files. + # eg. .git/index.lock when running tig operations. + # I don't fully understand this at the moment. + pass + else: + # All other errors are propagated. + raise + + def _unregister_kevent(self, path): + """ + Convenience function to close the kevent descriptor for a + specified kqueue-monitored path. + + :param path: + Path for which the kevent descriptor will be closed. + """ + self._descriptors.remove(path) + + def queue_event(self, event): + """ + Handles queueing a single event object. + + :param event: + An instance of :class:`watchdog.events.FileSystemEvent` + or a subclass. + """ + # Handles all the book keeping for queued events. + # We do not need to fire moved/deleted events for all subitems in + # a directory tree here, because this function is called by kqueue + # for all those events anyway. + EventEmitter.queue_event(self, event) + if event.event_type == EVENT_TYPE_CREATED: + self._register_kevent(event.src_path, event.is_directory) + elif event.event_type == EVENT_TYPE_MOVED: + self._unregister_kevent(event.src_path) + self._register_kevent(event.dest_path, event.is_directory) + elif event.event_type == EVENT_TYPE_DELETED: + self._unregister_kevent(event.src_path) + + def _queue_dirs_modified(self, + dirs_modified, + ref_snapshot, + new_snapshot): + """ + Queues events for directory modifications by scanning the directory + for changes. + + A scan is a comparison between two snapshots of the same directory + taken at two different times. This also determines whether files + or directories were created, which updated the modified timestamp + for the directory. + """ + if dirs_modified: + for dir_modified in dirs_modified: + self.queue_event(DirModifiedEvent(dir_modified)) + diff_events = new_snapshot - ref_snapshot + for file_created in diff_events.files_created: + self.queue_event(FileCreatedEvent(file_created)) + for directory_created in diff_events.dirs_created: + self.queue_event(DirCreatedEvent(directory_created)) + + def _queue_events_except_renames_and_dir_modifications(self, event_list): + """ + Queues events from the kevent list returned from the call to + :meth:`select.kqueue.control`. + + .. NOTE:: Queues only the deletions, file modifications, + attribute modifications. The other events, namely, + file creation, directory modification, file rename, + directory rename, directory creation, etc. are + determined by comparing directory snapshots. + """ + files_renamed = set() + dirs_renamed = set() + dirs_modified = set() + + for kev in event_list: + descriptor = self._descriptors.get_for_fd(kev.ident) + src_path = descriptor.path + + if is_deleted(kev): + if descriptor.is_directory: + self.queue_event(DirDeletedEvent(src_path)) + else: + self.queue_event(FileDeletedEvent(src_path)) + elif is_attrib_modified(kev): + if descriptor.is_directory: + self.queue_event(DirModifiedEvent(src_path)) + else: + self.queue_event(FileModifiedEvent(src_path)) + elif is_modified(kev): + if descriptor.is_directory: + # When a directory is modified, it may be due to + # sub-file/directory renames or new file/directory + # creation. We determine all this by comparing + # snapshots later. + dirs_modified.add(src_path) + else: + self.queue_event(FileModifiedEvent(src_path)) + elif is_renamed(kev): + # Kqueue does not specify the destination names for renames + # to, so we have to process these after taking a snapshot + # of the directory. + if descriptor.is_directory: + dirs_renamed.add(src_path) + else: + files_renamed.add(src_path) + return files_renamed, dirs_renamed, dirs_modified + + def _queue_renamed(self, + src_path, + is_directory, + ref_snapshot, + new_snapshot): + """ + Compares information from two directory snapshots (one taken before + the rename operation and another taken right after) to determine the + destination path of the file system object renamed, and adds + appropriate events to the event queue. + """ + try: + ref_stat_info = ref_snapshot.stat_info(src_path) + except KeyError: + # Probably caught a temporary file/directory that was renamed + # and deleted. Fires a sequence of created and deleted events + # for the path. + if is_directory: + self.queue_event(DirCreatedEvent(src_path)) + self.queue_event(DirDeletedEvent(src_path)) + else: + self.queue_event(FileCreatedEvent(src_path)) + self.queue_event(FileDeletedEvent(src_path)) + # We don't process any further and bail out assuming + # the event represents deletion/creation instead of movement. + return + + try: + dest_path = absolute_path( + new_snapshot.path_for_inode(ref_stat_info.st_ino)) + if is_directory: + event = DirMovedEvent(src_path, dest_path) + # TODO: Do we need to fire moved events for the items + # inside the directory tree? Does kqueue does this + # all by itself? Check this and then enable this code + # only if it doesn't already. + # A: It doesn't. So I've enabled this block. + if self.watch.is_recursive: + for sub_event in event.sub_moved_events(): + self.queue_event(sub_event) + self.queue_event(event) + else: + self.queue_event(FileMovedEvent(src_path, dest_path)) + except KeyError: + # If the new snapshot does not have an inode for the + # old path, we haven't found the new name. Therefore, + # we mark it as deleted and remove unregister the path. + if is_directory: + self.queue_event(DirDeletedEvent(src_path)) + else: + self.queue_event(FileDeletedEvent(src_path)) + + def _read_events(self, timeout=None): + """ + Reads events from a call to the blocking + :meth:`select.kqueue.control()` method. + + :param timeout: + Blocking timeout for reading events. + :type timeout: + ``float`` (seconds) + """ + return self._kq.control(self._descriptors.kevents, + MAX_EVENTS, + timeout) + + def queue_events(self, timeout): + """ + Queues events by reading them from a call to the blocking + :meth:`select.kqueue.control()` method. + + :param timeout: + Blocking timeout for reading events. + :type timeout: + ``float`` (seconds) + """ + with self._lock: + try: + event_list = self._read_events(timeout) + files_renamed, dirs_renamed, dirs_modified = ( + self._queue_events_except_renames_and_dir_modifications(event_list)) + + # Take a fresh snapshot of the directory and update the + # saved snapshot. + new_snapshot = DirectorySnapshot(self.watch.path, + self.watch.is_recursive) + ref_snapshot = self._snapshot + self._snapshot = new_snapshot + + if files_renamed or dirs_renamed or dirs_modified: + for src_path in files_renamed: + self._queue_renamed(src_path, + False, + ref_snapshot, + new_snapshot) + for src_path in dirs_renamed: + self._queue_renamed(src_path, + True, + ref_snapshot, + new_snapshot) + self._queue_dirs_modified(dirs_modified, + ref_snapshot, + new_snapshot) + except OSError as e: + if e.errno == errno.EBADF: + # logging.debug(e) + pass + else: + raise + + def on_thread_stop(self): + # Clean up. + with self._lock: + self._descriptors.clear() + self._kq.close() + + +class KqueueObserver(BaseObserver): + + """ + Observer thread that schedules watching directories and dispatches + calls to event handlers. + """ + + def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT): + BaseObserver.__init__(self, emitter_class=KqueueEmitter, timeout=timeout) diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/observers/polling.py b/script.service.kodi.callbacks/resources/lib/watchdog/observers/polling.py new file mode 100644 index 0000000000..153d874d74 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/observers/polling.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +:module: watchdog.observers.polling +:synopsis: Polling emitter implementation. +:author: yesudeep@google.com (Yesudeep Mangalapilly) + +Classes +------- +.. autoclass:: PollingObserver + :members: + :show-inheritance: + +.. autoclass:: PollingObserverVFS + :members: + :show-inheritance: + :special-members: +""" + +from __future__ import with_statement +import os +import threading +from functools import partial +from watchdog.utils import stat as default_stat +from watchdog.utils.dirsnapshot import DirectorySnapshot, DirectorySnapshotDiff +from watchdog.observers.api import ( + EventEmitter, + BaseObserver, + DEFAULT_OBSERVER_TIMEOUT, + DEFAULT_EMITTER_TIMEOUT +) + +from watchdog.events import ( + DirMovedEvent, + DirDeletedEvent, + DirCreatedEvent, + DirModifiedEvent, + FileMovedEvent, + FileDeletedEvent, + FileCreatedEvent, + FileModifiedEvent +) + + +class PollingEmitter(EventEmitter): + """ + Platform-independent emitter that polls a directory to detect file + system changes. + """ + + def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT, + stat=default_stat, listdir=os.listdir): + EventEmitter.__init__(self, event_queue, watch, timeout) + self._snapshot = None + self._lock = threading.Lock() + self._take_snapshot = lambda: DirectorySnapshot( + self.watch.path, self.watch.is_recursive, stat=stat, listdir=listdir) + + def on_thread_start(self): + self._snapshot = self._take_snapshot() + + def queue_events(self, timeout): + # We don't want to hit the disk continuously. + # timeout behaves like an interval for polling emitters. + if self.stopped_event.wait(timeout): + return + + with self._lock: + if not self.should_keep_running(): + return + + # Get event diff between fresh snapshot and previous snapshot. + # Update snapshot. + new_snapshot = self._take_snapshot() + events = DirectorySnapshotDiff(self._snapshot, new_snapshot) + self._snapshot = new_snapshot + + # Files. + for src_path in events.files_deleted: + self.queue_event(FileDeletedEvent(src_path)) + for src_path in events.files_modified: + self.queue_event(FileModifiedEvent(src_path)) + for src_path in events.files_created: + self.queue_event(FileCreatedEvent(src_path)) + for src_path, dest_path in events.files_moved: + self.queue_event(FileMovedEvent(src_path, dest_path)) + + # Directories. + for src_path in events.dirs_deleted: + self.queue_event(DirDeletedEvent(src_path)) + for src_path in events.dirs_modified: + self.queue_event(DirModifiedEvent(src_path)) + for src_path in events.dirs_created: + self.queue_event(DirCreatedEvent(src_path)) + for src_path, dest_path in events.dirs_moved: + self.queue_event(DirMovedEvent(src_path, dest_path)) + + +class PollingObserver(BaseObserver): + """ + Platform-independent observer that polls a directory to detect file + system changes. + """ + + def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT): + BaseObserver.__init__(self, emitter_class=PollingEmitter, timeout=timeout) + + +class PollingObserverVFS(BaseObserver): + """ + File system independent observer that polls a directory to detect changes. + """ + + def __init__(self, stat, listdir, polling_interval=1): + """ + :param stat: stat function. See ``os.stat`` for details. + :param listdir: listdir function. See ``os.listdir`` for details. + :type polling_interval: float + :param polling_interval: interval in seconds between polling the file system. + """ + emitter_cls = partial(PollingEmitter, stat=stat, listdir=listdir) + BaseObserver.__init__(self, emitter_class=emitter_cls, timeout=polling_interval) diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/observers/read_directory_changes.py b/script.service.kodi.callbacks/resources/lib/watchdog/observers/read_directory_changes.py new file mode 100644 index 0000000000..623a23563d --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/observers/read_directory_changes.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# Copyright 2014 Thomas Amland +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import with_statement + +import ctypes +import threading +import os.path +import time + +from watchdog.events import ( + DirCreatedEvent, + DirDeletedEvent, + DirMovedEvent, + DirModifiedEvent, + FileCreatedEvent, + FileDeletedEvent, + FileMovedEvent, + FileModifiedEvent, + generate_sub_moved_events, + generate_sub_created_events, +) + +from watchdog.observers.api import ( + EventEmitter, + BaseObserver, + DEFAULT_OBSERVER_TIMEOUT, + DEFAULT_EMITTER_TIMEOUT +) + +from watchdog.observers.winapi import ( + read_events, + get_directory_handle, + close_directory_handle, +) + + +# HACK: +WATCHDOG_TRAVERSE_MOVED_DIR_DELAY = 1 # seconds + + +class WindowsApiEmitter(EventEmitter): + """ + Windows API-based emitter that uses ReadDirectoryChangesW + to detect file system changes for a watch. + """ + + def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT): + EventEmitter.__init__(self, event_queue, watch, timeout) + self._lock = threading.Lock() + self._handle = None + + def on_thread_start(self): + self._handle = get_directory_handle(self.watch.path) + + def on_thread_stop(self): + if self._handle: + close_directory_handle(self._handle) + + def queue_events(self, timeout): + winapi_events = read_events(self._handle, self.watch.is_recursive) + with self._lock: + last_renamed_src_path = "" + for winapi_event in winapi_events: + src_path = os.path.join(self.watch.path, winapi_event.src_path) + + if winapi_event.is_renamed_old: + last_renamed_src_path = src_path + elif winapi_event.is_renamed_new: + dest_path = src_path + src_path = last_renamed_src_path + if os.path.isdir(dest_path): + event = DirMovedEvent(src_path, dest_path) + if self.watch.is_recursive: + # HACK: We introduce a forced delay before + # traversing the moved directory. This will read + # only file movement that finishes within this + # delay time. + time.sleep(WATCHDOG_TRAVERSE_MOVED_DIR_DELAY) + # The following block of code may not + # obtain moved events for the entire tree if + # the I/O is not completed within the above + # delay time. So, it's not guaranteed to work. + # TODO: Come up with a better solution, possibly + # a way to wait for I/O to complete before + # queuing events. + for sub_moved_event in generate_sub_moved_events(src_path, dest_path): + self.queue_event(sub_moved_event) + self.queue_event(event) + else: + self.queue_event(FileMovedEvent(src_path, dest_path)) + elif winapi_event.is_modified: + cls = DirModifiedEvent if os.path.isdir(src_path) else FileModifiedEvent + self.queue_event(cls(src_path)) + elif winapi_event.is_added: + isdir = os.path.isdir(src_path) + cls = DirCreatedEvent if isdir else FileCreatedEvent + self.queue_event(cls(src_path)) + if isdir: + # If a directory is moved from outside the watched folder to inside it + # we only get a created directory event out of it, not any events for its children + # so use the same hack as for file moves to get the child events + time.sleep(WATCHDOG_TRAVERSE_MOVED_DIR_DELAY) + sub_events = generate_sub_created_events(src_path) + for sub_created_event in sub_events: + self.queue_event(sub_created_event) + elif winapi_event.is_removed: + self.queue_event(FileDeletedEvent(src_path)) + + +class WindowsApiObserver(BaseObserver): + """ + Observer thread that schedules watching directories and dispatches + calls to event handlers. + """ + + def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT): + BaseObserver.__init__(self, emitter_class=WindowsApiEmitter, + timeout=timeout) diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/observers/winapi.py b/script.service.kodi.callbacks/resources/lib/watchdog/observers/winapi.py new file mode 100644 index 0000000000..eb5f372995 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/observers/winapi.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# winapi.py: Windows API-Python interface (removes dependency on pywin32) +# +# Copyright (C) 2007 Thomas Heller +# Copyright (C) 2010 Will McGugan +# Copyright (C) 2010 Ryan Kelly +# Copyright (C) 2010 Yesudeep Mangalapilly +# Copyright (C) 2014 Thomas Amland +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and / or other materials provided with the distribution. +# * Neither the name of the organization nor the names of its contributors may +# be used to endorse or promote products derived from this software without +# specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +# Portions of this code were taken from pyfilesystem, which uses the above +# new BSD license. + +from __future__ import with_statement + +import ctypes.wintypes +import struct +from functools import reduce + +try: + LPVOID = ctypes.wintypes.LPVOID +except AttributeError: + # LPVOID wasn't defined in Py2.5, guess it was introduced in Py2.6 + LPVOID = ctypes.c_void_p + +# Invalid handle value. +INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value + +# File notification contants. +FILE_NOTIFY_CHANGE_FILE_NAME = 0x01 +FILE_NOTIFY_CHANGE_DIR_NAME = 0x02 +FILE_NOTIFY_CHANGE_ATTRIBUTES = 0x04 +FILE_NOTIFY_CHANGE_SIZE = 0x08 +FILE_NOTIFY_CHANGE_LAST_WRITE = 0x010 +FILE_NOTIFY_CHANGE_LAST_ACCESS = 0x020 +FILE_NOTIFY_CHANGE_CREATION = 0x040 +FILE_NOTIFY_CHANGE_SECURITY = 0x0100 + +FILE_FLAG_BACKUP_SEMANTICS = 0x02000000 +FILE_FLAG_OVERLAPPED = 0x40000000 +FILE_LIST_DIRECTORY = 0x01 +FILE_SHARE_READ = 0x01 +FILE_SHARE_WRITE = 0x02 +FILE_SHARE_DELETE = 0x04 +OPEN_EXISTING = 3 + +# File action constants. +FILE_ACTION_CREATED = 1 +FILE_ACTION_DELETED = 2 +FILE_ACTION_MODIFIED = 3 +FILE_ACTION_RENAMED_OLD_NAME = 4 +FILE_ACTION_RENAMED_NEW_NAME = 5 +FILE_ACTION_OVERFLOW = 0xFFFF + +# Aliases +FILE_ACTION_ADDED = FILE_ACTION_CREATED +FILE_ACTION_REMOVED = FILE_ACTION_DELETED + +THREAD_TERMINATE = 0x0001 + +# IO waiting constants. +WAIT_ABANDONED = 0x00000080 +WAIT_IO_COMPLETION = 0x000000C0 +WAIT_OBJECT_0 = 0x00000000 +WAIT_TIMEOUT = 0x00000102 + +# Error codes +ERROR_OPERATION_ABORTED = 995 + + +class OVERLAPPED(ctypes.Structure): + _fields_ = [('Internal', LPVOID), + ('InternalHigh', LPVOID), + ('Offset', ctypes.wintypes.DWORD), + ('OffsetHigh', ctypes.wintypes.DWORD), + ('Pointer', LPVOID), + ('hEvent', ctypes.wintypes.HANDLE), + ] + + +def _errcheck_bool(value, func, args): + if not value: + raise ctypes.WinError() + return args + + +def _errcheck_handle(value, func, args): + if not value: + raise ctypes.WinError() + if value == INVALID_HANDLE_VALUE: + raise ctypes.WinError() + return args + + +def _errcheck_dword(value, func, args): + if value == 0xFFFFFFFF: + raise ctypes.WinError() + return args + + +ReadDirectoryChangesW = ctypes.windll.kernel32.ReadDirectoryChangesW +ReadDirectoryChangesW.restype = ctypes.wintypes.BOOL +ReadDirectoryChangesW.errcheck = _errcheck_bool +ReadDirectoryChangesW.argtypes = ( + ctypes.wintypes.HANDLE, # hDirectory + LPVOID, # lpBuffer + ctypes.wintypes.DWORD, # nBufferLength + ctypes.wintypes.BOOL, # bWatchSubtree + ctypes.wintypes.DWORD, # dwNotifyFilter + ctypes.POINTER(ctypes.wintypes.DWORD), # lpBytesReturned + ctypes.POINTER(OVERLAPPED), # lpOverlapped + LPVOID # FileIOCompletionRoutine # lpCompletionRoutine +) + +CreateFileW = ctypes.windll.kernel32.CreateFileW +CreateFileW.restype = ctypes.wintypes.HANDLE +CreateFileW.errcheck = _errcheck_handle +CreateFileW.argtypes = ( + ctypes.wintypes.LPCWSTR, # lpFileName + ctypes.wintypes.DWORD, # dwDesiredAccess + ctypes.wintypes.DWORD, # dwShareMode + LPVOID, # lpSecurityAttributes + ctypes.wintypes.DWORD, # dwCreationDisposition + ctypes.wintypes.DWORD, # dwFlagsAndAttributes + ctypes.wintypes.HANDLE # hTemplateFile +) + +CloseHandle = ctypes.windll.kernel32.CloseHandle +CloseHandle.restype = ctypes.wintypes.BOOL +CloseHandle.argtypes = ( + ctypes.wintypes.HANDLE, # hObject +) + +CancelIoEx = ctypes.windll.kernel32.CancelIoEx +CancelIoEx.restype = ctypes.wintypes.BOOL +CancelIoEx.errcheck = _errcheck_bool +CancelIoEx.argtypes = ( + ctypes.wintypes.HANDLE, # hObject + ctypes.POINTER(OVERLAPPED) # lpOverlapped +) + +CreateEvent = ctypes.windll.kernel32.CreateEventW +CreateEvent.restype = ctypes.wintypes.HANDLE +CreateEvent.errcheck = _errcheck_handle +CreateEvent.argtypes = ( + LPVOID, # lpEventAttributes + ctypes.wintypes.BOOL, # bManualReset + ctypes.wintypes.BOOL, # bInitialState + ctypes.wintypes.LPCWSTR, # lpName +) + +SetEvent = ctypes.windll.kernel32.SetEvent +SetEvent.restype = ctypes.wintypes.BOOL +SetEvent.errcheck = _errcheck_bool +SetEvent.argtypes = ( + ctypes.wintypes.HANDLE, # hEvent +) + +WaitForSingleObjectEx = ctypes.windll.kernel32.WaitForSingleObjectEx +WaitForSingleObjectEx.restype = ctypes.wintypes.DWORD +WaitForSingleObjectEx.errcheck = _errcheck_dword +WaitForSingleObjectEx.argtypes = ( + ctypes.wintypes.HANDLE, # hObject + ctypes.wintypes.DWORD, # dwMilliseconds + ctypes.wintypes.BOOL, # bAlertable +) + +CreateIoCompletionPort = ctypes.windll.kernel32.CreateIoCompletionPort +CreateIoCompletionPort.restype = ctypes.wintypes.HANDLE +CreateIoCompletionPort.errcheck = _errcheck_handle +CreateIoCompletionPort.argtypes = ( + ctypes.wintypes.HANDLE, # FileHandle + ctypes.wintypes.HANDLE, # ExistingCompletionPort + LPVOID, # CompletionKey + ctypes.wintypes.DWORD, # NumberOfConcurrentThreads +) + +GetQueuedCompletionStatus = ctypes.windll.kernel32.GetQueuedCompletionStatus +GetQueuedCompletionStatus.restype = ctypes.wintypes.BOOL +GetQueuedCompletionStatus.errcheck = _errcheck_bool +GetQueuedCompletionStatus.argtypes = ( + ctypes.wintypes.HANDLE, # CompletionPort + LPVOID, # lpNumberOfBytesTransferred + LPVOID, # lpCompletionKey + ctypes.POINTER(OVERLAPPED), # lpOverlapped + ctypes.wintypes.DWORD, # dwMilliseconds +) + +PostQueuedCompletionStatus = ctypes.windll.kernel32.PostQueuedCompletionStatus +PostQueuedCompletionStatus.restype = ctypes.wintypes.BOOL +PostQueuedCompletionStatus.errcheck = _errcheck_bool +PostQueuedCompletionStatus.argtypes = ( + ctypes.wintypes.HANDLE, # CompletionPort + ctypes.wintypes.DWORD, # lpNumberOfBytesTransferred + ctypes.wintypes.DWORD, # lpCompletionKey + ctypes.POINTER(OVERLAPPED), # lpOverlapped +) + + +class FILE_NOTIFY_INFORMATION(ctypes.Structure): + _fields_ = [("NextEntryOffset", ctypes.wintypes.DWORD), + ("Action", ctypes.wintypes.DWORD), + ("FileNameLength", ctypes.wintypes.DWORD), + #("FileName", (ctypes.wintypes.WCHAR * 1))] + ("FileName", (ctypes.c_char * 1))] + +LPFNI = ctypes.POINTER(FILE_NOTIFY_INFORMATION) + + +# We don't need to recalculate these flags every time a call is made to +# the win32 API functions. +WATCHDOG_FILE_FLAGS = FILE_FLAG_BACKUP_SEMANTICS +WATCHDOG_FILE_SHARE_FLAGS = reduce( + lambda x, y: x | y, [ + FILE_SHARE_READ, + FILE_SHARE_WRITE, + FILE_SHARE_DELETE, + ]) +WATCHDOG_FILE_NOTIFY_FLAGS = reduce( + lambda x, y: x | y, [ + FILE_NOTIFY_CHANGE_FILE_NAME, + FILE_NOTIFY_CHANGE_DIR_NAME, + FILE_NOTIFY_CHANGE_ATTRIBUTES, + FILE_NOTIFY_CHANGE_SIZE, + FILE_NOTIFY_CHANGE_LAST_WRITE, + FILE_NOTIFY_CHANGE_SECURITY, + FILE_NOTIFY_CHANGE_LAST_ACCESS, + FILE_NOTIFY_CHANGE_CREATION, + ]) + +BUFFER_SIZE = 2048 + + +def _parse_event_buffer(readBuffer, nBytes): + results = [] + while nBytes > 0: + fni = ctypes.cast(readBuffer, LPFNI)[0] + ptr = ctypes.addressof(fni) + FILE_NOTIFY_INFORMATION.FileName.offset + #filename = ctypes.wstring_at(ptr, fni.FileNameLength) + filename = ctypes.string_at(ptr, fni.FileNameLength) + results.append((fni.Action, filename.decode('utf-16'))) + numToSkip = fni.NextEntryOffset + if numToSkip <= 0: + break + readBuffer = readBuffer[numToSkip:] + nBytes -= numToSkip # numToSkip is long. nBytes should be long too. + return results + + +def get_directory_handle(path): + """Returns a Windows handle to the specified directory path.""" + return CreateFileW(path, FILE_LIST_DIRECTORY, WATCHDOG_FILE_SHARE_FLAGS, + None, OPEN_EXISTING, WATCHDOG_FILE_FLAGS, None) + + +def close_directory_handle(handle): + try: + CancelIoEx(handle, None) # force ReadDirectoryChangesW to return + CloseHandle(handle) # close directory handle + except WindowsError: + try: + CloseHandle(handle) # close directory handle + except: + return + + +def read_directory_changes(handle, recursive): + """Read changes to the directory using the specified directory handle. + + http://timgolden.me.uk/pywin32-docs/win32file__ReadDirectoryChangesW_meth.html + """ + event_buffer = ctypes.create_string_buffer(BUFFER_SIZE) + nbytes = ctypes.wintypes.DWORD() + try: + ReadDirectoryChangesW(handle, ctypes.byref(event_buffer), + len(event_buffer), recursive, + WATCHDOG_FILE_NOTIFY_FLAGS, + ctypes.byref(nbytes), None, None) + except WindowsError as e: + if e.winerror == ERROR_OPERATION_ABORTED: + return [], 0 + raise e + + # Python 2/3 compat + try: + int_class = long + except NameError: + int_class = int + return event_buffer.raw, int_class(nbytes.value) + + +class WinAPINativeEvent(object): + def __init__(self, action, src_path): + self.action = action + self.src_path = src_path + + @property + def is_added(self): + return self.action == FILE_ACTION_CREATED + + @property + def is_removed(self): + return self.action == FILE_ACTION_REMOVED + + @property + def is_modified(self): + return self.action == FILE_ACTION_MODIFIED + + @property + def is_renamed_old(self): + return self.action == FILE_ACTION_RENAMED_OLD_NAME + + @property + def is_renamed_new(self): + return self.action == FILE_ACTION_RENAMED_NEW_NAME + + def __repr__(self): + return ("" % (self.action, self.src_path)) + + +def read_events(handle, recursive): + buf, nbytes = read_directory_changes(handle, recursive) + events = _parse_event_buffer(buf, nbytes) + return [WinAPINativeEvent(action, path) for action, path in events] diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/tricks/__init__.py b/script.service.kodi.callbacks/resources/lib/watchdog/tricks/__init__.py new file mode 100644 index 0000000000..7e1c9fe271 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/tricks/__init__.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import os +import signal +import subprocess +import time + +from watchdog.utils import echo, has_attribute +from watchdog.events import PatternMatchingEventHandler + + +class Trick(PatternMatchingEventHandler): + + """Your tricks should subclass this class.""" + + @classmethod + def generate_yaml(cls): + context = dict(module_name=cls.__module__, + klass_name=cls.__name__) + template_yaml = """- %(module_name)s.%(klass_name)s: + args: + - argument1 + - argument2 + kwargs: + patterns: + - "*.py" + - "*.js" + ignore_patterns: + - "version.py" + ignore_directories: false +""" + return template_yaml % context + + +class LoggerTrick(Trick): + + """A simple trick that does only logs events.""" + + def on_any_event(self, event): + pass + + @echo.echo + def on_modified(self, event): + pass + + @echo.echo + def on_deleted(self, event): + pass + + @echo.echo + def on_created(self, event): + pass + + @echo.echo + def on_moved(self, event): + pass + + +class ShellCommandTrick(Trick): + + """Executes shell commands in response to matched events.""" + + def __init__(self, shell_command=None, patterns=None, ignore_patterns=None, + ignore_directories=False, wait_for_process=False, + drop_during_process=False): + super(ShellCommandTrick, self).__init__(patterns, ignore_patterns, + ignore_directories) + self.shell_command = shell_command + self.wait_for_process = wait_for_process + self.drop_during_process = drop_during_process + self.process = None + + def on_any_event(self, event): + from string import Template + + if self.drop_during_process and self.process and self.process.poll() is None: + return + + if event.is_directory: + object_type = 'directory' + else: + object_type = 'file' + + context = { + 'watch_src_path': event.src_path, + 'watch_dest_path': '', + 'watch_event_type': event.event_type, + 'watch_object': object_type, + } + + if self.shell_command is None: + if has_attribute(event, 'dest_path'): + context.update({'dest_path': event.dest_path}) + command = 'echo "${watch_event_type} ${watch_object} from ${watch_src_path} to ${watch_dest_path}"' + else: + command = 'echo "${watch_event_type} ${watch_object} ${watch_src_path}"' + else: + if has_attribute(event, 'dest_path'): + context.update({'watch_dest_path': event.dest_path}) + command = self.shell_command + + command = Template(command).safe_substitute(**context) + self.process = subprocess.Popen(command, shell=True) + if self.wait_for_process: + self.process.wait() + + +class AutoRestartTrick(Trick): + + """Starts a long-running subprocess and restarts it on matched events. + + The command parameter is a list of command arguments, such as + ['bin/myserver', '-c', 'etc/myconfig.ini']. + + Call start() after creating the Trick. Call stop() when stopping + the process. + """ + + def __init__(self, command, patterns=None, ignore_patterns=None, + ignore_directories=False, stop_signal=signal.SIGINT, + kill_after=10): + super(AutoRestartTrick, self).__init__( + patterns, ignore_patterns, ignore_directories) + self.command = command + self.stop_signal = stop_signal + self.kill_after = kill_after + self.process = None + + def start(self): + self.process = subprocess.Popen(self.command, preexec_fn=os.setsid) + + def stop(self): + if self.process is None: + return + try: + os.killpg(os.getpgid(self.process.pid), self.stop_signal) + except OSError: + # Process is already gone + pass + else: + kill_time = time.time() + self.kill_after + while time.time() < kill_time: + if self.process.poll() is not None: + break + time.sleep(0.25) + else: + try: + os.killpg(os.getpgid(self.process.pid), 9) + except OSError: + # Process is already gone + pass + self.process = None + + @echo.echo + def on_any_event(self, event): + self.stop() + self.start() diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/utils/__init__.py b/script.service.kodi.callbacks/resources/lib/watchdog/utils/__init__.py new file mode 100644 index 0000000000..8e6acb1640 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/utils/__init__.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +:module: watchdog.utils +:synopsis: Utility classes and functions. +:author: yesudeep@google.com (Yesudeep Mangalapilly) + +Classes +------- +.. autoclass:: BaseThread + :members: + :show-inheritance: + :inherited-members: + +""" +import os +import sys +import threading +try: + import watchdog.utils.platform + from watchdog.utils import platform +except Exception: + from resources.lib.watchdog.utils import platform +try: + from watchdog.utils.compat import Event +except Exception: + from resources.lib.watchdog.utils.compat import Event + +from collections import namedtuple + + +if sys.version_info[0] == 2 and platform.is_windows(): + # st_ino is not implemented in os.stat on this platform + import win32stat + stat = win32stat.stat +else: + stat = os.stat + + +def has_attribute(ob, attribute): + """ + :func:`hasattr` swallows exceptions. :func:`has_attribute` tests a Python object for the + presence of an attribute. + + :param ob: + object to inspect + :param attribute: + ``str`` for the name of the attribute. + """ + return getattr(ob, attribute, None) is not None + + +class UnsupportedLibc(Exception): + pass + + +class BaseThread(threading.Thread): + """ Convenience class for creating stoppable threads. """ + + def __init__(self): + threading.Thread.__init__(self) + if has_attribute(self, 'daemon'): + self.daemon = True + else: + self.setDaemon(True) + self._stopped_event = Event() + + if not has_attribute(self._stopped_event, 'is_set'): + self._stopped_event.is_set = self._stopped_event.isSet + + @property + def stopped_event(self): + return self._stopped_event + + def should_keep_running(self): + """Determines whether the thread should continue running.""" + return not self._stopped_event.is_set() + + def on_thread_stop(self): + """Override this method instead of :meth:`stop()`. + :meth:`stop()` calls this method. + + This method is called immediately after the thread is signaled to stop. + """ + pass + + def stop(self): + """Signals the thread to stop.""" + self._stopped_event.set() + self.on_thread_stop() + + def on_thread_start(self): + """Override this method instead of :meth:`start()`. :meth:`start()` + calls this method. + + This method is called right before this thread is started and this + object’s run() method is invoked. + """ + pass + + def start(self): + self.on_thread_start() + threading.Thread.start(self) + + +def load_module(module_name): + """Imports a module given its name and returns a handle to it.""" + try: + __import__(module_name) + except ImportError: + raise ImportError('No module named %s' % module_name) + return sys.modules[module_name] + + +def load_class(dotted_path): + """Loads and returns a class definition provided a dotted path + specification the last part of the dotted path is the class name + and there is at least one module name preceding the class name. + + Notes: + You will need to ensure that the module you are trying to load + exists in the Python path. + + Examples: + - module.name.ClassName # Provided module.name is in the Python path. + - module.ClassName # Provided module is in the Python path. + + What won't work: + - ClassName + - modle.name.ClassName # Typo in module name. + - module.name.ClasNam # Typo in classname. + """ + dotted_path_split = dotted_path.split('.') + if len(dotted_path_split) > 1: + klass_name = dotted_path_split[-1] + module_name = '.'.join(dotted_path_split[:-1]) + + module = load_module(module_name) + if has_attribute(module, klass_name): + klass = getattr(module, klass_name) + return klass + # Finally create and return an instance of the class + # return klass(*args, **kwargs) + else: + raise AttributeError('Module %s does not have class attribute %s' % ( + module_name, klass_name)) + else: + raise ValueError( + 'Dotted module path %s must contain a module name and a classname' % dotted_path) diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/utils/bricks.py b/script.service.kodi.callbacks/resources/lib/watchdog/utils/bricks.py new file mode 100644 index 0000000000..b6d6516eb9 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/utils/bricks.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +Utility collections or "bricks". + +:module: watchdog.utils.bricks +:author: yesudeep@google.com (Yesudeep Mangalapilly) +:author: lalinsky@gmail.com (Lukáš Lalinský) +:author: python@rcn.com (Raymond Hettinger) + +Classes +======= +.. autoclass:: OrderedSetQueue + :members: + :show-inheritance: + :inherited-members: + +.. autoclass:: OrderedSet + +""" + +import sys +import collections +from .compat import queue + +class SkipRepeatsQueue(queue.Queue): + + """Thread-safe implementation of an special queue where a + put of the last-item put'd will be dropped. + + The implementation leverages locking already implemented in the base class + redefining only the primitives. + + Queued items must be immutable and hashable so that they can be used + as dictionary keys. You must implement **only read-only properties** and + the :meth:`Item.__hash__()`, :meth:`Item.__eq__()`, and + :meth:`Item.__ne__()` methods for items to be hashable. + + An example implementation follows:: + + class Item(object): + def __init__(self, a, b): + self._a = a + self._b = b + + @property + def a(self): + return self._a + + @property + def b(self): + return self._b + + def _key(self): + return (self._a, self._b) + + def __eq__(self, item): + return self._key() == item._key() + + def __ne__(self, item): + return self._key() != item._key() + + def __hash__(self): + return hash(self._key()) + + based on the OrderedSetQueue below + """ + + def _init(self, maxsize): + queue.Queue._init(self, maxsize) + self._last_item = None + + def _put(self, item): + if item != self._last_item: + queue.Queue._put(self, item) + self._last_item = item + else: + # `put` increments `unfinished_tasks` even if we did not put + # anything into the queue here + self.unfinished_tasks -= 1 + + def _get(self): + item = queue.Queue._get(self) + if item is self._last_item: + self._last_item = None + return item + + +class OrderedSetQueue(queue.Queue): + + """Thread-safe implementation of an ordered set queue. + + Disallows adding a duplicate item while maintaining the + order of items in the queue. The implementation leverages + locking already implemented in the base class + redefining only the primitives. Since the internal queue + is not replaced, the order is maintained. The set is used + merely to check for the existence of an item. + + Queued items must be immutable and hashable so that they can be used + as dictionary keys. You must implement **only read-only properties** and + the :meth:`Item.__hash__()`, :meth:`Item.__eq__()`, and + :meth:`Item.__ne__()` methods for items to be hashable. + + An example implementation follows:: + + class Item(object): + def __init__(self, a, b): + self._a = a + self._b = b + + @property + def a(self): + return self._a + + @property + def b(self): + return self._b + + def _key(self): + return (self._a, self._b) + + def __eq__(self, item): + return self._key() == item._key() + + def __ne__(self, item): + return self._key() != item._key() + + def __hash__(self): + return hash(self._key()) + + :author: lalinsky@gmail.com (Lukáš Lalinský) + :url: http://stackoverflow.com/questions/1581895/how-check-if-a-task-is-already-in-python-queue + """ + + def _init(self, maxsize): + queue.Queue._init(self, maxsize) + self._set_of_items = set() + + def _put(self, item): + if item not in self._set_of_items: + queue.Queue._put(self, item) + self._set_of_items.add(item) + else: + # `put` increments `unfinished_tasks` even if we did not put + # anything into the queue here + self.unfinished_tasks -= 1 + + def _get(self): + item = queue.Queue._get(self) + self._set_of_items.remove(item) + return item + + +if sys.version_info >= (2, 6, 0): + KEY, PREV, NEXT = list(range(3)) + + class OrderedSet(collections.MutableSet): + + """ + Implementation based on a doubly-linked link and an internal dictionary. + This design gives :class:`OrderedSet` the same big-Oh running times as + regular sets including O(1) adds, removes, and lookups as well as + O(n) iteration. + + .. ADMONITION:: Implementation notes + + Runs on Python 2.6 or later (and runs on Python 3.0 or later + without any modifications). + + :author: python@rcn.com (Raymond Hettinger) + :url: http://code.activestate.com/recipes/576694/ + """ + + def __init__(self, iterable=None): + self.end = end = [] + end += [None, end, end] # sentinel node for doubly linked list + self.map = {} # key --> [key, prev, next] + if iterable is not None: + self |= iterable + + def __len__(self): + return len(self.map) + + def __contains__(self, key): + return key in self.map + + def add(self, key): + if key not in self.map: + end = self.end + curr = end[PREV] + curr[NEXT] = end[PREV] = self.map[key] = [key, curr, end] + + def discard(self, key): + if key in self.map: + key, prev, _next = self.map.pop(key) + prev[NEXT] = _next + _next[PREV] = prev + + def __iter__(self): + end = self.end + curr = end[NEXT] + while curr is not end: + yield curr[KEY] + curr = curr[NEXT] + + def __reversed__(self): + end = self.end + curr = end[PREV] + while curr is not end: + yield curr[KEY] + curr = curr[PREV] + + def pop(self, last=True): + if not self: + raise KeyError('set is empty') + key = next(reversed(self)) if last else next(iter(self)) + self.discard(key) + return key + + def __repr__(self): + if not self: + return '%s()' % (self.__class__.__name__,) + return '%s(%r)' % (self.__class__.__name__, list(self)) + + def __eq__(self, other): + if isinstance(other, OrderedSet): + return len(self) == len(other) and list(self) == list(other) + return set(self) == set(other) + + def __del__(self): + self.clear() # remove circular references diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/utils/compat.py b/script.service.kodi.callbacks/resources/lib/watchdog/utils/compat.py new file mode 100644 index 0000000000..0f6e7947b9 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/utils/compat.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2014 Thomas Amland +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import sys + +__all__ = ['queue', 'Event'] + +try: + import queue +except ImportError: + import Queue as queue + + +if sys.version_info < (2, 7): + from watchdog.utils.event_backport import Event +else: + from threading import Event \ No newline at end of file diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/utils/decorators.py b/script.service.kodi.callbacks/resources/lib/watchdog/utils/decorators.py new file mode 100644 index 0000000000..abb325c1c1 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/utils/decorators.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Most of this code was obtained from the Python documentation online. + +"""Decorator utility functions. + +decorators: +- synchronized +- propertyx +- accepts +- returns +- singleton +- attrs +- deprecated +""" + +import functools +import warnings +import threading +import sys + + +def synchronized(lock=None): + """Decorator that synchronizes a method or a function with a mutex lock. + + Example usage: + + @synchronized() + def operation(self, a, b): + ... + """ + if lock is None: + lock = threading.Lock() + + def wrapper(function): + def new_function(*args, **kwargs): + lock.acquire() + try: + return function(*args, **kwargs) + finally: + lock.release() + + return new_function + + return wrapper + + +def propertyx(function): + """Decorator to easily create properties in classes. + + Example: + + class Angle(object): + def __init__(self, rad): + self._rad = rad + + @property + def rad(): + def fget(self): + return self._rad + def fset(self, angle): + if isinstance(angle, Angle): + angle = angle.rad + self._rad = float(angle) + + Arguments: + - `function`: The function to be decorated. + """ + keys = ('fget', 'fset', 'fdel') + func_locals = {'doc': function.__doc__} + + def probe_func(frame, event, arg): + if event == 'return': + locals = frame.f_locals + func_locals.update(dict((k, locals.get(k)) for k in keys)) + sys.settrace(None) + return probe_func + + sys.settrace(probe_func) + function() + return property(**func_locals) + + +def accepts(*types): + """Decorator to ensure that the decorated function accepts the given types as arguments. + + Example: + @accepts(int, (int,float)) + @returns((int,float)) + def func(arg1, arg2): + return arg1 * arg2 + """ + + def check_accepts(f): + assert len(types) == f.__code__.co_argcount + + def new_f(*args, **kwds): + for (a, t) in zip(args, types): + assert isinstance(a, t),\ + "arg %r does not match %s" % (a, t) + return f(*args, **kwds) + + new_f.__name__ = f.__name__ + return new_f + + return check_accepts + + +def returns(rtype): + """Decorator to ensure that the decorated function returns the given + type as argument. + + Example: + @accepts(int, (int,float)) + @returns((int,float)) + def func(arg1, arg2): + return arg1 * arg2 + """ + + def check_returns(f): + def new_f(*args, **kwds): + result = f(*args, **kwds) + assert isinstance(result, rtype),\ + "return value %r does not match %s" % (result, rtype) + return result + + new_f.__name__ = f.__name__ + return new_f + + return check_returns + + +def singleton(cls): + """Decorator to ensures a class follows the singleton pattern. + + Example: + @singleton + class MyClass: + ... + """ + instances = {} + + def getinstance(): + if cls not in instances: + instances[cls] = cls() + return instances[cls] + + return getinstance + + +def attrs(**kwds): + """Decorator to add attributes to a function. + + Example: + + @attrs(versionadded="2.2", + author="Guido van Rossum") + def mymethod(f): + ... + """ + + def decorate(f): + for k in kwds: + setattr(f, k, kwds[k]) + return f + + return decorate + + +def deprecated(func): + """This is a decorator which can be used to mark functions + as deprecated. It will result in a warning being emitted + when the function is used. + + ## Usage examples ## + @deprecated + def my_func(): + pass + + @other_decorators_must_be_upper + @deprecated + def my_func(): + pass + """ + + @functools.wraps(func) + def new_func(*args, **kwargs): + warnings.warn_explicit( + "Call to deprecated function %(funcname)s." % { + 'funcname': func.__name__, + }, + category=DeprecationWarning, + filename=func.__code__.co_filename, + lineno=func.__code__.co_firstlineno + 1 + ) + return func(*args, **kwargs) + + return new_func diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/utils/delayed_queue.py b/script.service.kodi.callbacks/resources/lib/watchdog/utils/delayed_queue.py new file mode 100644 index 0000000000..6d98a50469 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/utils/delayed_queue.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2014 Thomas Amland +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import time +import threading +from collections import deque + + +class DelayedQueue(object): + + def __init__(self, delay): + self.delay = delay + self._lock = threading.Lock() + self._not_empty = threading.Condition(self._lock) + self._queue = deque() + self._closed = False + + def put(self, element): + """Add element to queue.""" + self._lock.acquire() + self._queue.append((element, time.time())) + self._not_empty.notify() + self._lock.release() + + def close(self): + """Close queue, indicating no more items will be added.""" + self._closed = True + # Interrupt the blocking _not_empty.wait() call in get + self._not_empty.acquire() + self._not_empty.notify() + self._not_empty.release() + + def get(self): + """Remove and return an element from the queue, or this queue has been + closed raise the Closed exception. + """ + while True: + # wait for element to be added to queue + self._not_empty.acquire() + while len(self._queue) == 0 and not self._closed: + self._not_empty.wait() + + if self._closed: + self._not_empty.release() + return None + head, insert_time = self._queue[0] + self._not_empty.release() + + # wait for delay + time_left = insert_time + self.delay - time.time() + while time_left > 0: + time.sleep(time_left) + time_left = insert_time + self.delay - time.time() + + # return element if it's still in the queue + self._lock.acquire() + try: + if len(self._queue) > 0 and self._queue[0][0] is head: + self._queue.popleft() + return head + finally: + self._lock.release() + + def remove(self, predicate): + """Remove and return the first items for which predicate is True, + ignoring delay.""" + try: + self._lock.acquire() + for i, (elem, t) in enumerate(self._queue): + if predicate(elem): + del self._queue[i] + return elem + finally: + self._lock.release() + return None diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/utils/dirsnapshot.py b/script.service.kodi.callbacks/resources/lib/watchdog/utils/dirsnapshot.py new file mode 100644 index 0000000000..122e3cfc7a --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/utils/dirsnapshot.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# Copyright 2014 Thomas Amland +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +:module: watchdog.utils.dirsnapshot +:synopsis: Directory snapshots and comparison. +:author: yesudeep@google.com (Yesudeep Mangalapilly) + +.. ADMONITION:: Where are the moved events? They "disappeared" + + This implementation does not take partition boundaries + into consideration. It will only work when the directory + tree is entirely on the same file system. More specifically, + any part of the code that depends on inode numbers can + break if partition boundaries are crossed. In these cases, + the snapshot diff will represent file/directory movement as + created and deleted events. + +Classes +------- +.. autoclass:: DirectorySnapshot + :members: + :show-inheritance: + +.. autoclass:: DirectorySnapshotDiff + :members: + :show-inheritance: + +""" + +import errno +import os +from stat import S_ISDIR +from resources.lib.watchdog.utils import platform +from resources.lib.watchdog.utils import stat as default_stat + + +class DirectorySnapshotDiff(object): + """ + Compares two directory snapshots and creates an object that represents + the difference between the two snapshots. + + :param ref: + The reference directory snapshot. + :type ref: + :class:`DirectorySnapshot` + :param snapshot: + The directory snapshot which will be compared + with the reference snapshot. + :type snapshot: + :class:`DirectorySnapshot` + """ + + def __init__(self, ref, snapshot): + created = snapshot.paths - ref.paths + deleted = ref.paths - snapshot.paths + + # check that all unchanged paths have the same inode + for path in ref.paths & snapshot.paths: + if ref.inode(path) != snapshot.inode(path): + created.add(path) + deleted.add(path) + + # find moved paths + moved = set() + for path in set(deleted): + inode = ref.inode(path) + new_path = snapshot.path(inode) + if new_path: + # file is not deleted but moved + deleted.remove(path) + moved.add((path, new_path)) + + for path in set(created): + inode = snapshot.inode(path) + old_path = ref.path(inode) + if old_path: + created.remove(path) + moved.add((old_path, path)) + + # find modified paths + # first check paths that have not moved + modified = set() + for path in ref.paths & snapshot.paths: + if ref.inode(path) == snapshot.inode(path): + if ref.mtime(path) != snapshot.mtime(path): + modified.add(path) + + for (old_path, new_path) in moved: + if ref.mtime(old_path) != snapshot.mtime(new_path): + modified.add(old_path) + + self._dirs_created = [path for path in created if snapshot.isdir(path)] + self._dirs_deleted = [path for path in deleted if ref.isdir(path)] + self._dirs_modified = [path for path in modified if ref.isdir(path)] + self._dirs_moved = [(frm, to) for (frm, to) in moved if ref.isdir(frm)] + + self._files_created = list(created - set(self._dirs_created)) + self._files_deleted = list(deleted - set(self._dirs_deleted)) + self._files_modified = list(modified - set(self._dirs_modified)) + self._files_moved = list(moved - set(self._dirs_moved)) + + @property + def files_created(self): + """List of files that were created.""" + return self._files_created + + @property + def files_deleted(self): + """List of files that were deleted.""" + return self._files_deleted + + @property + def files_modified(self): + """List of files that were modified.""" + return self._files_modified + + @property + def files_moved(self): + """ + List of files that were moved. + + Each event is a two-tuple the first item of which is the path + that has been renamed to the second item in the tuple. + """ + return self._files_moved + + @property + def dirs_modified(self): + """ + List of directories that were modified. + """ + return self._dirs_modified + + @property + def dirs_moved(self): + """ + List of directories that were moved. + + Each event is a two-tuple the first item of which is the path + that has been renamed to the second item in the tuple. + """ + return self._dirs_moved + + @property + def dirs_deleted(self): + """ + List of directories that were deleted. + """ + return self._dirs_deleted + + @property + def dirs_created(self): + """ + List of directories that were created. + """ + return self._dirs_created + +class DirectorySnapshot(object): + """ + A snapshot of stat information of files in a directory. + + :param path: + The directory path for which a snapshot should be taken. + :type path: + ``str`` + :param recursive: + ``True`` if the entire directory tree should be included in the + snapshot; ``False`` otherwise. + :type recursive: + ``bool`` + :param walker_callback: + .. deprecated:: 0.7.2 + :param stat: + Use custom stat function that returns a stat structure for path. + Currently only st_dev, st_ino, st_mode and st_mtime are needed. + + A function with the signature ``walker_callback(path, stat_info)`` + which will be called for every entry in the directory tree. + :param listdir: + Use custom listdir function. See ``os.listdir`` for details. + """ + + def __init__(self, path, recursive=True, + walker_callback=(lambda p, s: None), + stat=default_stat, + listdir=os.listdir): + self._stat_info = {} + self._inode_to_path = {} + + st = stat(path) + self._stat_info[path] = st + self._inode_to_path[(st.st_ino, st.st_dev)] = path + + def walk(root): + try: + paths = [os.path.join(root, name) for name in listdir(root)] + except OSError as e: + # Directory may have been deleted between finding it in the directory + # list of its parent and trying to delete its contents. If this + # happens we treat it as empty. + if e.errno == errno.ENOENT: + return + else: + raise + entries = [] + for p in paths: + try: + entries.append((p, stat(p))) + except OSError: + continue + for _ in entries: + yield _ + if recursive: + for path, st in entries: + if S_ISDIR(st.st_mode): + for _ in walk(path): + yield _ + + for p, st in walk(path): + i = (st.st_ino, st.st_dev) + self._inode_to_path[i] = p + self._stat_info[p] = st + walker_callback(p, st) + + @property + def paths(self): + """ + Set of file/directory paths in the snapshot. + """ + return set(self._stat_info.keys()) + + def path(self, id): + """ + Returns path for id. None if id is unknown to this snapshot. + """ + return self._inode_to_path.get(id) + + def inode(self, path): + """ Returns an id for path. """ + st = self._stat_info[path] + return (st.st_ino, st.st_dev) + + def isdir(self, path): + return S_ISDIR(self._stat_info[path].st_mode) + + def mtime(self, path): + return self._stat_info[path].st_mtime + + def stat_info(self, path): + """ + Returns a stat information object for the specified path from + the snapshot. + + Attached information is subject to change. Do not use unless + you specify `stat` in constructor. Use :func:`inode`, :func:`mtime`, + :func:`isdir` instead. + + :param path: + The path for which stat information should be obtained + from a snapshot. + """ + return self._stat_info[path] + + def __sub__(self, previous_dirsnap): + """Allow subtracting a DirectorySnapshot object instance from + another. + + :returns: + A :class:`DirectorySnapshotDiff` object. + """ + return DirectorySnapshotDiff(previous_dirsnap, self) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return str(self._stat_info) diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/utils/echo.py b/script.service.kodi.callbacks/resources/lib/watchdog/utils/echo.py new file mode 100644 index 0000000000..18a35891d7 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/utils/echo.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# echo.py: Tracing function calls using Python decorators. +# +# Written by Thomas Guest +# Please see http://wordaligned.org/articles/echo +# +# Place into the public domain. + +""" Echo calls made to functions and methods in a module. + +"Echoing" a function call means printing out the name of the function +and the values of its arguments before making the call (which is more +commonly referred to as "tracing", but Python already has a trace module). + +Example: to echo calls made to functions in "my_module" do: + + import echo + import my_module + echo.echo_module(my_module) + +Example: to echo calls made to functions in "my_module.my_class" do: + + echo.echo_class(my_module.my_class) + +Alternatively, echo.echo can be used to decorate functions. Calls to the +decorated function will be echoed. + +Example: + + @echo.echo + def my_function(args): + pass +""" +import inspect +import sys + + +def name(item): + " Return an item's name. " + return item.__name__ + + +def is_classmethod(instancemethod): + " Determine if an instancemethod is a classmethod. " + return instancemethod.__self__ is not None + + +def is_class_private_name(name): + " Determine if a name is a class private name. " + # Exclude system defined names such as __init__, __add__ etc + return name.startswith("__") and not name.endswith("__") + + +def method_name(method): + """ Return a method's name. + + This function returns the name the method is accessed by from + outside the class (i.e. it prefixes "private" methods appropriately). + """ + mname = name(method) + if is_class_private_name(mname): + mname = "_%s%s" % (name(method.__self__.__class__), mname) + return mname + + +def format_arg_value(arg_val): + """ Return a string representing a (name, value) pair. + + >>> format_arg_value(('x', (1, 2, 3))) + 'x=(1, 2, 3)' + """ + arg, val = arg_val + return "%s=%r" % (arg, val) + + +def echo(fn, write=sys.stdout.write): + """ Echo calls to a function. + + Returns a decorated version of the input function which "echoes" calls + made to it by writing out the function's name and the arguments it was + called with. + """ + import functools + # Unpack function's arg count, arg names, arg defaults + code = fn.__code__ + argcount = code.co_argcount + argnames = code.co_varnames[:argcount] + fn_defaults = fn.__defaults__ or list() + argdefs = dict(list(zip(argnames[-len(fn_defaults):], fn_defaults))) + + @functools.wraps(fn) + def wrapped(*v, **k): + # Collect function arguments by chaining together positional, + # defaulted, extra positional and keyword arguments. + positional = list(map(format_arg_value, list(zip(argnames, v)))) + defaulted = [format_arg_value((a, argdefs[a])) + for a in argnames[len(v):] if a not in k] + nameless = list(map(repr, v[argcount:])) + keyword = list(map(format_arg_value, list(k.items()))) + args = positional + defaulted + nameless + keyword + write("%s(%s)\n" % (name(fn), ", ".join(args))) + return fn(*v, **k) + + return wrapped + + +def echo_instancemethod(klass, method, write=sys.stdout.write): + """ Change an instancemethod so that calls to it are echoed. + + Replacing a classmethod is a little more tricky. + See: http://www.python.org/doc/current/ref/types.html + """ + mname = method_name(method) + never_echo = "__str__", "__repr__", # Avoid recursion printing method calls + if mname in never_echo: + pass + elif is_classmethod(method): + setattr(klass, mname, classmethod(echo(method.__func__, write))) + else: + setattr(klass, mname, echo(method, write)) + + +def echo_class(klass, write=sys.stdout.write): + """ Echo calls to class methods and static functions + """ + for _, method in inspect.getmembers(klass, inspect.ismethod): + echo_instancemethod(klass, method, write) + for _, fn in inspect.getmembers(klass, inspect.isfunction): + setattr(klass, name(fn), staticmethod(echo(fn, write))) + + +def echo_module(mod, write=sys.stdout.write): + """ Echo calls to functions and methods in a module. + """ + for fname, fn in inspect.getmembers(mod, inspect.isfunction): + setattr(mod, fname, echo(fn, write)) + for _, klass in inspect.getmembers(mod, inspect.isclass): + echo_class(klass, write) + +if __name__ == "__main__": + import doctest + + optionflags = doctest.ELLIPSIS + doctest.testfile('echoexample.txt', optionflags=optionflags) + doctest.testmod(optionflags=optionflags) diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/utils/event_backport.py b/script.service.kodi.callbacks/resources/lib/watchdog/utils/event_backport.py new file mode 100644 index 0000000000..5c136e46d5 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/utils/event_backport.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Backport of Event from py2.7 (method wait in py2.6 returns None) + +from threading import Condition, Lock + + +class Event(object): + + def __init__(self,): + self.__cond = Condition(Lock()) + self.__flag = False + + def isSet(self): + return self.__flag + + is_set = isSet + + def set(self): + self.__cond.acquire() + try: + self.__flag = True + self.__cond.notify_all() + finally: + self.__cond.release() + + def clear(self): + self.__cond.acquire() + try: + self.__flag = False + finally: + self.__cond.release() + + def wait(self, timeout=None): + self.__cond.acquire() + try: + if not self.__flag: + self.__cond.wait(timeout) + return self.__flag + finally: + self.__cond.release() diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/utils/importlib2.py b/script.service.kodi.callbacks/resources/lib/watchdog/utils/importlib2.py new file mode 100644 index 0000000000..5ad3ec5720 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/utils/importlib2.py @@ -0,0 +1,40 @@ +# The MIT License (MIT) + +# Copyright (c) 2013 Peter M. Elias + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE + + +def import_module(target, relative_to=None): + target_parts = target.split('.') + target_depth = target_parts.count('') + target_path = target_parts[target_depth:] + target = target[target_depth:] + fromlist = [target] + if target_depth and relative_to: + relative_parts = relative_to.split('.') + relative_to = '.'.join(relative_parts[:-(target_depth - 1) or None]) + if len(target_path) > 1: + relative_to = '.'.join(filter(None, [relative_to]) + target_path[:-1]) + fromlist = target_path[-1:] + target = fromlist[0] + elif not relative_to: + fromlist = [] + mod = __import__(relative_to or target, globals(), locals(), fromlist) + return getattr(mod, target, mod) diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/utils/platform.py b/script.service.kodi.callbacks/resources/lib/watchdog/utils/platform.py new file mode 100644 index 0000000000..cc46192fe2 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/utils/platform.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import sys + +PLATFORM_WINDOWS = 'windows' +PLATFORM_LINUX = 'linux' +PLATFORM_BSD = 'bsd' +PLATFORM_DARWIN = 'darwin' +PLATFORM_UNKNOWN = 'unknown' + + +def get_platform_name(): + if sys.platform.startswith("win"): + return PLATFORM_WINDOWS + elif sys.platform.startswith('darwin'): + return PLATFORM_DARWIN + elif sys.platform.startswith('linux'): + return PLATFORM_LINUX + elif sys.platform.startswith('bsd'): + return PLATFORM_BSD + else: + return PLATFORM_UNKNOWN + +__platform__ = get_platform_name() + + +def is_linux(): + return __platform__ == PLATFORM_LINUX + + +def is_bsd(): + return __platform__ == PLATFORM_BSD + + +def is_darwin(): + return __platform__ == PLATFORM_DARWIN + + +def is_windows(): + return __platform__ == PLATFORM_WINDOWS diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/utils/unicode_paths.py b/script.service.kodi.callbacks/resources/lib/watchdog/utils/unicode_paths.py new file mode 100644 index 0000000000..501a2f1512 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/utils/unicode_paths.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2013 Will Bond +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +import sys + +from watchdog.utils import platform + +try: + # Python 2 + str_cls = unicode + bytes_cls = str +except NameError: + # Python 3 + str_cls = str + bytes_cls = bytes + + +# This is used by Linux when the locale seems to be improperly set. UTF-8 tends +# to be the encoding used by all distros, so this is a good fallback. +fs_fallback_encoding = 'utf-8' +fs_encoding = sys.getfilesystemencoding() or fs_fallback_encoding + + +def encode(path): + if isinstance(path, str_cls): + try: + path = path.encode(fs_encoding, 'strict') + except UnicodeEncodeError: + if not platform.is_linux(): + raise + path = path.encode(fs_fallback_encoding, 'strict') + return path + + +def decode(path): + if isinstance(path, bytes_cls): + try: + path = path.decode(fs_encoding, 'strict') + except UnicodeDecodeError: + if not platform.is_linux(): + raise + path = path.decode(fs_fallback_encoding, 'strict') + return path diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/utils/win32stat.py b/script.service.kodi.callbacks/resources/lib/watchdog/utils/win32stat.py new file mode 100644 index 0000000000..c18d66f324 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/utils/win32stat.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2014 Thomas Amland +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +:module: watchdog.utils.win32stat +:synopsis: Implementation of stat with st_ino and st_dev support. + +Functions +--------- + +.. autofunction:: stat + +""" + +import ctypes +import ctypes.wintypes +import stat as stdstat +from collections import namedtuple + + +INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value +OPEN_EXISTING = 3 +FILE_READ_ATTRIBUTES = 0x80 +FILE_ATTRIBUTE_NORMAL = 0x80 +FILE_ATTRIBUTE_READONLY = 0x1 +FILE_ATTRIBUTE_DIRECTORY = 0x10 +FILE_FLAG_BACKUP_SEMANTICS = 0x02000000 +FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000 + + +class FILETIME(ctypes.Structure): + _fields_ = [("dwLowDateTime", ctypes.wintypes.DWORD), + ("dwHighDateTime", ctypes.wintypes.DWORD)] + + +class BY_HANDLE_FILE_INFORMATION(ctypes.Structure): + _fields_ = [('dwFileAttributes', ctypes.wintypes.DWORD), + ('ftCreationTime', FILETIME), + ('ftLastAccessTime', FILETIME), + ('ftLastWriteTime', FILETIME), + ('dwVolumeSerialNumber', ctypes.wintypes.DWORD), + ('nFileSizeHigh', ctypes.wintypes.DWORD), + ('nFileSizeLow', ctypes.wintypes.DWORD), + ('nNumberOfLinks', ctypes.wintypes.DWORD), + ('nFileIndexHigh', ctypes.wintypes.DWORD), + ('nFileIndexLow', ctypes.wintypes.DWORD)] + + +CreateFile = ctypes.windll.kernel32.CreateFileW +CreateFile.restype = ctypes.wintypes.HANDLE +CreateFile.argtypes = ( + ctypes.c_wchar_p, + ctypes.wintypes.DWORD, + ctypes.wintypes.DWORD, + ctypes.c_void_p, + ctypes.wintypes.DWORD, + ctypes.wintypes.DWORD, + ctypes.wintypes.HANDLE, +) + +GetFileInformationByHandle = ctypes.windll.kernel32.GetFileInformationByHandle +GetFileInformationByHandle.restype = ctypes.wintypes.BOOL +GetFileInformationByHandle.argtypes = ( + ctypes.wintypes.HANDLE, + ctypes.wintypes.POINTER(BY_HANDLE_FILE_INFORMATION), +) + +CloseHandle = ctypes.windll.kernel32.CloseHandle +CloseHandle.restype = ctypes.wintypes.BOOL +CloseHandle.argtypes = (ctypes.wintypes.HANDLE,) + + +StatResult = namedtuple('StatResult', 'st_dev st_ino st_mode st_mtime') + +def _to_mode(attr): + m = 0 + if (attr & FILE_ATTRIBUTE_DIRECTORY): + m |= stdstat.S_IFDIR | 0o111 + else: + m |= stdstat.S_IFREG + if (attr & FILE_ATTRIBUTE_READONLY): + m |= 0o444 + else: + m |= 0o666 + return m + +def _to_unix_time(ft): + t = (ft.dwHighDateTime) << 32 | ft.dwLowDateTime + return (t / 10000000) - 11644473600 + +def stat(path): + hfile = CreateFile(path, + FILE_READ_ATTRIBUTES, + 0, + None, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + None) + if hfile == INVALID_HANDLE_VALUE: + raise ctypes.WinError() + info = BY_HANDLE_FILE_INFORMATION() + r = GetFileInformationByHandle(hfile, info) + CloseHandle(hfile) + if not r: + raise ctypes.WinError() + return StatResult(st_dev=info.dwVolumeSerialNumber, + st_ino=(info.nFileIndexHigh << 32) + info.nFileIndexLow, + st_mode=_to_mode(info.dwFileAttributes), + st_mtime=_to_unix_time(info.ftLastWriteTime) + ) diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/version.py b/script.service.kodi.callbacks/resources/lib/watchdog/version.py new file mode 100644 index 0000000000..92de4ee0ef --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/version.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +# When updating this version number, please update the +# ``docs/source/global.rst.inc`` file as well. +VERSION_MAJOR = 0 +VERSION_MINOR = 8 +VERSION_BUILD = 3 +VERSION_INFO = (VERSION_MAJOR, VERSION_MINOR, VERSION_BUILD) +VERSION_STRING = "%d.%d.%d" % VERSION_INFO + +__version__ = VERSION_INFO diff --git a/script.service.kodi.callbacks/resources/lib/watchdog/watchmedo.py b/script.service.kodi.callbacks/resources/lib/watchdog/watchmedo.py new file mode 100644 index 0000000000..ce891f83b8 --- /dev/null +++ b/script.service.kodi.callbacks/resources/lib/watchdog/watchmedo.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2011 Yesudeep Mangalapilly +# Copyright 2012 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +:module: watchdog.watchmedo +:author: yesudeep@google.com (Yesudeep Mangalapilly) +:synopsis: ``watchmedo`` shell script utility. +""" + +import os.path +import sys +import yaml +import time +import logging + +try: + from cStringIO import StringIO +except ImportError: + try: + from StringIO import StringIO + except ImportError: + from io import StringIO + +from argh import arg, aliases, ArghParser, expects_obj +from watchdog.version import VERSION_STRING +from watchdog.utils import load_class + + +logging.basicConfig(level=logging.INFO) + +CONFIG_KEY_TRICKS = 'tricks' +CONFIG_KEY_PYTHON_PATH = 'python-path' + + +def path_split(pathname_spec, separator=os.path.sep): + """ + Splits a pathname specification separated by an OS-dependent separator. + + :param pathname_spec: + The pathname specification. + :param separator: + (OS Dependent) `:` on Unix and `;` on Windows or user-specified. + """ + return list(pathname_spec.split(separator)) + + +def add_to_sys_path(pathnames, index=0): + """ + Adds specified paths at specified index into the sys.path list. + + :param paths: + A list of paths to add to the sys.path + :param index: + (Default 0) The index in the sys.path list where the paths will be + added. + """ + for pathname in pathnames[::-1]: + sys.path.insert(index, pathname) + + +def load_config(tricks_file_pathname): + """ + Loads the YAML configuration from the specified file. + + :param tricks_file_path: + The path to the tricks configuration file. + :returns: + A dictionary of configuration information. + """ + f = open(tricks_file_pathname, 'rb') + content = f.read() + f.close() + config = yaml.load(content) + return config + + +def parse_patterns(patterns_spec, ignore_patterns_spec, separator=';'): + """ + Parses pattern argument specs and returns a two-tuple of + (patterns, ignore_patterns). + """ + patterns = patterns_spec.split(separator) + ignore_patterns = ignore_patterns_spec.split(separator) + if ignore_patterns == ['']: + ignore_patterns = [] + return (patterns, ignore_patterns) + + +def observe_with(observer, event_handler, pathnames, recursive): + """ + Single observer thread with a scheduled path and event handler. + + :param observer: + The observer thread. + :param event_handler: + Event handler which will be called in response to file system events. + :param pathnames: + A list of pathnames to monitor. + :param recursive: + ``True`` if recursive; ``False`` otherwise. + """ + for pathname in set(pathnames): + observer.schedule(event_handler, pathname, recursive) + observer.start() + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + observer.stop() + observer.join() + + +def schedule_tricks(observer, tricks, pathname, recursive): + """ + Schedules tricks with the specified observer and for the given watch + path. + + :param observer: + The observer thread into which to schedule the trick and watch. + :param tricks: + A list of tricks. + :param pathname: + A path name which should be watched. + :param recursive: + ``True`` if recursive; ``False`` otherwise. + """ + for trick in tricks: + for name, value in list(trick.items()): + TrickClass = load_class(name) + handler = TrickClass(**value) + trick_pathname = getattr(handler, 'source_directory', None) or pathname + observer.schedule(handler, trick_pathname, recursive) + + +@aliases('tricks') +@arg('files', + nargs='*', + help='perform tricks from given file') +@arg('--python-path', + default='.', + help='paths separated by %s to add to the python path' % os.path.sep) +@arg('--interval', + '--timeout', + dest='timeout', + default=1.0, + help='use this as the polling interval/blocking timeout') +@arg('--recursive', + default=True, + help='recursively monitor paths') +@expects_obj +def tricks_from(args): + """ + Subcommand to execute tricks from a tricks configuration file. + + :param args: + Command line argument options. + """ + from watchdog.observers import Observer + + add_to_sys_path(path_split(args.python_path)) + observers = [] + for tricks_file in args.files: + observer = Observer(timeout=args.timeout) + + if not os.path.exists(tricks_file): + raise IOError("cannot find tricks file: %s" % tricks_file) + + config = load_config(tricks_file) + + try: + tricks = config[CONFIG_KEY_TRICKS] + except KeyError: + raise KeyError("No `%s' key specified in %s." % ( + CONFIG_KEY_TRICKS, tricks_file)) + + if CONFIG_KEY_PYTHON_PATH in config: + add_to_sys_path(config[CONFIG_KEY_PYTHON_PATH]) + + dir_path = os.path.dirname(tricks_file) + if not dir_path: + dir_path = os.path.relpath(os.getcwd()) + schedule_tricks(observer, tricks, dir_path, args.recursive) + observer.start() + observers.append(observer) + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + for o in observers: + o.unschedule_all() + o.stop() + for o in observers: + o.join() + + +@aliases('generate-tricks-yaml') +@arg('trick_paths', + nargs='*', + help='Dotted paths for all the tricks you want to generate') +@arg('--python-path', + default='.', + help='paths separated by %s to add to the python path' % os.path.sep) +@arg('--append-to-file', + default=None, + help='appends the generated tricks YAML to a file; \ +if not specified, prints to standard output') +@arg('-a', + '--append-only', + dest='append_only', + default=False, + help='if --append-to-file is not specified, produces output for \ +appending instead of a complete tricks yaml file.') +@expects_obj +def tricks_generate_yaml(args): + """ + Subcommand to generate Yaml configuration for tricks named on the command + line. + + :param args: + Command line argument options. + """ + python_paths = path_split(args.python_path) + add_to_sys_path(python_paths) + output = StringIO() + + for trick_path in args.trick_paths: + TrickClass = load_class(trick_path) + output.write(TrickClass.generate_yaml()) + + content = output.getvalue() + output.close() + + header = yaml.dump({CONFIG_KEY_PYTHON_PATH: python_paths}) + header += "%s:\n" % CONFIG_KEY_TRICKS + if args.append_to_file is None: + # Output to standard output. + if not args.append_only: + content = header + content + sys.stdout.write(content) + else: + if not os.path.exists(args.append_to_file): + content = header + content + output = open(args.append_to_file, 'ab') + output.write(content) + output.close() + + +@arg('directories', + nargs='*', + default='.', + help='directories to watch.') +@arg('-p', + '--pattern', + '--patterns', + dest='patterns', + default='*', + help='matches event paths with these patterns (separated by ;).') +@arg('-i', + '--ignore-pattern', + '--ignore-patterns', + dest='ignore_patterns', + default='', + help='ignores event paths with these patterns (separated by ;).') +@arg('-D', + '--ignore-directories', + dest='ignore_directories', + default=False, + help='ignores events for directories') +@arg('-R', + '--recursive', + dest='recursive', + default=False, + help='monitors the directories recursively') +@arg('--interval', + '--timeout', + dest='timeout', + default=1.0, + help='use this as the polling interval/blocking timeout') +@arg('--trace', + default=False, + help='dumps complete dispatching trace') +@arg('--debug-force-polling', + default=False, + help='[debug] forces polling') +@arg('--debug-force-kqueue', + default=False, + help='[debug] forces BSD kqueue(2)') +@arg('--debug-force-winapi', + default=False, + help='[debug] forces Windows API') +@arg('--debug-force-winapi-async', + default=False, + help='[debug] forces Windows API + I/O completion') +@arg('--debug-force-fsevents', + default=False, + help='[debug] forces Mac OS X FSEvents') +@arg('--debug-force-inotify', + default=False, + help='[debug] forces Linux inotify(7)') +@expects_obj +def log(args): + """ + Subcommand to log file system events to the console. + + :param args: + Command line argument options. + """ + from watchdog.utils import echo + from watchdog.tricks import LoggerTrick + + if args.trace: + echo.echo_class(LoggerTrick) + + patterns, ignore_patterns =\ + parse_patterns(args.patterns, args.ignore_patterns) + handler = LoggerTrick(patterns=patterns, + ignore_patterns=ignore_patterns, + ignore_directories=args.ignore_directories) + if args.debug_force_polling: + from watchdog.observers.polling import PollingObserver as Observer + elif args.debug_force_kqueue: + from watchdog.observers.kqueue import KqueueObserver as Observer + elif args.debug_force_winapi_async: + from watchdog.observers.read_directory_changes_async import\ + WindowsApiAsyncObserver as Observer + elif args.debug_force_winapi: + from watchdog.observers.read_directory_changes import\ + WindowsApiObserver as Observer + elif args.debug_force_inotify: + from watchdog.observers.inotify import InotifyObserver as Observer + elif args.debug_force_fsevents: + from watchdog.observers.fsevents import FSEventsObserver as Observer + else: + # Automatically picks the most appropriate observer for the platform + # on which it is running. + from watchdog.observers import Observer + observer = Observer(timeout=args.timeout) + observe_with(observer, handler, args.directories, args.recursive) + + +@arg('directories', + nargs='*', + default='.', + help='directories to watch') +@arg('-c', + '--command', + dest='command', + default=None, + help='''shell command executed in response to matching events. +These interpolation variables are available to your command string:: + + ${watch_src_path} - event source path; + ${watch_dest_path} - event destination path (for moved events); + ${watch_event_type} - event type; + ${watch_object} - ``file`` or ``directory`` + +Note:: + Please ensure you do not use double quotes (") to quote + your command string. That will force your shell to + interpolate before the command is processed by this + subcommand. + +Example option usage:: + + --command='echo "${watch_src_path}"' +''') +@arg('-p', + '--pattern', + '--patterns', + dest='patterns', + default='*', + help='matches event paths with these patterns (separated by ;).') +@arg('-i', + '--ignore-pattern', + '--ignore-patterns', + dest='ignore_patterns', + default='', + help='ignores event paths with these patterns (separated by ;).') +@arg('-D', + '--ignore-directories', + dest='ignore_directories', + default=False, + help='ignores events for directories') +@arg('-R', + '--recursive', + dest='recursive', + default=False, + help='monitors the directories recursively') +@arg('--interval', + '--timeout', + dest='timeout', + default=1.0, + help='use this as the polling interval/blocking timeout') +@arg('-w', '--wait', + dest='wait_for_process', + action='store_true', + default=False, + help="wait for process to finish to avoid multiple simultaneous instances") +@arg('-W', '--drop', + dest='drop_during_process', + action='store_true', + default=False, + help="Ignore events that occur while command is still being executed " \ + "to avoid multiple simultaneous instances") +@arg('--debug-force-polling', + default=False, + help='[debug] forces polling') +@expects_obj +def shell_command(args): + """ + Subcommand to execute shell commands in response to file system events. + + :param args: + Command line argument options. + """ + from watchdog.tricks import ShellCommandTrick + + if not args.command: + args.command = None + + if args.debug_force_polling: + from watchdog.observers.polling import PollingObserver as Observer + else: + from watchdog.observers import Observer + + patterns, ignore_patterns = parse_patterns(args.patterns, + args.ignore_patterns) + handler = ShellCommandTrick(shell_command=args.command, + patterns=patterns, + ignore_patterns=ignore_patterns, + ignore_directories=args.ignore_directories, + wait_for_process=args.wait_for_process, + drop_during_process=args.drop_during_process) + observer = Observer(timeout=args.timeout) + observe_with(observer, handler, args.directories, args.recursive) + + +@arg('command', + help='''Long-running command to run in a subprocess. +''') +@arg('command_args', + metavar='arg', + nargs='*', + help='''Command arguments. + +Note: Use -- before the command arguments, otherwise watchmedo will +try to interpret them. +''') +@arg('-d', + '--directory', + dest='directories', + metavar='directory', + action='append', + help='Directory to watch. Use another -d or --directory option ' + 'for each directory.') +@arg('-p', + '--pattern', + '--patterns', + dest='patterns', + default='*', + help='matches event paths with these patterns (separated by ;).') +@arg('-i', + '--ignore-pattern', + '--ignore-patterns', + dest='ignore_patterns', + default='', + help='ignores event paths with these patterns (separated by ;).') +@arg('-D', + '--ignore-directories', + dest='ignore_directories', + default=False, + help='ignores events for directories') +@arg('-R', + '--recursive', + dest='recursive', + default=False, + help='monitors the directories recursively') +@arg('--interval', + '--timeout', + dest='timeout', + default=1.0, + help='use this as the polling interval/blocking timeout') +@arg('--signal', + dest='signal', + default='SIGINT', + help='stop the subprocess with this signal (default SIGINT)') +@arg('--kill-after', + dest='kill_after', + default=10.0, + help='when stopping, kill the subprocess after the specified timeout ' + '(default 10)') +@expects_obj +def auto_restart(args): + """ + Subcommand to start a long-running subprocess and restart it + on matched events. + + :param args: + Command line argument options. + """ + from watchdog.observers import Observer + from watchdog.tricks import AutoRestartTrick + import signal + import re + + if not args.directories: + args.directories = ['.'] + + # Allow either signal name or number. + if re.match('^SIG[A-Z]+$', args.signal): + stop_signal = getattr(signal, args.signal) + else: + stop_signal = int(args.signal) + + # Handle SIGTERM in the same manner as SIGINT so that + # this program has a chance to stop the child process. + def handle_sigterm(_signum, _frame): + raise KeyboardInterrupt() + + signal.signal(signal.SIGTERM, handle_sigterm) + + patterns, ignore_patterns = parse_patterns(args.patterns, + args.ignore_patterns) + command = [args.command] + command.extend(args.command_args) + handler = AutoRestartTrick(command=command, + patterns=patterns, + ignore_patterns=ignore_patterns, + ignore_directories=args.ignore_directories, + stop_signal=stop_signal, + kill_after=args.kill_after) + handler.start() + observer = Observer(timeout=args.timeout) + observe_with(observer, handler, args.directories, args.recursive) + handler.stop() + + +epilog = """Copyright 2011 Yesudeep Mangalapilly . +Copyright 2012 Google, Inc. + +Licensed under the terms of the Apache license, version 2.0. Please see +LICENSE in the source code for more information.""" + +parser = ArghParser(epilog=epilog) +parser.add_commands([tricks_from, + tricks_generate_yaml, + log, + shell_command, + auto_restart]) +parser.add_argument('--version', + action='version', + version='%(prog)s ' + VERSION_STRING) + + +def main(): + """Entry-point function.""" + parser.dispatch() + + +if __name__ == '__main__': + main() diff --git a/script.service.kodi.callbacks/resources/settings.xml b/script.service.kodi.callbacks/resources/settings.xml new file mode 100644 index 0000000000..11118134ba --- /dev/null +++ b/script.service.kodi.callbacks/resources/settings.xml @@ -0,0 +1,1214 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/script.service.kodi.callbacks/resources/skins/Default/720p/DialogTextBox.xml b/script.service.kodi.callbacks/resources/skins/Default/720p/DialogTextBox.xml new file mode 100644 index 0000000000..15d67c6285 --- /dev/null +++ b/script.service.kodi.callbacks/resources/skins/Default/720p/DialogTextBox.xml @@ -0,0 +1,101 @@ + + + 10 + 1 + + 1 + 275 + 25 + + + + 0 + 0 + 730 + 650 + DialogBack.png + + + Dialog Header image + 40 + 16 + 650 + 40 + dialogheader.png + + + header label + 100 + 20 + 530 + 30 + font13_title + + center + center + selected + black + + + Close Window button + 650 + 15 + 64 + 32 + + - + PreviousMenu + DialogCloseButton-focus.png + DialogCloseButton.png + 110 + 110 + 110 + 110 + system.getbool(input.enablemouse) + + + text box + 50 + 70 + 600 + 400 + true + left + white + 112 + + font13 + + + 695 + 120 + 25 + 380 + ScrollBarV.png + ScrollBarV_bar.png + ScrollBarV_bar_focus.png + ScrollBarNib.png + ScrollBarNib.png + 2 + 2 + false + vertical + + + OK button + 265 + 575 + 200 + 40 + + font12_title + white + white + center + 110 + 110 + 110 + 110 + + + \ No newline at end of file diff --git a/script.service.kodi.callbacks/restartaddon.py b/script.service.kodi.callbacks/restartaddon.py new file mode 100644 index 0000000000..592d83b547 --- /dev/null +++ b/script.service.kodi.callbacks/restartaddon.py @@ -0,0 +1,28 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import xbmc +import sys + +addonid = sys.argv[1] + +xbmc.executeJSONRPC('{"jsonrpc":"2.0","method":"Addons.SetAddonEnabled", "params":{"addonid":"%s","enabled":"toggle"},"id":1}' % addonid) +xbmc.log(msg='***** Toggling addon enabled 1: %s' % addonid) +xbmc.sleep(1000) +xbmc.executeJSONRPC('{"jsonrpc":"2.0","method":"Addons.SetAddonEnabled", "params":{"addonid":"%s","enabled":"toggle"},"id":1}' % addonid) +xbmc.log(msg='***** Toggling addon enabled 2: %s' % addonid) diff --git a/script.service.kodi.callbacks/script.py b/script.service.kodi.callbacks/script.py new file mode 100644 index 0000000000..5fb6b4a608 --- /dev/null +++ b/script.service.kodi.callbacks/script.py @@ -0,0 +1,190 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 KenV99 +# +# 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 . +# +scriptdebug = False # TODO: check +testTasks = False # TODO: check + +import os +import sys + +import resources.lib.pubsub as PubSub_Threaded +import xbmc +import xbmcgui +from default import branch +from resources.lib.kodilogging import KodiLogger +from resources.lib.settings import Settings +from resources.lib.subscriberfactory import SubscriberFactory +from resources.lib.utils.debugger import startdebugger +from resources.lib.utils.kodipathtools import translatepath +from resources.lib.utils.poutil import KodiPo + + +def notify(msg): + dialog = xbmcgui.Dialog() + dialog.notification('Kodi Callabacks', msg, xbmcgui.NOTIFICATION_INFO, 5000) + + +kodipo = KodiPo() +_ = kodipo.getLocalizedString +log = KodiLogger.log + + +def test(key): + global log + log = KodiLogger.log + import resources.lib.tests.direct_test as direct_test + from resources.lib.events import Events + import traceback + log(msg=_('Running Test for Event: %s') % key) + events = Events().AllEvents + settings = Settings() + settings.getSettings() + if settings.general['elevate_loglevel'] is True: + KodiLogger.setLogLevel(xbmc.LOGNOTICE) + else: + KodiLogger.setLogLevel(xbmc.LOGDEBUG) + log(msg=_('Settings for test read')) + evtsettings = settings.events[key] + topic = settings.topicFromSettingsEvent(key) + task_key = settings.events[key]['task'] + tasksettings = settings.tasks[task_key] + testlogger = direct_test.TestLogger() + log(msg=_('Creating subscriber for test')) + subscriberfactory = SubscriberFactory(settings, testlogger) + subscriber = subscriberfactory.createSubscriber(key) + if subscriber is not None: + log(msg=_('Test subscriber created successfully')) + try: + kwargs = events[evtsettings['type']]['expArgs'] + except KeyError: + kwargs = {} + testRH = direct_test.TestHandler(direct_test.testMsg(subscriber.taskmanagers[0], tasksettings, kwargs)) + subscriber.taskmanagers[0].returnHandler = testRH.testReturnHandler + # Run test + log(msg=_('Running test')) + nMessage = PubSub_Threaded.Message(topic=topic, **kwargs) + try: + subscriber.notify(nMessage) + except Exception: + msg = _('Unspecified error during testing') + e = sys.exc_info()[0] + if hasattr(e, 'message'): + msg = str(e.message) + msg = msg + '\n' + traceback.format_exc() + log(msg=msg) + msgList = msg.split('\n') + import resources.lib.dialogtb as dialogtb + dialogtb.show_textbox('Error', msgList) + else: + log(msg=_('Test subscriber creation failed due to errors')) + msgList = testlogger.retrieveLogAsList() + import resources.lib.dialogtb as dialogtb + dialogtb.show_textbox('Error', msgList) + + xbmc.sleep(2000) + + +if __name__ == '__main__': + dryrun = False + addonid = 'script.service.kodi.callbacks' + + if len(sys.argv) > 1: + if scriptdebug is True: + startdebugger() + dryrun = True + + if sys.argv[1] == 'regen': + from resources.lib.kodisettings.generate_xml import generate_settingsxml + + generate_settingsxml() + dialog = xbmcgui.Dialog() + msg = _('Settings Regenerated') + dialog.ok(_('Kodi Callbacks'), msg) + + elif sys.argv[1] == 'test': + KodiLogger.setLogLevel(KodiLogger.LOGNOTICE) + from resources.lib.tests.testTasks import testTasks + + tt = testTasks() + tt.runTests() + dialog = xbmcgui.Dialog() + msg = _('Native Task Testing Complete - see log for results') + dialog.notification(_('Kodi Callbacks'), msg, xbmcgui.NOTIFICATION_INFO, 5000) + + elif sys.argv[1] == 'updatefromzip': + from resources.lib.utils.updateaddon import UpdateAddon + + KodiLogger.setLogLevel(KodiLogger.LOGNOTICE) + dialog = xbmcgui.Dialog() + zipfn = dialog.browse(1, _('Locate zip file'), 'files', '.zip', False, False, translatepath('~')) + if zipfn != translatepath('~'): + if os.path.isfile(zipfn): + ua = UpdateAddon(addonid) + ua.installFromZip(zipfn, updateonly=True, dryrun=dryrun) + else: + dialog.ok(_('Kodi Callbacks'), _('Incorrect path')) + + elif sys.argv[1] == 'restorebackup': + KodiLogger.setLogLevel(KodiLogger.LOGNOTICE) + dialog = xbmcgui.Dialog() + zipfn = dialog.browse(1, _('Locate backup zip file'), 'files', '.zip', False, False, + translatepath('special://addondata/backup/')) + if zipfn != translatepath('special://addondata/backup/'): + from resources.lib.utils.updateaddon import UpdateAddon + + ua = UpdateAddon(addonid) + ua.installFromZip(zipfn, updateonly=False, dryrun=dryrun) + + elif sys.argv[1] == 'lselector': + from resources.lib.utils.selector import selectordialog + + try: + result = selectordialog(sys.argv[2:]) + except (SyntaxError, TypeError) as e: + xbmc.log(msg='Error: %s' % str(e), level=xbmc.LOGERROR) + + elif sys.argv[1] == 'logsettings': + KodiLogger.setLogLevel(KodiLogger.LOGNOTICE) + settings = Settings() + settings.getSettings() + settings.logSettings() + dialog = xbmcgui.Dialog() + msg = _('Settings written to log') + dialog.ok(_('Kodi Callbacks'), msg) + + elif branch != 'master' and sys.argv[1] == 'checkupdate': + try: + from resources.lib.utils.githubtools import processargs + except ImportError: + pass + else: + processargs(sys.argv) + + else: + # Direct Event/Task Testing + KodiLogger.setLogLevel(KodiLogger.LOGNOTICE) + eventId = sys.argv[1] + test(eventId) + + elif testTasks: + KodiLogger.setLogLevel(KodiLogger.LOGNOTICE) + startdebugger() + from resources.lib.tests.testTasks import testTasks + + tt = testTasks() + tt.runTests() diff --git a/script.service.kodi.callbacks/testme.py b/script.service.kodi.callbacks/testme.py new file mode 100644 index 0000000000..5f32bc842d --- /dev/null +++ b/script.service.kodi.callbacks/testme.py @@ -0,0 +1,93 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 KenV99 +# +# 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 . +# + +###################################################################### +# +# Use to test scripts and arguments +# +###################################################################### + +import sys + + + +def stripquotes(st): + if st.startswith('"') and st.endswith('"'): + return st[1:-1].strip() + else: + return st.strip() + +def showNotification(args, kwargs): + # Note that using execfile will not allow for module level imports! + try: + import xbmcgui + except ImportError: + pass + else: + mdialog = xbmcgui.Dialog() + argmsg = ", ".join(args) + kwargmsg = [] + for key in kwargs.keys(): + kwargmsg.append('%s:%s' % (key, kwargs[key])) + mdialog.ok('Test', 'args: %s\nkwargs:: %s' % (argmsg, ', '.join(kwargmsg))) + +def processargs(args, kwargs): + nargs = [] + if isinstance(args, str): + kwargs = {} + args = args.split(' ') + nargs = [] + for arg in args: + if ":" in arg: + tmp = arg.split(":", 1) + try: + key = tmp[0] + val = tmp[1] + val2 = stripquotes(val) + kwargs[key] = val2 + except (KeyError, LookupError): + pass + else: + nargs.append(stripquotes(arg)) + elif isinstance(args, list): + nargs = args + if kwargs is None: + kwargs = {} + return nargs, kwargs + +def run(args=None, kwargs=None): + args, kwargs = processargs(args, kwargs) + showNotification(args, kwargs) + +if __name__ == '__main__' or __name__ == 'Tasks': + xargs = [] + kwargs = {} + if __name__ == '__main__': + sysargv = sys.argv[1:] + else: + sysargv = locals()['args'].strip().split(' ') + for i, xarg in enumerate(sysargv): + if ":" in xarg: + key, entry = xarg.split(':', 1) + kwargs[key] = stripquotes(entry) + else: + xargs.append(stripquotes(xarg)) + run(xargs, kwargs) + + diff --git a/script.service.kodi.callbacks/timestamp.json b/script.service.kodi.callbacks/timestamp.json new file mode 100644 index 0000000000..78aaf4b64e --- /dev/null +++ b/script.service.kodi.callbacks/timestamp.json @@ -0,0 +1 @@ +{"resources/lib/tests/__init__.py": "2016-01-14T15:24:21Z", "resources/lib/watchdog/observers/polling.py": "2016-01-17T07:10:59Z", "resources/__init__.py": "2016-01-14T15:24:21Z", "resources/lib/publishers/__init__.py": "2016-01-14T15:24:21Z", "resources/lib/pathtools/README.rst": "2016-01-25T10:11:22Z", "resources/lib/taskExample.py": "2016-03-07T15:48:16Z", "resources/lib/pathtools/README": "2016-01-25T10:11:22Z", "resources/skins/Default/720p/DialogTextBox.xml": "2016-01-14T15:24:21Z", "resources/lib/watchdog/utils/delayed_queue.py": "2016-01-17T07:10:59Z", "resources/lib/kodisettings/generate_xml.py": "2016-03-31T17:27:35Z", "resources/lib/watchdog/watchmedo.py": "2016-01-17T07:10:59Z", ".gitignore": "2016-04-04T07:13:26Z", "resources/lib/tests/tstScript.sh": "2016-02-27T07:40:29Z", "default.py": "2016-04-16T05:33:46Z", "resources/lib/watchdog/LICENSE.txt": "2016-01-25T10:10:48Z", "resources/lib/watchdog/utils/event_backport.py": "2016-01-17T07:10:59Z", "resources/lib/watchdog/utils/compat.py": "2016-01-17T07:10:59Z", "resources/lib/publishers/watchdogStartup.py": "2016-03-29T14:16:26Z", "resources/lib/helpers/onQuit.sh": "2016-01-22T14:59:31Z", "resources/lib/pathtools/AUTHORS": "2016-01-25T10:11:22Z", "resources/lib/tasks/taskScript.py": "2016-03-06T11:30:01Z", "resources/lib/tasks/__init__.py": "2016-01-14T15:24:21Z", "resources/lib/publishers/schedule.py": "2016-04-16T05:34:34Z", "resources/lib/tests/testTasks.py": "2016-03-29T14:16:28Z", "resources/lib/kodisettings/struct.py": "2016-04-04T07:13:25Z", "icon.png": "2016-03-27T10:13:37Z", "resources/lib/watchdog/observers/__init__.py": "2016-01-19T05:48:22Z", "resources/lib/utils/selector.py": "2016-03-10T17:12:51Z", "resources/language/English/strings.po": "2016-04-16T05:36:00Z", "resources/lib/pubsub.py": "2016-04-04T07:13:25Z", "resources/lib/watchdog/observers/fsevents.py": "2016-01-17T07:10:59Z", "resources/lib/tasks/taskHttp.py": "2016-04-09T09:38:56Z", "resources/lib/events.py": "2016-04-02T09:09:38Z", "resources/lib/helpers/onQuit.bat": "2016-01-22T15:00:23Z", "resources/lib/subscriberfactory.py": "2016-02-26T13:45:16Z", "resources/lib/watchdog/AUTHORS": "2016-01-25T10:10:48Z", "resources/lib/utils/detectPath.py": "2016-02-27T06:41:25Z", "resources/lib/publishers/dummy.py": "2016-03-29T14:16:26Z", "resources/lib/kodilogging.py": "2016-04-04T07:13:26Z", "resources/lib/pathtools/__init__.py": "2016-01-17T07:10:43Z", "resources/lib/watchdog/observers/inotify_c.py": "2016-01-17T07:10:59Z", "resources/lib/watchdog/tricks/__init__.py": "2016-01-17T07:10:59Z", "script.py": "2016-04-14T06:35:41Z", "resources/lib/helpers/sudohelper.sh": "2016-01-28T07:46:47Z", "resources/settings.xml": "2016-04-16T05:35:59Z", "resources/lib/watchdog/utils/dirsnapshot.py": "2016-02-01T11:27:31Z", "resources/lib/watchdog/utils/bricks.py": "2016-01-17T07:10:59Z", "resources/lib/tasks/taskPython.py": "2016-03-06T11:30:01Z", "resources/lib/publishers/loop.py": "2016-03-29T14:16:26Z", "resources/lib/kodisettings/__init__.py": "2016-03-06T11:30:01Z", "timestamp.json": "2016-04-16T05:36:06Z", "testme.py": "2016-01-27T17:12:23Z", "resources/lib/watchdog/version.py": "2016-01-17T07:10:59Z", "resources/lib/utils/updateaddon.py": "2016-04-04T07:13:26Z", "resources/lib/watchdog/utils/unicode_paths.py": "2016-01-17T07:10:59Z", "resources/lib/publisherfactory.py": "2016-03-24T17:00:38Z", "resources/lib/schedule/__init__.py": "2016-02-26T13:45:25Z", "resources/lib/watchdog/__init__.py": "2016-01-17T07:10:59Z", "addon.xml": "2016-04-14T06:35:41Z", "resources/lib/watchdog/utils/platform.py": "2016-01-17T07:10:59Z", "resources/lib/tasks/taskBuiltin.py": "2016-03-06T11:30:01Z", "resources/lib/watchdog/utils/importlib2.py": "2016-01-17T07:10:59Z", "README.md": "2016-02-01T07:40:27Z", "resources/lib/pathtools/path.py": "2016-01-17T07:10:43Z", "resources/lib/tests/direct_test.py": "2016-04-09T09:38:56Z", "resources/lib/pathtools/LICENSE.txt": "2016-01-25T10:08:08Z", "resources/lib/watchdog/utils/echo.py": "2016-01-17T07:10:59Z", "resources/lib/pathtools/patterns.py": "2016-01-17T07:10:43Z", "resources/lib/publishers/monitor.py": "2016-04-04T07:13:26Z", "resources/lib/watchdog/observers/fsevents2.py": "2016-01-17T07:10:59Z", "resources/lib/watchdog/observers/kqueue.py": "2016-01-17T07:10:59Z", "resources/lib/watchdog/observers/read_directory_changes.py": "2016-01-17T07:10:59Z", "resources/lib/tests/tstScript.bat": "2016-02-27T07:24:43Z", "resources/lib/utils/__init__.py": "2016-01-24T07:36:06Z", "resources/lib/watchdog/utils/decorators.py": "2016-01-17T07:10:59Z", "resources/lib/watchdog/observers/inotify.py": "2016-01-17T07:10:59Z", "resources/lib/utils/poutil.py": "2016-04-14T06:35:41Z", "resources/lib/publishers/player.py": "2016-04-16T05:16:59Z", "LICENSE.txt": "2016-01-25T10:16:54Z", "resources/lib/watchdog/observers/winapi.py": "2016-01-17T07:10:59Z", "resources/lib/watchdog/events.py": "2016-01-17T07:10:59Z", "changelog.txt": "2016-03-27T10:13:57Z", "resources/lib/watchdog/utils/__init__.py": "2016-02-14T17:00:24Z", "resources/lib/tests/testPublishers.py": "2016-03-27T08:38:33Z", "resources/lib/taskABC.py": "2016-03-29T14:16:28Z", "resources/lib/watchdog/README.rst": "2016-01-25T10:10:48Z", "resources/lib/pathtools/version.py": "2016-01-17T07:10:43Z", "resources/lib/publishers/log.py": "2016-03-15T07:12:35Z", "resources/lib/utils/copyToDir.py": "2016-04-04T07:13:26Z", "resources/lib/watchdog/observers/inotify_buffer.py": "2016-01-17T07:10:59Z", "fanart.jpg": "2016-02-26T13:45:25Z", "resources/lib/utils/kodipathtools.py": "2016-03-27T10:13:57Z", "resources/lib/utils/debugger.py": "2016-04-02T08:14:50Z", "resources/lib/watchdog/utils/win32stat.py": "2016-01-17T07:10:59Z", "resources/lib/tasks/taskJson.py": "2016-03-11T06:45:04Z", "resources/lib/publishers/watchdog.py": "2016-03-29T14:16:26Z", "resources/lib/dialogtb.py": "2016-04-04T07:13:26Z", "resources/lib/settings.py": "2016-04-04T07:13:26Z", "restartaddon.py": "2016-02-13T15:07:18Z", "resources/lib/watchdog/observers/api.py": "2016-01-17T07:10:59Z", "resources/lib/tests/tstPythonGlobal.py": "2016-04-03T08:08:06Z", "resources/lib/tests/test.txt": "2016-04-16T05:36:04Z", "resources/default.py": "2016-04-12T18:18:32Z", "resources/lib/__init__.py": "2016-04-09T09:38:56Z", ".gitattributes": "2016-04-04T07:13:26Z", "resources/lib/watchdog/COPYING": "2016-01-25T10:10:48Z"} \ No newline at end of file